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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
- Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216))
- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215))
- Sentry OpenTelemetry integrations now honor `OTEL_BSP_SCHEDULE_DELAY` (`otel.bsp.schedule.delay`) for how often spans are sent to Sentry, instead of always waiting 5 seconds ([#6388](https://github.com/getsentry/sentry-java/pull/6388))
- Record ANR warnings from the system as `app.anr` breadcrumbs on Android 17 (API 37) and above ([#6245](https://github.com/getsentry/sentry-java/pull/6245))
- The breadcrumb contains the ANR type, the system description, the timeout, and how much of the timeout was already used. It is recorded when `enableAppLifecycleBreadcrumbs` is enabled.

### Behavioral Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.JavaBasePlugin
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
Expand Down Expand Up @@ -110,7 +111,18 @@ class JdkTargetConvention : Plugin<Project> {
}

listOf("com.android.library", "com.android.application").forEach { pluginId ->
project.plugins.withId(pluginId) { project.targetBuildJdkForUnitTests() }
project.plugins.withId(pluginId) {
project.targetBuildJdkForUnitTests()
// Robolectric's SDK 37 sandbox (the default, as targetSdk is 37) reflectively writes
// FileDescriptor fields in ApplicationSharedMemory; without this, setup fails with
// "Failed to interact with raw FileDescriptor internals".
project.tasks.withType(Test::class.java).configureEach {
jvmArgs(
"--add-opens=java.base/java.io=ALL-UNNAMED",
"--add-exports=java.base/jdk.internal.access=ALL-UNNAMED",
)
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.
okhttp-mockwebserver4 = { module = "com.squareup.okhttp3:mockwebserver", version = "4.9.2" }
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
okio = { module = "com.squareup.okio:okio", version = "1.13.0" }
roboelectric = { module = "org.robolectric:robolectric", version = "4.15" }
roboelectric = { module = "org.robolectric:robolectric", version = "4.17" }

[bundles]
androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"]
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration :

public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/Integration, java/io/Closeable {
public fun <init> ()V
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V
public fun close ()V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ static void installDefaultIntegrations(

// AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration
// relies on AppState set by it
options.addIntegration(new AppLifecycleIntegration());
options.addIntegration(new AppLifecycleIntegration(context, buildInfoProvider));
// AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on
// it to set the replayId in case of an ANR
options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,68 @@

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;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.ExceptionUtils;
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 = ContextUtils.getApplicationContext(context);
this.buildInfoProvider = buildInfoProvider;
}
Comment thread
markushi marked this conversation as resolved.
Resolved

@Override
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) {
Objects.requireNonNull(scopes, "Scopes are required");
Expand Down Expand Up @@ -61,14 +103,21 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
AppState.getInstance().addAppStateListener(watcher);
}

if (this.options.isEnableAppLifecycleBreadcrumbs()) {
scheduleRegisterAnrWarningListener(scopes, this.options);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ANR breadcrumbs enabled by default

Low Severity

The new app.anr breadcrumbs are installed whenever enableAppLifecycleBreadcrumbs is on, and that option already defaults to true. There is no separate opt-in for ANR warnings, so every Android 17+ app that leaves the existing lifecycle-breadcrumb toggle unchanged starts sending this new signal.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit a46e24c. Configure here.


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;
}
Expand All @@ -79,11 +128,216 @@ 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unregister can miss executor shutdown

Medium Severity

close() only runs unregisterAnrWarningListener inline when submit throws or the returned Future is already cancelled. A task that is queued and later dropped by executorService.close() → shutdownNow() never unregisters. ActivityManager then keeps the Consumer, which captures scopes and can pin the closed SDK until process death.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit a46e24c. Configure here.

}

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

// The binder call runs without the lock, so that close() never waits for the system.
try {
activityManager.registerAnrWarningListener(Runnable::run, consumer);
} catch (RuntimeException e) {
// Broad catch, because a call into the system server can throw any RuntimeException. On
// failure the listener is not installed, so no app.anr breadcrumbs are recorded.
ExceptionUtils.rethrowIfFatal(e);
options
.getLogger()
.log(SentryLevel.WARNING, "Failed to register the ANR warning listener.", e);
return;
}

final boolean closedDuringRegistration;
try (final ISentryLifecycleToken ignored = lock.acquire()) {
closedDuringRegistration = isClosed;
if (!closedDuringRegistration) {
anrWarningConsumer = consumer;
}
}
if (closedDuringRegistration) {
unregisterAnrWarningListener(activityManager, consumer, options);
return;
}
Comment thread
markushi marked this conversation as resolved.
Resolved

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;
}
unregisterAnrWarningListener(activityManager, consumerRef, options);
}

@SuppressLint("NewApi")
private static void unregisterAnrWarningListener(
final @NotNull ActivityManager activityManager,
final @NotNull Consumer<AnrWarningResult> consumer,
final @NotNull SentryAndroidOptions options) {
try {
activityManager.unregisterAnrWarningListener(consumer);
} catch (RuntimeException e) {
// Broad catch, because a call into the system server can throw any RuntimeException. On
// failure the listener stays registered until the process ends.
ExceptionUtils.rethrowIfFatal(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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ANR description may include PII

Medium Severity

The app.anr breadcrumb copies AnrWarningResult.getDescription() into event data with no sendDefaultPii check. For broadcast and similar ANRs the system string is an Intent dump and can include a data URI (phone numbers, mailto, content:// paths, URL query strings). That payload is then attached to later events by default.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit a46e24c. Configure here.

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
Expand Down
Loading