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.

Antigravity305Android24ANRApplicationExitInfoPlay 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 $10 for lifetime access
View Membership →

Related Articles

App Dev2026-06-26
Hand Over Generation and Shipping, but Never the Signing Key — Designing Key Custody and Handover for an AI-Driven Pipeline
Even in an era where AI Studio and Antigravity take over everything from generation to internal-test shipping, the app signing key is in a class of its own. Lose it, and that app can never be updated again. As a solo developer who has run several apps for years, here is how I design key custody — separating the upload key from the app signing key, storing it, and planning the handover for the worst case.
App Dev2026-06-26
Green in the Embedded Emulator, Broken on the First Real Device — Putting a Parity Gate on AI-Generated Compose Apps
AI Studio now generates Kotlin/Compose apps from a prompt, runs them in an embedded emulator, and pushes them to a real device over USB — all from one screen. Yet a screen that passed in the emulator can break the first time it lands on a real phone. As a solo developer running several apps, here is how I put a gate that catches device parity issues before they ship.
App Dev2026-06-24
The Day Generation, Device, and Internal-Test Shipping Became One Step — What I Refused to Hand Over
AI Studio now turns a text prompt into a Kotlin/Compose app and carries it through the emulator, a real device, and Google Play's internal test track from one screen. Behind that convenience sits a question: how much of the moment of shipping do you hand to the machine, and what do you keep in your own hands? Here is where I draw the line as a solo developer running several apps, and the implementation that holds that boundary.
📚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
See all →