feat(ads): third-party measurement tags#6311
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.
- 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
Fill cachebuster/consent macros in ad.link before navigation via a shared useAdClickUrl hook, so the ad server can set a CM360 trackclk (or any macro-bearing URL) as the click tag. Applied to the main card link and CTA across AdList, AdGrid, and SignalAdList; links without macros pass through unchanged. Resolves synchronously so the href is ready before the click.
Generalize doc comments and test fixtures to describe intent without naming specific ad vendors/products.
- drop getServerSideProps; frame-ancestors moves to next.config so the page prerenders and serves from cache with no per-request SSR - inline early-handshake script posts ready at document parse and buffers init until hydration, so the round-trip overlaps the JS load - preconnect to the frame origin from the extension new tab
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(viewability)The extension can't run measurement tags on its own pages, so they run in a single embed frame served from our web origin (
/embed/mf). On webapp tags run inline — no iframe cost where it isn't needed. Tag handling lives in the frame, so fixes ship with a webapp deploy instead of waiting for extension-store adoption.Key pieces
adMacros.ts— puresubstituteMacros: cachebuster + consent tokens; every other param passes through verbatim.adConsent.ts/useAdMacroContext.ts— consent resolved from the user's first-party cookie choice, read once the ad nears the viewport.measurementTags.ts— the single DOM injection core (macro fill,<script>re-execution,attributionsrcpreserved). Used by both the inline path and the frame page.AdPixel.tsx— fills macros before firing; gated on resolved consent and memoized 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. Tags inject only once the card nears the viewport (impressions fire on injection, not visibility), with a ~300px pre-warm.pages/embed/mf.tsx— bare embed page excluded from the app shell in_app.tsxand cacheable; framing locked to our extension origins viaframe-ancestors(same pattern asembed/youtube), andinitmessages accepted only from the actual framing parent.Performance
Consent
Driven by the user's first-party marketing-cookie choice. 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 (accepted on install), mirroringuseConsentCookie.Tests
substituteMacrosagainst realistic tracker URL shapes, the injection core (script re-exec,attributionsrc, macro fill, double-injection guard), pixel cachebuster stability across re-renders, and the inline measurement path inAdCard.spec.Follow-up
Coordinate with the ad-server team so skadi populates
tags[](and keeps preferring imagepixel[]when available).Preview domain
https://feat-ad-measurement-tags-impleme.preview.app.daily.dev