From df4b9aab98295176b74e41d2aa1da61303057c0b Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 30 Jul 2026 18:42:22 +0200 Subject: [PATCH 1/3] fix(app-start): Fix detection of foreground app start --- .../core/performance/AppStartMetrics.java | 37 ++++++-------- .../core/performance/AppStartMetricsTest.kt | 51 ++++++++++++++----- 2 files changed, 53 insertions(+), 35 deletions(-) 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 97f5c5fd6ec..9faae2106b2 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 @@ -30,7 +30,6 @@ import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -70,14 +69,7 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private final LazyEvaluator appLaunchedInForeground = - new LazyEvaluator<>( - new LazyEvaluator.Evaluator() { - @Override - public @NotNull Boolean evaluate() { - return ContextUtils.isForegroundImportance(); - } - }); + private @Nullable volatile Boolean appLaunchedInForeground; private volatile long firstIdle = -1; private final @NotNull TimeSpan appStartSpan; @@ -239,12 +231,12 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } public boolean isAppLaunchedInForeground() { - return appLaunchedInForeground.getValue(); + return Boolean.TRUE.equals(appLaunchedInForeground); } @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { - this.appLaunchedInForeground.setValue(appLaunchedInForeground); + this.appLaunchedInForeground = appLaunchedInForeground; } public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { @@ -318,8 +310,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { - return shouldSendStartMeasurements - && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + return shouldSendStartMeasurements && (ignoreForegroundCheck || isAppLaunchedInForeground()); } public boolean shouldSendStartMeasurements() { @@ -349,7 +340,7 @@ public long getClassLoadedUptimeMs() { final @NotNull SentryAndroidOptions options) { // If the app start type was never determined or app wasn't launched in foreground, // the app start is considered invalid - if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground.getValue()) { + if (appStartType != AppStartType.UNKNOWN && isAppLaunchedInForeground()) { if (options.isEnablePerformanceV2()) { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); @@ -412,7 +403,7 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground.resetValue(); + appLaunchedInForeground = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); @@ -510,7 +501,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { return; } isCallbackRegistered = true; - appLaunchedInForeground.resetValue(); + appLaunchedInForeground = null; application.registerActivityLifecycleCallbacks(instance); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { @@ -529,7 +520,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } else { appStartType = AppStartType.WARM; } - appLaunchedInForeground.setValue(isForegroundStartReason(info.getReason())); + appLaunchedInForeground = isForegroundStartReason(info.getReason()); } } } catch (RuntimeException ignored) { @@ -543,6 +534,10 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } } + // Fallback, if not matching ApplicationStartInfo is available + if (appLaunchedInForeground == null) { + appLaunchedInForeground = ContextUtils.isForegroundImportance(); + } if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { scheduleHeadlessAppStartCheckOnMain(); @@ -591,7 +586,7 @@ private void handleHeadlessAppStartIfNeededOnMain() { return; } - appLaunchedInForeground.setValue(false); + appLaunchedInForeground = 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. @@ -677,7 +672,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved // An active extension explicitly keeps the launch alive: resetting the span here would make // the extended vital measure from the activity while the eager app.start transaction stays // anchored at process start. - if ((!appLaunchedInForeground.getValue() + if ((!isAppLaunchedInForeground() || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) && !appStartExtension.isActive()) { appStartType = AppStartType.WARM; @@ -698,7 +693,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved } } } - appLaunchedInForeground.setValue(true); + appLaunchedInForeground = true; } @Override @@ -744,7 +739,7 @@ public void onActivityDestroyed(@NonNull Activity activity) { // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { appStartType = AppStartType.WARM; - appLaunchedInForeground.setValue(true); + appLaunchedInForeground = true; shouldSendStartMeasurements = true; firstDrawDone.set(false); } 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 edf72655740..6a32e7d453b 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 @@ -241,15 +241,16 @@ class AppStartMetricsTest { } @Test - fun `headless app start fires HeadlessAppStartListener`() = headlessProcess { - val listenerCalls = AtomicInteger() + fun `headless app start fires HeadlessAppStartListener`() = + withProcessImportance(false) { + val listenerCalls = AtomicInteger() - AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } - AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) - waitForMainLooperIdle() + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() - assertEquals(1, listenerCalls.get()) - } + assertEquals(1, listenerCalls.get()) + } @Test fun `foreground process does not fire HeadlessAppStartListener`() { @@ -295,7 +296,7 @@ class AppStartMetricsTest { @Test fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = - headlessProcess { + withProcessImportance(false) { val metrics = AppStartMetrics.getInstance() metrics.appStartTimeSpan.setStartedAt(100) metrics.setHeadlessAppStartListener {} @@ -312,7 +313,7 @@ class AppStartMetricsTest { @Test fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = - headlessProcess { + withProcessImportance(false) { val metrics = AppStartMetrics.getInstance() metrics.setClassLoadedUptimeMs(200) metrics.appStartTimeSpan.setStartedAt(100) @@ -371,12 +372,18 @@ class AppStartMetricsTest { Shadows.shadowOf(Looper.getMainLooper()).idle() } - // Simulates a real headless start (broadcast/service), i.e. a non-foreground-importance process. - // The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so headless - // scenarios must opt into a background importance explicitly. - private fun headlessProcess(block: () -> T): T = + /** + * Mocks the process importance to simulate a user initiated start (e.g. launcher) or a real + * headless start (broadcast/service), i.e. a non-foreground-importance process. + * + * The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so any headless + * scenarios must opt into a background importance explicitly. + */ + private fun withProcessImportance(isForeground: Boolean, block: () -> T): T = mockStatic(ContextUtils::class.java).use { contextUtils -> - contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + contextUtils + .`when` { ContextUtils.isForegroundImportance() } + .thenReturn(isForeground) block() } @@ -1123,4 +1130,20 @@ class AppStartMetricsTest { assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) metrics.appStartExtension.setExtendAppStartListener(null) } + + @Test + fun `broadcast starts are not considered a foreground start`() = + withProcessImportance(false) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `typical app starts are considered a foreground start`() = + withProcessImportance(true) { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + assertTrue(metrics.isAppLaunchedInForeground) + } } From 1e9b2be45d79f134659b35d9e4dd5ad8c318e53e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 31 Jul 2026 09:23:11 +0200 Subject: [PATCH 2/3] Update sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java Co-authored-by: arb --- .../io/sentry/android/core/performance/AppStartMetrics.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9faae2106b2..eb1a10dd646 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 @@ -534,7 +534,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } } - // Fallback, if not matching ApplicationStartInfo is available + // Fallback, if no matching ApplicationStartInfo is available if (appLaunchedInForeground == null) { appLaunchedInForeground = ContextUtils.isForegroundImportance(); } From 35d5c498dc8bf1205dc2f28e1dfaf25a9bab7626 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 31 Jul 2026 09:24:30 +0200 Subject: [PATCH 3/3] Update Changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d826db437c5..f49f5f1b214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841)) +- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841), [#5880](https://github.com/getsentry/sentry-java/pull/5880)) - Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835)) - `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions