Docs · Collectors
Cloudflare Worker collector
Report every request to a site Cloudflare proxies, from a Worker in front of it. Works for WordPress, Webflow and more.
Add the code
A Worker in front of your site. Works for WordPress, Webflow and anything else Cloudflare proxies.
// Cloudflare dashboard: Workers & Pages, Create, paste this, then add a route for yourdomain.com/*// and ETHOGRAM_KEY as a secret under Settings, Variables. Works for any site Cloudflare proxies.// The browser check tells people from AI agents driving a real browser. It sets no cookie and stores nothing.const BROWSER_CHECK = true;const secret = /^(cookie|authorization|proxy-authorization)$/i;const assets = /\.(?:css|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|mp4|webm)$/i;function report(request, env, ctx, extra) { // Every other header goes, signature, signature-input and signature-agent included: they prove a signed agent. const headers = Object.fromEntries([...request.headers].filter(([name]) => !secret.test(name))); // What Cloudflare saw of the connection: its network, protocol and TLS version, and a bot score where you have one. const { cf } = request; const edge = { asn: cf?.asn, org: cf?.asOrganization, protocol: cf?.httpProtocol, tls: cf?.tlsVersion, botScore: cf?.botManagement?.score }; ctx.waitUntil( fetch("https://ethogram.si/api/e", { method: "POST", headers: { authorization: `Bearer ${env.ETHOGRAM_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ collector: "0.12", events: [{ url: request.url, method: request.method, time: Date.now(), ip: request.headers.get("cf-connecting-ip"), headers, edge, ...extra }], }), }).catch(() => {}), );}export default { async fetch(request, env, ctx) { const start = Date.now(); const { pathname } = new URL(request.url); if (pathname === "/_ethogram/check.js") return fetch("https://ethogram.si/ethogram.js", { cf: { cacheEverything: true, cacheTtl: 3600 } }); if (pathname === "/_ethogram/report") { const body = await request.text(); try { if (body.length <= 4096) report(request, env, ctx, { browser: JSON.parse(body) }); } catch {} return new Response(null, { status: 204 }); } let response = await fetch(request); if (!assets.test(pathname)) { report(request, env, ctx, { time: start, status: response.status, duration: Date.now() - start, // What the site answered, so Ethogram can tell why an agent got stuck. response: { "content-type": response.headers.get("content-type"), "content-length": response.headers.get("content-length"), "cf-mitigated": response.headers.get("cf-mitigated"), }, }); } if (BROWSER_CHECK && response.headers.get("content-type")?.includes("text/html")) { response = new HTMLRewriter().on("head", { element: (head) => head.append('<script src="/_ethogram/check.js" defer></script>', { html: true }) }).transform(response); } return response; },};Set the server key
The code reads your project's server key from the ETHOGRAM_KEY environment variable. Create the key on your project's setup page in the dashboard (or under Settings, Server keys), and set it where your server runs. It's shown once, so save it straight away. Keep it secret, like a password: it lets whoever holds it report events for your site.
In the Cloudflare dashboard, go to Workers & Pages and create a Worker with the code above. Add a route for yourdomain.com/*, and add ETHOGRAM_KEY as a secret under Settings, Variables. The Worker passes each request on to your site unchanged and returns its response.
What it sends
One event per request, with ctx.waitUntil after your site has answered, so visitors never wait on Ethogram and errors are ignored. Each event has:
urlandmethod: the request as received.time,statusandduration: when the request arrived, your site's status code, and how many milliseconds it took.ip:cf-connecting-ip, the visitor's address as Cloudflare saw it.headers: every request header exceptcookie,authorizationandproxy-authorization. That includesuser-agent,referer, andsignature,signature-inputandsignature-agent, which prove a signed agent.response: your answer'scontent-type,content-lengthandcf-mitigatedheaders, so Ethogram can tell why an agent got stuck: a challenge page, a JavaScript-only page, or a slow answer. Kept for agents only. Collectors installed before October 13, 2026 don't send it: update yours to see reasons.edge: what Cloudflare saw of the connection: the network's number and name (asn,org), the HTTPprotocolandtlsversion, andbotScoreif your plan has Bot Management. Ethogram weighs them to tell people from automation that copies a browser's headers, and never keeps them.
With BROWSER_CHECK on, the Worker also adds the browser check's script tag to the <head> of every HTML page it passes on, with Cloudflare's HTMLRewriter. See below.
What it skips
Static assets such as CSS, images, fonts and video, by the path's file extension. JavaScript files (.js, .mjs) are reported: Ethogram counts the scripts each agent fetches for its profile, adds them to no request count, and drops people's on arrival. Collectors installed before October 19, 2026 skip them: update yours to see them.
Named actions
A Worker sits in front of your site and can't see what your code does, so there's no track() here. To name actions, send them from your site's own server with the HTTP API.
Optional: the browser check
Some AI agents drive a real browser, like Claude in Chrome or Comet. To your server, their requests look like a person's. The browser check is a small script that tells them apart by how each page is used: whether the pointer moved before a click, whether typed text came with key presses, whether the browser says automation controls it. On already: the Worker serves it and adds it to every HTML page. Set BROWSER_CHECK to false to turn it off.
The code above answers two paths of its own: /_ethogram/check.js, the script, fetched from https://ethogram.si/ethogram.js and cached for an hour, and /_ethogram/report, where the script sends its counts once when the page loads and again when it's hidden or closed. Each report is forwarded to Ethogram with the request's IP and headers, so it joins the visit it belongs to. Neither path is reported as a page.
It sets no cookie, stores nothing in the browser, and sends only counts and yes-or-no answers: never where the pointer was, what was typed or anything that identifies the person. See Data we keep. Without it, everything else works the same, and most of those agents count as people.
Check it
Deploy, then visit your site the way an AI agent would, with your own domain in place of example.com:
curl -A "GPTBot/1.2" https://example.com/The setup page turns live when the first event arrives. Your test shows up as a spoofed GPTBot, since it doesn't come from OpenAI. That's detection working. Nothing showing up? See the FAQ.