Skip to content

fix: [SDK-4822] make ANR watchdog app-state aware to stop false background ANRs#2676

Open
abdulraqeeb33 wants to merge 5 commits into
mainfrom
ar/sdk-4822
Open

fix: [SDK-4822] make ANR watchdog app-state aware to stop false background ANRs#2676
abdulraqeeb33 wants to merge 5 commits into
mainfrom
ar/sdk-4822

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

The Otel ANR watchdog (OtelAnrDetector) monitored the main thread continuously with no awareness of foreground/background, so any backgrounded main-thread block was reported as a crash-class ANR. A pull of ~25k recent ANRs showed ~87.5% are backgrounded main-thread, and the long-duration reports (median block ~6 min, up to ~25h) are impossible as real hangs — they are freeze/suspension artifacts. The net effect was an ANR metric inflated well beyond what Android actually counts (user-perceived ANRs are foreground input-dispatch timeouts).

Approach

Detection is now app-state aware, and background blocks are downgraded but not discarded:

  • Foreground block > 5s → crash-class ANR (unchanged).
  • Background block > 10s → recorded via a distinct BackgroundMainThreadBlockException on the same retained, disk-buffered crash path, so it stays out of the ANR/crash metric but remains queryable. app.state + SDK version are auto-attached, and the exception message carries a compact fingerprint (top frame + first OneSignal frame) for triage. This preserves visibility into real background regressions (broadcast onReceive, JobScheduler, main-thread SQLite, lock/binder waits) instead of dropping them.
  • Frozen-process guard: if the watchdog thread's own sleep overran the check interval beyond a 2s slack, the process was descheduled (Doze / cached) rather than the main thread being stuck → the measurement is suppressed.
  • Monotonic clock (SystemClock.uptimeMillis) instead of wall-clock, so NTP / DST / manual clock changes and deep sleep can't fabricate block time.
  • Independent foreground/background dedup (see review thread): a stream of background warnings can never suppress a real foreground ANR.

Foreground state is derived from the existing platformProvider.appState; "unknown" is treated as foreground so a genuine ANR is never silently downgraded.

Testability & coverage

  • Timing / classification / dedup extracted into a pure, Android-free AnrCheckEvaluator (100% JVM-covered).
  • Android touch points (main-thread Handler, stack capture, monotonic clock) abstracted behind an injectable AnrWatchdogPlatform seam so the whole detector runs under plain-JVM tests instead of Robolectric — which is what lets JaCoCo actually measure it. The shell went from 0% → ~73%, and aggregate diff coverage is ~84% (was 0%, which was failing the gate).

Test plan

  • ./gradlew :OneSignal:core:testDebugUnitTest — all pass
  • Diff coverage gate passes (~84% aggregate on touched lines)
  • Evaluator matrix: fg ANR, responsive, bg-warning threshold, frozen-process precedence + slack boundary, independent fg/bg dedup, post-boot dedup sentinel, fingerprint
  • Shell wiring: reportAnr (OneSignal-at-fault / not), background-warning path, start/stop lifecycle, unknown-state → foreground

Closes SDK-4822.

Made with Cursor

AR Abdul Azeez and others added 3 commits June 30, 2026 01:03
…round ANRs

The ANR watchdog monitored the main thread continuously with no foreground/
background awareness, so backgrounded main-thread blocks were reported as ANRs.
Android only raises real ANRs in the foreground, and background wall-clock
measurement is unreliable under Doze/cached-process freezing, so these were
false positives that inflated the ANR metric.

Detection is now classified by app state:
- Foreground block > 5s: real ANR (unchanged crash-class report).
- Background block > 10s: warning-class log, kept out of the ANR metric.
- Watchdog thread oversleeping beyond the interval: treated as a frozen
  process and suppressed.

Foreground state is derived from the existing platformProvider.appState, and
the decision is extracted into a pure classifyBlock() for deterministic tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
The watchdog measured all durations (lastResponseTime heartbeat, sleep timing,
unresponsiveness, and the report dedup window) with wall-clock
System.currentTimeMillis(). A clock adjustment mid-session (NTP sync, manual
time change, DST) would skew those deltas — a forward jump fabricates a block
and fires a false ANR; a backward jump corrupts the dedup window.

