Docs · Collectors
Express collector
Report every request your Express server handles from one middleware, and named actions with track().
Add the code
Add this before your routes and express.static, so it also sees the JavaScript files agents fetch.
// Behind a load balancer? Trust one hop so req.ip is the visitor, not the balancer.app.set("trust proxy", 1);const report = (req, extra) => fetch("https://ethogram.si/api/e", { method: "POST", headers: { authorization: `Bearer ${process.env.ETHOGRAM_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ collector: "0.12", events: [{ url: `${req.protocol}://${req.get("host")}${req.originalUrl}`, method: req.method, ip: req.ip, ...extra }], }), }).catch(() => {});// Every other header goes, signature, signature-input and signature-agent included: they prove a signed agent.const forwarded = ({ cookie, authorization, "proxy-authorization": _, ...headers }) => headers;// The optional browser check: its script, and what it reports about how each page was used.app.get("/_ethogram/check.js", async (req, res) => { const script = await fetch("https://ethogram.si/ethogram.js").then((r) => r.text()).catch(() => ""); res.type("js").set("cache-control", "public, max-age=3600").send(script);});app.post("/_ethogram/report", express.text({ type: "*/*", limit: "4kb" }), (req, res) => { res.sendStatus(204); try { report(req, { time: Date.now(), headers: forwarded(req.headers), browser: JSON.parse(req.body) }); } catch {}});// Before your routes and express.static: reports each request once the response is sent, with its status and timing.app.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { if (/\.(?:css|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|mp4|webm)$/i.test(req.path)) return; // What you answered, so Ethogram can tell why an agent got stuck (a challenge page, an empty JavaScript shell). const response = { "content-type": res.get("content-type"), "content-length": res.get("content-length"), "cf-mitigated": res.get("cf-mitigated") }; report(req, { time: start, status: res.statusCode, duration: Date.now() - start, headers: forwarded(req.headers), response }); }); next();});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.
What it sends
One event per request, once the response has been sent (on the response's finish event). Errors are ignored, so your server never fails on Ethogram. Each event has:
url: protocol, host and original URL.method: the request method.time,statusandduration: when the request arrived, the status code you answered with, and how many milliseconds it took.ip:req.ip. The snippet setstrust proxyto one hop, so behind a load balancer this is the visitor, not the balancer.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.
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.
Optional: named actions
Page views, searches, form posts and cart or checkout requests show up on their own. To see actions by name, report them from the code that does them. Call track() in the route that does it. It reuses report() from the middleware above. More in Named actions with track().
// In the route that does it: track(req, "Added to cart", product.name)// Who did it. Signatures aren't checked on actions (the page's own request carries them), so they needn't go.const track = (req, name, target) => report(req, { time: Date.now(), headers: { "user-agent": req.get("user-agent"), "signature-agent": req.get("signature-agent") }, action: { name, target } });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. Your server already serves it. Add this tag to the <head> of your HTML pages or layout template:
<script src="/_ethogram/check.js" defer></script>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.