Traccia
One binary serves the tracking script, the API, the dashboard and the admin panel. Plugins are JavaScript that runs inside the binary: they mutate or drop events before storage and declare panels the server renders. Not one line of that JS reaches the browser.
Traccia plugins are written in JavaScript. Not one line reaches the browser.
Self-hosted web analytics in one Go binary and one Postgres. To extend it you drop a .js in a folder: it runs inside the binary, hooks every event before it is stored and declares panels the dashboard renders on the server. No Node, no fork, no separate process.
An event travels from your page to the dashboard in a single process
Click through the steps or let them play. Each step lights up the piece it describes.
The minimal install: one tag and, if you want, one attribute on the button you care about.
The entire plugin system went in without touching a use case
Real hexagonal has a simple test: the most invasive piece of the product plugs in from the outside. Here it is a decorator on the EventRepository port.
ports.EventRepository and do not know plugins exist. Swapping storage means implementing the interface, wiring it in cmd/api and recompiling; today only the Postgres adapter exists, SQLite and ClickHouse are on the roadmap.This is what a plugin looks like: one file, two hooks. One runs on every event, the other once at startup.
metadata->>'from_currency'. Sample figures.From a currency calculator to a custom panel
A made-up case with small numbers, to see what happens at each stage before getting into the challenges.
/admin, copies the project_id into the script tag and saves the api_key she will only see once. Pageviews start counting on their own.traccia.track("calculator_used", { from_currency: "USD" }). The server stores the IP as 203.0.113.0, never the real one.calculadora.js in the plugins folder and restarts. registerPanel declares "Usage by currency" grouped by from_currency. The dashboard already shows the table: USD 312, VES 187, EUR 41 for the week.traccia.trackError("rate_unavailable"). The same plugin's onEvent sees it before it is stored and does an http.post to the Slack webhook. The event is stored anyway; the alert arrives in seconds.Five times the obvious solution was wrong
Every challenge follows the same pattern: what anyone would try first, why it does not work, what was done and the number that proves it. The technical detail stays folded.
That sentence decides the rest: server-rendered dashboard, declarative panels, vendored and embedded assets, a three-object sandbox. It is simpler than a plugin-server and more approachable than WASM, and it is what separates Traccia from a product you extend with a fork.
One filter: does this break the one-binary-and-one-Postgres story?
If the answer was yes, another way was found. If there was none, it was written down as a limit in the docs.
docker compose up. Traccia started as the first piece of a shared utilities backend and pivoted to a product: API, dashboard, admin and plugins.Go deeper
The script embedded and served at /t.js is part of the same story: a binary that brings its own SDK, like GoatCounter. The same file is the npm package under sdk/js, with no build step on either path. Every tag publishes binaries for linux and darwin, amd64 and arm64, and a multi-arch image on ghcr.io that runs as an unprivileged user.
onEvent and registerPanel, and the sandbox is closed by construction: only what the server puts into the runtime exists.Go deeper
WASM would give stronger isolation and several languages, at the cost of writing a plugin requiring a toolchain. A plugin-server like PostHog's scales better, at the cost of another process. For a product whose argument is "one binary", an embedded interpreter is the middle ground.
kv. Migrations are plain SQL that Postgres applies on first boot; after that the process runs nothing on its own. The backup is a pg_dump -Fc.Go deeper
make backup writes a compressed dump that is portable across Postgres versions; make restore is destructive and says so. There is no automatic schedule: whoever wants one puts it in their cron. The per-IP limit is in memory and single-node; the README declares it as such.
internal/domain imports only time. Everything the 17 use cases need is an interface in internal/ports with a default adapter. MIT favors adoption over protecting the project from being resold as a SaaS, which is what AGPL tries to do.Go deeper
The event model is track(name, metadata) plus identify(traits), the same pattern as GA4, Segment and PostHog. Visitor.Name is a dedicated field and not just another property, so the owner's traffic can be excluded with exclude_named. Ports and adapters table in the README.
Five decisions I would make the other way today
Recap
pg_dump..js in a folder: it hooks every event before it is stored and declares panels. It runs inside the binary with a 100 ms budget, and a broken plugin never loses an event.time and the entire plugin system is a decorator on one port. The use cases do not know it exists.