From 7b33db55c5f4caa15f5011317a9d452a74bd12ef Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 1/9] test(time): Publish the TestMonotonicTicker tick to other threads The ANR tests advance the ticker from the test thread while the watchdog thread reads it, which without volatile is a data race that can leave the watchdog looking at a stale tick forever. --- .../src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt index 2471a1bc8dd..31944ec22cb 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt @@ -8,8 +8,11 @@ import java.util.concurrent.TimeUnit * Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a * nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001, * MILLISECONDS)` cannot be. + * + * The tick is volatile so that a test thread can advance the ticker while the code under test reads + * it from another thread. */ -class TestMonotonicTicker(private var nanos: Long = 0) : MonotonicTicker { +class TestMonotonicTicker(@Volatile private var nanos: Long = 0) : MonotonicTicker { override fun tickNanos(): Long = nanos fun advance(amount: Long, unit: TimeUnit) { From 27cb3296b560037c9065f65681a598c077ce2e3c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:03 +0200 Subject: [PATCH 2/9] ref(android): Measure ANR thresholds on the monotonic clock (JAVA-579) The watchdog took its readings from an ICurrentDateProvider lambda over SystemClock.uptimeMillis(). The type named no clock, so a call site could not tell what it was measuring, and the arithmetic -- now minus the last tick, compared against a threshold -- was spelled out inline. MonotonicClock and Deadline replace both: the clock is a named type, and the watchdog asks the question it actually cares about, which is whether the main thread has missed its window. The clock counts deep sleep, which uptimeMillis() did not, so a suspend between posting the ticker and checking it now looks like a missed window. It cannot fabricate an ANR: the watchdog reports only once ActivityManager confirms the process is NOT_RESPONDING, and on resume the main thread runs the ticker that is already queued. --- .../io/sentry/android/core/ANRWatchDog.java | 30 ++++----- .../io/sentry/android/core/ANRWatchDogTest.kt | 63 +++++++++++++++---- 2 files changed, 67 insertions(+), 26 deletions(-) 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 c8f1bf5e361..2f59a092a28 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,12 +29,14 @@ import android.app.ActivityManager; import android.content.Context; -import android.os.SystemClock; import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.SentryLevel; -import io.sentry.transport.ICurrentDateProvider; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; +import io.sentry.time.Deadline; +import io.sentry.time.MonotonicTicker; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -47,7 +49,7 @@ final class ANRWatchDog extends Thread { private final boolean reportInDebug; private final ANRListener anrListener; private final MainLooperHandler uiHandler; - private final ICurrentDateProvider timeProvider; + private final MonotonicTicker monotonicTicker; /** the interval in which we check if there's an ANR, in ms */ private long pollingIntervalMs; @@ -55,7 +57,9 @@ final class ANRWatchDog extends Thread { private final long timeoutIntervalMillis; private final @NotNull ILogger logger; - private volatile long lastKnownActiveUiTimestampMs = 0; + /** How long the main thread has left to run the ticker before we call it an ANR. */ + private volatile @NotNull Deadline uiResponsiveUntil; + private final AtomicBoolean reported = new AtomicBoolean(false); private final @NotNull Context context; @@ -71,10 +75,8 @@ final class ANRWatchDog extends Thread { @NotNull ILogger logger, final @NotNull Context context, final @Nullable IDebugManager debugManager) { - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef this( - () -> SystemClock.uptimeMillis(), + AndroidMonotonicTicker.getInstance(), timeoutIntervalMillis, 500, reportInDebug, @@ -87,7 +89,7 @@ final class ANRWatchDog extends Thread { @TestOnly ANRWatchDog( - @NotNull final ICurrentDateProvider timeProvider, + @NotNull final MonotonicTicker monotonicTicker, long timeoutIntervalMillis, long pollingIntervalMillis, boolean reportInDebug, @@ -99,7 +101,7 @@ final class ANRWatchDog extends Thread { super("|ANR-WatchDog|"); - this.timeProvider = timeProvider; + this.monotonicTicker = monotonicTicker; this.timeoutIntervalMillis = timeoutIntervalMillis; this.pollingIntervalMs = pollingIntervalMillis; this.reportInDebug = reportInDebug; @@ -108,9 +110,12 @@ final class ANRWatchDog extends Thread { this.uiHandler = uiHandler; this.context = context; this.debugManager = debugManager; + this.uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); this.ticker = () -> { - lastKnownActiveUiTimestampMs = timeProvider.getCurrentTimeMillis(); + uiResponsiveUntil = + Deadline.after(monotonicTicker, timeoutIntervalMillis, TimeUnit.MILLISECONDS); reported.set(false); }; @@ -146,11 +151,8 @@ public void run() { return; } - final long unresponsiveDurationMs = - timeProvider.getCurrentTimeMillis() - lastKnownActiveUiTimestampMs; - // If the main thread has not handled ticker, it is blocked. ANR. - if (unresponsiveDurationMs > timeoutIntervalMillis) { + if (uiResponsiveUntil.hasPassed()) { if (!reportInDebug && debugManager != null && debugManager.isDebuggerConnected()) { logger.log( SentryLevel.DEBUG, 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 02522f725f1..b9a255c886d 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 @@ -4,10 +4,11 @@ import android.app.ActivityManager import android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING import android.app.ActivityManager.ProcessErrorStateInfo.NO_ERROR import android.content.Context -import io.sentry.transport.ICurrentDateProvider +import io.sentry.time.TestMonotonicTicker import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -20,12 +21,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class ANRWatchDogTest { - private var currentTimeMs = 0L - private val timeProvider = ICurrentDateProvider { currentTimeMs } + private lateinit var ticker: TestMonotonicTicker @Before fun `setup`() { - currentTimeMs = 12341234 + // an arbitrary, non-zero origin: no caller may assume a tick counts from zero + ticker = TestMonotonicTicker(MILLISECONDS.toNanos(12341234)) } @Test @@ -44,7 +45,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -63,7 +64,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -91,7 +92,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -107,7 +108,7 @@ class ANRWatchDogTest { var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize his runner always runs } while (!invoked && waitCount++ < 100) @@ -143,7 +144,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -162,7 +163,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) @@ -199,7 +200,7 @@ class ANRWatchDogTest { val sut = ANRWatchDog( - timeProvider, + ticker, interval, 1L, true, @@ -218,7 +219,7 @@ class ANRWatchDogTest { ) // Wait until worker posts the job for the "UI thread" var waitCount = 0 do { - currentTimeMs += 100L + ticker.advance(100, MILLISECONDS) Thread.sleep(100L) // Let worker realize this is ANR } while (anr == null && waitCount++ < 100) assertNull(anr) // callback never ran @@ -227,4 +228,42 @@ class ANRWatchDogTest { es.shutdown() } } + + @Test + fun `a device suspend does not trip the ANR threshold`() { + // The uptime clock stands still while the device is suspended, so a suspend cannot be mistaken + // for a blocked main thread. On an elapsed-real-time clock the suspended interval would count + // against the main thread and fabricate an ANR. + var anr: ApplicationNotResponding? = null + val handler = mock() + val latch = CountDownLatch(1) + whenever(handler.post(any())).then { latch.countDown() } + whenever(handler.thread).thenReturn(mock()) + + // context is a mock, so ActivityManager is absent and any passed deadline reports an ANR + val sut = + ANRWatchDog( + ticker, + 10L, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) + val es = Executors.newSingleThreadExecutor() + try { + es.submit { sut.run() } + + assertTrue(latch.await(10L, TimeUnit.SECONDS)) // wait until the watchdog is polling + Thread.sleep(200) // hundreds of polls of wall time, none of it uptime + + assertNull(anr) + } finally { + sut.interrupt() + es.shutdown() + } + } } From 76fb92079878b2d427d0a8a26627a95f7c8b13d3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 3/9] ref(android): Measure ANR profiling on the monotonic clock (JAVA-579) Same reasoning as the watchdog: the suspicion and ANR thresholds are now read from a named clock rather than SystemClock, and injecting it lets the tests drive it directly instead of going through Robolectric's shadow clock. Deep sleep cannot inflate the measurement here either, because the polling thread parks itself while the app is backgrounded and resets the baseline when it wakes. --- .../core/anr/AnrProfilingIntegration.java | 32 +++++++++++++----- .../core/anr/AnrProfilingIntegrationTest.kt | 33 +++++++++---------- 2 files changed, 40 insertions(+), 25 deletions(-) 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 bc41f3faf6a..52906f2cdd8 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 @@ -4,7 +4,6 @@ import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.IScopes; @@ -15,12 +14,16 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.time.AndroidMonotonicTicker; +import io.sentry.time.MonotonicTicker; +import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.SentryRandom; import java.io.Closeable; import java.io.File; import java.io.IOException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; @@ -44,12 +47,13 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private final @NotNull MonotonicTicker ticker; + private final @NotNull Runnable updater; private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = new AutoClosableReentrantLock(); - private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private volatile long lastMainThreadExecutionNanos; final AtomicInteger numCollectedStacks = new AtomicInteger(); private volatile MainThreadState mainThreadState = MainThreadState.IDLE; private volatile @Nullable AnrProfileManager profileManager; @@ -62,6 +66,17 @@ public class AnrProfilingIntegration private volatile @Nullable Handler mainHandler; private volatile @Nullable Thread mainThread; + public AnrProfilingIntegration() { + this(AndroidMonotonicTicker.getInstance()); + } + + @TestOnly + AnrProfilingIntegration(final @NotNull MonotonicTicker ticker) { + this.ticker = ticker; + this.lastMainThreadExecutionNanos = ticker.tickNanos(); + this.updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + } + @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = @@ -223,8 +238,8 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept return; } - final long now = SystemClock.uptimeMillis(); - final long diff = now - lastMainThreadExecutionTime; + final long diff = + TimeUnit.NANOSECONDS.toMillis(ticker.tickNanos() - lastMainThreadExecutionNanos); if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; @@ -253,14 +268,15 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept && (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { - final long start = SystemClock.uptimeMillis(); + final @NotNull Stopwatch stopwatch = Stopwatch.started(ticker); final @NotNull AnrStackTrace trace = new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); - final long duration = SystemClock.uptimeMillis() - start; if (logger.isEnabled(SentryLevel.DEBUG)) { logger.log( SentryLevel.DEBUG, - "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + "AnrWatchdog: capturing main thread stacktrace took " + + stopwatch.elapsed(TimeUnit.MILLISECONDS) + + "ms"); } addStackTrace(trace); } else { 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 6d78a8f70c8..e122b6e292c 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 @@ -1,6 +1,5 @@ package io.sentry.android.core.anr -import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.IScopes @@ -9,6 +8,8 @@ import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty +import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -30,9 +31,11 @@ class AnrProfilingIntegrationTest { private lateinit var mockScopes: IScopes private lateinit var mockLogger: ILogger private lateinit var options: SentryAndroidOptions + private lateinit var ticker: TestMonotonicTicker @BeforeTest fun setup() { + ticker = TestMonotonicTicker() mockScopes = mock() mockLogger = mock() options = @@ -163,7 +166,6 @@ class AnrProfilingIntegrationTest { @Test fun `properly walks through state transitions and collects stack traces`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -172,20 +174,20 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) // Drive the state machine synchronously to avoid racing the background polling thread. - SystemClock.setCurrentTimeMillis(1_000) + ticker.advance(900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) assertTrue(integration.profileManager.load().stacks.isEmpty()) - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) assertEquals(2, integration.profileManager.load().stacks.size) @@ -199,7 +201,6 @@ class AnrProfilingIntegrationTest { @Test fun `debugger pauses are treated as idle`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_000) var debuggerConnected = true val androidOptions = @@ -210,17 +211,17 @@ class AnrProfilingIntegrationTest { debugManager = { debuggerConnected } } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) - SystemClock.setCurrentTimeMillis(20_000) + ticker.advance(19_000, MILLISECONDS) 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) + ticker.advance(100, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) } @@ -228,7 +229,6 @@ class AnrProfilingIntegrationTest { @Test fun `background foreground transitions don't trigger an ANR`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_000) val androidOptions = SentryAndroidOptions().apply { @@ -237,11 +237,11 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) integration.onBackground() - SystemClock.setCurrentTimeMillis(20_000) + ticker.advance(19_000, MILLISECONDS) integration.onForeground() Thread.sleep(100) @@ -295,7 +295,6 @@ class AnrProfilingIntegrationTest { @Test fun `does not collect stacks when sample rate is zero`() { val mainThread = Thread.currentThread() - SystemClock.setCurrentTimeMillis(1_00) val androidOptions = SentryAndroidOptions().apply { @@ -304,17 +303,17 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 0.0 } - val integration = AnrProfilingIntegration() + val integration = AnrProfilingIntegration(ticker) integration.register(mockScopes, androidOptions) integration.onForeground() // Transition to suspicious - SystemClock.setCurrentTimeMillis(3_000) + ticker.advance(2_900, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) // Transition to ANR - SystemClock.setCurrentTimeMillis(6_000) + ticker.advance(3_000, MILLISECONDS) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) From c4e0e26034f53ef8248f7d105f8160f2dc36caaa Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 3 Sep 2026 17:29:04 +0200 Subject: [PATCH 4/9] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9cc8e2795..b3a16d9c7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0176) - [diff](https://github.com/getsentry/sentry-native/compare/0.17.4...0.17.6) +### Internal + +- Measure ANR detection and ANR profiling durations on the internal `MonotonicTicker` ([#6041](https://github.com/getsentry/sentry-java/pull/6041)) + ## 8.62.0 ### Improvements From 242fa018ed8d051296c7b2e7128f9a812d9b0449 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 9 Sep 2026 18:07:25 +0200 Subject: [PATCH 5/9] ref(time): Supply the ANR ticker through MonotonicTickerProvider (JAVA-579) Both ANR detectors resolved AndroidMonotonicTicker.getInstance() themselves, so each call site named the concrete Android ticker and neither could be pointed at another one. SentryAndroidOptions already knows which ticker the platform wants, but it is final, so a test cannot override the getter. Add a one-method MonotonicTickerProvider that SentryOptions implements. ANRWatchDog gains a static factory that reads its timeout, debug behavior, logger and ticker off options, replacing the telescoping convenience constructor. AnrProfilingIntegration installs the ticker in register(), which drops the two constructors it had grown and leaves the public no-arg constructor and sentry-android-core.api unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/sentry/android/core/ANRWatchDog.java | 31 +++++++++---------- .../sentry/android/core/AnrIntegration.java | 8 +---- .../core/anr/AnrProfilingIntegration.java | 28 ++++++++++------- .../core/anr/AnrProfilingIntegrationTest.kt | 12 ++++--- sentry/api/sentry.api | 6 +++- .../main/java/io/sentry/SentryOptions.java | 4 ++- .../sentry/time/MonotonicTickerProvider.java | 18 +++++++++++ 7 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java 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 2f59a092a28..f7f8d2de013 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 @@ -32,7 +32,6 @@ import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.SentryLevel; -import io.sentry.android.core.internal.time.AndroidMonotonicTicker; import io.sentry.time.Deadline; import io.sentry.time.MonotonicTicker; import java.util.List; @@ -40,12 +39,13 @@ 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. */ @SuppressWarnings("UnusedReturnValue") final class ANRWatchDog extends Thread { + private static final long DEFAULT_POLLING_INTERVAL_MS = 500; + private final boolean reportInDebug; private final ANRListener anrListener; private final MainLooperHandler uiHandler; @@ -68,26 +68,23 @@ final class ANRWatchDog extends Thread { @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; - ANRWatchDog( - long timeoutIntervalMillis, - boolean reportInDebug, - @NotNull ANRListener listener, - @NotNull ILogger logger, - final @NotNull Context context, - final @Nullable IDebugManager debugManager) { - this( - AndroidMonotonicTicker.getInstance(), - timeoutIntervalMillis, - 500, - reportInDebug, + /** Reads the timeout, the debug behavior, the logger and the ticker off {@code options}. */ + static @NotNull ANRWatchDog create( + final @NotNull SentryAndroidOptions options, + final @NotNull ANRListener listener, + final @NotNull Context context) { + return new ANRWatchDog( + options.getMonotonicTicker(), + options.getAnrTimeoutIntervalMillis(), + DEFAULT_POLLING_INTERVAL_MS, + options.isAnrReportInDebug(), listener, - logger, + options.getLogger(), new MainLooperHandler(), context, - debugManager); + options.getDebugManager()); } - @TestOnly ANRWatchDog( @NotNull final MonotonicTicker monotonicTicker, long timeoutIntervalMillis, 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 b9fd0b781d8..275914e3fe2 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 @@ -95,13 +95,7 @@ private void startAnrWatchdog( options.getAnrTimeoutIntervalMillis()); anrWatchDog = - new ANRWatchDog( - options.getAnrTimeoutIntervalMillis(), - options.isAnrReportInDebug(), - error -> reportANR(scopes, options, error), - options.getLogger(), - context, - options.getDebugManager()); + ANRWatchDog.create(options, error -> reportANR(scopes, options, error), context); 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 52906f2cdd8..f6ea9599a17 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 @@ -14,8 +14,9 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; -import io.sentry.android.core.internal.time.AndroidMonotonicTicker; +import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; +import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -47,8 +48,11 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private final @NotNull MonotonicTicker ticker; - private final @NotNull Runnable updater; + private volatile @NotNull MonotonicTicker ticker = JavaMonotonicTicker.getInstance(); + + @SuppressWarnings("UnnecessaryLambda") + private final @NotNull Runnable updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = new AutoClosableReentrantLock(); @@ -66,15 +70,16 @@ public class AnrProfilingIntegration private volatile @Nullable Handler mainHandler; private volatile @Nullable Thread mainThread; - public AnrProfilingIntegration() { - this(AndroidMonotonicTicker.getInstance()); - } - - @TestOnly - AnrProfilingIntegration(final @NotNull MonotonicTicker ticker) { - this.ticker = ticker; + /** + * Installs the ticker to measure main-thread stalls with. Also resets the last-execution reading, + * so a stall is never measured against a tick from a different ticker. + * + *

