Skip to content

feat(ads): measure the in-banner video too, on the path that can carry it - #320

Merged
ralyodio merged 1 commit into
masterfrom
ads-in-banner-video-tracking
Sep 25, 2026
Merged

ralyodio merged 1 commit into
masterfrom
ads-in-banner-video-tracking

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Follow-up to #315/#317, after Anthony pointed at /ad.js rather than stats.js.

Two corrections to what I said earlier in that thread, both mine:

  • I reported Rotate a slot's medium server-side, so no publisher is re-edited #316's migration as unapplied on dev2. It was already applied — I checked ad_creatives.media instead of the real columns, ad_impressions.media and ad_slots.media_mix.
  • I reported that rotation "never picks video in production". It does. Six sequential samples of a weighted rotation all came back static; the actual last-3-hours split is static 336, image 56, gif 26, audio 8, video 6.

The gap

#315 measured the streaming pre-roll. The other video ad is the one /ad.js serves: #316's media rotation fills a display slot as a muted looping MP4, and that unit reports exactly what a static banner does — one impression, one click, nothing about whether a frame ever ran.

An in-banner video is not a pre-roll

Measuring it as one would corrupt the numbers, so it isn't:

  • It loops. Only the first loop counts. A unit left on screen would otherwise report a completion every five seconds and a completion rate over 100% — which this network has already been bitten by once on impressions.
  • It autoplays wherever it is. A start requires the unit at least half visible. A muted video playing below the fold is not a view, and counting it as one turns the funnel into a measure of how much inventory is off-screen.
  • Nobody chose to watch it. abandon is recorded but means "the page went away", not "the viewer bailed".
  • placement is now a funnel column rather than something averaged away, across the RPCs, the API, the CLI, MCP and the dashboard card.

Where the script goes was the real constraint

ad.js injects creatives into a srcdoc iframe sandboxed without allow-scripts and without allow-same-origin. Nothing can run inside it, and the parent cannot reach the media element either (opaque origin). Granting scripts to advertiser-derived markup to collect a statistic is not a trade worth making, so that path is deliberately left unmeasurable — see the open question below.

/api/ads/frame is a real cross-origin document with its own CSP, so the beacon lives there, and only on a video or audio fill — every static banner stays exactly as script-free as it is today, and the publisher's page runs none of our JavaScript either way, which is that route's whole promise.

Beacons are image requests, not fetches: governed by the img-src an ad unit already needs, rather than a connect-src no publisher has granted. Hence the new GET /api/ads/video/events pixel form alongside the existing POST.

Verification

  • npm run typecheck clean
  • npx vitest run — 2,686 passed, 2 skipped, 0 failed (11 new)
  • next build --webpack compiles
  • Migration applied to dev2; all four funnel functions confirmed recreated with placement, and it ends with notify pgrst, 'reload schema' so it cannot repeat the silent no-op from fix(ads): say so when a pre-roll decision cannot be recorded #317

Open question for a human

In-banner video on the ad.js srcdoc path stays unmeasurable unless we add allow-scripts to that sandbox. That widens what advertiser-derived markup can do, so I did not make that call. The alternatives are to leave it (publishers on /api/ads/frame get playback, ad.js publishers get medium-level impressions only), or to move more publishers to the frame embed.

🤖 Generated with Claude Code

…y it

#315 measured the streaming pre-roll. The other video ad is the one
/ad.js serves: #316's media rotation fills a display slot as a muted
looping MP4, and that unit reported exactly what a static banner does —
one impression, one click, nothing about whether a frame ever ran. It is
live: 6 of the last 3 hours' impressions were media='video'.

An in-banner video is not a pre-roll and is deliberately not measured as
one. It loops, so only the FIRST loop counts (a unit left on screen
would otherwise report a completion every five seconds and a rate over
100%, which this network has been bitten by before). It autoplays
wherever it is, so a start requires the unit to be at least half
visible. And `placement` is now a funnel column rather than something
averaged away: a pre-roll someone waited through and a muted loop in the
corner of a page do not share a completion rate.

Where the script goes was the real constraint. ad.js injects creatives
into a srcdoc iframe sandboxed WITHOUT allow-scripts and WITHOUT
allow-same-origin, so nothing can run inside it and the parent cannot
reach the media element either. Granting scripts to advertiser-derived
markup to collect a statistic is not a trade worth making, so that path
is left alone and stays unmeasurable.

/api/ads/frame is a real cross-origin document with its own CSP, so the
beacon lives there — and only on a video or audio fill, so every static
banner is as script-free as it has always been, and the publisher's page
still runs none of our JavaScript either way. Beacons are image
requests, governed by the img-src an ad unit already needs, rather than
a fetch needing a connect-src nobody granted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

49 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 32 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-predictable-temp-path ops/selfhost/server/setup-supabase.sh:201
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit bceea7e into master Sep 25, 2026
10 checks passed
@ralyodio
ralyodio deleted the ads-in-banner-video-tracking branch September 25, 2026 05:52
ralyodio added a commit that referenced this pull request Sep 25, 2026
…ate (#322)

#316 records which medium every fill was served as. Nothing read it, which made
the rotation randomised delivery that taught nothing. This adds the read.

A rollup rather than a query over raw events, for the reason 20260902140000
exists: ad_impressions is ~376k rows growing ~90k/day, a 30-day window selects
about half the table, and the 8s statement_timeout on `authenticated` was
already cancelling reporting RPCs that scanned it. A `group by media` over the
same range walks straight back into that. Grain is (owner_id, day, media) —
daily, not the account series' hourly, because this is a comparison table and
nothing here is plotted at 4-hour buckets.

The part worth arguing about is what the card does NOT show. CTR per medium is
the obvious report and it is unreadable on this network: every slot and every
campaign belong to one account, so clicks book as free self-deal and there has
not been a valid click since 2026-07-29. Five rows of 0.000% is not a neutral
presentation of that — it reads as a finished experiment that found motion
worthless. So the rate column is withheld below 30 attributed clicks and
replaced by a note naming the structural cause, because "not enough data yet"
would tell the reader to wait and waiting will not fix it. Playback is the
signal that does discriminate today (#320), and the note says so.

Two denominators that would each have read as a real number if got wrong:

  * share is denominated on ROTATED delivery, not on everything in the window.
    The pre-rotation archive is far larger than anything the rotation has
    served, so including it would show all five arms at ~0% indefinitely.
  * a NULL media is 'unknown', never 'static'. Folding the archive into static
    would make static the permanent winner of an experiment it never ran in.
    Clicks whose impression_id no longer resolves land there too — ad_clicks is
    `on delete set null` and serveAd synthesises an id when its insert failed.

A click's medium comes from the impression it came from, not from its creative:
the same creative serves several arms, so inferring it would be wrong by
construction.

Migration applied on dev2 ahead of this, with the PostgREST schema reload.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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