Blog

90% of my "human" visitors were bots

My nginx dashboard counted 2.5k human visitors in a week. Checking which IPs loaded the CSS, and which cloud they came from, brought it down.

A while ago, I asked Reddit if people still used Orbital Market, a side project of mine. My metrics said 1.2k unique visitors per day, and I honestly didn't know if they were real people or just bots.

I never got a real answer.

Chrome 122 was an impostor. About 2.2k impostors remain
A bit of foreshadowing...

So when the Luna Park dashboard said 2.5k human visitors in a week, I admit I was pretty happy with the number, and I didn't question it. I mean, yeah, I know, it's not much, but hey... it's something!

Then a weird spike made me look closer, and most of those humans turned out to be bots.

Weekly human visitors after each filter
Same 7 days, same log. The dashed line is Turnstile, our own visitor counter.

How we counted humans

Our analytics are self-hosted: nginx writes the access log, Telegraf parses it into InfluxDB, and Grafana draws the charts. There's no third-party script on the site, so the access log is all we have.

Why not just add Google Analytics?

Because I don't want a bloated third-party script on my website. And most ad blockers block it anyway.

To count humans, the dashboard kept HTML page views and dropped every user agent matching this:

bot|crawl|spider|slurp|headless|facebookexternalhit|googleother|python|curl|go-http|wget|preview|scan|fetch|lighthouse

That gets rid of Googlebot, GPTBot, link previews, etc... Everything left claims to be Chrome, Firefox or Safari, so the dashboard counted it as human.

The spike

When looking at the graph, I noticed a spike 5 times taller than the others: about 200 page views, all from the same IP.

Hourly human page views with one huge spike
Our biggest marketing success ever. Allegedly.

Here is what it requested:

/robots.txt
/sitemap.xml
/docs/sitemap.xml
/branding
/blog
/docs/fr/backend-data/auth
…

Basically, it read the sitemap and went through it:

  • About 200 requests in 20 minutes
  • No referrer
  • Not a SINGLE script, stylesheet or image
  • "Chrome 122", more than 30 versions old
  • From a Hetzner Cloud VM, coming back every few days, rotating between VMs

When it follows a redirect, it also sends the redirecting URL as Referer. Browsers don't do that, but Go's net/http client does.

So it's probably a Go script with a Chrome user agent.

Okay, but why not just block it?

I could, but I don't know who runs it. It might be an SEO tool or an AI crawler, and I actually want Luna Park to show up in search results and LLM answers. I just don't want it in my numbers.

And it pays off: over the last 90 days, about 50 visitors came from a link in ChatGPT.

Humans load the CSS

The thing is, a browser has to download a page's JavaScript and CSS to display it. A scraper that only wants the text has no reason to.

No CSS download => not a browser => not a human. So I added a rule: a page view only counts if the same IP also loaded a .js or .css file that day.

But what about the users returning on the website that have every asset in cache?

Good point, they're dropped: our assets are cached for a year, so a returning browser doesn't request them again until we deploy. I checked: it's about 2% of the count. We deploy often, and each deploy changes some file names, which helps.

On a typical day, about 300 IPs passed the user agent filter, and only about 50 of them loaded anything. Most of the others had no referrer, went straight to /, and claimed to be pretty old browsers, like an iPhone on iOS 13 (from 2019...) or Chrome 120.

Three Chrome visitors in the access log
A sitemap walker, a headless browser and a human, all claiming to be Chrome.

With that rule, the weekly count went from 2.5k to 380... Ouch.

Then a bot that loads the CSS

The next evening had a "burst" of more than 10 visitors an hour, all passing the new check. When I listed them, the pattern was pretty clear:

43.134.109.74   /docs/fr/logic/scripts
43.156.2.23     /docs/fr/logic/variables
43.163.82.79    /docs/fr/interface/templates
43.173.164.79   /compare/draftbit
101.32.115.248  /docs/fr/styling/typography
…

Each IP loads one page, with no referrer, mostly in the French docs. It runs the page's JavaScript, so it's a real headless Chrome.

And all these IPs come straight from Tencent's datacenter. It went from about 100 requests a day to about 700 in a month. Some days, it was half of our "humans".

Changing IP on every page gets around per-IP rate limits, but people don't browse the web from a cloud server. So I excluded Tencent's ranges:

43.128.0.0 - 43.175.255.255
101.32.0.0 - 101.33.255.255
124.156.0.0/16
150.109.0.0/16

The weekly count went from 380 to 290.

Then the other clouds

But Tencent just happened to be the one I caught that evening. What about the other clouds? So I mapped every IP of the last 90 days to its network owner, with a free ASN database.

43% of all our requests come from cloud and hosting networks:

