Skip to content

Commit 3d1d730

Browse files
committed
firebase-perf: fix API 34+ background-start heuristic in AppStartTrace (#8103)
Replace the timing-window heuristic in resolveIsStartedFromBackground on API 34+ with an OS-reported process start cause. Pre-API-34 keeps the legacy runnable-before-onActivityCreated check. API < 34: mainThreadRunnableTime set first ⇒ suppress (legacy, unchanged). API 34+: ProcessStartCause.cause FOREGROUND ⇒ log. UNKNOWN / null ⇒ suppress. New ProcessStartCause helper reads RunningAppProcessInfo.importance at first capture via ActivityManager.getMyMemoryState. IMPORTANCE_FOREGROUND indicates an activity-driven start; anything else maps to UNKNOWN. Pre-API-34 returns UNKNOWN and the legacy AppStartTrace logic owns the decision. FirebasePerfEarly stops scheduling StartFromBackgroundRunnable on API 34+ since its output is no longer consumed there. :firebase-perf:testReleaseUnitTest — 0 failures / 0 errors. AppStartTraceTest 9/9, ProcessStartCauseTest 6/6.
1 parent f5b1aba commit 3d1d730

6 files changed

Lines changed: 360 additions & 53 deletions

File tree

firebase-perf/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Unreleased
22

3+
- [fixed] Fixed `_app_start` traces being suppressed on API 34+ devices for typical
4+
real-world apps. The previous timing-window heuristic has been replaced on API 34+ by
5+
`RunningAppProcessInfo.importance` at first capture, which indicates whether the
6+
process was forked to launch an activity. Pre-API-34 behavior is unchanged. [#8103]
7+
38
# 22.0.5
49

510
- [changed] Bumped internal dependencies.

firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfEarly.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package com.google.firebase.perf;
1616

1717
import android.content.Context;
18+
import android.os.Build;
1819
import androidx.annotation.Nullable;
1920
import com.google.firebase.FirebaseApp;
2021
import com.google.firebase.StartupTime;
@@ -48,7 +49,12 @@ public FirebasePerfEarly(
4849
if (startupTime != null) {
4950
AppStartTrace appStartTrace = AppStartTrace.getInstance();
5051
appStartTrace.registerActivityLifecycleCallbacks(context);
51-
uiExecutor.execute(new AppStartTrace.StartFromBackgroundRunnable(appStartTrace));
52+
// The posted runnable feeds AppStartTrace's pre-API-34 background-start check.
53+
// On API 34+ the runnable's output is unused (the causal signal owns the
54+
// decision), so we skip the main-thread post.
55+
if (Build.VERSION.SDK_INT < 34) {
56+
uiExecutor.execute(new AppStartTrace.StartFromBackgroundRunnable(appStartTrace));
57+
}
5258
}
5359

5460
// TODO: Bring back Firebase Sessions dependency to watch for updates to sessions.

firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,6 @@ public class AppStartTrace implements ActivityLifecycleCallbacks, LifecycleObser
7474
private static final @NonNull Timer PERF_CLASS_LOAD_TIME = new Clock().getTime();
7575
private static final long MAX_LATENCY_BEFORE_UI_INIT = TimeUnit.MINUTES.toMicros(1);
7676

77-
// If the `mainThreadRunnableTime` was set within this duration, the assumption
78-
// is that it was called immediately before `onActivityCreated` in foreground starts on API 34+.
79-
// See b/339891952.
80-
private static final long MAX_BACKGROUND_RUNNABLE_DELAY = TimeUnit.MILLISECONDS.toMicros(50);
81-
8277
// Core pool size 0 allows threads to shut down if they're idle
8378
private static final int CORE_POOL_SIZE = 0;
8479
private static final int MAX_POOL_SIZE = 1; // Only need single thread
@@ -134,6 +129,11 @@ public class AppStartTrace implements ActivityLifecycleCallbacks, LifecycleObser
134129
private final DrawCounter onDrawCounterListener = new DrawCounter();
135130
private boolean systemForegroundCheck = false;
136131

132+
// OS-reported reason this process was forked. Captured once during
133+
// registerActivityLifecycleCallbacks; consulted by resolveIsStartedFromBackground on
134+
// API 34+.
135+
private @Nullable ProcessStartCause processStartCause = null;
136+
137137
/**
138138
* Called from onCreate() method of an activity by instrumented byte code.
139139
*
@@ -224,6 +224,9 @@ public synchronized void registerActivityLifecycleCallbacks(@NonNull Context con
224224
if (appContext instanceof Application) {
225225
((Application) appContext).registerActivityLifecycleCallbacks(this);
226226
systemForegroundCheck = systemForegroundCheck || isAnyAppProcessInForeground(appContext);
227+
// Capture the OS-reported start cause as early as possible (this method runs from
228+
// FirebasePerfEarly during the ContentProvider init chain).
229+
processStartCause = ProcessStartCause.capture(appContext);
227230
isRegisteredForLifecycleCallbacks = true;
228231
this.appContext = appContext;
229232
}
@@ -327,37 +330,30 @@ private void recordOnDrawFrontOfQueue() {
327330
}
328331

329332
/**
330-
* Sets the `isStartedFromBackground` flag to `true` if the `mainThreadRunnableTime` was set
331-
* from the `StartFromBackgroundRunnable`.
332-
* <p>
333-
* If it's prior to API 34, it's always set to true if `mainThreadRunnableTime` was set.
334-
* <p>
335-
* If it's on or after API 34, and it was called less than `MAX_BACKGROUND_RUNNABLE_DELAY`
336-
* before `onActivityCreated`, the
337-
* assumption is that it was called immediately before the activity lifecycle callbacks in a
338-
* foreground start.
339-
* See b/339891952.
333+
* Decide whether this process was background-only and, if so, set
334+
* {@link #isStartedFromBackground} so the activity-lifecycle callbacks suppress the
335+
* {@code _app_start} trace.
336+
*
337+
* API < 34: legacy pre-bug ordering. If {@link StartFromBackgroundRunnable} fired
338+
* before the first {@code onActivityCreated}, suppress.
339+
*
340+
* API 34+: {@link ProcessStartCause} owns the decision. {@code FOREGROUND} lets the
341+
* trace through; {@code UNKNOWN} or null suppresses.
342+
*
343+
* See b/339891952 and https://github.com/firebase/firebase-android-sdk/issues/8103.
340344
*/
341345
private void resolveIsStartedFromBackground() {
342-
// If the mainThreadRunnableTime is null, either the runnable hasn't run, or this check has
343-
// already been made.
344-
if (mainThreadRunnableTime == null) {
346+
if (Build.VERSION.SDK_INT < 34) {
347+
if (mainThreadRunnableTime != null) {
348+
isStartedFromBackground = true;
349+
mainThreadRunnableTime = null;
350+
}
345351
return;
346352
}
347-
348-
// If the `mainThreadRunnableTime` was set prior to API 34, it's always assumed that's it's
349-
// a background start.
350-
// Otherwise it's assumed to be a background start if the runnable was set more than
351-
// `MAX_BACKGROUND_RUNNABLE_DELAY`
352-
// before the first `onActivityCreated` call.
353-
// TODO(b/339891952): Investigate removing the API check.
354-
if ((Build.VERSION.SDK_INT < 34)
355-
|| (mainThreadRunnableTime.getDurationMicros() > MAX_BACKGROUND_RUNNABLE_DELAY)) {
353+
if (processStartCause == null
354+
|| processStartCause.cause != ProcessStartCause.Cause.FOREGROUND) {
356355
isStartedFromBackground = true;
357356
}
358-
359-
// Set this to null to prevent additional checks.
360-
mainThreadRunnableTime = null;
361357
}
362358

363359
@Override
@@ -633,4 +629,15 @@ Timer getOnResumeTime() {
633629
void setMainThreadRunnableTime(Timer timer) {
634630
mainThreadRunnableTime = timer;
635631
}
632+
633+
@VisibleForTesting
634+
void setProcessStartCauseForTest(@Nullable ProcessStartCause cause) {
635+
this.processStartCause = cause;
636+
}
637+
638+
@VisibleForTesting
639+
@Nullable
640+
ProcessStartCause getProcessStartCauseForTest() {
641+
return processStartCause;
642+
}
636643
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
//
6+
// You may obtain a copy of the License at
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.perf.metrics;
16+
17+
import android.app.ActivityManager;
18+
import android.content.Context;
19+
import android.os.Build;
20+
import androidx.annotation.NonNull;
21+
import androidx.annotation.Nullable;
22+
import androidx.annotation.VisibleForTesting;
23+
24+
/**
25+
* OS-reported reason this process was forked, used by {@link AppStartTrace} to decide
26+
* whether to emit the {@code _app_start} trace.
27+
*
28+
* API 34+: {@link ActivityManager#getMyMemoryState} importance.
29+
* {@code IMPORTANCE_FOREGROUND} at first capture indicates an activity-driven start.
30+
*
31+
* API < 34: returns {@link Cause#UNKNOWN}; legacy logic in {@link AppStartTrace} owns
32+
* the decision on these versions.
33+
*
34+
* @hide
35+
*/
36+
final class ProcessStartCause {
37+
38+
/** Classification of why the process was forked. */
39+
enum Cause {
40+
/** Process forked to satisfy an activity launch. */
41+
FOREGROUND,
42+
/** Couldn't decide — caller falls back to its own heuristic. */
43+
UNKNOWN
44+
}
45+
46+
/** OS classification. Never null. */
47+
final @NonNull Cause cause;
48+
49+
/** {@code RunningAppProcessInfo.importance} at capture, or {@code -1} if unread. */
50+
final int importance;
51+
52+
/** {@link Build.VERSION#SDK_INT} at capture. */
53+
final int apiLevel;
54+
55+
@VisibleForTesting
56+
ProcessStartCause(@NonNull Cause cause, int importance, int apiLevel) {
57+
this.cause = cause;
58+
this.importance = importance;
59+
this.apiLevel = apiLevel;
60+
}
61+
62+
/**
63+
* Capture the cause for the current process. Call as early as possible (during
64+
* {@code AppStartTrace.registerActivityLifecycleCallbacks}) so the OS-set values still
65+
* reflect the original fork reason rather than transient state mid-init.
66+
*/
67+
static @NonNull ProcessStartCause capture(@Nullable Context appContext) {
68+
final int apiLevel = Build.VERSION.SDK_INT;
69+
if (appContext == null) {
70+
return new ProcessStartCause(Cause.UNKNOWN, -1, apiLevel);
71+
}
72+
73+
final ActivityManager activityManager =
74+
(ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE);
75+
if (activityManager == null) {
76+
return new ProcessStartCause(Cause.UNKNOWN, -1, apiLevel);
77+
}
78+
79+
final int importance = readImportance();
80+
81+
if (apiLevel >= 34) {
82+
Cause cause =
83+
importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
84+
? Cause.FOREGROUND
85+
: Cause.UNKNOWN;
86+
return new ProcessStartCause(cause, importance, apiLevel);
87+
}
88+
89+
// API < 34: legacy AppStartTrace logic owns the decision.
90+
return new ProcessStartCause(Cause.UNKNOWN, importance, apiLevel);
91+
}
92+
93+
private static int readImportance() {
94+
try {
95+
ActivityManager.RunningAppProcessInfo info = new ActivityManager.RunningAppProcessInfo();
96+
ActivityManager.getMyMemoryState(info);
97+
return info.importance;
98+
} catch (Throwable t) {
99+
return -1;
100+
}
101+
}
102+
}

firebase-perf/src/test/java/com/google/firebase/perf/metrics/AppStartTraceTest.java

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -238,55 +238,129 @@ public void testDelayedAppStart() {
238238
ArgumentMatchers.nullable(ApplicationProcessState.class));
239239
}
240240

241+
// --- Pre-API-34 regression tests for the legacy pre-bug-ordering path ---
242+
//
243+
// Pre-API-34 still detects background-only starts via the StartFromBackgroundRunnable
244+
// firing before any activity. These tests pin that behavior on the still-active code path.
245+
241246
@Test
242-
public void testStartFromBackground_within50ms() {
247+
@Config(sdk = 33)
248+
public void preApi34_runnableFiredBeforeActivity_marksAsBackground() {
243249
FakeScheduledExecutorService fakeExecutorService = new FakeScheduledExecutorService();
244-
Timer fakeTimer = spy(new Timer(currentTime));
245250
AppStartTrace trace =
246251
new AppStartTrace(transportManager, clock, configResolver, fakeExecutorService);
247252
trace.registerActivityLifecycleCallbacks(appContext);
248-
trace.setMainThreadRunnableTime(fakeTimer);
253+
// Simulate StartFromBackgroundRunnable having fired before any activity was created.
254+
trace.setMainThreadRunnableTime(spy(new Timer(currentTime)));
249255

250-
// See AppStartTrace.MAX_BACKGROUND_RUNNABLE_DELAY.
251-
when(fakeTimer.getDurationMicros()).thenReturn(TimeUnit.MILLISECONDS.toMicros(50) - 1);
252256
trace.onActivityCreated(activity1, bundle);
253-
Assert.assertNotNull(trace.getOnCreateTime());
257+
Assert.assertNull(trace.getOnCreateTime());
254258
++currentTime;
255259
trace.onActivityStarted(activity1);
256-
Assert.assertNotNull(trace.getOnStartTime());
260+
Assert.assertNull(trace.getOnStartTime());
257261
++currentTime;
258262
trace.onActivityResumed(activity1);
259-
Assert.assertNotNull(trace.getOnResumeTime());
263+
Assert.assertNull(trace.getOnResumeTime());
260264
fakeExecutorService.runAll();
261-
// There should be a trace sent since the delay between the main thread and onActivityCreated
262-
// is limited.
263-
verify(transportManager, times(1))
265+
266+
// Trace suppressed — pre-bug ordering says background.
267+
verify(transportManager, times(0))
264268
.log(
265269
traceArgumentCaptor.capture(),
266270
ArgumentMatchers.nullable(ApplicationProcessState.class));
267271
}
268272

269273
@Test
270-
public void testStartFromBackground_moreThan50ms() {
274+
@Config(sdk = 33)
275+
public void preApi34_runnableNotFired_traceLogs() {
271276
FakeScheduledExecutorService fakeExecutorService = new FakeScheduledExecutorService();
272-
Timer fakeTimer = spy(new Timer(currentTime));
273277
AppStartTrace trace =
274278
new AppStartTrace(transportManager, clock, configResolver, fakeExecutorService);
275279
trace.registerActivityLifecycleCallbacks(appContext);
276-
trace.setMainThreadRunnableTime(fakeTimer);
280+
// mainThreadRunnableTime is NOT set — i.e., the runnable hasn't fired yet, which is
281+
// the normal pre-bug-ordering state on a cold foreground start.
277282

278-
// See AppStartTrace.MAX_BACKGROUND_RUNNABLE_DELAY.
279-
when(fakeTimer.getDurationMicros()).thenReturn(TimeUnit.MILLISECONDS.toMicros(50) + 1);
283+
currentTime = 1;
280284
trace.onActivityCreated(activity1, bundle);
281-
Assert.assertNull(trace.getOnCreateTime());
282-
++currentTime;
285+
currentTime = 2;
283286
trace.onActivityStarted(activity1);
284-
Assert.assertNull(trace.getOnStartTime());
285-
++currentTime;
287+
currentTime = 3;
286288
trace.onActivityResumed(activity1);
287-
Assert.assertNull(trace.getOnResumeTime());
288-
// There should be no trace sent.
289289
fakeExecutorService.runAll();
290+
291+
// Trace logs — runnable-before-activity didn't happen.
292+
verify(transportManager, times(1))
293+
.log(
294+
traceArgumentCaptor.capture(),
295+
ArgumentMatchers.nullable(ApplicationProcessState.class));
296+
}
297+
298+
// --- API 34+ causal-signal decision tests ---
299+
// ProcessStartCause is the only decision input on API 34+; exercise each Cause value.
300+
301+
/** Builds an {@link AppStartTrace} and registers callbacks. */
302+
private AppStartTrace newTrace(FakeScheduledExecutorService executor) {
303+
AppStartTrace trace = new AppStartTrace(transportManager, clock, configResolver, executor);
304+
trace.registerActivityLifecycleCallbacks(appContext);
305+
return trace;
306+
}
307+
308+
@Test
309+
public void api34Plus_foregroundCause_traceLogs() {
310+
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
311+
AppStartTrace trace = newTrace(executor);
312+
trace.setProcessStartCauseForTest(
313+
new ProcessStartCause(ProcessStartCause.Cause.FOREGROUND, 100, 35));
314+
315+
currentTime = 1;
316+
trace.onActivityCreated(activity1, bundle);
317+
Assert.assertNotNull(trace.getOnCreateTime());
318+
currentTime = 2;
319+
trace.onActivityStarted(activity1);
320+
Assert.assertNotNull(trace.getOnStartTime());
321+
currentTime = 3;
322+
trace.onActivityResumed(activity1);
323+
Assert.assertNotNull(trace.getOnResumeTime());
324+
executor.runAll();
325+
326+
verify(transportManager, times(1))
327+
.log(
328+
traceArgumentCaptor.capture(),
329+
ArgumentMatchers.nullable(ApplicationProcessState.class));
330+
}
331+
332+
@Test
333+
public void api34Plus_unknownCause_traceSuppressed() {
334+
// UNKNOWN means importance != FOREGROUND at capture — typically a warm-start
335+
// scenario. Suppress to keep _app_start measuring real cold foreground launches.
336+
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
337+
AppStartTrace trace = newTrace(executor);
338+
trace.setProcessStartCauseForTest(
339+
new ProcessStartCause(ProcessStartCause.Cause.UNKNOWN, 200, 34));
340+
341+
trace.onActivityCreated(activity1, bundle);
342+
343+
Assert.assertNull(trace.getOnCreateTime());
344+
executor.runAll();
345+
verify(transportManager, times(0))
346+
.log(
347+
traceArgumentCaptor.capture(),
348+
ArgumentMatchers.nullable(ApplicationProcessState.class));
349+
}
350+
351+
@Test
352+
public void api34Plus_nullProcessStartCause_traceSuppressed() {
353+
// Defensive: if processStartCause is somehow null at decision time (e.g. the
354+
// capture didn't run), suppress — better to miss a trace than emit one with no
355+
// provenance.
356+
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
357+
AppStartTrace trace = newTrace(executor);
358+
trace.setProcessStartCauseForTest(null);
359+
360+
trace.onActivityCreated(activity1, bundle);
361+
362+
Assert.assertNull(trace.getOnCreateTime());
363+
executor.runAll();
290364
verify(transportManager, times(0))
291365
.log(
292366
traceArgumentCaptor.capture(),

0 commit comments

Comments
 (0)