From 7a7d63f7a8c88736e02410f74a8b9472fc51f3bf Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 23 Jun 2026 14:13:11 +0200 Subject: [PATCH 01/53] feat(extend-app-start): Add IAppStartExtender bridge and SentryOptions wiring Introduces the @ApiStatus.Internal IAppStartExtender contract (extendAppStart / finishAppStart / getExtendedAppStartSpan) and a NoOp default, wired into SentryOptions. This is the naming-stable core bridge for the app start extension API; it is inert (returns NoOpSpan / no-ops) until the Android implementation and public Sentry facade land later in the stack. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/api/sentry.api | 15 ++++++++ .../java/io/sentry/IAppStartExtender.java | 34 +++++++++++++++++++ .../java/io/sentry/NoOpAppStartExtender.java | 25 ++++++++++++++ .../main/java/io/sentry/SentryOptions.java | 18 ++++++++++ .../io/sentry/NoOpAppStartExtenderTest.kt | 17 ++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 20 +++++++++++ 6 files changed, 129 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/IAppStartExtender.java create mode 100644 sentry/src/main/java/io/sentry/NoOpAppStartExtender.java create mode 100644 sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e9083350349..7fbfa3505a9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -796,6 +796,12 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun withScope (Lio/sentry/ScopeCallback;)V } +public abstract interface class io/sentry/IAppStartExtender { + public abstract fun extendAppStart ()V + public abstract fun finishAppStart ()V + public abstract fun getExtendedAppStartSpan ()Lio/sentry/ISpan; +} + public abstract interface class io/sentry/IConnectionStatusProvider : java/io/Closeable { public abstract fun addConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)Z public abstract fun getConnectionStatus ()Lio/sentry/IConnectionStatusProvider$ConnectionStatus; @@ -1543,6 +1549,13 @@ public final class io/sentry/MonitorScheduleUnit : java/lang/Enum { public static fun values ()[Lio/sentry/MonitorScheduleUnit; } +public final class io/sentry/NoOpAppStartExtender : io/sentry/IAppStartExtender { + public fun extendAppStart ()V + public fun finishAppStart ()V + public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; + public static fun getInstance ()Lio/sentry/NoOpAppStartExtender; +} + public final class io/sentry/NoOpCompositePerformanceCollector : io/sentry/CompositePerformanceCollector { public fun close ()V public static fun getInstance ()Lio/sentry/NoOpCompositePerformanceCollector; @@ -3603,6 +3616,7 @@ public class io/sentry/SentryOptions { public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public static fun empty ()Lio/sentry/SentryOptions; public fun findPersistingScopeObserver ()Lio/sentry/cache/PersistingScopeObserver; + public fun getAppStartExtender ()Lio/sentry/IAppStartExtender; public fun getBackpressureMonitor ()Lio/sentry/backpressure/IBackpressureMonitor; public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; public fun getBeforeEnvelopeCallback ()Lio/sentry/SentryOptions$BeforeEnvelopeCallback; @@ -3749,6 +3763,7 @@ public class io/sentry/SentryOptions { public fun isTraceSampling ()Z public fun isTracingEnabled ()Z public fun merge (Lio/sentry/ExternalOptions;)V + public fun setAppStartExtender (Lio/sentry/IAppStartExtender;)V public fun setAttachServerName (Z)V public fun setAttachStacktrace (Z)V public fun setAttachThreads (Z)V diff --git a/sentry/src/main/java/io/sentry/IAppStartExtender.java b/sentry/src/main/java/io/sentry/IAppStartExtender.java new file mode 100644 index 00000000000..4a0f33d580b --- /dev/null +++ b/sentry/src/main/java/io/sentry/IAppStartExtender.java @@ -0,0 +1,34 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Bridges the {@code Sentry.extendAppStart()} / {@code Sentry.finishAppStart()} / {@code + * Sentry.getExtendedAppStartSpan()} static API to the Android implementation. The default + * implementation ({@link NoOpAppStartExtender}) does nothing, so the API is a no-op on platforms + * that don't provide an app start measurement. + */ +@ApiStatus.Internal +public interface IAppStartExtender { + + /** + * Begins extending the app start. Intended to be called from {@code Application.onCreate} right + * after SDK init. No-ops if the app start already finished, none is in progress, or it was + * already extended (first call wins). + */ + void extendAppStart(); + + /** + * Finishes the extended app start, allowing the app start transaction to complete. No-ops if the + * app start was not extended or this was already called. + */ + void finishAppStart(); + + /** + * Returns the active extended app start span to attach child spans to, or a no-op span when no + * extension is active. + */ + @NotNull + ISpan getExtendedAppStartSpan(); +} diff --git a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java new file mode 100644 index 00000000000..47dec3f4d17 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java @@ -0,0 +1,25 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +public final class NoOpAppStartExtender implements IAppStartExtender { + + private static final @NotNull NoOpAppStartExtender instance = new NoOpAppStartExtender(); + + private NoOpAppStartExtender() {} + + public static @NotNull NoOpAppStartExtender getInstance() { + return instance; + } + + @Override + public void extendAppStart() {} + + @Override + public void finishAppStart() {} + + @Override + public @NotNull ISpan getExtendedAppStartSpan() { + return NoOpSpan.getInstance(); + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 0d038482d07..f47dfae00ee 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -529,6 +529,9 @@ public class SentryOptions { private @NotNull FullyDisplayedReporter fullyDisplayedReporter = FullyDisplayedReporter.getInstance(); + /** Bridges the app start extension API to the Android implementation. */ + private @NotNull IAppStartExtender appStartExtender = NoOpAppStartExtender.getInstance(); + private @NotNull IConnectionStatusProvider connectionStatusProvider = new NoOpConnectionStatusProvider(); @@ -2653,6 +2656,21 @@ public void setFullyDisplayedReporter( this.fullyDisplayedReporter = fullyDisplayedReporter; } + /** + * Gets the app start extender, which bridges the app start extension API to its implementation. + * + * @return the app start extender. + */ + @ApiStatus.Internal + public @NotNull IAppStartExtender getAppStartExtender() { + return appStartExtender; + } + + @ApiStatus.Internal + public void setAppStartExtender(final @NotNull IAppStartExtender appStartExtender) { + this.appStartExtender = appStartExtender; + } + /** * Whether OPTIONS requests should be traced. * diff --git a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt new file mode 100644 index 00000000000..21353d92896 --- /dev/null +++ b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt @@ -0,0 +1,17 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertSame + +class NoOpAppStartExtenderTest { + private val extender = NoOpAppStartExtender.getInstance() + + @Test fun `extendAppStart does not throw`() = extender.extendAppStart() + + @Test fun `finishAppStart does not throw`() = extender.finishAppStart() + + @Test + fun `getExtendedAppStartSpan returns NoOpSpan`() { + assertSame(NoOpSpan.getInstance(), extender.extendedAppStartSpan) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 75a24dd68df..7976bb41dab 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -645,6 +645,26 @@ class SentryOptionsTest { assertEquals(FullyDisplayedReporter.getInstance(), SentryOptions().fullyDisplayedReporter) } + @Test + fun `when options are initialized, appStartExtender defaults to noop`() { + assertEquals(NoOpAppStartExtender.getInstance(), SentryOptions().appStartExtender) + } + + @Test + fun `when appStartExtender is set, its returned as well`() { + val options = SentryOptions() + val customExtender = + object : IAppStartExtender { + override fun extendAppStart() = Unit + + override fun finishAppStart() = Unit + + override fun getExtendedAppStartSpan(): ISpan = NoOpSpan.getInstance() + } + options.appStartExtender = customExtender + assertEquals(customExtender, options.appStartExtender) + } + @Test fun `when options are initialized, connectionStatusProvider is not null and default to noop`() { assertNotNull(SentryOptions().connectionStatusProvider) From 313fff18a9631558e6b39f7e665fc0a436c1d478 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 23 Jun 2026 23:19:03 +0200 Subject: [PATCH 02/53] chore(extend-app-start): Annotate NoOpAppStartExtender internal and fix test name typo Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/src/main/java/io/sentry/NoOpAppStartExtender.java | 2 ++ sentry/src/test/java/io/sentry/SentryOptionsTest.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java index 47dec3f4d17..83dde41f0d4 100644 --- a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java +++ b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java @@ -1,7 +1,9 @@ package io.sentry; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +@ApiStatus.Internal public final class NoOpAppStartExtender implements IAppStartExtender { private static final @NotNull NoOpAppStartExtender instance = new NoOpAppStartExtender(); diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 7976bb41dab..4643170d27f 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -651,7 +651,7 @@ class SentryOptionsTest { } @Test - fun `when appStartExtender is set, its returned as well`() { + fun `when appStartExtender is set, it's returned as well`() { val options = SentryOptions() val customExtender = object : IAppStartExtender { From 540931ae1a598ad1f81455d65d1caa473cc1e31e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:27:13 +0200 Subject: [PATCH 03/53] refactor(extend-app-start): Rename finishAppStart to finishExtendedAppStart Mirrors sentry-cocoa's finishExtendedAppLaunch() and makes the API name explicit about finishing the *extended* app start. Renames IAppStartExtender.finishAppStart() and the NoOpAppStartExtender implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/api/sentry.api | 4 ++-- sentry/src/main/java/io/sentry/IAppStartExtender.java | 4 ++-- sentry/src/main/java/io/sentry/NoOpAppStartExtender.java | 2 +- sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt | 2 +- sentry/src/test/java/io/sentry/SentryOptionsTest.kt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7fbfa3505a9..e74417b32a4 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -798,7 +798,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public abstract interface class io/sentry/IAppStartExtender { public abstract fun extendAppStart ()V - public abstract fun finishAppStart ()V + public abstract fun finishExtendedAppStart ()V public abstract fun getExtendedAppStartSpan ()Lio/sentry/ISpan; } @@ -1551,7 +1551,7 @@ public final class io/sentry/MonitorScheduleUnit : java/lang/Enum { public final class io/sentry/NoOpAppStartExtender : io/sentry/IAppStartExtender { public fun extendAppStart ()V - public fun finishAppStart ()V + public fun finishExtendedAppStart ()V public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/NoOpAppStartExtender; } diff --git a/sentry/src/main/java/io/sentry/IAppStartExtender.java b/sentry/src/main/java/io/sentry/IAppStartExtender.java index 4a0f33d580b..1cd5b59f060 100644 --- a/sentry/src/main/java/io/sentry/IAppStartExtender.java +++ b/sentry/src/main/java/io/sentry/IAppStartExtender.java @@ -4,7 +4,7 @@ import org.jetbrains.annotations.NotNull; /** - * Bridges the {@code Sentry.extendAppStart()} / {@code Sentry.finishAppStart()} / {@code + * Bridges the {@code Sentry.extendAppStart()} / {@code Sentry.finishExtendedAppStart()} / {@code * Sentry.getExtendedAppStartSpan()} static API to the Android implementation. The default * implementation ({@link NoOpAppStartExtender}) does nothing, so the API is a no-op on platforms * that don't provide an app start measurement. @@ -23,7 +23,7 @@ public interface IAppStartExtender { * Finishes the extended app start, allowing the app start transaction to complete. No-ops if the * app start was not extended or this was already called. */ - void finishAppStart(); + void finishExtendedAppStart(); /** * Returns the active extended app start span to attach child spans to, or a no-op span when no diff --git a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java index 83dde41f0d4..b229fdfc34a 100644 --- a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java +++ b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java @@ -18,7 +18,7 @@ private NoOpAppStartExtender() {} public void extendAppStart() {} @Override - public void finishAppStart() {} + public void finishExtendedAppStart() {} @Override public @NotNull ISpan getExtendedAppStartSpan() { diff --git a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt index 21353d92896..2ea276de1a2 100644 --- a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt +++ b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt @@ -8,7 +8,7 @@ class NoOpAppStartExtenderTest { @Test fun `extendAppStart does not throw`() = extender.extendAppStart() - @Test fun `finishAppStart does not throw`() = extender.finishAppStart() + @Test fun `finishExtendedAppStart does not throw`() = extender.finishExtendedAppStart() @Test fun `getExtendedAppStartSpan returns NoOpSpan`() { diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 4643170d27f..32fb8274d80 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -657,7 +657,7 @@ class SentryOptionsTest { object : IAppStartExtender { override fun extendAppStart() = Unit - override fun finishAppStart() = Unit + override fun finishExtendedAppStart() = Unit override fun getExtendedAppStartSpan(): ISpan = NoOpSpan.getInstance() } From 7dfe617e1ac1577284821b4537d41845a70dc525 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 23 Jun 2026 14:13:22 +0200 Subject: [PATCH 04/53] feat(extend-app-start): Extract AppStartExtension component for the Android extender Replaces the AppStartMetrics IAppStartExtender implementation and the deferred ExtendedAppStartSpan with a focused, lock-guarded AppStartExtension that owns the eager App Start transaction and extended span. AppStartMetrics now only holds the component and exposes isAppStartWindowOpen(). Inert until 3/4 registers the listener. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/sentry-android-core.api | 19 ++ .../core/AndroidOptionsInitializer.java | 1 + .../android/core/AppStartExtension.java | 170 ++++++++++++++ .../core/performance/AppStartMetrics.java | 26 +++ .../android/core/AppStartExtensionTest.kt | 212 ++++++++++++++++++ .../core/performance/AppStartMetricsTest.kt | 62 +++++ 6 files changed, 490 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 58325d08b5b..9148fb7a3d7 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,6 +184,23 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStartExtender { + public fun (Lio/sentry/android/core/performance/AppStartMetrics;)V + public fun extendAppStart ()V + public fun finishAppStart ()V + public fun finishTransaction (Lio/sentry/SentryDate;)V + public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; + public fun getExtendedEndTime ()Lio/sentry/SentryDate; + public fun isActive ()Z + public fun onExtended (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V + public fun reset ()V + public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V +} + +public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener { + public abstract fun onExtendAppStartRequested ()V +} + public final class io/sentry/android/core/AppState : java/io/Closeable { public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V public fun close ()V @@ -745,6 +762,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartBaggageHeader ()Ljava/lang/String; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartEndTime ()Lio/sentry/SentryDate; + public fun getAppStartExtension ()Lio/sentry/android/core/AppStartExtension; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; @@ -760,6 +778,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z + public fun isAppStartWindowOpen ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V public fun onActivityPaused (Landroid/app/Activity;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 5704cf7d7d4..9cc5cb3df0f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -198,6 +198,7 @@ static void initializeIntegrationsAndProcessors( } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + options.setAppStartExtender(appStartMetrics.getAppStartExtension()); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options)); 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 new file mode 100644 index 00000000000..626014948c3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -0,0 +1,170 @@ +package io.sentry.android.core; + +import io.sentry.IAppStartExtender; +import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.NoOpSpan; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SpanStatus; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.util.AutoClosableReentrantLock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Owns the lifecycle of an extended app start. Created and held by {@link AppStartMetrics}, it + * keeps the new "extend app start" concern out of that already-large class. + * + *

Both the eager standalone App Start {@link ITransaction} and its extended child {@link ISpan} + * are created by the integration (which has access to scopes) and handed back here via {@link + * #onExtended(ITransaction, ISpan)}. This component owns them from then on: it never stores them in + * the integration's shared transaction field, so the per-activity cleanup can never cancel an + * eagerly-created extension. + */ +@ApiStatus.Internal +public final class AppStartExtension implements IAppStartExtender { + + /** + * Notifies the integration that an extension was requested. The integration creates the + * standalone App Start transaction + extended child span (it has scopes) and hands them back via + * {@link #onExtended(ITransaction, ISpan)}. When no listener is registered (e.g. standalone + * tracing is disabled), {@link #extendAppStart()} is inert and the whole API stays a no-op. + */ + public interface ExtendAppStartListener { + void onExtendAppStartRequested(); + } + + private final @NotNull AppStartMetrics metrics; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private @Nullable ExtendAppStartListener extendAppStartListener; + private @Nullable ISpan extendedSpan; + private @Nullable ITransaction extendedTransaction; + + public AppStartExtension(final @NotNull AppStartMetrics metrics) { + this.metrics = metrics; + } + + public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) { + this.extendAppStartListener = listener; + } + + @Override + public void extendAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (extendedSpan != null) { + getLogger().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()) { + getLogger() + .log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); + return; + } + final @Nullable ExtendAppStartListener listener = extendAppStartListener; + if (listener != null) { + listener.onExtendAppStartRequested(); + } + } + } + + /** + * Hands the eagerly-created standalone App Start transaction and its extended child span over to + * this component, which owns them from now on. Called synchronously by the integration while + * handling {@link ExtendAppStartListener#onExtendAppStartRequested()}. + */ + public void onExtended( + final @NotNull ITransaction transaction, final @NotNull ISpan extendedSpan) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.extendedTransaction = transaction; + this.extendedSpan = extendedSpan; + } + } + + @Override + public void finishAppStart() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span != null && !span.isFinished()) { + span.finish(SpanStatus.OK); + } + } + } + + @Override + public @NotNull ISpan getExtendedAppStartSpan() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span != null && !span.isFinished()) { + return span; + } + return NoOpSpan.getInstance(); + } + } + + /** Whether an eagerly-created extension transaction exists and has not finished yet. */ + public boolean isActive() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedTransaction != null && !extendedTransaction.isFinished(); + } + } + + /** + * Finishes the owned transaction at the natural app start end (first frame, or the headless stop + * time). {@code waitForChildren} holds the transaction open until the extended span finishes, so + * the app start vital is never captured before this point. Idempotent. + */ + public void finishTransaction(final @NotNull SentryDate endTimestamp) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ITransaction transaction = extendedTransaction; + if (transaction != null && !transaction.isFinished()) { + transaction.finish(SpanStatus.OK, endTimestamp); + } + } + } + + /** + * The effective end of the extended app start, used to extend the app start vital. Returns {@code + * null} when no extension finished, or when it finished via the deadline timeout - in the latter + * case the vital is suppressed instead of reporting an artificially inflated duration. + */ + public @Nullable SentryDate getExtendedEndTime() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span == null || !span.isFinished()) { + return null; + } + if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) { + return null; + } + return span.getFinishDate(); + } + } + + /** + * Resets the per-start state so a stale extension can't affect a later (e.g. warm) app start. The + * registered listener is intentionally kept: it is registered once at SDK init and must survive + * across app starts. + */ + public void reset() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + extendedSpan = null; + extendedTransaction = null; + } + } + + private static @NotNull ILogger getLogger() { + return Sentry.getCurrentScopes().getOptions().getLogger(); + } +} 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 36cae8686ca..2a05690f60b 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 @@ -20,6 +20,7 @@ import io.sentry.NoOpLogger; import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.AppStartExtension; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; @@ -98,6 +99,7 @@ public enum AppStartType { private @Nullable String appStartBaggageHeader; private @Nullable SentryDate appStartEndTime; private @Nullable ApplicationStartInfo cachedStartInfo; + private final @NotNull AppStartExtension appStartExtension = new AppStartExtension(this); public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -281,6 +283,9 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); + // Reset extension state so a stale extended span/txn can't affect a later (e.g. warm) app + // start. + appStartExtension.reset(); } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { @@ -336,6 +341,26 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + // region app start extension + + /** The focused component that owns the "extend app start" lifecycle. */ + public @NotNull AppStartExtension getAppStartExtension() { + return appStartExtension; + } + + /** + * Whether the app start window is still open, i.e. an app start can be extended: measurements + * haven't been sent yet, no activity has been created, and the first frame hasn't been drawn. The + * foreground check is ignored so headless app starts (broadcast/service) can also be extended. + */ + public boolean isAppStartWindowOpen() { + return shouldSendStartMeasurements(true) + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get(); + } + + // endregion + @TestOnly void setFirstIdle(final long firstIdle) { this.firstIdle = firstIdle; @@ -377,6 +402,7 @@ public void clear() { appStartBaggageHeader = null; appStartEndTime = null; cachedStartInfo = null; + appStartExtension.reset(); } public @Nullable ITransactionProfiler getAppStartProfiler() { 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 new file mode 100644 index 00000000000..c0f1c62b693 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -0,0 +1,212 @@ +package io.sentry.android.core + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.NoOpSpan +import io.sentry.SentryNanotimeDate +import io.sentry.SpanStatus +import io.sentry.android.core.performance.AppStartMetrics +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class AppStartExtensionTest { + + private val metrics = mock() + + private fun extension(windowOpen: Boolean = true): AppStartExtension { + whenever(metrics.isAppStartWindowOpen).thenReturn(windowOpen) + return AppStartExtension(metrics) + } + + /** Simulates the integration's listener: hands a transaction + span back to the extension. */ + private fun AppStartExtension.registerHandOver( + txn: ITransaction = mock(), + span: ISpan = mock(), + ): Pair { + setExtendAppStartListener { onExtended(txn, span) } + return txn to span + } + + @Test + fun `extendAppStart fires the listener when the window is open`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + ext.setExtendAppStartListener { calls.incrementAndGet() } + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `extendAppStart does not fire the listener when the window is closed`() { + val ext = extension(windowOpen = false) + val calls = AtomicInteger() + ext.setExtendAppStartListener { calls.incrementAndGet() } + ext.extendAppStart() + assertEquals(0, calls.get()) + } + + @Test + fun `extendAppStart is inert when no listener is registered`() { + val ext = extension(windowOpen = true) + ext.extendAppStart() + assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + assertFalse(ext.isActive) + } + + @Test + fun `extendAppStart is ignored when already extending`() { + val ext = extension(windowOpen = true) + val calls = AtomicInteger() + val txn = mock() + val span = mock() + ext.setExtendAppStartListener { + calls.incrementAndGet() + ext.onExtended(txn, span) + } + ext.extendAppStart() + ext.extendAppStart() + assertEquals(1, calls.get()) + } + + @Test + fun `getExtendedAppStartSpan returns NoOpSpan when no extension is active`() { + assertSame(NoOpSpan.getInstance(), extension().extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns the span while extending`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + assertSame(span, ext.extendedAppStartSpan) + } + + @Test + fun `finishAppStart without a prior extend is a no-op`() { + val ext = extension() + ext.finishAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `finishAppStart finishes the extended span`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + ext.finishAppStart() + verify(span).finish(SpanStatus.OK) + } + + @Test + fun `finishAppStart does not finish an already finished span`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + ext.registerHandOver(span = span) + ext.extendAppStart() + ext.finishAppStart() + verify(span, never()).finish(any()) + } + + @Test + fun `isActive reflects the transaction state`() { + val ext = extension(windowOpen = true) + assertFalse(ext.isActive) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + whenever(txn.isFinished).thenReturn(true) + assertFalse(ext.isActive) + } + + @Test + fun `finishTransaction finishes the transaction at the given timestamp`() { + val ext = extension(windowOpen = true) + val (txn, _) = ext.registerHandOver() + ext.extendAppStart() + val endTimestamp = SentryNanotimeDate() + ext.finishTransaction(endTimestamp) + verify(txn).finish(SpanStatus.OK, endTimestamp) + } + + @Test + fun `finishTransaction does not finish an already finished transaction`() { + val ext = extension(windowOpen = true) + val txn = mock() + whenever(txn.isFinished).thenReturn(true) + ext.registerHandOver(txn = txn) + ext.extendAppStart() + ext.finishTransaction(SentryNanotimeDate()) + verify(txn, never()).finish(any(), any()) + } + + @Test + fun `getExtendedEndTime is null while the span is unfinished`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime is null when the extension finished via deadline`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.DEADLINE_EXCEEDED) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedEndTime) + } + + @Test + fun `getExtendedEndTime returns the finish date on a user finish`() { + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(true) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + + @Test + fun `reset clears the extension state`() { + val ext = extension(windowOpen = true) + ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + ext.reset() + assertFalse(ext.isActive) + assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns NoOpSpan after the span finished`() { + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(true) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + } +} 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 2737785349f..76a06dcea1d 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 @@ -1024,4 +1024,66 @@ 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) + } + + @Test + 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) + } + + @Test + fun `isAppStartWindowOpen is false once an activity was created`() { + val metrics = AppStartMetrics.getInstance() + metrics.onActivityCreated(mock(), null) + assertFalse(metrics.isAppStartWindowOpen) + } + + @Test + fun `isAppStartWindowOpen is false once the first frame was drawn`() { + val metrics = AppStartMetrics.getInstance() + metrics.onFirstFrameDrawn() + assertFalse(metrics.isAppStartWindowOpen) + } + + @Test + fun `isAppStartWindowOpen is false once start measurements were sent`() { + val metrics = AppStartMetrics.getInstance() + metrics.onAppStartSpansSent() + assertFalse(metrics.isAppStartWindowOpen) + } + + @Test + fun `getAppStartExtension returns the same instance`() { + val metrics = AppStartMetrics.getInstance() + assertSame(metrics.appStartExtension, metrics.appStartExtension) + } + + @Test + fun `clear resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartExtension.onExtended(mock(), mock()) + assertTrue(metrics.appStartExtension.isActive) + metrics.clear() + assertFalse(metrics.appStartExtension.isActive) + } + + @Test + fun `onAppStartSpansSent resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartExtension.onExtended(mock(), mock()) + assertTrue(metrics.appStartExtension.isActive) + metrics.onAppStartSpansSent() + assertFalse(metrics.appStartExtension.isActive) + } + + // endregion } From 4d0c97d8f4e31b64127c77d3b662e21f42efa691 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 02:19:26 +0200 Subject: [PATCH 05/53] refactor(extend-app-start): Inject logger into AppStartExtension instead of static scope lookup Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/AndroidOptionsInitializer.java | 4 +++- .../android/core/AppStartExtension.java | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 9cc5cb3df0f..b77d14e5c32 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -198,7 +198,9 @@ static void initializeIntegrationsAndProcessors( } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - options.setAppStartExtender(appStartMetrics.getAppStartExtension()); + final @NotNull AppStartExtension appStartExtension = appStartMetrics.getAppStartExtension(); + appStartExtension.setLogger(options.getLogger()); + options.setAppStartExtender(appStartExtension); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options)); 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 626014948c3..55db84b5d26 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 @@ -5,8 +5,8 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; +import io.sentry.NoOpLogger; import io.sentry.NoOpSpan; -import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SpanStatus; @@ -42,6 +42,10 @@ public interface ExtendAppStartListener { private final @NotNull AppStartMetrics metrics; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + // Set once at SDK init via setLogger(), read later when an extension is requested. Defaults to a + // no-op because this component is created before SentryOptions (and its logger) exist. + private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); + private @Nullable ExtendAppStartListener extendAppStartListener; private @Nullable ISpan extendedSpan; private @Nullable ITransaction extendedTransaction; @@ -54,11 +58,15 @@ public void setExtendAppStartListener(final @Nullable ExtendAppStartListener lis this.extendAppStartListener = listener; } + void setLogger(final @NotNull ILogger logger) { + this.logger = logger; + } + @Override public void extendAppStart() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (extendedSpan != null) { - getLogger().log(SentryLevel.WARNING, "App start is already being extended."); + logger.log(SentryLevel.WARNING, "App start is already being extended."); return; } // Ignore the foreground check: headless app starts (broadcast/service) run in a @@ -66,10 +74,9 @@ public void extendAppStart() { // extension once an activity was created, the first frame was drawn, or measurements were // already sent. if (!metrics.isAppStartWindowOpen()) { - getLogger() - .log( - SentryLevel.WARNING, - "Cannot extend app start: the app start window has already passed."); + logger.log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); return; } final @Nullable ExtendAppStartListener listener = extendAppStartListener; @@ -163,8 +170,4 @@ public void reset() { extendedTransaction = null; } } - - private static @NotNull ILogger getLogger() { - return Sentry.getCurrentScopes().getOptions().getLogger(); - } } From b5ac7f5357b5603249db7da43d6a28f6f0c53df2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 12:59:22 +0200 Subject: [PATCH 06/53] refactor(extend-app-start): Return ExtendedAppStart from the listener instead of a callback The listener now returns the created transaction+span (or null to decline) rather than calling back into onExtended() while extendAppStart() holds the lock. This removes the re-entrant lock acquisition and the A->B->A round trip, collapsing two public methods into one linear flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/sentry-android-core.api | 9 +++- .../android/core/AppStartExtension.java | 49 ++++++++++--------- .../android/core/AppStartExtensionTest.kt | 14 ++++-- .../core/performance/AppStartMetricsTest.kt | 18 +++++-- 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 9148fb7a3d7..981d41d4ca7 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -192,13 +192,18 @@ 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 onExtended (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V public fun reset ()V public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V } public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener { - public abstract fun onExtendAppStartRequested ()V + public abstract fun onExtendAppStartRequested ()Lio/sentry/android/core/AppStartExtension$ExtendedAppStart; +} + +public final class io/sentry/android/core/AppStartExtension$ExtendedAppStart { + public final field span Lio/sentry/ISpan; + public final field transaction Lio/sentry/ITransaction; + public fun (Lio/sentry/ITransaction;Lio/sentry/ISpan;)V } public final class io/sentry/android/core/AppState : java/io/Closeable { 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 55db84b5d26..47c885f8cda 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 @@ -21,22 +21,36 @@ * keeps the new "extend app start" concern out of that already-large class. * *

