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 @@ -12,6 +12,8 @@
- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Sentry creates each monitor from the method's `@Scheduled` schedule. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276), [#6277](https://github.com/getsentry/sentry-java/pull/6277))
- 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))
- 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 @@ -301,7 +301,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,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;
Expand All @@ -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;
}

Copy link
Copy Markdown

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 some Context implementations (ContextImpl is called out in ContextUtils). scheduleRegisterAnrWarningListener then treats a null context as “no Context” and never installs the ANR warning listener. Other integrations, including MemoryLimiterIntegration and AnrV2Integration, use ContextUtils.getApplicationContext, which falls back to the original context.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6c235cd. Configure here.


@Override
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) {
Objects.requireNonNull(scopes, "Scopes are required");
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Broad catch missing required handling

Medium Severity

The new RuntimeException catches around the ActivityManager binder calls sit at a system-service boundary but do not call ExceptionUtils.rethrowIfFatal first and have no comment explaining why the catch is broad or what happens on failure (listener not installed, or leftover registration). Nearby API 37 code such as MemoryLimiterIntegration follows that pattern.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit 6c235cd. Configure here.

}
anrWarningConsumer = consumer;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lock held across binder registration

Medium Severity

activityManager.registerAnrWarningListener is a binder IPC call, but it runs while lock is held, and the failure path also logs under that lock. close() acquires the same lock and often runs on the main thread, so a slow or stuck ActivityManager call can block Sentry.close() and show up as an ANR. Unregister already performs its binder call after releasing the lock.

Fix in Cursor Fix in Web

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