Switch the interval math to the monotonic SystemClock.uptimeMillis(), which is
immune to clock changes, matches the clock the main Looper schedules with, and
pauses during deep sleep so a dozing device accumulates no phantom block time.
Also reset the baseline in start() so a gap before start() can't read as a block.

Co-authored-by: Cursor <cursoragent@cursor.com>
…NR dedup

Add an injectable monotonic clock and split the per-iteration evaluation
(recordHeartbeat + evaluateCheck) out of the thread/sleep loop, so the full
heartbeat -> clock -> classify -> report path is exercised deterministically
with a fake clock — no background thread, real sleep, or shadow-clock racing.

Writing those tests surfaced a real bug: the report dedup compared
now - lastAnrReportTime against a 0 sentinel, so within the first 30s after
device boot (small uptimeMillis) the first genuine ANR would be suppressed.
Treat the sentinel explicitly as "never reported" so the first block always
reports regardless of how small the clock is.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • AnrCheckEvaluator.kt: 58/58 touched executable lines (100.0%) (182 touched lines in diff)
  • OtelAnrDetector.kt: 61/77 touched executable lines (79.2%) (187 touched lines in diff)
    • 16 uncovered touched lines in this file
  • OtelCrashReporter.kt: 9/9 touched executable lines (100.0%) (24 touched lines in diff)

Overall (aggregate gate)

