Skip to content

The playback beacon misses the ad.js install path, which is most of the inventory #321

Description

@ralyodio

#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:

<div data-cp-ad data-slot="<id>" data-format="banner_300x250"></div>
<script src="https://crawlproof.com/ad.js" async></script>

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/serve meters 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:

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

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions