Cookies on Tamperan

We use cookies and similar technologies for the things below. You can accept all, reject everything except what's essential, or pick what you're OK with.

Preferences
Remembers things like your last workspace and how you had a list sorted. Improves the experience but the site works without.
Improvement
Anonymous usage measurement so we can fix bugs and prioritise work.
Marketing
Lets us measure whether ads we run send people who actually use the site. We don't share personal data with advertisers.

Read our cookies policy and the privacy policy.

Loading…

Writing

Your analytics tool is counting bots as people

Bot filtering in analytics products is a user-agent blocklist, so it only catches bots that admit what they are. Here are three ways to identify the ones that don't, why your analytics vendor structurally can't do the best of them, and the Cloudflare rule that stops them at the edge.

Shane Wright analytics privacy cloudflare observability

Every analytics product filters bots. Almost none of them filter the ones that matter.

The filter is a user-agent blocklist. posthog-js drops anything whose UA contains bot, spider, crawl or headlesschrome, plus a navigator.webdriver check. GA4 works off the IAB/ABC spiders-and-bots list. Both are the same idea: catch the robots that declare themselves.

Plenty do. The interesting ones don't. So a headless browser with a spoofed user agent and a real JavaScript engine executes your page, fires your analytics snippet, and gets counted as a person by every tool in that list - because it never said otherwise.

On a small site this isn't a rounding error. It's the difference between the number you report and a number several times smaller.

Here's how to tell, using signals your analytics tool either doesn't look at or structurally can't see.

1. Real people don't arrive on a schedule

The cheapest tell, and it needs nothing beyond the session list you already have.

Look at the minute past the hour each session started. Humans scatter. A scheduled job doesn't.

We pulled a fortnight of sessions for one marketing site on 19 August 2026. One signature - reported as Microsoft Edge on Windows, from the US, one visit per day

  • landed at these minutes past the hour:
33, 33, 34, 34, 34, 35, 35, 35, 35, 35

Ten independent humans do not arrive inside a three-minute band on ten different days. That's a cron entry. A second signature, reported as Chrome on iOS, landed at 51, 51, 52.

So sort your sessions by minute-past-the-hour and the clusters fall straight out. No tooling required, and it takes about five minutes.

2. The user agent contradicts itself

Once you have a suspect, read its full user agent rather than the tidy "Microsoft Edge / Windows" your dashboard renders. The one above was:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/125.0.6422.60 Safari/537.36 Edge/12.246

Chrome 125 shipped in 2024. Edge/12.246 is Edge Legacy, from 2015. No real browser has ever sent both. Whoever assembled that string pasted two eras together.

The second-order problem is worse than the fake itself. UA parsers take the trailing token as the browser, so this gets filed as Microsoft Edge on Windows - a plausible, boring, entirely fictional user sitting in your dashboard looking like your audience. The spoof doesn't just evade the filter, it actively populates your demographics.

Check version pairs against release dates. Contradictions turn up a lot in spoofed strings, probably because whoever wrote them was copying rather than simulating.

3. Where it actually came from - and why your vendor can't see it

The strongest signal is the source network. Humans don't browse from a datacentre. Bots overwhelmingly do.

Correlating the same window against server-side request traces: of the addresses POSTing to the analytics capture endpoint in 24 hours, the substantive ones were 34.122.147.229 (Google Cloud, us-central1) and 54.200.149.144 (AWS, us-west-2). Both fetched main.css, main.js and the full analytics bundle - a complete browser doing a complete page load, from a rack.

Your analytics vendor probably can't do this check, and if you've done privacy properly it's your own doing. Proxying analytics through your own domain and stripping the visitor IP before forwarding is the correct call - it keeps the address out of a third party's hands. It also means the vendor sees your server's address, not the visitor's, so source-network filtering becomes something only you can do.

Which is a real trade, and worth making deliberately rather than by accident. You keep the privacy property (good) and you inherit the filtering job (less good).

