Skip to content

Commit 6de3962

Browse files
alwxmarkushi
andauthored
fix(android): Drop inflated app start for background-spawned processes on API 35+ (#5841)
* fix(android): Drop inflated app start for background-spawned processes on API 35+ When the OS spawns the process for background work (FCM push, job, service, broadcast, etc.) and the user opens the app later, the app start stayed anchored at background process creation, inflating the reported cold start by the whole idle gap. On API 35+ we now use ApplicationStartInfo.getReason() to detect background process starts and mark them as not launched in foreground, so the first created activity re-classifies them as a warm start anchored at activity creation. * docs: fix changelog PR link * docs: trim code comments * docs: trim changelog entry * Update Changelog * Reverse isBackgroundStartReason to isForegroundStartReason * Add extra test --------- Co-authored-by: Markus Hintersteiner <markus.hintersteiner@sentry.io>
1 parent 41d7c51 commit 6de3962

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- 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))
78
- Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835))
89
- `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
910

‎sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import android.util.Log;
1515
import androidx.annotation.NonNull;
1616
import androidx.annotation.Nullable;
17+
import androidx.annotation.RequiresApi;
1718
import androidx.annotation.VisibleForTesting;
1819
import io.sentry.IContinuousProfiler;
1920
import io.sentry.ISentryLifecycleToken;
@@ -208,6 +209,35 @@ public void setAppStartType(final @NotNull AppStartType appStartType) {
208209
}
209210
}
210211

212+
/**
213+
* Whether {@link ApplicationStartInfo#getReason()} indicates the OS spawned the app process
214+
* because of an intentional user interaction.
215+
*
216+
* @return true if the user actively launched the app, false if the app was launched in
217+
* background, and null if unknown.
218+
*/
219+
@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM)
220+
private static @Nullable Boolean isForegroundStartReason(final int reason) {
221+
switch (reason) {
222+
case ApplicationStartInfo.START_REASON_LAUNCHER:
223+
case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS:
224+
case ApplicationStartInfo.START_REASON_START_ACTIVITY:
225+
return true;
226+
case ApplicationStartInfo.START_REASON_ALARM:
227+
case ApplicationStartInfo.START_REASON_BACKUP:
228+
case ApplicationStartInfo.START_REASON_BOOT_COMPLETE:
229+
case ApplicationStartInfo.START_REASON_BROADCAST:
230+
case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER:
231+
case ApplicationStartInfo.START_REASON_JOB:
232+
case ApplicationStartInfo.START_REASON_PUSH:
233+
case ApplicationStartInfo.START_REASON_SERVICE:
234+
return false;
235+
case ApplicationStartInfo.START_REASON_OTHER:
236+
default:
237+
return null;
238+
}
239+
}
240+
211241
public boolean isAppLaunchedInForeground() {
212242
return appLaunchedInForeground.getValue();
213243
}
@@ -499,6 +529,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) {
499529
} else {
500530
appStartType = AppStartType.WARM;
501531
}
532+
appLaunchedInForeground.setValue(isForegroundStartReason(info.getReason()));
502533
}
503534
}
504535
} catch (RuntimeException ignored) {

‎sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package io.sentry.android.core.performance
22

3+
import android.app.Activity
34
import android.app.ActivityManager.RunningAppProcessInfo
45
import android.app.Application
56
import android.app.ApplicationStartInfo
67
import android.os.Build
78
import android.os.Handler
89
import android.os.Looper
10+
import android.os.SystemClock
911
import androidx.test.core.app.ApplicationProvider
1012
import androidx.test.ext.junit.runners.AndroidJUnit4
1113
import io.sentry.android.core.SentryShadowActivityManager
@@ -16,6 +18,7 @@ import kotlin.test.Test
1618
import kotlin.test.assertEquals
1719
import kotlin.test.assertFalse
1820
import kotlin.test.assertNull
21+
import kotlin.test.assertTrue
1922
import org.junit.Before
2023
import org.junit.runner.RunWith
2124
import org.mockito.kotlin.mock
@@ -263,6 +266,115 @@ class AppStartMetricsTestApi35 {
263266
assertNull(metrics.appStartReason)
264267
}
265268

269+
@Test
270+
fun `background start reason marks app as not launched in foreground`() {
271+
val mockStartInfo = mock<ApplicationStartInfo>()
272+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
273+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
274+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH)
275+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
276+
val metrics = AppStartMetrics.getInstance()
277+
278+
val app = ApplicationProvider.getApplicationContext<Application>()
279+
metrics.registerLifecycleCallbacks(app)
280+
281+
assertFalse(metrics.isAppLaunchedInForeground)
282+
}
283+
284+
@Test
285+
fun `all background start reasons mark app as not launched in foreground`() {
286+
val backgroundReasons =
287+
listOf(
288+
ApplicationStartInfo.START_REASON_ALARM,
289+
ApplicationStartInfo.START_REASON_BACKUP,
290+
ApplicationStartInfo.START_REASON_BOOT_COMPLETE,
291+
ApplicationStartInfo.START_REASON_BROADCAST,
292+
ApplicationStartInfo.START_REASON_CONTENT_PROVIDER,
293+
ApplicationStartInfo.START_REASON_JOB,
294+
ApplicationStartInfo.START_REASON_PUSH,
295+
ApplicationStartInfo.START_REASON_SERVICE,
296+
)
297+
298+
val app = ApplicationProvider.getApplicationContext<Application>()
299+
for (reason in backgroundReasons) {
300+
AppStartMetrics.getInstance().clear()
301+
SentryShadowActivityManager.reset()
302+
303+
val mockStartInfo = mock<ApplicationStartInfo>()
304+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
305+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
306+
whenever(mockStartInfo.reason).thenReturn(reason)
307+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
308+
309+
AppStartMetrics.getInstance().registerLifecycleCallbacks(app)
310+
311+
assertFalse(
312+
AppStartMetrics.getInstance().isAppLaunchedInForeground,
313+
"reason $reason should not be launched in foreground",
314+
)
315+
}
316+
}
317+
318+
@Test
319+
fun `user-initiated start reason keeps app launched in foreground`() {
320+
val mockStartInfo = mock<ApplicationStartInfo>()
321+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
322+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
323+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER)
324+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
325+
SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND)
326+
val metrics = AppStartMetrics.getInstance()
327+
328+
val app = ApplicationProvider.getApplicationContext<Application>()
329+
metrics.registerLifecycleCallbacks(app)
330+
331+
assertTrue(metrics.isAppLaunchedInForeground)
332+
}
333+
334+
@Test
335+
fun `unknown start reason falls back to foreground importance check`() {
336+
val mockStartInfo = mock<ApplicationStartInfo>()
337+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
338+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
339+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_OTHER)
340+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
341+
SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND)
342+
val metrics = AppStartMetrics.getInstance()
343+
344+
val app = ApplicationProvider.getApplicationContext<Application>()
345+
metrics.registerLifecycleCallbacks(app)
346+
347+
assertTrue(metrics.isAppLaunchedInForeground)
348+
}
349+
350+
@Test
351+
fun `background-spawned start is re-classified as warm on the first activity`() {
352+
val mockStartInfo = mock<ApplicationStartInfo>()
353+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
354+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
355+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH)
356+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
357+
val metrics = AppStartMetrics.getInstance()
358+
// App start span anchored at background process creation.
359+
metrics.appStartTimeSpan.setStartedAt(42)
360+
361+
val app = ApplicationProvider.getApplicationContext<Application>()
362+
metrics.registerLifecycleCallbacks(app)
363+
364+
assertFalse(metrics.isAppLaunchedInForeground)
365+
assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType)
366+
367+
// User opens the app 20s later (under the 1-minute warm threshold).
368+
val activityCreatedUptimeMs = 20_000L
369+
SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs)
370+
metrics.onActivityCreated(mock<Activity>(), null)
371+
372+
// Re-classified as a warm start re-anchored at activity creation.
373+
assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType)
374+
assertTrue(metrics.isAppLaunchedInForeground)
375+
assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs)
376+
}
377+
266378
private fun waitForMainLooperIdle() {
267379
Handler(Looper.getMainLooper()).post {}
268380
Shadows.shadowOf(Looper.getMainLooper()).idle()

0 commit comments

Comments
 (0)