diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b5b7d1686..9ba11385713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### 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) + ### Dependencies - Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0d83082548f..58b5e18ef3d 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 @@ -713,9 +715,12 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getActivityLifecycleTimeSpans ()Ljava/util/List; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanDirect ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -739,8 +744,10 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setOnNoActivityStartedListener (Lio/sentry/android/core/performance/AppStartMetrics$OnNoActivityStartedListener;)V public fun shouldSendStartMeasurements ()Z } @@ -752,6 +759,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$OnNoActivityStartedListener { + public abstract fun onNoActivityStarted ()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..eaa4c63b588 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -33,6 +33,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -55,12 +56,15 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; static final long TTFD_TIMEOUT_MILLIS = 25000; private static final String TRACE_ORIGIN = "auto.ui.activity"; + private static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + private static final String APP_START_REASON_DATA = "app.vitals.start.reason"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,6 +81,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -124,6 +129,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance().setOnNoActivityStartedListener(this::onNoActivityStarted); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +145,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setOnNoActivityStartedListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -240,32 +251,82 @@ private void startTracing(final @NotNull Activity activity) { setSpanOrigin(transactionOptions); // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + // If a non-activity app start transaction was created earlier, reuse its trace ID + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + // When we reuse a stashed traceId, it means the process's app start has already been + // accounted for by a standalone transaction from the non-activity path — don't emit + // a second standalone here just because an activity subsequently showed up. + final boolean isFollowingNonActivityStart = (storedAppStartTraceId != null); + + final ITransaction transaction; + if (storedAppStartTraceId != null) { + transaction = + scopes.startTransaction( + new TransactionContext( + storedAppStartTraceId, + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + AppStartMetrics.getInstance().setAppStartTraceId(null); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); // in case appStartTime isn't available, we don't create a span for it. if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (options.isEnableStandaloneAppStartTracing() + && foregroundImportance + && !isFollowingNonActivityStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + setSpanOrigin(appStartTransactionOptions); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + transaction.getSpanContext().getTraceId(), + getAppStartTxnName(), + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + setAppStartReasonData(appStartTransaction, AppStartMetrics.getInstance()); + + // in case there's already an end time (e.g. due to deferred SDK init) + // we can finish the app start transaction + finishAppStartSpan(); + } else if (!options.isEnableStandaloneAppStartTracing()) { + // Legacy behavior: app start span as child of activity transaction + 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(); + } + // else: flag ON but not foreground — non-activity launch path is handled + // via OnNoActivityStartedListener callback in checkCreateTimeOnMain() } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -440,8 +501,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 +539,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 +618,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +637,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -637,21 +700,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(); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final @NotNull SentryDate endDate = firstFrameEndDate; final long durationNanos = endDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( @@ -677,6 +742,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 +855,28 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + + private @NotNull String getAppStartTxnName() { + return "App Start"; + } + + private void setAppStartReasonData( + final @NotNull ITransaction transaction, final @NotNull AppStartMetrics metrics) { + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +886,61 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } + } + } + + private void onNoActivityStarted() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // For non-activity starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanDirect(); + + 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 boolean coldStart = metrics.getAppStartType() == AppStartMetrics.AppStartType.COLD; + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + setSpanOrigin(txnOptions); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + getAppStartTxnName(), TransactionNameSource.COMPONENT, STANDALONE_APP_START_OP, null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + setAppStartReasonData(transaction, metrics); + + // Store trace ID so future activity transactions can share it + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + + transaction.finish(SpanStatus.OK, endTime); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 2779f803a69..6fe85cc1d67 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -232,6 +232,10 @@ public static Map getAppStartMeasurement() { final @NotNull Map result = new HashMap<>(); result.put("spans", spans); result.put("type", metrics.getAppStartType().toString().toLowerCase(Locale.ROOT)); + final @Nullable String reason = metrics.getAppStartReason(); + if (reason != null) { + result.put("reason", reason); + } if (metrics.getAppStartTimeSpan().hasStarted()) { result.put("app_start_timestamp_ms", metrics.getAppStartTimeSpan().getStartTimestampMs()); } 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 6d90bb5ca8e..8a252c0fb46 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 @@ -106,6 +106,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"; @@ -493,6 +496,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..2397970d464 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 @@ -2,6 +2,7 @@ import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -84,9 +85,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 non-activity starts, appLaunchedInForeground is false, so + // shouldSendStartMeasurements() would return false. We still want to attach child spans. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + + if (appStartMetrics.shouldSendStartMeasurements() || isStandaloneAppStartTxn) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanDirect() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -216,9 +225,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +252,14 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() 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 054e43322a2..5b4e1eb7ebb 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,52 @@ 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. + *
  • Without an Activity: for launches started by something like a broadcast receiver, + * service, or content provider, 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, no-Activity 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 no-Activity start, its {@code ui.load} transaction reuses + * the app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 746805fcfdc..91f867b6792 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -25,6 +25,7 @@ import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface OnNoActivityStartedListener { + void onNoActivityStarted(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,10 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private @Nullable OnNoActivityStartedListener noActivityStartedListener; + private @Nullable SentryId appStartTraceId; + private @Nullable Application applicationContext; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -152,6 +161,51 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + @SuppressWarnings("NewApi") + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + + try { + return normalizeAppStartReason(cachedStartInfo.getReason()); + } catch (Throwable ignored) { + return null; + } + } + + @SuppressWarnings("NewApi") + private @NotNull String normalizeAppStartReason(final int reason) { + switch (reason) { + case ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + return "launcher_recents"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + default: + return "unknown"; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -161,6 +215,31 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setOnNoActivityStartedListener(final @Nullable OnNoActivityStartedListener listener) { + this.noActivityStartedListener = listener; + } + + /** Trace ID from a non-activity app start transaction, to be reused by a later activity. */ + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for non-activity starts where appLaunchedInForeground is + * false. + */ + public @NotNull TimeSpan getAppStartTimeSpanDirect() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * Provides all collected content provider onCreate time spans * @@ -258,6 +337,10 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + noActivityStartedListener = null; + appStartTraceId = null; + applicationContext = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -336,6 +419,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } isCallbackRegistered = true; appLaunchedInForeground.resetValue(); + applicationContext = application; application.registerActivityLifecycleCallbacks(instance); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { @@ -346,6 +430,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,7 +442,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( @@ -369,7 +454,7 @@ public boolean queueIdle() { return false; } }); - } else if (appStartType == AppStartType.UNKNOWN) { + } else { // 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 @@ -392,6 +477,15 @@ private void checkCreateTimeOnMain() { if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground.setValue(false); + // Reaching this callback means Application.onCreate() finished with no Activity created, + // which is definitionally a cold start for this process. On API < 35 we can't resolve the + // start type via ApplicationStartInfo, so appStartType is still UNKNOWN at this point — + // default it to COLD so the standalone transaction (and PerformanceAndroidEventProcessor) + // classify it correctly. + 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,9 +495,50 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + resolveNonActivityAppStartEndTime(); + + if (noActivityStartedListener != null) { + noActivityStartedListener.onNoActivityStarted(); + } } } + private void resolveNonActivityAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + appStartSpan.setStoppedAt(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(); + // Timestamps are in nanoseconds (monotonic clock) + final @Nullable Long onCreateNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateNanos != null && onCreateNanos > 0) { + final long onCreateUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateNanos); + appStartSpan.setStoppedAt(onCreateUptimeMs); + + // Also fill applicationOnCreate stop time if not already set by Gradle plugin + if (applicationOnCreate.hasStarted() && applicationOnCreate.hasNotStopped()) { + applicationOnCreate.setStoppedAt(onCreateUptimeMs); + } + return; + } + } catch (Throwable ignored) { + // best effort + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) — always available + appStartSpan.setStoppedAt(CLASS_LOADED_UPTIME_MS); + } + @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { final long activityCreatedUptimeMillis = SystemClock.uptimeMillis(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationApi35Test.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationApi35Test.kt new file mode 100644 index 00000000000..37a121a4269 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationApi35Test.kt @@ -0,0 +1,149 @@ +package io.sentry.android.core + +import android.app.Activity +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.NoOpLogger +import io.sentry.Scopes +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.android.core.performance.AppStartMetrics +import java.util.concurrent.TimeUnit +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM], + shadows = [SentryShadowProcess::class, SentryShadowActivityManager::class], +) +class ActivityLifecycleIntegrationApi35Test { + @BeforeTest + fun setup() { + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) + } + + @Test + fun `non-activity standalone app start transaction includes app start reason`() { + setAppStartInfo(ApplicationStartInfo.START_REASON_PUSH) + + val app = ApplicationProvider.getApplicationContext() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.registerLifecycleCallbacks(app) + + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + isEnableStandaloneAppStartTracing = true + } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + + val createdTransactions = mutableListOf() + val contextCaptor = argumentCaptor() + val optionCaptor = argumentCaptor() + whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())).thenAnswer { + SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue).also { + createdTransactions.add(it) + } + } + + val sut = ActivityLifecycleIntegration(app, BuildInfoProvider(NoOpLogger.getInstance()), mock()) + try { + sut.register(scopes, options) + waitForMainLooperIdle() + + val appStartTransaction = + createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("push", appStartTransaction.getData("app.vitals.start.reason")) + assertNull(appStartTransaction.getData("app.vitals.start.screen")) + } finally { + sut.close() + } + } + + @Test + fun `activity standalone app start transaction includes app start reason`() { + setAppStartInfo(ApplicationStartInfo.START_REASON_LAUNCHER) + + val app = ApplicationProvider.getApplicationContext() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.registerLifecycleCallbacks(app) + + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + isEnableStandaloneAppStartTracing = true + } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + + val createdTransactions = mutableListOf() + val contextCaptor = argumentCaptor() + val optionCaptor = argumentCaptor() + whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())).thenAnswer { + SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue).also { + createdTransactions.add(it) + } + } + + val sut = ActivityLifecycleIntegration(app, BuildInfoProvider(NoOpLogger.getInstance()), mock()) + try { + sut.register(scopes, options) + sut.onActivityCreated(mock(), null) + + val appStartTransaction = + createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + } finally { + sut.close() + } + } + + private fun setAppStartInfo(reason: Int) { + val appStartEndMs = 250L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(appStartEndMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} 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..fe207aead8d 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 @@ -83,6 +85,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +107,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +235,163 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `OnNoActivityStartedListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.UNKNOWN) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `OnNoActivityStartedListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `OnNoActivityStartedListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears OnNoActivityStartedListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareNonActivityAppStart() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onNoActivityStarted creates standalone App Start transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.COLD) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onNoActivityStarted creates standalone App Start transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareNonActivityAppStart(appStartType = AppStartType.WARM) + + driveNoActivityStarted() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onNoActivityStarted does nothing when appStartTimeSpan is incomplete`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveNoActivityStarted() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +695,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1071,102 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `activity following a non-activity start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2022,41 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveNoActivityStarted() { + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareNonActivityAppStart( + 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 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/InternalSentrySdkApi35Test.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkApi35Test.kt new file mode 100644 index 00000000000..0c30297c90d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkApi35Test.kt @@ -0,0 +1,42 @@ +package io.sentry.android.core + +import android.app.Application +import android.app.ApplicationStartInfo +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.performance.AppStartMetrics +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM], + shadows = [SentryShadowActivityManager::class], +) +class InternalSentrySdkApi35Test { + @BeforeTest + fun setup() { + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + } + + @Test + fun `app start measurement includes app start reason for hybrids`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals("launcher", InternalSentrySdk.getAppStartMeasurement()["reason"]) + } +} 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 81b73d5dea7..dab600aad6c 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 @@ -1442,6 +1442,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..86eae202e47 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 @@ -14,6 +14,7 @@ import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -95,6 +96,63 @@ 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 `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) @@ -867,12 +925,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/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..f1f8be8730c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -16,8 +16,10 @@ import io.sentry.SentryNanotimeDate import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -44,6 +46,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +68,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +82,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -208,6 +213,93 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `checkCreateTimeOnMain defaults appStartType to COLD when UNKNOWN and no activity started`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `checkCreateTimeOnMain does not overwrite appStartType when already set`() { + 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 `checkCreateTimeOnMain fires onNoActivityStartedListener when no activity started`() { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setOnNoActivityStartedListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `checkCreateTimeOnMain does not fire onNoActivityStartedListener when an activity has started`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setOnNoActivityStartedListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveNonActivityAppStartEndTime 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() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveNonActivityAppStartEndTime 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() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `getAppStartTimeSpanDirect 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.getAppStartTimeSpanDirect()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -585,7 +677,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) 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..94931e34185 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.assertNull 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) @@ -26,6 +33,7 @@ class AppStartMetricsTestApi35 { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -81,4 +89,101 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `normalizes app start reason using ApplicationStartInfo on API 35`() { + val reasons = + mapOf( + ApplicationStartInfo.START_REASON_ALARM to "alarm", + ApplicationStartInfo.START_REASON_BACKUP to "backup", + ApplicationStartInfo.START_REASON_BOOT_COMPLETE to "boot_complete", + ApplicationStartInfo.START_REASON_BROADCAST to "broadcast", + ApplicationStartInfo.START_REASON_CONTENT_PROVIDER to "content_provider", + ApplicationStartInfo.START_REASON_JOB to "job", + ApplicationStartInfo.START_REASON_LAUNCHER to "launcher", + ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS to "launcher_recents", + ApplicationStartInfo.START_REASON_OTHER to "other", + ApplicationStartInfo.START_REASON_PUSH to "push", + ApplicationStartInfo.START_REASON_SERVICE to "service", + ApplicationStartInfo.START_REASON_START_ACTIVITY to "start_activity", + 999 to "unknown", + ) + + reasons.forEach { (reason, expected) -> + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals(expected, AppStartMetrics.getInstance().appStartReason) + } + } + + @Test + fun `app start reason is null when ApplicationStartInfo list is empty`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `checkCreateTimeOnMain keeps appStartType COLD when ApplicationStartInfo reports cold start`() { + 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.setOnNoActivityStartedListener { 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 `resolveNonActivityAppStartEndTime uses ApplicationStartInfo application onCreate timestamp`() { + val onCreateUptimeMs = 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(onCreateUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(150, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + 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 548e5e8ac0d..32bea127d97 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..424494455cc --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,25 @@ +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: 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 b9cbb2ae1b2..98f46414705 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4539,6 +4539,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) + } }