Skip to content

feat: iubenda CMP with TCF consent for ads tracking - #6382

Open
pnvasanth wants to merge 11 commits into
mainfrom
feat/iubenda-cmp-tcf-consent
Open

feat: iubenda CMP with TCF consent for ads tracking#6382
pnvasanth wants to merge 11 commits into
mainfrom
feat/iubenda-cmp-tcf-consent

Conversation

@pnvasanth

@pnvasanth pnvasanth commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Changes

Adds the iubenda Cookie Solution (IAB TCF v2.2) as CMP for GDPR-covered users, behind the new iubenda_cmp GrowthBook flag (default off). Companion PRs: dailydotdev/skadi#352 (deploy first), dailydotdev/daily-api#4027.

  • Iubenda.tsx (webapp only — extension CSP blocks CMP scripts): loads stub → safe-tcf → core in order for GDPR-covered users, mirrors iubenda purposes into the existing ilikecookies* cookies (incl. a new withdrawal path in useCookieConsent that updates the react-query cache), suppresses the homegrown banner for those users.
  • lib/tcf.ts: store over __tcfapi exposing {gdprApplies, tcString, addtlConsent}; mirrors the TC string to localStorage so the next boot sends it as an x-gdpr-consent header (lib/boot.ts).
  • Ad consent fixes: resolveAdConsent previously abused gdpr=1 to mean "not consented" — now gdprApplies states scope and the TC string carries consent; useAdMacroContext recomputes on consent change. The existing ${GDPR_CONSENT_755}/${ADDTL_CONSENT} macros now populate (pixels, measurement tags, click URLs, incl. the extension measurement frame contract).
  • Ad requests: all three ad endpoints carry gdpr/gdpr_consent/addtl_consent; consent is part of the ad queryKey so ads refetch after the banner is answered.
  • Settings → privacy opens iubenda's preferences UI instead of the custom modal when the flag is on.

Behavior changes: everything is gated on iubenda_cmp. Flag off (or extension) = byte-identical pre-PR behavior — no CMP script, homegrown banner, legacy hasMarketingConsent() macro semantics, no consent params on ad requests, no consent_id on log events (the feed test mocks are unchanged from main, which proves the parity). Flag on = TCF banner, gdpr/gdpr_consent/addtl_consent on ad requests, boot consent header, consent_id on events. The single flag-independent change is a bug fix: withdrawing marketing consent in settings now takes effect immediately instead of after a reload.

Rollout checklist

Values pulled from the live daily.dev homepage's iubenda embed (same site/policy so consent is shared across *.daily.dev):

  • Vercel (webapp project): NEXT_PUBLIC_IUBENDA_SITE_ID=1334205 ✓ verified inlined in the preview build
  • Vercel (webapp project): NEXT_PUBLIC_IUBENDA_POLICY_ID=14695236 ✓ verified inlined in the preview build
  • Local dev: add both to packages/webapp/.env (edit-protected, not included in this PR)
  • TCF is functionally active via the embed config (enableTcf: true) — verified end-to-end on the preview: TCF banner renders, Accept produces a valid TC string (865 chars) + Google ACM string, ads refetch with gdpr_consent/addtl_consent, prod skadi accepts them (200)
  • iubenda dashboard: curate the TCF vendor list — the banner currently discloses the full Global Vendor List (~1196 vendors); restrict to actual partners to shrink the disclosure, the consent strings, and the legal surface. Also confirm the dashboard TCF integration status matches intent and add publisher restrictions if needed
  • GrowthBook: iubenda_cmp feature created (default false), force-enabled for the test account ✓ verified evaluating on preview
  • Redeploy after adding the vars ✓
  • Marketing site: add localConsentDomain: 'daily.dev' to the homepage embed so homepage consent is visible to app.daily.dev (today its cookie is host-only; webapp→homepage sharing already works)
  • Ramp iubenda_cmp flag 0% → EU-targeted (GB geo attributes available from boot) → consider tightening outsideGdpr before 100%
  • Post-100% cleanup (follow-up PR): port consumers (Pixels, settings, embeds) to read CMP state directly, then delete the ilikecookies* mirror, the hasMarketingConsent() compatibility layer, and the homegrown banner

Events

New shared property on all log events: consent_id — the iubenda consent-record correlator (cons.rand from the _iub_cs-* cookie), unset when no iubenda consent exists. Needs coordination with the analytics pipeline.

Preview domain

https://feat-iubenda-cmp-tcf-consent.preview.app.daily.dev

Load the iubenda Cookie Solution (TCF mode) for GDPR-covered webapp
users behind the iubenda_cmp flag, mirror its consent into the existing
ilikecookies* cookies, populate the ad tracker consent macros from
__tcfapi, send gdpr/gdpr_consent/addtl_consent on ad requests, attach
the iubenda consent id to analytics events, and forward the stored TC
string to boot via the x-gdpr-consent header.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
daily-webapp Ready Ready Preview Jul 30, 2026 10:51am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Jul 30, 2026 10:51am

Request Review

