Skip to content

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

Merged
idoshamun merged 9 commits into
mainfrom
feat/ad-measurement-tags-implementation
Jul 14, 2026
Merged

feat(ads): third-party measurement tags#6311
idoshamun merged 9 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
pixel[] image impressions <img> native (macros filled) <img> native (macros filled)
tags[], impression-only inline, hidden one 0×0 iframe on our web origin
tags[], overlay: true (viewability) inline over card one iframe sized to the card

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 — pure substituteMacros: 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, attributionsrc preserved). 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.tsx and cacheable; framing locked to our extension origins via frame-ancestors (same pattern as embed/youtube), and init messages accepted only from the actual framing parent.

Performance

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

Consent

Driven by the user's first-party marketing-cookie choice. 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 (accepted on install), mirroring useConsentCookie.

Tests

substituteMacros against 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 in AdCard.spec.

Follow-up

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

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 14, 2026 8:49am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Jul 14, 2026 8:49am

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
- 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.
@idoshamun idoshamun changed the title feat(ads): third-party measurement tags (CM360 + DoubleVerify) feat(ads): third-party measurement tags Jul 14, 2026
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
@idoshamun idoshamun merged commit 9171dd8 into main Jul 14, 2026
12 checks passed
@idoshamun idoshamun deleted the feat/ad-measurement-tags-implementation branch July 14, 2026 15:17
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