I wanted to know which pages of my projects people read without loading a 40 KB script on every visit, without sending the data to servers in the United States and without fighting data protection law. That’s where Tempoo Analytics came from: a Google Analytics alternative I started in November 2025 that now measures, among other sites, this blog.
This article covers how it’s built: the script that runs on the site, the service that receives the events, where they’re stored and which data gets dropped along the way.
The architecture at a glance
Everything goes through a gateway built with YARP that routes requests by path. Events go to a Go service, the dashboard and the API are .NET 10, and locally I orchestrate the whole thing with .NET Aspire.
The split of responsibilities is simple:
- ClickHouse stores the events and answers every metrics query.
- Postgres stores the transactional data: users, sites, subscriptions, funnels and the message outbox.
- Redis works as a cache and as the queue for long-running jobs, such as importing history from Plausible or GA4.
A script under 2 KB
The script is a single file with no dependencies, built with esbuild. Minified it weighs 2,937 bytes, and with brotli compression the browser downloads 1,115. Installing it takes one line in the <head>:
<script defer src="https://cdn.tempooanalytics.eu/analytics.js" data-site="YOUR_SITE_ID" data-key="YOUR_WRITE_KEY"></script>On load it checks a few things before measuring anything: that it hasn’t already been loaded, that the browser doesn’t have Do Not Track enabled and that the page isn’t localhost or a local file. If everything checks out, it records the page view and hooks into history.pushState and popstate to follow navigation in single-page apps.
To send events it uses navigator.sendBeacon, which doesn’t block the page and keeps working when the user closes it. If the browser doesn’t support it, it falls back to fetch with keepalive: true:
function send(payload) { const body = new Blob([JSON.stringify(payload)], { type: "application/json" }); if (navigator.sendBeacon && navigator.sendBeacon(`${apiUrl}?key=${writeKey}`, body)) return; fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": writeKey }, body: JSON.stringify(payload), keepalive: true, });}Conversions are tracked without writing JavaScript. A click on any element with data-tempoo-goal sends an event, and any data-tempoo-goal-* attributes travel as properties:
<a href="/pricing" data-tempoo-goal="view-pricing" data-tempoo-goal-plan="freelance">See pricing</a>Ingestion in Go: answer fast, write in batches
The POST /analytics/events endpoint is written in Go with Gin. Its job is to accept the event as soon as possible and return a 202. Before that it does only what’s essential:
- It looks up the site configuration (plan, quota, allowed origins). The query goes to Postgres and the result is cached in Redis for five minutes, so it rarely hits the database.
- It validates the write key and, if there isn’t one, checks that the request origin is on the site’s list.
- It enriches the event: country, city and ASN from the MaxMind GeoLite2 databases, browser and operating system from the user agent, and the UTM parameters from the URL.
- It classifies the traffic as human, crawler or AI bot (GPTBot, ClaudeBot, Perplexity and friends). Bots aren’t dropped: they’re labelled, so the dashboard can show them separately.
What it doesn’t do is write to ClickHouse in that same request. ClickHouse performs much better with large, infrequent inserts than with thousands of single-row inserts, so the event goes into a channel that holds up to 100,000 events. Three goroutines drain it and write in batches of up to 5,000 events, or every five seconds if the batch doesn’t fill up first:
func (w *Worker) run(ctx context.Context) { batch := make([]Event, 0, 5000) ticker := time.NewTicker(5 * time.Second) defer ticker.Stop()
for { select { case e := <-w.events: batch = append(batch, e) if len(batch) == cap(batch) { w.flush(ctx, batch) batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { w.flush(ctx, batch) batch = batch[:0] } case <-ctx.Done(): w.flush(context.Background(), batch) return } }}This is a simplified version of the real worker, but the idea is the same. When the service shuts down, whatever is left in the channel gets flushed. The trade-off is that the queue lives in memory: if the process crashes, the events from the last few seconds that hadn’t been written yet are lost.
ClickHouse for the numbers, Postgres for the rest
At first the events lived in Postgres too, with a separate aggregates table. In March 2026 I moved them to ClickHouse, and in the same change ingestion moved from .NET to Go.
The events table is a MergeTree partitioned by month and ordered by site and date, which is exactly what every dashboard query filters on. This is a trimmed-down version of the schema:
CREATE TABLE events( site_id String, date DateTime, event_name LowCardinality(String), user_hash FixedString(64), visitor_id String, session_id UUID, path String, browser LowCardinality(String), country_code LowCardinality(String), traffic_type Enum8('human' = 1, 'bot_ia' = 2, 'crawler' = 3, 'vpn_proxy' = 4) -- more columns: UTM, referrer, device...)ENGINE = MergeTreePARTITION BY toYYYYMM(date)ORDER BY (site_id, date, event_name, user_hash);On top of it, materialized views fill hourly and per-dimension stats tables (browser, country, page, referrer). Funnels are solved with windowFunnel, a ClickHouse function that works out, for each session, how far into the funnel it got within a time window. Doing that in standard SQL takes a lot more lines.
Close to real-time data
With the ingestion buffer, an event takes at most about five seconds to reach ClickHouse. The dashboard asks for data every ten seconds, so what happens on the site shows up on screen within seconds. There are no websockets: for an analytics dashboard, polling every few seconds is simpler for me to maintain and good enough.
What data it keeps and what it doesn’t
The script doesn’t use cookies and I don’t store the full IP address. From the IP and the user agent I compute a SHA-256 hash that changes every month, and I use it to count unique visitors without knowing who they are. The script also generates two random IDs: a session ID in sessionStorage, which disappears when the tab closes, and a visitor ID in localStorage. If the browser has Do Not Track enabled, nothing is sent. The servers are in the European Union, at OVHcloud (France) and Contabo (Germany).
The consent banner deserves an explanation. Not using cookies doesn’t mean you never need to ask for permission: European rules (the ePrivacy directive) cover storing or reading information on the user’s device, and localStorage falls into that category. Some consider a visitor ID used for first-party analytics exempt and some don’t. On this blog I went with the cautious option: the Tempoo script only loads if you accept it in the notice, and if you reject it I delete both IDs.
What’s there so far
Besides everything above, Tempoo already has public read-only links to share the dashboard with clients, history import from Plausible, and payments through Polar. I build it on my own, in changes small enough that each one fits in a single PR.
If you want to try it on your site, there’s a 15-day free trial at tempooanalytics.eu.
Comments
No comments yet. Be the first!
Leave a Comment