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)
+ }
}