A crash tracker that fits on one box

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

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

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:

X-Auth-Request-User
X-Auth-Request-Email

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

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:

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