Comment thread packages/webapp/components/Iubenda.tsx
Comment on lines +88 to +104
lang: 'en',
enableTcf: true,
googleAdditionalConsentMode: true,
perPurposeConsent: true,
askConsentAtCookiePolicyUpdate: true,
invalidateConsentWithoutLog: true,
// consent cookie shared across *.daily.dev, matching the existing
// `_iub_cs-*` bridge from the marketing homepage
localConsentDomain: process.env.NEXT_PUBLIC_DOMAIN,
floatingPreferencesButtonDisplay: false,
banner: {
position: 'float-bottom-center',
acceptButtonDisplay: true,
rejectButtonDisplay: true,
customizeButtonDisplay: true,
closeButtonDisplay: false,
listPurposes: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to make sure it matches with our homepage and marketing site configuration as well, since there we use iubenda already

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — compared key-by-key against the homepage's live embed and aligned in 0812c3b: added countryDetection, enableLgpd, enableUspr, cookiePolicyInOtherWindow to match. Deliberate differences kept: enableTcf + Google ACM (the point of this PR) and no floating preferences button (settings/privacy is the re-entry point — though note logged-out users currently have no consent re-entry on the webapp, open to enabling the floating button if we care about that).

One finding from the comparison: the homepage embed does not set localConsentDomain, so its consent cookie is host-only on daily.dev and isn't visible to app.daily.dev — meaning the existing homepage→webapp consent bridge likely never worked cross-subdomain. Our side sets localConsentDomain so webapp consent IS visible to the homepage; for the reverse direction the marketing site should add localConsentDomain: 'daily.dev' to its embed (separate change, not this repo).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no app.daily.dev, it worked with getIubendaConsent from 9171dd8

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction — I wrongly assumed an app. subdomain; since the webapp and homepage share the daily.dev host, the host-only cookie was always readable and the bridge works as that commit intended. Keeping localConsentDomain here anyway so the consent cookie also covers subdomains (e.g. *.preview.app.daily.dev), same scoping our ilikecookies* cookies already use — shout if you'd rather drop it.

startTcfSubscription ran synchronously after appending the script tags,
before the stub executed, so __tcfapi was undefined and the subscription
no-oped — the TC string never reached the store or localStorage.
countryDetection, LGPD/USPR and cookiePolicyInOtherWindow now match the
marketing site so both surfaces classify users the same way.
* the actual consent. In scope without a TC string, vendors must treat the
* request as not-consented.
*/
export const hasMarketingConsent = (): boolean => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this removed, it was added as compatibility layer between our consent (when we did not have iubenda), so it should stay until we deploy it to everyone without feature flag

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — restored in d063964. resolveAdConsent now only uses TCF semantics when actual TCF data exists (CMP loaded and answered); otherwise it falls back to the exact pre-PR behavior via hasMarketingConsent() (first-party marketing cookie or extension → gdpr=0). So flag-off users and the extension keep their current measurement untouched during the transition, and the compatibility layer can be deleted once the flag is rolled out to everyone.

resolveAdConsent falls back to the pre-TCF behavior (first-party
marketing cookie / extension => gdpr=0) whenever no TCF data exists, so
flag-off users keep their current measurement during the transition.
Comment thread packages/shared/src/lib/tcf.ts Outdated

export const getStoredTcString = (): string | undefined => {
try {
return globalThis?.localStorage?.getItem(TCF_STORAGE_KEY) || undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this localStorage overhead, seems like something that can get out of sync, ideally tcf somehow handles it and exposes value outside of listener?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, dropped in 746ffd9. The CMP already persists the TC string itself in the IAB-conventional euconsent-v2 cookie, so getStoredTcString now reads that instead of a localStorage mirror — single source of truth owned by iubenda, nothing to drift. (Some persisted read is unavoidable for boot: the request fires before the CMP script loads, so the live __tcfapi can never answer in time; the in-memory snapshot stays for the reactive in-session consumers.)

},
};

IUBENDA_SCRIPTS.forEach((src, index) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a <Script /> next.js component as well that Iubenda can use to render instead of manual work, potentially better

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considered it — manual injection is deliberate here: the three scripts must execute in order (stub → safe-tcf → core), and dynamically-inserted scripts with async = false are the spec-guaranteed way to get ordered execution with parallel downloads. next/script afterInteractive doesn't guarantee cross-script execution order, and chaining via onLoad would serialize the three downloads (slower waterfall). Happy to switch if you feel strongly, but we'd be trading a spec guarantee for component sugar.

@capJavert capJavert Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, but seems weird this execution order is needed, is it from iubenda docs?

onPreferenceRef.current = (pref) => {
const marketing = pref?.purposes?.['5'] === true;
saveCookies(
marketing ? otherGdprConsents : [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think once we move to iubenda we could just ignore our current cookies no? two integrations do not need to cross write anymore?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long-term yes — but not while the CMP is EU-only. Non-EU users never load iubenda (flag + geo gated), so ilikecookies* stays the source of truth for them, and every consumer (Pixels consent mode, settings toggles, YouTube embeds, the adConsent fallback you asked to keep) reads those cookies. The mirror keeps one canonical downstream representation so none of those consumers need to care which banner produced the consent. Once/if iubenda goes global we can port consumers to read the CMP state directly and delete both the mirror and the homegrown banner in one sweep — added it as a follow-up note.

@capJavert capJavert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pnvasanth left some comments, but either way I am fine with deploying iubenda just we must ensure everything related to it happens only if feature flag is on, and when off site works as it worked before.

Drop the localStorage mirror; the CMP already persists the TC string in
the IAB-conventional euconsent-v2 cookie, so getStoredTcString reads
that instead — single source of truth, nothing to drift.
With iubenda_cmp off, ad requests and log events are now byte-identical
to pre-CMP behavior: no gdpr/gdpr_consent params, no consent_id. The
feed test mocks revert to their original main-branch form, proving the
parity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants