The analytics beacon the ad blocker ate | WaretaGarasuSkip to main content
WaretaGarasu
Language
Theme
Glass
All writingNote · Analytics

The analytics beacon the ad blocker ate

My /stats page showed zero visitors, including me, even with consent granted. The events were never leaving the browser, and the culprit was the one thing I had assumed was safe: the URL.

AnalyticsCloudflare WorkersAd blockersDebugging

The symptom

I built my own analytics: opt-in, cookieless, first-party, no third-party scripts, the whole privacy-friendly checklist. It worked in testing. Then the public /stats page kept showing nothing. Not low numbers. Zero. Active visitors, page views, the live map: all empty, most of the time.

The strange part was that it was not always empty. Some visits registered. And when I opened the network tab on my own machine, with consent granted, the collect request was sitting there in red: net::ERR_BLOCKED_BY_CLIENT. The browser was refusing to send it, and the server never heard a thing.

Why it happens

ERR_BLOCKED_BY_CLIENT is not a server error or a CORS problem. It means something inside the browser cancelled the request before it left, and on a normal page that something is an ad or privacy blocker. uBlock Origin, EasyPrivacy, Brave shields, and most of their kin do not inspect what you send. They match the URL.

My endpoint was /api/metrics/collect. Two of those path segments, metrics and collect, are on every tracker blocklist, because Google Analytics has used /collect for years. It did not matter that my analytics were first-party and harmless: the path looked like a tracker, so it was treated like one. That also explains the 'most of the time'. Visitors without a blocker still counted, which is exactly enough data to make the dashboard look alive sometimes and dead the rest.

The fix

The blockers match path tokens, so the fix is to stop using tokens they match. I moved the endpoints to an opaque, first-party path with no tracker words in it, and kept the keepalive fetch that lets the beacon outlive the page. The Worker accepts the old path as an alias too, so redeploying does not drop events from clients still running the old bundle.

// Before: the beacon path tripped every ad-blocker list.
const ENDPOINT = '/api/metrics/collect' // "metrics" + "collect" are blocked

// After: an opaque, first-party path with no tracker tokens.
const ENDPOINT = '/api/wg/c'

fetch(ENDPOINT, { method: 'POST', body: payload, keepalive: true })

// The Worker keeps the old path as an alias, so the cutover drops no events:
if (path === '/api/wg/c' || path === '/api/metrics/collect') {
  return handleAnalyticsEvent(request, env)
}

After deploying, the request left the browser with my blocker still on, and /stats started counting me. This is the same trick Plausible and Umami document for self-hosting: serve the script and the endpoint from a path that does not advertise what it is.

The takeaway

Privacy-friendly is not the same as unblockable. Blockers do not read your privacy policy; they read your URLs. If you run first-party analytics and the numbers look impossibly low, open the network tab before you touch the database: a request shown as blocked, not failed, means the call never happened. And keep tracker words like metrics, collect, analytics, track, and event out of the path, because the path is the only thing the blocker sees.