Repository navigation
feat(android): Add ANR warning breadcrumbs on Android 17+ #6245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
01efed6
8ff189d
222087c
7f86c34
18a3504
5800936
c6c5bc3
a9ea1cc
acaa97c
6c235cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,14 @@ | |
|
|
||
| import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; | ||
|
|
||
| import android.annotation.SuppressLint; | ||
| import android.app.ActivityManager; | ||
| import android.app.AnrTypes; | ||
| import android.app.AnrWarningResult; | ||
| import android.content.Context; | ||
| import android.os.Build; | ||
| import androidx.annotation.RequiresApi; | ||
| import io.sentry.Breadcrumb; | ||
| import io.sentry.IScopes; | ||
| import io.sentry.ISentryLifecycleToken; | ||
| import io.sentry.Integration; | ||
|
|
@@ -11,17 +19,50 @@ | |
| import io.sentry.util.Objects; | ||
| import java.io.Closeable; | ||
| import java.io.IOException; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.RejectedExecutionException; | ||
| import java.util.function.Consumer; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
| import org.jetbrains.annotations.TestOnly; | ||
|
|
||
| /** | ||
| * Tracks the application lifecycle to drive session tracking and adds the following breadcrumbs: | ||
| * | ||
| * <ul> | ||
| * <li>{@code app.lifecycle}: the app moved to the foreground or background | ||
| * <li>{@code app.anr}: the system reported an ANR warning (Android 17+) | ||
| * </ul> | ||
| */ | ||
| public final class AppLifecycleIntegration implements Integration, Closeable { | ||
|
|
||
| private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); | ||
| @TestOnly @Nullable volatile LifecycleWatcher watcher; | ||
|
|
||
| private @Nullable SentryAndroidOptions options; | ||
|
|
||
| // Both null when created through the deprecated no-arg constructor, which skips ANR warnings. | ||
| private final @Nullable Context context; | ||
| private final @Nullable BuildInfoProvider buildInfoProvider; | ||
| private @Nullable Consumer<AnrWarningResult> anrWarningConsumer; | ||
| private boolean isClosed; | ||
|
|
||
| /** | ||
| * @deprecated Without a {@link Context} the integration cannot listen for ANR warnings. Use | ||
| * {@link #AppLifecycleIntegration(Context, BuildInfoProvider)} instead. | ||
| */ | ||
| @Deprecated | ||
| public AppLifecycleIntegration() { | ||
| this.context = null; | ||
| this.buildInfoProvider = null; | ||
| } | ||
|
|
||
| public AppLifecycleIntegration( | ||
| final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { | ||
| this.context = context.getApplicationContext(); | ||
| this.buildInfoProvider = buildInfoProvider; | ||
| } | ||
|
|
||
| @Override | ||
| public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { | ||
| Objects.requireNonNull(scopes, "Scopes are required"); | ||
|
|
@@ -61,14 +102,21 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions | |
| AppState.getInstance().addAppStateListener(watcher); | ||
| } | ||
|
|
||
| if (this.options.isEnableAppLifecycleBreadcrumbs()) { | ||
| scheduleRegisterAnrWarningListener(scopes, this.options); | ||
| } | ||
|
|
||
| options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); | ||
| addIntegrationToSdkVersion("AppLifecycle"); | ||
| } | ||
| } | ||
|
|
||
| private void removeObserver() { | ||
| private void removeObservers() { | ||
| final @Nullable LifecycleWatcher watcherRef; | ||
| try (final ISentryLifecycleToken ignored = lock.acquire()) { | ||
| // Set before scheduling the unregister, so a register task that is still queued bails out | ||
| // instead of resurrecting the listener after close. | ||
| isClosed = true; | ||
| watcherRef = watcher; | ||
| watcher = null; | ||
| } | ||
|
|
@@ -79,11 +127,189 @@ private void removeObserver() { | |
| options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed."); | ||
| } | ||
| } | ||
|
|
||
| scheduleUnregisterAnrWarningListener(); | ||
| } | ||
|
|
||
| /** | ||
| * {@link ActivityManager#registerAnrWarningListener} is a binder call, so it must not run on the | ||
| * caller's thread — {@link #register} is driven from {@code Sentry.init} on the main thread. | ||
| */ | ||
| private void scheduleRegisterAnrWarningListener( | ||
| final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { | ||
| final @Nullable Context context = this.context; | ||
| if (context == null || buildInfoProvider == null) { | ||
| options | ||
| .getLogger() | ||
| .log(SentryLevel.DEBUG, "ANR warning listener needs a Context, not installed."); | ||
| return; | ||
| } | ||
| if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.CINNAMON_BUN) { | ||
| options | ||
| .getLogger() | ||
| .log(SentryLevel.DEBUG, "ANR warning listener needs Android API 37 and above."); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| options | ||
| .getExecutorService() | ||
| .submit(() -> registerAnrWarningListener(context, scopes, options)); | ||
| } catch (RejectedExecutionException e) { | ||
| options | ||
| .getLogger() | ||
| .log( | ||
| SentryLevel.WARNING, | ||
| "Failed to submit the ANR warning listener registration to the executor service.", | ||
| e); | ||
| } | ||
| } | ||
|
|
||
| private void scheduleUnregisterAnrWarningListener() { | ||
| final @Nullable SentryAndroidOptions options = this.options; | ||
| if (options == null) { | ||
| return; | ||
| } | ||
|
|
||
| // unregisterAnrWarningListener is idempotent, so running it twice after a dropped task is | ||
| // harmless, whereas never running it would leak the listener for the process lifetime. | ||
| try { | ||
| final @NotNull Future<?> future = | ||
| options.getExecutorService().submit(() -> unregisterAnrWarningListener(options)); | ||
| if (future.isCancelled()) { | ||
| unregisterAnrWarningListener(options); | ||
| } | ||
| } catch (RejectedExecutionException e) { | ||
| unregisterAnrWarningListener(options); | ||
| } | ||
| } | ||
|
|
||
| /** Runs on the executor service. Guarded by {@link #scheduleRegisterAnrWarningListener}. */ | ||
| // Lint cannot see through buildInfoProvider, which scheduleRegisterAnrWarningListener checks | ||
| // before this ever runs. The suppression is scoped so that register() stays lint-checked. | ||
| @SuppressLint("NewApi") | ||
| private void registerAnrWarningListener( | ||
| final @NotNull Context context, | ||
| final @NotNull IScopes scopes, | ||
| final @NotNull SentryAndroidOptions options) { | ||
| final @Nullable ActivityManager activityManager = | ||
| (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); | ||
| if (activityManager == null) { | ||
| options | ||
| .getLogger() | ||
| .log( | ||
| SentryLevel.INFO, | ||
| "ActivityManager is not available, ANR warning listener not installed."); | ||
| return; | ||
| } | ||
|
|
||
| final @NotNull Consumer<AnrWarningResult> consumer = | ||
| result -> scopes.addBreadcrumb(createAnrBreadcrumb(result)); | ||
|
|
||
| try (final ISentryLifecycleToken ignored = lock.acquire()) { | ||
| if (isClosed || anrWarningConsumer != null) { | ||
| return; | ||
| } | ||
| try { | ||
| activityManager.registerAnrWarningListener(Runnable::run, consumer); | ||
| } catch (RuntimeException e) { | ||
| options | ||
| .getLogger() | ||
| .log(SentryLevel.WARNING, "Failed to register the ANR warning listener.", e); | ||
| return; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Broad catch missing required handlingMedium Severity The new Additional Locations (1)Triggered by project rule: PR Review Guidelines for Cursor Bugbot Reviewed by Cursor Bugbot for commit 6c235cd. Configure here. |
||
| } | ||
| anrWarningConsumer = consumer; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lock held across binder registrationMedium Severity
Triggered by project rule: PR Review Guidelines for Cursor Bugbot Reviewed by Cursor Bugbot for commit 6c235cd. Configure here. |
||
|
|
||
| options.getLogger().log(SentryLevel.DEBUG, "ANR warning listener installed."); | ||
| } | ||
|
|
||
| @SuppressLint("NewApi") | ||
| private void unregisterAnrWarningListener(final @NotNull SentryAndroidOptions options) { | ||
| final @Nullable Consumer<AnrWarningResult> consumerRef; | ||
| try (final ISentryLifecycleToken ignored = lock.acquire()) { | ||
| consumerRef = anrWarningConsumer; | ||
| anrWarningConsumer = null; | ||
| } | ||
|
|
||
| final @Nullable Context context = this.context; | ||
| if (consumerRef == null || context == null) { | ||
| return; | ||
| } | ||
|
|
||
| final @Nullable ActivityManager activityManager = | ||
| (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); | ||
| if (activityManager == null) { | ||
| options | ||
| .getLogger() | ||
| .log( | ||
| SentryLevel.INFO, | ||
| "ActivityManager is not available, ANR warning listener not unregistered."); | ||
| return; | ||
| } | ||
| try { | ||
| activityManager.unregisterAnrWarningListener(consumerRef); | ||
| } catch (RuntimeException e) { | ||
| options | ||
| .getLogger() | ||
| .log(SentryLevel.WARNING, "Failed to unregister the ANR warning listener.", e); | ||
| } | ||
| } | ||
|
|
||
| @RequiresApi(api = Build.VERSION_CODES.CINNAMON_BUN) | ||
| static @NotNull Breadcrumb createAnrBreadcrumb(final @NotNull AnrWarningResult result) { | ||
| final Breadcrumb breadcrumb = new Breadcrumb(); | ||
| breadcrumb.setType("system"); | ||
| breadcrumb.setCategory("app.anr"); | ||
| breadcrumb.setLevel(SentryLevel.WARNING); | ||
| breadcrumb.setMessage("ANR warning"); | ||
| breadcrumb.setData("anr_type", getAnrTypeName(result.getAnrType())); | ||
| breadcrumb.setData("anr_id", result.getAnrId()); | ||
| breadcrumb.setData("timeout_ms", result.getTimeoutMillis()); | ||
| breadcrumb.setData("consumed_ms", result.getConsumedMillis()); | ||
| // The system leaves this empty for some ANR types, so do not carry a blank value. | ||
| final @Nullable String description = result.getDescription(); | ||
| if (description != null && !description.isEmpty()) { | ||
| breadcrumb.setData("description", description); | ||
| } | ||
| return breadcrumb; | ||
| } | ||
|
|
||
| // AnrTypes only holds compile-time int constants, which javac inlines, so this needs no | ||
| // runtime API 37 symbol and stays callable (and testable) on every API level. | ||
| @SuppressLint("NewApi") | ||
| static @NotNull String getAnrTypeName(final int anrType) { | ||
| switch (anrType) { | ||
| case AnrTypes.ANR_TYPE_OTHER: | ||
| return "other"; | ||
| case AnrTypes.ANR_TYPE_INPUT_DISPATCH_NO_FOCUSED_WINDOW: | ||
| return "input_dispatch_no_focused_window"; | ||
| case AnrTypes.ANR_TYPE_INPUT_DISPATCH: | ||
| return "input_dispatch"; | ||
| case AnrTypes.ANR_TYPE_BROADCAST_OF_INTENT: | ||
| return "broadcast_of_intent"; | ||
| case AnrTypes.ANR_TYPE_START_FOREGROUND_SERVICE: | ||
| return "start_foreground_service"; | ||
| case AnrTypes.ANR_TYPE_EXECUTE_SERVICE: | ||
| return "execute_service"; | ||
| case AnrTypes.ANR_TYPE_CONTENT_PROVIDER_NOT_RESPONDING: | ||
| return "content_provider_not_responding"; | ||
| case AnrTypes.ANR_TYPE_APP_TRIGGERED: | ||
| return "app_triggered"; | ||
| case AnrTypes.ANR_TYPE_FOREGROUND_SHORT_SERVICE_TIMEOUT: | ||
| return "foreground_short_service_timeout"; | ||
| case AnrTypes.ANR_TYPE_JOB_SERVICE_START: | ||
| return "job_service_start"; | ||
| case AnrTypes.ANR_TYPE_APPLICATION_START: | ||
| return "application_start"; | ||
| default: | ||
| return "unknown (" + anrType + ")"; | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void close() throws IOException { | ||
| removeObserver(); | ||
| removeObservers(); | ||
| // TODO: probably should move it to Scopes.close(), but that'd require a new interface and | ||
| // different implementations for Java and Android. This is probably fine like this too, because | ||
| // integrations are closed in the same place | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Null application context skips ANR listener
Low Severity
The new constructor stores
context.getApplicationContext()directly. That method can return null for someContextimplementations (ContextImplis called out inContextUtils).scheduleRegisterAnrWarningListenerthen treats a null context as “no Context” and never installs the ANR warning listener. Other integrations, includingMemoryLimiterIntegrationandAnrV2Integration, useContextUtils.getApplicationContext, which falls back to the original context.Reviewed by Cursor Bugbot for commit 6c235cd. Configure here.