From a1d0b5fad9f62373d5530a269802c4b9b6bc13c2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 29 May 2026 20:16:09 +0200 Subject: [PATCH 1/2] feat(android): Add standalone app start tracing Squashed representation of PR #5342. Adds standalone Android app-start transaction support behind the opt-in enableStandaloneAppStartTracing option and the io.sentry.standalone-app-start-tracing.enable manifest key, including the non-activity (headless) startup path. Legacy flag-off behavior is preserved: activity app-start data remains nested under the ui.load transaction. Refs #5342 --- CHANGELOG.md | 5 + .../api/sentry-android-core.api | 11 + .../core/ActivityLifecycleIntegration.java | 195 ++++++++-- .../android/core/ManifestMetadataReader.java | 10 + .../PerformanceAndroidEventProcessor.java | 56 ++- .../android/core/SentryAndroidOptions.java | 49 +++ .../core/performance/AppStartMetrics.java | 167 ++++++-- .../core/ActivityLifecycleIntegrationTest.kt | 362 +++++++++++++++++- .../core/ManifestMetadataReaderTest.kt | 30 ++ .../PerformanceAndroidEventProcessorTest.kt | 145 ++++++- .../android/core/SentryAndroidOptionsTest.kt | 6 + .../core/SentryShadowActivityManager.kt | 13 + .../android/core/SentryShadowProcess.kt | 16 +- .../core/performance/AppStartMetricsTest.kt | 153 +++++++- .../performance/AppStartMetricsTestApi35.kt | 151 ++++++++ .../src/main/AndroidManifest.xml | 13 + .../android/TestBroadcastReceiver.java | 30 ++ sentry/api/sentry.api | 1 + .../java/io/sentry/TransactionContext.java | 19 + .../java/io/sentry/TransactionContextTest.kt | 32 ++ 20 files changed, 1368 insertions(+), 96 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda85d8b99..b18925d5615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Features +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `SentryAndroidOptions.setEnableStandaloneAppStartTracing(true)` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) ## 8.41.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 3d4512fc2b4..20eaf1e5344 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -391,6 +391,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z @@ -421,6 +422,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V @@ -742,7 +744,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -766,9 +770,12 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z + public fun shouldSendStartMeasurements (Z)Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -779,6 +786,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { + public abstract fun onHeadlessAppStart ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I 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 9d748e5a27a..d6e5ae85626 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 @@ -33,6 +33,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -55,12 +56,15 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; 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 long TTFD_TIMEOUT_MILLIS = 25000; private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,6 +81,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -124,6 +129,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +145,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setHeadlessAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +250,68 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + // A non-null trace ID means a standalone app-start txn was already emitted. + final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + + final ITransaction transaction; + if (storedAppStartTraceId != null) { + transaction = + scopes.startTransaction( + new TransactionContext( + storedAppStartTraceId, + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + AppStartMetrics.getInstance().setAppStartTraceId(null); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (options.isEnableStandaloneAppStartTracing() && !isFollowingHeadlessAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + setSpanOrigin(appStartTransactionOptions); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + transaction.getSpanContext().getTraceId(), + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + + finishAppStartSpan(); + } else if (!options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -440,8 +486,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,8 +524,7 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } @@ -559,6 +603,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +622,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -637,22 +685,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null && ttidSpan != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +713,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +726,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +839,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +858,63 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } + } + } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + setSpanOrigin(txnOptions); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + + transaction.finish(SpanStatus.OK, endTime); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index b52634774d6..fd9bcc939cf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -107,6 +107,9 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; @@ -499,6 +502,13 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); 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 f7b51cce620..0b50b5080f4 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 @@ -1,7 +1,9 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -84,9 +86,21 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -216,9 +230,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +257,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +274,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +289,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +302,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +312,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 8fe702aad50..bd11b65dce9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -240,6 +240,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -663,6 +665,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

