Counting visitors without cookies, from server logs
Most visitor statistics rely on a script in the page and an identifier stored in the browser. Our sites take a different route: once a day, a small program reads the web server's logs and keeps nothing but daily totals. This note explains how that works and where its limits are.
29 October 2026
Why not a tracking script?
A typical analytics setup adds a script to every page. The script runs in the visitor's browser, stores an identifier there and sends each page view to an analytics service – often a third party. In the EU, storing identifiers in the browser that aren't strictly necessary generally requires consent, which is one reason so many sites show a banner before you can read anything.
We wanted something simpler. The web server already writes a log line for every request: it needs that to deliver the site, find errors and fend off attacks. If the statistics come from those logs, nothing extra is collected in the browser, no script has to load, and no data leaves our server for this purpose.
What a log line contains
Each request leaves one line in the access log. It records:
- the IP address of the client
- date and time
- the method and address that was requested, the HTTP status code and the size of the response
- the page the visitor came from (referrer), if the browser sends it
- the browser identifier (user agent)
That's enough to answer the questions we care about: which pages are read, roughly how many people read them, and how they found the site. The logs themselves are rotated daily and deleted after ten days at the latest.
From log lines to visitors
Counting page views is easy: every successful request for a real page is one view. Counting visitors is harder without an identifier. Our approach is deliberately modest: for each day, the IP address and the browser identifier are combined into a hash value, and every distinct value counts as one device.
key = hashlib.sha256(f"{ip}|{user_agent}".encode()).hexdigest()
visits[(day, key)].append(path) # in memory only
The hash only exists in memory while the logs are being read. What gets written to disk afterwards are totals per day: page views per page, the estimated number of visitors and where visits came from. There's no list of visitors, no IP address and no browser identifier in the stored data.
Because the key changes every day, the same person reading on Monday and Tuesday counts twice over the week. That's why our reports call the number an estimate.
Leaving out bots
A large part of all requests isn't from people at all: search engine crawlers, link previews in messengers, uptime monitors and, above all, scanners that probe every site for login pages and configuration files. Counting them would make the numbers meaningless. So a device only counts if it passes a few checks:
- It behaves like a browser: it loads the site's stylesheet or other assets, or it receives a page as “not modified” from its cache. Most simple bots only fetch the HTML.
- Its browser identifier doesn't name a bot, a command-line tool or a preview service.
- It doesn't request typical attack targets – admin pages of software we don't run, hidden files, backups – and doesn't pile up errors.
- Its browser version isn't far behind the current releases. Real browsers update themselves; a version that's years old is usually a script in disguise.
Search engine crawlers are counted separately. Many scanners pretend to be Googlebot, so a crawler only counts as genuine if a reverse DNS lookup of its address leads to the search engine's domain and that host name resolves back to the same address – the method the search engines themselves recommend.
host = socket.gethostbyaddr(ip)[0]
genuine = host.endswith(('.googlebot.com', '.google.com')) and \
ip in {a[4][0] for a in socket.getaddrinfo(host, None)}
Where visitors come from
For the referrer, only the domain name is kept – “google.com” or “example.org”, never the full address of the page. Known search engines are grouped by name. That's enough to see whether people arrive through search, through a link on another site or by typing the address, without storing what someone searched for or which page linked to us.
What it can't do
- It can't follow a visitor across days, so it can't tell new and returning visitors apart.
- Several people behind one internet connection using the same browser version count as one; a phone that switches between Wi-Fi and mobile data can count twice.
- It sees requests, not behaviour: there's no time on page, no scroll depth and no clicks within a page.
- New filter rules can only be applied to the days that are still in the logs.
On the other hand, it isn't affected by ad or script blockers, it adds no weight to the pages, and it works the same way for every visitor. For small sites that mainly want to know which pages are useful, that trade-off works well for us.
How it runs
The whole thing is a single Python script that only uses the standard library. It runs once a day on the server, recounts every day still in the logs and sends us a short weekly summary. It covers both www.codekredo.com and xcombos.codekredo.com, with a separate report for each site.
Our privacy policy describes the setup and its legal basis. This note explains our approach; it isn't legal advice for your own site.
More notes
- Spam protection without third parties: proof of workHow the form on XCOMBO Codes keeps spam out with a self-hosted proof-of-work check, small hurdles and moderation – no cookies, image puzzles or outside service.
- A multilingual static site in plain PythonHow XCOMBO Codes turns a few data files into close to 1,000 pages in five languages with one Python script and no framework – and the details that mattered.