diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 58325d08b5..9d064193ff 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,6 +184,28 @@ 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 clear ()V + public fun extendAppStart ()V + public fun finishExtendedAppStart ()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 setExtendAppStartListener (Lio/sentry/android/core/AppStartExtension$ExtendAppStartListener;)V +} + +public abstract interface class io/sentry/android/core/AppStartExtension$ExtendAppStartListener { + 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 { public fun addAppStateListener (Lio/sentry/android/core/AppState$AppStateListener;)V public fun close ()V @@ -745,6 +767,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 +783,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 5704cf7d7d..9cc5cb3df0 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 0000000000..540220b7dd --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -0,0 +1,149 @@ +package io.sentry.android.core; + +import io.sentry.IAppStartExtender; +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; + +@ApiStatus.Internal +public final class AppStartExtension implements IAppStartExtender { + + 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; + } + } + + public interface ExtendAppStartListener { + @Nullable + ExtendedAppStart 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) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "App start is already being extended."); + return; + } + if (!metrics.isAppStartWindowOpen()) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Cannot extend app start: the app start window has already passed."); + return; + } + final @Nullable ExtendAppStartListener listener = extendAppStartListener; + if (listener != null) { + final @Nullable ExtendedAppStart extended = listener.onExtendAppStartRequested(); + if (extended != null) { + this.extendedTransaction = extended.transaction; + this.extendedSpan = extended.span; + } + } + } + } + + @Override + public void finishExtendedAppStart() { + 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(); + } + } + + public boolean isActive() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedTransaction != null && !extendedTransaction.isFinished(); + } + } + + 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. + 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); + } + } + } + + public @Nullable SentryDate getExtendedEndTime() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ISpan span = extendedSpan; + if (span == null) { + return null; + } + // A deadline timeout would report an artificially inflated duration; suppress the vital + // instead. + 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(); + } + } + + 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 36cae8686c..6bad1a0018 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,7 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); + appStartExtension.clear(); } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { @@ -336,6 +339,21 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + 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(); + } + @TestOnly void setFirstIdle(final long firstIdle) { this.firstIdle = firstIdle; @@ -377,6 +395,7 @@ public void clear() { appStartBaggageHeader = null; appStartEndTime = null; cachedStartInfo = null; + 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 new file mode 100644 index 0000000000..4241f03f40 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -0,0 +1,245 @@ +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.SentryLongDate +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 { AppStartExtension.ExtendedAppStart(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() + null + } + 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() + null + } + 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() + AppStartExtension.ExtendedAppStart(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 `finishExtendedAppStart finishes the extended span`() { + val ext = extension(windowOpen = true) + val (_, span) = ext.registerHandOver() + ext.extendAppStart() + ext.finishExtendedAppStart() + verify(span).finish(SpanStatus.OK) + } + + @Test + 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.finishExtendedAppStart() + 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 `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) + 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 `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) + ext.registerHandOver() + ext.extendAppStart() + assertTrue(ext.isActive) + ext.clear() + 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 2737785349..530f84e33e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -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 @@ -1024,4 +1025,64 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + + @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 + 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) + } + + /** 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() + activateExtension(metrics) + metrics.clear() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } + + @Test + fun `onAppStartSpansSent resets the extension state`() { + val metrics = AppStartMetrics.getInstance() + activateExtension(metrics) + metrics.onAppStartSpansSent() + assertFalse(metrics.appStartExtension.isActive) + metrics.appStartExtension.setExtendAppStartListener(null) + } }