From 931d18e972c74f5a7b6d3ed11f8bbacc3af807cc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 26 Aug 2026 13:00:41 +0200 Subject: [PATCH 1/4] feat(android): Set app start vitals on standalone children Copy app.vitals.start.screen and app.vitals.start.type onto every span under standalone app.start, including user work under the extended span, so they can be grouped with the root. Co-authored-by: Cursor --- sentry-android-core/build.gradle.kts | 1 + .../core/ActivityLifecycleIntegration.java | 1 + .../PerformanceAndroidEventProcessor.java | 28 +++++ .../PerformanceAndroidEventProcessorTest.kt | 115 ++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de486..a005a098acf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) testImplementation(libs.roboelectric) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.androidx.core.ktx) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index f416df6a988..505bb84ea2c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -73,6 +73,7 @@ public final class ActivityLifecycleIntegration static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_TYPE_DATA = "app.vitals.start.type"; static final String APP_START_REASON_DATA = "app.vitals.start.reason"; static final String APP_START_TRACE_ORIGIN = "auto.app.start"; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index d758470baf7..1340ca6fade 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -2,6 +2,7 @@ import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_TYPE_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; @@ -162,6 +163,10 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { ? "cold" : "warm"; appContext.setStartType(appStartType); + + if (isStandaloneAppStartTxn) { + setAppStartVitals(transaction, appStartType); + } } setContributingFlags(transaction); @@ -186,6 +191,29 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { } } + private static void setAppStartVitals( + final @NotNull SentryTransaction transaction, final @NotNull String appStartType) { + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + if (traceContext == null) { + return; + } + + traceContext.setData(APP_START_TYPE_DATA, appStartType); + final @Nullable Object screen = traceContext.getData().get(APP_START_SCREEN_DATA); + + for (final @NotNull SentrySpan span : transaction.getSpans()) { + @Nullable Map data = span.getData(); + if (data == null) { + data = new ConcurrentHashMap<>(); + span.setData(data); + } + data.put(APP_START_TYPE_DATA, appStartType); + if (screen != null) { + data.put(APP_START_SCREEN_DATA, screen); + } + } + } + private void setContributingFlags(SentryTransaction transaction) { @Nullable SentrySpan ttidSpan = null; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 1dc00f09f95..9b1e63d0499 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -2,6 +2,7 @@ package io.sentry.android.core import android.content.ContentProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Hint import io.sentry.IScopes import io.sentry.ISpan @@ -16,7 +17,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_EXTENDED_OP import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_TYPE_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP @@ -172,6 +175,97 @@ class PerformanceAndroidEventProcessorTest { } } + @Test + fun `foreground standalone app start sets screen and type on root and breakdown children`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "Activity") + tr = sut.process(tr, Hint()) + + assertThat(tr.contexts.trace!!.data[APP_START_SCREEN_DATA]).isEqualTo("Activity") + assertThat(tr.contexts.trace!!.data[APP_START_TYPE_DATA]).isEqualTo("cold") + assertThat(tr.spans).isNotEmpty() + for (span in tr.spans) { + assertThat(span.data?.get(APP_START_SCREEN_DATA)).isEqualTo("Activity") + assertThat(span.data?.get(APP_START_TYPE_DATA)).isEqualTo("cold") + } + } + + @Test + fun `standalone app start sets screen and type on user spans under the extended span`() { + val sut = fixture.getSut() + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "Activity") + val traceId = tr.contexts.trace!!.traceId + val extendedSpanId = SpanId() + val childSpanId = SpanId() + tr.spans.add( + createSpan(traceId, extendedSpanId, tr.contexts.trace!!.spanId, APP_START_EXTENDED_OP) + ) + tr.spans.add(createSpan(traceId, childSpanId, extendedSpanId, "user.work")) + tr.spans.add(createSpan(traceId, SpanId(), childSpanId, "user.work.child")) + + tr = sut.process(tr, Hint()) + + assertThat(tr.contexts.trace!!.data[APP_START_SCREEN_DATA]).isEqualTo("Activity") + assertThat(tr.contexts.trace!!.data[APP_START_TYPE_DATA]).isEqualTo("cold") + assertThat(tr.spans).hasSize(3) + assertThat(tr.spans.map { it.data?.get(APP_START_SCREEN_DATA) }) + .containsExactly("Activity", "Activity", "Activity") + assertThat(tr.spans.map { it.data?.get(APP_START_TYPE_DATA) }) + .containsExactly("cold", "cold", "cold") + } + + @Test + fun `headless standalone app start sets type but not screen on children`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + tr = sut.process(tr, Hint()) + + assertThat(tr.contexts.trace!!.data[APP_START_SCREEN_DATA]).isNull() + assertThat(tr.contexts.trace!!.data[APP_START_TYPE_DATA]).isEqualTo("cold") + assertThat(tr.spans).isNotEmpty() + for (span in tr.spans) { + assertThat(span.data?.get(APP_START_SCREEN_DATA)).isNull() + assertThat(span.data?.get(APP_START_TYPE_DATA)).isEqualTo("cold") + } + } + + @Test + fun `ui load attached app start child does not get standalone vitals attributes`() { + setAppStart(fixture.options, coldStart = true) + + val sut = fixture.getSut(enablePerformanceV2 = true) + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertThat(tr.contexts.trace!!.data[APP_START_SCREEN_DATA]).isNull() + assertThat(tr.contexts.trace!!.data[APP_START_TYPE_DATA]).isNull() + val appStartSpan = tr.spans.single { it.op == APP_START_COLD } + assertThat(appStartSpan.data?.get(APP_START_SCREEN_DATA)).isNull() + assertThat(appStartSpan.data?.get(APP_START_TYPE_DATA)).isNull() + } + @Test fun `foreground standalone app start measurement uses foreground fallback time span`() { val sut = fixture.getSut(enablePerformanceV2 = false) @@ -1089,6 +1183,27 @@ class PerformanceAndroidEventProcessorTest { } } + private fun createSpan( + traceId: SentryId, + spanId: SpanId, + parentSpanId: SpanId, + op: String, + ): SentrySpan = + SentrySpan( + 0.0, + 1.0, + traceId, + spanId, + parentSpanId, + op, + op, + SpanStatus.OK, + null, + emptyMap(), + emptyMap(), + null, + ) + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) From 1418332ee260c5656226f6c4245ba2f8568437c2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 26 Aug 2026 13:15:21 +0200 Subject: [PATCH 2/4] changelog Co-authored-by: Cursor --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a7..c3400471982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Set `app.vitals.start.screen` and `app.vitals.start.type` on standalone `app.start` children ([#6005](https://github.com/getsentry/sentry-java/pull/6005)) + ## 8.49.0 ### Features From 1a87845a6606d1a58b3a6b7c6f7eba1a15f912f7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 26 Aug 2026 13:30:38 +0200 Subject: [PATCH 3/4] test(android): Inline SentrySpan constructors in app start vitals test Co-authored-by: Cursor --- .../PerformanceAndroidEventProcessorTest.kt | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 9b1e63d0499..0f5c731aaf1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -218,10 +218,53 @@ class PerformanceAndroidEventProcessorTest { val extendedSpanId = SpanId() val childSpanId = SpanId() tr.spans.add( - createSpan(traceId, extendedSpanId, tr.contexts.trace!!.spanId, APP_START_EXTENDED_OP) + SentrySpan( + 0.0, + 1.0, + traceId, + extendedSpanId, + tr.contexts.trace!!.spanId, + APP_START_EXTENDED_OP, + APP_START_EXTENDED_OP, + SpanStatus.OK, + null, + emptyMap(), + emptyMap(), + null, + ) + ) + tr.spans.add( + SentrySpan( + 0.0, + 1.0, + traceId, + childSpanId, + extendedSpanId, + "user.work", + "user.work", + SpanStatus.OK, + null, + emptyMap(), + emptyMap(), + null, + ) + ) + tr.spans.add( + SentrySpan( + 0.0, + 1.0, + traceId, + SpanId(), + childSpanId, + "user.work.child", + "user.work.child", + SpanStatus.OK, + null, + emptyMap(), + emptyMap(), + null, + ) ) - tr.spans.add(createSpan(traceId, childSpanId, extendedSpanId, "user.work")) - tr.spans.add(createSpan(traceId, SpanId(), childSpanId, "user.work.child")) tr = sut.process(tr, Hint()) @@ -1183,27 +1226,6 @@ class PerformanceAndroidEventProcessorTest { } } - private fun createSpan( - traceId: SentryId, - spanId: SpanId, - parentSpanId: SpanId, - op: String, - ): SentrySpan = - SentrySpan( - 0.0, - 1.0, - traceId, - spanId, - parentSpanId, - op, - op, - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - ) - private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) From 64015ddd150c9764c6c3cb715206d862d7fcb321 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 26 Aug 2026 13:51:28 +0200 Subject: [PATCH 4/4] build(android): Drop duplicate Truth test dependency Co-authored-by: Cursor --- sentry-android-core/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index de5bc185130..75b77752349 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -113,7 +113,6 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) testImplementation(libs.roboelectric) testImplementation(libs.kotlin.test.junit) - testImplementation(libs.google.truth) testImplementation(libs.androidx.core.ktx) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit)