# katcher

> A crash tracker that fits on one box: a native binary, an HTMX UI, embedded SQLite, and authentication delegated to the proxy already in front.

Page: https://kotlin.website/katcher

![The dashboard: crash groups for one application, grouped by fingerprint and rendered server-side](https://s3.kotlin.website/public/katcher/screenshot.png)

## 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:

```bash
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:

```kotlin
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
}
```

```kotlin
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.
