The problem
I wanted to answer fairly ordinary questions. Which games are people playing? Which pages get viewed? Are games being started and finished? Which countries are generating activity?
What I didn’t need was a visitor profile, advertising attribution, cookies, session replay or a tag-management system that needed its own afternoon just to configure an event.
The more I thought about it, the more appealing the simple version became: the application says what happened, Counter counts it, and that’s basically the contract.
Starting with the event
The client API is deliberately small. A site can report something like game.started with game: "solitaire", or game.ended with a result and duration. There is no separate step where that event has to be created in an analytics dashboard before Counter understands it.
Event namespaces provide the categories automatically, properties become useful filters and dimensions, and numeric values can become measures. The application reports the fact; Counter works out how to present it.
That was probably the main design rule throughout the project: don’t recreate the configuration work I was trying to get away from in the first place.
Privacy by leaving things out
I didn’t want “privacy-first” to mean collecting loads of information and then promising to be responsible with it. The more useful question was what Counter didn’t need to collect at all.
There are no analytics cookies, persistent visitor IDs, sessions, fingerprints or stored IP addresses. The collector validates incoming events and rejects obvious identifying properties. Location is deliberately coarse: Cloudflare derives a country code at the edge and Counter stores that against the event without keeping the address that produced it.
That means I can answer “how many Solitaire starts came from the UK?” but not “what else did this person do?”, because Counter doesn’t have a person to join those events back to.
Keeping raw data temporary
Recent anonymous events are useful because they allow detailed live views and give the system something to aggregate from, but I didn’t want that event-level history hanging around forever either.
Counter keeps raw events for a limited period and then compacts them into daily statistics. Counts, dimensions and numeric measures survive; the individual raw rows don’t.
The slightly unglamorous part here was making sure compaction was safe. If a job fails halfway through, it shouldn’t lose events. If it runs twice, it shouldn’t count everything twice. That meant transactions, bounded batches, retry behaviour and quite a few tests for scenarios I sincerely hope never become interesting in production.
Making it useful to look at
The dashboard started fairly conventionally and immediately became annoying to use. There were too many dropdowns leading to other views, which meant answering one question involved repeatedly selecting something, going somewhere else and selecting something again.
I changed it into more of a single analytics workspace. Site, time range, category, event, game, result, country and other dimensions can be filtered at the top, with the data underneath updating around them.
Recent data is live by default. The last hour uses minute-level buckets, the last 24 hours uses 15-minute buckets, and the dashboard refreshes automatically while it’s visible. Longer periods fall back to coarser aggregates, because I probably don’t need to know what happened at 14:17 three months ago.
The infrastructure
Counter is intentionally made from fairly small pieces. The dashboard is a static app on DigitalOcean. Collection and queries run through serverless functions. Turso stores the data. A small Cloudflare Worker sits in front of collection so country can be derived from trusted edge information without Counter storing visitor IPs.
Most importantly for a personal project, the whole thing is designed to stay comfortably inside free tiers at the scale I’m using it. I wanted useful infrastructure, not another collection of monthly subscriptions quietly breeding in the background.
What I learned
Counter has been a good example of a project where the constraints actually made the design better. Deciding that I didn’t want to identify visitors removed a surprising amount of complexity. Deciding that events shouldn’t need configuring separately kept the client simple. Deciding that it should run cheaply pushed the infrastructure towards small serverless pieces rather than a permanently running application.
It has also been a useful AI-assisted project. A lot of the implementation has been carried through by Codex, including infrastructure and production verification, but the interesting work has still been deciding what the system should and shouldn’t do, reviewing the choices it makes and tightening things when a technically valid solution doesn’t quite fit the product I want.
Where it is now
Counter is live on KJ’s Games and this site, so it has moved past being a demo built to prove itself. I’m now using the dashboard to understand real activity and letting that tell me what needs improving next.
There are plenty of things I could add, but I’m trying to resist turning it into Analytics Platform: The Enterprise Years. The useful version is still the one where a project can send a few meaningful events and immediately get useful information back.