Skip to content

feat(ads): third-party measurement tags (CM360 + DoubleVerify)#6311

Open
idoshamun wants to merge 4 commits into
mainfrom
feat/ad-measurement-tags-implementation
Open

feat(ads): third-party measurement tags (CM360 + DoubleVerify)#6311
idoshamun wants to merge 4 commits into
mainfrom
feat/ad-measurement-tags-implementation

Conversation

@idoshamun

@idoshamun idoshamun commented Jul 13, 2026

Copy link
Copy Markdown
Member

Implements the client for the ad.tags measurement contract (merged in #6310) across both surfaces. One macro + injection core is shared by every path so behavior never drifts.

How it works

Tracker webapp extension (MV3)
pixel[] image impressions <img> native (macros filled) <img> native (macros filled)
tags[], impression-only inline, hidden one 0×0 web-origin iframe
tags[], overlay: true (DV viewability) inline over card one web-origin iframe sized to the card

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 — pure substituteMacros: 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/__gpp window 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, attributionsrc preserved). 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 via frame-ancestors (same pattern as embed/youtube). URL has no `ad` token so ad blockers don't key on it.

Performance

  • Iframe only on the extension, only for JS tags, one per ad (all tags share it), and only sized to the card when a tag needs viewport geometry — otherwise 0×0.
  • Frame page skips boot/auth/providers for fastest load; loading starts ~300px before the card enters view so measurement is ready on time.

Tests

substituteMacros against the real anonymized CM360 reference tags, the injection core (script re-exec, attributionsrc, macro fill, double-injection guard), and the inline measurement path in AdCard.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=0 and measurement proceeds; in-scope-and-not-consented → gdpr=1 with no string (not measured). The extension is treated as consented, mirroring useConsentCookie.

Follow-up

Coordinate with the ad-server team so skadi populates tags[] (and keeps preferring image pixel[] when available).

Ref: plans/cm360-doubleverify-support.md

Preview domain

https://feat-ad-measurement-tags-impleme.preview.app.daily.dev

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.
@vercel

vercel Bot commented Jul 13, 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 13, 2026 6:54pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Jul 13, 2026 6:54pm

Request Review

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({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do we really need that or can we just render the measurement? Anyway the measurement probably has some logic to wait for visibility

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Why do we need theme?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 && (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

What is this? Not sure I understand why we have pixel and measurement only there

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.
Comment thread packages/webapp/pages/mf.tsx Outdated
* the URL. Framing is locked to our extension origins via `frame-ancestors`.
*/

export const getServerSideProps: GetServerSideProps = async ({ res }) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Move this page under the /embeds family we already have

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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
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.

1 participant