feat(ads): third-party measurement tags (CM360 + DoubleVerify)#6311
feat(ads): third-party measurement tags (CM360 + DoubleVerify)#6311idoshamun wants to merge 4 commits into
Conversation
Implements client rendering for the ad.tags measurement contract across both surfaces, reusing one macro/injection core so the paths never drift: - adMacros: pure substituteMacros for cachebuster + TCF/GPP consent tokens, everything else passes through verbatim - adConsent + useAdMacroContext: best-effort consent read via the standard __tcfapi/__gpp APIs with a geo fallback and safe empty default - measurementTags: shared DOM injection (macro fill + script re-exec, preserving attributionsrc), used by both the web inline path and the frame - AdPixel: fills macros before firing image impressions - AdMeasurement: surface-aware renderer mounted on all ad card variants — inline on web, a single web-origin iframe on the extension (MV3 CSP). Overlay (viewability) covers the card; impression-only stays 0x0 hidden. Frame pre-warms ~300px before view; non-interactive so the native card keeps clicks - webapp /mf: fast, bare frame page (excluded from the app shell) that runs the tags the extension CSP forbids; framing locked to extension origins via frame-ancestors; URL carries no 'ad' token to dodge blockers Note: no TCF/GPP CMP is registered today, so EU (gdpr=1) traffic that requires a TC string will not be measured until a CMP lands.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The extension now posts only raw data (tags, theme, geo hint); the frame reads consent and substitutes macros itself. This keeps the entire macro pipeline deployable with the webapp, so bugs are fixed without waiting for extension-store adoption, and lets the daily.dev-origin frame read first-party consent the extension origin can't. Also drops the parent-side consent wait, so the frame inits as soon as it reports ready.
|
|
||
| // Pre-warm ~300px before the card enters view so measurement is ready the | ||
| // moment it becomes visible, without loading frames for far-off ads. | ||
| const { ref, inView } = useInView({ |
There was a problem hiding this comment.
Do we really need that or can we just render the measurement? Anyway the measurement probably has some logic to wait for visibility
There was a problem hiding this comment.
Only the viewability logic waits for visibility — impression tags fire the moment they're injected. Without the in-view gate we'd count impressions for ads that were never scrolled anywhere near the viewport (and on the extension we'd mount an iframe per ad up front). Kept the gate and added a comment in the code explaining exactly that.
| const { tags } = ad; | ||
| const hasTags = !!tags?.length; | ||
| const overlay = useMemo(() => tagsRequireOverlay(tags), [tags]); | ||
| const theme: MeasurementTheme = useIsLightTheme() ? 'light' : 'dark'; |
There was a problem hiding this comment.
We don't — leftover from an earlier iteration where the frame rendered visible UI. Removed it from the message contract entirely.
| ) : ( | ||
| <PostTag {...postCardProps}> | ||
| {item.type === FeedItemType.Ad && <AdPixel pixel={item.ad.pixel} />} | ||
| {item.type === FeedItemType.Ad && ( |
There was a problem hiding this comment.
What is this? Not sure I understand why we have pixel and measurement only there
There was a problem hiding this comment.
This is the boosted-post path: item.type === Ad but the ad wraps a real post, so it renders through the regular post card (PostTag) instead of AdGrid/AdList — the trackers have to be injected as children here. Plain ads (case FeedItemType.Ad) get them inside the ad card components themselves. The pixel already worked this way; measurement just follows the same placement.
We don't run an IAB TCF/GPP CMP, so there's no TC string to fill the vendor consent macro. Instead, gate on the gdpr flag from the user's own cookie choice: if they've accepted marketing cookies (or are outside GDPR scope), signal gdpr=0 and measurement proceeds; an in-scope user who hasn't consented stays gdpr=1 with no string (not measured). Mirrors the app's existing consent model where the extension is treated as consented. Replaces the speculative __tcfapi/__gpp reader with a synchronous first-party resolver. The parent computes the boolean decision from app consent state and passes it to the frame; macro string substitution still runs in the frame.
| * the URL. Framing is locked to our extension origins via `frame-ancestors`. | ||
| */ | ||
|
|
||
| export const getServerSideProps: GetServerSideProps = async ({ res }) => { |
There was a problem hiding this comment.
Move this page under the /embeds family we already have
There was a problem hiding this comment.
Done — moved to pages/embed/mf.tsx (/embed/mf), same frame-ancestors pattern as embed/youtube.
- accept frame init messages only from the actual framing parent - match ready messages to each card's own iframe so multiple ads don't race - memoize pixel macro substitution so re-renders don't refire impressions - move the frame page under the embed family (/embed/mf), drop unused theme - extension id fallbacks + cache header on the frame page
Implements the client for the
ad.tagsmeasurement contract (merged in #6310) across both surfaces. One macro + injection core is shared by every path so behavior never drifts.How it works
pixel[]image impressions<img>native (macros filled)<img>native (macros filled)tags[], impression-onlytags[],overlay: true(DV viewability)The extension's
script-src 'self'CSP forbids remote JS on the new-tab page, so JS tags run in a single cross-origin frame served from our web origin (/mf). On webapp there's no such CSP, so tags run inline — no iframe cost where it isn't needed.Key pieces
adMacros.ts— puresubstituteMacros: cachebuster ([timestamp],${CACHEBUSTER},%n) + TCF/GPP consent tokens; every other param passes through verbatim.adConsent.ts/useAdMacroContext.ts— best-effort consent via the standard__tcfapi/__gppwindow APIs with a geo fallback and a safe empty default; read once the ad nears the viewport.measurementTags.ts— the single DOM injection core (macro fill,<script>re-execution,attributionsrcpreserved). Used by both the web inline path and the frame page.AdPixel.tsx— fills macros before firing, gated on resolved consent so an impression never fires twice.AdMeasurement.tsx— surface-aware renderer on all ad card variants; overlay covers the card for viewability, impression-only stays hidden; non-interactive so the native card keeps clicks; frame pre-warms ~300px before view.pages/mf.tsx— bare, fast frame page excluded from the app shell in_app.tsx; framing locked to extension origins viaframe-ancestors(same pattern asembed/youtube). URL has no `ad` token so ad blockers don't key on it.Performance
Tests
substituteMacrosagainst the real anonymized CM360 reference tags, the injection core (script re-exec, attributionsrc, macro fill, double-injection guard), and the inline measurement path inAdCard.spec.Consent
Measurement consent is driven by the user's first-party marketing-cookie choice, not an IAB CMP. Accepted marketing (or outside GDPR scope) →
gdpr=0and measurement proceeds; in-scope-and-not-consented →gdpr=1with no string (not measured). The extension is treated as consented, mirroringuseConsentCookie.Follow-up
Coordinate with the ad-server team so skadi populates
tags[](and keeps preferring imagepixel[]when available).Ref:
plans/cm360-doubleverify-support.mdPreview domain
https://feat-ad-measurement-tags-impleme.preview.app.daily.dev