← Back to Blog

2.8x Traffic Spike: How to Identify Bot Traffic

Part of LLMO Field Guide

A stacked bar chart where each day totals 100 percent, split between real traffic and non-human traffic. August 29 through 31 sit between 27 and 43 percent non-human, while September 1 swells to 69 percent

On September 1, sessions on kenimoto.dev came in at 2.8x the previous day.

The real increase was 1.2x. The rest was a magic trick, and the method was sitting in my own access log.

About 70% of that day’s sessions were footprints left by something that wasn’t a person. They weren’t all the same thing either: three separate operations with different goals landed on the same day. One I want to keep. One is hiding its name. One thinks my site runs WordPress.

GA4 can’t tell them apart. Here’s why, and what to read instead.

Almost all of the spike came from one country

Here’s the daily split, with Singapore and China pulled out.

DateShare of raw sessions that were non-human
Aug 2943%
Aug 3041%
Aug 3127%
Sep 169%

Excluding those two countries, the day-over-day increase was under 20%, and the last 21 days all sit in the same band. Only one side of the number moved.

Singapore alone was 9x the previous day. The shape of it:

  • Engagement rate 0.0%
  • Average duration 0.5 seconds
  • Exactly one pageview per session
  • 99% on Chrome / Windows / desktop
  • 98% on the same screen resolution, 1280x1200

Visitors who stay half a second, view one page, and leave, all on identical hardware. Human crowds don’t line up that way.

Breaking it down to the minute makes it clearer.

Time (JST)Share of that day’s Singapore sessions
17:114%
17:1212%
17:1323%
17:1421%
17:1515%
17:166%

Six minutes hold 80% of the day’s Singapore traffic. That window lands right after I published a batch of English posts, which is also when the sitemap changed.

GA4 doesn’t have the User-Agent

This is where GA4 runs out. It gives you country, browser name, OS, and screen resolution. It does not give you the User-Agent string. All you get is “Chrome,” so a real browser and something impersonating one look identical.

There’s a second limit that matters more. GA4 only records hits that executed JavaScript. If gtag doesn’t run, the visit doesn’t exist as far as GA4 is concerned. Which means:

  • Crawlers that don’t run JS (most search engine bots) never appear in GA4 at all
  • Anything non-human that does appear in GA4 is a headless browser capable of running JS

Half the picture is missing before you start.

To see the rest you need logs from the serving layer. kenimoto.dev runs on Cloudflare Workers, so I queried the Cloudflare GraphQL Analytics API.

TOKEN=<Cloudflare API token>
ZONE=<zone id>

read -r -d '' Q <<'EOF'
query($zone:String!,$start:Time!,$end:Time!){
  viewer{ zones(filter:{zoneTag:$zone}){
    httpRequestsAdaptiveGroups(
      limit:20,
      filter:{datetime_geq:$start, datetime_lt:$end},
      orderBy:[count_DESC]
    ){ count dimensions{ userAgent clientCountryName } }
  }}}
EOF

curl -s -X POST https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg q "$Q" --arg z "$ZONE" \
        '{query:$q, variables:{zone:$z,
          start:"2026-09-01T08:10:00Z", end:"2026-09-01T08:20:00Z"}}')"

Timestamps go in as UTC. 17:11 JST is 08:11Z.

Not every field is available on the free plan. What I actually got:

FieldFreeUse
userAgentThe main signal
clientCountryNameOrigin
clientRequestPathWhat they took
edgeResponseStatusHigh 404 rate means probing
clientRequestHTTPProtocolHTTP/1.1 vs HTTP/2
clientASNDescriptionPermission error
clientRefererHostPermission error

No ASN means I can’t name the hosting provider, but everything needed for the call is available for free.

Three different things, same day

Three comparison cards for the crawler types that arrived the same day. Card A, crawlers that identify themselves, read robots.txt, stay out of GA4 and return citation links. Card B, headless clients that hide their name, skip robots.txt, show up in GA4 and return nothing. Card C, vulnerability scans, which end in 404s

