NOTE-006 · FIELD NOTE FILED 02 NOV 2026 · REVISED — F-101 F-602 F-201 F-205
How I set up tracking for a job board on Cloudflare Workers.
ABSTRACT — The build notes from SponsorJobs, a UK visa-sponsor job board running on Cloudflare Workers: Zaraz loaded by hand, GA4 and PostHog behind one consent banner, cookies kept out of the edge cache, a prefetch bug that inflated apply clicks by about half, bots labelled instead of blocked, and first-touch attribution that survives Safari.
SponsorJobs is a job board for people who need a UK visa sponsor. It is my own product, an Astro site where every response goes through a Cloudflare Worker, and its tracking is the one set-up I can show in full without redacting a client. Most of what went wrong was not a tag problem. It was the Worker, the edge cache and the router doing things a normal tag set-up never has to think about.
The stack: Cloudflare Zaraz as the loader, GA4 proxied through Zaraz, PostHog behind a same-origin proxy, one custom consent banner, and a first-party event table for the numbers that must not depend on consent or ad blockers. Here is what each piece needed, in the order it broke.
Zaraz does not inject itself on Worker pages
Zaraz has an “Auto-inject script” setting, on by default. It works on pages Cloudflare proxies from an origin. It does not run on responses a Worker builds, so the loader never reached the HTML. No Zaraz meant no GA4 and no PostHog, and nothing complained.
The fix is to load it by hand in the root layout, which Cloudflare documents. Three follow-ups came with it:
- Turn auto-inject off. With the manual tag in place and auto-inject still on, Zaraz loaded twice and every pageview counted twice. The manual tag is the single source now, because it is baked into the HTML and behaves the same on every cache hit.
- Guard against the router. Astro’s client router re-runs head scripts on each in-app navigation, so the loader ran again in the same window. A
windowflag that loads it once per page life fixed that. - Publish means publish. Toggling a tool on in the Zaraz dashboard only saves a draft. Nothing changes until you press Publish, and the version number in the served loader tells you whether it went out.
GA4 through Zaraz, PostHog beside it, one banner over both
GA4 runs through Zaraz, so there is no gtag or dataLayer on the page. If you audit the site with a browser extension, it looks like GA4 is missing. Check GA4 Realtime instead.
The consent banner is my own component, but Zaraz still does the enforcing. The banner is UI only: it calls Zaraz’s consent API, and Zaraz decides which tools may load. PostHog is assigned to the analytics purpose, so it does not load without a yes. GA4 runs Consent Mode v2 with everything denied by default and sends cookieless pings until the visitor answers.
The catch: Zaraz did not send the Consent Mode update on accept. The debug output showed the consent state still fully denied after a visitor accepted. The banner now sends the update itself next to the consent call, and the state flips to granted for analytics while the ad signals stay denied. This is the commonest way Consent Mode goes quietly wrong, filed under F-201 .
Zaraz’s consent tool reports no consent numbers anywhere in the dashboard, so the banner logs its own: consent_shown once per session, then consent_accept or consent_reject, into the first-party event table. The rate worth trusting is accepts over accepts plus rejects. Both need a human click. The shown count can carry bot noise.
A cookie in the cache is everyone’s cookie
Job pages are cached at the edge and served to anonymous visitors. One feature needs a per-visitor cookie: a counter for how many jobs an anonymous visitor has viewed. If that response goes into a shared cache with its Set-Cookie header attached, every later cache hit hands the same cookie to a different person. One visitor’s counter becomes everyone’s.
The fix splits the response just before caching. The copy stored in the cache has Set-Cookie removed, and the copy going back to this visitor keeps it. On a cache hit, the Worker builds the right cookie for that visitor and adds it to the cached body. If you cache HTML in a Worker and set any per-visitor state, this is the line to check first.
About one in three apply clicks was not a click
The main metric is apply_click: a signed-in visitor clicking through to an employer’s application page, logged server-side on a redirect route. From 17 September to 7 October 2026 it ran 45 to 49 percent above the truth.
The cause was the router again. Astro’s client router prefetches links on hover by default, and the apply links went through that redirect route. Every hover was a request the server logged as a click, and a real click after a hover was logged twice.
How it showed up: the apply links carry rel="noreferrer", so a real click arrives with no referrer. Prefetch requests did carry one. Pairing rows by user, job and time made the twins obvious: a row with a referrer, within 10 seconds of a row without one, for the same person and the same job.
| ROW TYPE | HOW IT WAS IDENTIFIED | SHARE OF ROWS |
|---|---|---|
| Real click | no referrer, or within 15s of signing in | 67% |
| Prefetch twin | referrer row within 10s of a real click, same user and job | 28% |
| Hover-only prefetch | referrer, no click after it | ~3% |
| Ambiguous | referrer, 15s to 5 min after sign-in | ~2% |
The inflation was flat week to week, so the trend line was right and the level was about 1.5 times too high. The fix was two attributes on every redirect link, one to turn prefetch off and one to force a full page load, plus a rule on the server: a request marked as a prefetch gets an empty response and is not logged. Older rows are corrected in reporting with the same referrer rule, not rewritten. Prefetch counting as a conversion is a cousin of F-101 , duplicate firing, and it is worth checking on any site that logs clicks through a redirect.
Label bots, never delete them
The basic bot flag is a user-agent check, and it misses automation that runs a full browser. In late October an audit found clusters of automated visits counted as human, including a pair of accounts making bursts of visits and apply clicks from a single network, cloud scanners and email link scanners.
The decision was to label them, not block or delete them. Each suspect visit and account gets one reason code, such as a burst of first visits from one network to one landing page, a wave of sessions with the same browser fingerprint in a few minutes, or a known scanner network. Nothing is removed and the label can be cleared. Reports now show three numbers side by side: raw, corrected (headline rules out) and strict (a shadow rule based on Cloudflare’s bot score also out). Whether strict becomes the headline is decided after about two weeks of comparison. Bots counted as people is F-602 , and deleting them destroys the evidence you need to tune the rules.
Visits and first-touch sign-up attribution
The last gap was the question “where did this sign-up come from?” The sign-up record held the referrer and UTMs of the page where the person signed up. UTMs disappear after the first navigation and the referrer becomes the site’s own page, so 3 of 10 recent sign-ups had no usable source. PostHog keeps first-touch data, but it only sees visitors who accept the banner, and about 76 percent of people never answer it.
So the site now records its own visits, one row per session, using GA4’s rules: a new session after 30 minutes idle, or when a new campaign arrives. A classifier sorts each visit into a channel, with explicit UTMs beating the referrer, and the raw fields are kept so the channel can be recomputed. A server-set cookie keeps the first-touch visit and the last few, and because the server writes it, Safari’s seven-day cap on script-written storage does not wipe it (the same reason a server-set cookie matters in Tag Gateway doesn’t fix Safari ITP). The cookie is only set on API routes, which are never edge-cached, so the cache leak above cannot happen here. At sign-up, the recent visits are linked to the new account, which stores first touch, the last non-direct touch and the number of visits it took.
It runs on an opt-out basis under the UK’s 2025 statistics exemption: disclosed, and switched off entirely when someone presses Reject. Visits that never connect to a sign-up are deleted after 26 months. Sessions that lose their source mid-journey are the job-board version of F-205 .
What I would tell anyone on Workers
Assume nothing about injection, consent updates or caching behaves the way a classic tag set-up does. Load the tag manager by hand, send the consent update yourself, strip Set-Cookie before anything goes into a shared cache, turn off prefetch on any link that logs an event, and label suspicious traffic instead of throwing it away. Keep one first-party record of the events that matter, so you have something to reconcile GA4 and PostHog against.
You can see the result live at SponsorJobs, the UK visa-sponsor job board this set-up runs on.