All projects
Open source · v0.1.1Project 07
AnalyticsSelf-hostedOpen source

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.

GoPostgreSQLHTMXgojaHexagonalMIT

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.

browser
t.js · tracking
5 KB
Served from the binary itself. Unminified, no build.
browser
htmx + uPlot · dashboard
99 KB
Vendored and embedded. No CDN.
server · inside the binary
Plugins .js
0 bytes
Run in goja, a JS interpreter in pure Go. Nothing reaches the client.
100 msbudget per plugin and event
8ports: interfaces with a swappable adapter
151tests, 17 against a real Postgres in CI
01 · How it works

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.

Your site
t.js · 5 KB
Ingest
/api/v1/track
Plugins
onEvent · 100 ms
Postgres
single state
Dashboard
HTMX · panels
Step 1 of 5 · plays on its own
One script tag on your page
The binary serves its own tracking script at /t.js: 5 KB unminified, the same file as the npm package. It counts pageviews on its own, including SPA route changes, and exposes traccia.track(name, metadata).

The minimal install: one tag and, if you want, one attribute on the button you care about.

<script src="https://analytics.your-domain.com/t.js"
data-project="<project_id>" defer></script>
<button data-traccia-event="signup_clicked">Create account</button>
# or from JS · example
traccia.track("calculator_used", { from_currency: "USD" })
02 · Ports and adapters

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.

In · driving adapters
httpapi
REST /api/v1 · serves /t.js
no business logic
dashboard
HTMX + Go templates
embed.FS
admin
accounts, projects, jump to the dashboard
Core
domain + usecase
domain imports only time · 17 use cases
EventRepositoryProjectRepositoryVisitorRepositoryAdminUserRepositoryUserAgentParserGeoResolverAPIKeyHasherPasswordHasher
Out · driven adapters
plugins
EventRepository decorator: runs onEvent and delegates Save
goja
postgres
events · visitors · projects · admin_users · plugin_kv
only storage adapter
geoip · useragent
local MaxMind or no-op · heuristic parser
apikey · password
SHA-256 for tokens · bcrypt for passwords
Use cases depend on 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.

// plugins/calculadora.js
function onEvent(event) {
if (event.type === "error") {
http.post(WEBHOOK, { text: event.name });
}
return event; // null drops it
}
function registerPanel() {
return { title: "Usage by currency",
eventName: "calculator_used",
groupBy: "from_currency" };
}
onEvent · on every event, before it is stored
return eventstored, with the changes the plugin made
return nulldropped: never touches Postgres
error or > 100 msstored unchanged and a warning is logged
registerPanel · once, at startup
Usage by currency
USD312
VES187
EUR41
Table the server renders in the dashboard with metadata->>'from_currency'. Sample figures.
Ports in Go
Swap storage, GeoIP or the user-agent parser by implementing an interface. Recompile.
JS plugins
Mutate or drop events, fire webhooks, declare panels. One file and a restart.
Single state
Everything lives in Postgres. Plain SQL migrations on first boot, no background jobs after that.
03 · An end-to-end example

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.

1
Monday · 09:00
Lucía installs the script
She has a currency calculator on her site. She creates the project in /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.
2
Monday · 09:40
An event with metadata
A visitor converts 100 USD to bolívares. Her code calls traccia.track("calculator_used", { from_currency: "USD" }). The server stores the IP as 203.0.113.0, never the real one.
3
Tuesday
A twelve-line plugin
She drops 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.
4
Thursday · 15:12
An error in production
Her code calls 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.
5
Friday
A broken plugin loses nothing
She edits the plugin and leaves an infinite loop by mistake. At 100 ms the runtime interrupts it, the event is stored unchanged and the log says so. Real traffic never noticed.
04 · The challenges

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.

01

Plugins without Node, without WASM and without a separate process

The obvious approach
A Node plugin-server next to the binary, or WASM, or a directory of Go binaries loaded at startup.
Why it fails
Each option breaks the "one binary" promise: another image to maintain, another process to watch, or a toolchain the user has to install to write ten lines. And a hung plugin cannot be allowed to turn into lost traffic.
Everything a plugin can touch
✓ log.info / warn / error
✓ http.post(url, body)
✓ kv.get(key) / kv.set(key, value)
3 objects, 6 functions. The webhook has a 5 s timeout and returns nothing to the script.
What does not exist
✗ require / import
✗ file system
✗ fetch or arbitrary network
✗ webhook response
✗ another plugin's kv
The sandbox is the security boundary, not a suggestion.
What was done
Every .js runs in its own goja runtime, JavaScript interpreted in pure Go. The sandbox is three objects: log, http.post and kv. Each call to onEvent gets 100 ms; past that, the runtime is interrupted and the event is stored unchanged. A plugin with a syntax error is skipped with one log line, it does not block startup.
13 testsrun real JavaScript, including the infinite loop that gets interrupted and leaves the runtime reusable.
Technical detail

