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/datais an nginxaliasonto the access log itself, served astext/plainbehind 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
PUTback 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,
aliasonto the log,auth_basic, and WebDAVPUTon 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
- Geolocation — evo.locate over the internal network, data by DB-IP