Share of all requests by cloud network
Google Cloud is servers rented by anyone. Google is Google itself, so mostly its own crawlers.

And even among the "humans" that passed both checks, there were still plenty of them:

  • A single IP on MEVSPACE, a Polish server host, with about 500 page views in a month
  • AWS, Google Cloud and Hetzner IPs loading our JS and CSS like any browser
  • A fake Chrome 113 on a small hosting provider, sending up to 1,300 requests in an hour

So I excluded the ranges of every cloud where a "human" showed up: about 190 ranges from 15 networks.

The weekly count went from 290 to 230, and the page views dropped even more, from ~1.3k to ~400.

And what about people using a VPN?

Well, some VPNs run on datacenter IPs too, so a few of them are probably dropped now. But let's be honest, most people don't browse through a VPN, so I'd happily exclude every cloud.

The thing is, my filter is a list of IP ranges, and "every cloud" is about 40k of them (that's a 1 MB regex...). That's what the daily job in "What's next" is for.

Is 230 real?

Luckily, I had a way to check. Luna Park has its own visitor counter, called Turnstile (theme park, remember?), and it doesn't use the access log at all.

It counts about 220 visitors a week. So 230 is pretty close, while 2.5k was more than 10 times too high.

It's still not perfect: a headless browser on a residential proxy gets through, and so will the next crawler on a cloud I haven't listed.

The rest of the zoo

While I was at it, I went through every spike of the last 90 days. Most of them are the usual noise, but a few are worth sharing.

The AI key hunters. On 86 days out of 90, something asked for files like these:

/.aws/credentials
/.claude/settings.json
/.env.anthropic
/.anthropic/config.json
/.cursor/mcp.json

That's about 2,700 requests from almost 250 IPs, looking for AI API keys left on a server by mistake. On one day, a handful of Google Cloud IPs each sent the exact same ~280 requests. Clearly the same tool.

The French National Library. Its bot (bnf.fr_bot) archived our sites: about 500 requests in an hour. So Luna Park is officially part of the French web heritage now :)

A real human spike! The biggest burst of actual people was 13 of them using Luna Park's live activity and the challenge for more than an hour. I got excited for a second... until I realized it was us, running a workshop.

And of course, the classics: .env hunters, people looking for wp-backup.zip on a site that has never run WordPress, and probes for VPN and remote desktop logins.

Do you do anything about those?

For the obvious scanners, yes: fail2ban watches the access log. One request to a path like /.env, /.git, /.aws, /.claude, wp-login or phpmyadmin, or a SQL injection in the URL, and the IP is banned for 2 hours. Three bans in a day, and it's banned for a week on every port.

Making it fast

These checks are joins on raw log lines (more than 10k requests a day), and InfluxDB only has one CPU core on our server. The 90-day dashboard took more than a minute to load, and I wasn't going to sit through that every time.

So a nightly task now saves each day's top 500 pages, referrers, user agents and IPs. Queries read the full days from there, and only today and the first partial day from the raw log.

Query time before and after the daily rollups
Each row on its own scale.

What's next

The cloud list is still built from what I've seen, so the next crawler on a new network will get through for a while. A few things I'd like to try:

  • A daily ASN job: once a day, look up the network owner of every IP seen the day before, and flag the ones from hosting providers. Every cloud is excluded as a whole, with no list of ranges to maintain.
  • Browser headers: a real Chrome sends Sec-Fetch-*, sec-ch-ua and Accept-Language on every page. Go's net/http sends none of them, so logging them in nginx would catch the Chrome costume even if the bot loaded the CSS.
  • TLS fingerprints: a Go client's TLS handshake (its JA4 fingerprint) looks nothing like Chrome's, whatever its user agent says. Very reliable, but it needs a different setup than plain nginx.

Why not use a bot management service, like Cloudflare?

That's probably the most accurate option. But it's a third party in front of the whole site, for the same reason I didn't add Google Analytics. And they're mostly built to block bots, while I'm fine with them crawling the site.

Why it matters

I admit we're pretty bad at marketing. We post on Reddit, write articles, try things... and then we look at the dashboard to see if it worked.

With 2.5k "humans" a week, that was impossible. A crawler burst looked like a successful post, and a few real visitors from a good one were lost in the noise.

Around 230 is a lot less flattering, but at least it's real. Now, if a post brings 20 people, it shows up, and we can tell what works (and what doesn't).

TLDR: if an IP never loads your CSS, or comes from a datacenter, it's probably not a human.

If you've found other good ways to tell humans from bots, I'd love to hear about it on our Discord!

(And if you're the one running the Go crawler: hi! :))

Luna Park is a visual editor that builds full-stack web apps and exports readable Vue and Node.js code. Try it in your browser · GitHub