Dexio / how-we-build-dexio / site
Google Analytics with a consent banner only where it is required
How dexio.wiki and app.dexio.wiki measure visits and sign-ups. The site itself: astro-marketing-site. How its pages get found: agentic-seo-content-engine.
This is how we built it and why. It is not legal advice; check the rules that apply to you.
What it is
- One script,
consent.js, about 100 lines, shared by the site and the app. - A device whose time zone is in the EEA, the UK or Switzerland sees a banner, and nothing from Google loads until it accepts. Everyone else gets Google Analytics 4 as the page loads.
- The answer is a cookie on the parent domain for 180 days, so one answer covers the site and
the app. A Cookie settings link reopens the banner; declining deletes the
_gacookies. - Google sees only the page's origin and path, plus campaign tags (
utm_*,gclid,gbraid,wbraid). Other query strings are cut, and a referrer from our own domain is cut to its origin. - In the app, only the sign-in and sign-up pages load it, plus a one-time
/joinedhop that records asign_upevent. Wiki pages never load anything from Google, so a page's address, title or text never reaches it. - Google signals is off, and the Google Ads link has personalized advertising off.
sign_upis a key event, which Ads imports as its conversion.
Why it is built this way
- No consent vendor. A vendor adds a third-party script, a dashboard and a bill in order to show one banner to a minority of visitors.
- Time zone rather than IP location: no request, no lookup at the edge, decided before the tag loads, and a VPN does not change it. The cost we accepted: a visitor in Europe whose device is set to a US time zone is not asked.
- A tag on the whole app would send page titles and addresses through automatic page views, and search terms and file links through enhanced measurement. A wiki's pages are our customers' data, so analytics runs only on app pages that hold none.
- Trimming the address matters even on sign-in pages:
?next=names a wiki page, and reset and invite links carry tokens.
How to build it
-
Write the time-zone test:
var ASK = /^(Europe\/|Arctic\/Longyearbyen$|Atlantic\/(Azores|Canary|Faroe|Madeira|Reykjavik)$|Africa\/Ceuta$|Asia\/(Nicosia|Famagusta)$|America\/(Cayenne|Guadeloupe|Marigot|Martinique|St_Barthelemy)$|Indian\/(Mayotte|Reunion)$|(CET|EET|MET|WET|GB|GB-Eire|Eire|Iceland|Poland|Portugal)$)/; function mustAsk() { try { return ASK.test(Intl.DateTimeFormat().resolvedOptions().timeZone || ""); } catch (e) { return true; } // cannot tell, so ask }Europe/*alone misses parts of the EU: the Canaries, Madeira, the Azores, Ceuta, Cyprus, Réunion, Mayotte, French Guiana and the French Caribbean all have other time zone names. -
Trim what the tag reads, before it loads:
var KEEP = /^(utm_[a-z]+|gclid|gbraid|wbraid)$/; gtag("config", ID, { page_location: trimmedLocation(), page_referrer: trimmedReferrer() });trimmedLocationkeeps the origin, the path and theKEEPparameters.trimmedReferrerreturns just the origin when the referrer is your own domain. -
Write
load()andstop().load()setswindow["ga-disable-" + ID] = falseand injectsgtag.jsonce.stop()sets it to true and deletes_gaand_ga_<id>both on the host and on the parent domain. If the script fails to load (a blocker), fire an event, so a page that waits on analytics moves on at once. -
Wire the banner with data attributes, so any page can carry it: buttons with
data-consent="granted"ordata-consent="denied"inside#consent, and any link withdata-consent-opento reopen it. Give Accept and Decline the same button style. -
Include the script in production builds only (in Astro, behind
import.meta.env.PROD), so development never sends hits. -
Record sign-ups with a hop. After a new account, redirect through
/joined, which sendssign_upwith the method (password, Google or GitHub) and moves on: at most 1.5 seconds, and at once if the tag failed or consent was declined. Skip it for invited teammates, who are not acquisition. -
In GA4, turn Google signals off, link Google Ads with personalized advertising off, mark
sign_upas a key event and remove the value GA fills in for it, so conversions carry no made-up money. In Ads, make the imported sign-up the primary conversion. -
Say all of this in the privacy policy, and change the policy whenever the tag changes.
Verify
In headless Chromium with Google's requests intercepted:
- Time zone
America/Los_Angeles: no banner, hits sent. Europe/Berlin,Europe/London,Europe/Madrid,Europe/Dublin: banner, nothing sent until Accept.- Decline on the site, then open the app: still declined.
- Open a sign-in link carrying
?next=: the hit's page location has nonext. - Open a wiki page in the app: no request to Google at all.
Pitfalls
- Automatic page views and enhanced measurement send more than the page path. Decide page by page where the tag runs.
- Deleting
_gaonly on the host leaves the parent-domain cookie behind. - A Content-Security-Policy that blocks Google's script makes the tag fail silently; that is what the failure event is for.
- GA pre-fills a value on a new key event. Remove it, or every sign-up reports revenue.