Crawler logs

Cloudflare Worker

Deploy a small Cloudflare Worker that forwards AI-crawler hits to Shruwd, including requests your CDN serves from cache.

A small Worker on a route in front of your site forwards AI-crawler hits to Shruwd. It runs before cache lookup, so it sees the requests Cloudflare answers from cache without calling your origin — which on a static or prerendered site is most of the pages worth measuring.

Works on any Cloudflare plan. It sends one short line per crawler hit and nothing at all for ordinary visitors.

The Worker only sees requests Cloudflare lets through. A request refused by Cloudflare's own settings (AI crawler blocks, WAF custom rules, rate limiting) stops before the Worker runs, so Shruwd never hears about it. Cloudflare does not document where Bot Fight Mode runs, so do not count on seeing its refusals either. To see what Cloudflare refused, open Security → Analytics in the Cloudflare dashboard. On an Enterprise zone, Logpush records those refusals too.

What the Worker does see is a refusal made behind Cloudflare — by your host, a security plugin or your server — and the finding says that is where it happened.

Prerequisites

  • Permissions: admin or owner, to create the token.
  • A Cloudflare zone for your domain, and permission to deploy a Worker on it.
  • wrangler available (npx wrangler needs no install).

1. Get your endpoint and token

  1. Open the brand's Settings → Crawler logs.
  2. Select Create token.
  3. Copy the endpoint and the token.

The token is shown once and cannot be retrieved later. Creating a new one immediately revokes the old one, which will stop a Worker that is already running.

2. Create the Worker

Make a directory in your site's repository with the two files below.

wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "shruwd-log-forwarder",
  "main": "src/index.js",
  "compatibility_date": "2026-09-01",

  // The route must match your canonical host ONLY. If customer custom domains
  // resolve through this same zone, this pattern must not catch them.
  "routes": [
    { "pattern": "example.com/*", "zone_name": "example.com" }
  ],

  "vars": {
    "CANONICAL_HOST": "example.com",

    // Comma-separated path prefixes. "/" is the root page only;
    // "/blog/" is everything underneath it.
    "PATH_ALLOWLIST": "/,/pricing,/features,/blog/,/docs/",

    "SHRUWD_INGEST_URL": "YOUR_ENDPOINT"
  }
}
src/index.js
// Forwards AI-crawler hits to Shruwd. Fails open: whatever happens in here,
// your site's response is returned unchanged.

// A broad prefilter, not the bot list. Identification proper happens on
// Shruwd's side against a catalogue that is kept current, so this only has to
// be loose enough not to drop something that catalogue would have caught.
const BOT_UA_PREFILTER =
  /bot|crawl|spider|gpt|claude|perplexity|bytespider|applebot|ccbot|meta-external|google|extended/i

// Static assets are never the page a crawler judges you on. .txt and .xml stay,
// because robots.txt and sitemaps are interesting.
const ASSET_PATH =
  /\.(?:js|mjs|cjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|pdf|zip|gz|json|wasm)$/i

function parseAllowlist(value) {
  return String(value ?? '/').split(',').map((s) => s.trim()).filter(Boolean)
}

// Cheapest checks first — this runs on every request to the zone.
function decide({ method, host, path, userAgent }, { canonicalHost, allowlist }) {
  if (method !== 'GET' && method !== 'HEAD') return false
  if (host.toLowerCase() !== canonicalHost.toLowerCase()) return false
  if (ASSET_PATH.test(path)) return false
  if (!allowlist.some((p) => (p === '/' ? path === '/' : path === p || path.startsWith(p)))) return false
  return BOT_UA_PREFILTER.test(userAgent)
}

let warned = false

function maybeReport(request, response, env, ctx) {
  const url = new URL(request.url)
  const userAgent = request.headers.get('user-agent') ?? ''

  const report = decide(
    { method: request.method, host: url.hostname, path: url.pathname, userAgent },
    { canonicalHost: env.CANONICAL_HOST, allowlist: parseAllowlist(env.PATH_ALLOWLIST) },
  )
  if (!report) return

  // Set by the edge; a client cannot forge it. Inside a Worker,
  // x-forwarded-for can be forged, so it is not used as a fallback.
  const clientIp = request.headers.get('cf-connecting-ip')
  if (!clientIp) return

  // The query string is never sent: it carries ids on some routes, and a
  // per-page crawl count needs none of it.
  const line = {
    timestamp: new Date().toISOString(),
    host: url.hostname,
    path: url.pathname,
    user_agent: userAgent,
    status: response.status,
    client_ip: clientIp,
    method: request.method,
    bytes: Number(response.headers.get('content-length') ?? 0) || 0,
  }

  // One request per hit, deliberately. A Worker isolate is ephemeral and
  // per-colo, so a buffer would be lost on eviction and split across colos.
  // Volume is low because the prefilter already ran.
  ctx.waitUntil(
    fetch(env.SHRUWD_INGEST_URL, {
      method: 'POST',
      headers: {
        'x-shruwd-ingest-token': env.SHRUWD_INGEST_TOKEN,
        'content-type': 'application/x-ndjson',
      },
      body: JSON.stringify(line) + '\n',
    })
      .then((res) => {
        if (!res.ok && !warned) {
          warned = true
          console.warn(`shruwd forwarder: ingest answered ${res.status}; check the token and URL`)
        }
      })
      .catch((error) => {
        if (!warned) {
          warned = true
          console.warn('shruwd forwarder: ingest unreachable', error?.message ?? error)
        }
      }),
  )
}

export default {
  async fetch(request, env, ctx) {
    // Never inside the try. The only unconditional path is returning your
    // origin's response — a logging pipeline that can break the site it
    // observes is worse than one that loses events.
    const response = await fetch(request)
    try {
      maybeReport(request, response, env, ctx)
    } catch {
      // deliberately silent
    }
    return response
  },
}

3. Configure it

Edit the four values in wrangler.jsonc:

SettingValue
routesYour canonical host, e.g. example.com/* with zone_name example.com
CANONICAL_HOSTYour canonical host, without a scheme
PATH_ALLOWLISTYour public, crawlable pages, comma separated
SHRUWD_INGEST_URLThe endpoint from step 1

PATH_ALLOWLIST is an allowlist on purpose. Your site probably serves customer content and token-bearing links from the same origin, and a blocklist fails open — the next route someone adds starts forwarding data to a third party. The worst case here is a missing datapoint for a marketing page.

4. Deploy

Store the token as a secret rather than putting it in the config or the source.

bash
npx wrangler secret put SHRUWD_INGEST_TOKEN
npx wrangler deploy

Paste the token when prompted.

5. Verify

Request a prerendered page with a crawler user-agent. That is the case origin middleware could not see, so it is the one worth testing.

curl -s -o /dev/null "https://example.com/pricing" \
  -H "User-Agent: Mozilla/5.0 (compatible; GPTBot/1.2)"

Within a minute, Settings → Crawler logs moves from awaiting first logs to active.

Your test hit comes from your own machine, not from OpenAI, so it is recorded as unverified and excluded from headline metrics. That is correct — and the card moving off awaiting first logs is the signal you are looking for.

Next steps