Back to all posts
8 min read

Kaunta Is Rust Now. Postgres Did Not Notice.

Kaunta Is Rust Now. Postgres Did Not Notice.

In March I wrote The Bloat Industry about kaunta, the analytics server that counts pageviews on this blog. It was a Go binary. Last week, in The Republic of Slop, I mentioned in one line that I had rewritten it in Rust.

If you are reading this on my blog, the Rust version has already counted you. If your browser sends Do Not Track, it skipped you instead, because the tracker honors that by default.

This is the long version.

There is no new repository and no kaunta-rs brand. The Rust version lived on my own Gitea while I ported it. On October 2 I squashed its history into one commit and pushed it on top of the Go code in the same repository. Every name stays, down to the release assets. So does the database, and that turns out to be the whole story.

Fiber, then Chi

Go did nothing wrong. My position in it was the problem. Every layer under Kaunta belonged to someone else, and when one of them moved, my code moved with it.

The web framework is the clearest example. I started Kaunta on September 2, 2025 and shipped it on November 1, on Fiber v2. Eight days after that I upgraded it to Fiber v3, a breaking major release. In December the dashboard went from Alpine.js to Datastar.

Then Fiber stopped fitting. Fiber runs on fasthttp, its own HTTP stack beside Go’s net/http, and anything written for the standard library needs an adaptor before Fiber can use it. The official Datastar Go SDK is written for net/http. Delaney Gillilan, Datastar’s creator and CEO, told me Datastar would never accept Fiber slop, and after that I saw Fiber for what it is: a hack on top of Go.

Yassine Ait Rahou asked on the Datastar Discord which server to use, and Delaney suggested Chi. In February Yassine moved the backend to Chi under an issue titled Migrate backend from Fiber to Chi to align with Datastar Go SDK. The pull request touched 36 files, and I merged it.

So Kaunta rewrote its backend to fit the SDK of its frontend library. The Go version never got as far as importing that SDK.

There is a plainer reason too. I need to understand the code I run, and that takes a brain that isn’t spread thin. The languages I keep loaded are Ruby, Rust, C and C++. I’m learning Zig, and Odin is next on the list. Go fell out of that set, and I would rather master a few languages than use all of them and mix them up.

The layers are mine now

In Rust, most of what sits under Kaunta is code I wrote or code I help maintain.

The application and setup lifecycles are state machines on state-machines. Login and setup attempts go through a per-peer GCRA limiter from throttle-machines. When the GeoIP download or the realtime feed’s Postgres connection fails, chrono-machines backs off before the retry. The MCP endpoint runs on mcp-host, which puts a breaker-machines circuit breaker around each tool. All of them are public on crates.io, and I am the owner of each one.

The HTTP layer is Rama. I mentioned in the Rust Revolution post that I am its third-largest contributor. Here is what that bought Kaunta.

GeoIP comes from Rama. The Go version pulled in a MaxMind reader library. The Rust version uses Rama’s IpGeoDb, whose MMDB decoder I deduplicated in June. User-agent parsing comes from Rama too.

Datastar ships inside Rama as well. rama-http embeds the Datastar JavaScript bundle and gives handlers typed helpers for reading signals and streaming patches. Kaunta serves that bundle and talks to the browser through those helpers. I am the one who keeps the bundle current, five bumps since December, from RC.7 to 1.0.4. When Kaunta needs something from Datastar, I don’t wait for a framework maintainer to discover that Datastar exists.

The logic was already in Postgres

My friend Greg Molnar hates Rust with a passion. In November he sent Kaunta a pull request that fixed the Docker healthcheck. When the Rust version landed, I told him: “Look who contributed to a Rust project.” His answer: “Fake news. I never contributed to Rust.”

He’s right. Neither of us wrote Kaunta in Go or Rust, and neither did Yassine. I built the Go version while I was learning Go, but Kaunta was always a PostgreSQL application with a proxy in front of it. You could rewrite it in any stack you like in one afternoon, because the logic is in Postgres.

The Go version shipped 26 migrations defining 34 PostgreSQL functions. Password hashing and session validation are SQL functions (verify_password, validate_session), and so are bot detection and partition cleanup. The dashboard queries live there too: get_dashboard_stats, get_timeseries, get_breakdown, get_map_data.

The Rust port embeds every one of those migrations with the same SQL. It writes the same schema_migrations table that golang-migrate uses, so the database has no way to tell which binary is connected.

This is why Kaunta requires PostgreSQL 18 with no escape hatch. Rust took Go’s seat in front of it.

The new features landed there too. Migrations 27 through 34 exist only in the Rust version. They classify traffic sources and channels (kaunta_source, kaunta_channel) and compute an engagement-adjusted bounce rate. They break down custom events and store tracking exclusions. A trigger enforces a delete policy and refuses hard deletes, and a maintenance function folds old daily partitions into monthly ones.

