Writing / Engineering
I Have 48 Readers, Not 4,958: Counting Real People From a Server Log
- Published
- Read time
- 11 min read
- Written by
- Victor Solano
Forty-eight.
That's how many pageviews on thechosenvictor.com over six days I can actually prove came from a person operating a browser. Twenty-one distinct addresses, twenty-seven sessions, across 22,740 logged requests between 2026-08-20 08:18 and 2026-08-26 11:46 CEST. That's 6.15 days of retention, which is all I had; the log rotation window was still refilling after I raised roll_keep from 5 to 40 the day before.
Filter the same log the way almost everyone filters an access log, keep anything with a browser user agent, drop the self-declared bots, and it reports 4,958 pageviews from 529 visitors. That number is wrong by a factor of 103, and I would have quoted it without blinking if I'd stopped at the obvious query.
This is the method that got me from 4,958 to 48, every command it takes, and the two places my own classifier was broken while I was busy being smug about everyone else's.
Why the Access Log at All
I have Cloudflare Web Analytics on the page and an Umami container running on the box. Neither is the source of truth here. Cloudflare's beacon is JavaScript, and the audience for a post about self-hosting Next.js is exactly the audience running uBlock. Umami is deployed but has zero references in the app code, so it recorded two homepage events in thirty days. Both meters measure people who let a meter run.
The Caddy access log has the opposite bias. It sees everything that reaches the origin, including clients that would never run a line of my JavaScript, which means the problem flips from "who did I miss" to "which of these 22,740 lines is a human." That's a filtering problem, and filtering problems have answers.
Caddy writes JSON, one object per line, which is the only reason any of this is a one-liner. First step is treating the rolls and the live file as one stream:
sudo sh -c 'cat /var/log/caddy/*.access.log; \
gzip -dc /var/log/caddy/*.access.log.gz 2>/dev/null' > /tmp/all.log
wc -l /tmp/all.log
Mine was five compressed rolls plus the live log, 6,365,373 bytes on disk.
Failure One: Your Unique Visitors Are Your CDN
.request.client_ip in a Caddy log behind Cloudflare is the Cloudflare edge node that proxied the request. Not the reader. Count uniques on it and you're counting datacenters.
jq -r '.request.client_ip' /tmp/all.log | sort | uniq -c | sort -rn | head -5
jq -r '.request.headers["Cf-Connecting-Ip"][0] // "none"' /tmp/all.log \
| sort | uniq -c | sort -rn | head -10
Run both and look at the shapes. The first collapses hundreds of people into a handful of edge addresses in whatever POPs serve your traffic. The second is the real client. Every number in this post is built on Cf-Connecting-Ip, and if that header isn't in your log you don't have a visitor count, you have a proxy inventory.
Failure Two: My Own Robots Wear Browser Clothes
The biggest single class of traffic on my site is me. Not me reading it. My tooling hitting it.
| Class | Requests | Share |
|---|---|---|
| Own tooling | 8,257 | 36.3% |
| Browser-UA traffic | 8,091 | 35.6% |
| Hostile probes | 3,398 | 14.9% |
| Declared crawlers | 2,994 | 13.2% |
curl/8.7.1 alone was 5,578 requests. TCVSeoAudit/1.0 was another 1,790. Those two announce themselves, so they're easy. The dangerous ones announce nothing useful:
jq -r '.request.headers["User-Agent"][0]' /tmp/all.log \
| sort | uniq -c | sort -rn | head -40
Read that list line by line, all of it, and mark every UA you recognise as yours. I found six that my exclusion pattern had missed, including Mozilla/5.0 (compatible; TCVReadOnlyAudit/1.0) at 169 requests, which sails straight past any filter looking for the word "Mozilla" as a sign of life. Re-running with those six added moved 233 requests out of the audience column and dropped the unverified tier by 171 pageviews. It did not move the confirmed number by one.
That last part is the useful bit. A method whose headline shifts every time you tune the exclusion list isn't measuring anything. The confirmed tier held still because it never depended on the exclusion list in the first place. It depends on evidence.
Failure Three: Machines Fake Engagement Better Than Humans
The sessions that look best in a naive report are the ones with depth. Multiple pages, tight timing, no bounce. Those are the scrapers.
One address in Singapore, 50.114.41.210, requested five pages inside a single second: /work-with-victor three times and my App Review Rejection Decoder twice. In any dashboard that's a hot lead reading my services page and then my tool. In the log it's five HTML requests in under a second with not one CSS file, not one JS chunk, and no font. Nothing rendered. Something read the markup and left.
So cadence and assets are the two things you check, and you check them per address:
jq -r 'select(.request.method == "GET") | select(.status >= 200 and .status < 300)
| (.request.headers["Cf-Connecting-Ip"][0] // .request.client_ip) as $ip
| .request.uri as $u
| if ($u | test("^/_next/static|\\.(css|js|woff2?|png|svg|ico)($|\\?)"))
then "\($ip)\tasset" else "\($ip)\tpage" end' /tmp/all.log \
| sort | uniq -c | sort -k2
An address with page rows and no asset row is not a browser.
Why the Asset Test Is Allowed Here
That test is only valid if assets actually reach your origin. If your CDN serves every static file from cache and never forwards it, absence of asset requests tells you nothing and the whole method collapses.
So prove it before you use it:
jq -r 'select(.request.uri | startswith("/_next/static"))
| .request.headers["Cf-Connecting-Ip"][0]' /tmp/all.log | sort -u | wc -l
I got 1,740 /_next/static requests from 278 distinct addresses in the window. Static files are demonstrably arriving at my box from hundreds of different clients, so an address that pulls HTML and never pulls a chunk is making a statement. Run this first. If it comes back near zero, stop, because everything below it is unsupported.
The Tier Model
Four buckets, and only one of them is a number I'll say out loud.
Tier A, confirmed browser. All of: a successful 2xx GET for a document URL, at least one static asset fetched by the same address on the same day, and request timing that isn't machine-shaped. Twenty-seven sessions, 21 addresses, 48 pageviews.
Tier B, unverified browser UA. Browser-shaped user agent, no supporting evidence. 4,910 pageviews from 516 addresses. Treat it as residue. It holds scrapers, undeclared bots, headless agents, and whatever first-party tooling my exclusion list still misses.
Declared crawlers. Googlebot and friends, self-identified. Useful for indexing questions, irrelevant to readership.
Hostile probes. /wp-admin/install.php, /config.json, /graphql. Noise, though counting it tells you how much of your bandwidth is people looking for a way in.
A plus B is the naive answer: 4,958 pageviews, 529 visitors. Same log, same six days. The gap is the point.
Session depth in Tier A: twenty one-page sessions, four two-page, three three-page. There's no browsing happening on my site. The deep sessions were all in Tier B and all of them were machines.
Two Bugs In My Own Classifier
A post about measurement that hides its own measurement bugs is worthless, so here are mine.
Bug one: a failed POST could count as a pageview. The earlier version of my classifier counted requests, not successful document reads. A browser-costumed scanner that fired POST / and got a 404 landed in the pageview pile, and if that same address had pulled an asset at any point that day, it satisfied the asset test and got promoted to confirmed. Real examples sat in my window: POST / returning 404, GET /config.json returning 404. Both from a Chrome user agent. The fix is one predicate, and every command in this post already carries it:
select(.request.method == "GET") | select(.status >= 200 and .status < 300)
A pageview is a successful GET of a document. A scanner that got told no is not a reader.
Bug two: auditing only 404s missed an entire class of broken URLs. I'd been checking for missing pages by grepping status 404. Here's the full histogram for the window:
jq -r '.status' /tmp/all.log | sort -n | uniq -c | sort -rn
200 14,730 · 404 6,560 · 308 1,372 · 304 188 · 410 73 · 400 15 · 405 8 · 403 7 · 500 2 · 204 1.
Seventy-three requests hit 410 Gone across roughly two dozen retired blog and newsletter URLs, and I'd been blind to all of them. I serve 410 deliberately for eight withdrawn posts, so I'd filed the whole status code under "working as intended" and stopped looking. Then I looked at who was hitting them. Googlebot on many, which is expected and fine. But two of them were real Google-referred browsers landing on /blog/three-apps-solo-developer on 2026-08-23 and dying there. Google was still ranking a page I'd told it was permanently gone, and still sending live people to it.
That's a bug in the audit, not in the 410. The audit asked "what's missing" when the question is "what's failing," and those aren't the same query.
What the Number Is Not
Tier A is a floor. It has one systematic undercount I can't fix, and I'd rather name it than have someone else find it.
A returning reader with a warm cache asks for the HTML and nothing else. Every asset is already in their browser. My method files that person as a bot, because from the log they look identical to one. Same for anyone behind a privacy setup that strips or reorders things. The true human count sits somewhere above 48 and nowhere close to 4,958, and I can't narrow it further with this data.
Some other things I'm not claiming. Unique addresses are not people; households and carrier NAT merge several humans into one, VPNs and rotating IPv6 prefixes split one human into several. My own machine's address changes between sessions. And 6.15 days is not a trend. Daily Tier A pageviews ran 8, 8, 5, 1, 18, 5, 3. At that volume one person swings the metric twenty percent, so anyone drawing a line through those points is drawing it through noise.
What 48 Actually Told Me
The point of getting the number right is that it changes what you do next.
Twenty-seven of the 48 confirmed pageviews, 56%, were app privacy and support pages. Compliance traffic and existing users looking something up. Nobody arrives at a privacy policy to read the writing.
/work-with-victor had 513 successful raw hits in the window. One of them was a confirmed human. If I'd read 513 I'd have concluded my services page has traffic and my conversion copy is broken, and I'd have spent a week rewriting the page. The actual failure is that one person saw it.
External referrers in Tier A across the whole six days: one, from Google. Widen it all the way out to every browser-UA request regardless of tier and it's 22 referred requests total, some of which are the same address twice and some of which carry crawler-shaped agents. Twenty-two, at the most generous reading I can defend.
So I'm not buying more analytics. More instrumentation would measure the same absence at higher resolution. Nothing is broken in the plumbing: the box is healthy, the pages are 200, the assets are arriving. Nobody is coming, and now I have a number that says so precisely enough to argue with.
Run the asset test on your own log before you quote your visitor count. It takes one command, and it either confirms the number you've been telling people or it doesn't.
Related Articles
I Adapted Nine Workflows From Matt Pocock's Skills Repo
Matt Pocock's repo gave me nine useful workflows. I rewrote them around my own rules, pinned the pack to the commit I reviewed, and added checks for drift.
The First Thing My New Linter Flagged Was My Own Live App
Before publishing screenproof, I ran it on screenshots from apps I had already shipped. One folder came back with 16 findings: eight accepted PNGs with alpha channels, plus eight HTML files I had left beside them.