Both the eager standalone App Start {@link ITransaction} and its extended child {@link ISpan} - * are created by the integration (which has access to scopes) and handed back here via {@link - * #onExtended(ITransaction, ISpan)}. This component owns them from then on: it never stores them in - * the integration's shared transaction field, so the per-activity cleanup can never cancel an - * eagerly-created extension. + * are created by the integration (which has access to scopes) and returned to this component from + * {@link ExtendAppStartListener#onExtendAppStartRequested()}. This component owns them from then + * on: it never stores them in the integration's shared transaction field, so the per-activity + * cleanup can never cancel an eagerly-created extension. */ @ApiStatus.Internal public final class AppStartExtension implements IAppStartExtender { + /** + * The standalone App Start transaction and its extended child span, created by the integration. + */ + public static final class ExtendedAppStart { + public final @NotNull ITransaction transaction; + public final @NotNull ISpan span; + + public ExtendedAppStart(final @NotNull ITransaction transaction, final @NotNull ISpan span) { + this.transaction = transaction; + this.span = span; + } + } + /** * Notifies the integration that an extension was requested. The integration creates the - * standalone App Start transaction + extended child span (it has scopes) and hands them back via - * {@link #onExtended(ITransaction, ISpan)}. When no listener is registered (e.g. standalone - * tracing is disabled), {@link #extendAppStart()} is inert and the whole API stays a no-op. + * standalone App Start transaction + extended child span (it has scopes) and returns them, or + * returns {@code null} to decline (e.g. standalone tracing is disabled). When no listener is + * registered, {@link #extendAppStart()} is inert and the whole API stays a no-op. */ public interface ExtendAppStartListener { - void onExtendAppStartRequested(); + @Nullable + ExtendedAppStart onExtendAppStartRequested(); } private final @NotNull AppStartMetrics metrics; @@ -81,24 +95,15 @@ public void extendAppStart() { } final @Nullable ExtendAppStartListener listener = extendAppStartListener; if (listener != null) { - listener.onExtendAppStartRequested(); + final @Nullable ExtendedAppStart extended = listener.onExtendAppStartRequested(); + if (extended != null) { + this.extendedTransaction = extended.transaction; + this.extendedSpan = extended.span; + } } } } - /** - * Hands the eagerly-created standalone App Start transaction and its extended child span over to - * this component, which owns them from now on. Called synchronously by the integration while - * handling {@link ExtendAppStartListener#onExtendAppStartRequested()}. - */ - public void onExtended( - final @NotNull ITransaction transaction, final @NotNull ISpan extendedSpan) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - this.extendedTransaction = transaction; - this.extendedSpan = extendedSpan; - } - } - @Override public void finishAppStart() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { 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 c0f1c62b693..ab41b6acbc2 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 @@ -39,7 +39,7 @@ class AppStartExtensionTest { txn: ITransaction = mock(), span: ISpan = mock(), ): Pair { - setExtendAppStartListener { onExtended(txn, span) } + setExtendAppStartListener { AppStartExtension.ExtendedAppStart(txn, span) } return txn to span } @@ -47,7 +47,10 @@ class AppStartExtensionTest { fun `extendAppStart fires the listener when the window is open`() { val ext = extension(windowOpen = true) val calls = AtomicInteger() - ext.setExtendAppStartListener { calls.incrementAndGet() } + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } ext.extendAppStart() assertEquals(1, calls.get()) } @@ -56,7 +59,10 @@ class AppStartExtensionTest { fun `extendAppStart does not fire the listener when the window is closed`() { val ext = extension(windowOpen = false) val calls = AtomicInteger() - ext.setExtendAppStartListener { calls.incrementAndGet() } + ext.setExtendAppStartListener { + calls.incrementAndGet() + null + } ext.extendAppStart() assertEquals(0, calls.get()) } @@ -77,7 +83,7 @@ class AppStartExtensionTest { val span = mock() ext.setExtendAppStartListener { calls.incrementAndGet() - ext.onExtended(txn, span) + AppStartExtension.ExtendedAppStart(txn, span) } ext.extendAppStart() ext.extendAppStart() 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 76a06dcea1d..329718eada2 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 @@ -13,6 +13,7 @@ import io.sentry.DateUtils import io.sentry.IContinuousProfiler import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.AppStartExtension import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions @@ -1067,22 +1068,31 @@ class AppStartMetricsTest { 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 { + AppStartExtension.ExtendedAppStart(mock(), mock()) + } + metrics.appStartExtension.extendAppStart() + assertTrue(metrics.appStartExtension.isActive) + } + @Test fun `clear resets the extension state`() { val metrics = AppStartMetrics.getInstance() - metrics.appStartExtension.onExtended(mock(), mock()) - assertTrue(metrics.appStartExtension.isActive) + activateExtension(metrics) metrics.clear() assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) } @Test fun `onAppStartSpansSent resets the extension state`() { val metrics = AppStartMetrics.getInstance() - metrics.appStartExtension.onExtended(mock(), mock()) - assertTrue(metrics.appStartExtension.isActive) + activateExtension(metrics) metrics.onAppStartSpansSent() assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) } // endregion From 81efded159adf0433863e1daa8527ff41f4b595f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:08:19 +0200 Subject: [PATCH 07/53] chore(extend-app-start): Remove redundant comments and rename reset to clear Drop comments that restate code or annotate omissions (region markers, getter javadoc, the reset call-site comment, the ExtendedAppStart/isActive docs). Rename AppStartExtension.reset() to clear() to match its owner AppStartMetrics.clear(). Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry-android-core/api/sentry-android-core.api | 2 +- .../io/sentry/android/core/AppStartExtension.java | 11 +---------- .../android/core/performance/AppStartMetrics.java | 11 ++--------- .../io/sentry/android/core/AppStartExtensionTest.kt | 4 ++-- 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 981d41d4ca7..788ee7eaf5e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -186,13 +186,13 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStartExtender { public fun (Lio/sentry/android/core/performance/AppStartMetrics;)V + public fun clear ()V public fun extendAppStart ()V public fun finishAppStart ()V public fun finishTransaction (Lio/sentry/SentryDate;)V public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; public fun getExtendedEndTime ()Lio/sentry/SentryDate; public fun isActive ()Z - public fun reset ()V public fun setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V } 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 47c885f8cda..c8c85d81f30 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 @@ -29,9 +29,6 @@ @ApiStatus.Internal public final class AppStartExtension implements IAppStartExtender { - /** - * The standalone App Start transaction and its extended child span, created by the integration. - */ public static final class ExtendedAppStart { public final @NotNull ITransaction transaction; public final @NotNull ISpan span; @@ -125,7 +122,6 @@ public void finishAppStart() { } } - /** Whether an eagerly-created extension transaction exists and has not finished yet. */ public boolean isActive() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { return extendedTransaction != null && !extendedTransaction.isFinished(); @@ -164,12 +160,7 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { } } - /** - * Resets the per-start state so a stale extension can't affect a later (e.g. warm) app start. The - * registered listener is intentionally kept: it is registered once at SDK init and must survive - * across app starts. - */ - public void reset() { + public void clear() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { extendedSpan = null; extendedTransaction = null; 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 2a05690f60b..6bad1a00187 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 @@ -283,9 +283,7 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); - // Reset extension state so a stale extended span/txn can't affect a later (e.g. warm) app - // start. - appStartExtension.reset(); + appStartExtension.clear(); } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { @@ -341,9 +339,6 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } - // region app start extension - - /** The focused component that owns the "extend app start" lifecycle. */ public @NotNull AppStartExtension getAppStartExtension() { return appStartExtension; } @@ -359,8 +354,6 @@ public boolean isAppStartWindowOpen() { && !firstDrawDone.get(); } - // endregion - @TestOnly void setFirstIdle(final long firstIdle) { this.firstIdle = firstIdle; @@ -402,7 +395,7 @@ public void clear() { appStartBaggageHeader = null; appStartEndTime = null; cachedStartInfo = null; - appStartExtension.reset(); + appStartExtension.clear(); } public @Nullable ITransactionProfiler getAppStartProfiler() { 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 ab41b6acbc2..cb29d69c196 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 @@ -196,12 +196,12 @@ class AppStartExtensionTest { } @Test - fun `reset clears the extension state`() { + fun `clear clears the extension state`() { val ext = extension(windowOpen = true) ext.registerHandOver() ext.extendAppStart() assertTrue(ext.isActive) - ext.reset() + ext.clear() assertFalse(ext.isActive) assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) } From 3cc743ed7116e774b544d32e94adca64cd649acd Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:15:57 +0200 Subject: [PATCH 08/53] refactor(extend-app-start): Inline the logger lookup instead of injecting it The extension logs only two warnings, both on rare guard paths in extendAppStart(). Inline Sentry.getCurrentScopes().getOptions().getLogger() at those sites instead of holding a logger field set at init, dropping the field, setLogger(), and the AndroidOptionsInitializer wiring. extendAppStart() runs post-init, so the lookup always yields the configured logger. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/AndroidOptionsInitializer.java | 4 +-- .../android/core/AppStartExtension.java | 25 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index b77d14e5c32..9cc5cb3df0f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -198,9 +198,7 @@ static void initializeIntegrationsAndProcessors( } final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - final @NotNull AppStartExtension appStartExtension = appStartMetrics.getAppStartExtension(); - appStartExtension.setLogger(options.getLogger()); - options.setAppStartExtender(appStartExtension); + options.setAppStartExtender(appStartMetrics.getAppStartExtension()); if (options.getModulesLoader() instanceof NoOpModulesLoader) { options.setModulesLoader(new AssetsModulesLoader(context, options)); 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 c8c85d81f30..74054a15a9e 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 @@ -1,12 +1,11 @@ package io.sentry.android.core; import io.sentry.IAppStartExtender; -import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; -import io.sentry.NoOpLogger; import io.sentry.NoOpSpan; +import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SpanStatus; @@ -53,10 +52,6 @@ public interface ExtendAppStartListener { private final @NotNull AppStartMetrics metrics; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - // Set once at SDK init via setLogger(), read later when an extension is requested. Defaults to a - // no-op because this component is created before SentryOptions (and its logger) exist. - private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); - private @Nullable ExtendAppStartListener extendAppStartListener; private @Nullable ISpan extendedSpan; private @Nullable ITransaction extendedTransaction; @@ -69,15 +64,14 @@ public void setExtendAppStartListener(final @Nullable ExtendAppStartListener lis this.extendAppStartListener = listener; } - void setLogger(final @NotNull ILogger logger) { - this.logger = logger; - } - @Override public void extendAppStart() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (extendedSpan != null) { - logger.log(SentryLevel.WARNING, "App start is already being extended."); + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "App start is already being extended."); return; } // Ignore the foreground check: headless app starts (broadcast/service) run in a @@ -85,9 +79,12 @@ public void extendAppStart() { // extension once an activity was created, the first frame was drawn, or measurements were // already sent. if (!metrics.isAppStartWindowOpen()) { - logger.log( - SentryLevel.WARNING, - "Cannot extend app start: the app start window has already passed."); + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); return; } final @Nullable ExtendAppStartListener listener = extendAppStartListener; From 1dafb9b4ecdb3e5b8dbc5d542f3d4c72039b4b35 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:18:09 +0200 Subject: [PATCH 09/53] chore(extend-app-start): Drop the class and listener javadocs on AppStartExtension Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/AppStartExtension.java | 16 ---------------- 1 file changed, 16 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 74054a15a9e..a03d49917b9 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 @@ -15,16 +15,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** - * Owns the lifecycle of an extended app start. Created and held by {@link AppStartMetrics}, it - * keeps the new "extend app start" concern out of that already-large class. - * - *

Both the eager standalone App Start {@link ITransaction} and its extended child {@link ISpan} - * are created by the integration (which has access to scopes) and returned to this component from - * {@link ExtendAppStartListener#onExtendAppStartRequested()}. This component owns them from then - * on: it never stores them in the integration's shared transaction field, so the per-activity - * cleanup can never cancel an eagerly-created extension. - */ @ApiStatus.Internal public final class AppStartExtension implements IAppStartExtender { @@ -38,12 +28,6 @@ public ExtendedAppStart(final @NotNull ITransaction transaction, final @NotNull } } - /** - * Notifies the integration that an extension was requested. The integration creates the - * standalone App Start transaction + extended child span (it has scopes) and returns them, or - * returns {@code null} to decline (e.g. standalone tracing is disabled). When no listener is - * registered, {@link #extendAppStart()} is inert and the whole API stays a no-op. - */ public interface ExtendAppStartListener { @Nullable ExtendedAppStart onExtendAppStartRequested(); From 8548b4d72d3535d893b1cb7941246ec84b4fb44e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:21:58 +0200 Subject: [PATCH 10/53] chore(extend-app-start): Trim finishTransaction/getExtendedEndTime comments Drop finishTransaction's javadoc (trivial body; it described caller context and waitForChildren behavior configured elsewhere) and reduce getExtendedEndTime's javadoc to a single inline note on the only non-obvious branch (deadline suppression). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/AppStartExtension.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 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 a03d49917b9..4153c88430b 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 @@ -109,11 +109,6 @@ public boolean isActive() { } } - /** - * Finishes the owned transaction at the natural app start end (first frame, or the headless stop - * time). {@code waitForChildren} holds the transaction open until the extended span finishes, so - * the app start vital is never captured before this point. Idempotent. - */ public void finishTransaction(final @NotNull SentryDate endTimestamp) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ITransaction transaction = extendedTransaction; @@ -123,17 +118,14 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { } } - /** - * The effective end of the extended app start, used to extend the app start vital. Returns {@code - * null} when no extension finished, or when it finished via the deadline timeout - in the latter - * case the vital is suppressed instead of reporting an artificially inflated duration. - */ public @Nullable SentryDate getExtendedEndTime() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; if (span == null || !span.isFinished()) { return null; } + // A deadline timeout would report an artificially inflated duration; suppress the vital + // instead. if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) { return null; } From 01b1dc44fe5bfe30daf6669827fa35a4cedce4a6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:28:08 +0200 Subject: [PATCH 11/53] refactor(extend-app-start): Rename AppStartExtension.finishAppStart to finishExtendedAppStart Implements the renamed IAppStartExtender.finishExtendedAppStart(). Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry-android-core/api/sentry-android-core.api | 2 +- .../io/sentry/android/core/AppStartExtension.java | 2 +- .../io/sentry/android/core/AppStartExtensionTest.kt | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 788ee7eaf5e..9d064193ff1 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -188,7 +188,7 @@ public final class io/sentry/android/core/AppStartExtension : io/sentry/IAppStar public fun (Lio/sentry/android/core/performance/AppStartMetrics;)V public fun clear ()V public fun extendAppStart ()V - public fun finishAppStart ()V + public fun finishExtendedAppStart ()V public fun finishTransaction (Lio/sentry/SentryDate;)V public fun getExtendedAppStartSpan ()Lio/sentry/ISpan; public fun getExtendedEndTime ()Lio/sentry/SentryDate; 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 4153c88430b..c5e98fc8529 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 @@ -83,7 +83,7 @@ public void extendAppStart() { } @Override - public void finishAppStart() { + public void finishExtendedAppStart() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; if (span != null && !span.isFinished()) { 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 cb29d69c196..ab7754e17fa 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 @@ -104,29 +104,29 @@ class AppStartExtensionTest { } @Test - fun `finishAppStart without a prior extend is a no-op`() { + fun `finishExtendedAppStart without a prior extend is a no-op`() { val ext = extension() - ext.finishAppStart() + ext.finishExtendedAppStart() assertNull(ext.extendedEndTime) } @Test - fun `finishAppStart finishes the extended span`() { + fun `finishExtendedAppStart finishes the extended span`() { val ext = extension(windowOpen = true) val (_, span) = ext.registerHandOver() ext.extendAppStart() - ext.finishAppStart() + ext.finishExtendedAppStart() verify(span).finish(SpanStatus.OK) } @Test - fun `finishAppStart does not finish an already finished span`() { + fun `finishExtendedAppStart does not finish an already finished span`() { val ext = extension(windowOpen = true) val span = mock() whenever(span.isFinished).thenReturn(true) ext.registerHandOver(span = span) ext.extendAppStart() - ext.finishAppStart() + ext.finishExtendedAppStart() verify(span, never()).finish(any()) } From d996425aa74c04f9a7f539e896490dd309fe97f9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 01:27:31 +0200 Subject: [PATCH 12/53] 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 9d064193ff1..28fc82c4418 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 d70ff837178..2d5eb6f1dbc 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 c5e98fc8529..96926abe4ac 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 0b50b5080f4..30407d40350 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 8b842a0cfa9..0b1b4262ea3 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 173b4e3d999..051fb6c3cb0 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 13/53] 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 96926abe4ac..f244c70a8b7 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 14/53] 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 ab7754e17fa..fd1e416e1e3 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 15/53] 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 329718eada2..530f84e33eb 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 05a9df2f8b3fd747d98ed472665be2adb8307632 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:52:13 +0200 Subject: [PATCH 16/53] 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 c5e98fc8529..d3a5f35f488 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 5830303fab5b95e52899ac06d16e1db9e6cb94f1 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:57:42 +0200 Subject: [PATCH 17/53] 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 329718eada2..530f84e33eb 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 ec4da6c52b109541f052ac0d79e4affbb1492a43 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 15:11:01 +0200 Subject: [PATCH 18/53] test(extend-app-start): Drop the no-op finishExtendedAppStart test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/io/sentry/android/core/AppStartExtensionTest.kt | 7 ------- 1 file changed, 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 ab7754e17fa..bd53dfda476 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) From 38f83e1654ae62983c1bcd37e4dfa808b2639fa0 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 15:22:25 +0200 Subject: [PATCH 19/53] 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 2d5eb6f1dbc..206434ce6a2 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 30407d40350..cb14a28edd0 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 0b1b4262ea3..ffc3320cd92 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 051fb6c3cb0..1a042023b08 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 20/53] 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 206434ce6a2..595edaeede2 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 d2c16a255b5bace2926ba36351c4c40d6ee7ddf9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 16:51:50 +0200 Subject: [PATCH 21/53] fix(extend-app-start): Read the extended span finish date, not its finished flag getExtendedEndTime() gated on span.isFinished(), but finishing the extended span completes the waitForChildren transaction and runs the event processor re-entrantly within finishExtendedAppStart(), before the span's finished flag is set. The processor then saw an unfinished span and dropped the app start measurement whenever the extension finished after the first frame. Read getFinishDate() (set before the finish callback) instead, which also keeps the extended end controllable in tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/AppStartExtension.java | 5 ++++- .../android/core/AppStartExtensionTest.kt | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 d3a5f35f488..eceeee4b21d 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 @@ -117,7 +117,7 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { public @Nullable SentryDate getExtendedEndTime() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; - if (span == null || !span.isFinished()) { + if (span == null) { return null; } // A deadline timeout would report an artificially inflated duration; suppress the vital @@ -125,6 +125,9 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { if (span.getStatus() == SpanStatus.DEADLINE_EXCEEDED) { return null; } + // Read the finish date, not isFinished(): finishing the extended span completes the + // waitForChildren transaction and runs the event processor re-entrantly before the span's + // finished flag is set, but the finish timestamp is already in place. Null until finished. return span.getFinishDate(); } } 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 bd53dfda476..be91c137075 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 @@ -188,6 +188,23 @@ class AppStartExtensionTest { assertSame(finishDate, ext.extendedEndTime) } + @Test + fun `getExtendedEndTime returns the finish date even when the span still reports unfinished`() { + // Reproduces the waitForChildren reentrancy: finishing the extended span completes the + // transaction and runs the event processor before the span's isFinished() flips, while the + // finish timestamp is already set. getExtendedEndTime() must read the finish date, not the + // flag. + val ext = extension(windowOpen = true) + val finishDate = SentryNanotimeDate() + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.status).thenReturn(SpanStatus.OK) + whenever(span.finishDate).thenReturn(finishDate) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(finishDate, ext.extendedEndTime) + } + @Test fun `clear clears the extension state`() { val ext = extension(windowOpen = true) From b6dd76ef66dfaf8da8cf71305aed4a89040ada61 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 17:22:30 +0200 Subject: [PATCH 22/53] fix(extend-app-start): End the extended transaction at the extended span end When the extended span finished after the timestamp passed to finishTransaction() but before that call ran (e.g. a synchronous extension in a headless start, where finishTransaction runs later at main-thread idle), waitForChildren had nothing left to wait for and the transaction kept the earlier passed timestamp. The extended span then ended after the transaction, and the app start vital exceeded the transaction duration. Finish at max(endTimestamp, extended span finish date) so the span is contained and the duration matches the vital. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sentry/android/core/AppStartExtension.java | 10 +++++++++- .../android/core/AppStartExtensionTest.kt | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 eceeee4b21d..540220b7ddc 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 @@ -109,7 +109,15 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ITransaction transaction = extendedTransaction; if (transaction != null && !transaction.isFinished()) { - transaction.finish(SpanStatus.OK, endTimestamp); + // If the extended span already finished after endTimestamp, end the transaction there so it + // contains the extended span and its duration matches the reported app start vital. When + // the + // span is still open, waitForChildren keeps the transaction open until it finishes. + final @Nullable ISpan span = extendedSpan; + final @Nullable SentryDate spanEnd = span == null ? null : span.getFinishDate(); + final @NotNull SentryDate end = + spanEnd != null && spanEnd.isAfter(endTimestamp) ? spanEnd : endTimestamp; + transaction.finish(SpanStatus.OK, end); } } } 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 be91c137075..4241f03f40f 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 @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ISpan import io.sentry.ITransaction import io.sentry.NoOpSpan +import io.sentry.SentryLongDate import io.sentry.SentryNanotimeDate import io.sentry.SpanStatus import io.sentry.android.core.performance.AppStartMetrics @@ -155,6 +156,22 @@ class AppStartExtensionTest { verify(txn, never()).finish(any(), any()) } + @Test + fun `finishTransaction ends at the extended span end when it finished after the given timestamp`() { + // Headless: the extended span can finish (in onCreate) before finishTransaction runs (at idle) + // with a finish date later than the headless end. The transaction must end there so it contains + // the extended span and its duration matches the app start vital. + val ext = extension(windowOpen = true) + val txn = mock() + val span = mock() + val spanEnd = SentryLongDate(2_000_000_000L) + whenever(span.finishDate).thenReturn(spanEnd) + ext.registerHandOver(txn = txn, span = span) + ext.extendAppStart() + ext.finishTransaction(SentryLongDate(1_000_000_000L)) + verify(txn).finish(SpanStatus.OK, spanEnd) + } + @Test fun `getExtendedEndTime is null while the span is unfinished`() { val ext = extension(windowOpen = true) From 571416e7b9cecc2a2fe44e0205522a47a47e049c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 18:39:38 +0200 Subject: [PATCH 23/53] 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 595edaeede2..ae7705ed436 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 ff8c0562165050fbad39b1b5d427bace1f759e63 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:29:20 +0200 Subject: [PATCH 24/53] fix(extend-app-start): Guard extendAppStartListener with the lock The setter wrote the field without synchronization while extendAppStart() reads it under the lock, leaving no happens-before edge. Acquire the same lock in the setter, consistent with the rest of the class's mutable state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/sentry/android/core/AppStartExtension.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 540220b7ddc..457deb03c0b 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 @@ -45,7 +45,9 @@ public AppStartExtension(final @NotNull AppStartMetrics metrics) { } public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) { - this.extendAppStartListener = listener; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.extendAppStartListener = listener; + } } @Override From 1eb12d5e24262481731aaf50d871d012aedff60f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 13:42:38 +0200 Subject: [PATCH 25/53] 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 ae7705ed436..a9ee2cce901 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 ffc3320cd92..d3b8d5214b2 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 26/53] 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 a9ee2cce901..623401159b6 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 d3b8d5214b2..8f5ec5a190e 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 27/53] 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 623401159b6..b15d065f08a 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 28/53] 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 b15d065f08a..1e997ebe5c7 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 29/53] 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 1e997ebe5c7..630dac6b76c 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 30/53] 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 630dac6b76c..822b8a2cc36 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 8f5ec5a190e..9160a619981 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 31/53] 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 822b8a2cc36..8f6c56d67d6 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 9160a619981..ba59a247f32 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 32/53] 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 8f6c56d67d6..5c2dd25a1d4 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 e26ee97ce5299a795d6741551a0af7701fad05b4 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 14:19:29 +0200 Subject: [PATCH 33/53] style(extend-app-start): Trim verbose finishTransaction comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/sentry/android/core/AppStartExtension.java | 5 +---- 1 file changed, 1 insertion(+), 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 457deb03c0b..0cacae0db00 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 @@ -111,10 +111,7 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ITransaction transaction = extendedTransaction; if (transaction != null && !transaction.isFinished()) { - // If the extended span already finished after endTimestamp, end the transaction there so it - // contains the extended span and its duration matches the reported app start vital. When - // the - // span is still open, waitForChildren keeps the transaction open until it finishes. + // End at the extended span's finish if it ran past endTimestamp, so the txn covers it. final @Nullable ISpan span = extendedSpan; final @Nullable SentryDate spanEnd = span == null ? null : span.getFinishDate(); final @NotNull SentryDate end = From 0de722b2c20ad8caf40b4bc575d0ea7d0008e41d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 26 Jun 2026 14:21:24 +0200 Subject: [PATCH 34/53] 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 8f6c56d67d6..8ba8e23071f 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 cb14a28edd0..2bb57afca7e 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 83e2058480506ddd2ba232dd7f839de8fb6238ae Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 09:17:02 +0200 Subject: [PATCH 35/53] fix(extend-app-start): Read finish date in getExtendedAppStartSpan Finishing the extended span runs the event processor re-entrantly (via the waitForChildren transaction) before the span's isFinished() flag is set, while the finish timestamp is already in place. Reading isFinished() could therefore hand out a span that is already finishing. Switch to getFinishDate() == null, matching getExtendedEndTime(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/AppStartExtension.java | 5 ++++- .../sentry/android/core/AppStartExtensionTest.kt | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 0cacae0db00..b212689ec4c 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 @@ -94,7 +94,10 @@ public void finishExtendedAppStart() { public @NotNull ISpan getExtendedAppStartSpan() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; - if (span != null && !span.isFinished()) { + // Read the finish date, not isFinished(): finishing the extended span runs the event + // processor re-entrantly before the span's finished flag is set, but the finish timestamp is + // already in place. Matches getExtendedEndTime(). + if (span != null && span.getFinishDate() == null) { return span; } return NoOpSpan.getInstance(); 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 4241f03f40f..bea0b9769a2 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 @@ -238,6 +238,20 @@ class AppStartExtensionTest { val ext = extension(windowOpen = true) val span = mock() whenever(span.isFinished).thenReturn(true) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + } + + @Test + fun `getExtendedAppStartSpan returns NoOpSpan once the finish date is set even if still unfinished`() { + // Same waitForChildren reentrancy as getExtendedEndTime: the finish timestamp is set before the + // span's isFinished() flips, so the span must not be handed out for new children anymore. + val ext = extension(windowOpen = true) + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) ext.registerHandOver(span = span) ext.extendAppStart() assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) From 53cf7db7191320b1273fa5713168291eec7c24a5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 09:49:07 +0200 Subject: [PATCH 36/53] style(extend-app-start): Trim duplicated reentrancy comment in getExtendedAppStartSpan Replace the repeated reentrancy explanation with a cross-reference to getExtendedEndTime(), which holds the canonical version. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/sentry/android/core/AppStartExtension.java | 4 +--- 1 file changed, 1 insertion(+), 3 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 b212689ec4c..bdd4e5a6731 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 @@ -94,9 +94,7 @@ public void finishExtendedAppStart() { public @NotNull ISpan getExtendedAppStartSpan() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; - // Read the finish date, not isFinished(): finishing the extended span runs the event - // processor re-entrantly before the span's finished flag is set, but the finish timestamp is - // already in place. Matches getExtendedEndTime(). + // Read the finish date, not isFinished() — see getExtendedEndTime() for the reentrancy reason. if (span != null && span.getFinishDate() == null) { return span; } From 11d704d6c7ea216d97028cc64490a1f27678da8a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 09:50:15 +0200 Subject: [PATCH 37/53] style(extend-app-start): Keep trimmed reentrancy comment within line length Single-line form fits the 100-char limit so spotless leaves it unwrapped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/io/sentry/android/core/AppStartExtension.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bdd4e5a6731..56092e98a9e 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 @@ -94,7 +94,7 @@ public void finishExtendedAppStart() { public @NotNull ISpan getExtendedAppStartSpan() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; - // Read the finish date, not isFinished() — see getExtendedEndTime() for the reentrancy reason. + // Read the finish date, not isFinished(); see getExtendedEndTime() for why. if (span != null && span.getFinishDate() == null) { return span; } From b442c1e4c510b8dd471abe8f3d4f0335f47cf9d6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 09:50:50 +0200 Subject: [PATCH 38/53] 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 ba59a247f32..71720e4d2f7 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 dc76d9a44e266bc2a1bfba6f00b2f95d50a18989 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 10:59:35 +0200 Subject: [PATCH 39/53] style(extend-app-start): Reword getExtendedAppStartSpan reentrancy comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/io/sentry/android/core/AppStartExtension.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 56092e98a9e..977838851fb 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 @@ -94,7 +94,7 @@ public void finishExtendedAppStart() { public @NotNull ISpan getExtendedAppStartSpan() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; - // Read the finish date, not isFinished(); see getExtendedEndTime() for why. + // Mirrors getExtendedEndTime(): the finish date is set before isFinished() flips. if (span != null && span.getFinishDate() == null) { return span; } From 33a6aa074cf17dc4424fb00335c871403194b372 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 11:09:13 +0200 Subject: [PATCH 40/53] refactor(extend-app-start): Rename isAppStartWindowOpen to canExtendAppStart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate has a single consumer — the extend gate in AppStartExtension — so name it for that intent. Also drop the self-evident max-end comment in finishTransaction. Regenerated apiDump. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/sentry-android-core.api | 2 +- .../android/core/AppStartExtension.java | 3 +-- .../core/performance/AppStartMetrics.java | 8 ++++---- .../android/core/AppStartExtensionTest.kt | 2 +- .../core/performance/AppStartMetricsTest.kt | 20 +++++++++---------- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 9d064193ff1..8817ad07510 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -761,6 +761,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static final field staticLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V + public fun canExtendAppStart ()Z public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; @@ -783,7 +784,6 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isAppStartWindowOpen ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V public fun onActivityPaused (Landroid/app/Activity;)V 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 977838851fb..280231ccd40 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 @@ -60,7 +60,7 @@ public void extendAppStart() { .log(SentryLevel.WARNING, "App start is already being extended."); return; } - if (!metrics.isAppStartWindowOpen()) { + if (!metrics.canExtendAppStart()) { Sentry.getCurrentScopes() .getOptions() .getLogger() @@ -112,7 +112,6 @@ public void finishTransaction(final @NotNull SentryDate endTimestamp) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ITransaction transaction = extendedTransaction; if (transaction != null && !transaction.isFinished()) { - // End at the extended span's finish if it ran past endTimestamp, so the txn covers it. final @Nullable ISpan span = extendedSpan; final @Nullable SentryDate spanEnd = span == null ? null : span.getFinishDate(); final @NotNull SentryDate end = 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 6bad1a00187..ee5057def65 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 @@ -344,11 +344,11 @@ public long getClassLoadedUptimeMs() { } /** - * Whether the app start window is still open, i.e. an app start can be extended: measurements - * haven't been sent yet, no activity has been created, and the first frame hasn't been drawn. The - * foreground check is ignored so headless app starts (broadcast/service) can also be extended. + * Whether the app start can still be extended: measurements haven't been sent yet, no activity + * has been created, and the first frame hasn't been drawn. The foreground check is ignored so + * headless app starts (broadcast/service) can also be extended. */ - public boolean isAppStartWindowOpen() { + public boolean canExtendAppStart() { return shouldSendStartMeasurements(true) && activeActivitiesCounter.get() == 0 && !firstDrawDone.get(); 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 bea0b9769a2..0a1f0f91fbe 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 @@ -31,7 +31,7 @@ class AppStartExtensionTest { private val metrics = mock() private fun extension(windowOpen: Boolean = true): AppStartExtension { - whenever(metrics.isAppStartWindowOpen).thenReturn(windowOpen) + whenever(metrics.canExtendAppStart()).thenReturn(windowOpen) return AppStartExtension(metrics) } 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 530f84e33eb..1950e6ff57b 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 @@ -1027,36 +1027,36 @@ class AppStartMetricsTest { } @Test - fun `isAppStartWindowOpen is true on a fresh foreground start`() { - assertTrue(AppStartMetrics.getInstance().isAppStartWindowOpen) + fun `canExtendAppStart is true on a fresh foreground start`() { + assertTrue(AppStartMetrics.getInstance().canExtendAppStart()) } @Test - fun `isAppStartWindowOpen is true for a headless (non-foreground) start`() { + fun `canExtendAppStart is true for a headless (non-foreground) start`() { val metrics = AppStartMetrics.getInstance() metrics.isAppLaunchedInForeground = false - assertTrue(metrics.isAppStartWindowOpen) + assertTrue(metrics.canExtendAppStart()) } @Test - fun `isAppStartWindowOpen is false once an activity was created`() { + fun `canExtendAppStart is false once an activity was created`() { val metrics = AppStartMetrics.getInstance() metrics.onActivityCreated(mock(), null) - assertFalse(metrics.isAppStartWindowOpen) + assertFalse(metrics.canExtendAppStart()) } @Test - fun `isAppStartWindowOpen is false once the first frame was drawn`() { + fun `canExtendAppStart is false once the first frame was drawn`() { val metrics = AppStartMetrics.getInstance() metrics.onFirstFrameDrawn() - assertFalse(metrics.isAppStartWindowOpen) + assertFalse(metrics.canExtendAppStart()) } @Test - fun `isAppStartWindowOpen is false once start measurements were sent`() { + fun `canExtendAppStart is false once start measurements were sent`() { val metrics = AppStartMetrics.getInstance() metrics.onAppStartSpansSent() - assertFalse(metrics.isAppStartWindowOpen) + assertFalse(metrics.canExtendAppStart()) } /** Drives the singleton's eager extension into the active state via the listener path. */ From 210b4eefe33eff0cfb89c5b632ca23ee7587f196 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 11:21:11 +0200 Subject: [PATCH 41/53] test(extend-app-start): Drop redundant getExtendedAppStartSpan finished test After getExtendedAppStartSpan switched to getFinishDate(), the "after the span finished" case exercises the same branch as the reentrancy test (finishDate set -> NoOp); the isFinished stub was inert. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/AppStartExtensionTest.kt | 11 ----------- 1 file changed, 11 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 0a1f0f91fbe..3a19fa4f7b1 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 @@ -233,17 +233,6 @@ class AppStartExtensionTest { assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) } - @Test - fun `getExtendedAppStartSpan returns NoOpSpan after the span finished`() { - val ext = extension(windowOpen = true) - val span = mock() - whenever(span.isFinished).thenReturn(true) - whenever(span.finishDate).thenReturn(SentryNanotimeDate()) - ext.registerHandOver(span = span) - ext.extendAppStart() - assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) - } - @Test fun `getExtendedAppStartSpan returns NoOpSpan once the finish date is set even if still unfinished`() { // Same waitForChildren reentrancy as getExtendedEndTime: the finish timestamp is set before the From 824c12f81a682bedc5bebccefeeb5c5c1f48fa84 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 11:32:06 +0200 Subject: [PATCH 42/53] 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 71720e4d2f7..c83e8219453 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 1a042023b08..1dc00f09f95 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 bdadd2be50540608485f228549af44bd458aa6ea Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 11:37:15 +0200 Subject: [PATCH 43/53] feat(extend-app-start): Add setData and isExtended to AppStartExtension Move these component accessors into the component-extraction PR (they were introduced later in the wiring PR). Keeps the full AppStartExtension surface and its unit tests together here; the wiring PR only consumes them. Inert on its own. Regenerated apiDump. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/sentry-android-core.api | 2 ++ .../android/core/AppStartExtension.java | 23 +++++++++++++++++++ .../android/core/AppStartExtensionTest.kt | 12 ++++++++++ 3 files changed, 37 insertions(+) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8817ad07510..424a283c7d2 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/AppStartExtension.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java index 280231ccd40..7b10a2743cd 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 @@ -80,6 +80,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()) { @@ -108,6 +121,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/test/java/io/sentry/android/core/AppStartExtensionTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt index 3a19fa4f7b1..aa93fb36783 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 @@ -135,6 +135,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 c861ba9dc989d1d2f1c5c5e3bf391ef225e5fb9f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 13:42:31 +0200 Subject: [PATCH 44/53] 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 8ba8e23071f..738945a28e5 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 2bb57afca7e..d758470baf7 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 b11b0fa0adc335041d59b77ecd982fe920b4ada5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 29 Jun 2026 14:04:49 +0200 Subject: [PATCH 45/53] fix(extend-app-start): Make shouldSendStartMeasurements volatile It is written without a lock in onAppStartSpansSent() and read across threads in canExtendAppStart() (via extendAppStart()), with no happens-before edge. volatile guarantees visibility, matching the AtomicInteger/AtomicBoolean siblings in the same check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/performance/AppStartMetrics.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ee5057def65..b4cf4beddc9 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 @@ -87,7 +87,7 @@ public enum AppStartType { private @Nullable IContinuousProfiler appStartContinuousProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; private boolean isCallbackRegistered = false; - private boolean shouldSendStartMeasurements = true; + private volatile boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); From 04c2835f625f276e06f319c13bca493052217b53 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Jul 2026 11:27:35 +0200 Subject: [PATCH 46/53] docs(extend-app-start): Explain why AppStartExtension holds span and transaction Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/AppStartExtension.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 7b10a2743cd..e927b0559e4 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 @@ -37,6 +37,20 @@ public interface ExtendAppStartListener { private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private @Nullable ExtendAppStartListener extendAppStartListener; + // We hold onto both the span and its transaction because they mean different things and finish + // at different times: + // + // - extendedSpan is what the app developer works with: they get it from + // getExtendedAppStartSpan(), add their own child spans to it, and finish it by calling + // finishExtendedAppStart(). Its end time is what extends the app start measurement. + // + // - extendedTransaction is the standalone "app.start" transaction that actually gets sent to + // Sentry. It carries the span and the screen name. The SDK asks it to finish at the first + // frame (or headless end), but because it uses waitForChildren it stays open until the span + // finishes (or the deadline is hit). + // + // A span doesn't expose its transaction, and pulling the span back out of the transaction would + // be fragile, so we just keep a reference to each. private @Nullable ISpan extendedSpan; private @Nullable ITransaction extendedTransaction; From b8779ca96ee9dc9cf738f20062dde0e2e86dbd0f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Jul 2026 12:05:03 +0200 Subject: [PATCH 47/53] feat(extend-app-start): Return null from getExtendedAppStartSpan when inactive Mirrors Sentry.getSpan(): the extender reports null when there is no active extended span instead of a NoOpSpan, so callers can tell there is no span running. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/src/main/java/io/sentry/IAppStartExtender.java | 8 ++++---- sentry/src/main/java/io/sentry/NoOpAppStartExtender.java | 5 +++-- .../src/test/java/io/sentry/NoOpAppStartExtenderTest.kt | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/IAppStartExtender.java b/sentry/src/main/java/io/sentry/IAppStartExtender.java index 1cd5b59f060..079fe272d08 100644 --- a/sentry/src/main/java/io/sentry/IAppStartExtender.java +++ b/sentry/src/main/java/io/sentry/IAppStartExtender.java @@ -1,7 +1,7 @@ package io.sentry; import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Bridges the {@code Sentry.extendAppStart()} / {@code Sentry.finishExtendedAppStart()} / {@code @@ -26,9 +26,9 @@ public interface IAppStartExtender { void finishExtendedAppStart(); /** - * Returns the active extended app start span to attach child spans to, or a no-op span when no - * extension is active. + * Returns the active extended app start span to attach child spans to, or {@code null} when the + * app start is not currently being extended. */ - @NotNull + @Nullable ISpan getExtendedAppStartSpan(); } diff --git a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java index b229fdfc34a..b5fe92ba295 100644 --- a/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java +++ b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class NoOpAppStartExtender implements IAppStartExtender { @@ -21,7 +22,7 @@ public void extendAppStart() {} public void finishExtendedAppStart() {} @Override - public @NotNull ISpan getExtendedAppStartSpan() { - return NoOpSpan.getInstance(); + public @Nullable ISpan getExtendedAppStartSpan() { + return null; } } diff --git a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt index 2ea276de1a2..db95270ea8f 100644 --- a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt +++ b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt @@ -1,7 +1,7 @@ package io.sentry import kotlin.test.Test -import kotlin.test.assertSame +import kotlin.test.assertNull class NoOpAppStartExtenderTest { private val extender = NoOpAppStartExtender.getInstance() @@ -11,7 +11,7 @@ class NoOpAppStartExtenderTest { @Test fun `finishExtendedAppStart does not throw`() = extender.finishExtendedAppStart() @Test - fun `getExtendedAppStartSpan returns NoOpSpan`() { - assertSame(NoOpSpan.getInstance(), extender.extendedAppStartSpan) + fun `getExtendedAppStartSpan returns null`() { + assertNull(extender.extendedAppStartSpan) } } From 8420f74c524160030584bf18f4617604d26e0e47 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Jul 2026 12:06:52 +0200 Subject: [PATCH 48/53] feat(extend-app-start): Return null from AppStartExtension.getExtendedAppStartSpan when inactive Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/sentry/android/core/AppStartExtension.java | 5 ++--- .../io/sentry/android/core/AppStartExtensionTest.kt | 13 ++++++------- 2 files changed, 8 insertions(+), 10 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 e927b0559e4..3583474cfa7 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 @@ -4,7 +4,6 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; -import io.sentry.NoOpSpan; import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryLevel; @@ -118,14 +117,14 @@ public void finishExtendedAppStart() { } @Override - public @NotNull ISpan getExtendedAppStartSpan() { + public @Nullable ISpan getExtendedAppStartSpan() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable ISpan span = extendedSpan; // Mirrors getExtendedEndTime(): the finish date is set before isFinished() flips. if (span != null && span.getFinishDate() == null) { return span; } - return NoOpSpan.getInstance(); + return null; } } 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 aa93fb36783..7fbbca4a3a5 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 @@ -4,7 +4,6 @@ import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ISpan import io.sentry.ITransaction -import io.sentry.NoOpSpan import io.sentry.SentryLongDate import io.sentry.SentryNanotimeDate import io.sentry.SpanStatus @@ -72,7 +71,7 @@ class AppStartExtensionTest { fun `extendAppStart is inert when no listener is registered`() { val ext = extension(windowOpen = true) ext.extendAppStart() - assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + assertNull(ext.extendedAppStartSpan) assertFalse(ext.isActive) } @@ -92,8 +91,8 @@ class AppStartExtensionTest { } @Test - fun `getExtendedAppStartSpan returns NoOpSpan when no extension is active`() { - assertSame(NoOpSpan.getInstance(), extension().extendedAppStartSpan) + fun `getExtendedAppStartSpan returns null when no extension is active`() { + assertNull(extension().extendedAppStartSpan) } @Test @@ -242,11 +241,11 @@ class AppStartExtensionTest { assertTrue(ext.isActive) ext.clear() assertFalse(ext.isActive) - assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + assertNull(ext.extendedAppStartSpan) } @Test - fun `getExtendedAppStartSpan returns NoOpSpan once the finish date is set even if still unfinished`() { + fun `getExtendedAppStartSpan returns null once the finish date is set even if still unfinished`() { // Same waitForChildren reentrancy as getExtendedEndTime: the finish timestamp is set before the // span's isFinished() flips, so the span must not be handed out for new children anymore. val ext = extension(windowOpen = true) @@ -255,6 +254,6 @@ class AppStartExtensionTest { whenever(span.finishDate).thenReturn(SentryNanotimeDate()) ext.registerHandOver(span = span) ext.extendAppStart() - assertSame(NoOpSpan.getInstance(), ext.extendedAppStartSpan) + assertNull(ext.extendedAppStartSpan) } } From 3d4d55ae6f2775f11ab2375a030dd8ca73c6b822 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 2 Jul 2026 12:12:05 +0200 Subject: [PATCH 49/53] 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 c83e8219453..e92b9b25b8a 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 50/53] 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 738945a28e5..80936a88be9 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 51/53] 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 80936a88be9..f416df6a988 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 e92b9b25b8a..d198c8d975e 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 52/53] 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 b4cf4beddc9..c7ddd09e78f 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 1950e6ff57b..edf72655740 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) + } } From bcb53b013d297ece96989f709752f5de5027cae7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 6 Jul 2026 11:25:39 +0200 Subject: [PATCH 53/53] test(extend-app-start): Remove NoOpAppStartExtenderTest per PR review Co-Authored-By: Claude Opus 4.8 --- .../java/io/sentry/NoOpAppStartExtenderTest.kt | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt diff --git a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt b/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt deleted file mode 100644 index db95270ea8f..00000000000 --- a/sentry/src/test/java/io/sentry/NoOpAppStartExtenderTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertNull - -class NoOpAppStartExtenderTest { - private val extender = NoOpAppStartExtender.getInstance() - - @Test fun `extendAppStart does not throw`() = extender.extendAppStart() - - @Test fun `finishExtendedAppStart does not throw`() = extender.finishExtendedAppStart() - - @Test - fun `getExtendedAppStartSpan returns null`() { - assertNull(extender.extendedAppStartSpan) - } -}