The integration with the rest of the system is a single point: EventRepositoryDecorator wraps the real repository and runs the plugins before Save. The first plugin that returns null drops the event; the ones after it cannot bring it back. Order is alphabetical by file name.

goja runtimes are not safe for concurrent use, so each plugin carries a mutex: requests hitting the same plugin queue up, different plugins run in parallel. It is not a path for high-throughput ingest with heavy per-event work; that is written down as a design limit in docs/plugins.md.

The kv persists in a plugin_kv table namespaced per plugin. The webhook is fire-and-forget with a 5 s timeout and does not expose the response to the script.

02

A dashboard that plugins extend without any frontend

The obvious approach
A React SPA with nice charts and a widget system.
Why it fails
For a plugin to add a panel it would have to ship frontend components: toolchain, bundler and module federation, the Grafana route. And the browser would run third-party code the server cannot fence in.
SPA dashboard
1The plugin writes a frontend component
2Toolchain and bundler to compile it
3Module federation to hot-load it
4The browser runs third-party code
The Grafana route. Right at their scale, a fork at the scale of this project.
Traccia
1The plugin returns an object: title, event, groupBy
2The server turns it into a Panel struct
3A Go template renders it as HTML
4HTMX inserts it into the page
Zero build step. The plugin never ships JavaScript to the browser.
What was done
registerPanel() returns an object with a title, an event and a groupBy. The server turns it into a struct and a Go template renders it. The groupBy is real: it groups occurrences of the event by the value of that metadata key in the chosen range, and events without the key are excluded instead of landing in an empty bucket. htmx and uPlot are vendored and embedded with embed.FS.
0 KBof plugin JavaScript in the browser. No CDN, no frontend build step.
Technical detail

The dashboard session is an HMAC-signed cookie with no server-side store: nothing to clean up and nothing to lose on restart except active logins. A shared design system (/assets/theme.css) came later, so dashboard and admin stopped being two different skins.

Today every panel renders as a name/count table. chart: "line" | "bars" is accepted in the spec but only captured. It is on the README roadmap, not hidden.
03

Three credentials, three trust levels

The obvious approach
A single API key used both to send events and to read the stats.
Why it fails
That key would go in the script tag, and anyone reading the page source could read the stats of the whole project. The initial design mixed the two and they had to be split. And for the admin, pasting a 64-character hex token into a form was never a human login.
project_id
public
Goes in the script tag. Only good for writing events. The defense is the per-IP limit, not secrecy.
api_key
read access to one project
Shown once when the project is created. Reads the stats of that project and no other.
admin account
everything
Username and password with bcrypt. Creates, lists and deletes projects. Separate from the API machine token.
What was done
The project_id is public by design, like the measurement ID of any analytics: write-only, and the defense is the per-IP limit. The api_key is shown once and only reads its own project. The admin account is username and password with bcrypt, with a one-time setup on the first visit to /admin and no open registration afterwards.
10 / minper IP on all three logins. It is the only thing between an api_key and a brute-force attack. Ingest allows 120 / min.
Technical detail

Two different hashers on purpose: SHA-256 for API keys, which are high-entropy random tokens, and bcrypt for passwords, which are low-entropy and need a slow, salted algorithm. Picking one for both would be a mistake in one direction or the other.

The dashboard's project switcher only appears, and only works, if the request also carries a valid admin session; it is checked on the server in POST /dashboard/switch. A session holding only one project's api_key never reaches another. requireAdminSession re-checks against the database that the account in the cookie still exists. Full model in the README.

04

What only shows up against real infrastructure

