Repository navigation
ref(android): Measure ANR thresholds on the monotonic clock (JAVA-579) - #6041
runningcode wants to merge 8 commits into
Conversation
📲 Install BuildsAndroid
|
edd22f0 to
fef63c7
Compare
cabb812 to
77eb8c9
Compare
fef63c7 to
9829cc8
Compare
77eb8c9 to
2065803
Compare
9829cc8 to
c11fec4
Compare
2065803 to
498932f
Compare
c11fec4 to
0d1d4c9
Compare
498932f to
ae9d046
Compare
0d1d4c9 to
6861150
Compare
ae9d046 to
46f3c83
Compare
6861150 to
b35959c
Compare
46f3c83 to
2c9adb8
Compare
b35959c to
1eb13ab
Compare
2c9adb8 to
d570570
Compare
1eb13ab to
5b8105e
Compare
d570570 to
51e0083
Compare
5b8105e to
67379d3
Compare
51e0083 to
a2620aa
Compare
97ad063 to
76d5e07
Compare
| private final boolean reportInDebug; | ||
| private final ANRListener anrListener; | ||
| private final MainLooperHandler uiHandler; | ||
| private final ICurrentDateProvider timeProvider; |
There was a problem hiding this comment.
This returns EITHER a monotonic clock or a wall clock depending on the platform. This is a bad abstraction.
On android it gives you a monotonic clock which is what we want for this class, but the fact that it has Date in the name but gives you a monotonic clock is quite confusing.
50973fd to
fd94017
Compare
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.
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.
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.
…A-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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
fd94017 to
88946b3
Compare
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 <noreply@anthropic.com>
| * <p>Package-private so a test can install a fake ticker after {@link #register}, which {@code | ||
| * SentryAndroidOptions} cannot supply. | ||
| */ | ||
| void installTicker(final @NotNull MonotonicTicker ticker) { |
There was a problem hiding this comment.
This is probably the only weird design about this PR. Open to other ways of doing this!
There was a problem hiding this comment.
I see two other options, none of them is great:
- Open up the
SentryOptionswith asetMonotonicTicker - Static mocks within the tests
val testTicker = TestMonotonicTicker(0)
fun withMockedTicker(closure: () -> Unit) {
Mockito.mockStatic(AndroidMonotonicTicker::class.java).use { mock ->
mock
.`when`<Any> { AndroidMonotonicTicker.getInstance() }
.thenReturn(testTicker)
closure()
}
}
(In the long run replacing the generic SentryOptions with a custom interface , e.g. AnrProfilingIntegrationOptions in this case feels like the right solution).
There was a problem hiding this comment.
I agree that having a custom interface is the way to go, but I don't want to blow up the scope of this PR.
For now I'll use static mocks in tests since that avoids adding an extra API.
Co-Authored-By: Nelson Osacky <nelson.osacky@sentry.io>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4a11cb6. Configure here.
| return; | ||
| } | ||
| final long diff = | ||
| TimeUnit.NANOSECONDS.toMillis(currentTicker.tickNanos() - lastMainThreadExecutionNanos); |
There was a problem hiding this comment.
Sleep inflates ANR profiling stalls
Medium Severity
Measuring the stall with elapsedRealtime counts time the device spent asleep. ProcessLifecycleOwner keeps the app started for 700ms of uptime after the screen turns off, so a suspend in that window makes checkMainThread treat the sleep as a main-thread stall and persist a false ANR profile.
Reviewed by Cursor Bugbot for commit 4a11cb6. Configure here.
There was a problem hiding this comment.
That's fine for now, we only ever report the profile if there's an ANR available via ApplicationExitInfo APIs.


📜 Description
Moves the ANR integrations to use the
MonotonicTickerabstraction. This also makes them more testable since we aren't hard coding a direct access toSystemClock.A note: We previously used
SystemClock.uptimeMilliswhich does not include time spent in sleep. The implementation ofMonotonicTickeron Android usesSystemClock.elapsedRealtimeNanos().ANRWatchDogpolls in the background too, so right after the device wakes up the deadline can already have passed. That doesn't produce a false ANR: the watchdog only reports when ActivityManager also lists the process as NOT_RESPONDING. The cost is one extragetProcessesInErrorState()call after a wake-up.AnrProfilingIntegrationonly polls in the foreground and resets its reading on foreground.Also
ANRWatchdogwas previously using aICurrentDateProvider. Which has two different implementations, a wall clock and a monotonic clock. It happens to work by chance on Android because on Android it should always return a monotonic clock but this is quite brittle.💡 Motivation and Context
Fixing clocks and making the code more sane.
💚 How did you test it?
./gradlew :sentry-android-core:testReleaseUnitTest :sentry:checkKotlinAbi— green.📝 Checklist
sendDefaultPIIis enabled.