mParticle Paid Media Integration#15581
Conversation
…bles for triggers and actions
…r handling in documentation
…endorName cast, and add signalStatus to consent state
|
Hello 👋! When you're ready to run Chromatic, please apply the You will need to reapply the label each time you want to run Chromatic. |
…d versions, and update aws-cdk/cloud-assembly-schema
|
Thanks Andre, I've reviewed the description so far and it sounds great. A couple of questions/points:
|
Good catch on both points, John. 1. Cookies → browser storage You're right, these two values are purely client-side deduplication state and have no business being sent to the server on every request. We'll switch:
Both are already available via 2. Storage blocked/unavailable The The behavioural consequence when storage is blocked is that:
So affected users would trigger a PATCH on every page load rather than once. However, I believe that the population with storage fully blocked is very small, the mParticle endpoint is idempotent, and this is the same failure mode you'd have with cookies if cookies were blocked too, so it shouldn't cause any meaningful increase in traffic or issues. No special handling is needed for this case. |
…age/sessionStorage for fingerprint management
| import( | ||
| /* webpackMode: 'eager' */ './mparticle/mparticle-consent' | ||
| ).then(({ syncMparticleConsent }) => syncMparticleConsent()), | ||
| { priority: 'critical' }, |
There was a problem hiding this comment.
Why does this module need to load with critical priority? The module's logic seems to be triggered after other asynchronous tasks have been completed, so can it be lazy-loaded after user-critical chunks?
|
@andresilva-guardian thanks for looking into the cookie vs local storage issue. Having researched a bit more, and looked into how sourcepoint itself works, it looks like
Sourcepoint works by storing the main information in localstorage and also on the server, then using cookies to share the UUID and last updated between domains, as a kind of cache-validity check. Does that affect your decision about what to use? There's a tradeoff between traffic on every request vs traffic as people switch between domains. |
|
regarding storage being blocked, I'm not sure it makes sense what you say, it was more a point for consideration rather than something that needed a quick reassurance.
I realise that, it's more like the amount of potential traffic that it could receive - given that the ratio of page views to consent changes is very high, only a small group of browsers could have a big impact. If it's a risk then we may need a mitigation on the backend.
That would only make sense if we already had a cookie based solution in place and working. My question was intended to cover either case. |
What does this change?
Adds a new client-side module
src/client/mparticle/that syncs a user's GDPR consent state to the mParticle backend. The sync fires for all users (anonymous and signed-in) whenever their consent state or auth state has changed since the last successful call. When the user is signed in, a Bearer token is also attached so the backend can immediately link the record to the user'sidentity_id; for anonymous users the call is made without auth and the backend records consent againstbwidfor later identity resolution.New files:
src/client/mparticle/mparticle-consent.tsonConsentChangecallback; runs all guard checks before calling the APIsrc/client/mparticle/mparticleConsentApi.tsPATCH /consents/{browserId}call; attaches Bearer token only for signed-in userssrc/client/mparticle/cookies/mparticleConsentSynced.tsgu.mparticle.lastSynced(localStorage — fingerprint of last successful PATCH) andgu.mparticle.sessionAttempted(sessionStorage — session-scoped retry cap)src/client/mparticle/mparticle-consent.test.tsModified files:
src/client/main.web.tsstartup('mparticleConsentSync', …)entrysrc/model/guardian.tsmparticleApiUrl?: stringinconfig.pagefixtures/config.jsmparticleApiUrlfor local developmentAlso bumps
@guardian/libs30.1.1→31.0.0. This release addsmparticleto theVendorIDsregistry (csnx PR #2347), makinggetConsentFor('mparticle', state)runtime-safe.Why?
MRR (Marketing Reader Revenue) want to connect mParticle to Meta (Facebook Ads) and Google Ads audiences. To legally send user data to those platforms, mParticle must hold a current record of each user's GDPR consent state. The browser is the source of truth for consent (via Sourcepoint / our CMP), so dotcom-rendering is responsible for forwarding that state to the backend when it changes.
The call is made for all users, not just signed-in ones, because the backend uses the
bwidbrowser ID for identity resolution in the data lake: anonymous consent records are linked to anidentity_idovernight once the user signs in or is resolved. If a signed-in user's consent state is already stored as"anonymous:false"and they sign in, the fingerprint changes to"signed-in:false"and the call fires again — this time with a Bearer token — so the backend can immediately associate the record with their identity without waiting for overnight resolution.How the new module fits into the startup pipeline
The new startup task runs concurrently with
bootCmpanduserFeatures, all atcriticalpriority. BecauseonConsentChangeonly fires aftercmp.init()resolves insidebootCmp, the mParticle callback always receives a real consent state, regardless of startup ordering.flowchart TD A["Browser loads page\nwindow.guardian.config is set"] --> B["main.web.ts - startup() × N"] B --> C["bootCmp - priority: critical"] B --> D["userFeatures - priority: critical"] B --> E["abTesting / sentryLoader / islands / …\npriority: critical"] B --> NEW["🆕 mparticleConsentSync - priority: critical\nswitch-gated - off by default"]The new flow in detail
flowchart TD MW["main.web.ts\nif switches.mparticleConsentSync\nstartup('mparticleConsentSync')"] --> SYNC["mparticle-consent.ts\nsyncMparticleConsent()"] SYNC --> REG["onConsentChange(callback)\nfires immediately on page load\nand again on any privacy-modal change"] REG --> CB["callback(state) fires"] CB --> BWID{"getCookie('bwid')"} BWID -- "No bwid cookie" --> NOOP["↩ return - no API call"] BWID -- "Has bwid" --> AUTH["getAuthStatus()\n→ SignedIn | SignedOut"] AUTH --> FP["buildFingerprint(consented, isSignedIn)\ne.g. 'signed-in:true' or 'anonymous:false'"] FP --> STALE{"mparticleConsentNeedsSync()?\ncompare fingerprint vs\ngu.mparticle.lastSynced (localStorage)"} STALE -- "Fingerprint matches - skip" --> NOOP2["↩ return - no API call"] STALE -- "Fingerprint differs" --> SESSION{"sessionAttemptExists()?\ngu.mparticle.sessionAttempted (sessionStorage)"} SESSION -- "Already attempted this session - skip" --> NOOP3["↩ return - no API call"] SESSION -- "No attempt yet" --> ATTEMPT["markSessionAttempt(fingerprint)\nsessionStorage - cleared on tab close"] ATTEMPT --> API["PATCH mparticle-consent.guardianapis.com\n/consents/{browserId}\n{ consented, pageViewId }\nAuthorization: Bearer … (signed-in only)"] API -- "200 OK" --> MARK["markMparticleConsentSynced(consented, isSignedIn)\nsets gu.mparticle.lastSynced in localStorage (persistent)"] API -- "error" --> ERR["throw Error → surfaces in Sentry\ngu.mparticle.lastSynced NOT updated\n→ will retry next session"]Rate-limiting approach
Instead of a time-based TTL, the module uses a consent-state fingerprint. After a successful PATCH, the fingerprint is persisted to
localStorage(keygu.mparticle.lastSynced) as a string encoding both the consent value and auth state — e.g."anonymous:false"or"signed-in:true". On eachonConsentChangecallback, the current fingerprint is computed and compared. If it matches, no call is made.A second entry in
sessionStorage(keygu.mparticle.sessionAttempted) records that a PATCH was attempted for the current fingerprint in this browser session. If the API call fails,gu.mparticle.lastSyncedis not updated (so the state still looks unsynced) but the sessionStorage entry prevents a retry on every subsequent page load within the same tab. The retry happens on the next browser session.Using
localStorage/sessionStoragerather than cookies means these values are never sent to the server on HTTP requests. Both are written viastorage.local/storage.sessionfrom@guardian/libs, which handle blocked storage gracefully (returningnullrather than throwing).This means:
"anonymous:false"→"signed-in:false"→ fires with Bearer tokenThis PR is safe to merge before the Scala and backend changes are ready
The entire feature is gated behind
window.guardian.config.switches.mparticleConsentSync:The
Switchesinterface uses an open index signature ([key: string]: boolean | undefined). The Scalafrontendrepo does not emitmparticleConsentSync, so the value isundefinedat runtime, theifblock is never entered, no module is imported, and no API call is ever made.The remaining work (adding the switch in Scala
frontend, injectingmparticleApiUrlwith the correct per-environment URL, and deploying the backend endpoint tomparticle-consent.guardianapis.com) is tracked in docs/mparticle-work-tracking.md.Design doc
Full architecture, sequence diagrams, mParticle identity model, fingerprint storage rationale, and manual testing guide: docs/mparticle-paid-media-integration.md.
Screenshots
No visual changes — this is a backend API integration. The only observable frontend artefacts are a network request and two browser storage entries (
localStorageandsessionStorage), visible in DevTools → Application once the switch is enabled.PATCH /consents/{bwid}fires for all users when their consent/auth state has genuinely changed (when switch is enabled)