From a26b3e18c8253f05d95fe62749f6257850a282d4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 27 Oct 2026 12:59:22 +0100 Subject: [PATCH 1/3] fix(anr): Skip ANR profiling while a debugger is connected --- .../api/sentry-android-core.api | 1 + .../core/anr/AnrProfilingIntegration.java | 15 +++++++++ .../core/anr/AnrProfilingIntegrationTest.kt | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index efd776cabb8..b1d0fbff0d9 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -736,6 +736,7 @@ public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Inte public fun close ()V protected fun getProfileManager ()Lio/sentry/android/core/anr/AnrProfileManager; protected fun getState ()Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + protected fun isDebuggerConnected ()Z public fun onBackground ()V public fun onForeground ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 3cb0bea4b0f..38db383081b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -2,6 +2,7 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; +import android.os.Debug; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; @@ -211,6 +212,15 @@ public void run() { @ApiStatus.Internal protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { + if (isDebuggerConnected()) { + // The main thread can sit at a breakpoint for a long time, which is not an ANR. Reset the + // timestamp, so the paused time does not count against us after the debugger detaches. + mainThreadState = MainThreadState.IDLE; + sampled = false; + updater.run(); + return; + } + final long now = SystemClock.uptimeMillis(); final long diff = now - lastMainThreadExecutionTime; @@ -268,6 +278,11 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept } } + @ApiStatus.Internal + protected boolean isDebuggerConnected() { + return Debug.isDebuggerConnected(); + } + @TestOnly @NotNull protected MainThreadState getState() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 2ae48fb3253..75bca72b080 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -196,6 +196,37 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) } + @Test + fun `debugger pauses are treated as idle`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + } + + var debuggerConnected = true + val integration = + object : AnrProfilingIntegration() { + override fun isDebuggerConnected(): Boolean = debuggerConnected + } + integration.register(mockScopes, androidOptions) + + SystemClock.setCurrentTimeMillis(20_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + assertEquals(0, integration.numCollectedStacks.get()) + + // the time spent paused must not count towards an ANR once the debugger detaches + debuggerConnected = false + SystemClock.setCurrentTimeMillis(20_100) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + } + @Test fun `background foreground transitions don't trigger an ANR`() { val mainThread = Thread.currentThread() From b494bcccf13b5bd1eec83ed585b490923dee655b Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 27 Oct 2026 13:19:31 +0100 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5886cb0c6d2..e85bcf1600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Skip ANR profiling while a debugger is connected ([#6389](https://github.com/getsentry/sentry-java/pull/6389)) + ### Dependencies - Bump Native SDK from v0.17.2 to v0.17.3 ([#6250](https://github.com/getsentry/sentry-java/pull/6250)) From b9e008821a8cca54c0b52f675864768e17502ab5 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Nov 2026 09:06:31 +0100 Subject: [PATCH 3/3] ref(anr): Provide debugger detection through SentryOptions --- .../api/sentry-android-core.api | 6 ++- .../io/sentry/android/core/ANRWatchDog.java | 16 +++++-- .../android/core/AndroidDebugManager.java | 14 ++++++ .../core/AndroidOptionsInitializer.java | 3 ++ .../sentry/android/core/AnrIntegration.java | 3 +- .../core/anr/AnrProfilingIntegration.java | 11 ++--- .../io/sentry/android/core/ANRWatchDogTest.kt | 48 +++++++++++++++++-- .../core/AndroidOptionsInitializerTest.kt | 7 +++ .../core/anr/AnrProfilingIntegrationTest.kt | 8 ++-- sentry/api/sentry.api | 6 +++ .../main/java/io/sentry/IDebugManager.java | 13 +++++ .../main/java/io/sentry/SentryOptions.java | 12 +++++ 12 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java create mode 100644 sentry/src/main/java/io/sentry/IDebugManager.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index b1d0fbff0d9..0154adc7aaf 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -65,6 +65,11 @@ public final class io/sentry/android/core/AndroidDateUtils { public static fun getCurrentSentryDateTime ()Lio/sentry/SentryDate; } +public final class io/sentry/android/core/AndroidDebugManager : io/sentry/IDebugManager { + public fun ()V + public fun isDebuggerConnected ()Z +} + public final class io/sentry/android/core/AndroidFatalLogger : io/sentry/ILogger { public fun ()V public fun (Ljava/lang/String;)V @@ -736,7 +741,6 @@ public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Inte public fun close ()V protected fun getProfileManager ()Lio/sentry/android/core/anr/AnrProfileManager; protected fun getState ()Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; - protected fun isDebuggerConnected ()Z public fun onBackground ()V public fun onForeground ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java index b726dd0c881..c8f1bf5e361 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java @@ -29,14 +29,15 @@ import android.app.ActivityManager; import android.content.Context; -import android.os.Debug; import android.os.SystemClock; +import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.transport.ICurrentDateProvider; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** A watchdog timer thread that detects when the UI thread has frozen. */ @@ -58,6 +59,7 @@ final class ANRWatchDog extends Thread { private final AtomicBoolean reported = new AtomicBoolean(false); private final @NotNull Context context; + private final @Nullable IDebugManager debugManager; @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; @@ -67,7 +69,8 @@ final class ANRWatchDog extends Thread { boolean reportInDebug, @NotNull ANRListener listener, @NotNull ILogger logger, - final @NotNull Context context) { + final @NotNull Context context, + final @Nullable IDebugManager debugManager) { // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef this( @@ -78,7 +81,8 @@ final class ANRWatchDog extends Thread { listener, logger, new MainLooperHandler(), - context); + context, + debugManager); } @TestOnly @@ -90,7 +94,8 @@ final class ANRWatchDog extends Thread { @NotNull ANRListener listener, @NotNull ILogger logger, @NotNull MainLooperHandler uiHandler, - final @NotNull Context context) { + final @NotNull Context context, + final @Nullable IDebugManager debugManager) { super("|ANR-WatchDog|"); @@ -102,6 +107,7 @@ final class ANRWatchDog extends Thread { this.logger = logger; this.uiHandler = uiHandler; this.context = context; + this.debugManager = debugManager; this.ticker = () -> { lastKnownActiveUiTimestampMs = timeProvider.getCurrentTimeMillis(); @@ -145,7 +151,7 @@ public void run() { // If the main thread has not handled ticker, it is blocked. ANR. if (unresponsiveDurationMs > timeoutIntervalMillis) { - if (!reportInDebug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { + if (!reportInDebug && debugManager != null && debugManager.isDebuggerConnected()) { logger.log( SentryLevel.DEBUG, "An ANR was detected but ignored because the debugger is connected."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java new file mode 100644 index 00000000000..75afd02f24c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java @@ -0,0 +1,14 @@ +package io.sentry.android.core; + +import android.os.Debug; +import io.sentry.IDebugManager; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class AndroidDebugManager implements IDebugManager { + + @Override + public boolean isDebuggerConnected() { + return Debug.isDebuggerConnected() || Debug.waitingForDebugger(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 12ffb90356c..3873e727639 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -249,6 +249,9 @@ static void initializeIntegrationsAndProcessors( if (options.getThreadChecker() instanceof NoOpThreadChecker) { options.setThreadChecker(AndroidThreadChecker.getInstance()); } + if (options.getDebugManager() == null) { + options.setDebugManager(new AndroidDebugManager()); + } if (options.getSocketTagger() instanceof NoOpSocketTagger) { options.setSocketTagger(AndroidSocketTagger.getInstance()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index f37d433d308..b9fd0b781d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -100,7 +100,8 @@ private void startAnrWatchdog( options.isAnrReportInDebug(), error -> reportANR(scopes, options, error), options.getLogger(), - context); + context, + options.getDebugManager()); anrWatchDog.start(); options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 38db383081b..bc41f3faf6a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -2,10 +2,10 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; -import android.os.Debug; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -55,6 +55,7 @@ public class AnrProfilingIntegration private volatile @Nullable AnrProfileManager profileManager; private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); private volatile @Nullable SentryAndroidOptions options; + private volatile @Nullable IDebugManager debugManager; private volatile @Nullable Thread thread = null; private volatile boolean sampled = false; private volatile boolean inForeground = false; @@ -68,6 +69,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); + this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { if (this.options.getCacheDirPath() == null) { @@ -212,7 +214,7 @@ public void run() { @ApiStatus.Internal protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { - if (isDebuggerConnected()) { + if (debugManager != null && debugManager.isDebuggerConnected()) { // The main thread can sit at a breakpoint for a long time, which is not an ANR. Reset the // timestamp, so the paused time does not count against us after the debugger detaches. mainThreadState = MainThreadState.IDLE; @@ -278,11 +280,6 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept } } - @ApiStatus.Internal - protected boolean isDebuggerConnected() { - return Debug.isDebuggerConnected(); - } - @TestOnly @NotNull protected MainThreadState getState() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index 2f3b6791c5b..02522f725f1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -43,7 +43,17 @@ class ANRWatchDogTest { val interval = 10L val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -80,7 +90,17 @@ class ANRWatchDogTest { val interval = 10L val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -122,7 +142,17 @@ class ANRWatchDogTest { whenever(am.processesInErrorState).thenReturn(anrs) val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + context, + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -168,7 +198,17 @@ class ANRWatchDogTest { whenever(am.processesInErrorState).thenReturn(anrs) val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + context, + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 4610e4bbcb8..f1cdd26a1f7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -860,6 +860,13 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.threadChecker is AndroidThreadChecker } } + @Test + fun `AndroidDebugManager is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.debugManager is AndroidDebugManager } + } + @Test fun `AndroidSocketTagger is set to options`() { fixture.initSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 75bca72b080..6d78a8f70c8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -200,19 +200,17 @@ class AnrProfilingIntegrationTest { fun `debugger pauses are treated as idle`() { val mainThread = Thread.currentThread() SystemClock.setCurrentTimeMillis(1_000) + var debuggerConnected = true val androidOptions = SentryAndroidOptions().apply { cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) anrProfilingSampleRate = 1.0 + debugManager = { debuggerConnected } } - var debuggerConnected = true - val integration = - object : AnrProfilingIntegration() { - override fun isDebuggerConnected(): Boolean = debuggerConnected - } + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) SystemClock.setCurrentTimeMillis(20_000) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 01b068ee689..26dbd34f053 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -906,6 +906,10 @@ public abstract interface class io/sentry/IContinuousProfiler { public abstract fun stopProfiler (Lio/sentry/ProfileLifecycle;)V } +public abstract interface class io/sentry/IDebugManager { + public abstract fun isDebuggerConnected ()Z +} + public abstract interface class io/sentry/IDistributionApi { public abstract fun checkForUpdate ()Ljava/util/concurrent/Future; public abstract fun checkForUpdateBlocking ()Lio/sentry/UpdateStatus; @@ -3787,6 +3791,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun getDataCollectionResolver ()Lio/sentry/DataCollectionResolver; public fun getDateProvider ()Lio/sentry/SentryDateProvider; public fun getDeadlineTimeout ()J + public fun getDebugManager ()Lio/sentry/IDebugManager; public fun getDebugMetaLoader ()Lio/sentry/internal/debugmeta/IDebugMetaLoader; public fun getDefaultScopeType ()Lio/sentry/ScopeType; public fun getDiagnosticLevel ()Lio/sentry/SentryLevel; @@ -3941,6 +3946,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun setDateProvider (Lio/sentry/SentryDateProvider;)V public fun setDeadlineTimeout (J)V public fun setDebug (Z)V + public fun setDebugManager (Lio/sentry/IDebugManager;)V public fun setDebugMetaLoader (Lio/sentry/internal/debugmeta/IDebugMetaLoader;)V public fun setDefaultScopeType (Lio/sentry/ScopeType;)V public fun setDiagnosticLevel (Lio/sentry/SentryLevel;)V diff --git a/sentry/src/main/java/io/sentry/IDebugManager.java b/sentry/src/main/java/io/sentry/IDebugManager.java new file mode 100644 index 00000000000..7d0bee68f9c --- /dev/null +++ b/sentry/src/main/java/io/sentry/IDebugManager.java @@ -0,0 +1,13 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public interface IDebugManager { + + /** + * Returns whether a debugger is currently attached or the process is waiting for a debugger to + * attach. + */ + boolean isDebuggerConnected(); +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 2b2f9d370a0..00e7809f96f 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -517,6 +517,8 @@ public class SentryOptions implements RateLimiterConfig { private @NotNull IThreadChecker threadChecker = NoOpThreadChecker.getInstance(); + private @Nullable IDebugManager debugManager; + // TODO [MAJOR] this should default to false on the next major /** Whether OPTIONS requests should be traced. */ private boolean traceOptionsRequests = true; @@ -2739,6 +2741,16 @@ public void setThreadChecker(final @NotNull IThreadChecker threadChecker) { this.threadChecker = threadChecker; } + @ApiStatus.Internal + public @Nullable IDebugManager getDebugManager() { + return debugManager; + } + + @ApiStatus.Internal + public void setDebugManager(final @Nullable IDebugManager debugManager) { + this.debugManager = debugManager; + } + /** * Gets the performance collector used to collect performance stats in a time period. *