The blog's analytics panel currently counts 13,975 views in its thirty-day window. The previous thirty days had 7,171. Nearly double. When you see a number like that, a voice inside you wants to say "something must be working," and that voice is very persuasive.
A little further down the same panel sits the referrer table:
| Source | Views |
|---|---|
direct |
13,969 |
google.com |
5 |
google.com.tr |
1 |
Six views in thirty days came from somewhere. The other 13,969 already knew the address.
On a properly indexed blog with thousands of URLs in its sitemap, 99.96% of readers do not type the address from memory. That number is not a reader count. This post is my note on trying to measure that gap and discovering I cannot fully trust my own measurement either.
The Panel Shows Two Different Bot Rates, and I Wrote Both
In June I added a geography estimate. classifyAudienceGeo uses three buckets: CN, HK and SG are "datacenter"; Turkey, Europe and Turkic-speaking countries are "real"; everywhere else is "mixed." Crude, but defensible: Turkish technical content does not draw an audience from Singapore.
Today's values look like this. Raw thirty days: 13,975. Datacenter: 9,900. Real: 1,777 — of which 1,068 from Turkey and 709 from the diaspora. Mixed: 2,298. The bot rate the panel prints: 71%.
In September I added a second measurement: a classifier that scores the request itself. Its numbers, accumulating since 17 September, say this: 1,038 human views, 10,386 bot views. A bot rate of 90.9%.
Same site, same week, two different rates. The twenty-point gap is not an error, it is a definitional difference. The geography estimate marks only three countries as bots and leaves the 1,242 views from the United States sitting in the middle as "mixed"; the scoring classifier ignores the country and looks at how the request behaves. Their windows differ too: one describes thirty days, the other nine. If I counted the mixed bucket as bots, the rate would be 87.3%.
Producing three different correct numbers bothered me, but that is not the thing that is wrong. What is wrong is picking one of them and saying "this is my traffic." Any metric shared without naming the definition underneath it is really a statement of preference.
Apparently My Best Day Is Thursday
The panel also has a day-of-week breakdown. Thursday: 9,560 views. The other six days combined: 4,415.
At first glance that looks actionable. Should I move my publishing to Thursdays? Then I checked the dates. 17 September was a Thursday, and the page table logged 8,854 views that day, while the traffic table counts 7,462 bots for the same day. The two numbers come from two separate ledgers, so you cannot subtract one from the other — but both say the same thing. There is no best day of the week; there is a day a swarm visited.
This is the most insidious form of a bad metric. A wrong number does not merely hand you a wrong figure, it also suggests an action. Had I followed that suggestion, I would have rearranged my publishing calendar around an Alibaba datacenter's shift schedule.
The Swarm's Signature: One Browser, Sixty-Nine "Visitors"
I wrote down what happened on 17 September. The comment at the top of src/lib/traffic-class.mjs describes it exactly: 1,253 Singapore views in a single day in the nginx access log, all with one user agent (Mac Chrome 145), coming through Alibaba's 43.119 and 47.82 blocks. A second wave arrived from inside 2a04:4e41::/32, sending every request from a different /64. The counter that counted uniqueness per IP saw this as 69 distinct visitors.
"Unique IP equals unique reader" breaks exactly here. In IPv6 the space a provider hands a customer is so vast that using a fresh address for every request costs nobody anything. This is not an abuse but the design itself: the IETF best-practice document on the subject recommends that even a home user receive "multiple subnets worth of space, by default." Counting unique visitors is no longer the same as counting addresses.
Instead of trusting a single signal, I wrote a scoring function:
| Signal | Points |
|---|---|
Geography in CN/HK/SG (geo) |
2 |
Swarm: same prefix + same UA, at least 8 distinct IPs within an hour (swarm) |
2 |
No Accept-Language header at all (nolang) |
1 |
No Referer (noref) |
1 |
| Threshold to be counted a bot | 2 |
This way a broad prefix alone does not make anyone a bot; a real reader can sit behind a CDN too. But once the prefix, the identical browser signature and dozens of addresses in a short window line up, the decision becomes clear.
Here is how the bot reasons break down. Note carefully: this table does not cover thirty days but the nine days since 17 September, when the human/bot columns started filling. The field is called botReasons30, which is exactly why I read it as thirty days at first glance.
| Reason | Views |
|---|---|
geo+nolang+swarm |
8,040 |
ua (known crawler signature) |
1,977 |
geo+nolang |
302 |
geo |
67 |
The part worth noticing is not the bottom rows but the ratio. Crawlers that announce themselves openly, that have "bot" in their name, that can be added to a blocklist, make up one sixth of the total. The rest look like real Chrome and no blocklist catches them. Tomorrow they will put on a different user agent and play the same game; I will add another line to the list. In this race my side is always one step behind.
A Good Reason Not to Trust My Own Threshold
While writing that scoring function I left myself a justification in the code: a real browser never drops the Accept-Language header. For this post I opened MDN's page on it and read it, because technical sentences written from memory have been my most expensive mistakes.
MDN offers no such guarantee. It says browsers shorten that list to reduce fingerprinting surface, and gives examples: in Safari always, and in Chrome's incognito mode, "only one language is listed." So the header shrinks; it does not disappear. My assumption is not entirely wrong, but it is not a documented rule either — and I leaned one point of a two-point threshold on it.
It stays for now, because that point alone makes nobody a bot; it only carries weight once it combines with a geography or swarm signal. But the code now says this is an assumption, not a fact. That difference changes everything for whoever reads that line six months from now.
Anyone reading this far should be asking one question: the site already sits behind Cloudflare, so why not just read Cloudflare's bot score? Because I cannot. Cloudflare's own documentation is explicit: the numeric bot score goes only to Enterprise customers who have purchased Bot Management, while Pro and above see coarser groupings along the lines of automated, likely automated, likely human. On my plan there is no per-request score, so there is no field I could attach to the beacon. I wrote my own scoring not because I dislike Cloudflare's, but because I do not have theirs — and because I want the raw data in my own database.
For what it is worth: Cloudflare draws a line in its Radar documentation too. A bot score between 1 and 29 counts as a bot, 30 and above counts as human. Even one of the most trafficked networks on earth solves this with a threshold rather than certainty. That my hundred-and-forty-line file produces no definitive answer is not the flaw; presenting it as definitive would have been.
robots.txt Does Not Stop This
In June I reworked robots.txt: open to agents that cite, closed to those scraping for training. I still think that was the right call, but it has nothing to do with the traffic table.
RFC 9309, the document that standardized robots.txt, was published in September 2022 and contains this sentence: "These rules are not a form of access authorization." The document requests that crawlers honor the rules. It defines no penalty for those that do not, and could not. Everyone knows the difference between writing "please do not enter" on a door and fitting a lock to it.
Cloudflare's July 2026 report shows the same picture at scale: as of June 2026, 52% of crawler requests are for AI training, up from 22% in spring 2025. Mixed-purpose crawlers account for more than 36%. Search crawling is a shrinking slice.
On my panel, the counterpart to that picture is this: AI-sourced referrals number zero. My robots.txt is open to citation, I am being crawled, and in thirty days not a single reader arrived by that road. Being scraped and being read are not the same thing, and the bridge between them has not been built yet.
There Is Also What Real Readers Actually Read
The device breakdown: desktop 13,296, mobile 674, tablet 5. So 95% of raw traffic is desktop. There is no world in which one in twenty people reading technical content in Turkey is on a phone; even I read my own posts on a phone most of the time. That ratio alone is not a bot measure, but it is enough to make anyone looking at the table stop and say "something is off here."
The genuinely interesting part appears when you sort the panel by the human column. Six of the ten most-read pages are in English, and four of those are straight two-tool decisions: Kopia or Restic, OpenBao or Vault, Cloudflare Tunnel or Nginx Proxy Manager, a cluster without kube-proxy. The other two are the English home page and a Forgejo setup guide. A small sample, but the lean is clear: people do not come to me to read opinions, they come to read a decision made between two tools.
This also exposes the blind spot in my own geography classification. The panel says "the real audience is Turkey," because I coded it that way. The page data says a meaningful share of real reading happens on English comparison posts — and some of those readers sit inside the 2,298 views my geography estimate left stranded in the "mixed" bucket. I narrowed my own audience with my own assumption. To be honest I have not proven this yet: I now have a country-blind human/bot classifier, and cross-tabbing that mixed bucket against the human column would answer the question. I did not do it while writing this post; it is sitting on the list.
The home page tells the same story: 76 of its 308 raw views are human. Bots stop at the index; humans go into the article. Meanwhile my analytics page was opened eleven times in thirty days by ten distinct visitors; knowing there is a handful of people watching my measurement obsession is oddly nice. This whole post is about separating counts from people, and I got that very sentence wrong on the first pass.
And then there is this table, which I think holds the most honest line in the post: over thirty days, 4,101 reactions, 143 "this helped" votes, 2 comments, 3 subscribers. Reacting is free; writing a comment and leaving your email address is not. Cheap signals are always abundant. The scarcer the expensive signal, the more it tells you — and I would take three subscribers over 4,101 reactions, because I know all three of them exist.
So What Am I Measuring Now
On 23 September I fixed the counter on the home page. That day the storefront said "107 visits today"; 43 of that 107 were bots, a forty percent inflation. At the same moment the panel showed 49 human visitors — so the gap held both a bot difference and a views-versus-visitors difference: two separate definitions of today on one site. The counter now reads the human column, and its label became "readers."
This work is not finished, and it cannot be. A few rules I hold onto:
- Every publicly displayed number is fed by data classified as human. If there is no human data, the number is not inflated; a dash goes in its place.
- Raw data is never deleted. Classification is an estimate; if I change the threshold tomorrow I must be able to recompute the past.
- A metric cannot be both the target and the measurement. If I target the view count, I will start to love whatever inflates it.
- The assumption behind a threshold gets written into the code. "This header always arrives" will be wrong one day; on that day you need to be able to read the reasoning.
- I track the real audience by country. Thirty-five readers a day from Turkey (1,068 over thirty days), or sixty counting the diaspora, says more than ten thousand requests from Singapore.
The daily series in the human column since 17 September looks like this: 34, 135, 101, 117, 112, 144, 159, 182. An eight-day series, and its first day is a partial one — measurement began midday on 17 September, and I left today's half-finished figure off the list. Statistically it proves nothing and I will not extract a trend from it. But I can say this: these numbers do not make the seven-and-a-half-thousand jump that raw traffic made in the same period, because they cannot. Real reader counts accumulate slowly. The boring flatness of that chart is the best evidence that I am measuring the right thing.
Thirty-five is not a flattering number. But whether I am thinking about what I deliberately did not automate while running seven servers alone, or about repairing what an agent wrote sixty-seven times, I arrive at the same place: a small correct number is more useful than a large uncertain one. I can make decisions looking at the small number. Looking at the large one, all I can do is feel good.
When a Number Feels Good, Treat It as a Warning
There is no cheap way to measure whether a metric is true, but there is a cheap alarm: if looking at a number makes you feel relieved, your appetite for checking where it came from drops at precisely that moment. And let me say this too: the ninety-five percent increase in this very post is itself the work of a swarm. Take 17 September out of the table and the thirty-day total falls below the previous period. So "my traffic doubled" is the exact mistake I am trying to correct.
Measurement tools do not defend themselves. A panel will not tell you "this figure is actually from Singapore"; it will show you and stay quiet, and you are the one who has to make the connection. I measure my own blog's traffic with code I wrote myself, and I can still produce three different correct answers — I would rather not think about how much someone should trust a single number from a panel they did not build.
For now I do this: next to every number I show, I write which definition it came from. Maybe nobody reads it. But for me to stop panicking at that panel six months from now over "traffic is down," that small explanatory line is worth more than all the charts.
Official Sources
- RFC 9309 — Robots Exclusion Protocol — the standard text for robots.txt; the section stating plainly that it is not access authorization lives here.
- Cloudflare Radar — Bot classes — how the bot/human split is drawn with a score threshold (1-29 bot, 30+ human).
- RFC 6177 (BCP 157) — IPv6 Address Assignment to End Sites — the document recommending that end sites receive multiple subnets by default.
- MDN — Accept-Language header — how browsers shorten this header for fingerprinting reasons.
Top comments (0)