
Why it exists
Every crash reporting service wanted one of three things: my data, a JVM with a gigabyte of heap, or a Kubernetes cluster. For a side project — or an internal tool at a company that would rather not ship stack traces to a third party — none of those is a good trade.
What you get
- One native binary, 30–50 MB resident, starting in milliseconds.
scpit to a box and run it. - Automatic grouping by fingerprint (message + stack trace), with a dashboard rendered server-side through HTMX — no bundler, no Node in production. Dark and light theme, responsive.
- Embedded SQLite through sqlx4k. No database to operate.
- No login screen on purpose. It trusts
X-Auth-Request-UserandX-Auth-Request-Emailfrom oauth2-proxy, Traefik ForwardAuth or NGINXauth_request, and answers 401 without them. That buys Keycloak, Google or GitHub SSO without a line of OAuth code. - Symbolication for Android: the Gradle plugin uploads the R8 mapping file after a build, and the server applies it to incoming traces.
Install
Server:
docker run -p 8080:8080 -v ./data:/data ghcr.io/youndie/katcher:latest
A Helm chart ships in the repository under charts/katcher — it wires the ingress, the forward-auth middleware and, when MCP is enabled, the bypass for it.
Client, in the app you want to hear about:
repositories { maven("https://reposilite.kotlin.website/snapshots") }
dependencies {
implementation("io.github.youndie.katcher:client:$katcher_version")
implementation("io.ktor:ktor-client-cio:$ktor_version") // any Ktor engine
}
Katcher.start {
remoteHost = "https://katcher.example.com"
appKey = "<YOUR_APP_KEY>"
release = "1.0.0"
environment = "Production"
}
The client
One KMP library, published for JVM, linuxX64, linuxArm64, both macOS targets, all three iOS targets and mingwX64 — the same set in every release, whatever machine cut it. Android has its own coordinate, plus a Gradle plugin that generates the BuildConfig fields and uploads the mapping file. The client brings no HTTP engine of its own: pick the one your platform has (ktor-client-cio on JVM and Linux, ktor-client-darwin on Apple targets).
Katcher.start {} is called from common code. It installs the platform crash hook — Thread.setDefaultUncaughtExceptionHandler on the JVM, setUnhandledExceptionHook on Apple targets — and chains to whatever hook was there before, so an existing handler still runs and the process still terminates the way it did.
Katcher.catch(e)reports a caught exception, fire-and-forget — norunBlocking, no coroutine scope at the call site.Katcher.addBreadcrumb(...)keeps the last 50 user actions in memory and attaches them to any report.- A report is written to disk before it is sent, so a crash at launch — before the upload coroutine ever runs — is delivered on the next launch instead of being lost.
One detail worth stealing whatever you use: an atomic guard around the handler, so a crash inside the reporter cannot re-enter it. Without one, a bad day becomes an infinite one.
Crashes for coding agents
The server can expose crashes over the Model Context Protocol: point an agent at a repository, ask it to look into a crash, and it reads the group, its events, breadcrumbs and context, then records the pull request that fixes it. Off by default — without MCP_TOKEN the endpoint is not mounted at all: no route, no secret, nothing to reach.
Crash text is written by whoever holds an app key, and app keys ship inside client applications — so the server treats it as hostile. Content that reads as an instruction to an agent is held back, and reading a crash is split in two: structured, identifier-shaped metadata first, the free text only after the agent reports which stack frames it could locate in the repository. Neither check makes untrusted text safe — no server-side check can — they narrow the easy path.
Limitations
- The hook sees Kotlin exceptions that reach the runtime uncaught. A
SIGSEGV, anNSExceptionraised in Swift or Objective-C, a watchdog termination — all invisible to it, and still need the platform's own crash reports. - iOS stack traces arrive the way Kotlin/Native prints them; there is no dSYM step. Android R8 mappings are the only symbolication the server does.
- On the JVM the upload runs on daemon threads: a crash on the last non-daemon thread can end the process before the send fires. The report waits on disk — in a container, that directory needs a persistent volume, or it will not survive the restart it is waiting through.
- One process, one SQLite file. There is no clustering story, and none planned.