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..4511c6dcf85 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 @@ -737,11 +739,13 @@ 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; 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 getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; @@ -768,7 +772,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setOnMainIdleNoActivityCallback (Lio/sentry/android/core/performance/AppStartMetrics$OnMainIdleNoActivityCallback;)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 +785,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$OnMainIdleNoActivityCallback { + public abstract fun onMainIdleNoActivity ()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..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 @@ -90,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 = @@ -98,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; @@ -124,6 +128,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -239,33 +244,53 @@ 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( + 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), - transactionOptions); + 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); - // 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 (isFirstProcessStart) { + if (firstUiLoadListener == null) { + // legacy path: app start is a child span of the ui.load transaction. + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -440,8 +465,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 +503,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 +582,11 @@ 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); + final @Nullable FirstUiLoadListener firstUiLoadListener = + firstUiLoadCoordinator.getListener(); + if (firstUiLoadListener != null) { + firstUiLoadListener.onActivityDestroyed(); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -637,22 +665,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 +693,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 +706,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 +819,20 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + 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; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +842,23 @@ 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); + final @Nullable FirstUiLoadListener firstUiLoadListener = + firstUiLoadCoordinator.getListener(); + if (firstUiLoadListener != null && appStartEndTime != null) { + firstUiLoadListener.onFirstFrameDrawn(appStartEndTime); + } } } } 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/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..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 @@ -84,9 +84,17 @@ 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 isHeadlessStandaloneAppStartTxn = + StandaloneAppStartReporter.isHeadlessAppStart(traceContext); + + 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 @@ -215,10 +223,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 StandaloneAppStartReporter.isStandaloneAppStart(txn.getContexts().getTrace()); } private void attachAppStartSpans( @@ -245,6 +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 && isStandalone) { + parentSpanId = traceContext.getSpanId(); + } + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +264,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 +279,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 +292,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 +302,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/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 746805fcfdc..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 @@ -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; @@ -49,6 +49,14 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + /** + * 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 { UNKNOWN, COLD, @@ -84,6 +92,10 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + 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() { if (instance == null) { @@ -161,6 +173,14 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + 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; + } + /** * Provides all collected content provider onCreate time spans * @@ -188,14 +208,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 +294,10 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + mainIdleNoActivityCheckPending.set(false); + mainIdleNoActivityCallbackInvoked.set(false); + onMainIdleNoActivityCallback = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -346,6 +386,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 +398,57 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // 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 scheduleMainIdleNoActivityCheckOnMain() { + if (!mainIdleNoActivityCheckPending.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(); + mainIdleNoActivityCheckPending.set(false); + handleMainIdleNoActivityOnMain(); + 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( + () -> { + mainIdleNoActivityCheckPending.set(false); + handleMainIdleNoActivityOnMain(); + }); }); } } - 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, applies shared boot bookkeeping and notifies {@link OnMainIdleNoActivityCallback} + * subscribers. Must be called on the main thread. + */ + private void handleMainIdleNoActivityOnMain() { if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground.setValue(false); + // 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; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); @@ -401,6 +458,67 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable OnMainIdleNoActivityCallback callback = onMainIdleNoActivityCallback; + if (callback != null && mainIdleNoActivityCallbackInvoked.compareAndSet(false, true)) { + callback.onMainIdleNoActivity(); + } + } + } + + /** + * 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(); + stopAppStartSpanAt(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); + } + stopAppStartSpanAt(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 + stopAppStartSpanAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopAppStartSpanAt(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..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 @@ -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,11 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() + val firstUiLoadCoordinator = FirstUiLoadCoordinator() + var appStartIntegration: AppStartIntegration? = null fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +110,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) @@ -112,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 { @@ -148,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()) } @@ -156,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() @@ -166,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() @@ -176,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) @@ -188,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) @@ -201,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() @@ -225,13 +259,198 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + fixture.registerIntegrations(sut) + + 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 == StandaloneAppStartReporter.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + 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 }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `OnMainIdleNoActivityCallback is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + fixture.registerIntegrations(sut) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + StandaloneAppStartReporter.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `OnMainIdleNoActivityCallback is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + fixture.registerIntegrations(sut) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `OnMainIdleNoActivityCallback is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + fixture.registerIntegrations(sut) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears OnMainIdleNoActivityCallback`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + fixture.registerIntegrations(sut) + fixture.closeIntegrations(sut) + 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 + } + fixture.registerIntegrations(sut) + 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(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, + fixture.appStartIntegration!!.getReusableTraceId(), + ) + 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 + } + fixture.registerIntegrations(sut) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(StandaloneAppStartReporter.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + fixture.appStartIntegration!!.getReusableTraceId(), + ) + 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 + } + fixture.registerIntegrations(sut) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(StandaloneAppStartReporter.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 + } + fixture.registerIntegrations(sut) + 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() 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) @@ -249,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) @@ -267,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) @@ -284,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) @@ -303,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) @@ -315,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() @@ -337,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) @@ -356,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) @@ -383,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() @@ -410,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) @@ -424,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) @@ -452,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 @@ -463,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) @@ -476,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) @@ -490,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) @@ -502,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) @@ -515,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() @@ -528,11 +747,33 @@ 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 + } + fixture.registerIntegrations(sut) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(StandaloneAppStartReporter.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() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) setAppStartTime() @@ -547,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() @@ -566,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() @@ -583,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() @@ -603,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() @@ -619,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()) @@ -632,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()) @@ -645,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()) @@ -657,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()) @@ -676,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()) @@ -698,7 +939,7 @@ class ActivityLifecycleIntegrationTest { fullyDisplayedReporter = ttfdReporter } - sut.register(fixture.scopes, fixture.options) + fixture.registerIntegrations(sut) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -710,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) @@ -735,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) @@ -758,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) @@ -786,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) @@ -803,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 @@ -830,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) @@ -854,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) @@ -882,11 +1123,107 @@ 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 + } + fixture.registerIntegrations(sut) + 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(StandaloneAppStartReporter.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 + } + fixture.registerIntegrations(sut) + fixture.appStartIntegration!!.setReusableTraceId(storedTraceId) + 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(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 } + fixture.registerIntegrations(sut) + 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) 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 @@ -913,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) @@ -937,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) @@ -972,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) @@ -989,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) @@ -1012,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) @@ -1025,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) @@ -1040,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] @@ -1077,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] @@ -1104,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] @@ -1147,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) @@ -1186,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) @@ -1227,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) @@ -1292,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 @@ -1314,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) @@ -1350,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) @@ -1382,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 @@ -1405,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 @@ -1426,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 @@ -1448,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 @@ -1477,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) @@ -1493,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) @@ -1518,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) @@ -1544,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) @@ -1584,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) @@ -1618,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) @@ -1642,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) @@ -1674,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) @@ -1694,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) @@ -1716,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()) @@ -1737,6 +2074,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..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 @@ -15,6 +15,8 @@ import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM 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 @@ -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/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 c15ea3c37d0..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 @@ -18,6 +18,7 @@ import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess 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 +45,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -167,10 +169,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 +182,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 +196,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 +210,126 @@ 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 OnMainIdleNoActivityCallback`() { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setOnMainIdleNoActivityCallback { + listenerCalls.incrementAndGet() + } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `activity start prevents OnMainIdleNoActivityCallback`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setOnMainIdleNoActivityCallback { 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.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() + + 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.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() + + 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.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + metrics.finalizeHeadlessAppStartEndTime() + + 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 +453,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 +491,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 +509,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 +707,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +912,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 +925,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 +933,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..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 @@ -3,16 +3,23 @@ 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 kotlin.test.assertTrue 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 +32,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 +51,25 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + 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) + assertTrue(metrics.appStartTimeSpan.hasNotStopped()) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +109,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.setOnMainIdleNoActivityCallback { 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.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } + + 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.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } + + 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.setOnMainIdleNoActivityCallback { metrics.finalizeHeadlessAppStartEndTime() } + + 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.setOnMainIdleNoActivityCallback { 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) + } }