From d996425aa74c04f9a7f539e896490dd309fe97f9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 01:27:31 +0200 Subject: [PATCH 01/23] feat(extend-app-start): Eagerly create the extended App Start transaction (standalone-only) Registers an extend-listener on AppStartExtension that eagerly creates the standalone app.start transaction + extended child span in Application.onCreate, held open via waitForChildren until Sentry.finishAppStart() or the deadline. The first activity continues the eager trace into ui.load (attaching the screen so it stays a foreground app.start) instead of creating a second app.start; headless finishes the eager txn. The app start vital is max(natural, extended) so an early finish never shortens it, and is suppressed on deadline. Standalone-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/sentry-android-core.api | 2 + .../core/ActivityLifecycleIntegration.java | 139 ++++++++++++++-- .../android/core/AppStartExtension.java | 23 +++ .../PerformanceAndroidEventProcessor.java | 54 ++++-- .../core/ActivityLifecycleIntegrationTest.kt | 155 ++++++++++++++++++ .../PerformanceAndroidEventProcessorTest.kt | 77 +++++++++ 6 files changed, 427 insertions(+), 23 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 9d064193ff..28fc82c441 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -193,6 +193,8 @@ public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStar public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; public fun getExtendedEndTime ()Lio/sentry/SentryDate; public fun isActive ()Z + public fun isExtended ()Z + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V } 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 d70ff83717..2d5eb6f1db 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 @@ -65,6 +65,8 @@ public final class ActivityLifecycleIntegration static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; + static final String APP_START_EXTENDED_OP = "app.start.extended_app_start"; + static final String APP_START_EXTENDED_DESC = "Extended App Start"; static final long TTFD_TIMEOUT_MILLIS = 25000; // If a headless app start and the following activity's ui.load are more than this far apart, they // are treated as unrelated and not connected into the same trace. @@ -139,7 +141,12 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions application.registerActivityLifecycleCallbacks(this); if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { - AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(this::onHeadlessAppStart); + // Enables Sentry.extendAppStart(): the eager App Start transaction is created here (we have + // scopes). Only registered for standalone tracing, which makes the extend API + // standalone-only. + metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); addIntegrationToSdkVersion("StandaloneAppStart"); } @@ -154,7 +161,9 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); - AppStartMetrics.getInstance().setHeadlessAppStartListener(null); + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + metrics.setHeadlessAppStartListener(null); + metrics.getAppStartExtension().setExtendAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -259,17 +268,26 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); + // An eagerly-created extension transaction (Sentry.extendAppStart) is still open: continue + // its trace into ui.load instead of creating a second app.start, and don't treat its stored + // trace headers as a finished headless start (hardening B). + final boolean extensionActive = + AppStartMetrics.getInstance().getAppStartExtension().isActive(); + final @Nullable SentryId storedAppStartTraceId = AppStartMetrics.getInstance().getAppStartTraceId(); - final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + final boolean isFollowingHeadlessAppStart = + !extensionActive && (storedAppStartTraceId != null); final boolean isAppStart = !(firstActivityCreated || appStartTime == null || coldStart == null); - // Foreground starts create app.start first; ui.load then shares its trace. + // Foreground starts create app.start first; ui.load then shares its trace. When the app + // start is being extended, the eager app.start txn already exists, so we continue it. final boolean createStandaloneAppStart = isAppStart && options.isEnableStandaloneAppStartTracing() - && !isFollowingHeadlessAppStart; + && !isFollowingHeadlessAppStart + && !extensionActive; if (createStandaloneAppStart) { final TransactionOptions appStartTransactionOptions = new TransactionOptions(); @@ -300,8 +318,9 @@ private void startTracing(final @NotNull Activity activity) { continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); - } else if (isFollowingHeadlessAppStart - && isWithinAppStartContinuationWindow(ttidStartTime)) { + } else if (extensionActive + || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { + // Continue the eager extension's app.start trace, or an earlier headless app.start. continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); } else { @@ -309,6 +328,16 @@ && isWithinAppStartContinuationWindow(ttidStartTime)) { continueBaggage = null; } + if (extensionActive) { + // The eager app.start txn was created in onCreate, before any activity existed. Attach + // the + // screen (this first activity) now so it matches the foreground standalone app.start and + // the event processor treats it as a foreground - not headless - start. + AppStartMetrics.getInstance() + .getAppStartExtension() + .setData(APP_START_SCREEN_DATA, activityName); + } + final @Nullable TransactionContext continuedContext = continueSentryTrace == null ? null @@ -967,6 +996,12 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) { if (appStartTransaction != null && !appStartTransaction.isFinished()) { appStartTransaction.finish(SpanStatus.OK, appStartEndTime); } + // Finish the eagerly-created extended app start transaction (owned by the extension, so it is + // not in appStartTransaction). waitForChildren holds it open until the extended span + // finishes, + // which is why the vital can never be shorter than this natural first-frame end. No-op when + // the app start was not extended. + AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime); } } @@ -994,17 +1029,52 @@ private void onHeadlessAppStart() { return; } + // If the headless app start was extended, the eager app.start txn already exists. Finish it at + // the headless end; waitForChildren keeps it open until the extended span finishes (or the + // deadline forces it). Don't create a second transaction. + if (metrics.getAppStartExtension().isActive()) { + metrics.getAppStartExtension().finishTransaction(endTime); + return; + } + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, null, false); + // Persist the end time so a later activity can decide whether its ui.load is close enough in + // time to continue this trace. + metrics.setAppStartEndTime(endTime); + + transaction.finish(SpanStatus.OK, endTime); + } + + /** + * Creates the standalone {@code app.start} transaction (not bound to the scope) and persists its + * trace headers so a later {@code ui.load} can share the same trace. Shared by the headless path + * and the eager extension path. When {@code holdOpenForExtension} is true, the transaction waits + * for its children and gets a deadline so it stays open until the extended span finishes. + */ + private @NotNull ITransaction createStandaloneAppStartTransaction( + final @NotNull SentryDate startTime, + final @Nullable TracesSamplingDecision samplingDecision, + final boolean holdOpenForExtension) { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + final TransactionOptions txnOptions = new TransactionOptions(); txnOptions.setBindToScope(false); txnOptions.setStartTimestamp(startTime); txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + txnOptions.setAppStartTransaction(samplingDecision != null); + if (holdOpenForExtension) { + txnOptions.setWaitForChildren(true); + final long deadlineTimeoutMillis = options.getDeadlineTimeout(); + txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + } final @NotNull TransactionContext txnContext = new TransactionContext( STANDALONE_APP_START_NAME, TransactionNameSource.COMPONENT, STANDALONE_APP_START_OP, - null); + samplingDecision); final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); final @Nullable String appStartReason = metrics.getAppStartReason(); @@ -1016,10 +1086,55 @@ private void onHeadlessAppStart() { metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); - // Persist the end time so a later activity can decide whether its ui.load is close enough in - // time to continue this trace. - metrics.setAppStartEndTime(endTime); + return transaction; + } - transaction.finish(SpanStatus.OK, endTime); + /** + * Handles {@code Sentry.extendAppStart()}: eagerly creates the standalone app.start transaction + * and the extended child span (we have scopes here), then hands both to the {@link + * AppStartExtension}, which owns them. The transaction is held open ({@code waitForChildren}) + * until the user calls {@code Sentry.finishExtendedAppStart()} or the deadline forces it. + * Standalone-only: this is only registered as a listener when standalone app start tracing is + * enabled. + */ + private @Nullable AppStartExtension.ExtendedAppStart onExtendAppStartRequested() { + if (scopes == null + || options == null + || !performanceEnabled + || !options.isEnableStandaloneAppStartTracing()) { + return null; + } + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + + // The earliest known start of this app start (process start when perf-v2 is available, else SDK + // init). It is available before the first activity because SentryPerformanceProvider sets it. + final @NotNull TimeSpan appStartTimeSpan = + metrics.getAppStartTimeSpan().hasStarted() + ? metrics.getAppStartTimeSpan() + : metrics.getSdkInitTimeSpan(); + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + if (startTime == null) { + return null; + } + + // The app start txn inherits the sampling decision from app start profiling, then clears it so + // it doesn't leak to the later ui.load. + final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision(); + metrics.setAppStartSamplingDecision(null); + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, samplingDecision, true); + + final SpanOptions spanOptions = new SpanOptions(); + setSpanOrigin(spanOptions); + final @NotNull ISpan extendedSpan = + transaction.startChild( + APP_START_EXTENDED_OP, + APP_START_EXTENDED_DESC, + AndroidDateUtils.getCurrentSentryDateTime(), + Instrumenter.SENTRY, + spanOptions); + + return new AppStartExtension.ExtendedAppStart(transaction, extendedSpan); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java index c5e98fc852..96926abe4a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -82,6 +82,19 @@ public void extendAppStart() { } } + /** + * Sets data on the owned (eager) transaction if it is still open. Used to attach the screen name + * once the first activity is known, since the transaction is created in {@code onCreate} before + * any activity exists. + */ + public void setData(final @NotNull String key, final @Nullable Object value) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedTransaction != null && !extendedTransaction.isFinished()) { + extendedTransaction.setData(key, value); + } + } + } + @Override public void finishExtendedAppStart() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { @@ -109,6 +122,16 @@ public boolean isActive() { } } + /** + * Whether this app start was extended at all, regardless of finish or deadline state. Used by the + * event processor to decide whether to apply the never-shorten vital logic. + */ + public boolean isExtended() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedSpan != null; + } + } + public void finishTransaction(final @NotNull SentryDate endTimestamp) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ITransaction transaction = extendedTransaction; 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 0b50b5080f..30407d4035 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 @@ -10,6 +10,7 @@ import io.sentry.Hint; import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; +import io.sentry.SentryDate; import io.sentry.SentryEvent; import io.sentry.SpanContext; import io.sentry.SpanDataConvention; @@ -29,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -101,20 +103,50 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { isHeadlessStandaloneAppStartTxn ? appStartMetrics.getAppStartTimeSpanForHeadless() : appStartMetrics.getAppStartTimeSpanWithFallback(options); - final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); + final long naturalDurationMs = appStartTimeSpan.getDurationMs(); + + final long appStartUpDurationMs; + // Whether the app start is ready to be finalized (spans attached, marked sent). When not + // ready (duration 0 on a non-extended start), we leave it for a later transaction to + // retry. + final boolean appStartReady; + final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); + if (extension.isExtended()) { + final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); + if (extendedEnd != null && appStartTimeSpan.hasStarted()) { + // The user finished the extension: measure from process start to the extended end, + // but never report shorter than the natural first-frame duration. + final long extendedDurationMs = + TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp()) + - appStartTimeSpan.getStartTimestampMs(); + appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs); + appStartReady = appStartUpDurationMs != 0; + } else { + // The extension hit the deadline (DEADLINE_EXCEEDED -> null) or there is no valid + // start: suppress the measurement so we never emit an artificially inflated value, + // but still finalize the app start spans. + appStartUpDurationMs = 0; + appStartReady = appStartTimeSpan.hasStarted(); + } + } else { + appStartUpDurationMs = naturalDurationMs; + // if appStartUpDurationMs is 0, metrics are not ready to be sent + appStartReady = appStartUpDurationMs != 0; + } - // if appStartUpDurationMs is 0, metrics are not ready to be sent - if (appStartUpDurationMs != 0) { - final MeasurementValue value = - new MeasurementValue( - (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); + if (appStartReady) { + if (appStartUpDurationMs != 0) { + final MeasurementValue value = + new MeasurementValue( + (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); - final String appStartKey = - appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD - ? MeasurementValue.KEY_APP_START_COLD - : MeasurementValue.KEY_APP_START_WARM; + final String appStartKey = + appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.COLD + ? MeasurementValue.KEY_APP_START_COLD + : MeasurementValue.KEY_APP_START_WARM; - transaction.getMeasurements().put(appStartKey, value); + transaction.getMeasurements().put(appStartKey, value); + } attachAppStartSpans(appStartMetrics, transaction); appStartMetrics.onAppStartSpansSent(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 8b842a0cfa..0b1b4262ea 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -323,6 +323,161 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartTransaction.getData("app.vitals.start.reason")) } + // region extended app start + + @Test + fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + // Eager creation happens here, before any activity is created. + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertTrue( + appStartTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + assertFalse(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan.isNoOp) + } + + @Test + fun `extended app start continues the trace into ui load without a second app start transaction`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransactions = + fixture.createdTransactions.filter { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + // The eager app.start txn is reused; no second one is created at the first activity. + assertEquals(1, appStartTransactions.size) + // The screen (first activity) is attached to the eager app.start, matching foreground + // standalone. + assertEquals("Activity", appStartTransactions.single().getData("app.vitals.start.screen")) + val uiLoadTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP + } + // ui.load shares the eager app.start trace. + assertEquals( + appStartTransactions.single().spanContext.traceId, + uiLoadTransaction.spanContext.traceId, + ) + } + + @Test + fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + + // waitForChildren keeps the app start transaction open until the extension finishes. + appStartTransaction.finish(SpanStatus.OK) + assertFalse(appStartTransaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(appStartTransaction.isFinished) + } + + @Test + fun `extended headless app start transaction stays open until finishExtendedAppStart`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertTrue( + transaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP + } + ) + // Headless finishes the transaction, but waitForChildren holds it until the extension finishes. + assertFalse(transaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + assertTrue(transaction.isFinished) + } + + @Test + fun `extendAppStart is a no-op when standalone tracing is disabled`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + assertFalse(AppStartMetrics.getInstance().appStartExtension.isActive) + assertTrue(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan.isNoOp) + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `extended app start transaction is owned by the extension and survives activity destroy`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + + // The eager txn is owned by the extension, not the integration's appStartTransaction field, so + // the per-activity cleanup can't cancel it (hardening A). + sut.onActivityDestroyed(activity) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + } + + // endregion + @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Headless standalone app start transaction carries app start reason when available`() { 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 173b4e3d99..051fb6c3cb 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 @@ -4,7 +4,10 @@ import android.content.ContentProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ITransaction import io.sentry.MeasurementUnit +import io.sentry.SentryLongDate import io.sentry.SentryTracer import io.sentry.SpanContext import io.sentry.SpanDataConvention @@ -192,6 +195,80 @@ class PerformanceAndroidEventProcessorTest { assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) } + // region extended app start + + private fun extendAppStartFinishedWith(status: SpanStatus, endMs: Long) { + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(status) + whenever(span.finishDate).thenReturn(SentryLongDate(endMs * 1_000_000L)) + val ext = AppStartMetrics.getInstance().appStartExtension + ext.setExtendAppStartListener { AppStartExtension.ExtendedAppStart(mock(), span) } + ext.extendAppStart() + } + + @Test + fun `extended app start uses the extended end for the cold start measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + // extended end is 500ms after start, well past the ~99ms natural duration + extendAppStartFinishedWith(SpanStatus.OK, startMs + 500) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(500f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start never reports shorter than the natural first frame duration`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(1000) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + // finished early (100ms), before the 999ms natural first-frame duration + extendAppStartFinishedWith(SpanStatus.OK, startMs + 100) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertEquals(999f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + + @Test + fun `extended app start that hit the deadline suppresses the measurement`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartType.COLD + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + val startMs = metrics.appStartTimeSpan.startTimestampMs + extendAppStartFinishedWith(SpanStatus.DEADLINE_EXCEEDED, startMs + 30_000) + + var tr = createUiLoadTransactionWithAppStartChildSpan() + tr = sut.process(tr, Hint()) + + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + + // endregion + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) From 51e4969a1abc5d7f3d5ecca1964827326320c149 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:52:13 +0200 Subject: [PATCH 02/23] chore(extend-app-start): Drop the redundant foreground-check comment on extendAppStart Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/sentry/android/core/AppStartExtension.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java index 96926abe4a..f244c70a8b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -58,10 +58,6 @@ public void extendAppStart() { .log(SentryLevel.WARNING, "App start is already being extended."); return; } - // Ignore the foreground check: headless app starts (broadcast/service) run in a - // non-foreground process but can still be extended. The window gate still rejects an - // extension once an activity was created, the first frame was drawn, or measurements were - // already sent. if (!metrics.isAppStartWindowOpen()) { Sentry.getCurrentScopes() .getOptions() From 4c1f0d731e46c28d178ff0921b10ef82460c8d29 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:52:13 +0200 Subject: [PATCH 03/23] test(extend-app-start): Replace the no-op finish test with isExtended coverage Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/core/AppStartExtensionTest.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt index ab7754e17f..fd1e416e1e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -103,13 +103,6 @@ class AppStartExtensionTest { assertSame(span, ext.extendedAppStartSpan) } - @Test - fun `finishExtendedAppStart without a prior extend is a no-op`() { - val ext = extension() - ext.finishExtendedAppStart() - assertNull(ext.extendedEndTime) - } - @Test fun `finishExtendedAppStart finishes the extended span`() { val ext = extension(windowOpen = true) @@ -141,6 +134,18 @@ class AppStartExtensionTest { assertFalse(ext.isActive) } + @Test + fun `isExtended stays true once extended, even after the transaction finishes`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isExtended) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isExtended) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + assertTrue(ext.isExtended) + } + @Test fun `finishTransaction finishes the transaction at the given timestamp`() { val ext = extension(windowOpen = true) From 48bb3a1d07ea0c48966a789bb2ab2f0002f02ec7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:57:42 +0200 Subject: [PATCH 04/23] test(extend-app-start): Drop the no-value getAppStartExtension test and trim comments Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/core/performance/AppStartMetricsTest.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 329718eada..530f84e33e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1026,8 +1026,6 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } - // region app start extension - @Test fun `isAppStartWindowOpen is true on a fresh foreground start`() { assertTrue(AppStartMetrics.getInstance().isAppStartWindowOpen) @@ -1037,7 +1035,6 @@ class AppStartMetricsTest { fun `isAppStartWindowOpen is true for a headless (non-foreground) start`() { val metrics = AppStartMetrics.getInstance() metrics.isAppLaunchedInForeground = false - // The foreground check is ignored, so a headless start can still be extended. assertTrue(metrics.isAppStartWindowOpen) } @@ -1062,12 +1059,6 @@ class AppStartMetricsTest { assertFalse(metrics.isAppStartWindowOpen) } - @Test - fun `getAppStartExtension returns the same instance`() { - val metrics = AppStartMetrics.getInstance() - assertSame(metrics.appStartExtension, metrics.appStartExtension) - } - /** Drives the singleton's eager extension into the active state via the listener path. */ private fun activateExtension(metrics: AppStartMetrics) { metrics.appStartExtension.setExtendAppStartListener { @@ -1094,6 +1085,4 @@ class AppStartMetricsTest { assertFalse(metrics.appStartExtension.isActive) metrics.appStartExtension.setExtendAppStartListener(null) } - - // endregion } From 38f83e1654ae62983c1bcd37e4dfa808b2639fa0 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 15:22:25 +0200 Subject: [PATCH 05/23] chore(extend-app-start): Trim and de-duplicate comments in the eager app start path Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ActivityLifecycleIntegration.java | 25 +++++++------------ .../PerformanceAndroidEventProcessor.java | 1 - .../core/ActivityLifecycleIntegrationTest.kt | 6 +---- .../PerformanceAndroidEventProcessorTest.kt | 4 --- 4 files changed, 10 insertions(+), 26 deletions(-) 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 2d5eb6f1db..206434ce6a 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 @@ -143,9 +143,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); metrics.setHeadlessAppStartListener(this::onHeadlessAppStart); - // Enables Sentry.extendAppStart(): the eager App Start transaction is created here (we have - // scopes). Only registered for standalone tracing, which makes the extend API - // standalone-only. + // Enables Sentry.extendAppStart(). Standalone-only, since it is only registered here. metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); addIntegrationToSdkVersion("StandaloneAppStart"); } @@ -270,7 +268,7 @@ private void startTracing(final @NotNull Activity activity) { // An eagerly-created extension transaction (Sentry.extendAppStart) is still open: continue // its trace into ui.load instead of creating a second app.start, and don't treat its stored - // trace headers as a finished headless start (hardening B). + // trace headers as a finished headless start. final boolean extensionActive = AppStartMetrics.getInstance().getAppStartExtension().isActive(); @@ -329,10 +327,9 @@ private void startTracing(final @NotNull Activity activity) { } if (extensionActive) { - // The eager app.start txn was created in onCreate, before any activity existed. Attach - // the - // screen (this first activity) now so it matches the foreground standalone app.start and - // the event processor treats it as a foreground - not headless - start. + // Attach the screen (this first activity) so the eager app.start matches the foreground + // standalone app.start and the event processor treats it as a foreground (not headless) + // start. AppStartMetrics.getInstance() .getAppStartExtension() .setData(APP_START_SCREEN_DATA, activityName); @@ -996,11 +993,8 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) { if (appStartTransaction != null && !appStartTransaction.isFinished()) { appStartTransaction.finish(SpanStatus.OK, appStartEndTime); } - // Finish the eagerly-created extended app start transaction (owned by the extension, so it is - // not in appStartTransaction). waitForChildren holds it open until the extended span - // finishes, - // which is why the vital can never be shorter than this natural first-frame end. No-op when - // the app start was not extended. + // Finish the eager extended transaction at the natural first-frame end. waitForChildren keeps + // it open until the extended span finishes; no-op if the app start was not extended. AppStartMetrics.getInstance().getAppStartExtension().finishTransaction(appStartEndTime); } } @@ -1029,9 +1023,8 @@ private void onHeadlessAppStart() { return; } - // If the headless app start was extended, the eager app.start txn already exists. Finish it at - // the headless end; waitForChildren keeps it open until the extended span finishes (or the - // deadline forces it). Don't create a second transaction. + // Extended headless start: finish the existing eager txn at the headless end instead of + // creating a second one. if (metrics.getAppStartExtension().isActive()) { metrics.getAppStartExtension().finishTransaction(endTime); return; 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 30407d4035..cb14a28edd 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 @@ -130,7 +130,6 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { } } else { appStartUpDurationMs = naturalDurationMs; - // if appStartUpDurationMs is 0, metrics are not ready to be sent appStartReady = appStartUpDurationMs != 0; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 0b1b4262ea..ffc3320cd9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -323,8 +323,6 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartTransaction.getData("app.vitals.start.reason")) } - // region extended app start - @Test fun `extendAppStart eagerly creates a standalone app start transaction with the extended span`() { val sut = @@ -471,13 +469,11 @@ class ActivityLifecycleIntegrationTest { assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) // The eager txn is owned by the extension, not the integration's appStartTransaction field, so - // the per-activity cleanup can't cancel it (hardening A). + // the per-activity cleanup can't cancel it. sut.onActivityDestroyed(activity) assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) } - // endregion - @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Headless standalone app start transaction carries app start reason when available`() { 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 051fb6c3cb..1a042023b0 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 @@ -195,8 +195,6 @@ class PerformanceAndroidEventProcessorTest { assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) } - // region extended app start - private fun extendAppStartFinishedWith(status: SpanStatus, endMs: Long) { val span = mock() whenever(span.isFinished).thenReturn(true) @@ -267,8 +265,6 @@ class PerformanceAndroidEventProcessorTest { assertFalse(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) } - // endregion - @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) From 55decba5f2871026bf4329b54ec49fa214e46625 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 15:28:40 +0200 Subject: [PATCH 06/23] chore(extend-app-start): Clarify the extensionActive comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/ActivityLifecycleIntegration.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 206434ce6a..595edaeede 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 @@ -266,9 +266,10 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // An eagerly-created extension transaction (Sentry.extendAppStart) is still open: continue - // its trace into ui.load instead of creating a second app.start, and don't treat its stored - // trace headers as a finished headless start. + // An extend-app-start transaction (Sentry.extendAppStart) is already open. Reuse its trace + // for this ui.load instead of creating a second app.start. It also stores an app-start + // trace id, so the headless-start check below is guarded with !extensionActive to avoid + // mistaking it for a finished headless start. final boolean extensionActive = AppStartMetrics.getInstance().getAppStartExtension().isActive(); From 571416e7b9cecc2a2fe44e0205522a47a47e049c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 18:39:38 +0200 Subject: [PATCH 07/23] ref(extend-app-start): Rename extended app start span op to app.start.extended Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/ActivityLifecycleIntegration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 595edaeede..ae7705ed43 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 @@ -65,7 +65,7 @@ public final class ActivityLifecycleIntegration static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; - static final String APP_START_EXTENDED_OP = "app.start.extended_app_start"; + static final String APP_START_EXTENDED_OP = "app.start.extended"; static final String APP_START_EXTENDED_DESC = "Extended App Start"; static final long TTFD_TIMEOUT_MILLIS = 25000; // If a headless app start and the following activity's ui.load are more than this far apart, they From 1eb12d5e24262481731aaf50d871d012aedff60f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:42:38 +0200 Subject: [PATCH 08/23] fix(extend-app-start): Fix headless app start end time and duplicate txn Two issues in onHeadlessAppStart for extended starts: - The extended branch finished the eager transaction but never persisted appStartEndTime, leaving the continuation window unbounded so a later activity would wrongly continue the stale trace. Persist it on all paths. - The branch only checked isActive(), so if the extension already finished (finishExtendedAppStart or the deadline) before the headless idle ran, a second, empty standalone app.start was created. Guard on shouldSendStartMeasurements to avoid the duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ActivityLifecycleIntegration.java | 23 ++++++---- .../core/ActivityLifecycleIntegrationTest.kt | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) 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 ae7705ed43..a9ee2cce90 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 @@ -1024,19 +1024,26 @@ private void onHeadlessAppStart() { return; } - // Extended headless start: finish the existing eager txn at the headless end instead of - // creating a second one. - if (metrics.getAppStartExtension().isActive()) { - metrics.getAppStartExtension().finishTransaction(endTime); + // Persist the end time so a later activity can decide whether its ui.load is close enough in + // time to continue this trace; without it the continuation window is treated as unbounded. + metrics.setAppStartEndTime(endTime); + + final @NotNull AppStartExtension extension = metrics.getAppStartExtension(); + // Extended headless start still open: finish the existing eager txn at the headless end instead + // of creating a second one. + if (extension.isActive()) { + extension.finishTransaction(endTime); + return; + } + // The extension already created and finished the standalone app.start for this launch + // (finishExtendedAppStart() or the deadline ran before this headless check). Don't duplicate + // it. + if (!metrics.shouldSendStartMeasurements(true)) { return; } final @NotNull ITransaction transaction = createStandaloneAppStartTransaction(startTime, null, false); - // Persist the end time so a later activity can decide whether its ui.load is close enough in - // time to continue this trace. - metrics.setAppStartEndTime(endTime); - transaction.finish(SpanStatus.OK, endTime); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index ffc3320cd9..d3b8d5214b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -439,6 +439,48 @@ class ActivityLifecycleIntegrationTest { assertTrue(transaction.isFinished) } + @Test + fun `extended headless app start persists the app start end time`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + driveHeadlessAppStart() + + // Without persisting the end time, the continuation window is treated as unbounded and a later + // activity would wrongly continue this trace. + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `extended headless app start does not create a duplicate when the extension already finished`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + // The user finishes the extension and its app.start is sent (onAppStartSpansSent, normally + // driven by the event processor) before the headless idle check runs. + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + val transactionsBefore = fixture.createdTransactions.size + + driveHeadlessAppStart() + + // The eager extension txn already covered this launch; no second standalone app.start. + assertEquals(transactionsBefore, fixture.createdTransactions.size) + } + @Test fun `extendAppStart is a no-op when standalone tracing is disabled`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } From b5bf4ec1f770a4e7094ff686ef518846e82fc8bf Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:44:26 +0200 Subject: [PATCH 09/23] style(extend-app-start): Trim low-value comments in the headless fix Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/core/ActivityLifecycleIntegration.java | 12 +++++------- .../android/core/ActivityLifecycleIntegrationTest.kt | 7 ++----- 2 files changed, 7 insertions(+), 12 deletions(-) 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 a9ee2cce90..623401159b 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 @@ -1024,21 +1024,19 @@ private void onHeadlessAppStart() { return; } - // Persist the end time so a later activity can decide whether its ui.load is close enough in - // time to continue this trace; without it the continuation window is treated as unbounded. + // Persist the end time so a later ui.load can tell whether it is close enough to continue this + // trace; without it the continuation window is unbounded. metrics.setAppStartEndTime(endTime); final @NotNull AppStartExtension extension = metrics.getAppStartExtension(); - // Extended headless start still open: finish the existing eager txn at the headless end instead - // of creating a second one. if (extension.isActive()) { + // Extended start still open: finish the eager txn instead of creating a second one. extension.finishTransaction(endTime); return; } - // The extension already created and finished the standalone app.start for this launch - // (finishExtendedAppStart() or the deadline ran before this headless check). Don't duplicate - // it. if (!metrics.shouldSendStartMeasurements(true)) { + // The extension already created and finished this app.start (finishExtendedAppStart or the + // deadline); don't create a duplicate. return; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index d3b8d5214b..8f5ec5a190 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -453,8 +453,6 @@ class ActivityLifecycleIntegrationTest { driveHeadlessAppStart() - // Without persisting the end time, the continuation window is treated as unbounded and a later - // activity would wrongly continue this trace. assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) } @@ -469,15 +467,14 @@ class ActivityLifecycleIntegrationTest { prepareHeadlessAppStart(appStartType = AppStartType.COLD) AppStartMetrics.getInstance().appStartExtension.extendAppStart() - // The user finishes the extension and its app.start is sent (onAppStartSpansSent, normally - // driven by the event processor) before the headless idle check runs. + // Finish and send the extension's app.start (onAppStartSpansSent is normally driven by the + // event processor) before the headless idle check runs. AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() AppStartMetrics.getInstance().onAppStartSpansSent() val transactionsBefore = fixture.createdTransactions.size driveHeadlessAppStart() - // The eager extension txn already covered this launch; no second standalone app.start. assertEquals(transactionsBefore, fixture.createdTransactions.size) } From bb88dadf3557f577f08790878907d09ef873761e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:46:38 +0200 Subject: [PATCH 10/23] style(extend-app-start): Drop redundant extend-listener comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/ActivityLifecycleIntegration.java | 1 - 1 file changed, 1 deletion(-) 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 623401159b..b15d065f08 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 @@ -143,7 +143,6 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); metrics.setHeadlessAppStartListener(this::onHeadlessAppStart); - // Enables Sentry.extendAppStart(). Standalone-only, since it is only registered here. metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); addIntegrationToSdkVersion("StandaloneAppStart"); } From 85e23d8f9862b68c23e2df94a0b98bc9d5d8af22 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:48:10 +0200 Subject: [PATCH 11/23] style(extend-app-start): Trim redundant comments in onActivityCreated Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/ActivityLifecycleIntegration.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 b15d065f08..1e997ebe5c 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 @@ -318,7 +318,6 @@ private void startTracing(final @NotNull Activity activity) { continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); } else if (extensionActive || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { - // Continue the eager extension's app.start trace, or an earlier headless app.start. continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); } else { @@ -327,9 +326,7 @@ private void startTracing(final @NotNull Activity activity) { } if (extensionActive) { - // Attach the screen (this first activity) so the eager app.start matches the foreground - // standalone app.start and the event processor treats it as a foreground (not headless) - // start. + // Without a screen the processor would classify the eager app.start as a headless start. AppStartMetrics.getInstance() .getAppStartExtension() .setData(APP_START_SCREEN_DATA, activityName); From f2d1c34d0ca4c0b7b47b588b1a94baef12410661 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:50:28 +0200 Subject: [PATCH 12/23] style(extend-app-start): Remove explanatory comments in app start paths Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/ActivityLifecycleIntegration.java | 7 ------- 1 file changed, 7 deletions(-) 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 1e997ebe5c..630dac6b76 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 @@ -1026,13 +1026,10 @@ private void onHeadlessAppStart() { final @NotNull AppStartExtension extension = metrics.getAppStartExtension(); if (extension.isActive()) { - // Extended start still open: finish the eager txn instead of creating a second one. extension.finishTransaction(endTime); return; } if (!metrics.shouldSendStartMeasurements(true)) { - // The extension already created and finished this app.start (finishExtendedAppStart or the - // deadline); don't create a duplicate. return; } @@ -1101,8 +1098,6 @@ private void onHeadlessAppStart() { } final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); - // The earliest known start of this app start (process start when perf-v2 is available, else SDK - // init). It is available before the first activity because SentryPerformanceProvider sets it. final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpan().hasStarted() ? metrics.getAppStartTimeSpan() @@ -1112,8 +1107,6 @@ private void onHeadlessAppStart() { return null; } - // The app start txn inherits the sampling decision from app start profiling, then clears it so - // it doesn't leak to the later ui.load. final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision(); metrics.setAppStartSamplingDecision(null); From d2df48bc2fe856cf09e6d7d9e66e232545ac4932 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:58:50 +0200 Subject: [PATCH 13/23] fix(extend-app-start): Consume stored app start trace on the extension path The first activity continuing an extended app.start did not clear the stored trace headers (only the headless-follow path did), so a later activity saw them and wrongly reused the finished app start trace. Clear them on the extension path too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ActivityLifecycleIntegration.java | 4 +-- .../core/ActivityLifecycleIntegrationTest.kt | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) 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 630dac6b76..822b8a2cc3 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 @@ -351,8 +351,8 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions); } - if (isFollowingHeadlessAppStart) { - // Consume the stored headless app-start trace so it isn't reused by another activity. + if (isFollowingHeadlessAppStart || extensionActive) { + // Consume the stored app-start trace so a later activity doesn't reuse it. AppStartMetrics.getInstance().setAppStartTraceId(null); AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); AppStartMetrics.getInstance().setAppStartBaggageHeader(null); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 8f5ec5a190..9160a61998 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -384,6 +384,37 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `extended app start trace is not reused by a later activity`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + val appStartTraceId = + fixture.createdTransactions + .single { it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + .spanContext + .traceId + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + + val secondActivity = mock() + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(secondActivity, fixture.bundle) + + // The second activity must not continue the already-finished extended app.start trace. + assertNotEquals(appStartTraceId, fixture.createdTransactions.last().spanContext.traceId) + } + @Test fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { val sut = From 142a9d212248ac5c8a0c0bec8041d60f50810d68 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 14:12:42 +0200 Subject: [PATCH 14/23] fix(extend-app-start): Only the launch activity attaches the app start screen While the extension was open, every activity's onActivityCreated re-attached app.vitals.start.screen to the eager app.start, so a second activity opened before finishExtendedAppStart() overwrote the launch screen. Gate the attach on isAppStart so only the launch activity sets it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ActivityLifecycleIntegration.java | 5 ++-- .../core/ActivityLifecycleIntegrationTest.kt | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) 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 822b8a2cc3..8f6c56d67d 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 @@ -325,8 +325,9 @@ private void startTracing(final @NotNull Activity activity) { continueBaggage = null; } - if (extensionActive) { - // Without a screen the processor would classify the eager app.start as a headless start. + if (extensionActive && isAppStart) { + // Attach only the launch activity's screen so a later activity can't overwrite it. Without + // a screen the processor would classify the eager app.start as a headless start. AppStartMetrics.getInstance() .getAppStartExtension() .setData(APP_START_SCREEN_DATA, activityName); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 9160a61998..ba59a247f3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -415,6 +415,32 @@ class ActivityLifecycleIntegrationTest { assertNotEquals(appStartTraceId, fixture.createdTransactions.last().spanContext.traceId) } + @Test + fun `extended app start screen is not overwritten by a later activity`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + val firstActivity = mock() + sut.onActivityCreated(firstActivity, fixture.bundle) + + // A second activity opens while the extension is still open. + sut.onActivityPaused(firstActivity) + sut.onActivityCreated(mock(), fixture.bundle) + + val appStart = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStart.getData("app.vitals.start.screen")) + } + @Test fun `extended standalone app start transaction stays open until finishExtendedAppStart`() { val sut = @@ -2605,3 +2631,5 @@ class ActivityLifecycleIntegrationTest { } } } + +private open class SecondAppStartActivity : Activity() From dd7b3e0f612ec29b289c090c1692e3c63c9fce3d Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 26 Jun 2026 12:16:13 +0000 Subject: [PATCH 15/23] Format code --- .../io/sentry/android/core/ActivityLifecycleIntegration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8f6c56d67d..5c2dd25a1d 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 @@ -326,7 +326,8 @@ private void startTracing(final @NotNull Activity activity) { } if (extensionActive && isAppStart) { - // Attach only the launch activity's screen so a later activity can't overwrite it. Without + // Attach only the launch activity's screen so a later activity can't overwrite it. + // Without // a screen the processor would classify the eager app.start as a headless start. AppStartMetrics.getInstance() .getAppStartExtension() From 0de722b2c20ad8caf40b4bc575d0ea7d0008e41d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 14:21:24 +0200 Subject: [PATCH 16/23] style(extend-app-start): Trim verbose comments in app start paths Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/core/ActivityLifecycleIntegration.java | 12 ++++-------- .../core/PerformanceAndroidEventProcessor.java | 13 +++++-------- 2 files changed, 9 insertions(+), 16 deletions(-) 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 8f6c56d67d..8ba8e23071 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 @@ -265,10 +265,8 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // An extend-app-start transaction (Sentry.extendAppStart) is already open. Reuse its trace - // for this ui.load instead of creating a second app.start. It also stores an app-start - // trace id, so the headless-start check below is guarded with !extensionActive to avoid - // mistaking it for a finished headless start. + // Guards the headless-start check below with !extensionActive so the eager extension's + // stored trace id isn't mistaken for a finished headless start. final boolean extensionActive = AppStartMetrics.getInstance().getAppStartExtension().isActive(); @@ -279,8 +277,6 @@ private void startTracing(final @NotNull Activity activity) { final boolean isAppStart = !(firstActivityCreated || appStartTime == null || coldStart == null); - // Foreground starts create app.start first; ui.load then shares its trace. When the app - // start is being extended, the eager app.start txn already exists, so we continue it. final boolean createStandaloneAppStart = isAppStart && options.isEnableStandaloneAppStartTracing() @@ -326,8 +322,8 @@ private void startTracing(final @NotNull Activity activity) { } if (extensionActive && isAppStart) { - // Attach only the launch activity's screen so a later activity can't overwrite it. Without - // a screen the processor would classify the eager app.start as a headless start. + // Only the launch activity sets the screen, so a later activity can't overwrite it. A + // screen also keeps the processor from classifying the eager app.start as headless. AppStartMetrics.getInstance() .getAppStartExtension() .setData(APP_START_SCREEN_DATA, activityName); 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 cb14a28edd..2bb57afca7 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 @@ -106,25 +106,22 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { final long naturalDurationMs = appStartTimeSpan.getDurationMs(); final long appStartUpDurationMs; - // Whether the app start is ready to be finalized (spans attached, marked sent). When not - // ready (duration 0 on a non-extended start), we leave it for a later transaction to - // retry. + // Not ready (duration 0 on a non-extended start) leaves it for a later transaction. final boolean appStartReady; final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); if (extension.isExtended()) { final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); if (extendedEnd != null && appStartTimeSpan.hasStarted()) { - // The user finished the extension: measure from process start to the extended end, - // but never report shorter than the natural first-frame duration. + // Measure to the extended end, but never shorter than the natural first-frame + // duration. final long extendedDurationMs = TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp()) - appStartTimeSpan.getStartTimestampMs(); appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs); appStartReady = appStartUpDurationMs != 0; } else { - // The extension hit the deadline (DEADLINE_EXCEEDED -> null) or there is no valid - // start: suppress the measurement so we never emit an artificially inflated value, - // but still finalize the app start spans. + // Deadline (null) or no valid start: suppress the measurement to avoid an inflated + // value, but still finalize the spans. appStartUpDurationMs = 0; appStartReady = appStartTimeSpan.hasStarted(); } From b442c1e4c510b8dd471abe8f3d4f0335f47cf9d6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 09:50:50 +0200 Subject: [PATCH 17/23] style(extend-app-start): Drop two line-narrating test comments Remove comments that restated the adjacent test code (the eager extendAppStart call and the second-activity open). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/ActivityLifecycleIntegrationTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index ba59a247f3..71720e4d2f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -333,7 +333,6 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) setAppStartTime() - // Eager creation happens here, before any activity is created. AppStartMetrics.getInstance().appStartExtension.extendAppStart() val appStartTransaction = @@ -430,7 +429,6 @@ class ActivityLifecycleIntegrationTest { val firstActivity = mock() sut.onActivityCreated(firstActivity, fixture.bundle) - // A second activity opens while the extension is still open. sut.onActivityPaused(firstActivity) sut.onActivityCreated(mock(), fixture.bundle) From 824c12f81a682bedc5bebccefeeb5c5c1f48fa84 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 11:32:06 +0200 Subject: [PATCH 18/23] test(extend-app-start): Drop narrating comments from app start tests The test names describe the scenarios; the inline comments restated them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/core/ActivityLifecycleIntegrationTest.kt | 11 ----------- .../core/PerformanceAndroidEventProcessorTest.kt | 2 -- 2 files changed, 13 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 71720e4d2f..c83e821945 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -367,16 +367,12 @@ class ActivityLifecycleIntegrationTest { fixture.createdTransactions.filter { it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } - // The eager app.start txn is reused; no second one is created at the first activity. assertEquals(1, appStartTransactions.size) - // The screen (first activity) is attached to the eager app.start, matching foreground - // standalone. assertEquals("Activity", appStartTransactions.single().getData("app.vitals.start.screen")) val uiLoadTransaction = fixture.createdTransactions.single { it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP } - // ui.load shares the eager app.start trace. assertEquals( appStartTransactions.single().spanContext.traceId, uiLoadTransaction.spanContext.traceId, @@ -410,7 +406,6 @@ class ActivityLifecycleIntegrationTest { sut.onActivityPaused(firstActivity) sut.onActivityCreated(secondActivity, fixture.bundle) - // The second activity must not continue the already-finished extended app.start trace. assertNotEquals(appStartTraceId, fixture.createdTransactions.last().spanContext.traceId) } @@ -459,7 +454,6 @@ class ActivityLifecycleIntegrationTest { it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } - // waitForChildren keeps the app start transaction open until the extension finishes. appStartTransaction.finish(SpanStatus.OK) assertFalse(appStartTransaction.isFinished) @@ -487,7 +481,6 @@ class ActivityLifecycleIntegrationTest { it.operation == ActivityLifecycleIntegration.APP_START_EXTENDED_OP } ) - // Headless finishes the transaction, but waitForChildren holds it until the extension finishes. assertFalse(transaction.isFinished) AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() @@ -522,8 +515,6 @@ class ActivityLifecycleIntegrationTest { prepareHeadlessAppStart(appStartType = AppStartType.COLD) AppStartMetrics.getInstance().appStartExtension.extendAppStart() - // Finish and send the extension's app.start (onAppStartSpansSent is normally driven by the - // event processor) before the headless idle check runs. AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() AppStartMetrics.getInstance().onAppStartSpansSent() val transactionsBefore = fixture.createdTransactions.size @@ -562,8 +553,6 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, fixture.bundle) assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) - // The eager txn is owned by the extension, not the integration's appStartTransaction field, so - // the per-activity cleanup can't cancel it. sut.onActivityDestroyed(activity) assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) } 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 1a042023b0..1dc00f09f9 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 @@ -216,7 +216,6 @@ class PerformanceAndroidEventProcessorTest { setStoppedAt(100) } val startMs = metrics.appStartTimeSpan.startTimestampMs - // extended end is 500ms after start, well past the ~99ms natural duration extendAppStartFinishedWith(SpanStatus.OK, startMs + 500) var tr = createUiLoadTransactionWithAppStartChildSpan() @@ -236,7 +235,6 @@ class PerformanceAndroidEventProcessorTest { setStoppedAt(1000) } val startMs = metrics.appStartTimeSpan.startTimestampMs - // finished early (100ms), before the 999ms natural first-frame duration extendAppStartFinishedWith(SpanStatus.OK, startMs + 100) var tr = createUiLoadTransactionWithAppStartChildSpan() From c861ba9dc989d1d2f1c5c5e3bf391ef225e5fb9f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 13:42:31 +0200 Subject: [PATCH 19/23] refactor(extend-app-start): Name app start branching booleans by intent Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ActivityLifecycleIntegration.java | 14 ++++++------- .../PerformanceAndroidEventProcessor.java | 21 +++++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) 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 8ba8e23071..738945a28e 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 @@ -265,15 +265,15 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // Guards the headless-start check below with !extensionActive so the eager extension's + // Guards the headless-start check below with !isExtensionActive so the eager extension's // stored trace id isn't mistaken for a finished headless start. - final boolean extensionActive = + final boolean isExtensionActive = AppStartMetrics.getInstance().getAppStartExtension().isActive(); final @Nullable SentryId storedAppStartTraceId = AppStartMetrics.getInstance().getAppStartTraceId(); final boolean isFollowingHeadlessAppStart = - !extensionActive && (storedAppStartTraceId != null); + !isExtensionActive && (storedAppStartTraceId != null); final boolean isAppStart = !(firstActivityCreated || appStartTime == null || coldStart == null); @@ -281,7 +281,7 @@ private void startTracing(final @NotNull Activity activity) { isAppStart && options.isEnableStandaloneAppStartTracing() && !isFollowingHeadlessAppStart - && !extensionActive; + && !isExtensionActive; if (createStandaloneAppStart) { final TransactionOptions appStartTransactionOptions = new TransactionOptions(); @@ -312,7 +312,7 @@ private void startTracing(final @NotNull Activity activity) { continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); - } else if (extensionActive + } else if (isExtensionActive || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); @@ -321,7 +321,7 @@ private void startTracing(final @NotNull Activity activity) { continueBaggage = null; } - if (extensionActive && isAppStart) { + if (isExtensionActive && isAppStart) { // Only the launch activity sets the screen, so a later activity can't overwrite it. A // screen also keeps the processor from classifying the eager app.start as headless. AppStartMetrics.getInstance() @@ -348,7 +348,7 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions); } - if (isFollowingHeadlessAppStart || extensionActive) { + if (isFollowingHeadlessAppStart || isExtensionActive) { // Consume the stored app-start trace so a later activity doesn't reuse it. AppStartMetrics.getInstance().setAppStartTraceId(null); AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); 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 2bb57afca7..d758470baf 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 @@ -106,8 +106,8 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { final long naturalDurationMs = appStartTimeSpan.getDurationMs(); final long appStartUpDurationMs; - // Not ready (duration 0 on a non-extended start) leaves it for a later transaction. - final boolean appStartReady; + final boolean shouldAttachAppStartSpans; + final boolean reportAppStartMeasurement; final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); if (extension.isExtended()) { final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); @@ -118,20 +118,23 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { TimeUnit.NANOSECONDS.toMillis(extendedEnd.nanoTimestamp()) - appStartTimeSpan.getStartTimestampMs(); appStartUpDurationMs = Math.max(naturalDurationMs, extendedDurationMs); - appStartReady = appStartUpDurationMs != 0; + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; } else { - // Deadline (null) or no valid start: suppress the measurement to avoid an inflated - // value, but still finalize the spans. + // Deadline (null) or no valid start: attach the spans but suppress the measurement so + // it isn't inflated. appStartUpDurationMs = 0; - appStartReady = appStartTimeSpan.hasStarted(); + shouldAttachAppStartSpans = appStartTimeSpan.hasStarted(); + reportAppStartMeasurement = false; } } else { appStartUpDurationMs = naturalDurationMs; - appStartReady = appStartUpDurationMs != 0; + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; } - if (appStartReady) { - if (appStartUpDurationMs != 0) { + if (shouldAttachAppStartSpans) { + if (reportAppStartMeasurement) { final MeasurementValue value = new MeasurementValue( (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); From 3d4d55ae6f2775f11ab2375a030dd8ca73c6b822 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Jul 2026 12:12:05 +0200 Subject: [PATCH 20/23] test(extend-app-start): Assert null extended span now that getExtendedAppStartSpan is nullable Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/ActivityLifecycleIntegrationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index c83e821945..e92b9b25b8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -345,7 +345,7 @@ class ActivityLifecycleIntegrationTest { } ) assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) - assertFalse(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan.isNoOp) + assertNotNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) } @Test @@ -533,7 +533,7 @@ class ActivityLifecycleIntegrationTest { AppStartMetrics.getInstance().appStartExtension.extendAppStart() assertFalse(AppStartMetrics.getInstance().appStartExtension.isActive) - assertTrue(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan.isNoOp) + assertNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) verify(fixture.scopes, never()).startTransaction(any(), any()) } From 958b74c61dea4a8f0cbe5482a64319be81ac123e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 3 Jul 2026 11:16:30 +0200 Subject: [PATCH 21/23] docs(extend-app-start): Explain single-use app start sampling decision The get-then-clear of the app start sampling decision in onExtendAppStartRequested looks redundant without context. Document that the decision is pre-rolled on the previous run, forces the eager app.start transaction's sampling and profiler binding, and must be cleared so the first ui.load can't also claim it. Co-Authored-By: Claude --- .../io/sentry/android/core/ActivityLifecycleIntegration.java | 5 +++++ 1 file changed, 5 insertions(+) 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 738945a28e..80936a88be 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 @@ -1104,6 +1104,11 @@ private void onHeadlessAppStart() { return null; } + // The app start sampling decision was pre-rolled on the previous run so the app start + // profiler could start before Sentry.init. It forces the trace sampling of the eager + // app.start transaction created below (no re-roll, staying consistent with whether the + // profiler actually started) and lets it bind the app start profiler. It's single-use: + // we clear it so the first ui.load can't also claim it. final @Nullable TracesSamplingDecision samplingDecision = metrics.getAppStartSamplingDecision(); metrics.setAppStartSamplingDecision(null); From 9196ec9294884a50324680ca40d0f4f6fa7db691 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 3 Jul 2026 11:44:55 +0200 Subject: [PATCH 22/23] fix(extend-app-start): Bound the trace continuation window after an eager extension finishes The eager extendAppStart path persists trace headers so a later ui.load can continue the app.start trace, but never recorded the app start end time - that was only set on the headless path. If the extended transaction finished (user finish or deadline) before any activity, the first ui.load treated the continuation window as unbounded and joined the completed app-start trace no matter how much later it started. Persist the transaction's finish date via the transaction finished callback, which covers every finish path (finishExtendedAppStart, first frame, deadline), so the existing continuation window check bounds the extend path the same way it bounds the headless one. Co-Authored-By: Claude --- .../core/ActivityLifecycleIntegration.java | 7 +++ .../core/ActivityLifecycleIntegrationTest.kt | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) 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 80936a88be..f416df6a98 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 @@ -1056,6 +1056,13 @@ private void onHeadlessAppStart() { txnOptions.setWaitForChildren(true); final long deadlineTimeoutMillis = options.getDeadlineTimeout(); txnOptions.setDeadlineTimeout(deadlineTimeoutMillis <= 0 ? null : deadlineTimeoutMillis); + // Persist the end time (covering every finish path: user finish, first frame, deadline) so a + // later ui.load can tell whether it is close enough to continue this trace; without it the + // continuation window is unbounded. + txnOptions.setTransactionFinishedCallback( + finishedTransaction -> + AppStartMetrics.getInstance() + .setAppStartEndTime(finishedTransaction.getFinishDate())); } final @NotNull TransactionContext txnContext = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index e92b9b25b8..d198c8d975 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -504,6 +504,57 @@ class ActivityLifecycleIntegrationTest { assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) } + @Test + fun `finished eager extended app start persists the app start end time`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + assertNull(AppStartMetrics.getInstance().getAppStartEndTime()) + + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate()) + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + + assertNotNull(AppStartMetrics.getInstance().getAppStartEndTime()) + } + + @Test + fun `activity long after the eager extended app start finished starts a fresh trace`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // the eager extension starts at launch and finishes before any activity exists + setAppStartTime(date = SentryNanotimeDate(1, 0)) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + val appStartTraceId = fixture.capturedContexts.single().traceId + AppStartMetrics.getInstance() + .appStartExtension + .extendedAppStartSpan!! + .finish(SpanStatus.OK, SentryNanotimeDate(2, 0)) + AppStartMetrics.getInstance().appStartExtension.finishTransaction(SentryNanotimeDate(2, 0)) + + // the first activity opens more than a minute after the extension finished + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + // too far apart: the ui.load gets its own fresh trace, not the finished app.start one + assertNotEquals(appStartTraceId, uiLoadContext.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + @Test fun `extended headless app start does not create a duplicate when the extension already finished`() { val sut = From a6846bd9662c299d864bc7ee875520971821c55d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 3 Jul 2026 12:10:15 +0200 Subject: [PATCH 23/23] fix(extend-app-start): Skip warm-start reclassification while the extension is active A first activity arriving more than a minute after launch resets the app start span to the activity create time and flips the type to warm. While an app start extension is active this corrupts the extended vital: the eager app.start transaction stays anchored at process start while the measurement subtracts the reset span start, and a cold launch gets reported under the warm key. An active extension is the user explicitly saying the launch is still in progress, so let it pin the launch: skip the reclassification while the extension is active and apply the heuristic as before once it has finished. Co-Authored-By: Claude --- .../core/performance/AppStartMetrics.java | 8 +++- .../core/performance/AppStartMetricsTest.kt | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index b4cf4beddc..c7ddd09e78 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -632,8 +632,12 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved // NOTE: meaningless in standalone app start mode, where a headless start is already its own // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); - if (!appLaunchedInForeground.getValue() - || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + // An active extension explicitly keeps the launch alive: resetting the span here would make + // the extended vital measure from the activity while the eager app.start transaction stays + // anchored at process start. + if ((!appLaunchedInForeground.getValue() + || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) + && !appStartExtension.isActive()) { appStartType = AppStartType.WARM; shouldSendStartMeasurements = true; appStartSpan.reset(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 1950e6ff57..edf7265574 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -11,6 +11,7 @@ import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.IContinuousProfiler +import io.sentry.ITransaction import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate import io.sentry.android.core.AppStartExtension @@ -1085,4 +1086,41 @@ class AppStartMetricsTest { assertFalse(metrics.appStartExtension.isActive) metrics.appStartExtension.setExtendAppStartListener(null) } + + @Test + fun `late first activity does not reset the app start while the extension is active`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + activateExtension(metrics) + + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(1, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `late first activity resets the app start once the extension has finished`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.COLD + metrics.appStartTimeSpan.setStartedAt(1) + val transaction = mock() + whenever(transaction.isFinished).thenReturn(true) + metrics.appStartExtension.setExtendAppStartListener { + AppStartExtension.ExtendedAppStart(transaction, mock()) + } + metrics.appStartExtension.extendAppStart() + assertFalse(metrics.appStartExtension.isActive) + + val now = TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(now) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + metrics.appStartExtension.setExtendAppStartListener(null) + } }