The first of them repairs Go. Migration 5 was edited in place after some installs had already run it, so those databases never got the website.user_id column. Migration 27 adds it where it is missing. Migrations only roll forward now.

Upgrading from Go

The Go binary’s self-updater asks GitHub for the latest release and looks for an asset named kaunta_{os}_{arch}.tar.gz. The Rust releases use that exact name, for the same five platforms the Go version shipped. Run kaunta --self-upgrade on a Go install and you get the Rust binary. The version jumps from 0.53 to 0.121. The rewrite is a breaking change, and in 0.x there is no major version to bump. The next major would be 1.0.0, which promises stability, and declaring a fresh port stable would be slop. So the minor number jumps, and the gap is the warning. The port itself was 0.100. On Gitea the minor number then climbed with almost every feature I added, so the first Rust release on GitHub is 0.121. Semver still orders 0.121 after 0.53, so the Go updater picks it up.

The Go version stopped at 0.53.0 in June. Once the port started I stopped adding features to Go, so I would not build everything twice.

The Rust version reads the config file the Go setup wizard wrote, including the comma-joined trusted_origins string and the [server] port table. It finds the schema at migration 26 and applies the new ones. Connections still require TLS unless you pass sslmode=disable, because Go’s lib/pq defaulted to that and sqlx does not, so the port sets it explicitly.

The Go updater never verified what it downloaded. The Rust updater refuses any release that doesn’t publish a .sha256 file next to the archive.

Some defaults changed on purpose:

  • The client IP comes from the proxy_mode setting. Go trusted X-Forwarded-For from anyone.
  • A failed migration stops startup. Go logged a warning and carried on with a dirty schema.

Retention is new. Event partitions are kept until you set event_retention_days. The Go version had a scheduler that would have pruned them at a hardcoded 90 days. It had unit tests, and nothing ever started it.

What Go never had

Kaunta has an MCP endpoint built into the web server. It is off until you set mcp = true, and while it is off /mcp returns 404. A key minted from the dashboard lets an agent read the traffic its creator can see. Write tools only appear for keys minted from the CLI, which carry operator reach. Every write goes through MCP elicitation: the client has to put a confirmation in front of a human, and the server refuses the write from a client that can’t ask one. Deletes are soft because the database blocks hard ones, and a website that has data waits out a 30-day grace period first, so restore_website can undo a mistake. Hosts that support MCP Apps get the overview, the live feed, the map and the goals as interactive panels.

The dashboard gained acquisition sources and channels, plus comparison against the previous period. Bounce rate now accounts for engagement. A single-page visit with at least ten seconds of focused time or half the page scrolled no longer counts as a bounce, which matters on a blog where people read one article and close the tab. The Map page draws its choropleth as SVG in the browser, from a world map the binary serves. The Go version drew it with Leaflet on tiles fetched from OpenStreetMap’s servers.

The binary takes backups and restores them. Tracking exclusions can be edited while the server runs, and ?kaunta_ignore=true opts a browser out without needing a fixed address. After 90 days, daily event partitions are folded into monthly ones.

Kaunta is also a Ruby gem now. The kaunta gem carries the native binary for your platform, so gem install kaunta gets you the server. New transports come next: Rama already has a gRPC crate, and I wrote its OTLP gRPC exporter in March. QUIC is planned as well.

The part that stays

The first post said I would rather understand a small thing fully than depend on a large thing partially. The Go version depended on Fiber, then on Chi. The Rust version depends mostly on code where I can land the fix myself.

The binary will change again someday. The schema has already outlived one.

🔗Interstellar Communications

No transmissions detected yet.Be the first to establish contact!

• Link to this post from your site• Share your thoughts via webmention• Join the IndieWeb conversation

Related Posts

The Republic of Slop

A country where no citizen has ever felt a constraint. The dev box has 128GB, the model never says "this is too much," and the cloud bills instead of refusing. Then it ships to a 2GB server. On languages as materials, and the 100x receipt that proves nobody measured anything.

AIengineeringslop

The Bloat Industry: 30,000 Lines to Count Pageviews

Vibe coders celebrate 30k LOC Rails blogs and 8-container analytics stacks. I built Kaunta — one Go binary, 15MB — because I needed to count pageviews, not run Kafka. The industry of bloat is real, and it's getting louder.

AIopen-sourceengineering

CrystalMeet

CrystalMeet turned Ruby into Crystal with a pile of regular expressions. It half worked, and the other half lives in Ruby's runtime, where no regex can reach. /r/rails deleted it in 2024. Its scenarios are landing in spinel now.

rubycrystalrust