128/144 touched executable lines covered (88.9% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • OtelAnrDetector.kt: 79.2% (16 uncovered touched lines)

📥 View workflow run

@abdulraqeeb33 abdulraqeeb33 requested review from a team and nan-li June 30, 2026 17:44
@fadi-george

Copy link
Copy Markdown
Contributor

One concern: lastAnrReportTime is shared between background warnings and foreground ANRs. If a background block is recorded, then the app foregrounds while the main thread is still blocked, the foreground crash-class ANR can be suppressed for 30s. That seems to conflict with the intent to keep foreground ANRs reported normally.
Also worth validating the app-state rule: Android can still count some non-foreground work as ANR-relevant, such as broadcasts or foreground services. If OneSignal can block the main thread in those paths, downgrading solely on appState == "background" may underreport real ANRs.

…lock stream

Extract the watchdog's timing/classification/dedup into a pure, Android-free
AnrCheckEvaluator and abstract the Android touch points (main-thread Handler,
stack capture, monotonic clock) behind an injectable AnrWatchdogPlatform seam.
This lets the whole detector run under plain-JVM tests instead of Robolectric,
so JaCoCo actually measures it (the shell went from 0% to ~73%, evaluator 100%,
aggregate diff coverage ~84%).

Behavior changes:
- Foreground blocks past the ANR threshold stay crash-class ANRs; background
  blocks past the (higher) background threshold are recorded under a distinct
  BackgroundMainThreadBlockException via the retained, disk-buffered crash path,
  so they stay out of the ANR metric but remain queryable (app.state + SDK
  version are auto-attached; the message carries a top-frame/OneSignal-frame
  fingerprint for triage).
- Foreground and background reports now dedup independently, so a stream of
  background warnings can never suppress a real foreground ANR.
- Frozen-process guard and monotonic clock retained; post-boot dedup sentinel
  keeps the first report from being swallowed near t=0.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Thanks Fadi — both good catches, and I reworked the PR around them.

1. Shared dedup → fixed. Foreground and background now keep independent dedup timestamps (lastForegroundReportTime / lastBackgroundReportTime in the new AnrCheckEvaluator), so a background warning can never suppress a foreground ANR. If the app foregrounds while the main thread is still blocked, the foreground ANR reports on the next check regardless of any recent background warning. Covered by a dedicated test: "foreground and background dedup are independent — a bg warning never suppresses a fg ANR".

2. Non-foreground ANR-relevant work → we no longer drop it. You're right that Android can count some non-foreground work (broadcast onReceive, foreground services, JobScheduler) and that appState == "background" is coarser than that. The important change is that background blocks are no longer discarded — they're routed to a retained, queryable stream as a distinct BackgroundMainThreadBlockException (same disk-buffered crash path, with app.state + SDK version auto-attached and a top-frame / first-OneSignal-frame fingerprint in the message). So we keep full visibility into genuine background main-thread blocks; they're just categorized out of the user-perceived ANR metric rather than lost. The data pull supports this: the plausible 5–60s background window does contain real OneSignal main-thread blocks, and this keeps them observable and groupable. We only have foreground/background/unknown granularity from platformProvider.appState today (unknown → treated as foreground so real ANRs are never downgraded); if the retained stream shows a specific broadcast/FGS path is user-impacting, we can promote those to ANR-class in a follow-up instead of guessing now.

Also worth noting: this pass makes the detector JVM-testable (pure AnrCheckEvaluator + an injectable AnrWatchdogPlatform seam), which is what finally gives the watchdog real coverage (0% → ~84% aggregate on the diff) instead of the Robolectric 0%.

@fadi-george

Copy link
Copy Markdown
Contributor

Thanks for the update. The independent foreground/background dedupe addresses my main concern.

One follow-up: reportBackgroundBlock now uses crashReporter.saveCrash, but OtelCrashReporter.saveCrash hardcodes Severity.FATAL. Since the PR describes these as retained background warnings that stay out of ANR/crash metrics, can we confirm the backend segments BackgroundMainThreadBlockException independently and ignores the fatal severity for crash/ANR metrics? If not, this may need an explicit non-fatal/warning reporter path.

I’d also still want product/Android validation on the app-state rule. The new background stream preserves visibility, but the SDK still downgrades solely on appState == "background", while some background entry points like broadcasts/foreground services can still be ANR-relevant.

Background blocks were routed through crashReporter.saveCrash, which hardcodes
Severity.FATAL — so on the wire a backgrounded block was byte-for-byte a fatal
crash, distinguished only by exception.type. Keeping it out of the ANR/crash
metric relied entirely on the backend special-casing that classname, an
unverified and fragile contract.

Add an explicit non-fatal path to the crash reporter and route background
blocks through it:
- IOtelCrashReporter.saveNonFatal emits on the same retained, disk-buffered
  crash telemetry but at Severity.WARN.
- Records now carry an SDK-owned ossdk.crash.fatal attribute (true for
  crashes/foreground ANRs, false for background blocks), so the backend can
  segment on a stable, intentional signal instead of severity or exception.type
  alone.
- OtelAnrDetector.reportBackgroundBlock now calls saveNonFatal; reportAnr and
  the uncaught-exception handler stay on saveCrash (FATAL).

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Good catch — you were right, the FATAL severity wasn't enforced anywhere. Fixed in 08cf970.

Background blocks now go through a new saveNonFatal path: emitted at WARN with an SDK-owned ossdk.crash.fatal=false attribute. Real crashes and foreground ANRs stay on saveCrash (FATAL, flag true). So the backend can exclude on severity or that flag, not just the exception classname.

One thing I confirmed while in there: crash reports and normal remote logs go through the same network exporter — there's no separate "crash" channel, so the metric is a query over severity/attributes. WARN + the flag should drop these out cleanly, but if you can confirm the metric keys on one of those I'll call it closed.

Still want your/product read on the app-state rule. We use a flat 5s foreground / 10s background threshold, not Android's per-context timeouts (broadcast 10s, service 20/200s) — so a backgrounded broadcast/FGS main-thread hang that's technically ANR-relevant would land as a non-fatal background block. Upside: it's retained and queryable, so we can promote specific paths to ANR-class once the data shows real user impact instead of guessing now. OK to ship that as the default?

@fadi-george

Copy link
Copy Markdown
Contributor

One API-compat concern: IOtelCrashReporter is public in the published :OneSignal:otel artifact, and adding saveNonFatal(...) is a breaking interface change for any external implementation.
If this module/API is treated as internal-only, that may be fine. Otherwise, can we avoid changing the public interface, or confirm this break is acceptable for the release?

@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

One API-compat concern: IOtelCrashReporter is public in the published :OneSignal:otel artifact, and adding saveNonFatal(...) is a breaking interface change for any external implementation. If this module/API is treated as internal-only, that may be fine. Otherwise, can we avoid changing the public interface, or confirm this break is acceptable for the release?

Yeah this is internal only and currently used only by Android SDK.

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, just calling out that (currently) making background ANRs WARN-level effectively means we're not going to capture them in telemetry to the same extent anymore, because we have so few apps on log level WARN. It may still be enough to actually get usable metrics. And if we were to bump the loglevel to WARN for more apps, the volume would be too great. These would be meaningfully captured if we do something different with our logging in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants