Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<ApplicationStartInfo> 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<ApplicationStartInfo> 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -47,13 +51,15 @@ class AppStartMetricsTestApi35 {
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))

val app = ApplicationProvider.getApplicationContext<Application>()
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<ApplicationStartInfo>()
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
Expand All @@ -62,10 +68,12 @@ class AppStartMetricsTestApi35 {

val app = ApplicationProvider.getApplicationContext<Application>()
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
Expand All @@ -76,13 +84,15 @@ class AppStartMetricsTestApi35 {
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))

val app = ApplicationProvider.getApplicationContext<Application>()
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<ApplicationStartInfo>()
whenever(mockStartInfo.startupState)
.thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN)
Expand All @@ -93,19 +103,24 @@ class AppStartMetricsTestApi35 {

val app = ApplicationProvider.getApplicationContext<Application>()
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<Application>()
metrics.registerLifecycleCallbacks(app)

assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType)

metrics.onActivityCreated(mock(), null)
assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType)
}

@Test
Expand Down
Loading