diff --git a/CHANGELOG.md b/CHANGELOG.md index 107b916808..24df41730a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### Features +- Add `Sentry.extendAppStart()`, `Sentry.finishExtendedAppStart()`, and `Sentry.getExtendedAppStartSpan()` to extend the app start measurement past the first frame for extra launch-time work on Android ([#5604](https://github.com/getsentry/sentry-java/pull/5604)) + - Requires standalone app start tracing (`options.isEnableStandaloneAppStartTracing`). Call `extendAppStart()` in `Application.onCreate` after SDK init and `finishExtendedAppStart()` when done: + + ```kotlin + Sentry.extendAppStart() + + // Optionally, retrieve the extended app start span to attach your own child spans + val child = Sentry.getExtendedAppStartSpan()?.startChild("preload", "Preload resources") + // ... extra launch-time work ... + child?.finish() + + Sentry.finishExtendedAppStart() + ``` - Add `trace_metric_byte` data category and record byte-level client reports when trace metrics are discarded ([#5626](https://github.com/getsentry/sentry-java/pull/5626)) - Support the `io.sentry.tombstone.report-historical` manifest option to enable historical tombstone reporting via `AndroidManifest.xml` `` ([#5683](https://github.com/getsentry/sentry-java/pull/5683)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 58325d08b5..424a283c7d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,6 +184,30 @@ 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 isExtended ()Z + public fun setData (Ljava/lang/String;Ljava/lang/Object;)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 ()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 @@ -739,12 +763,14 @@ 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; 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; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index d70ff83717..f416df6a98 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -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"; + 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,9 @@ 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); + metrics.getAppStartExtension().setExtendAppStartListener(this::onExtendAppStartRequested); addIntegrationToSdkVersion("StandaloneAppStart"); } @@ -154,7 +158,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 +265,23 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); + // 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 isExtensionActive = + AppStartMetrics.getInstance().getAppStartExtension().isActive(); + final @Nullable SentryId storedAppStartTraceId = AppStartMetrics.getInstance().getAppStartTraceId(); - final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + final boolean isFollowingHeadlessAppStart = + !isExtensionActive && (storedAppStartTraceId != null); final boolean isAppStart = !(firstActivityCreated || appStartTime == null || coldStart == null); - // Foreground starts create app.start first; ui.load then shares its trace. final boolean createStandaloneAppStart = isAppStart && options.isEnableStandaloneAppStartTracing() - && !isFollowingHeadlessAppStart; + && !isFollowingHeadlessAppStart + && !isExtensionActive; if (createStandaloneAppStart) { final TransactionOptions appStartTransactionOptions = new TransactionOptions(); @@ -300,8 +312,8 @@ 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 (isExtensionActive + || (isFollowingHeadlessAppStart && isWithinAppStartContinuationWindow(ttidStartTime))) { continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); } else { @@ -309,6 +321,14 @@ && isWithinAppStartContinuationWindow(ttidStartTime)) { continueBaggage = null; } + 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() + .getAppStartExtension() + .setData(APP_START_SCREEN_DATA, activityName); + } + final @Nullable TransactionContext continuedContext = continueSentryTrace == null ? null @@ -328,8 +348,8 @@ && isWithinAppStartContinuationWindow(ttidStartTime)) { transactionOptions); } - if (isFollowingHeadlessAppStart) { - // Consume the stored headless app-start trace so it isn't reused by another activity. + 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); AppStartMetrics.getInstance().setAppStartBaggageHeader(null); @@ -967,6 +987,9 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) { if (appStartTransaction != null && !appStartTransaction.isFinished()) { appStartTransaction.finish(SpanStatus.OK, appStartEndTime); } + // 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); } } @@ -994,17 +1017,60 @@ private void onHeadlessAppStart() { return; } + // 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(); + if (extension.isActive()) { + extension.finishTransaction(endTime); + return; + } + if (!metrics.shouldSendStartMeasurements(true)) { + return; + } + + final @NotNull ITransaction transaction = + createStandaloneAppStartTransaction(startTime, null, false); + 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); + // 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 = 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 +1082,56 @@ 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(); + + 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 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); + + 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/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..3583474cfa --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartExtension.java @@ -0,0 +1,184 @@ +package io.sentry.android.core; + +import io.sentry.IAppStartExtender; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +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; + // 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; + + public AppStartExtension(final @NotNull AppStartMetrics metrics) { + this.metrics = metrics; + } + + public void setExtendAppStartListener(final @Nullable ExtendAppStartListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + 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.canExtendAppStart()) { + 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; + } + } + } + } + + /** + * 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()) { + final @Nullable ISpan span = extendedSpan; + if (span != null && !span.isFinished()) { + span.finish(SpanStatus.OK); + } + } + } + + @Override + 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 null; + } + } + + public boolean isActive() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return extendedTransaction != null && !extendedTransaction.isFinished(); + } + } + + /** + * 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; + if (transaction != null && !transaction.isFinished()) { + 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/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 0b50b5080f..d758470baf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -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,49 @@ 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; + final boolean shouldAttachAppStartSpans; + final boolean reportAppStartMeasurement; + final @NotNull AppStartExtension extension = appStartMetrics.getAppStartExtension(); + if (extension.isExtended()) { + final @Nullable SentryDate extendedEnd = extension.getExtendedEndTime(); + if (extendedEnd != null && appStartTimeSpan.hasStarted()) { + // 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); + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } else { + // Deadline (null) or no valid start: attach the spans but suppress the measurement so + // it isn't inflated. + appStartUpDurationMs = 0; + shouldAttachAppStartSpans = appStartTimeSpan.hasStarted(); + reportAppStartMeasurement = false; + } + } else { + appStartUpDurationMs = naturalDurationMs; + shouldAttachAppStartSpans = appStartUpDurationMs != 0; + reportAppStartMeasurement = shouldAttachAppStartSpans; + } - // 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 (shouldAttachAppStartSpans) { + if (reportAppStartMeasurement) { + 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/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 828e103e8b..0f4b646856 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 @@ -21,6 +21,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; @@ -87,7 +88,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); @@ -99,6 +100,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) { @@ -282,6 +284,7 @@ public void onAppStartSpansSent() { shouldSendStartMeasurements = false; contentProviderOnCreates.clear(); activityLifecycles.clear(); + appStartExtension.clear(); } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { @@ -337,6 +340,21 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + public @NotNull AppStartExtension getAppStartExtension() { + return appStartExtension; + } + + /** + * 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 canExtendAppStart() { + return shouldSendStartMeasurements(true) + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get(); + } + @TestOnly void setFirstIdle(final long firstIdle) { this.firstIdle = firstIdle; @@ -378,6 +396,7 @@ public void clear() { appStartBaggageHeader = null; appStartEndTime = null; cachedStartInfo = null; + appStartExtension.clear(); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -624,8 +643,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/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 8b842a0cfa..d198c8d975 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -323,6 +323,291 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartTransaction.getData("app.vitals.start.reason")) } + @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() + 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) + assertNotNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + } + + @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 + } + assertEquals(1, appStartTransactions.size) + assertEquals("Activity", appStartTransactions.single().getData("app.vitals.start.screen")) + val uiLoadTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.UI_LOAD_OP + } + assertEquals( + appStartTransactions.single().spanContext.traceId, + uiLoadTransaction.spanContext.traceId, + ) + } + + @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) + + 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) + + 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 = + 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 + } + + 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 + } + ) + assertFalse(transaction.isFinished) + + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + 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() + + 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 = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + AppStartMetrics.getInstance().appStartExtension.finishExtendedAppStart() + AppStartMetrics.getInstance().onAppStartSpansSent() + val transactionsBefore = fixture.createdTransactions.size + + driveHeadlessAppStart() + + 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 } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + AppStartMetrics.getInstance().appStartExtension.extendAppStart() + + assertFalse(AppStartMetrics.getInstance().appStartExtension.isActive) + assertNull(AppStartMetrics.getInstance().appStartExtension.extendedAppStartSpan) + 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) + + sut.onActivityDestroyed(activity) + assertTrue(AppStartMetrics.getInstance().appStartExtension.isActive) + } + @Test @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) fun `Headless standalone app start transaction carries app start reason when available`() { @@ -2384,3 +2669,5 @@ class ActivityLifecycleIntegrationTest { } } } + +private open class SecondAppStartActivity : Activity() 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..7fbbca4a3a --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStartExtensionTest.kt @@ -0,0 +1,259 @@ +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.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.canExtendAppStart()).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() + assertNull(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 null when no extension is active`() { + assertNull(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 `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) + 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) + assertNull(ext.extendedAppStartSpan) + } + + @Test + 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) + val span = mock() + whenever(span.isFinished).thenReturn(false) + whenever(span.finishDate).thenReturn(SentryNanotimeDate()) + ext.registerHandOver(span = span) + ext.extendAppStart() + assertNull(ext.extendedAppStartSpan) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 173b4e3d99..1dc00f09f9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -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,74 @@ class PerformanceAndroidEventProcessorTest { assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) } + 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 + 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 + 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)) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) 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..edf7265574 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -11,8 +11,10 @@ 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 import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions @@ -1024,4 +1026,101 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + + @Test + fun `canExtendAppStart is true on a fresh foreground start`() { + assertTrue(AppStartMetrics.getInstance().canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is true for a headless (non-foreground) start`() { + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = false + assertTrue(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once an activity was created`() { + val metrics = AppStartMetrics.getInstance() + metrics.onActivityCreated(mock(), null) + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once the first frame was drawn`() { + val metrics = AppStartMetrics.getInstance() + metrics.onFirstFrameDrawn() + assertFalse(metrics.canExtendAppStart()) + } + + @Test + fun `canExtendAppStart is false once start measurements were sent`() { + val metrics = AppStartMetrics.getInstance() + metrics.onAppStartSpansSent() + assertFalse(metrics.canExtendAppStart()) + } + + /** 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) + } + + @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) + } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 6df8ede588..d9d142cfc6 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -2,6 +2,7 @@ import android.app.Application; import android.os.StrictMode; +import io.sentry.ISpan; import io.sentry.Sentry; import io.sentry.samples.android.sqlite.SampleDatabases; @@ -19,6 +20,8 @@ public void onCreate() { strictMode(); super.onCreate(); + extendAppStartExample(); + SampleDatabases.INSTANCE.warmUp(this); // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. @@ -36,6 +39,35 @@ public void onCreate() { // }); } + // Example of extending the app start: launch-time work done here (after the SDK auto-inits) is + // included in the app start measurement. Requires standalone app start tracing + // (io.sentry.standalone-app-start-tracing.enable in the manifest). The artificial delays stand in + // for real launch work, e.g. loading remote config or feature flags before the first screen. + private void extendAppStartExample() { + Sentry.extendAppStart(); + + final ISpan extendedSpan = Sentry.getExtendedAppStartSpan(); + if (extendedSpan != null) { + final ISpan configSpan = extendedSpan.startChild("remote_config", "Load remote config"); + artificialDelay(200); + configSpan.finish(); + + final ISpan flagsSpan = extendedSpan.startChild("feature_flags", "Fetch feature flags"); + artificialDelay(100); + flagsSpan.finish(); + } + + Sentry.finishExtendedAppStart(); + } + + private static void artificialDelay(final long millis) { + try { + Thread.sleep(millis); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + private void strictMode() { // https://developer.android.com/reference/android/os/StrictMode // StrictMode is a developer tool which detects things you might be doing by accident and diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 09ede4804a..c0b6fec780 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -797,6 +797,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 finishExtendedAppStart ()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; @@ -1542,6 +1548,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 finishExtendedAppStart ()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; @@ -2755,7 +2768,9 @@ public final class io/sentry/Sentry { public static fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; public static fun distribution ()Lio/sentry/IDistributionApi; public static fun endSession ()V + public static fun extendAppStart ()V public static fun feedback ()Lio/sentry/IFeedbackApi; + public static fun finishExtendedAppStart ()V public static fun flush (J)V public static fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; public static fun forkedRootScopes (Ljava/lang/String;)Lio/sentry/IScopes; @@ -2764,6 +2779,7 @@ public final class io/sentry/Sentry { public static fun getCurrentHub ()Lio/sentry/IHub; public static fun getCurrentScopes ()Lio/sentry/IScopes; public static fun getCurrentScopes (Z)Lio/sentry/IScopes; + public static fun getExtendedAppStartSpan ()Lio/sentry/ISpan; public static fun getGlobalScope ()Lio/sentry/IScope; public static fun getLastEventId ()Lio/sentry/protocol/SentryId; public static fun getSpan ()Lio/sentry/ISpan; @@ -3602,6 +3618,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; @@ -3748,6 +3765,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 0000000000..079fe272d0 --- /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.Nullable; + +/** + * 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. + */ +@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 finishExtendedAppStart(); + + /** + * Returns the active extended app start span to attach child spans to, or {@code null} when the + * app start is not currently being extended. + */ + @Nullable + 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 0000000000..b5fe92ba29 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpAppStartExtender.java @@ -0,0 +1,28 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +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 finishExtendedAppStart() {} + + @Override + public @Nullable ISpan getExtendedAppStartSpan() { + return null; + } +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f5397f191a..275e2a3c61 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1276,6 +1276,38 @@ public static void reportFullyDisplayed() { getCurrentScopes().reportFullyDisplayed(); } + /** + * Begins extending the app start so launch-time work done after {@code Application.onCreate} + * (e.g. loading remote config before the first screen) is included in the app start measurement. + * + *