Package-private so a test can install a fake ticker after {@link #register}, which {@code + * SentryAndroidOptions} cannot supply. + */ + void installTickerFrom(final @NotNull MonotonicTickerProvider provider) { + this.ticker = provider.getMonotonicTicker(); this.lastMainThreadExecutionNanos = ticker.tickNanos(); - this.updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); } @Override @@ -84,6 +89,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); + installTickerFrom(options); this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { 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 e122b6e292c..94f02863e91 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 @@ -174,8 +174,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } // Drive the state machine synchronously to avoid racing the background polling thread. ticker.advance(900, MILLISECONDS) @@ -211,8 +212,9 @@ class AnrProfilingIntegrationTest { debugManager = { debuggerConnected } } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } ticker.advance(19_000, MILLISECONDS) integration.checkMainThread(mainThread) @@ -237,8 +239,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 1.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } integration.onBackground() ticker.advance(19_000, MILLISECONDS) @@ -303,8 +306,9 @@ class AnrProfilingIntegrationTest { anrProfilingSampleRate = 0.0 } - val integration = AnrProfilingIntegration(ticker) + val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) + integration.installTickerFrom { ticker } integration.onForeground() // Transition to suspicious diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index bb869c6a1f9..8781c352090 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3759,7 +3759,7 @@ public final class io/sentry/SentryOpenTelemetryMode : java/lang/Enum { public static fun values ()[Lio/sentry/SentryOpenTelemetryMode; } -public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { +public class io/sentry/SentryOptions : io/sentry/time/MonotonicTickerProvider, io/sentry/transport/RateLimiterConfig { public static final field DEFAULT_PROPAGATION_TARGETS Ljava/lang/String; public static final field MAX_EVENT_SIZE_BYTES J protected final field lock Lio/sentry/util/AutoClosableReentrantLock; @@ -7748,6 +7748,10 @@ public abstract interface class io/sentry/time/MonotonicTicker { public abstract fun tickNanos ()J } +public abstract interface class io/sentry/time/MonotonicTickerProvider { + public abstract fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; +} + public final class io/sentry/time/Stopwatch { public fun elapsed (Ljava/util/concurrent/TimeUnit;)J public fun elapsedNanos ()J diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 4a056c7609c..da33d82d7bc 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -24,6 +24,7 @@ import io.sentry.time.EpochClock; import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; +import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; @@ -62,7 +63,7 @@ // public API. @SuppressWarnings("this-escape") @Open -public class SentryOptions implements RateLimiterConfig { +public class SentryOptions implements RateLimiterConfig, MonotonicTickerProvider { @ApiStatus.Internal public static final @NotNull String DEFAULT_PROPAGATION_TARGETS = ".*"; @@ -3161,6 +3162,7 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { * which this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal + @Override public @NotNull MonotonicTicker getMonotonicTicker() { return JavaMonotonicTicker.getInstance(); } diff --git a/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java b/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java new file mode 100644 index 00000000000..e7cff5d3830 --- /dev/null +++ b/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java @@ -0,0 +1,18 @@ +package io.sentry.time; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Supplies the {@link MonotonicTicker} to measure elapsed time with. + * + *

{@code SentryOptions} implements this, and the Android module overrides it with a ticker the + * core module cannot reference. Depending on this interface rather than on the options class keeps + * the concrete ticker out of call sites, and lets a test supply its own without subclassing the + * final {@code SentryAndroidOptions}. + */ +@ApiStatus.Internal +public interface MonotonicTickerProvider { + @NotNull + MonotonicTicker getMonotonicTicker(); +} From 0e691e868e2d170207286531bc7506719e88104f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 10 Sep 2026 10:32:29 +0200 Subject: [PATCH 6/9] ref(android): Require an installed ticker for ANR profiling (JAVA-579) Drop the JavaMonotonicTicker fallback. On Android nanoTime() stops while the device is suspended, so a default that is never meant to be used would measure main-thread stalls against the wrong clock if it ever were. Start the test ticker at a non-zero origin. A fake that starts at zero makes a reading taken against a different origin indistinguishable from a correct one, which is the mistake this whole change exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/anr/AnrProfilingIntegration.java | 25 +++++++++++++------ .../core/anr/AnrProfilingIntegrationTest.kt | 18 ++++++++++++- 2 files changed, 35 insertions(+), 8 deletions(-) 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 f6ea9599a17..a9dbce72fce 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 @@ -14,7 +14,6 @@ import io.sentry.SentryOptions; import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; -import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.Stopwatch; @@ -48,10 +47,17 @@ public class AnrProfilingIntegration static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); - private volatile @NotNull MonotonicTicker ticker = JavaMonotonicTicker.getInstance(); + + private volatile @Nullable MonotonicTicker ticker; @SuppressWarnings("UnnecessaryLambda") - private final @NotNull Runnable updater = () -> lastMainThreadExecutionNanos = ticker.tickNanos(); + private final @NotNull Runnable updater = + () -> { + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker != null) { + lastMainThreadExecutionNanos = currentTicker.tickNanos(); + } + }; private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); private final @NotNull AutoClosableReentrantLock profileManagerLock = @@ -78,8 +84,9 @@ public class AnrProfilingIntegration * SentryAndroidOptions} cannot supply. */ void installTickerFrom(final @NotNull MonotonicTickerProvider provider) { - this.ticker = provider.getMonotonicTicker(); - this.lastMainThreadExecutionNanos = ticker.tickNanos(); + final @NotNull MonotonicTicker installedTicker = provider.getMonotonicTicker(); + this.ticker = installedTicker; + this.lastMainThreadExecutionNanos = installedTicker.tickNanos(); } @Override @@ -244,8 +251,12 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept return; } + final @Nullable MonotonicTicker currentTicker = ticker; + if (currentTicker == null) { + return; + } final long diff = - TimeUnit.NANOSECONDS.toMillis(ticker.tickNanos() - lastMainThreadExecutionNanos); + TimeUnit.NANOSECONDS.toMillis(currentTicker.tickNanos() - lastMainThreadExecutionNanos); if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; @@ -274,7 +285,7 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept && (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { - final @NotNull Stopwatch stopwatch = Stopwatch.started(ticker); + final @NotNull Stopwatch stopwatch = Stopwatch.started(currentTicker); final @NotNull AnrStackTrace trace = new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); if (logger.isEnabled(SentryLevel.DEBUG)) { 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 94f02863e91..7881a0d3b31 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 @@ -1,6 +1,7 @@ package io.sentry.android.core.anr import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage @@ -9,6 +10,7 @@ import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -35,7 +37,9 @@ class AnrProfilingIntegrationTest { @BeforeTest fun setup() { - ticker = TestMonotonicTicker() + // A tick origin far from zero, as on any device that has been up a while. Starting at zero + // would let a reading taken against a different origin pass unnoticed. + ticker = TestMonotonicTicker(HOURS.toNanos(30)) mockScopes = mock() mockLogger = mock() options = @@ -252,6 +256,18 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) } + @Test + fun `does not report a stall when the ticker origin is far from zero`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.installTickerFrom { ticker } + + integration.checkMainThread(Thread.currentThread()) + + assertThat(integration.state).isEqualTo(AnrProfilingIntegration.MainThreadState.IDLE) + assertThat(integration.profileManager.load().stacks).isEmpty() + } + @Test fun `does not register when options is not SentryAndroidOptions`() { val plainOptions = From aea76c9a20ef24e3951ef5eb3fb5b243560e14da Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 2 Nov 2026 14:16:16 +0100 Subject: [PATCH 7/9] ref(android): Pass the ANR profiling ticker directly (JAVA-579) MonotonicTickerProvider only existed so a test could hand AnrProfilingIntegration a fake ticker. A package-private method taking the ticker does the same without a new core interface or a change to the SentryOptions supertype. Also rename the ANRWatchDog test that claimed to cover a device suspend: the Android ticker counts suspend, so the test only shows the threshold is measured on the injected ticker rather than wall time. Co-Authored-By: Claude Opus 5.5 --- .../io/sentry/android/core/ANRWatchDog.java | 1 - .../core/anr/AnrProfilingIntegration.java | 10 ++++------ .../io/sentry/android/core/ANRWatchDogTest.kt | 7 ++----- .../core/anr/AnrProfilingIntegrationTest.kt | 10 +++++----- sentry/api/sentry.api | 6 +----- .../src/main/java/io/sentry/SentryOptions.java | 4 +--- .../sentry/time/MonotonicTickerProvider.java | 18 ------------------ 7 files changed, 13 insertions(+), 43 deletions(-) delete mode 100644 sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java 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 f7f8d2de013..6f8fedcecea 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 @@ -68,7 +68,6 @@ final class ANRWatchDog extends Thread { @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; - /** Reads the timeout, the debug behavior, the logger and the ticker off {@code options}. */ static @NotNull ANRWatchDog create( final @NotNull SentryAndroidOptions options, final @NotNull ANRListener listener, 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 a9dbce72fce..d61b768475f 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 @@ -15,7 +15,6 @@ import io.sentry.android.core.AppState; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.time.MonotonicTicker; -import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.Stopwatch; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -83,10 +82,9 @@ public class AnrProfilingIntegration *

Package-private so a test can install a fake ticker after {@link #register}, which {@code * SentryAndroidOptions} cannot supply. */ - void installTickerFrom(final @NotNull MonotonicTickerProvider provider) { - final @NotNull MonotonicTicker installedTicker = provider.getMonotonicTicker(); - this.ticker = installedTicker; - this.lastMainThreadExecutionNanos = installedTicker.tickNanos(); + void installTicker(final @NotNull MonotonicTicker ticker) { + this.ticker = ticker; + this.lastMainThreadExecutionNanos = ticker.tickNanos(); } @Override @@ -96,7 +94,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); - installTickerFrom(options); + installTicker(options.getMonotonicTicker()); this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { 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 b9a255c886d..71f477b24a1 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 @@ -230,10 +230,7 @@ class ANRWatchDogTest { } @Test - fun `a device suspend does not trip the ANR threshold`() { - // The uptime clock stands still while the device is suspended, so a suspend cannot be mistaken - // for a blocked main thread. On an elapsed-real-time clock the suspended interval would count - // against the main thread and fabricate an ANR. + fun `measures the ANR threshold on the ticker, not on wall time`() { var anr: ApplicationNotResponding? = null val handler = mock() val latch = CountDownLatch(1) @@ -258,7 +255,7 @@ class ANRWatchDogTest { es.submit { sut.run() } assertTrue(latch.await(10L, TimeUnit.SECONDS)) // wait until the watchdog is polling - Thread.sleep(200) // hundreds of polls of wall time, none of it uptime + Thread.sleep(200) // hundreds of polls of wall time while the ticker stands still assertNull(anr) } finally { 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 7881a0d3b31..7747a8a2a1c 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 @@ -180,7 +180,7 @@ class AnrProfilingIntegrationTest { val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) - integration.installTickerFrom { ticker } + integration.installTicker(ticker) // Drive the state machine synchronously to avoid racing the background polling thread. ticker.advance(900, MILLISECONDS) @@ -218,7 +218,7 @@ class AnrProfilingIntegrationTest { val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) - integration.installTickerFrom { ticker } + integration.installTicker(ticker) ticker.advance(19_000, MILLISECONDS) integration.checkMainThread(mainThread) @@ -245,7 +245,7 @@ class AnrProfilingIntegrationTest { val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) - integration.installTickerFrom { ticker } + integration.installTicker(ticker) integration.onBackground() ticker.advance(19_000, MILLISECONDS) @@ -260,7 +260,7 @@ class AnrProfilingIntegrationTest { fun `does not report a stall when the ticker origin is far from zero`() { val integration = AnrProfilingIntegration() integration.register(mockScopes, options) - integration.installTickerFrom { ticker } + integration.installTicker(ticker) integration.checkMainThread(Thread.currentThread()) @@ -324,7 +324,7 @@ class AnrProfilingIntegrationTest { val integration = AnrProfilingIntegration() integration.register(mockScopes, androidOptions) - integration.installTickerFrom { ticker } + integration.installTicker(ticker) integration.onForeground() // Transition to suspicious diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8781c352090..bb869c6a1f9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3759,7 +3759,7 @@ public final class io/sentry/SentryOpenTelemetryMode : java/lang/Enum { public static fun values ()[Lio/sentry/SentryOpenTelemetryMode; } -public class io/sentry/SentryOptions : io/sentry/time/MonotonicTickerProvider, io/sentry/transport/RateLimiterConfig { +public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public static final field DEFAULT_PROPAGATION_TARGETS Ljava/lang/String; public static final field MAX_EVENT_SIZE_BYTES J protected final field lock Lio/sentry/util/AutoClosableReentrantLock; @@ -7748,10 +7748,6 @@ public abstract interface class io/sentry/time/MonotonicTicker { public abstract fun tickNanos ()J } -public abstract interface class io/sentry/time/MonotonicTickerProvider { - public abstract fun getMonotonicTicker ()Lio/sentry/time/MonotonicTicker; -} - public final class io/sentry/time/Stopwatch { public fun elapsed (Ljava/util/concurrent/TimeUnit;)J public fun elapsedNanos ()J diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index da33d82d7bc..4a056c7609c 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -24,7 +24,6 @@ import io.sentry.time.EpochClock; import io.sentry.time.JavaMonotonicTicker; import io.sentry.time.MonotonicTicker; -import io.sentry.time.MonotonicTickerProvider; import io.sentry.time.SystemEpochClock; import io.sentry.transport.ITransport; import io.sentry.transport.ITransportGate; @@ -63,7 +62,7 @@ // public API. @SuppressWarnings("this-escape") @Open -public class SentryOptions implements RateLimiterConfig, MonotonicTickerProvider { +public class SentryOptions implements RateLimiterConfig { @ApiStatus.Internal public static final @NotNull String DEFAULT_PROPAGATION_TARGETS = ".*"; @@ -3162,7 +3161,6 @@ public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { * which this module cannot reference. On the JVM there is no suspend state to account for. */ @ApiStatus.Internal - @Override public @NotNull MonotonicTicker getMonotonicTicker() { return JavaMonotonicTicker.getInstance(); } diff --git a/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java b/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java deleted file mode 100644 index e7cff5d3830..00000000000 --- a/sentry/src/main/java/io/sentry/time/MonotonicTickerProvider.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.sentry.time; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** - * Supplies the {@link MonotonicTicker} to measure elapsed time with. - * - *

{@code SentryOptions} implements this, and the Android module overrides it with a ticker the - * core module cannot reference. Depending on this interface rather than on the options class keeps - * the concrete ticker out of call sites, and lets a test supply its own without subclassing the - * final {@code SentryAndroidOptions}. - */ -@ApiStatus.Internal -public interface MonotonicTickerProvider { - @NotNull - MonotonicTicker getMonotonicTicker(); -} From f05669b65dcf85ec0944c3e40301a80a5a8235aa Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 4 Nov 2026 10:57:48 +0100 Subject: [PATCH 8/9] ref(android): Read the ANR profiling ticker from options (JAVA-579) Tests spy SentryAndroidOptions to supply a TestMonotonicTicker, so the package-private installTicker seam is no longer needed. Co-Authored-By: Claude Opus 5.5 --- .../core/anr/AnrProfilingIntegration.java | 17 ++++----------- .../core/anr/AnrProfilingIntegrationTest.kt | 21 ++++++++++--------- 2 files changed, 15 insertions(+), 23 deletions(-) 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 d61b768475f..fd495491c93 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 @@ -75,18 +75,6 @@ public class AnrProfilingIntegration private volatile @Nullable Handler mainHandler; private volatile @Nullable Thread mainThread; - /** - * Installs the ticker to measure main-thread stalls with. Also resets the last-execution reading, - * so a stall is never measured against a tick from a different ticker. - * - *

Package-private so a test can install a fake ticker after {@link #register}, which {@code - * SentryAndroidOptions} cannot supply. - */ - void installTicker(final @NotNull MonotonicTicker ticker) { - this.ticker = ticker; - this.lastMainThreadExecutionNanos = ticker.tickNanos(); - } - @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = @@ -94,7 +82,10 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); - installTicker(options.getMonotonicTicker()); + final @NotNull MonotonicTicker ticker = options.getMonotonicTicker(); + // A tick's origin is arbitrary, so the default of 0 would read as a stall of unknown length. + this.lastMainThreadExecutionNanos = ticker.tickNanos(); + this.ticker = ticker; this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { 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 7747a8a2a1c..76cdac4d1e6 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 @@ -24,6 +24,8 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class AnrProfilingIntegrationTest { @@ -56,6 +58,10 @@ class AnrProfilingIntegrationTest { AppState.getInstance().resetInstance() } + /** `SentryAndroidOptions` is final and has no ticker setter, so a spy supplies [ticker]. */ + private fun withTestTicker(options: SentryAndroidOptions): SentryAndroidOptions = + spy(options).also { whenever(it.monotonicTicker).thenReturn(ticker) } + @Test fun `onForeground starts monitoring thread`() { val integration = AnrProfilingIntegration() @@ -179,8 +185,7 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) - integration.installTicker(ticker) + integration.register(mockScopes, withTestTicker(androidOptions)) // Drive the state machine synchronously to avoid racing the background polling thread. ticker.advance(900, MILLISECONDS) @@ -217,8 +222,7 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) - integration.installTicker(ticker) + integration.register(mockScopes, withTestTicker(androidOptions)) ticker.advance(19_000, MILLISECONDS) integration.checkMainThread(mainThread) @@ -244,8 +248,7 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) - integration.installTicker(ticker) + integration.register(mockScopes, withTestTicker(androidOptions)) integration.onBackground() ticker.advance(19_000, MILLISECONDS) @@ -259,8 +262,7 @@ class AnrProfilingIntegrationTest { @Test fun `does not report a stall when the ticker origin is far from zero`() { val integration = AnrProfilingIntegration() - integration.register(mockScopes, options) - integration.installTicker(ticker) + integration.register(mockScopes, withTestTicker(options)) integration.checkMainThread(Thread.currentThread()) @@ -323,8 +325,7 @@ class AnrProfilingIntegrationTest { } val integration = AnrProfilingIntegration() - integration.register(mockScopes, androidOptions) - integration.installTicker(ticker) + integration.register(mockScopes, withTestTicker(androidOptions)) integration.onForeground() // Transition to suspicious From 8140b359c91820da51a8582922f89bbc65184600 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 6 Nov 2026 12:55:45 +0100 Subject: [PATCH 9/9] ref(android): Drop the UnnecessaryLambda suppression in ANR profiling (JAVA-579) Error Prone does not run on sentry-android-core, so the suppression had no effect. The lambda field stays: removeCallbacks matches the posted Runnable by identity. Co-Authored-By: Claude Opus 5.5 --- .../java/io/sentry/android/core/anr/AnrProfilingIntegration.java | 1 - 1 file changed, 1 deletion(-) 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 fd495491c93..1a8552c51a8 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 @@ -49,7 +49,6 @@ public class AnrProfilingIntegration private volatile @Nullable MonotonicTicker ticker; - @SuppressWarnings("UnnecessaryLambda") private final @NotNull Runnable updater = () -> { final @Nullable MonotonicTicker currentTicker = ticker;