Skip to content

Implement Google Consent Mode v2 in Cookie Banner #4627

@vfanucci

Description

@vfanucci

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.

Current Implementation (source: cookie-consent.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.

Technical Details

  • GTM container: GTM-T4F85WRF
  • GA4 property: G-EYVNS03HHR
  • Google Ads tag: AW-18075963910
  • File to modify: cookie-consent.ts (or equivalent in the Astro project)

Required Changes

Step 1: Always load GTM (even before consent)

Move the GTM script injection outside of enabledAnalytics() so it loads on every page for European users too. But before loading GTM, set the consent defaults to denied.

Before GTM loads (for European users):

// Set consent defaults BEFORE loading GTM
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
  'ad_storage': 'denied',
  'ad_user_data': 'denied',
  'ad_personalization': 'denied',
  'analytics_storage': 'denied',
  'wait_for_update': 500
});

// Now load GTM
window.dataLayer.push({
  'gtm.start': new Date().getTime(),
  event: 'gtm.js'
});
const s = document.createElement('script');
s.async = true;
s.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`;
document.head.appendChild(s);

Step 2: Update consent when user accepts

In the onConsent callback, after checking categories, push the consent update:

onConsent: ({ cookie }) => {
  let consentCategories = cookie.categories;

  if (consentCategories.includes('analytics')) {
    gtag('consent', 'update', {
      'analytics_storage': 'granted'
    });
    // Keep existing PostHog init logic here
    // But REMOVE the GTM loading code (it's already loaded in Step 1)
  }

  if (consentCategories.includes('marketing')) {
    gtag('consent', 'update', {
      'ad_storage': 'granted',
      'ad_user_data': 'granted',
      'ad_personalization': 'granted'
    });
    enabledMarketing(); // Keep existing behavior
  }
}

Step 3: Handle non-Europe users

For non-European users, set consent to granted by default (before GTM loads):

if (!isEurope) {
  gtag('consent', 'default', {
    'ad_storage': 'granted',
    'ad_user_data': 'granted',
    'ad_personalization': 'granted',
    'analytics_storage': 'granted'
  });
  // Then load GTM + PostHog + marketing as before
}

Step 4: Handle decline / only partial consent

  • If user only accepts analytics: analytics_storagegranted, ad params remain denied
  • If user rejects all: all parameters remain denied, Google tags fire in cookieless ping mode

Suggested Refactored Structure

window.addEventListener('DOMContentLoaded', () => {
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}

  if (!isEurope) {
    // Non-Europe: grant all, load everything
    gtag('consent', 'default', {
      'ad_storage': 'granted',
      'ad_user_data': 'granted',
      'ad_personalization': 'granted',
      'analytics_storage': 'granted'
    });
    loadGTM();
    initPostHog();
    enabledMarketing();
    return;
  }

  // Europe: deny all by default, load GTM anyway
  gtag('consent', 'default', {
    'ad_storage': 'denied',
    'ad_user_data': 'denied',
    'ad_personalization': 'denied',
    'analytics_storage': 'denied',
    'wait_for_update': 500
  });
  loadGTM(); // GTM loads but tags wait for consent

  // Show cookie banner
  cookieConsent.run({
    mode: 'opt-in',
    // ... existing config ...
    onConsent: ({ cookie }) => {
      if (cookie.categories.includes('analytics')) {
        gtag('consent', 'update', {
          'analytics_storage': 'granted'
        });
        initPostHog(); // PostHog only after analytics consent
      }
      if (cookie.categories.includes('marketing')) {
        gtag('consent', 'update', {
          'ad_storage': 'granted',
          'ad_user_data': 'granted',
          'ad_personalization': 'granted'
        });
        enabledMarketing();
      }
    },
    // ... rest of existing config ...
  });
});

Key Architectural Change

Aspect Current Required
GTM loading (Europe) Only after consent Always (with denied defaults)
Consent signals to Google None denied by default → granted on accept
PostHog loading After analytics consent After analytics consent (unchanged)
Marketing dataLayer push After marketing consent After marketing consent (unchanged)
Conversion modeling Not possible Enabled (Google receives anonymous pings)

Acceptance Criteria

  • GTM Preview → Consent tab shows ad_storage, analytics_storage, ad_user_data, ad_personalization
  • Before banner interaction: all four parameters show denied (Europe users)
  • After clicking "Accept": parameters update to granted based on accepted categories
  • After declining: parameters remain denied but Google tags still fire in cookieless mode
  • Non-Europe users: all parameters default to granted
  • PostHog still only initializes after analytics consent (unchanged behavior)
  • enable_marketing dataLayer event still fires only after marketing consent (unchanged behavior)
  • No regression on existing analytics or marketing tracking

GTM Configuration Required After This Change

Once the consent signals are implemented in the code, the following GTM changes are needed (to be done by SEO/Marketing team):

  1. Each Google Ads tag needs its "Consent Settings" configured to require ad_storage and ad_user_data
  2. The GA4 tag needs its "Consent Settings" configured to require analytics_storage

Priority

High — Prerequisite for accurate Google Ads conversion reporting and full GDPR compliance. Blocking conversion modeling for European paid campaigns.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/frontendNeeds frontend code changes

    Type

    No fields configured for Task.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions