# A crash tracker that fits on one box

> katcher — Kotlin/Native, Ktor and HTMX, in one binary small enough that nobody has to justify running it.

Page: https://kotlin.website/blog/crash-tracker-kotlin-native · Published: 2025-12-06

Every crash reporting service I looked at 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 are a
good trade.

So [katcher](https://github.com/youndie/katcher) starts from three constraints, and
everything below follows from them.

1. **One binary.** `scp` it to a box, run it, done.
2. **Small enough to ignore.** If it uses less memory than a browser tab, nobody has to
   justify it in a planning meeting.
3. **No frontend build step.** There is no bundler in this project and there will not be
   one.

Kotlin/Native covers the first two. HTMX covers the third.

## Nothing extra

The server is Ktor on the CIO engine, compiled to a native Linux executable. Storage is
SQLite in the same process. The UI is server-rendered through Kotlin's HTML DSL, with
HTMX for the parts that would otherwise need a framework.

Startup is measured in milliseconds and the resident set sits around 30–50 MB — which is
what makes it reasonable as a sidecar or on the cheapest VPS you can rent.

```text
[INFO] (io.ktor.server.Application): Application started in 0.01 seconds
```

For the database I went with **sqlx4k** rather than SQLDelight: it is a Kotlin wrapper
over the Rust `sqlx` driver, reached through cinterop. You get Rust doing the byte
handling and Kotlin doing the shape of the code.

## Type-safe HTML, and HTMX for the rest

Two things make server-rendered HTML pleasant rather than a chore.

The first is wrapping components instead of scattering class strings. A `button` takes a
variant and decides its own Tailwind classes; templates never spell them out. It is the
shadcn idea, expressed as a DSL.

The second is Ktor's `Resources` plugin, so routes are a class hierarchy rather than
strings. A small helper lets a deeply nested path be written on one line with the parent
hierarchy hidden inside.

A paginated table then becomes ordinary code: rows issue an HTMX `GET` with
`pushUrl = "true"` so the back button keeps working, and the pagination controls target
one element with `HxSwap.innerHtml` instead of reloading the page.

## The image

Two stages. The first builds the native binary:

```dockerfile
FROM gradle:9.2.0-jdk21-jammy AS build
RUN gradle :server:linkReleaseExecutableNative --no-daemon --stacktrace
```

The second copies the resulting `server.kexe` into `distroless/cc-debian12`, along with
`libcrypt.so.1` — which Kotlin/Native links against and distroless does not carry. No JDK,
no Gradle, no development packages.

The two stages are a matched pair, and getting it wrong fails at `exec` rather than at
build time, with nothing but "no such file or directory" to explain it. The builder's
glibc has to be no newer than the runtime's.

## Authentication it does not implement

katcher has no login screen, and that is deliberate. It reads two headers:

```text
X-Auth-Request-User
X-Auth-Request-Email
```

If they are missing, it answers 401. The intended shape is

```text
browser → Traefik/NGINX → oauth2-proxy (or any SSO) → katcher
```

which means you get Keycloak, Google or GitHub without a line of OAuth code in the
project. In the Helm chart this shows up as two routes with different treatment: the UI
behind the auth middleware, and `/api/reports` deliberately outside it, because the
services posting crashes hold no browser session and would be rejected before katcher ever
saw them.

## The client

Kotlin Multiplatform, currently JVM:

```kotlin
Katcher.start {
    remoteHost = "https://katcher.example.com"
    appKey = "<YOUR_APP_KEY>"
    release = "1.0.0"
    environment = "Production"
}
```

It installs a global uncaught-exception handler and a `CoroutineExceptionHandler`, and
takes manual reports with a context map.

One detail worth stealing: an atomic guard around the handler. If reporting a crash
crashes, the second crash must not re-enter the reporter — only the first is processed
while the flag is raised. Without it, a bad day becomes an infinite one.

---

Source: [github.com/youndie/katcher](https://github.com/youndie/katcher)
