Analytics dashboard

Know who visited, without telling anyone else.

Self-hosted visitor analytics for every site in the estate: one HTML page, served by the nginx edge, reading the access log it already writes. Geolocation is answered on the same box, so a visitor’s address never reaches a third party.

Why not just use Google Analytics

A portfolio is a strange thing to instrument. The people I most want to know about — someone reading a case study before an interview — are exactly the people whose visit I have no business handing to an advertising company. Analytics scripts also want a consent banner, a third-party request on every page, and a tracker that ad blockers remove anyway, which quietly makes the numbers wrong.

The edge already writes an access log for every request it serves. Everything I actually wanted to know is in it. So the dashboard reads that, and nothing leaves the machine.

What it is

One HTML file, about a thousand lines, served directly by nginx at an authenticated path. There is no analytics service, no database, no build step and no collector: the page fetches the log, parses it in the browser, and draws sessions, unique addresses, page views by path, and the status-code distribution.

Nothing is added to the sites being measured. No script tag, no cookie, no beacon, no consent banner — because there is nothing on the visitor’s side to consent to. A visitor is counted because nginx served them a file, which it was going to record either way.

Where the numbers come from

  • The log is the API. /admin/logs/data is an nginx alias onto the access log itself, served as text/plain behind basic auth. There is no endpoint to keep in step with a schema, because there is no schema.
  • Per-site logs, because the shared format could not say which site. The default log line carries no $host, so before each vhost got its own file a visit could only be attributed by guessing at paths unique to that page. Every site now writes its own.
  • The dashboard can clear the log — an empty PUT back to the same path, with WebDAV methods enabled on that one location. Useful after testing, when the traffic you are looking at is your own.
  • Nothing is cached. Both the data and the geo endpoints send no-store; a stale analytics number is worse than a slow one.

Geolocation without a third party

Turning an address into a place is the one step that normally requires calling someone. Here it is answered by evo.locate running on the same box — a self-hosted lookup service over a local database file, reached over the internal Docker network and never over the internet.

The proxy deliberately blanks X-Real-IP and the X-Forwarded-* headers on the way through. Without that, the act of looking up a visitor would hand the lookup service the visitor’s own address as the caller — which is the sort of leak that only shows up if you go looking for it.

The bug that justifies the write-up

For a while the dashboard showed every visitor’s address and no location at all. Not an error, not an empty state — a column that was simply blank, on a page that otherwise worked.

The lookup lived at /api/geo/<ip>. The dashboard lived at /admin/logs. A browser attaches cached basic-auth credentials to the path it authenticated at and its children — and /api/geo is a sibling, not a child. So every lookup went out unauthenticated, came back as a 401 with an HTML body, res.json() threw on the first angle bracket, and the catch block quietly rendered nothing.

Three separate things had to be true for it to be invisible: the request succeeded at the network level, the failure surfaced as a parse error rather than an auth error, and the handler treated a missing location as an ordinary absence. The fix was one line of routing — a second lookup at /admin/logs/geo/<ip>, underneath the path the browser already authenticated at.

It is on this page because it is the kind of defect no test suite was ever going to catch. Nothing threw, nothing 500’d, and every check that existed stayed green. Somebody had to look at the screen and ask why a column was empty.

Keeping my own visits out of it

The person who visits a portfolio most is the person who built it, and unfiltered numbers on a low-traffic site are mostly the author reloading. The dashboard keeps an address whitelist — exact addresses or wildcards like 203.0.113.* — and shows filtered and raw counts as two separate views rather than silently choosing one.

Both numbers stay visible on purpose. A filter you cannot see the other side of is a way to fool yourself.

Why it stays behind a password

The dashboard is internal and returns 401 to everyone, including me until I authenticate. That is not modesty about the numbers: the page shows visitor IP addresses, the paths they read and when, which is exactly the data the whole design exists to keep off other people’s servers. Publishing it would undo the point.

The credential file sits above every site root, so no vhost can serve it even by accident. Fonts and the charting library are self-hosted rather than pulled from a CDN — an analytics page that phoned out to two third parties to render itself would be a poor joke.

Happy to walk through it live in an interview; the screenshot on the projects page is the real thing.

Technology stack

  • Serving and auth — nginx: static page, alias onto the log, auth_basic, and WebDAV PUT on one location
  • Dashboard — one HTML file, vanilla JavaScript, no framework and no build step
  • Charts — Chart.js 4.5.1, self-hosted
  • Fonts — DM Sans and Outfit, self-hosted from @fontsource
  • Geolocationevo.locate over the internal network, data by DB-IP

Back to Projects · Contact