The obvious approach
Green unit tests, use cases with fake repositories: it works.
Why it fails
Unit tests do not see the router, the connection pool or the user-agent used to test with. Four failures slipped through there without a sound.
CORS that never ran
✓ unit: the API tests passed
✗ real: ServeMux answered 405 to the preflight OPTIONS before the middleware: no browser on another origin could send an event
A /healthz that lied
✓ unit: it returned 200
✗ real: pgxpool connects lazily: the server declared itself healthy before Postgres finished the first-boot migrations
The smoke test was a bot
✓ unit: the full flow passed
✗ real: curl without a User-Agent matches the anti-bot heuristic, so its events were left out of the stats. Correct behavior, wrong script
The seed lost events
✓ unit: the script finished without errors
✗ real: bursts from a single IP: the 120 per minute limit dropped the rest. It was the limiter doing its job
What was done
Tests on real HTTP handlers, an integration suite against Postgres 16 running in CI as a service, and a smoke test on docker compose with a clean volume. healthz now does pool.Ping, compose waits for pg_isready, preflights have explicit OPTIONS routes and the smoke test sends a browser User-Agent.
17integration tests against a real Postgres, out of 151 in total. CI: build, vet, gofmt, test, integration.
Technical detail

The CORS bug is the most instructive: Go's ServeMux answers 405 to an OPTIONS against a pattern registered with another method, before any middleware runs. The API tests passed because they never did a preflight. A test with a real browser from another origin caught it.

The /healthz one was a race: pgxpool connects lazily and the server declared itself healthy before Postgres finished the initdb.d migrations. It only failed with a fresh volume, intermittently. Fixed in two layers, compose and endpoint.

05

The visitor's IP never reaches the database

The obvious approach
Store the IP on every event and resolve country and city by calling an external API.
Why it fails
The IP is personal data, and an external API sends every visitor's IP to a third party on every pageview. Exactly what self-hosted analytics promises not to do.
IPv4 · arrives
203.0.113.42
→
IPv4 · stored
203.0.113.0
IPv6 · arrives
2001:db8:85a3:8d3:1319:8a2e:370:7348
→
IPv6 · stored
2001:db8:85a3::
Last octet zeroed in IPv4, last 80 bits in IPv6. Sample addresses.
What was done
The use case resolves geolocation on the original IP, in process, reading a local MaxMind .mmdb, and then zeroes the last octet (IPv4) or the last 80 bits (IPv6) before building the event. What gets stored is IPAnonymized; the full IP does not exist in any table.
0network calls per event. With no GeoIP file configured, the resolver falls back to no-op with a warning, never to a startup failure.
Technical detail

The GeoIP database is not shipped with the binary: its license does not allow it. It is downloaded with a free account and pointed at with GEOIP_DB_PATH. The per-IP limit is applied on the original address, before anonymizing; otherwise every visitor in the same /24 would share one quota.

The script identifies the visitor with a first-party cookie, _traccia_vid, a random UUID with a two-year lifetime. It is not cookieless analytics: it is an identifier that does not cross domains and is not shared with anyone.
"A plugin never ships JavaScript to the browser."
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.
05 · Decisions

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.

WHY
Why a full product and not an embeddable library?
Because nobody self-hosts a library with no interface. People spin up Umami or Plausible because they see charts after a 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.

WHY
goja instead of WASM or a plugin-server
goja is JavaScript in pure Go: no separate process, no Node in the image, and it runs in tests without Docker. The v1 extension points are exactly two, 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.

Accepted trade-off: one runtime per plugin with a lock, because goja is not thread-safe. A slow plugin serializes the requests that hit it. Full reference in docs/plugins.md.
WHY
Postgres as the only state and no background jobs
Everything lives in one database: events, visitors, projects, accounts and the plugins' 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.

WHY
Hexagonal with swappable ports and an MIT license
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.

06 · What I would do differently

Five decisions I would make the other way today

I did
A /healthz that returned 200 without touching the database
Today I would
pool.Ping from the first commit. pgxpool connects lazily: the server declared itself healthy before Postgres finished the migrations.
I did
HMAC admin session without re-checking against the database
Today I would
Check on every request that the account still exists. A deleted admin kept access for up to seven days with a stateless cookie.
I did
Logins with no per-IP limit in the first version
Today I would
Rate-limit /dashboard/login, /admin/login and /admin/setup from the start. It is the only thing between an api_key and brute force.
I did
Accepting chart: "line" | "bars" in the plugin spec
Today I would
Do not accept in the spec what the dashboard does not render. Today every panel is a name/count table.
I did
Leaving admin password recovery out
Today I would
Without SMTP the flow is bigger than it looks; today recovering access means deleting the admin_users row and redoing the setup. Documented instead of half-built.
07 · If you take away three things

Recap

1
One binary and one Postgres: the tracking script, the API, the dashboard and the admin come out of the same process. No CDN, no background jobs, backup with pg_dump.
2
It extends with a .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.
3
Verifiable hexagonal: the domain imports only time and the entire plugin system is a decorator on one port. The use cases do not know it exists.
Links