The SDK reports app start through these paths: + * + *

    + *
  • With an Activity: the SDK sends an {@code App Start} transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
      + *
    • On Android API 35 and newer, the SDK can use {@code ApplicationStartInfo} to + * classify cold versus warm starts and find the {@code Application.onCreate} end + * time. + *
    • Before Android API 35, headless launches are treated as cold once {@code + * Application.onCreate} finishes without an Activity. The end time falls back to the + * best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; 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 746805fcfdc..d1eb30c8e3a 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 @@ -10,7 +10,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.MessageQueue; +import android.os.Process; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -25,6 +25,7 @@ import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,11 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + private @Nullable SentryId appStartTraceId; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -161,6 +171,25 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + /** Trace ID from a headless app start transaction, to be reused by a later activity. */ + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + /** * Provides all collected content provider onCreate time spans * @@ -188,14 +217,30 @@ public void onAppStartSpansSent() { activityLifecycles.clear(); } + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements + && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + } + public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -258,6 +303,11 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -346,6 +396,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { activityManager.getHistoricalProcessStartReasons(1); if (!historicalProcessStartReasons.isEmpty()) { final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { appStartType = AppStartType.COLD; @@ -357,41 +408,53 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( - new MessageQueue.IdleHandler() { - @Override - public boolean queueIdle() { - firstIdle = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - return false; - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; }); - } else if (appStartType == AppStartType.UNKNOWN) { - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. + } else { final Handler handler = new Handler(Looper.getMainLooper()); handler.post( - new Runnable() { - @Override - public void run() { - // not technically correct, but close enough for pre-M - firstIdle = SystemClock.uptimeMillis(); - handler.post(() -> checkCreateTimeOnMain()); - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); }); } } - private void checkCreateTimeOnMain() { - // if no activity has ever been created, app was launched in background + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground.setValue(false); + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); @@ -401,6 +464,62 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateNanos != null && onCreateNanos > 0) { + // ApplicationStartInfo documents "clock monotonic" timestamps in nanoseconds, + // without specifying the clock base. Compute a duration first and re-anchor it + // onto TimeSpan's uptime base. If the platform uses a different base and the delta + // is implausible, this falls through to the class-loaded fallback below. + final long onCreateElapsedRealtimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); + final long durationMs = onCreateElapsedRealtimeMs - Process.getStartElapsedRealtime(); + if (durationMs > 0 && durationMs <= TimeUnit.MINUTES.toMillis(1)) { + final long onCreateUptimeMs = Process.getStartUptimeMillis() + durationMs; + if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { + applicationOnCreate.setStoppedAt(onCreateUptimeMs); + } + stopHeadlessAppStartAt(onCreateUptimeMs); + return; + } + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) — always available + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); } } 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 9e94d7b9905..4efda198de6 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 @@ -7,6 +7,7 @@ import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -33,6 +34,7 @@ import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty @@ -64,6 +66,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +86,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +108,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +236,191 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +724,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1100,102 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `activity following a headless start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2051,52 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index cedf5ca18bb..d34228d3176 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1467,6 +1467,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange 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 e2fed5bb003..e61b0aef675 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 @@ -13,7 +13,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -95,6 +97,103 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = getTransaction(AppStartType.COLD) + setAppStart(fixture.options) + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = getTransaction(AppStartType.COLD) + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = getTransaction(AppStartType.COLD) + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = getTransaction(AppStartType.COLD) + tr.contexts.trace!!.setData(APP_START_SCREEN_DATA, "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) @@ -148,6 +247,23 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = getTransaction(AppStartType.COLD) + tr1 = sut.process(tr1, Hint()) + + var tr2 = getTransaction(AppStartType.COLD) + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() @@ -464,10 +580,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,12 +983,31 @@ class PerformanceAndroidEventProcessorTest { } } + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } + } + } + } + private fun getTransaction(type: AppStartType): SentryTransaction { val op = when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + AppStartType.COLD -> STANDALONE_APP_START_OP + AppStartType.WARM -> STANDALONE_APP_START_OP + AppStartType.UNKNOWN -> UI_LOAD_OP } val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..0eec9502702 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index e7079bd46d0..a959c5dd865 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.app.ApplicationStartInfo import android.os.Build import org.robolectric.annotation.Implementation @@ -10,13 +11,25 @@ import org.robolectric.annotation.Implements class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setImportance(importance: Int) { + this.importance = importance + } + fun reset() { historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } 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 c15ea3c37d0..c66f180ec58 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 @@ -16,8 +16,10 @@ import io.sentry.SentryNanotimeDate import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -44,6 +46,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +68,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +82,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -167,10 +172,10 @@ class AppStartMetricsTest { // when the looper runs waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -180,7 +185,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -194,7 +199,7 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched + // when the handler callback is executed and the start is headless waitForMainLooperIdle() // isAppLaunchedInForeground should be false @@ -208,6 +213,125 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -331,12 +455,12 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created + // Main thread performs the check and sets the flag to false if the start is headless waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -369,11 +493,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -387,18 +511,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -585,7 +709,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +914,7 @@ class AppStartMetricsTest { whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() waitForMainLooperIdle() @@ -804,7 +927,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(secondActivity, null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() // Third activity - should still be warm @@ -812,7 +935,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..330fed135e8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -3,16 +3,22 @@ package io.sentry.android.core.performance import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -25,7 +31,9 @@ class AppStartMetricsTestApi35 { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -42,6 +50,22 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +105,131 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime converts ApplicationStartInfo timestamp to uptime`() { + val processStartUptimeMs = 100L + val processStartElapsedMs = 10_000L + val onCreateElapsedRealtimeMs = 10_250L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateElapsedRealtimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowProcess.setStartUptimeMillis(processStartUptimeMs) + SentryShadowProcess.setStartElapsedRealtime(processStartElapsedMs) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(processStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back when ApplicationStartInfo duration is invalid`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to TimeUnit.MINUTES.toNanos(2) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowProcess.setStartUptimeMillis(100) + SentryShadowProcess.setStartElapsedRealtime(0) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 26f526124b4..a945ef717a7 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ android:exported="true" android:foregroundServiceType="remoteMessaging" /> + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..a4c930b12c0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,30 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing app start importance. + * + *

When this receiver triggers a cold start (process was dead), Application.onCreate() runs + * first. We can then check if importance == IMPORTANCE_FOREGROUND even though no activity will + * launch. + * + *

Test with: + * + *

{@code
+ * adb shell am force-stop io.sentry.samples.android && \
+ * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
+ *   -n io.sentry.samples.android/.TestBroadcastReceiver
+ * }
+ */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a433abbb37c..fe91c3b05b2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4579,6 +4579,7 @@ public final class io/sentry/TracesSamplingDecision { public final class io/sentry/TransactionContext : io/sentry/SpanContext { public static final field DEFAULT_TRANSACTION_NAME Ljava/lang/String; public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/TracesSamplingDecision;Lio/sentry/Baggage;)V + public fun (Lio/sentry/protocol/SentryId;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/TransactionContext.java b/sentry/src/main/java/io/sentry/TransactionContext.java index 5785917add4..6fcfd33bd7d 100644 --- a/sentry/src/main/java/io/sentry/TransactionContext.java +++ b/sentry/src/main/java/io/sentry/TransactionContext.java @@ -84,6 +84,25 @@ public TransactionContext( this.baggage = TracingUtils.ensureBaggage(null, samplingDecision); } + /** + * Creates {@link TransactionContext} that shares a trace ID with another transaction. Used for + * standalone app start transactions that need to belong to the same trace as the activity + * transaction. + */ + @ApiStatus.Internal + public TransactionContext( + final @NotNull SentryId traceId, + final @NotNull String name, + final @NotNull TransactionNameSource transactionNameSource, + final @NotNull String operation, + final @Nullable TracesSamplingDecision samplingDecision) { + super(traceId, new SpanId(), operation, null, samplingDecision); + this.name = Objects.requireNonNull(name, "name is required"); + this.transactionNameSource = transactionNameSource; + this.setSamplingDecision(samplingDecision); + this.baggage = TracingUtils.ensureBaggage(null, samplingDecision); + } + @ApiStatus.Internal public TransactionContext( final @NotNull SentryId traceId, diff --git a/sentry/src/test/java/io/sentry/TransactionContextTest.kt b/sentry/src/test/java/io/sentry/TransactionContextTest.kt index 55603853a66..dc301f627e0 100644 --- a/sentry/src/test/java/io/sentry/TransactionContextTest.kt +++ b/sentry/src/test/java/io/sentry/TransactionContextTest.kt @@ -5,6 +5,7 @@ import io.sentry.protocol.TransactionNameSource import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -136,4 +137,35 @@ class TransactionContextTest { assertNotNull(context.baggage) assertEquals(0.2, context.baggage?.sampleRand!!, 0.0001) } + + @Test + fun `traceId constructor reuses provided trace id and operation`() { + val traceId = SentryId() + val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2, true, 0.3) + + val context = + TransactionContext(traceId, "name", TransactionNameSource.COMPONENT, "op", samplingDecision) + + assertEquals(traceId, context.traceId) + assertEquals("name", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("op", context.operation) + assertTrue(context.sampled!!) + assertTrue(context.profileSampled!!) + } + + @Test + fun `traceId constructor creates a fresh span id and baggage`() { + val traceId = SentryId() + val samplingDecision = TracesSamplingDecision(true, 0.1, 0.2) + + val context = + TransactionContext(traceId, "name", TransactionNameSource.COMPONENT, "op", samplingDecision) + + assertNotNull(context.spanId) + assertNotEquals(SpanId.EMPTY_ID, context.spanId) + assertNull(context.parentSpanId) + assertNotNull(context.baggage) + assertEquals(0.2, context.baggage?.sampleRand!!, 0.0001) + } } From e9b8dfc4449912f5cbe9290a1c8c0e0bc7e586e4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 29 May 2026 20:07:36 +0200 Subject: [PATCH 2/2] ref(android): Consolidate standalone app start into StandaloneAppStartReporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone app-start feature was spread across ActivityLifecycleIntegration, AppStartMetrics, and PerformanceAndroidEventProcessor: ALI held the transaction field, creation branches, and the headless listener callback; AppStartMetrics acted as a trace-id mailbox; and the event processor re-derived "is this a headless standalone app start" by sniffing the presence of a span-data key. No single place owned the feature. Introduce StandaloneAppStartReporter, which owns the feature end-to-end on the post-init side of the SDK (it holds an IScopes and can start transactions): emitting the activity-launch sibling and the headless transaction, keeping the shared trace id, and classifying transactions for the event processor via isStandaloneAppStart/isHeadlessAppStart. AppStartMetrics stays a pre-init, scope-less recorder that only emits the headless signal; ALI delegates to the reporter instead of carrying the logic; the event processor asks the reporter how to classify a transaction rather than poking at its data map. Only data crosses the pre-/post-init seam, not logic. No behavior change. Net -76 lines and two fewer public methods on AppStartMetrics. Co-Authored-By: Claude Opus 4.8 (1M context) ref(android): Consolidate headless app start scheduling to one site Scheduling of the main-thread idle check lived in two places: registerLifecycleCallbacks (gated on appStartType == UNKNOWN || listener != null) and setHeadlessAppStartListener (a side-effect gated on isCallbackRegistered && no activities yet && first draw not done). The setter's side-effect was load-bearing because the listener is installed during SDK init, after the early SentryPerformanceProvider registration, so on API 35+ with a known start type registerLifecycleCallbacks would skip scheduling and the setter had to cover it. Schedule the idle check unconditionally from the single registration site and make the setter a plain assignment. The idle handler already no-ops once an Activity exists and reads the listener when it fires (after init has installed it), so it can serve both consumers — the pre-API-35 cold/warm heuristic and standalone headless emission — without the setter needing to know about timing. The only behavior change: on API 35+ with a known type and standalone disabled, the idle check is now scheduled (previously skipped). It no-ops for foreground launches and, for a truly headless launch, correctly marks the start as background and stops app-start profilers. ref(android): Add AppStartIntegration for standalone app start Move headless standalone app-start emission out of AppStartMetrics into StandaloneAppStartReporter behind a new AppStartIntegration. Metrics keeps idle scheduling and shared boot bookkeeping; ActivityLifecycleIntegration coordinates via StandaloneAppStartCoordinator only when the feature is enabled. Refs #5342 Co-Authored-By: Cursor Co-authored-by: Cursor ref(android): Decouple app start from ActivityLifecycleIntegration ActivityLifecycleIntegration dispatches AppStartLifecycle callbacks instead of taking a StandaloneAppStartCoordinator. AppStartIntegration registers StandaloneAppStartReporter as the listener at init time. Refs #5342 Co-Authored-By: Cursor Co-authored-by: Cursor ref(android): Plan first ui.load via standalone app start listener Use UiLoadStartPlan so ActivityLifecycleIntegration starts ui.load without headless branching while StandaloneAppStartReporter owns trace reuse and sibling App Start emission. Co-Authored-By: Cursor ref(android): Use single ui.load startTransaction path for standalone planFirstUiLoad now takes isFirstProcessStart so ActivityLifecycleIntegration always builds one TransactionContext while headless reuse stays in the reporter. Co-Authored-By: Cursor ref(android): Rename FirstUiLoad types and narrow standalone API Rename AppStartLifecycle and AppStartLifecycleListener to FirstUiLoad and FirstUiLoadListener. Make standalone app start wiring package-private and drop those types from the public api dump. Co-Authored-By: Cursor ref(android): Replace static FirstUiLoad with init-scoped coordinator Wire the standalone app-start listener through a FirstUiLoadCoordinator created in AndroidOptionsInitializer and shared by AppStartIntegration and ActivityLifecycleIntegration instead of a static global holder. Co-Authored-By: Auto Co-authored-by: Cursor --- .../api/sentry-android-core.api | 9 +- .../core/ActivityLifecycleIntegration.java | 158 +++------- .../core/AndroidOptionsInitializer.java | 9 +- .../android/core/AppStartIntegration.java | 79 +++++ .../android/core/FirstUiLoadCoordinator.java | 24 ++ .../android/core/FirstUiLoadListener.java | 48 +++ .../PerformanceAndroidEventProcessor.java | 22 +- .../core/StandaloneAppStartReporter.java | 238 ++++++++++++++ .../sentry/android/core/UiLoadStartPlan.java | 33 ++ .../core/performance/AppStartMetrics.java | 97 +++--- .../core/ActivityLifecycleIntegrationTest.kt | 227 ++++++++------ .../PerformanceAndroidEventProcessorTest.kt | 4 +- .../core/StandaloneAppStartReporterTest.kt | 290 ++++++++++++++++++ .../core/performance/AppStartMetricsTest.kt | 20 +- .../performance/AppStartMetricsTestApi35.kt | 18 +- 15 files changed, 976 insertions(+), 300 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AppStartIntegration.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadCoordinator.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadListener.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/StandaloneAppStartReporter.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/UiLoadStartPlan.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/StandaloneAppStartReporterTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 20eaf1e5344..4511c6dcf85 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -739,6 +739,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun finalizeHeadlessAppStartEndTime ()V public fun getActivityLifecycleTimeSpans ()Ljava/util/List; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; @@ -746,7 +747,6 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; - public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -770,10 +770,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V - public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V - public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V + public fun setOnMainIdleNoActivityCallback (Lio/sentry/android/core/performance/AppStartMetrics$OnMainIdleNoActivityCallback;)V public fun shouldSendStartMeasurements ()Z public fun shouldSendStartMeasurements (Z)Z } @@ -786,8 +785,8 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } -public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { - public abstract fun onHeadlessAppStart ()V +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$OnMainIdleNoActivityCallback { + public abstract fun onMainIdleNoActivity ()V } public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { 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 d6e5ae85626..86b0ed39c9f 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 @@ -33,7 +33,6 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; -import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -56,15 +55,12 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; - static final String STANDALONE_APP_START_OP = "app.start"; - private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; 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 long TTFD_TIMEOUT_MILLIS = 25000; private static final String TRACE_ORIGIN = "auto.ui.activity"; - static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -81,7 +77,6 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; - private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -95,6 +90,7 @@ public final class ActivityLifecycleIntegration new WeakHashMap<>(); private final @NotNull ActivityFramesTracker activityFramesTracker; + private final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private boolean fullyDisplayedCalled = false; private final @NotNull AutoClosableReentrantLock fullyDisplayedLock = @@ -103,12 +99,15 @@ public final class ActivityLifecycleIntegration public ActivityLifecycleIntegration( final @NotNull Application application, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ActivityFramesTracker activityFramesTracker) { + final @NotNull ActivityFramesTracker activityFramesTracker, + final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator) { this.application = Objects.requireNonNull(application, "Application is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.activityFramesTracker = Objects.requireNonNull(activityFramesTracker, "ActivityFramesTracker is required"); + this.firstUiLoadCoordinator = + Objects.requireNonNull(firstUiLoadCoordinator, "FirstUiLoadCoordinator is required"); if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.Q) { isAllActivityCallbacksAvailable = true; @@ -130,10 +129,6 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions application.registerActivityLifecycleCallbacks(this); - if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { - AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); - } - this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -145,7 +140,6 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); - AppStartMetrics.getInstance().setHeadlessAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -250,58 +244,43 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - final @Nullable SentryId storedAppStartTraceId = - AppStartMetrics.getInstance().getAppStartTraceId(); - // A non-null trace ID means a standalone app-start txn was already emitted. - final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); - - final ITransaction transaction; - if (storedAppStartTraceId != null) { - transaction = - scopes.startTransaction( - new TransactionContext( - storedAppStartTraceId, - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); - AppStartMetrics.getInstance().setAppStartTraceId(null); - } else { - transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + final @Nullable FirstUiLoadListener firstUiLoadListener = + options != null && options.isEnableStandaloneAppStartTracing() + ? firstUiLoadCoordinator.getListener() + : null; + + final boolean isFirstProcessStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + + final @Nullable UiLoadStartPlan plan = + firstUiLoadListener != null + ? firstUiLoadListener.planFirstUiLoad( + activityName, appStartSamplingDecision, isFirstProcessStart) + : null; + + final @NotNull TransactionContext transactionContext = + plan != null + ? plan.getTransactionContext() + : new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision); + + final ITransaction transaction = + scopes.startTransaction(transactionContext, transactionOptions); + + if (isFirstProcessStart && firstUiLoadListener != null) { + firstUiLoadListener.onFirstUiLoadTransactionStarted( + transaction, plan, appStartTime, activityName, appStartSamplingDecision); } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - if (options.isEnableStandaloneAppStartTracing() && !isFollowingHeadlessAppStart) { - final TransactionOptions appStartTransactionOptions = new TransactionOptions(); - appStartTransactionOptions.setBindToScope(false); - appStartTransactionOptions.setStartTimestamp(appStartTime); - appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); - setSpanOrigin(appStartTransactionOptions); - - appStartTransaction = - scopes.startTransaction( - new TransactionContext( - transaction.getSpanContext().getTraceId(), - STANDALONE_APP_START_NAME, - TransactionNameSource.COMPONENT, - STANDALONE_APP_START_OP, - appStartSamplingDecision), - appStartTransactionOptions); - appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); - - finishAppStartSpan(); - } else if (!options.isEnableStandaloneAppStartTracing()) { + if (isFirstProcessStart) { + if (firstUiLoadListener == null) { + // legacy path: app start is a child span of the ui.load transaction. appStartSpan = transaction.startChild( getAppStartOp(coldStart), @@ -603,8 +582,10 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); - if (appStartTransaction != null && !appStartTransaction.isFinished()) { - appStartTransaction.finish(SpanStatus.CANCELLED); + final @Nullable FirstUiLoadListener firstUiLoadListener = + firstUiLoadCoordinator.getListener(); + if (firstUiLoadListener != null) { + firstUiLoadListener.onActivityDestroyed(); } // we finish the ttidSpan as cancelled in case it isn't completed yet @@ -622,7 +603,6 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; - appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -840,8 +820,12 @@ WeakHashMap getTtfdSpanMap() { } private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { - if (appStartTransaction != null) { - return appStartTransaction; + final @Nullable FirstUiLoadListener firstUiLoadListener = firstUiLoadCoordinator.getListener(); + if (firstUiLoadListener != null) { + final @Nullable ISpan appStartTransaction = firstUiLoadListener.getAppStartTransaction(); + if (appStartTransaction != null) { + return appStartTransaction; + } } if (appStartSpan != null) { return appStartSpan; @@ -870,51 +854,11 @@ private void finishAppStartSpan(final @Nullable SentryDate endDate) { .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); - if (appStartTransaction != null && !appStartTransaction.isFinished()) { - appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + final @Nullable FirstUiLoadListener firstUiLoadListener = + firstUiLoadCoordinator.getListener(); + if (firstUiLoadListener != null && appStartEndTime != null) { + firstUiLoadListener.onFirstFrameDrawn(appStartEndTime); } } } - - private void onHeadlessAppStart() { - if (scopes == null || options == null || !performanceEnabled) { - return; - } - - final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); - // Profilers are stopped for headless starts; clear the decision so it doesn't - // leak to a later ui.load transaction if an activity eventually opens. - metrics.setAppStartSamplingDecision(null); - - // For headless starts, appLaunchedInForeground is false, so we can't use - // getAppStartTimeSpanWithFallback (which gates on foreground). - final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); - - if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { - return; - } - - final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); - final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); - if (startTime == null || endTime == null) { - return; - } - - final TransactionOptions txnOptions = new TransactionOptions(); - txnOptions.setBindToScope(false); - txnOptions.setStartTimestamp(startTime); - setSpanOrigin(txnOptions); - - final @NotNull TransactionContext txnContext = - new TransactionContext( - STANDALONE_APP_START_NAME, - TransactionNameSource.COMPONENT, - STANDALONE_APP_START_OP, - null); - - final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); - metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); - - transaction.finish(SpanStatus.OK, endTime); - } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 5704cf7d7d4..b6fef7b06cd 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 @@ -405,9 +405,16 @@ static void installDefaultIntegrations( // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { + final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator = new FirstUiLoadCoordinator(); + if (AppStartIntegration.isEnabled(options)) { + options.addIntegration(new AppStartIntegration(firstUiLoadCoordinator)); + } options.addIntegration( new ActivityLifecycleIntegration( - (Application) context, buildInfoProvider, activityFramesTracker)); + (Application) context, + buildInfoProvider, + activityFramesTracker, + firstUiLoadCoordinator)); options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); options.addIntegration(new FeedbackShakeIntegration((Application) context)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppStartIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartIntegration.java new file mode 100644 index 00000000000..6141785a4a8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppStartIntegration.java @@ -0,0 +1,79 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; +import org.jetbrains.annotations.VisibleForTesting; + +/** + * Registers standalone {@code App Start} tracing when {@link + * SentryAndroidOptions#isEnableStandaloneAppStartTracing()} is enabled. + * + *

Delegates to {@link StandaloneAppStartReporter}, which registers as {@link + * FirstUiLoadListener} for callbacks from {@link ActivityLifecycleIntegration}. + */ +final class AppStartIntegration implements Integration, java.io.Closeable { + + private final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator; + private @Nullable StandaloneAppStartReporter reporter; + + AppStartIntegration(final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator) { + this.firstUiLoadCoordinator = firstUiLoadCoordinator; + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + final @Nullable SentryAndroidOptions androidOptions = + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null; + if (androidOptions == null || !isEnabled(androidOptions)) { + return; + } + + reporter = new StandaloneAppStartReporter(scopes, "auto.ui.activity", firstUiLoadCoordinator); + reporter.register(); + + androidOptions.getLogger().log(SentryLevel.DEBUG, "AppStartIntegration installed."); + addIntegrationToSdkVersion("AppStart"); + } + + @VisibleForTesting + static boolean isEnabled(final @NotNull SentryAndroidOptions options) { + return options.isTracingEnabled() + && options.isEnableAutoActivityLifecycleTracing() + && options.isEnableStandaloneAppStartTracing(); + } + + @Override + public void close() { + if (reporter != null) { + reporter.close(); + reporter = null; + } + } + + @TestOnly + @Nullable + StandaloneAppStartReporter getReporter() { + return reporter; + } + + @TestOnly + @Nullable + SentryId getReusableTraceId() { + return reporter != null ? reporter.getReusableTraceId() : null; + } + + @TestOnly + void setReusableTraceId(final @Nullable SentryId traceId) { + if (reporter != null) { + reporter.setReusableTraceId(traceId); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadCoordinator.java b/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadCoordinator.java new file mode 100644 index 00000000000..3dbba437075 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadCoordinator.java @@ -0,0 +1,24 @@ +package io.sentry.android.core; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Holds the {@link FirstUiLoadListener} wired by {@link AppStartIntegration} for {@link + * ActivityLifecycleIntegration}. One instance is created per SDK init in {@link + * AndroidOptionsInitializer} and shared by both integrations. + */ +@ApiStatus.Internal +final class FirstUiLoadCoordinator { + + private volatile @Nullable FirstUiLoadListener listener; + + void setListener(final @Nullable FirstUiLoadListener listener) { + this.listener = listener; + } + + @Nullable + FirstUiLoadListener getListener() { + return listener; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadListener.java new file mode 100644 index 00000000000..120fe0e41f3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FirstUiLoadListener.java @@ -0,0 +1,48 @@ +package io.sentry.android.core; + +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.SentryDate; +import io.sentry.TracesSamplingDecision; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Callbacks from {@link ActivityLifecycleIntegration} for standalone app-start tracing. Registered + * by {@link AppStartIntegration} during SDK init. + */ +@ApiStatus.Internal +interface FirstUiLoadListener { + + /** + * Builds the {@code ui.load} {@link TransactionContext}. On the first activity of a process + * start, consumes any trace id stashed by a prior headless app start. + */ + @NotNull + UiLoadStartPlan planFirstUiLoad( + @NotNull String activityName, + @Nullable TracesSamplingDecision samplingDecision, + boolean isFirstProcessStart); + + /** + * The first {@code ui.load} transaction was started. Emits a sibling {@code App Start} + * transaction when {@link UiLoadStartPlan#shouldEmitSiblingAppStart()} is true. + */ + void onFirstUiLoadTransactionStarted( + @NotNull ITransaction uiLoadTransaction, + @NotNull UiLoadStartPlan plan, + @NotNull SentryDate appStartTime, + @NotNull String activityName, + @Nullable TracesSamplingDecision samplingDecision); + + /** Parent for {@code activity.load} spans during the first activity, if applicable. */ + @Nullable + ISpan getAppStartTransaction(); + + /** Finish the activity-launch app-start transaction at first frame. */ + void onFirstFrameDrawn(@NotNull SentryDate endDate); + + /** Cancel and clear the activity-launch app-start transaction. */ + void onActivityDestroyed(); +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 0b50b5080f4..e7da9135679 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 @@ -1,9 +1,7 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; -import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; -import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -89,12 +87,8 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // For headless starts, appLaunchedInForeground is false, so only headless standalone app // start transactions bypass the foreground check, not the duplicate-send guard. final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); - final boolean isStandaloneAppStartTxn = - traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); final boolean isHeadlessStandaloneAppStartTxn = - traceContext != null - && isStandaloneAppStartTxn - && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + StandaloneAppStartReporter.isHeadlessAppStart(traceContext); if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = @@ -229,8 +223,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } } - final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); + return StandaloneAppStartReporter.isStandaloneAppStart(txn.getContexts().getTrace()); } private void attachAppStartSpans( @@ -257,16 +250,13 @@ private void attachAppStartSpans( } } + final boolean isStandalone = StandaloneAppStartReporter.isStandaloneAppStart(traceContext); + // For standalone app start transactions, the transaction root IS the app start span - if (parentSpanId == null) { - final @NotNull String txnOp = traceContext.getOperation(); - if (STANDALONE_APP_START_OP.equals(txnOp)) { - parentSpanId = traceContext.getSpanId(); - } + if (parentSpanId == null && isStandalone) { + parentSpanId = traceContext.getSpanId(); } - final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); - // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/StandaloneAppStartReporter.java b/sentry-android-core/src/main/java/io/sentry/android/core/StandaloneAppStartReporter.java new file mode 100644 index 00000000000..0587346077c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/StandaloneAppStartReporter.java @@ -0,0 +1,238 @@ +package io.sentry.android.core; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.SentryDate; +import io.sentry.SentryOptions; +import io.sentry.SpanContext; +import io.sentry.SpanStatus; +import io.sentry.TracesSamplingDecision; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.TransactionNameSource; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +/** + * Owns standalone app-start transactions: activity-launch siblings, headless emit-and-finish, trace + * reuse for a later {@code ui.load}, and transaction classification for the event processor. + * + *

{@link AppStartMetrics} remains a pre-init historian and schedules the main-idle/no-activity + * check. This reporter registers as {@link AppStartMetrics.OnMainIdleNoActivityCallback} and {@link + * FirstUiLoadListener}. + */ +final class StandaloneAppStartReporter + implements FirstUiLoadListener, AppStartMetrics.OnMainIdleNoActivityCallback { + + static final String STANDALONE_APP_START_OP = "app.start"; + static final String STANDALONE_APP_START_NAME = "App Start"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + + private final @NotNull IScopes scopes; + private final @NotNull String traceOrigin; + private final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator; + + /** The activity-launch sibling transaction. Headless starts are emitted and finished inline. */ + private @Nullable ITransaction appStartTransaction; + + /** Trace id from a headless app start, to be reused by a later activity so both share a trace. */ + private volatile @Nullable SentryId reusableTraceId; + + StandaloneAppStartReporter( + final @NotNull IScopes scopes, + final @NotNull String traceOrigin, + final @NotNull FirstUiLoadCoordinator firstUiLoadCoordinator) { + this.scopes = scopes; + this.traceOrigin = traceOrigin; + this.firstUiLoadCoordinator = firstUiLoadCoordinator; + } + + void register() { + AppStartMetrics.getInstance().setOnMainIdleNoActivityCallback(this); + firstUiLoadCoordinator.setListener(this); + } + + void close() { + AppStartMetrics.getInstance().setOnMainIdleNoActivityCallback(null); + firstUiLoadCoordinator.setListener(null); + } + + @Override + public @NotNull UiLoadStartPlan planFirstUiLoad( + final @NotNull String activityName, + final @Nullable TracesSamplingDecision samplingDecision, + final boolean isFirstProcessStart) { + if (!isFirstProcessStart) { + return new UiLoadStartPlan( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + samplingDecision), + false); + } + final @Nullable SentryId reusableTrace = reusableTraceId; + reusableTraceId = null; + final boolean emitSiblingAppStart = reusableTrace == null; + final @NotNull TransactionContext context = + reusableTrace != null + ? new TransactionContext( + reusableTrace, + activityName, + TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + samplingDecision) + : new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + samplingDecision); + return new UiLoadStartPlan(context, emitSiblingAppStart); + } + + @Override + public void onFirstUiLoadTransactionStarted( + final @NotNull ITransaction uiLoadTransaction, + final @NotNull UiLoadStartPlan plan, + final @NotNull SentryDate appStartTime, + final @NotNull String activityName, + final @Nullable TracesSamplingDecision samplingDecision) { + if (plan.shouldEmitSiblingAppStart()) { + startForActivity( + uiLoadTransaction.getSpanContext().getTraceId(), + activityName, + appStartTime, + samplingDecision); + finishActivityAppStartAtProjectedTime(); + } + } + + private void finishActivityAppStartAtProjectedTime() { + final @NotNull SentryOptions options = scopes.getOptions(); + if (!(options instanceof SentryAndroidOptions)) { + return; + } + final @Nullable SentryDate projectedEndTime = + AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback((SentryAndroidOptions) options) + .getProjectedStopTimestamp(); + if (projectedEndTime != null + && appStartTransaction != null + && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, projectedEndTime); + } + } + + private void startForActivity( + final @NotNull SentryId traceId, + final @NotNull String activityName, + final @NotNull SentryDate appStartTime, + final @Nullable TracesSamplingDecision samplingDecision) { + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(appStartTime); + txnOptions.setAppStartTransaction(samplingDecision != null); + txnOptions.setOrigin(traceOrigin); + + final ITransaction transaction = + scopes.startTransaction( + new TransactionContext( + traceId, + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + samplingDecision), + txnOptions); + transaction.setData(APP_START_SCREEN_DATA, activityName); + appStartTransaction = transaction; + } + + @Override + public @Nullable ISpan getAppStartTransaction() { + return appStartTransaction; + } + + @Override + public void onFirstFrameDrawn(final @NotNull SentryDate endDate) { + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, endDate); + } + } + + @Override + public void onActivityDestroyed() { + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } + appStartTransaction = null; + } + + @VisibleForTesting + @Nullable + SentryId getReusableTraceId() { + return reusableTraceId; + } + + @VisibleForTesting + void setReusableTraceId(final @Nullable SentryId traceId) { + this.reusableTraceId = traceId; + } + + @Override + public void onMainIdleNoActivity() { + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped in AppStartMetrics; clear the decision so it doesn't leak to a later + // ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + metrics.finalizeHeadlessAppStartEndTime(); + emitHeadlessAppStartTransaction(metrics); + } + + private void emitHeadlessAppStartTransaction(final @NotNull AppStartMetrics metrics) { + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(traceOrigin); + + final ITransaction transaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + null), + txnOptions); + reusableTraceId = transaction.getSpanContext().getTraceId(); + transaction.finish(SpanStatus.OK, endTime); + } + + /** Whether the transaction is a standalone {@code App Start} transaction this reporter emits. */ + static boolean isStandaloneAppStart(final @Nullable SpanContext context) { + return context != null && STANDALONE_APP_START_OP.equals(context.getOperation()); + } + + /** + * Whether the transaction is a headless (non-activity) standalone app start. Headless starts are + * the only standalone app starts without the {@link #APP_START_SCREEN_DATA} screen marker, which + * this reporter sets exclusively on the activity-launch path. + */ + static boolean isHeadlessAppStart(final @Nullable SpanContext context) { + return isStandaloneAppStart(context) && !context.getData().containsKey(APP_START_SCREEN_DATA); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UiLoadStartPlan.java b/sentry-android-core/src/main/java/io/sentry/android/core/UiLoadStartPlan.java new file mode 100644 index 00000000000..513ad691143 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UiLoadStartPlan.java @@ -0,0 +1,33 @@ +package io.sentry.android.core; + +import io.sentry.TransactionContext; +import io.sentry.util.Objects; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * How the first {@code ui.load} transaction of a process start should be created when standalone + * app-start tracing is enabled. + */ +@ApiStatus.Internal +final class UiLoadStartPlan { + + private final @NotNull TransactionContext transactionContext; + private final boolean emitSiblingAppStart; + + UiLoadStartPlan( + final @NotNull TransactionContext transactionContext, final boolean emitSiblingAppStart) { + this.transactionContext = + Objects.requireNonNull(transactionContext, "transactionContext is required"); + this.emitSiblingAppStart = emitSiblingAppStart; + } + + @NotNull + TransactionContext getTransactionContext() { + return transactionContext; + } + + boolean shouldEmitSiblingAppStart() { + return emitSiblingAppStart; + } +} 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 d1eb30c8e3a..f26a6963dc7 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 @@ -25,7 +25,6 @@ import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; -import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -50,8 +49,12 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { - public interface HeadlessAppStartListener { - void onHeadlessAppStart(); + /** + * Invoked on the main thread after the main looper goes idle and no Activity was created. Used by + * standalone app-start tracing to emit a headless {@code App Start} transaction. + */ + public interface OnMainIdleNoActivityCallback { + void onMainIdleNoActivity(); } public enum AppStartType { @@ -89,10 +92,9 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); - private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); - private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); - private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; - private @Nullable SentryId appStartTraceId; + private final AtomicBoolean mainIdleNoActivityCheckPending = new AtomicBoolean(false); + private final AtomicBoolean mainIdleNoActivityCallbackInvoked = new AtomicBoolean(false); + private volatile @Nullable OnMainIdleNoActivityCallback onMainIdleNoActivityCallback; private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { @@ -171,23 +173,12 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } - public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { - this.headlessAppStartListener = listener; - if (listener != null - && isCallbackRegistered - && activeActivitiesCounter.get() == 0 - && !firstDrawDone.get()) { - scheduleHeadlessAppStartCheckOnMain(); - } - } - - /** Trace ID from a headless app start transaction, to be reused by a later activity. */ - public @Nullable SentryId getAppStartTraceId() { - return appStartTraceId; - } - - public void setAppStartTraceId(final @Nullable SentryId traceId) { - this.appStartTraceId = traceId; + public void setOnMainIdleNoActivityCallback( + final @Nullable OnMainIdleNoActivityCallback callback) { + // No scheduling here: the idle check is scheduled once from registerLifecycleCallbacks and + // reads this callback when it fires (after the main looper goes idle, by which point init has + // installed it). The callback may legitimately be set after registration. + this.onMainIdleNoActivityCallback = callback; } /** @@ -303,10 +294,9 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; - headlessAppStartCheckPending.set(false); - headlessAppStartListenerInvoked.set(false); - headlessAppStartListener = null; - appStartTraceId = null; + mainIdleNoActivityCheckPending.set(false); + mainIdleNoActivityCallbackInvoked.set(false); + onMainIdleNoActivityCallback = null; cachedStartInfo = null; } @@ -408,13 +398,16 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { - scheduleHeadlessAppStartCheckOnMain(); - } + // Single scheduling site for the main-thread idle check. It serves two consumers that may not + // both be known yet at registration time: the pre-API-35 cold/warm heuristic (needed whenever + // the type is still UNKNOWN) and standalone headless app-start emission (whose callback is + // installed later, during SDK init). Scheduling unconditionally lets the idle handler — which + // already no-ops once an Activity exists and reads the callback when it fires — make that call. + scheduleMainIdleNoActivityCheckOnMain(); } - private void scheduleHeadlessAppStartCheckOnMain() { - if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + private void scheduleMainIdleNoActivityCheckOnMain() { + if (!mainIdleNoActivityCheckPending.compareAndSet(false, true)) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -423,8 +416,8 @@ private void scheduleHeadlessAppStartCheckOnMain() { .addIdleHandler( () -> { firstIdle = SystemClock.uptimeMillis(); - headlessAppStartCheckPending.set(false); - handleHeadlessAppStartIfNeededOnMain(); + mainIdleNoActivityCheckPending.set(false); + handleMainIdleNoActivityOnMain(); return false; }); } else { @@ -434,8 +427,8 @@ private void scheduleHeadlessAppStartCheckOnMain() { firstIdle = SystemClock.uptimeMillis(); handler.post( () -> { - headlessAppStartCheckPending.set(false); - handleHeadlessAppStartIfNeededOnMain(); + mainIdleNoActivityCheckPending.set(false); + handleMainIdleNoActivityOnMain(); }); }); } @@ -443,13 +436,14 @@ private void scheduleHeadlessAppStartCheckOnMain() { /** * Checks whether startup reached an Activity after the main looper had a chance to create one. If - * not, handles the headless app start path. Must be called on the main thread. + * not, applies shared boot bookkeeping and notifies {@link OnMainIdleNoActivityCallback} + * subscribers. Must be called on the main thread. */ - private void handleHeadlessAppStartIfNeededOnMain() { + private void handleMainIdleNoActivityOnMain() { if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground.setValue(false); - // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // Process starts without an Activity have no pre-API-35 warm/cold heuristic signal. // If ApplicationStartInfo did not resolve the type, classify the process start as cold. if (appStartType == AppStartType.UNKNOWN) { appStartType = AppStartType.COLD; @@ -465,20 +459,25 @@ private void handleHeadlessAppStartIfNeededOnMain() { appStartContinuousProfiler = null; } - final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; - if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { - resolveHeadlessAppStartEndTime(); - listener.onHeadlessAppStart(); + final @Nullable OnMainIdleNoActivityCallback callback = onMainIdleNoActivityCallback; + if (callback != null && mainIdleNoActivityCallbackInvoked.compareAndSet(false, true)) { + callback.onMainIdleNoActivity(); } } } - private void resolveHeadlessAppStartEndTime() { + /** + * Stops the app-start time span for a process boot that never reached an Activity, using the best + * available end-time source. Called by standalone app-start code before emitting a headless + * {@code App Start} transaction. + */ + @ApiStatus.Internal + public void finalizeHeadlessAppStartEndTime() { // Priority 1: Gradle plugin instrumented onApplicationPostCreate if (applicationOnCreate.hasStopped()) { final long stopUptimeMs = applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); - stopHeadlessAppStartAt(stopUptimeMs); + stopAppStartSpanAt(stopUptimeMs); return; } @@ -500,7 +499,7 @@ private void resolveHeadlessAppStartEndTime() { if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { applicationOnCreate.setStoppedAt(onCreateUptimeMs); } - stopHeadlessAppStartAt(onCreateUptimeMs); + stopAppStartSpanAt(onCreateUptimeMs); return; } } @@ -510,10 +509,10 @@ private void resolveHeadlessAppStartEndTime() { } // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) — always available - stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + stopAppStartSpanAt(CLASS_LOADED_UPTIME_MS); } - private void stopHeadlessAppStartAt(final long stopUptimeMs) { + private void stopAppStartSpanAt(final long stopUptimeMs) { if (appStartSpan.hasStarted()) { if (appStartSpan.hasNotStopped()) { appStartSpan.setStoppedAt(stopUptimeMs); 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 4efda198de6..0da797df4a0 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 @@ -89,6 +89,8 @@ class ActivityLifecycleIntegrationTest { val createdTransactions = mutableListOf() val capturedContexts = mutableListOf() val capturedOptions = mutableListOf() + val firstUiLoadCoordinator = FirstUiLoadCoordinator() + var appStartIntegration: AppStartIntegration? = null fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -123,7 +125,28 @@ class ActivityLifecycleIntegrationTest { val processes = mutableListOf(process) shadowActivityManager.setProcesses(processes) - return ActivityLifecycleIntegration(application, buildInfo, activityFramesTracker) + appStartIntegration = + if (options.isEnableStandaloneAppStartTracing && options.tracesSampleRate != null) { + AppStartIntegration(firstUiLoadCoordinator) + } else { + null + } + return ActivityLifecycleIntegration( + application, + buildInfo, + activityFramesTracker, + firstUiLoadCoordinator, + ) + } + + fun registerIntegrations(sut: ActivityLifecycleIntegration) { + appStartIntegration?.register(scopes, options) + sut.register(scopes, options) + } + + fun closeIntegrations(sut: ActivityLifecycleIntegration) { + sut.close() + appStartIntegration?.close() } fun createView(): View { @@ -159,7 +182,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is registered, it registers activity callback`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) verify(fixture.application).registerActivityLifecycleCallbacks(any()) } @@ -167,7 +190,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.close() @@ -177,7 +200,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is closed, it should close the ActivityFramesTracker`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.close() @@ -187,7 +210,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When tracing is disabled, do not start tracing`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -199,7 +222,7 @@ class ActivityLifecycleIntegrationTest { fun `When tracing is enabled but activity is running, do not start tracing again`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -212,7 +235,7 @@ class ActivityLifecycleIntegrationTest { fun `Transaction op is ui_load and idle+deadline timeouts are set`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -243,7 +266,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -254,12 +277,12 @@ class ActivityLifecycleIntegrationTest { val contexts = fixture.capturedContexts val appStartContext = - contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + contexts.single { it.operation == StandaloneAppStartReporter.STANDALONE_APP_START_OP } assertEquals("App Start", appStartContext.name) assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) val appStartTransaction = fixture.createdTransactions.single { - it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + it.spanContext.operation == StandaloneAppStartReporter.STANDALONE_APP_START_OP } assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) @@ -272,29 +295,29 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + fun `OnMainIdleNoActivityCallback is registered when standalone flag is on and performance enabled`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) driveHeadlessAppStart() assertEquals(1, fixture.capturedContexts.size) assertEquals( - ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + StandaloneAppStartReporter.STANDALONE_APP_START_OP, fixture.capturedContexts.single().operation, ) assertEquals("App Start", fixture.capturedContexts.single().name) } @Test - fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + fun `OnMainIdleNoActivityCallback is not registered when standalone flag is off`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessAppStart() driveHeadlessAppStart() @@ -303,9 +326,9 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `HeadlessAppStartListener is not registered when performance is disabled`() { + fun `OnMainIdleNoActivityCallback is not registered when performance is disabled`() { val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessAppStart() driveHeadlessAppStart() @@ -314,14 +337,14 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `close clears HeadlessAppStartListener`() { + fun `close clears OnMainIdleNoActivityCallback`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) - sut.close() + fixture.registerIntegrations(sut) + fixture.closeIntegrations(sut) prepareHeadlessAppStart() driveHeadlessAppStart() @@ -336,7 +359,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessAppStart(appStartType = AppStartType.COLD) driveHeadlessAppStart() @@ -345,14 +368,14 @@ class ActivityLifecycleIntegrationTest { val context = fixture.capturedContexts.single() val options = fixture.capturedOptions.single() val transaction = fixture.createdTransactions.single() - assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals(StandaloneAppStartReporter.STANDALONE_APP_START_OP, context.operation) assertEquals("App Start", context.name) assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) assertFalse(options.isBindToScope) assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) assertEquals( transaction.spanContext.traceId, - AppStartMetrics.getInstance().getAppStartTraceId(), + fixture.appStartIntegration!!.getReusableTraceId(), ) assertTrue(transaction.isFinished) assertEquals(SpanStatus.OK, transaction.status) @@ -366,7 +389,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessSdkInitAppStart() driveHeadlessAppStart() @@ -375,12 +398,12 @@ class ActivityLifecycleIntegrationTest { val context = fixture.capturedContexts.single() val options = fixture.capturedOptions.single() val transaction = fixture.createdTransactions.single() - assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals(StandaloneAppStartReporter.STANDALONE_APP_START_OP, context.operation) assertEquals("App Start", context.name) assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) assertEquals( transaction.spanContext.traceId, - AppStartMetrics.getInstance().getAppStartTraceId(), + fixture.appStartIntegration!!.getReusableTraceId(), ) assertTrue(transaction.isFinished) assertEquals(SpanStatus.OK, transaction.status) @@ -393,14 +416,14 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) prepareHeadlessAppStart(appStartType = AppStartType.WARM) driveHeadlessAppStart() assertEquals(1, fixture.capturedContexts.size) val context = fixture.capturedContexts.single() - assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals(StandaloneAppStartReporter.STANDALONE_APP_START_OP, context.operation) assertEquals("App Start", context.name) assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) } @@ -412,7 +435,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) AppStartMetrics.getInstance().appStartTimeSpan.reset() AppStartMetrics.getInstance().sdkInitTimeSpan.reset() @@ -427,7 +450,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.deadlineTimeout = 60000L // 60 seconds - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), fixture.bundle) verify(fixture.scopes) @@ -445,7 +468,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.deadlineTimeout = 0L // No deadline - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), fixture.bundle) verify(fixture.scopes) @@ -463,7 +486,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.deadlineTimeout = -1L // No deadline - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), fixture.bundle) verify(fixture.scopes) @@ -480,7 +503,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), fixture.bundle) verify(fixture.scopes) @@ -499,7 +522,7 @@ class ActivityLifecycleIntegrationTest { fun `Activity gets added to ActivityFramesTracker during transaction creation`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityStarted(activity) @@ -511,7 +534,7 @@ class ActivityLifecycleIntegrationTest { fun `Transaction name is the Activity's name`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -533,7 +556,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) @@ -552,7 +575,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) @@ -579,7 +602,7 @@ class ActivityLifecycleIntegrationTest { it.idleTimeout = 100 } ) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) sut.ttidSpanMap.values.first().finish() @@ -606,7 +629,7 @@ class ActivityLifecycleIntegrationTest { fun `When tracing auto finish is enabled, it doesn't stop the transaction on onActivityPostResumed`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -620,7 +643,7 @@ class ActivityLifecycleIntegrationTest { fun `When tracing has status, do not overwrite it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -648,7 +671,7 @@ class ActivityLifecycleIntegrationTest { it.isEnableActivityLifecycleTracingAutoFinish = false } ) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) // We don't schedule the transaction to finish @@ -659,7 +682,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When tracing is disabled, do not finish transaction`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityPostResumed(activity) @@ -672,7 +695,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed but transaction is running, finish it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -686,7 +709,7 @@ class ActivityLifecycleIntegrationTest { fun `When transaction is started, adds to WeakWef`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -698,7 +721,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed removes WeakRef`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -711,7 +734,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets appStartSpan status to cancelled and finish it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -731,7 +754,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -741,7 +764,7 @@ class ActivityLifecycleIntegrationTest { val appStartTransaction = fixture.createdTransactions[ - transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + transactionIndexForOperation(StandaloneAppStartReporter.STANDALONE_APP_START_OP)] assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) assertTrue(appStartTransaction.isFinished) } @@ -750,7 +773,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -765,7 +788,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, finish ttidSpan with deadline_exceeded and remove from map`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -784,7 +807,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets ttidSpan to null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -801,7 +824,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -821,7 +844,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -837,7 +860,7 @@ class ActivityLifecycleIntegrationTest { fun `When new Activity and transaction is created, finish previous ones`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), mock()) @@ -850,7 +873,7 @@ class ActivityLifecycleIntegrationTest { fun `do not stop transaction on resumed`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -863,7 +886,7 @@ class ActivityLifecycleIntegrationTest { fun `start transaction on created`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(mock(), mock()) verify(fixture.scopes).startTransaction(any(), any()) @@ -875,7 +898,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true fixture.options.idleTimeout = 0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -894,7 +917,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -916,7 +939,7 @@ class ActivityLifecycleIntegrationTest { fullyDisplayedReporter = ttfdReporter } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -928,7 +951,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) @@ -953,7 +976,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start sampling decision is set, start transaction with isAppStart true`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) @@ -976,7 +999,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When firstActivityCreated is false and app start sampling decision is not set, start transaction with isAppStart false`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(false) val date = SentryNanotimeDate(Date(1), 0) @@ -1004,7 +1027,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true and app start sampling decision is set, start transaction with isAppStart false`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(true) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -1021,7 +1044,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and no app start time is set, default to onActivityPreCreated time`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider @@ -1048,7 +1071,7 @@ class ActivityLifecycleIntegrationTest { fun `When not foregroundImportance, do not create app start span`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_BACKGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually set by SentryPerformanceProvider val date = SentryNanotimeDate(Date(1), 0) @@ -1072,7 +1095,7 @@ class ActivityLifecycleIntegrationTest { fun `Create and finish app start span immediately in case SDK init is deferred`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually set by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -1107,7 +1130,7 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val firstFrameDate = SentryNanotimeDate(Date(1499), 0) fixture.options.dateProvider = SentryDateProvider { firstFrameDate } setAppStartTime(SentryNanotimeDate(Date(1), 0)) @@ -1119,7 +1142,7 @@ class ActivityLifecycleIntegrationTest { assertEquals(2, fixture.capturedContexts.size) val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) val appStartIndex = - transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + transactionIndexForOperation(StandaloneAppStartReporter.STANDALONE_APP_START_OP) val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] val appStartTransaction = fixture.createdTransactions[appStartIndex] @@ -1161,8 +1184,8 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableStandaloneAppStartTracing = true } - AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) + fixture.appStartIntegration!!.setReusableTraceId(storedTraceId) setAppStartTime() val activity = mock() @@ -1172,13 +1195,13 @@ class ActivityLifecycleIntegrationTest { val context = fixture.capturedContexts.single() assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) assertEquals(storedTraceId, context.traceId) - assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(fixture.appStartIntegration!!.getReusableTraceId()) } @Test fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() val activity = mock() @@ -1200,7 +1223,7 @@ class ActivityLifecycleIntegrationTest { fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init @@ -1227,7 +1250,7 @@ class ActivityLifecycleIntegrationTest { fun `When app-start end time is already set, it should not be overwritten`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -1251,7 +1274,7 @@ class ActivityLifecycleIntegrationTest { fun `When activity lifecycle happens multiple times, app-start end time should not be overwritten`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -1286,7 +1309,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(true) val date = SentryNanotimeDate(Date(1), 0) @@ -1303,7 +1326,7 @@ class ActivityLifecycleIntegrationTest { fun `When transaction is finished, it gets removed from scope`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1326,7 +1349,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = false - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1339,7 +1362,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1354,7 +1377,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true fixture.options.executorService = deferredExecutorService - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) val ttfdSpan = sut.ttfdSpanMap[activity] @@ -1391,7 +1414,7 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) val ttidSpan = sut.ttidSpanMap[activity] @@ -1418,7 +1441,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) val ttidSpan = sut.ttidSpanMap[activity] @@ -1461,7 +1484,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() val activity2 = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1500,7 +1523,7 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) // Make the integration create the spans and register to the FirstDrawDoneListener - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1541,7 +1564,7 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) // Make the integration create the spans and register to the FirstDrawDoneListener - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1606,7 +1629,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) // The ttid span should be running @@ -1628,7 +1651,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true fixture.options.executorService = deferredExecutorService - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1664,7 +1687,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) val ttfdSpan = sut.ttfdSpanMap[activity] assertNotNull(ttfdSpan) @@ -1696,7 +1719,7 @@ class ActivityLifecycleIntegrationTest { argumentCaptor.value.run(scope) } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context @@ -1719,7 +1742,7 @@ class ActivityLifecycleIntegrationTest { argumentCaptor.value.run(scope) } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) // once for the screen @@ -1740,7 +1763,7 @@ class ActivityLifecycleIntegrationTest { argumentCaptor.value.run(scope) } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context @@ -1762,7 +1785,7 @@ class ActivityLifecycleIntegrationTest { argumentCaptor.value.run(scope) } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context @@ -1791,7 +1814,7 @@ class ActivityLifecycleIntegrationTest { fun `when transaction is finished, sets frame metrics`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1807,7 +1830,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(5678), 910) @@ -1832,7 +1855,7 @@ class ActivityLifecycleIntegrationTest { fun `On activity preCreated onCreate span is started`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -1858,7 +1881,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.dateProvider = SentryDateProvider { startDate } setAppStartTime(appStartDate) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertTrue(sut.activitySpanHelpers.isEmpty()) sut.onActivityPreCreated(activity, null) @@ -1898,7 +1921,7 @@ class ActivityLifecycleIntegrationTest { // Don't set app start time, so there's no app start span // setAppStartTime(appStartDate) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertTrue(sut.activitySpanHelpers.isEmpty()) sut.onActivityPreCreated(activity, null) @@ -1932,7 +1955,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() setAppStartTime() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertTrue(sut.activitySpanHelpers.isEmpty()) sut.onActivityPreCreated(activity, null) @@ -1956,7 +1979,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.dateProvider = SentryDateProvider { startDate } setAppStartTime(appStartDate) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertTrue(sut.activitySpanHelpers.isEmpty()) sut.onActivityCreated(activity, null) @@ -1988,7 +2011,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() setAppStartTime() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertTrue(sut.activitySpanHelpers.isEmpty()) sut.onActivityCreated(activity, null) @@ -2008,7 +2031,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } setAppStartTime(appStartDate) - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) sut.setFirstActivityCreated(true) sut.onActivityPreCreated(activity, null) @@ -2030,7 +2053,7 @@ class ActivityLifecycleIntegrationTest { setAppStartTime(appStartDate) // Let's pretend app start started and finished appStartMetrics.appStartTimeSpan.stop() - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) assertEquals(0, sut.getProperty("lastPausedTime").nanoTimestamp()) 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 e61b0aef675..0d281d9f1d3 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 @@ -13,10 +13,10 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD -import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM -import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP +import io.sentry.android.core.StandaloneAppStartReporter.APP_START_SCREEN_DATA +import io.sentry.android.core.StandaloneAppStartReporter.STANDALONE_APP_START_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/StandaloneAppStartReporterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/StandaloneAppStartReporterTest.kt new file mode 100644 index 00000000000..3d28c4971e8 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/StandaloneAppStartReporterTest.kt @@ -0,0 +1,290 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +import io.sentry.SentryTracer +import io.sentry.SpanContext +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.android.core.StandaloneAppStartReporter.APP_START_SCREEN_DATA +import io.sentry.android.core.StandaloneAppStartReporter.STANDALONE_APP_START_OP +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.protocol.SentryId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +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.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class StandaloneAppStartReporterTest { + + private val traceOrigin = "auto.ui.activity" + + private val scopes = mock() + private val firstUiLoadCoordinator = FirstUiLoadCoordinator() + private val createdTransactions = mutableListOf() + private val capturedContexts = argumentCaptor() + private val capturedOptions = argumentCaptor() + + private fun getSut(): StandaloneAppStartReporter { + whenever(scopes.options) + .thenReturn(SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" }) + whenever(scopes.startTransaction(capturedContexts.capture(), capturedOptions.capture())) + .thenAnswer { + val t = SentryTracer(capturedContexts.lastValue, scopes, capturedOptions.lastValue) + createdTransactions.add(t) + return@thenAnswer t + } + return StandaloneAppStartReporter(scopes, traceOrigin, firstUiLoadCoordinator) + } + + @BeforeTest + fun setup() { + AppStartMetrics.getInstance().clear() + } + + @AfterTest + fun teardown() { + AppStartMetrics.getInstance().clear() + } + + // region classification: the single source of truth for "what kind of app start is this" + + @Test + fun `isStandaloneAppStart is true only for the app start operation`() { + assertTrue( + StandaloneAppStartReporter.isStandaloneAppStart(SpanContext(STANDALONE_APP_START_OP)) + ) + assertFalse(StandaloneAppStartReporter.isStandaloneAppStart(SpanContext("ui.load"))) + assertFalse(StandaloneAppStartReporter.isStandaloneAppStart(null)) + } + + @Test + fun `isHeadlessAppStart is true for a standalone app start without the screen marker`() { + assertTrue(StandaloneAppStartReporter.isHeadlessAppStart(SpanContext(STANDALONE_APP_START_OP))) + } + + @Test + fun `isHeadlessAppStart is false for an activity app start carrying the screen marker`() { + val context = SpanContext(STANDALONE_APP_START_OP) + context.setData(APP_START_SCREEN_DATA, "MainActivity") + assertFalse(StandaloneAppStartReporter.isHeadlessAppStart(context)) + } + + @Test + fun `isHeadlessAppStart is false for non standalone transactions`() { + assertFalse(StandaloneAppStartReporter.isHeadlessAppStart(SpanContext("ui.load"))) + assertFalse(StandaloneAppStartReporter.isHeadlessAppStart(null)) + } + + // endregion + + // region activity-launch path + + @Test + fun `onFirstUiLoadTransactionStarted emits an unbound App Start transaction sharing the ui load trace id`() { + val sut = getSut() + val traceId = SentryId() + val appStartTime = AndroidDateUtils.getCurrentSentryDateTime() + val uiLoadTransaction = + SentryTracer( + TransactionContext( + traceId, + "MainActivity", + io.sentry.protocol.TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + null, + ), + scopes, + TransactionOptions(), + ) + + val plan = sut.planFirstUiLoad("MainActivity", null, true) + sut.onFirstUiLoadTransactionStarted(uiLoadTransaction, plan, appStartTime, "MainActivity", null) + + val transaction = createdTransactions.single() + val context = capturedContexts.allValues.single() + val options = capturedOptions.allValues.single() + assertEquals(STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(traceId, context.traceId) + assertFalse(options.isBindToScope) + assertEquals(appStartTime, options.startTimestamp) + assertEquals(traceOrigin, options.origin) + assertEquals("MainActivity", transaction.getData(APP_START_SCREEN_DATA)) + assertSame(transaction, sut.getAppStartTransaction()) + } + + @Test + fun `onFirstFrameDrawn finishes the activity transaction at the given date`() { + val sut = getSut() + val appStartTime = AndroidDateUtils.getCurrentSentryDateTime() + val traceId = SentryId() + val uiLoadTransaction = + SentryTracer( + TransactionContext( + traceId, + "MainActivity", + io.sentry.protocol.TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + null, + ), + scopes, + TransactionOptions(), + ) + val plan = sut.planFirstUiLoad("MainActivity", null, true) + sut.onFirstUiLoadTransactionStarted(uiLoadTransaction, plan, appStartTime, "MainActivity", null) + + val endDate = AndroidDateUtils.getCurrentSentryDateTime() + sut.onFirstFrameDrawn(endDate) + + val transaction = createdTransactions.single() + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onActivityDestroyed cancels a running app start transaction and clears it`() { + val sut = getSut() + val uiLoadTransaction = + SentryTracer( + TransactionContext( + SentryId(), + "MainActivity", + io.sentry.protocol.TransactionNameSource.COMPONENT, + ActivityLifecycleIntegration.UI_LOAD_OP, + null, + ), + scopes, + TransactionOptions(), + ) + val plan = sut.planFirstUiLoad("MainActivity", null, true) + sut.onFirstUiLoadTransactionStarted( + uiLoadTransaction, + plan, + AndroidDateUtils.getCurrentSentryDateTime(), + "MainActivity", + null, + ) + + sut.onActivityDestroyed() + + val transaction = createdTransactions.single() + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.CANCELLED, transaction.status) + assertNull(sut.getAppStartTransaction()) + } + + @Test + fun `planFirstUiLoad reuses headless trace id and skips sibling app start`() { + val sut = getSut() + val storedTraceId = SentryId() + sut.setReusableTraceId(storedTraceId) + + val plan = sut.planFirstUiLoad("MainActivity", null, true) + + assertEquals(storedTraceId, plan.transactionContext.traceId) + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, plan.transactionContext.operation) + assertNull(sut.getReusableTraceId()) + val uiLoadTransaction = SentryTracer(plan.transactionContext, scopes, TransactionOptions()) + sut.onFirstUiLoadTransactionStarted( + uiLoadTransaction, + plan, + AndroidDateUtils.getCurrentSentryDateTime(), + "MainActivity", + null, + ) + verify(scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `planFirstUiLoad for later activities does not consume reusable trace id`() { + val sut = getSut() + val storedTraceId = SentryId() + sut.setReusableTraceId(storedTraceId) + + val plan = sut.planFirstUiLoad("SecondActivity", null, false) + + assertEquals(storedTraceId, sut.getReusableTraceId()) + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, plan.transactionContext.operation) + assertFalse(plan.shouldEmitSiblingAppStart()) + } + + // endregion + + // region headless path + + @Test + fun `onMainIdleNoActivity emits a finished App Start transaction and stashes its trace id`() { + val sut = getSut() + AppStartMetrics.getInstance().appStartTimeSpan.apply { + setStartedAt(100) + setStartUnixTimeMs(100) + setStoppedAt(200) + } + + sut.onMainIdleNoActivity() + + val transaction = createdTransactions.single() + val context = capturedContexts.allValues.single() + assertEquals(STANDALONE_APP_START_OP, context.operation) + assertEquals(context.traceId, transaction.spanContext.traceId) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + assertEquals(transaction.spanContext.traceId, sut.getReusableTraceId()) + // headless starts emit no parent transaction for later lifecycle spans + assertNull(sut.getAppStartTransaction()) + } + + @Test + fun `onMainIdleNoActivity does nothing when the app start time span is incomplete`() { + val sut = getSut() + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + sut.onMainIdleNoActivity() + + verify(scopes, never()).startTransaction(any(), any()) + assertNull(sut.getReusableTraceId()) + } + + // endregion + + // region listener wiring + + @Test + fun `register installs lifecycle and idle callbacks and close removes them`() { + val sut = getSut() + + sut.register() + assertNotNull(mainIdleNoActivityCallback()) + assertSame(sut, firstUiLoadCoordinator.getListener()) + + sut.close() + assertNull(mainIdleNoActivityCallback()) + assertNull(firstUiLoadCoordinator.getListener()) + } + + private fun mainIdleNoActivityCallback(): AppStartMetrics.OnMainIdleNoActivityCallback? { + val field = + AppStartMetrics::class.java.getDeclaredField("onMainIdleNoActivityCallback").apply { + isAccessible = true + } + return field.get(AppStartMetrics.getInstance()) as AppStartMetrics.OnMainIdleNoActivityCallback? + } + + // endregion +} 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 c66f180ec58..415da105bc1 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 @@ -16,7 +16,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess -import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -68,7 +67,6 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() - metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -82,7 +80,6 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) - assertNull(metrics.getAppStartTraceId()) } @Test @@ -238,10 +235,12 @@ class AppStartMetricsTest { } @Test - fun `headless app start fires HeadlessAppStartListener`() { + fun `headless app start fires OnMainIdleNoActivityCallback`() { val listenerCalls = AtomicInteger() - AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().setOnMainIdleNoActivityCallback { + listenerCalls.incrementAndGet() + } AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) waitForMainLooperIdle() @@ -249,11 +248,11 @@ class AppStartMetricsTest { } @Test - fun `activity start prevents HeadlessAppStartListener`() { + fun `activity start prevents OnMainIdleNoActivityCallback`() { val listenerCalls = AtomicInteger() val metrics = AppStartMetrics.getInstance() - metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.setOnMainIdleNoActivityCallback { listenerCalls.incrementAndGet() } metrics.onActivityCreated(mock(), null) metrics.registerLifecycleCallbacks(mock()) waitForMainLooperIdle() @@ -265,7 +264,6 @@ class AppStartMetricsTest { fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() { val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) - metrics.setHeadlessAppStartListener {} metrics.applicationOnCreateTimeSpan.apply { setStartedAt(120) setStoppedAt(200) @@ -273,6 +271,7 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() assertEquals(100, metrics.appStartTimeSpan.durationMs) } @@ -282,10 +281,9 @@ class AppStartMetricsTest { val metrics = AppStartMetrics.getInstance() metrics.setClassLoadedUptimeMs(200) metrics.appStartTimeSpan.setStartedAt(100) - metrics.setHeadlessAppStartListener {} - metrics.registerLifecycleCallbacks(mock()) waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() assertEquals(100, metrics.appStartTimeSpan.durationMs) } @@ -297,7 +295,6 @@ class AppStartMetricsTest { setStartedAt(100) setStoppedAt(150) } - metrics.setHeadlessAppStartListener {} metrics.applicationOnCreateTimeSpan.apply { setStartedAt(120) setStoppedAt(200) @@ -305,6 +302,7 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() assertEquals(50, metrics.appStartTimeSpan.durationMs) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index 330fed135e8..ba739ba64a2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -51,19 +52,22 @@ class AppStartMetricsTestApi35 { } @Test - fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + fun `known ApplicationStartInfo type without listener emits no headless app start`() { val mockStartInfo = mock() whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) waitForMainLooperIdle() + // The idle check is always scheduled now, but with no listener installed it resolves and + // emits nothing: the app start time span is left open and the resolved type is unchanged. assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertEquals(-1, metrics.firstIdle) + assertTrue(metrics.appStartTimeSpan.hasNotStopped()) } @Test @@ -116,7 +120,7 @@ class AppStartMetricsTestApi35 { val listenerCalls = AtomicInteger() val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) - metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.setOnMainIdleNoActivityCallback { listenerCalls.incrementAndGet() } val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) @@ -137,7 +141,7 @@ class AppStartMetricsTestApi35 { val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) metrics.setClassLoadedUptimeMs(200) - metrics.setHeadlessAppStartListener {} + metrics.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) @@ -168,7 +172,7 @@ class AppStartMetricsTestApi35 { SentryShadowProcess.setStartElapsedRealtime(processStartElapsedMs) val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(processStartUptimeMs) - metrics.setHeadlessAppStartListener {} + metrics.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) @@ -195,7 +199,7 @@ class AppStartMetricsTestApi35 { val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) metrics.setClassLoadedUptimeMs(200) - metrics.setHeadlessAppStartListener {} + metrics.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) @@ -220,7 +224,7 @@ class AppStartMetricsTestApi35 { metrics.registerLifecycleCallbacks(app) // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering - metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.setOnMainIdleNoActivityCallback { listenerCalls.incrementAndGet() } waitForMainLooperIdle() assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType)