youndie@kotlin.website:~/src$ cat katcher/README.md

katcher

kotlin/native · htmx · sqlite
Self-hosted crash tracker for Kotlin: native binary, HTMX UI, embedded SQLite, and login delegated to whatever proxy already sits in front. Small enough that nobody has to justify running it.

The dashboard: crash groups for one application, grouped by fingerprint and rendered server-side

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. scp it 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-User and X-Auth-Request-Email from oauth2-proxy, Traefik ForwardAuth or NGINX auth_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 — no runBlocking, 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, an NSException raised 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.