From 7dfe617e1ac1577284821b4537d41845a70dc525 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 23 Jun 2026 14:13:22 +0200 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 05a9df2f8b3fd747d98ed472665be2adb8307632 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 14:52:13 +0200 Subject: [PATCH 09/13] 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 10/13] 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 11/13] 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 d2c16a255b5bace2926ba36351c4c40d6ee7ddf9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 25 Jun 2026 16:51:50 +0200 Subject: [PATCH 12/13] 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 13/13] 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)