Implement Google Consent Mode v2 in Cookie Banner
- Dominant language
- Astro
- Stars
- 155
- Forks
- 177
- Avg merge
- 1d 20m
- Merged PRs (30d)
- 192
Description
## Context
We are launching Google Ads campaigns on kestra.io and need **Google Consent Mode v2** properly implemented. Currently, GTM does not load at all until the user accepts cookies in Europe, which means Google cannot use **conversion modeling** to estimate conversions from users who decline cookies — causing ~10-30% underreporting of conversions in European markets.
Internal spec / business source of truth: [Notion — Google Consent Mode v2 rollout](https://www.notion.so/34536907f7b581ffabbbd2139b5a010f).
## Current Implementation (source: `src/scripts/cookieconsent.ts`)
**Library:** `vanilla-cookieconsent` (open-source)
**Detection:** Europe detection via `Intl.DateTimeFormat().resolvedOptions().timeZone`
**Current behavior:**
- **Non-Europe users:** GTM + PostHog + marketing load immediately (no consent required) ✅
- **Europe users:** GTM is **not loaded at all** until user clicks "Accept". The `enabledAnalytics()` function injects the GTM script tag only after consent is granted via the `onConsent` callback.
**Two consent categories exist:**
- `analytics` → triggers `enabledAnalytics()` (loads GTM + PostHog)
- `marketing` → triggers `enabledMarketing()` (pushes `enable_marketing` event to dataLayer)
**The problem:** Google Consent Mode v2 requires GTM to be loaded **before** consent, with all consent parameters set to `denied`. This allows Google to send anonymous pings for conversion modeling. Currently, GTM is not present on the page at all before consent, so Google receives zero signals.
## Tag IDs
- **GTM container:** `GTM-T4F85WRF`
- **GA4 property:** `G-EYVNS03HHR`
- **Google Ads tag:** `AW-18075963910`
## Expected Behavior
The implementation should satisfy the following behavior. Implementation details are left to the developer — please refer to [Google's Consent Mode v2 documentation](https://developers.google.com/tag-platform/security/guides/consent) for the canonical patterns.
| Aspect | Current | Required |
|---|---|---|
| GTM loading (Europe) | Only after consent | Always loaded, with `denied` defaults |
| Consent signals to Google | None | `denied` by default → `granted` on accept |
| Conversion modeling | Not possible | Enabled (Google receives anonymous pings) |
Specifically:
1. **Europe users, before any interaction with the banner:** GTM is loaded, and the four Consent Mode v2 signals (`ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage`) are set to `denied` *before* GTM loads, with `wait_for_update` configured.
2. **Europe users, after accepting a category:** the relevant signals transition to `granted` via `gtag('consent', 'update', ...)`.
3. **Europe users, after declining or only partially accepting:** Google tags fire in cookieless ping mode for the still-`denied` signals (no client-side cookies, anonymous pings only).
4. **Europe users who later revoke consent** via the Settings modal: the relevant signals must transition back to `denied`. This is the edge case the developer should explicitly handle and test.
5. **Non-Europe users:** all four signals default to `granted`, behavior otherwise unchanged.
### Out of scope — do not regress
- **PostHog** initialization must continue to fire only after `analytics` consent (it is not a Google product and is not driven by Consent Mode v2 signals; just keep the existing trigger).
- **`enable_marketing`** dataLayer event must continue to fire only after `marketing` consent.
- No regression on existing analytics or marketing tracking for non-Europe users.
## Acceptance Criteria
- [ ] GTM Preview → **Consent tab** shows `ad_storage`, `analytics_storage`, `ad_user_data`, `ad_personalization`
- [ ] **Before** banner interaction (Europe): all four parameters show `denied`, and GTM is loaded on the page
- [ ] **After** clicking "Accept all" (Europe): parameters update to `granted` based on accepted categories
- [ ] **After** declining (Europe): parameters remain `denied`, Google tags fire in cookieless mode
- [ ] **After revoking consent via Settings** (Europe): parameters transition back to `denied`
- [ ] **Non-Europe users**: all four parameters default to `granted`
- [ ] PostHog still only initializes after analytics consent (unchanged)
- [ ] `enable_marketing` dataLayer event still fires only after marketing consent (unchanged)
## GTM Configuration Required (Marketing/SEO team — blocking)
The code change alone does **not** enable cookieless pinging. The following GTM configuration is required for the acceptance criteria to be met, and must be coordinated with this PR:
1. Each Google Ads tag's "Consent Settings" must require `ad_storage` and `ad_user_data`.
2. The GA4 tag's "Consent Settings" must require `analytics_storage`.
## Priority
**High** — Prerequisite for accurate Google Ads conversion reporting and full GDPR compliance. Blocking conversion modeling for European paid campaigns.
## References
- [Notion spec (business source of truth)](https://www.notion.so/34536907f7b581ffabbbd2139b5a010f)
- [Google Consent Mode v2 docs](https://developers.google.com/tag-platform/security/guides/consent)
- [vanilla-cookieconsent docs](https://cookieconsent.orestbida.com/)
- [GTM Consent Mode setup](https://support.google.com/tagmanager/answer/10718549)
Contributor guide
Assessment
This issue has not been assessed yet.