◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-07-03Advanced

The Failure That Never Shows Up in Your Crash List — Collecting ANR Traces with ApplicationExitInfo and Handing Triage to an Agent

ANRs never reach your crash handler. Here is how I collect them at startup with ApplicationExitInfo and hand triage to an Antigravity agent, with five weeks of real numbers.

Antigravity377Android27ANRApplicationExitInfoPlay Console

✦ Premium Article

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.

EventUncaughtExceptionHandlerApplicationExitInfo (API 30+)
JVM crashCaughtRecorded as REASON_CRASH
Native crashNot caughtRecorded as REASON_CRASH_NATIVE
ANRNot caughtRecorded as REASON_ANR, with a trace
OOM kill / force stopNot caughtREASON_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.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A startup-time ApplicationExitInfo collector for ANR traces, with timestamp deduplication and a fallback for missing traces
✦Three ANR patterns that AI-generated code tends to introduce, with Before/After fixes that move work off the main thread
✦Constraint design for handing traces to an agent (verbatim quoting, max three candidates, human-reviewed fixes) and five weeks of measured results
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-05-25
One Month Splitting Antigravity's Inline Edit and Agent Mode Across Four Wallpaper Apps
A month of notes from running Antigravity's Inline Edit and Agent Mode across four production wallpaper apps — with real counts, the decision rule I wrote into AGENTS.md, a one-pass dSYM fix, and how credit cost factors in.
▣ App Dev2026-09-29
Purging Cloudflare Didn't Bring Back the New Wallpapers: the Stale JPEGs Were Held by XPageSpeed on the Origin
An operations record from a wallpaper app: images overwritten at the same URL kept arriving stale even after a Cloudflare purge. Covers the three-way local/origin/edge probe that names the guilty layer, when prefix purge beats per-URL purge, and how to switch off Xserver's XPageSpeed in .htaccess without a 500.
▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links