diff --git a/CHANGELOG.md b/CHANGELOG.md index 648533ec18..24bc9589a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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) + - On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) ### Improvements diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0500ba4499..58325d08b5 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -746,6 +746,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartEndTime ()Lio/sentry/SentryDate; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; @@ -780,6 +781,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V public fun setClassLoadedUptimeMs (J)V public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z 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 19cee7fcce..3a86dd8b45 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 @@ -72,6 +72,7 @@ public final class ActivityLifecycleIntegration static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_REASON_DATA = "app.vitals.start.reason"; static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; @@ -140,6 +141,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + addIntegrationToSdkVersion("StandaloneAppStart"); } this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); @@ -286,6 +288,10 @@ private void startTracing(final @NotNull Activity activity) { appStartSamplingDecision), appStartTransactionOptions); appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason(); + if (appStartReason != null) { + appStartTransaction.setData(APP_START_REASON_DATA, appStartReason); + } } // Continue either the foreground app.start above or an earlier headless app.start. @@ -1002,6 +1008,10 @@ private void onHeadlessAppStart() { null); final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); // Persist trace headers so a later ui.load can share traceId and sampleRand. metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); 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 d8cb0827ba..36cae8686c 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 @@ -166,6 +166,45 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + /** + * The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}. + * Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null} + * otherwise or for an unmapped reason. + */ + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + switch (cachedStartInfo.getReason()) { + 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_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -372,6 +411,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) { CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs; } + @TestOnly + @ApiStatus.Internal + public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) { + this.cachedStartInfo = cachedStartInfo; + } + /** * Called by instrumentation * 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 f2ffb4b4b9..2ca25a102a 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application +import android.app.ApplicationStartInfo import android.content.Context import android.os.Build import android.os.Bundle @@ -275,6 +276,76 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Standalone app start transaction carries app start reason when available`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `Standalone app start transaction has no app start reason when unavailable`() { + 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) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertNull(appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Headless standalone app start transaction carries app start reason when available`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertEquals("broadcast", transaction.getData("app.vitals.start.reason")) + } + @Test fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { val sut = 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 3068685215..b5d87ab77c 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 @@ -15,6 +15,7 @@ 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 @@ -207,6 +208,47 @@ class AppStartMetricsTestApi35 { assertEquals(1, listenerCalls.get()) } + @Test + fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() { + 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_BROADCAST) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals("broadcast", metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null when no ApplicationStartInfo is available`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null for an unmapped reason`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle()