Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions firebase-perf/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Unreleased

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

# 22.0.5

- [changed] Bumped internal dependencies.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package com.google.firebase.perf;

import android.content.Context;
import android.os.Build;
import androidx.annotation.Nullable;
import com.google.firebase.FirebaseApp;
import com.google.firebase.StartupTime;
Expand Down Expand Up @@ -48,7 +49,12 @@ public FirebasePerfEarly(
if (startupTime != null) {
AppStartTrace appStartTrace = AppStartTrace.getInstance();
appStartTrace.registerActivityLifecycleCallbacks(context);
uiExecutor.execute(new AppStartTrace.StartFromBackgroundRunnable(appStartTrace));
// The posted runnable feeds AppStartTrace's pre-API-34 background-start check.
// On API 34+ the runnable's output is unused (the causal signal owns the
// decision), so we skip the main-thread post.
if (Build.VERSION.SDK_INT < 34) {
uiExecutor.execute(new AppStartTrace.StartFromBackgroundRunnable(appStartTrace));
}
}

// TODO: Bring back Firebase Sessions dependency to watch for updates to sessions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,6 @@ public class AppStartTrace implements ActivityLifecycleCallbacks, LifecycleObser
private static final @NonNull Timer PERF_CLASS_LOAD_TIME = new Clock().getTime();
private static final long MAX_LATENCY_BEFORE_UI_INIT = TimeUnit.MINUTES.toMicros(1);

// If the `mainThreadRunnableTime` was set within this duration, the assumption
// is that it was called immediately before `onActivityCreated` in foreground starts on API 34+.
// See b/339891952.
private static final long MAX_BACKGROUND_RUNNABLE_DELAY = TimeUnit.MILLISECONDS.toMicros(50);

// Core pool size 0 allows threads to shut down if they're idle
private static final int CORE_POOL_SIZE = 0;
private static final int MAX_POOL_SIZE = 1; // Only need single thread
Expand Down Expand Up @@ -134,6 +129,11 @@ public class AppStartTrace implements ActivityLifecycleCallbacks, LifecycleObser
private final DrawCounter onDrawCounterListener = new DrawCounter();
private boolean systemForegroundCheck = false;

// OS-reported reason this process was forked. Captured once during
// registerActivityLifecycleCallbacks; consulted by resolveIsStartedFromBackground on
// API 34+.
private @Nullable ProcessStartCause processStartCause = null;

/**
* Called from onCreate() method of an activity by instrumented byte code.
*
Expand Down Expand Up @@ -224,6 +224,9 @@ public synchronized void registerActivityLifecycleCallbacks(@NonNull Context con
if (appContext instanceof Application) {
((Application) appContext).registerActivityLifecycleCallbacks(this);
systemForegroundCheck = systemForegroundCheck || isAnyAppProcessInForeground(appContext);
// Capture the OS-reported start cause as early as possible (this method runs from
// FirebasePerfEarly during the ContentProvider init chain).
processStartCause = ProcessStartCause.capture(appContext);
isRegisteredForLifecycleCallbacks = true;
this.appContext = appContext;
}
Expand Down Expand Up @@ -327,37 +330,30 @@ private void recordOnDrawFrontOfQueue() {
}

/**
* Sets the `isStartedFromBackground` flag to `true` if the `mainThreadRunnableTime` was set
* from the `StartFromBackgroundRunnable`.
* <p>
* If it's prior to API 34, it's always set to true if `mainThreadRunnableTime` was set.
* <p>
* If it's on or after API 34, and it was called less than `MAX_BACKGROUND_RUNNABLE_DELAY`
* before `onActivityCreated`, the
* assumption is that it was called immediately before the activity lifecycle callbacks in a
* foreground start.
* See b/339891952.
* Decide whether this process was background-only and, if so, set
* {@link #isStartedFromBackground} so the activity-lifecycle callbacks suppress the
* {@code _app_start} trace.
*
* API < 34: legacy pre-bug ordering. If {@link StartFromBackgroundRunnable} fired
* before the first {@code onActivityCreated}, suppress.
*
* API 34+: {@link ProcessStartCause} owns the decision. {@code FOREGROUND} lets the
* trace through; {@code UNKNOWN} or null suppresses.
*
* See b/339891952 and https://github.com/firebase/firebase-android-sdk/issues/8103.
*/
private void resolveIsStartedFromBackground() {
// If the mainThreadRunnableTime is null, either the runnable hasn't run, or this check has
// already been made.
if (mainThreadRunnableTime == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment here summarizing that only if it's < API 34 it checks / updates the mainThreadRunnable.

if (Build.VERSION.SDK_INT < 34) {
if (mainThreadRunnableTime != null) {
isStartedFromBackground = true;
mainThreadRunnableTime = null;
}
return;
}

// If the `mainThreadRunnableTime` was set prior to API 34, it's always assumed that's it's
// a background start.
// Otherwise it's assumed to be a background start if the runnable was set more than
// `MAX_BACKGROUND_RUNNABLE_DELAY`
// before the first `onActivityCreated` call.
// TODO(b/339891952): Investigate removing the API check.
if ((Build.VERSION.SDK_INT < 34)
|| (mainThreadRunnableTime.getDurationMicros() > MAX_BACKGROUND_RUNNABLE_DELAY)) {
if (processStartCause == null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment here about the API 34+ behaviour?

|| processStartCause.cause != ProcessStartCause.Cause.FOREGROUND) {
isStartedFromBackground = true;
}

// Set this to null to prevent additional checks.
mainThreadRunnableTime = null;
}

@Override
Expand Down Expand Up @@ -633,4 +629,15 @@ Timer getOnResumeTime() {
void setMainThreadRunnableTime(Timer timer) {
mainThreadRunnableTime = timer;
}

@VisibleForTesting
void setProcessStartCauseForTest(@Nullable ProcessStartCause cause) {
this.processStartCause = cause;
}

@VisibleForTesting
@Nullable
ProcessStartCause getProcessStartCauseForTest() {
return processStartCause;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
//
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.perf.metrics;

import android.app.ActivityManager;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

/**
* OS-reported reason this process was forked, used by {@link AppStartTrace} to decide
* whether to emit the {@code _app_start} trace.
*
* API 34+: {@link ActivityManager#getMyMemoryState} importance.
* {@code IMPORTANCE_FOREGROUND} at first capture indicates an activity-driven start.
*
* API < 34: returns {@link Cause#UNKNOWN}; legacy logic in {@link AppStartTrace} owns
* the decision on these versions.
*
* @hide
*/
final class ProcessStartCause {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename this to AppStartCause or AppStartReason?

ProcessStartCause seems like a (missing) Android class - which was confusing to me.


/** Classification of why the process was forked. */
enum Cause {
/** Process forked to satisfy an activity launch. */
FOREGROUND,
/** Couldn't decide — caller falls back to its own heuristic. */
UNKNOWN
}

/** OS classification. Never null. */
final @NonNull Cause cause;

/** {@code RunningAppProcessInfo.importance} at capture, or {@code -1} if unread. */
final int importance;

/** {@link Build.VERSION#SDK_INT} at capture. */
final int apiLevel;

@VisibleForTesting
ProcessStartCause(@NonNull Cause cause, int importance, int apiLevel) {
this.cause = cause;
this.importance = importance;
this.apiLevel = apiLevel;
}

/**
* Capture the cause for the current process. Call as early as possible (during
* {@code AppStartTrace.registerActivityLifecycleCallbacks}) so the OS-set values still
* reflect the original fork reason rather than transient state mid-init.
*/
static @NonNull ProcessStartCause capture(@Nullable Context appContext) {
final int apiLevel = Build.VERSION.SDK_INT;
if (appContext == null) {
return new ProcessStartCause(Cause.UNKNOWN, -1, apiLevel);
}

final ActivityManager activityManager =
(ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE);
if (activityManager == null) {
return new ProcessStartCause(Cause.UNKNOWN, -1, apiLevel);
}

final int importance = readImportance();

if (apiLevel >= 34) {
Cause cause =
importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
? Cause.FOREGROUND
: Cause.UNKNOWN;
return new ProcessStartCause(cause, importance, apiLevel);
}

// API < 34: legacy AppStartTrace logic owns the decision.
return new ProcessStartCause(Cause.UNKNOWN, importance, apiLevel);
}

private static int readImportance() {
try {
ActivityManager.RunningAppProcessInfo info = new ActivityManager.RunningAppProcessInfo();
ActivityManager.getMyMemoryState(info);
return info.importance;
} catch (Throwable t) {
return -1;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,55 +238,129 @@ public void testDelayedAppStart() {
ArgumentMatchers.nullable(ApplicationProcessState.class));
}

// --- Pre-API-34 regression tests for the legacy pre-bug-ordering path ---
//
// Pre-API-34 still detects background-only starts via the StartFromBackgroundRunnable
// firing before any activity. These tests pin that behavior on the still-active code path.

@Test
public void testStartFromBackground_within50ms() {
@Config(sdk = 33)
public void preApi34_runnableFiredBeforeActivity_marksAsBackground() {
FakeScheduledExecutorService fakeExecutorService = new FakeScheduledExecutorService();
Timer fakeTimer = spy(new Timer(currentTime));
AppStartTrace trace =
new AppStartTrace(transportManager, clock, configResolver, fakeExecutorService);
trace.registerActivityLifecycleCallbacks(appContext);
trace.setMainThreadRunnableTime(fakeTimer);
// Simulate StartFromBackgroundRunnable having fired before any activity was created.
trace.setMainThreadRunnableTime(spy(new Timer(currentTime)));

// See AppStartTrace.MAX_BACKGROUND_RUNNABLE_DELAY.
when(fakeTimer.getDurationMicros()).thenReturn(TimeUnit.MILLISECONDS.toMicros(50) - 1);
trace.onActivityCreated(activity1, bundle);
Assert.assertNotNull(trace.getOnCreateTime());
Assert.assertNull(trace.getOnCreateTime());
++currentTime;
trace.onActivityStarted(activity1);
Assert.assertNotNull(trace.getOnStartTime());
Assert.assertNull(trace.getOnStartTime());
++currentTime;
trace.onActivityResumed(activity1);
Assert.assertNotNull(trace.getOnResumeTime());
Assert.assertNull(trace.getOnResumeTime());
fakeExecutorService.runAll();
// There should be a trace sent since the delay between the main thread and onActivityCreated
// is limited.
verify(transportManager, times(1))

// Trace suppressed — pre-bug ordering says background.
verify(transportManager, times(0))
.log(
traceArgumentCaptor.capture(),
ArgumentMatchers.nullable(ApplicationProcessState.class));
}

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

// See AppStartTrace.MAX_BACKGROUND_RUNNABLE_DELAY.
when(fakeTimer.getDurationMicros()).thenReturn(TimeUnit.MILLISECONDS.toMicros(50) + 1);
currentTime = 1;
trace.onActivityCreated(activity1, bundle);
Assert.assertNull(trace.getOnCreateTime());
++currentTime;
currentTime = 2;
trace.onActivityStarted(activity1);
Assert.assertNull(trace.getOnStartTime());
++currentTime;
currentTime = 3;
trace.onActivityResumed(activity1);
Assert.assertNull(trace.getOnResumeTime());
// There should be no trace sent.
fakeExecutorService.runAll();

// Trace logs — runnable-before-activity didn't happen.
verify(transportManager, times(1))
.log(
traceArgumentCaptor.capture(),
ArgumentMatchers.nullable(ApplicationProcessState.class));
}

// --- API 34+ causal-signal decision tests ---
// ProcessStartCause is the only decision input on API 34+; exercise each Cause value.

/** Builds an {@link AppStartTrace} and registers callbacks. */
private AppStartTrace newTrace(FakeScheduledExecutorService executor) {
AppStartTrace trace = new AppStartTrace(transportManager, clock, configResolver, executor);
trace.registerActivityLifecycleCallbacks(appContext);
return trace;
}

@Test
public void api34Plus_foregroundCause_traceLogs() {
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
AppStartTrace trace = newTrace(executor);
trace.setProcessStartCauseForTest(
new ProcessStartCause(ProcessStartCause.Cause.FOREGROUND, 100, 35));

currentTime = 1;
trace.onActivityCreated(activity1, bundle);
Assert.assertNotNull(trace.getOnCreateTime());
currentTime = 2;
trace.onActivityStarted(activity1);
Assert.assertNotNull(trace.getOnStartTime());
currentTime = 3;
trace.onActivityResumed(activity1);
Assert.assertNotNull(trace.getOnResumeTime());
executor.runAll();

verify(transportManager, times(1))
.log(
traceArgumentCaptor.capture(),
ArgumentMatchers.nullable(ApplicationProcessState.class));
}

@Test
public void api34Plus_unknownCause_traceSuppressed() {
// UNKNOWN means importance != FOREGROUND at capture — typically a warm-start
// scenario. Suppress to keep _app_start measuring real cold foreground launches.
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
AppStartTrace trace = newTrace(executor);
trace.setProcessStartCauseForTest(
new ProcessStartCause(ProcessStartCause.Cause.UNKNOWN, 200, 34));

trace.onActivityCreated(activity1, bundle);

Assert.assertNull(trace.getOnCreateTime());
executor.runAll();
verify(transportManager, times(0))
.log(
traceArgumentCaptor.capture(),
ArgumentMatchers.nullable(ApplicationProcessState.class));
}

@Test
public void api34Plus_nullProcessStartCause_traceSuppressed() {
// Defensive: if processStartCause is somehow null at decision time (e.g. the
// capture didn't run), suppress — better to miss a trace than emit one with no
// provenance.
FakeScheduledExecutorService executor = new FakeScheduledExecutorService();
AppStartTrace trace = newTrace(executor);
trace.setProcessStartCauseForTest(null);

trace.onActivityCreated(activity1, bundle);

Assert.assertNull(trace.getOnCreateTime());
executor.runAll();
verify(transportManager, times(0))
.log(
traceArgumentCaptor.capture(),
Expand Down
Loading
Loading