What it isrobots.txtIn GA4?
ACrawlers that identify themselvesReads itNo
BHeadless clients hiding their nameSkips itYes
CVulnerability scansSkips itNo

Only B was polluting GA4. A and C don’t run JS, so they were never in those numbers.

A: AI search crawlers all write their names

Here’s one day, filtered to the ones that identify themselves.

CrawlerRequestsWho
Bytespider140ByteDance
bingbot140Microsoft
Googlebot133Google
PetalBot110Huawei (Petal Search)
Semrush102SEO tooling
Applebot64Apple
ChatGPT-User57ChatGPT fetching on a user’s request
ClaudeBot52Anthropic
Ahrefs48SEO tooling
Amazonbot37Amazon
Claude-User15
GPTBot14OpenAI (training)
PerplexityBot9Perplexity
OAI-SearchBot7ChatGPT search indexing
DuckAssistBot2DuckDuckGo

The AI ones put their name and a URL in the UA. Bytespider even includes a contact address.

Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko)
Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)

The reason is practical: robots.txt works by name. Without a name there’s nothing for you to allow or deny. Publishing the name is how they stay addressable when someone says “don’t train on my site.”

Which also means robots.txt does nothing to anyone who won’t name themselves. Mine currently looks like this:

User-agent: *
Allow: /

User-agent: GPTBot
Allow: /

User-agent: ClaudeBot
Allow: /
...

Everything is allowed. The welcome mat is out.

Still, some of them don’t say who they are.

B: The ones hiding their name leave fingerprints

Back to that six-minute window. Every UA in it:

RequestsVersion claimed
47Chrome/131
45Chrome/109
40Chrome/110
36Chrome/111
28Chrome/107
24Chrome/133
23Chrome/116

And on through 103, 104, 105, 106, 108, 112, 117, 120, 124. More than 16 versions in rotation.

Chrome 103 through 111 shipped in 2022 and 2023. No real user population looks like that in 2026. The spread exists because a single fixed UA gets blocked after enough consecutive hits.

The protocol sealed it.

Requests
HTTP/1.1389
HTTP/25

99% arriving over HTTP/1.1 while claiming to be Chrome. Real Chrome negotiates HTTP/2. The UA string was rewritten; the transport underneath wasn’t.

The usable signals:

SignalIdentifies itselfDoesn’t
UA consistencyFixedChanges every hit
ProtocolMostly HTTP/2Skews HTTP/1.1
robots.txtReads it firstNever touches it
Request pacingSmoothed outBursts

Whether it read robots.txt is the cleanest of these. Across all 394 requests from group B, /robots.txt appears zero times.

What they took tells you why

The file types tell you a lot about intent. Here’s what group B pulled in those six minutes:

TypeRequests
HTML290
.json98
.js3
.png3

Almost no images. A site built to repost your content takes the images too, because the look has to be reproduced. Text pulled without images points at the text itself being the product.

Other things that fell out:

  • Only 3 requests returned 404. They hit real URLs and nothing else, which means they were working from a sitemap or a URL list. This isn’t discovery
  • Coverage spanned /ja/, /pt/, /es/. Not one article, the whole site
  • It arrived right after an update. Something is watching for changes

Put together, that narrows to site-wide text collection: training data, or source material for rewrites.

One more thing. This site specifically was the target. Compare the same period across sites I run:

Site (last 14 days)Share of sessions from Singapore
kenimoto.dev34.5%
mypcrig.com2.3%
kaoriq.com4.4%
legacydram.com3.5%

An indiscriminate sweep would produce similar ratios everywhere.

Four ways to check whether you’ve been reposted

If text is walking out the door, reposting is the next thing to rule out. I ran four checks.

1. Exact-phrase search

Pull a sentence only you would write and put it in quotes. Quoted queries are exact-match: only pages containing that exact string come back.

"I was snapshotting views and likes every single day"

If someone copy-pasted the article, an unfamiliar domain shows up next to yours. I tried four phrases across Japanese, English, and Portuguese. Only my own site came back.

