diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfc501e9d6..4d3acdd07c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **IMPORTANT:** This disables collecting external storage size (total/free) by default, to enable it back use `options.isCollectExternalStorageContext = true` or `` - Fix `NullPointerException` when reading ANR marker ([#4979](https://github.com/getsentry/sentry-java/pull/4979)) +- Improve app start type detection with main thread timing ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) ### Improvements 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 add5762fbd4..35f69beeedf 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 @@ -57,6 +57,7 @@ public enum AppStartType { private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; private boolean appLaunchedInForeground; + private volatile long firstPostUptimeMillis = -1; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -234,6 +235,7 @@ public void clear() { shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); + firstPostUptimeMillis = -1; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -316,7 +318,15 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); + new Handler(Looper.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + firstPostUptimeMillis = SystemClock.uptimeMillis(); + checkCreateTimeOnMain(); + } + }); } private void checkCreateTimeOnMain() { @@ -348,7 +358,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, it's likely wrong + // If the app (process) was launched more than 1 minute ago, consider it a warm start final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { appStartType = AppStartType.WARM; @@ -360,8 +370,12 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved CLASS_LOADED_UPTIME_MS = nowUptimeMs; contentProviderOnCreates.clear(); applicationOnCreate.reset(); + } else if (savedInstanceState != null) { + appStartType = AppStartType.WARM; + } else if (firstPostUptimeMillis > 0 && nowUptimeMs > firstPostUptimeMillis) { + appStartType = AppStartType.WARM; } else { - appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + appStartType = AppStartType.COLD; } } appLaunchedInForeground = true; 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 24159cab5cb..863fb8f828c 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 @@ -537,4 +537,127 @@ class AppStartMetricsTest { assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) } + + @Test + fun `firstPostUptimeMillis is properly cleared`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + val firstPostValue = reflectionField.getLong(metrics) + assertTrue(firstPostValue > 0) + + metrics.clear() + + val clearedValue = reflectionField.getLong(metrics) + assertEquals(-1, clearedValue) + } + + @Test + fun `firstPostUptimeMillis is set when registerLifecycleCallbacks is called`() { + val metrics = AppStartMetrics.getInstance() + val beforeRegister = SystemClock.uptimeMillis() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val afterIdle = SystemClock.uptimeMillis() + + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + val firstPostValue = reflectionField.getLong(metrics) + + assertTrue(firstPostValue >= beforeRegister) + assertTrue(firstPostValue <= afterIdle) + } + + @Test + fun `Sets app launch type to WARM when activity created after firstPost`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created before firstPost executes`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created at same time as firstPost`() { + val metrics = AppStartMetrics.getInstance() + + val now = SystemClock.uptimeMillis() + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + reflectionField.setLong(metrics, now) + + SystemClock.setCurrentTimeMillis(now) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `savedInstanceState check takes precedence over firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `timeout check takes precedence over firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(futureTime, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `firstPost timing does not affect subsequent activity creations`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } }