Back to Blog Craft
2 min read ✉ Subscribe
Real analytics from nginx logs, with no logging stack
Craft

Real analytics from nginx logs, with no logging stack

Page views, top pages and per-site traffic, from the file already on your disk — no JavaScript, no third party, no Elasticsearch.

AX
Alien XP
Jul 20, 2026 · 2 min read

Every request your server handles is already written to a file. If all you need is page views, top pages and where traffic goes, you do not need a tracking script, a third-party account, or a log-shipping pipeline. You need to parse a file you already have.

First, log the one field that is missing

The default combined format is nearly enough, but it omits the thing that matters most on a server hosting more than one site: which host the request was for. Add it once, at the top of nginx.conf:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" vhost=$host';

access_log /var/log/nginx/access.log main;
This is not retroactive. Whatever you do not log today, you cannot analyse tomorrow — which is a good argument for adding the field before you think you need it.

Decide what counts as a view

This is where most home-made analytics goes wrong. A raw line count is not traffic. You want a definition and you want to apply it consistently:

  • Not a bot. Match the user agent against a bot pattern, and treat an empty UA as a bot too.
  • Not an asset. Exclude .css, .js, images, fonts — a page with twelve images is one view, not thirteen.
  • Not an error. Only 2xx and 3xx. A 404 is not a page view.
  • Not you. Drop your own IP and any private ranges.

The parse

re = r'^(\S+) \S+ \S+ \[([^\]]+)\] "(?:(\S+) )?([^"]*?)(?: HTTP/[\d.]+)?" '
     r'(\d{3}) (\d+|-)(?: "[^"]*" "([^"]*)")?(?:.*\bvhost=(\S+))?'

Group 4 is the path, 5 the status, 7 the user agent, 8 the vhost. Make the vhost group optional so the parser still works on a server that has not adopted the custom format — it degrades to "no per-site data" rather than to zero.

Cache the result, not the parse

Scanning a few hundred thousand lines takes a couple of seconds. That is fine for a cron job and far too slow for a page load. Write the aggregate to a JSON file with a timestamp and re-scan only when it goes stale:

cache = '/tmp/analytics-%d.json' % days
if fresh(cache, ttl=900):
    return load(cache)
... scan ...
save(cache)

Fifteen minutes of staleness is invisible to anyone reading a traffic report. Four seconds of page load is not.

What you get, and what you do not

You get page views, unique-ish visitors by IP, top pages, referrers, status-code breakdown, bandwidth, and per-site splits — all of it without a single byte of JavaScript on your pages and without sending anybody's browsing to a third party.

What you do not get: anything that requires client-side code. Time on page, scroll depth, viewport size, whether a visitor is the same person as yesterday. If you need those, you need a tracker. If you do not — and most people asking "how many people read that post" do not — the log is enough, and it is already there.

AX

Alien XP

Craft · 10 essays

Runs the servers behind AlphaPanel and writes up what breaks.

Keep reading

All posts