# Logging AI crawlers on a static site

umar.codes builds fully static, so there was no server log to show which AI crawlers visit. This log covers the middleware that fixed that — bot user agents matched at the edge, each hit written to durable storage, no added latency and no client JavaScript. It also covers what the first 882 records revealed: nearly half of them were not crawlers at all.

Published: 2026-08-10
Canonical: https://umar.codes/logging-ai-crawlers

## Revisions

- 2026-07-28 — Created.
- 2026-07-30 — Wrote the full body; the logger shipped 2026-07-29 and the first fourteen hours of production data are included.
- 2026-08-10 — Published. Added the verification finding — 45% of the first 882 records requested credential paths, so the log now stores the client IP and a verdict per hit. Corrected the record shape (four fields to six), the bot-list size (twelve to thirty), and the drain description, all of which had changed since the body was written.
- 2026-08-10 — Corrected the same day: I claimed Anthropic published no crawler IP range file, having guessed four URLs rather than reading their crawler documentation. The file exists at claude.com/crawling/bots.json, so ClaudeBot, Claude-User and Claude-SearchBot now verify — ten operators covered, not seven. The per-bot-not-aggregate conclusion is unchanged, and holds for the operators that genuinely publish nothing.

---

This site is fully static, so I had no record of which AI crawlers actually
read it. Edge middleware now matches crawler user agents and writes each hit
to storage, while human requests pass through untouched. The first 882 records
then taught me something I had not planned for: the user-agent header is a
claim, not evidence.

## Why a static site has no visibility

Building fully static was a deliberate choice, and its trade-off is that
there's no request log you own. The host has access logs; you can't query
them the way this needs. For a site whose stated purpose is being read by AI
systems, that's flying blind on the one metric that matters. The local dev
server had logged bot hits to `.crawler-log/crawler-log.jsonl` since the
rebuild — production had been a blocker entry for two days short of the
site's whole life.

## The design: match at the edge, write async

The production answer is routing middleware in front of the static output,
built to three rules. First, humans pay nothing: a request whose user-agent
matches no crawler short-circuits after one list scan — no storage call, no
await. Second, bots never wait either: a matched hit is written fire-and-forget
(the response returns while the write completes), one `LPUSH` onto a Redis
list capped at 50,000 entries so a stampede can't grow storage unboundedly.
Third, the logger fails open: any thrown error, missing configuration, or
slow dependency and the site just serves — an observability feature must
never become an availability bug.

A daily cron drains the list oldest-first in batches of 500, each batch
removed only after its contents are safely stored, into the *same* JSONL shape
the local server writes — one analysis toolset for both sources. The record
started as four fields: timestamp, raw user-agent, matched bot name, path. It
is now six. The two I added later are the subject of most of this post.

## Which user agents count as AI crawlers

The shared list started at twelve entries and now holds thirty, but the
families matter more than the count: **training crawlers** (GPTBot, ClaudeBot,
CCBot, Google-Extended, Applebot-Extended) collect for model training; **live
retrieval fetchers** (ChatGPT-User, Claude-User, Perplexity-User) fetch a page
because a user asked something *right now*; and **AI search indexers**
(OAI-SearchBot, Claude-SearchBot, PerplexityBot, Bingbot) build the indexes
AI search answers from. A training hit is a bet on future recall; a retrieval
hit is a citation happening in real time.

I closed that section, when I first wrote it, with a caveat: everything here
is user-agent-declared and unverified, anything can claim to be GPTBot, so
treat the counts as indicative rather than audited. I wrote that as a
disclaimer. It turned out to be the finding.

## Nearly half the log was a scanner in a costume

Thirteen days in, the log held 882 records and I finally looked at the paths
rather than the bot names. The largest single "crawler" in the dataset was
`Perplexity-User`, at 347 hits. Of those, 321 asked for `/.env`,
`/.ssh/id_rsa`, `/credentials.json`, `/@fs/proc/self/environ`, and eighty-odd
variations on the same theme. Perplexity does not want your SSH key. That is
a vulnerability scanner wearing Perplexity's name.

Sorted by how many of each bot's requests went to paths only a credential
scanner asks for:

| Claimed bot | Hits | Credential paths | Share |
| --- | --- | --- | --- |
| `Perplexity-User` | 347 | 321 | 93% |
| `Google-Extended` | 6 | 6 | 100% |
| `ChatGPT-User` | 19 | 17 | 89% |
| `Amazonbot` | 38 | 24 | 63% |
| `Bingbot` | 39 | 11 | 28% |
| `ClaudeBot` | 260 | 4 | 2% |

