Add app startup performance telemetry with dual TTID measure (API 35+ native + manual)#7803
Merged
GerardPaligot merged 20 commits intodevelopfrom Mar 4, 2026
Merged
Conversation
Contributor
Author
This stack of pull requests is managed by Graphite. Learn more about stacking. |
5862004 to
73535e0
Compare
f4e9ad0 to
fbfdd6c
Compare
17f15fb to
1c322fe
Compare
aitorvs
reviewed
Feb 26, 2026
Collaborator
aitorvs
left a comment
There was a problem hiding this comment.
Left couple comments that I believe we should fix
ba4859d to
aacf7ad
Compare
lmac012
reviewed
Mar 2, 2026
lmac012
reviewed
Mar 2, 2026
lmac012
reviewed
Mar 2, 2026
lmac012
reviewed
Mar 3, 2026
lmac012
reviewed
Mar 3, 2026
lmac012
reviewed
Mar 3, 2026
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Stale manual TTID sent with wrong launch type
- sendPixel now snapshots and clears manual TTID before any early return so unsent measurements cannot leak into later launches with different startup types.
- ✅ Fixed: RAM bucketing skewed by OS memory reservation
- RAM is now rounded to the nearest GiB before bucketing so devices reporting slightly below physical capacity map to the expected bucket.
Or push these changes by commenting:
@cursor push d197e82351
Preview (d197e82351)
diff --git a/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt b/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
--- a/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
+++ b/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
@@ -234,7 +234,10 @@
}
private fun sendPixel(systemMeasurement: Measurement?) {
- if (manualTtidMs == null && systemMeasurement == null) {
+ val manualTtidForThisLaunch = manualTtidMs
+ manualTtidMs = null
+
+ if (manualTtidForThisLaunch == null && systemMeasurement == null) {
logcat { "TTID: No valid startup time measurement available, skipping pixel" }
return
}
@@ -249,16 +252,15 @@
return
}
- logcat { "TTID: firing pixel — ours=${manualTtidMs}ms, system=${systemMeasurement?.ttidMs}ms" }
+ logcat { "TTID: firing pixel — ours=${manualTtidForThisLaunch}ms, system=${systemMeasurement?.ttidMs}ms" }
val parameters = buildMap {
if (systemMeasurement != null) {
put(StartupMetricsPixelParameters.STARTUP_TYPE, systemMeasurement.startup.name.lowercase())
put(StartupMetricsPixelParameters.TTID_DURATION_MS, systemMeasurement.ttidMs.toString())
}
- if (manualTtidMs != null) {
- put(StartupMetricsPixelParameters.TTID_MANUAL_DURATION_MS, manualTtidMs.toString())
- manualTtidMs = null
+ if (manualTtidForThisLaunch != null) {
+ put(StartupMetricsPixelParameters.TTID_MANUAL_DURATION_MS, manualTtidForThisLaunch.toString())
}
put(StartupMetricsPixelParameters.API_LEVEL, buildConfig.sdkInt.toString())
diff --git a/app/src/main/java/com/duckduckgo/app/startup/metrics/MemoryCollector.kt b/app/src/main/java/com/duckduckgo/app/startup/metrics/MemoryCollector.kt
--- a/app/src/main/java/com/duckduckgo/app/startup/metrics/MemoryCollector.kt
+++ b/app/src/main/java/com/duckduckgo/app/startup/metrics/MemoryCollector.kt
@@ -21,6 +21,7 @@
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
+import kotlin.math.round
/**
* Collects device specification metrics for app startup analysis.
@@ -49,7 +50,7 @@
?: return null
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
- val totalRamGb = memoryInfo.totalMem.toDouble() / BYTES_PER_GB
+ val totalRamGb = round(memoryInfo.totalMem.toDouble() / BYTES_PER_GB)
bucketRamSize(totalRamGb)
} catch (_: Exception) {
null
diff --git a/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt b/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
--- a/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
@@ -148,6 +148,53 @@
}
@Test
+ fun `when first launch is not sampled then stale manual TTID is not sent on later sampled launch`() {
+ val setupActivity = createMockActivity()
+ val firstPausedActivity = createMockActivity()
+ val secondPausedActivity = createMockActivity()
+ val coldStartInfo = createMockApplicationStartInfo(
+ startType = ApplicationStartInfo.START_TYPE_COLD,
+ launchTimestamp = 1000L * 1_000_000L,
+ firstFrameTimestamp = 4000L * 1_000_000L,
+ )
+ val hotStartInfo = createMockApplicationStartInfo(
+ startType = ApplicationStartInfo.START_TYPE_HOT,
+ launchTimestamp = 5000L * 1_000_000L,
+ firstFrameTimestamp = 5200L * 1_000_000L,
+ )
+ whenever(activityManager.getHistoricalProcessStartReasons(any()))
+ .thenReturn(listOf(coldStartInfo), listOf(hotStartInfo))
+ whenever(samplingDecider.shouldSample()).thenReturn(false, true)
+
+ observer.onActivityCreated(setupActivity, null)
+ observer.onActivityStarted(setupActivity)
+ triggerFirstFrame()
+ observer.onActivityStopped(setupActivity)
+ observer.onActivityDestroyed(setupActivity)
+
+ observer.onActivityStarted(firstPausedActivity)
+ observer.onActivityPaused(firstPausedActivity)
+ observer.onActivityStopped(firstPausedActivity)
+
+ observer.onActivityStarted(secondPausedActivity)
+ observer.onActivityPaused(secondPausedActivity)
+
+ argumentCaptor<Map<String, String>>().apply {
+ verify(pixel).fire(
+ pixel = eq(StartupMetricsPixelName.APP_STARTUP_TIME),
+ parameters = capture(),
+ encodedParameters = any(),
+ type = eq(Pixel.PixelType.Count),
+ )
+
+ val params = firstValue
+ assertEquals("hot", params[StartupMetricsPixelParameters.STARTUP_TYPE])
+ assertEquals("200", params[StartupMetricsPixelParameters.TTID_DURATION_MS])
+ assertNull(params[StartupMetricsPixelParameters.TTID_MANUAL_DURATION_MS])
+ }
+ }
+
+ @Test
fun `when startup info collected then fires pixel with correct parameters`() {
val activity = createMockActivity()
whenever(memoryCollector.collectDeviceRamBucket()).thenReturn("4gb")
diff --git a/app/src/test/java/com/duckduckgo/app/startup/metrics/MemoryCollectorTest.kt b/app/src/test/java/com/duckduckgo/app/startup/metrics/MemoryCollectorTest.kt
--- a/app/src/test/java/com/duckduckgo/app/startup/metrics/MemoryCollectorTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/startup/metrics/MemoryCollectorTest.kt
@@ -57,13 +57,13 @@
}
@Test
- fun `when device has 1point5GB RAM then returns 1GB bucket`() {
+ fun `when device has 1point5GB RAM then returns 2GB bucket`() {
val memoryInfo = createMemoryInfo(totalRamMb = 1536)
setupMockMemoryInfo(memoryInfo)
val bucket = collector.collectDeviceRamBucket()
- assertEquals("1GB", bucket)
+ assertEquals("2GB", bucket)
}
@Test
@@ -77,6 +77,16 @@
}
@Test
+ fun `when device reports slightly under 2GB usable RAM then returns 2GB bucket`() {
+ val memoryInfo = createMemoryInfo(totalRamBytes = 2_070_597_632L)
+ setupMockMemoryInfo(memoryInfo)
+
+ val bucket = collector.collectDeviceRamBucket()
+
+ assertEquals("2GB", bucket)
+ }
+
+ @Test
fun `when device has 3GB RAM then returns 2GB bucket`() {
val memoryInfo = createMemoryInfo(totalRamMb = 3072)
setupMockMemoryInfo(memoryInfo)
@@ -97,6 +107,16 @@
}
@Test
+ fun `when device reports slightly under 4GB usable RAM then returns 4GB bucket`() {
+ val memoryInfo = createMemoryInfo(totalRamBytes = 3_972_075_520L)
+ setupMockMemoryInfo(memoryInfo)
+
+ val bucket = collector.collectDeviceRamBucket()
+
+ assertEquals("4GB", bucket)
+ }
+
+ @Test
fun `when device has 6GB RAM then returns 6GB bucket`() {
val memoryInfo = createMemoryInfo(totalRamMb = 6144)
setupMockMemoryInfo(memoryInfo)
@@ -157,8 +177,12 @@
}
private fun createMemoryInfo(totalRamMb: Int): ActivityManager.MemoryInfo {
+ return createMemoryInfo(totalRamBytes = totalRamMb * 1024L * 1024L)
+ }
+
+ private fun createMemoryInfo(totalRamBytes: Long): ActivityManager.MemoryInfo {
return ActivityManager.MemoryInfo().apply {
- totalMem = totalRamMb * 1024L * 1024L // Convert MB to bytes
+ totalMem = totalRamBytes
availMem = totalMem / 2 // Not used in bucketing, just set to something
}
}
lmac012
reviewed
Mar 3, 2026
lmac012
reviewed
Mar 3, 2026
lmac012
approved these changes
Mar 3, 2026
Contributor
lmac012
left a comment
There was a problem hiding this comment.
Looks good and works as expected! I think we should address the feature flag setup though.
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Collection logic runs on main thread regardless of feature flag
- Added an early feature-enabled guard in activity create/pause callbacks so startup listeners and system metric collection are skipped on the main thread when the flag is off.
Or push these changes by commenting:
@cursor push ab8e999617
Preview (ab8e999617)
diff --git a/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt b/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
--- a/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
+++ b/app/src/main/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserver.kt
@@ -106,6 +106,7 @@
activity: Activity,
savedInstanceState: Bundle?,
) {
+ if (!isStartupMetricsEnabled()) return
if (hasCollectedThisLaunch) return
if (frameListenerAttached) {
if (activity === listeningToActivity) return
@@ -134,6 +135,7 @@
@SuppressLint("NewApi")
override fun onActivityPaused(activity: Activity) {
+ if (!isStartupMetricsEnabled()) return
if (!hasCollectedThisLaunch && listeningToActivity == null) {
logcat { "TTID: First activity paused, collecting startup metrics" }
val systemMeasurement = if (buildConfig.sdkInt >= 35) {
@@ -286,6 +288,10 @@
}
}
+ private fun isStartupMetricsEnabled(): Boolean {
+ return startupMetricsFeature.self().isEnabled()
+ }
+
private data class Measurement(
val ttidMs: Long,
val startup: StartupType,
diff --git a/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt b/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
--- a/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/startup/StartupMetricsLifecycleObserverTest.kt
@@ -118,6 +118,8 @@
observer.onActivityStarted(activity2)
observer.onActivityPaused(activity2)
+ assertEquals(0, frameCallbacks.size)
+ verify(activityManager, never()).getHistoricalProcessStartReasons(any())
verifyNoInteractions(pixel)
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1213167063549557?focus=true
Description
Steps to test this PR
API 35+
< API 35
UI changes
n/a
Note
Medium Risk
Adds new telemetry emitted from lifecycle callbacks and uses API 35+
ApplicationStartInfo, so mistakes could affect pixel volume/accuracy or introduce startup overhead, but collection is feature-flagged and sampled.Overview
Adds a new feature-flagged + sampled startup telemetry flow that fires the
m_app_startup_timepixel once per launch, reportingstartup_type, system TTID on API 35+ (viaApplicationStartInfo), and a manual first-frame duration fallback.Introduces supporting infrastructure: a
StartupMetricsFeatureremote toggle,SamplingDecider(JSON-configured sampling rate; defaults to 1%),MemoryCollector(RAM bucket parameter),ProcessTimeProviderfor uptime-based timing, and a DIRandomprovider; updates the pixel schema to includettid_manual_duration_msand adds unit tests covering sampling, lifecycle behavior, and parameter correctness.Written by Cursor Bugbot for commit 7ed69e6. This will update automatically on new commits. Configure here.