Late last year I opened the Play Console for a wallpaper app I run as an indie developer. The crash rate was under 0.1%, and the crash list had been quiet for weeks. Yet the user-perceived ANR rate had climbed to 0.62% — past the 0.47% bad-behavior threshold.
Not a single new crash, but the quality metric kept sinking. It took me longer than I would like to admit to understand that asymmetry. ANRs happen through a different mechanism than crashes, and they can only be observed through a different one, too.
This article walks through collecting those ANRs yourself with ApplicationExitInfo, the constraints I use when handing trace triage to an Antigravity agent, and what five weeks of running the pipeline actually changed.
Why ANRs never reach your crash handler
Most homegrown crash reporting is built on Thread.setDefaultUncaughtExceptionHandler. If a JVM exception is thrown, you catch it.
An ANR never passes through that path. When the main thread stops responding, no exception is thrown inside your process. The system decides from the outside that the process is unresponsive, shows the dialog, and usually kills the process. There is simply no moment at which your handler gets called.
| Event | UncaughtExceptionHandler | ApplicationExitInfo (API 30+) |
|---|---|---|
| JVM crash | Caught | Recorded as REASON_CRASH |
| Native crash | Not caught | Recorded as REASON_CRASH_NATIVE |
| ANR | Not caught | Recorded as REASON_ANR, with a trace |
| OOM kill / force stop | Not caught | REASON_LOW_MEMORY / REASON_USER_REQUESTED |
To be fair: Firebase Crashlytics also reports ANRs on API 30 and above, and it uses the same ApplicationExitInfo underneath. If all you want is a rate on a dashboard, that is enough.
I went down the self-collection path for one reason: I wanted the full trace text inside my own pipeline. Not a collapsed stack in an aggregation UI, but the raw trace, as plain text, ready to be fed to an agent. Everything in the second half of this article starts from that.
Collecting last session's ANRs at startup
ApplicationExitInfo is a query-after-the-fact API. You cannot do anything at the moment the ANR happens, but on the next launch you can retrieve the reason and the trace.
What this code solves: at app startup, it queries the previous process's exit history and persists only the ANRs locally.
class ExitInfoCollector(private val context: Context) {
private val prefs =
context.getSharedPreferences("exit_info_collector", Context.MODE_PRIVATE)
// Call from Application.onCreate on Dispatchers.IO.
// Reading the trace on the main thread can itself seed the next ANR.
fun collect() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
val am = context.getSystemService(ActivityManager::class.java)
val lastSeen = prefs.getLong("last_timestamp", 0L)
val reports = am.getHistoricalProcessExitReasons(context.packageName, 0, 16)
reports
.filter { it.timestamp > lastSeen }
.filter { it.reason == ApplicationExitInfo.REASON_ANR }
.forEach { info ->
val trace = runCatching {
info.traceInputStream?.bufferedReader()?.use { r -> r.readText() }
}.getOrNull()
saveReport(
timestamp = info.timestamp,
description = info.description ?: "no description",
trace = trace
)
}
// Remember the newest timestamp across all reasons to avoid reprocessing
reports.maxByOrNull { it.timestamp }?.let {
prefs.edit().putLong("last_timestamp", it.timestamp).apply()
}
}
private fun saveReport(timestamp: Long, description: String, trace: String?) {
val dir = File(context.filesDir, "anr_reports").apply { mkdirs() }
val body = buildString {
appendLine("timestamp: $timestamp")
appendLine("description: $description")
appendLine("---")
append(trace ?: "trace unavailable")
}
File(dir, "anr_$timestamp.txt").writeText(body)
}
}Why it is written this way — three things matter.
The trace can be null. traceInputStream only returns a value for REASON_ANR and REASON_CRASH_NATIVE, and even for ANRs it is sometimes missing depending on device and timing. In my app the retrieval success rate hovered around 84%. Always persist the description even when the trace is gone, so at least the fact of the ANR survives.
Deduplicate by timestamp. getHistoricalProcessExitReasons returns history as-is; without bookkeeping you would process the same reports on every launch. Storing the newest processed timestamp is unglamorous and sufficient.
Keep the collector itself off the main thread. Traces can run to hundreds of kilobytes. Reading one synchronously inside Application.onCreate means the code you wrote to observe ANRs starts manufacturing them.