Skip to content
Development

Analytics that asks first: building an opt-in-only tracking library

By Victor Da Luz
astroprivacyanalyticsdev-logastro-tools

I want to know if anyone reads my sites. I also believe tracking should be opt-in, even when the law says it doesn’t have to be. Self-hosted Umami is cookieless and banner-exempt, so I could legally just add the snippet and move on. I didn’t want to. If the visitor never says yes, my analytics script has no business on their page.

So I built a small library that makes the ethical default the easy default: astro-opt-in-analytics. The tracker script doesn’t exist until you consent. Not disabled. Not paused. Absent.

The tracker that was never installed

The research phase turned up something funny. I’d assumed vdaluz.com was already tracked, because I deployed an Umami server for exactly that purpose months ago. The site’s repo had zero Umami references. The live HTML confirmed it: four script tags, one of them JSON-LD schema, none of them a tracker. I had deployed the analytics server and then never added the snippet to the site. The ticket to do that didn’t even exist.

That accident became an advantage. Nothing to migrate, no opt-out users to convert. Both sites get opt-in tracking from their first tracked visitor.

Klaro and vanilla-cookieconsent are good open-source consent managers, and I looked at both seriously. They solve a different problem: multi-vendor cookie compliance with per-service granularity, legal-framework framing, and their own modal UI. My problem is one cookieless tracker and one binary question. Wrapping a CMP would have cost more integration code than the whole gate costs to write. The library came out to about 470 lines including both components.

The design

Two Astro components and a small runtime. ConsentGate reads a versioned record from localStorage: a grant injects the tracker script tag, anything else means the tag never exists. ConsentPrompt is a fixed toast that shows only when there’s no decision. The answer is stored as { v, decision, at }; bump the version in config when tracking scope changes and old decisions re-prompt.

Two decisions I feel strongly about.

Global Privacy Control is an answer. If navigator.globalPrivacyControl is true, the visitor already said no through their browser. The prompt never shows. Asking someone to reconsider a preference they’ve configured globally is itself a dark pattern.

The anti-dark-pattern rules are hard-coded. Equal-weight buttons from a single CSS rule. Decline first in DOM order. Esc means no. Dismissal means no. No overlay, no scroll lock. None of it is configurable. If you want a consent prompt where “accept” glows and “reject” hides in a settings submenu, you’ll have to fork, and the README will judge you.

No Playwright on this machine, and consent flows are exactly the kind of thing that needs multi-step verification: decide, restart the browser, check the decision stuck. The trick that made it work: --user-data-dir persists localStorage across headless invocations. Each test “user” is a profile directory. The test page drives itself, reading a ?e2e=accept query param, clicking its own buttons, and stamping the observable state into the DOM as JSON for --dump-dom to capture.

Faking the GPC signal turned out to be easy too. An inline head script runs before any bundled module, so Object.defineProperty(navigator, 'globalPrivacyControl', ...) behind a query param does it with no extension.

Seven flows, all green: fresh visitor sees the prompt and zero tracker execution; accept injects and runs the script immediately with no reload; the grant survives a browser restart; decline loads nothing, forever; GPC means no prompt and no tracker; the footer link reopens the prompt after a denial; Esc counts as no. The fake tracker fixture sets a window flag so the tests assert the script actually executed, not just that a tag existed.

One gotcha for the notes: headless Chrome prints the DOM and then hangs around, holding the profile lock and failing the next run with SingletonLock: File exists. The fix is --timeout plus killing leftovers between runs, and if you kill by pattern from a compound shell command, pkill -f "profile[-]a" so the pattern doesn’t match its own command line.

Shipped

v0.1.0 is tagged and public, MIT with an actual LICENSE file from day one (the blog package shipped without one for months; lesson absorbed). Both sites consume it next as pinned tarball dependencies. The pitch fits in a sentence: zero requests until yes.

Related reading