The answer. Analytics Engine is the cheapest way to get first-party, consent-independent telemetry that identifies nobody: a passthrough Worker on the zone route calls env.DATASET.writeDataPoint() with an allowlist of fields, then returns fetch(request). The write is fire-and-forget, so the visitor never waits. Two things aren't obvious from the docs. Blobs have no names — blob1..blobN are positional, so the order in the writer is the schema; keep the read-out next to the writer and document the positions in both. And the dataset is sampled under load: a query must sum(_sample_interval) to get the real count, because count() returns stored rows, which is fewer.
The pattern.
[[analytics_engine_datasets]]
binding = "UTM"
dataset = "my_utm_landings"
// Blob allowlist — ORDER IS THE SCHEMA (the read-out reads by position):
// blob1 source, blob2 campaign, blob3 content, blob4 medium, blob5 path, blob6 country, blob7 ua_class.
// Never the full query string. No cookies, no identity, no PII.
export default {
async fetch(request, env) {
try {
const url = new URL(request.url), p = (k) => url.searchParams.get(k) || "";
if (p("utm_source")) env.UTM.writeDataPoint({
indexes: [p("utm_source").slice(0, 96)],
blobs: [p("utm_source"), p("utm_campaign"), p("utm_content"), p("utm_medium"),
url.pathname, request.cf?.country || "", uaClass(request)],
doubles: [1],
});
} catch {} // fail open: analytics must never break the site
return fetch(request);
},
};
-- POST as text/plain to /accounts/<id>/analytics_engine/sql with an "Account Analytics Read" token
SELECT blob1 AS source, blob2 AS campaign, blob7 AS ua_class,
sum(_sample_interval) AS hits, max(timestamp) AS last_hit
FROM my_utm_landings WHERE timestamp > now() - INTERVAL '30' DAY
GROUP BY source, campaign, ua_class ORDER BY hits DESC
The receipt. On the HarperFlow marketing zone, route live since 2026-08-16 (owner sign-off the same day): the Worker logs only utm_* landings on our own links, plus a count-only consent-choice beacon (POST /edge/consent-choice → 204) that made the cookie banner's accept rate measurable for the first time. Every row carries a ua_class (human, bot-ua, dc-asn — a UA regex plus a small datacenter-ASN set) because in an earlier outreach wave a datacenter scanner in Ashburn read as a warm lead. The Python read-out sums _sample_interval, groups by position and prints bot-classed hits separately; it is now the retargeting source of truth for cold-email waves (distinct utm_content where ua_class = human). Landing volumes are not recorded in the repo — unmeasured here.
Watch out.
- Ingestion lags about a minute; an empty read-out right after deploy is not a bug.
- More specific routes win: an existing
/ingest/*Worker keeps precedence over your new/*route. Verify both after enabling. - On a
workers.devhostnamefetch(request)recurses into yourself; return a stub in test mode.
Related: cache-api-poisoned-by-transient-error · worker-fetch-own-zone-error-1042