Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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) {
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
|| 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 {

/** 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