Across every bot, 394 of 882 records — 45% — were this. My fetch-side
instrument, the one built specifically to answer "is any of this working?",
was 45% noise, and I had been reading its totals for two weeks.

Note what that table is and is not. It sorts by *path*, which is a heuristic:
a strong one, since nothing legitimate requests `/.ssh/id_rsa`, but still an
inference rather than proof. It is the best that can be done retrospectively,
because those 882 records store no client address. That is the honest reading
and I would rather state it than round it up.

## Verifying the claim instead of trusting it

The fix is not clever. Major crawler operators publish the IP ranges they
crawl from — OpenAI, Perplexity, Microsoft and Google all serve a JSON file
of prefixes. Snapshot those into the build, and verifying a user-agent claim
becomes a CIDR containment test against the client address: no DNS lookup, no
network call, nothing added to the response path. Ten bots and 1,491
prefixes at the time of writing.

Each record now carries the client IP and a verdict. `verified` means the
address sits inside a range the claimed operator publishes. `forged` means
the operator publishes ranges and the address is outside all of them — a
positive finding, not a shrug. Everything else is `unknown`, which means
absence of evidence and nothing more.

Two decisions I would defend. **Forged records are labelled, not dropped.** A
false claim is a fact about the world, and deleting the rows would make the
forgery rate itself unmeasurable — the exact mistake that let this sit
undetected for two weeks. **The verdict never guesses upward.** A missing
address, an unparseable one, or an operator with no published ranges all read
`unknown`, never `verified`.

Which leads to a mistake worth recording, since this post is otherwise about
not trusting claims. I first wrote this section stating that Anthropic
published no range file, on the strength of four plausible URLs that all
returned 404. Anthropic publishes one. It is at `claude.com/crawling/bots.json`,
linked from their crawler documentation — which I had not read. Guessing
hostnames is not a search, and I made that error on the single operator where
it mattered most: ClaudeBot is the largest genuinely-behaving crawler in this
log at 260 hits, with a 2% scanner share. It verifies now, along with
Claude-User and Claude-SearchBot.

The wider point survives the correction, though. Ten of the thirty agents on
the list publish ranges; Meta, Apple, Amazon, ByteDance and the research
crawlers do not, and read `unknown` by construction. A single "verified share"
figure for this site would therefore measure which operators publish their IP
ranges, not how much of the traffic is real. The numbers only mean anything
per-bot.

## The first fourteen hours

The numbers in this section predate verification, so read them as
user-agent-declared. Their paths are clean — reconnaissance and content, no
credential probing — which is weak evidence for them being genuine, not
strong evidence.

The logger went live on 29 July, and the first 14.5 hours logged 31 genuine
crawler hits (my own verification requests excluded): ClaudeBot 17, GPTBot 10,
Bingbot 2, OAI-SearchBot 2. The shape of the traffic is the interesting part.
Nineteen hits — over 60% — were reconnaissance: `robots.txt` eleven times,
`sitemap.xml` eight. Six content pages got actual reads, the
[spec-driven development essay](/spec-driven-development) first among them.
One crawler requested a blog URL from the 2021 version of this site, retired a
year of redesigns ago and absent from even the redirect map — crawler memories
are long, and by August eleven such URLs were still being requested often
enough that I gave them redirects. And `llms.txt`, the file
this site maintains specifically for AI consumption: zero fetches so far. That
last number gets its own post when there's enough data to be fair about it.

## What it costs

Per human request: one substring scan over thirty entries, no I/O. Per bot
request: the same scan, a CIDR test against the compiled-in prefix snapshot,
and an async write the response doesn't wait for. Verification is pure
arithmetic on two integers, which is why it could go in the request path at
all — the moment it needed a DNS lookup it would have belonged somewhere
else. At
the cap, storage tops out around 12 MB — comfortably inside a free Redis
tier — and the cap plus fail-open design means the worst realistic failure is
lost log lines, never a slow page. The whole system is one middleware file,
one shared bot list, and one export script.

## What I'll do with the data

Three posts consume this pipeline: a data report after roughly ten weeks of
accumulation, the llms.txt fetch-rate question, and the year-end
retrospective. The experiment shipped nineteen days ahead of its calendar
slot precisely so those posts would have something worth reporting — data
compounds, and the cheapest way to have ten weeks of it in October is to
start logging in July.

All three now carry a caveat I did not expect to write. Verification landed on
10 August, so every record before that date is unverified and unverifiable,
and any comparison spanning that date has to be segmented on it or the change
in the numbers is just the instrument changing. That is the second lesson
here, and it is the more general one: an instrument is not trustworthy because
you built it carefully. It is trustworthy once you have tried to find out how
it lies.