You need server-side logs or traces to do it at all, which is the argument for having them regardless: the client-side tool cannot see the thing that identifies its own worst data.

Stopping it at the edge

The CDN already knows the source ASN, already maintains the ASN-to-prefix mapping, and already sits in front of every request. That's the right altitude - better than shipping a cloud-prefix list inside your application and maintaining it yourself.

One Cloudflare WAF custom rule in the http_request_firewall_custom phase, action block:

(starts_with(http.request.uri.path, "/.ph/e")
 or starts_with(http.request.uri.path, "/.ph/batch")
 or starts_with(http.request.uri.path, "/.ph/capture")
 or starts_with(http.request.uri.path, "/.ph/s"))
and ip.src.asnum in {16509 14618 15169 396982 8075 14061 24940 213230 16276
                     63949 132203 45090 45102 37963 51167 12876 20473}

Substitute your own proxy's capture paths. Match only the ingestion endpoints, not the whole analytics path - feature-flag, config and asset fetches should still pass, so an affected client's page behaves normally and only its event capture dies.

Two things verified against the live API on 20 August 2026, both of which shape the rule:

  • ASN matching works on Cloudflare's Free plan. ip.src.asnum is accepted in WAF custom rules. This surprised us; it's the sort of thing usually gated.
  • The matches regex operator is not. It returns not entitled: the use of operator Matches is not allowed, a Business plan or a WAF Advanced plan is required. Hence the starts_with OR-chain above rather than one tidy pattern.

The ASN list covers the major clouds - AWS, Google, Azure, DigitalOcean, Hetzner, OVH, Linode, Tencent, Alibaba, Contabo, Scaleway, Vultr. Confirm each entry before you deploy it; an ASN list copied from a blog post is a list of someone else's assumptions.

Blocking Google's 15169 sounds alarming and isn't. The rule only touches analytics ingestion, so crawling and indexing are untouched.

Who actually gets to make this call

In most organisations, not the engineer who found it. Marketing owns the number. Engineering owns the edge and can implement any filter you like, but the reported figure belongs to whoever is judged on it, and that is not the person writing the ASN rule.

Which makes this a harder conversation than it looks, because you are arriving with a change whose entire effect is to make somebody's number smaller. Not smaller and more accurate in some abstract sense - smaller on the slide that goes to the board next week, against a figure already reported last quarter using the old method.

So bring the evidence rather than the principle. The minute-of-hour clustering, the impossible user-agent pairings, the n pageviews = n persons = n sessions on one path. Those are checkable in the tool the marketing team already uses, by them, without taking your word for anything. An argument about bot filtering is lost before it starts; a list of sessions that are obviously one machine is not an argument at all.

Who else this blocks

This also drops event capture from real humans behind cloud-hosted VPN egress or a corporate proxy on one of those networks.

For marketing sites that's obviously the right trade - the population is small and the bot contamination is large. For authenticated application hostnames it is not, and defaulting it there would mean a customer on a corporate cloud proxy silently vanishing from your product analytics, which you'd then be diagnosing from an absence. Roll it out to marketing zones first and decide app hostnames separately, on evidence.

What this doesn't fix

  • Residential-proxy scrapers are outside what any ASN list catches. They egress through consumer connections precisely so this check fails. The expected outcome is materially lower, not clean.
  • It's retroactive to nothing. Historical figures stay contaminated, so any before-and-after comparison spans a methodology change. Note the date you applied it and stop comparing across it.
  • The list needs maintenance. New cloud ASNs appear. This is a rule that decays quietly, which is the worst way for a rule to decay - schedule a review rather than trusting it indefinitely.

Underneath all three techniques is the same thing: the tool that reports your traffic is the wrong place to judge whether that traffic is real. It only ever sees what the client chose to tell it, so judging it needs a signal from somewhere the client doesn't control - your own logs, or the edge sitting in front of them.

The minute-past-the-hour check costs nothing and needs no tooling, so it's the one to run first. If you do, I'd be interested to hear what falls out - my guess is that most people have never thought to look.