One warning: use more than one engine. I ran the same phrases through Bing and DuckDuckGo, and DuckDuckGo returned zero results for the Japanese exact-match queries. Not just no reposts, it couldn’t find the original either. Its Japanese index is thin, so that zero means “not measured,” not “not reposted.” Bing returned my site correctly.

Query one engine and you’ll read the second case as the first.

2. Read every referrer

Automated reposting tools sometimes leave a source link. I pulled every referral domain from 90 days of GA4 and visited the ones I didn’t recognize.

  • sunblog.asia → 302 redirects to xtraffic.plus. This is referrer spam: the domain gets planted in your analytics so you’ll click it. Not a repost
  • aleemuh.com → a personal portfolio. Unrelated

3. Pull your backlinks

GetLinkCounts in Bing Webmaster Tools returned zero. Nothing Bing knows about links back from a repost.

4. Check whether they took the images (above)

All four came back clean. No trace of copy-paste reposting.

That doesn’t mean it hasn’t happened. These four only see copy-paste reposts that got indexed. Three things stay invisible:

  • Reposts that were rewritten or translated (exact-match can’t reach them by definition)
  • Reposts that were never indexed
  • Anything absorbed as LLM training data, which has no external observable at all

I’d rather not treat absence of evidence as proof of safety. Given how this particular client behaves, the third one is the likely case.

If you want certainty, embed a unique string per article somewhere a human won’t see it and search for it on a schedule. A repost carries it along.

C: Vulnerability scans arrive daily and miss every time

The third group is a different animal. Same day, other countries.

OriginPaths hitCount
Netherlands/wp-json/batch/v1 across 20+ directory layouts~175
Russia/wp-admin/install.php?step=117
Germany/admin1, /ur-admin, /backup, /fileadmin (ffuf)4-5 each
Germany/.env, /.git/HEAD4 each

/wp-json/batch/v1 is the WordPress REST batch endpoint, probed for known auth weaknesses. Trying it under /wp/, /blog/, /wordpress/ and a dozen more prefixes covers wherever WordPress might be installed.

/.git/HEAD checks for an exposed repository, /.env for readable environment variables, and ffuf is a directory brute-forcer.

This site is static Astro. No PHP, no WordPress, no .env. Every one of those is a 404. Scans like this are permanent background noise on any public domain, and they need no response.

The separator here is the 404 rate. Group B produced 3. Group C 404s on nearly everything it touches. Precise retrieval of real URLs versus firing into the dark.

The cost isn’t bandwidth, it’s measurement

So where’s the actual damage?

Not bandwidth. A few hundred requests won’t strain anything.

It lands on measurement. In August I was running a canonical A/B test on Dev.to, and the inflation was sitting on one arm only. A/B tests are read as differences, so one side swelling flips the conclusion.

Read raw, September 1 says “2.8x day over day.” Evaluate a change against that number and you’ll certify something that did nothing. The real figure was 1.2x.

There’s a second cost tied to AI search. When a crawler that identifies itself cites you, a link comes back with the citation. Collection that hides its name returns nothing. It takes, and nothing comes back.

Excluding one country is a workable stopgap, but the more important habit is not reading a drop as a decline without checking how much you excluded. I always print the before and after side by side now.

The procedure

The order I’d use next time:

  1. Look at daily GA4 numbers and find the day that moved
  2. Split that day by country x engagement rate x average duration. 0.0%, under a second, and exactly one pageview per session together mean non-human
  3. Break it to the minute. Bursts mean automation
  4. Query User-Agent from Cloudflare or whatever serves your traffic. GA4 does not have it
  5. Check whether the UA names itself. Named ones are controllable through robots.txt
  6. For the unnamed, confirm with the HTTP/1.1 skew and whether robots.txt was ever fetched
  7. Look at which file types they took. HTML without images means the text is the target
  8. Use the 404 rate to separate collection from probing

Looking at GA4 alone, this day would have gone into the record as the day traffic went up 2.8x.

Why ChatGPT Ignores Your Website Related book Why ChatGPT Ignores Your Website Google SEO won't get you cited by ChatGPT — this is the playbook for being seen by AI search, with measurement included View the book page →