You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#320 built the in-banner playback beacon, and it is the right design — first loop only, a 50%-visibility gate on start, in_banner kept separate from preroll. This issue is about the half of the inventory it cannot reach.
The gap
The beacon is injected in one place: app/api/ads/frame/route.ts. That is the no-JS iframe embed.
The canonical install is the other one. lib/ads/snippets.ts leads with:
and ad.js renders the creative with iframe.srcdoc = res.html into a frame sandboxed allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation — no allow-scripts. So on that path the beacon markup either is not injected (it isn't) or would not execute (it wouldn't). Every video and audio fill served through ad.js reports nothing.
That matters more than it sounds, because playback is currently the only signal that can separate the rotation's arms: CTR is structurally unreadable while every campaign and slot share one account (see the note in lib/ads/media-stats.ts, and #319's sibling discussion). We are rotating five media and measuring two of them on a subset of inventory.
What not to do
Do not add allow-scripts to the ad.js sandbox.#320's own comment gets this right: that frame contains advertiser-derived markup (logoUrl, imageUrl, copy) sitting inside the publisher's page, and granting it script execution to collect a statistic is not a trade worth making. The sandbox is the only thing standing between a creative and the host page.
Proposed shape
Serve video/audio fills on the ad.js path as a real cross-origin document instead of srcdoc — i.e. point the iframe at /api/ads/frame, which is where the beacon already works and which carries its own CSP. Nothing about the sandbox has to be loosened, because the document stops being "our markup inside their origin" and becomes "our page on our origin".
The trap to design around
/api/ads/frame calls serveAd() itself. Pointing the iframe at it after ad.js has already called /api/ads/servemeters a second impression for one unit — and doubles it for exactly the arms we are trying to measure, which would then look twice as widely delivered and half as engaging as they are. Two ways out:
Pass the fill through./api/ads/serve returns a frameUrl carrying the impression it already metered (&i=<impressionId>); the frame route, given that, rehydrates and renders without calling serveAd. Keeps ad.js's width measurement and theme detection, which are the two things the JSON path is better at. More plumbing, and the rehydrate path needs to be as tolerant as the rest of serving.
Skip the JSON round trip for these fills — ad.js sets iframe.src directly and never calls /api/ads/serve. Simpler, but ad.js then has to size the frame from its own pickFormat guess rather than from what was actually served, and fitAdFormat can legitimately downgrade — so the box and the creative can disagree. It also gives up the measured w=.
(1) looks right. (2) is worth prototyping first only to confirm the beacon fires end-to-end in a real publisher page before building the rehydrate path.
Also worth deciding
allow="autoplay" is already set by ad.js on video fills (Rotate a slot's medium server-side, so no publisher is re-edited #316). It carries over to an src frame unchanged, so no change needed — but it should be verified on a real cross-origin frame rather than assumed, since the permission delegation rules differ from srcdoc.
A publisher's CSP needs frame-src crawlproof.com for an src frame where srcdoc needed nothing. That is the one real cost to publishers, and it is the same entry the no-JS embed already documents. Worth a line in the snippet notes.
Audio reports through the same beacon (document.querySelector('video, audio')), but it is click-to-play, so its funnel means something quite different from the video one: a start is a deliberate act, not an autoplay. Probably wants its own row rather than being averaged with video — the same argument feat(ads): measure the in-banner video too, on the path that can carry it #320 makes for keeping in_banner apart from preroll.
Acceptance
A video fill installed via the ad.js snippet produces asset_requested / start / quartile / complete rows in ad_video_events for its decision.
Exactly one ad_impressions row per unit, verified by count over a window of known fills.
The in_banner funnel in the dashboard shows non-zero starts attributable to ad.js-installed slots.
#320 built the in-banner playback beacon, and it is the right design — first loop only, a 50%-visibility gate on
start,in_bannerkept separate frompreroll. This issue is about the half of the inventory it cannot reach.The gap
The beacon is injected in one place:
app/api/ads/frame/route.ts. That is the no-JS iframe embed.The canonical install is the other one.
lib/ads/snippets.tsleads with:and
ad.jsrenders the creative withiframe.srcdoc = res.htmlinto a frame sandboxedallow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation— noallow-scripts. So on that path the beacon markup either is not injected (it isn't) or would not execute (it wouldn't). Every video and audio fill served throughad.jsreports nothing.That matters more than it sounds, because playback is currently the only signal that can separate the rotation's arms: CTR is structurally unreadable while every campaign and slot share one account (see the note in
lib/ads/media-stats.ts, and #319's sibling discussion). We are rotating five media and measuring two of them on a subset of inventory.What not to do
Do not add
allow-scriptsto the ad.js sandbox. #320's own comment gets this right: that frame contains advertiser-derived markup (logoUrl,imageUrl, copy) sitting inside the publisher's page, and granting it script execution to collect a statistic is not a trade worth making. The sandbox is the only thing standing between a creative and the host page.Proposed shape
Serve video/audio fills on the ad.js path as a real cross-origin document instead of
srcdoc— i.e. point the iframe at/api/ads/frame, which is where the beacon already works and which carries its own CSP. Nothing about the sandbox has to be loosened, because the document stops being "our markup inside their origin" and becomes "our page on our origin".The trap to design around
/api/ads/framecallsserveAd()itself. Pointing the iframe at it afterad.jshas already called/api/ads/servemeters a second impression for one unit — and doubles it for exactly the arms we are trying to measure, which would then look twice as widely delivered and half as engaging as they are. Two ways out:/api/ads/servereturns aframeUrlcarrying the impression it already metered (&i=<impressionId>); the frame route, given that, rehydrates and renders without callingserveAd. Keeps ad.js's width measurement and theme detection, which are the two things the JSON path is better at. More plumbing, and the rehydrate path needs to be as tolerant as the rest of serving.iframe.srcdirectly and never calls/api/ads/serve. Simpler, but ad.js then has to size the frame from its ownpickFormatguess rather than from what was actually served, andfitAdFormatcan legitimately downgrade — so the box and the creative can disagree. It also gives up the measuredw=.(1) looks right. (2) is worth prototyping first only to confirm the beacon fires end-to-end in a real publisher page before building the rehydrate path.
Also worth deciding
allow="autoplay"is already set by ad.js on video fills (Rotate a slot's medium server-side, so no publisher is re-edited #316). It carries over to ansrcframe unchanged, so no change needed — but it should be verified on a real cross-origin frame rather than assumed, since the permission delegation rules differ fromsrcdoc.frame-src crawlproof.comfor ansrcframe wheresrcdocneeded nothing. That is the one real cost to publishers, and it is the same entry the no-JS embed already documents. Worth a line in the snippet notes.document.querySelector('video, audio')), but it is click-to-play, so its funnel means something quite different from the video one: astartis a deliberate act, not an autoplay. Probably wants its own row rather than being averaged with video — the same argument feat(ads): measure the in-banner video too, on the path that can carry it #320 makes for keepingin_bannerapart frompreroll.Acceptance
ad.jssnippet producesasset_requested/start/ quartile /completerows inad_video_eventsfor its decision.ad_impressionsrow per unit, verified by count over a window of known fills.in_bannerfunnel in the dashboard shows non-zero starts attributable to ad.js-installed slots.🤖 Generated with Claude Code