Intended to be called from {@code Application.onCreate} right after {@code + * SentryAndroid.init}. Only effective on Android with standalone app start tracing enabled; + * otherwise it is a no-op. Also no-ops if the app start already finished, none is in progress, or + * it was already extended (first call wins). Call {@link #finishExtendedAppStart()} once the + * extra work is done; if it is never called, the app start transaction is finished by its + * deadline and no extended measurement is reported. + */ + public static void extendAppStart() { + getCurrentScopes().getOptions().getAppStartExtender().extendAppStart(); + } + + /** + * Finishes the app start extension started by {@link #extendAppStart()}, allowing the app start + * transaction to complete. No-ops if the app start was not extended or this was already called. + */ + public static void finishExtendedAppStart() { + getCurrentScopes().getOptions().getAppStartExtender().finishExtendedAppStart(); + } + + /** + * Returns the active extended app start span, to attach child spans for the launch-time work + * being measured, or {@code null} when no extension is active (e.g. {@link #extendAppStart()} was + * not called, the app start window already passed, or standalone app start tracing is disabled). + */ + public static @Nullable ISpan getExtendedAppStartSpan() { + return getCurrentScopes().getOptions().getAppStartExtender().getExtendedAppStartSpan(); + } + /** * Configuration options callback * diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 8c99500101..3c55f5e1cf 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(); @@ -2652,6 +2655,22 @@ 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 @Nullable IAppStartExtender appStartExtender) { + this.appStartExtender = + appStartExtender != null ? appStartExtender : NoOpAppStartExtender.getInstance(); + } + /** * 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 0000000000..db95270ea8 --- /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.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) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 75a24dd68d..9402c6fee9 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, it's returned as well`() { + val options = SentryOptions() + val customExtender = + object : IAppStartExtender { + override fun extendAppStart() = Unit + + override fun finishExtendedAppStart() = Unit + + override fun getExtendedAppStartSpan(): ISpan = NoOpSpan.getInstance() + } + options.setAppStartExtender(customExtender) + assertEquals(customExtender, options.appStartExtender) + } + @Test fun `when options are initialized, connectionStatusProvider is not null and default to noop`() { assertNotNull(SentryOptions().connectionStatusProvider) diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 4a122f1142..9da52c39b5 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1768,4 +1768,53 @@ class SentryTest { val scopes = Sentry.getCurrentScopes() assertFalse(scopes.isNoOp) } + + // region app start extension + + private fun initWithExtender(extender: IAppStartExtender) { + initForTest { + it.dsn = dsn + it.setAppStartExtender(extender) + } + } + + @Test + fun `extendAppStart delegates to the app start extender`() { + val extender = mock() + initWithExtender(extender) + + Sentry.extendAppStart() + + verify(extender).extendAppStart() + } + + @Test + fun `finishExtendedAppStart delegates to the app start extender`() { + val extender = mock() + initWithExtender(extender) + + Sentry.finishExtendedAppStart() + + verify(extender).finishExtendedAppStart() + } + + @Test + fun `getExtendedAppStartSpan delegates to the app start extender`() { + val span = mock() + val extender = mock() + whenever(extender.extendedAppStartSpan).thenReturn(span) + initWithExtender(extender) + + assertSame(span, Sentry.getExtendedAppStartSpan()) + } + + @Test + fun `app start extension api is a no-op when the SDK is disabled`() { + // beforeTest called Sentry.close(), so the current scopes are NoOp. + Sentry.extendAppStart() + Sentry.finishExtendedAppStart() + assertNull(Sentry.getExtendedAppStartSpan()) + } + + // endregion }