diff --git a/CHANGELOG.md b/CHANGELOG.md index a01233aebf9..cb9414a19ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Performance +- Defer the API 35+ `getHistoricalProcessStartReasons` binder call to a background thread so it no longer blocks the main thread during app start ([#5866](https://github.com/getsentry/sentry-java/pull/5866)) - Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) - Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819)) - Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814)) 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 0f4b646856a..d313b728bad 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 @@ -1,5 +1,6 @@ package io.sentry.android.core.performance; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; import android.app.Application; @@ -35,6 +36,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -99,7 +101,17 @@ public enum AppStartType { private @Nullable String appStartSentryTraceHeader; private @Nullable String appStartBaggageHeader; private @Nullable SentryDate appStartEndTime; - private @Nullable ApplicationStartInfo cachedStartInfo; + // Volatile: written from the background lookup thread, read from the main thread. Null until the + // deferred getHistoricalProcessStartReasons lookup resolves. + private volatile @Nullable ApplicationStartInfo cachedStartInfo; + // Runs the getHistoricalProcessStartReasons binder call off the main thread. The Sentry executor + // does not exist this early (ContentProvider time), so a plain daemon thread is used by default. + private @NotNull Executor startInfoExecutor = + command -> { + final Thread thread = new Thread(command, "SentryAppStartInfo"); + thread.setDaemon(true); + thread.start(); + }; private final @NotNull AppStartExtension appStartExtension = new AppStartExtension(this); public static @NotNull AppStartMetrics getInstance() { @@ -487,37 +499,59 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { final @Nullable ActivityManager activityManager = (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { - try { - final List historicalProcessStartReasons = - 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; - } else { - appStartType = AppStartType.WARM; - } - } - } - } catch (RuntimeException ignored) { - // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: - // - SecurityException when called from an isolated process - // - IllegalArgumentException when called with a wrong userId - // - others - // See impl: - // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 - Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log - } + // getHistoricalProcessStartReasons blocks on a system_server binder round-trip. Run it off + // the main thread so it doesn't stall the coldest part of app start. The result is consumed + // best-effort: appStartType (see onActivityCreated) falls back to the pre-API-35 heuristic + // and getAppStartReason returns null if the lookup hasn't resolved yet. + startInfoExecutor.execute(() -> loadStartInfo(activityManager)); } } - if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { - scheduleHeadlessAppStartCheckOnMain(); + scheduleHeadlessAppStartCheckOnMain(); + } + + @SuppressLint("NewApi") // reached only from the API 35+ guarded branch above + private void loadStartInfo(final @NotNull ActivityManager activityManager) { + try { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + cachedStartInfo = historicalProcessStartReasons.get(0); + } + } catch (RuntimeException ignored) { + // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: + // - SecurityException when called from an isolated process + // - IllegalArgumentException when called with a wrong userId + // - others + // See impl: + // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 + Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log } } + /** + * Maps the deferred {@link ApplicationStartInfo} to an app start type. Returns {@link + * AppStartType#UNKNOWN} when the lookup hasn't resolved yet or the OS hadn't finished the start, + * so callers fall back to the pre-API-35 heuristic. + */ + private @NotNull AppStartType getStartInfoAppStartType() { + final @Nullable ApplicationStartInfo info = cachedStartInfo; + if (info == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return AppStartType.UNKNOWN; + } + if (info.getStartupState() != ApplicationStartInfo.STARTUP_STATE_STARTED) { + return AppStartType.UNKNOWN; + } + return info.getStartType() == ApplicationStartInfo.START_TYPE_COLD + ? AppStartType.COLD + : AppStartType.WARM; + } + + @TestOnly + void setStartInfoExecutor(final @NotNull Executor startInfoExecutor) { + this.startInfoExecutor = startInfoExecutor; + } + private void scheduleHeadlessAppStartCheckOnMain() { if (!headlessAppStartCheckPending.compareAndSet(false, true)) { return; @@ -562,10 +596,12 @@ private void handleHeadlessAppStartIfNeededOnMain() { appLaunchedInForeground.setValue(false); - // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. - // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. Prefer the + // deferred ApplicationStartInfo type if its lookup has resolved, otherwise classify the + // process start as cold. if (appStartType == AppStartType.UNKNOWN) { - appStartType = AppStartType.COLD; + final @NotNull AppStartType startInfoType = getStartInfoAppStartType(); + appStartType = startInfoType != AppStartType.UNKNOWN ? startInfoType : AppStartType.COLD; } // we stop the app start profilers, as they are useless and likely to timeout @@ -657,8 +693,12 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved contentProviderOnCreates.clear(); applicationOnCreate.reset(); } else if (appStartType == AppStartType.UNKNOWN) { - // pre API 35 handling - if (savedInstanceState != null) { + // Prefer the authoritative API 35+ ApplicationStartInfo if its deferred lookup has + // resolved, otherwise fall back to the pre-API-35 heuristic. + final @NotNull AppStartType startInfoType = getStartInfoAppStartType(); + if (startInfoType != AppStartType.UNKNOWN) { + appStartType = startInfoType; + } else if (savedInstanceState != null) { appStartType = AppStartType.WARM; } else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { appStartType = AppStartType.WARM; 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 0624e70b898..a71435ab514 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 @@ -16,6 +16,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -37,6 +38,9 @@ class AppStartMetricsTestApi35 { SentryShadowActivityManager.reset() AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true + // Resolve the deferred getHistoricalProcessStartReasons lookup synchronously so tests can + // observe cachedStartInfo right after registerLifecycleCallbacks. + AppStartMetrics.getInstance().setStartInfoExecutor { it.run() } } @Test @@ -47,13 +51,15 @@ class AppStartMetricsTestApi35 { SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) val app = ApplicationProvider.getApplicationContext() - AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) - assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + fun `foreground launch with known start type is not treated as headless`() { val mockStartInfo = mock() whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) @@ -62,10 +68,12 @@ class AppStartMetricsTestApi35 { val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + // The headless idle check is always scheduled now; a created activity must keep it a no-op. waitForMainLooperIdle() assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertEquals(-1, metrics.firstIdle) + assertTrue(metrics.isAppLaunchedInForeground) } @Test @@ -76,13 +84,15 @@ class AppStartMetricsTestApi35 { SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) val app = ApplicationProvider.getApplicationContext() - AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) - assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test - fun `does not set app start type when ApplicationStartInfo list is invalid`() { + fun `ignores ApplicationStartInfo when startup state is not started`() { val mockStartInfo = mock() whenever(mockStartInfo.startupState) .thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN) @@ -93,19 +103,24 @@ class AppStartMetricsTestApi35 { val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) - assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + // Not STARTED, so the start type is ignored and the heuristic classifies the foreground launch. + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `does not set app start type when ApplicationStartInfo list is empty`() { + fun `falls back to heuristic when ApplicationStartInfo list is empty`() { SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) val metrics = AppStartMetrics.getInstance() val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) - assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test