diff --git a/CHANGELOG.md b/CHANGELOG.md index 474be6c28d..46e5800d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ ## Unreleased +### Fixes + +- Drop inflated Android cold/warm app starts for background-spawned processes (e.g. FCM push) ([#6526](https://github.com/getsentry/sentry-react-native/pull/6526)) + + When Android spawns the app process in the background (for example to handle an FCM push) and the user opens the app later, the native SDK keeps the app start anchored at background process creation, inflating `app_start_cold`/`app_start_warm` by the whole idle gap. On API 35+ the SDK now uses the process start reason (`ApplicationStartInfo#getReason()`) to detect these background launches and drops the app start, similar to how the Cocoa SDK drops pre-warmed starts. + ### Changes - Expose `instrumentStateGraph` for manual LangGraph instrumentation ([#6520](https://github.com/getsentry/sentry-react-native/pull/6520)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index f2e2e09a14..b8986407dd 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -76,11 +76,14 @@ import java.net.URISyntaxException; import java.nio.charset.Charset; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -108,6 +111,36 @@ public class RNSentryModuleImpl { @VisibleForTesting static long lastStartTimestampMs = -1; + // App start reasons (from ApplicationStartInfo#getReason(), API 35+) that indicate the process + // was spawned by the system in the background rather than by a user-initiated launch. When the + // app is later brought to the foreground, the native SDK keeps the app start anchored at + // background process creation, which inflates app_start_cold/warm (see + // https://github.com/getsentry/sentry-react-native/issues/6382). We drop these app starts, + // similar to how the Cocoa SDK drops pre-warmed starts. + private static final Set BACKGROUND_APP_START_REASONS = + new HashSet<>( + Arrays.asList( + "alarm", + "backup", + "boot_complete", + "broadcast", + "content_provider", + "job", + "push", + "service")); + + /** + * @param appStartReason the process start reason from {@link + * io.sentry.android.core.performance.AppStartMetrics#getAppStartReason()} (API 35+, {@code + * null} on lower API levels or for an unmapped reason) + * @return {@code true} if the process was spawned by the system in the background rather than by + * a user-initiated launch, in which case the app start data should be dropped + */ + @VisibleForTesting + static boolean isBackgroundAppStartReason(final @Nullable String appStartReason) { + return appStartReason != null && BACKGROUND_APP_START_REASONS.contains(appStartReason); + } + // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; // 16ms (slower than 60fps) to constitute slow frames. @@ -367,6 +400,23 @@ protected void fetchNativeAppStart( return; } + // On API 35+ the process start reason tells us whether the process was spawned in the + // background (e.g. by an FCM push, a job or a service) rather than by the user opening the app. + // In that case the native app start remains anchored at background process creation, so the + // reported cold/warm start is the full idle gap until the user finally opens the app. We drop + // such app starts to avoid inflating app_start_cold/warm. Reason is null on API < 35, where we + // fall back to the existing foreground check above. + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (isBackgroundAppStartReason(appStartReason)) { + logger.log( + SentryLevel.WARNING, + "Invalid app start data: app was launched in the background (reason: " + + appStartReason + + ")."); + promise.resolve(null); + return; + } + WritableMap mutableMeasurement = (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryAppStartTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryAppStartTest.java new file mode 100644 index 0000000000..a55218a349 --- /dev/null +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryAppStartTest.java @@ -0,0 +1,45 @@ +package io.sentry.react; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class RNSentryAppStartTest { + + @Test + public void backgroundReasonsAreTreatedAsBackgroundAppStart() { + final String[] backgroundReasons = { + "alarm", "backup", "boot_complete", "broadcast", "content_provider", "job", "push", "service" + }; + + for (final String reason : backgroundReasons) { + assertTrue( + "Expected reason '" + reason + "' to be treated as a background app start", + RNSentryModuleImpl.isBackgroundAppStartReason(reason)); + } + } + + @Test + public void userLaunchReasonsAreNotTreatedAsBackgroundAppStart() { + final String[] userLaunchReasons = {"launcher", "launcher_recents", "start_activity", "other"}; + + for (final String reason : userLaunchReasons) { + assertFalse( + "Expected reason '" + reason + "' to be treated as a user-initiated app start", + RNSentryModuleImpl.isBackgroundAppStartReason(reason)); + } + } + + @Test + public void nullReasonIsNotTreatedAsBackgroundAppStart() { + // getAppStartReason() returns null on API < 35, where we fall back to the foreground check + // only. + assertFalse(RNSentryModuleImpl.isBackgroundAppStartReason(null)); + } + + @Test + public void unknownReasonIsNotTreatedAsBackgroundAppStart() { + assertFalse(RNSentryModuleImpl.isBackgroundAppStartReason("some_future_reason")); + } +}