From 06ed7991f061a1f674a0f080df63298f2ee0b641 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 05:49:33 +0000 Subject: [PATCH] feat(ads): measure the in-banner video too, on the path that can carry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) --- app/(app)/dashboard/ads/[id]/page.tsx | 17 +- app/api/ads/frame/route.ts | 39 ++- app/api/ads/video/events/route.ts | 69 +++++- cli/index.ts | 16 +- components/ads/video-funnel-card.tsx | 7 +- lib/ads/video/bannerBeacon.ts | 146 +++++++++++ lib/ads/video/decisions.ts | 2 +- lib/ads/video/stats.ts | 22 ++ lib/mcp/stats.ts | 4 +- ...260925160000_ad_video_funnel_placement.sql | 234 ++++++++++++++++++ tests/ads-video-banner-beacon.test.ts | 74 ++++++ 11 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 lib/ads/video/bannerBeacon.ts create mode 100644 supabase/migrations/20260925160000_ad_video_funnel_placement.sql create mode 100644 tests/ads-video-banner-beacon.test.ts diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index d9fbb21..cbc03c9 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -105,10 +105,11 @@ export default async function CampaignDetailPage({ // and ad_video_events, which carry RLS with no public policy (they are // written by the serving path, not by a session). Ownership was already // settled by the campaign select above, and the RPC filters on it again. - const videoFunnel = - (await videoFunnelForOwner(serviceClient(), user.id, 30).catch(() => [])).find( - (r) => r.campaignId === id, - ) ?? null; + // All of them, not the first: the funnel splits by placement, so a campaign + // running both a streaming pre-roll and an in-banner loop has a row each and + // they are different products with different completion rates. + const videoFunnel = (await videoFunnelForOwner(serviceClient(), user.id, 30).catch(() => [])) + .filter((r) => r.campaignId === id); // The bid, who sets it, and its paper ledger: their own read too, for the // same reason — the columns ride behind a hand-applied migration and a @@ -289,11 +290,11 @@ export default async function CampaignDetailPage({ - {videoFunnel && ( -
- + {videoFunnel.map((row) => ( +
+
- )} + ))} {creatives.length > 0 && (
diff --git a/app/api/ads/frame/route.ts b/app/api/ads/frame/route.ts index fda35c3..4117fef 100644 --- a/app/api/ads/frame/route.ts +++ b/app/api/ads/frame/route.ts @@ -12,11 +12,23 @@ // Because there is no script, there is also no theme detection: this document // carries both palettes and lets its own `prefers-color-scheme` decide. Pass // `&theme=light` or `&theme=dark` to pin it. +// +// ONE exception to "no script", added deliberately and narrowly: a fill that +// rendered as video or as the audible companion carries a small measurement +// script. It is the only way to know whether a video ad actually played, it +// runs inside THIS document rather than the host page (so the zero-JS promise +// to the publisher is unchanged), and a reader with JavaScript off still gets +// the ad — the unit renders and clicks exactly as before, it simply reports +// nothing. Every other medium is served as script-free as it has always been. import { NextRequest, NextResponse } from "next/server"; import { serveAd, isAdFormat } from "@/lib/ads/serve"; import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; import { parseDevice } from "@/lib/tracker/device"; +import { serviceClient } from "@/lib/supabase/service"; +import { recordDecision } from "@/lib/ads/video/decisions"; +import { bannerBeaconScript, injectBannerBeacon } from "@/lib/ads/video/bannerBeacon"; +import { env } from "@/lib/env"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -76,7 +88,32 @@ export async function GET(request: NextRequest) { }); if (!fill) return htmlResponse(EMPTY_HTML); - return htmlResponse(fill.html); + + // Only the media that can actually report anything. A static banner has + // no playback to measure and gets no script. + if (fill.media !== "video" && fill.media !== "audio") { + return htmlResponse(fill.html); + } + + const decisionId = await recordDecision(serviceClient(), { + slotId, + // No playback session on a display surface: the unit is drawn once and + // a reload is a genuinely new impression, so the fill is its own session. + sessionId: crypto.randomUUID(), + placement: "in_banner", + kind: fill.media === "audio" ? "audio" : "video", + surface: "web", + fill, + assetRevision: null, + }); + + // Unmeasurable is not unservable: without a decision to report against + // there is nothing to put in the script, and the ad goes out as it is. + if (!decisionId) return htmlResponse(fill.html); + + return htmlResponse( + injectBannerBeacon(fill.html, bannerBeaconScript(decisionId, env.siteUrl)), + ); } catch { return htmlResponse(EMPTY_HTML); } diff --git a/app/api/ads/video/events/route.ts b/app/api/ads/video/events/route.ts index b3e26c8..df45716 100644 --- a/app/api/ads/video/events/route.ts +++ b/app/api/ads/video/events/route.ts @@ -1,6 +1,7 @@ -// Playback beacons for a pre-roll. +// Playback beacons for a video ad. // // POST { decision, events: [{ type, mediaTimeMs, playedMs, ts, id? }] } +// GET ?d=&t=&m=&p= -> a 1x1 gif // // Open to any origin and unauthenticated, exactly like /api/track and the ad // click redirect: the caller is a media element on a publisher's page, and @@ -13,6 +14,13 @@ // The body may arrive as `navigator.sendBeacon` sends it — text/plain, or a // Blob with no content type at all, during page teardown — so the content type // is not checked. The text is parsed as JSON and that is the whole contract. +// +// The GET form exists for one reason: an ad unit rendered inside a publisher's +// page is governed by the PUBLISHER's Content-Security-Policy, and a `fetch` to +// us needs a `connect-src` entry they have not granted and should not have to. +// An image request is governed by `img-src`, which a unit carrying advertiser +// artwork already requires, so the pixel measures where the POST would be +// silently blocked. It carries one event; that is the whole difference. import { NextRequest, NextResponse } from "next/server"; import { serviceClient } from "@/lib/supabase/service"; @@ -25,7 +33,7 @@ function cors(request: Request): Record { const origin = request.headers.get("origin"); return { "access-control-allow-origin": origin ?? "*", - "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-methods": "GET, POST, OPTIONS", "access-control-allow-headers": "content-type", "cache-control": "no-store", vary: "Origin", @@ -63,3 +71,60 @@ export async function POST(request: NextRequest) { return NextResponse.json({ accepted: 0, duplicates: 0 }, { status: 202, headers }); } } + +/** + * A 1x1 transparent gif, answered whatever happens. + * + * The caller is an `` inside somebody's ad unit and has nothing useful to + * do with an error — a broken-image icon in a publisher's banner is a worse + * outcome than a measurement we quietly dropped. + */ +const PIXEL = Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64", +); + +function pixel(headers: Record) { + return new NextResponse(PIXEL, { + status: 200, + headers: { + ...headers, + "content-type": "image/gif", + "content-length": String(PIXEL.length), + }, + }); +} + +/** The pixel form: one event per image request. */ +export async function GET(request: NextRequest) { + const headers = cors(request); + const url = new URL(request.url); + + const num = (v: string | null) => { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : undefined; + }; + + const parsed = parseEventBatch({ + decision: url.searchParams.get("d"), + events: [ + { + type: url.searchParams.get("t"), + id: url.searchParams.get("i") ?? undefined, + mediaTimeMs: num(url.searchParams.get("m")), + playedMs: num(url.searchParams.get("p")), + source: url.searchParams.get("s") ?? "media_element", + errorReason: url.searchParams.get("e") ?? undefined, + }, + ], + }); + if ("error" in parsed) return pixel(headers); + + try { + await recordVideoEvents(serviceClient(), parsed); + } catch { + // Same rule as the POST: a measurement we failed to store is not the + // reader's problem, and it must never show up in their page. + } + return pixel(headers); +} diff --git a/cli/index.ts b/cli/index.ts index 1030604..813b986 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -490,27 +490,35 @@ async function cmdAds(args: Args): Promise { return 0; } const pct = (v: unknown) => `${Math.round(Number(v ?? 0) * 100)}%`; + // A pre-roll and an in-banner loop are different products; the column is + // there so nobody reads one row's completion rate as the other's. + const label = (v: unknown) => { + const p = String(v ?? "preroll"); + return p === "in_banner" ? "in-banner" : p === "preroll" ? "pre-roll" : p; + }; const campaigns = (json.campaigns as Record[]) ?? []; const slots = (json.slots as Record[]) ?? []; if (campaigns.length) { process.stdout.write(`Campaigns (${json.days}d)\n`); - process.stdout.write(` ${"fills".padStart(7)} ${"starts".padStart(7)} ${"done".padStart(7)} ${"start%".padStart(7)} ${"done%".padStart(7)} campaign\n`); + process.stdout.write(` ${"fills".padStart(7)} ${"starts".padStart(7)} ${"done".padStart(7)} ${"start%".padStart(7)} ${"done%".padStart(7)} ${"where".padEnd(10)} campaign\n`); for (const c of campaigns) { process.stdout.write( ` ${String(c.fills).padStart(7)} ${String(c.starts).padStart(7)} ${String(c.completes).padStart(7)}` + - ` ${pct(c.startRate).padStart(7)} ${pct(c.completionRate).padStart(7)} ${c.campaignName}\n`, + ` ${pct(c.startRate).padStart(7)} ${pct(c.completionRate).padStart(7)}` + + ` ${label(c.placement).padEnd(10)} ${c.campaignName}\n`, ); } } if (slots.length) { if (campaigns.length) process.stdout.write("\n"); process.stdout.write(`Slots (${json.days}d)\n`); - process.stdout.write(` ${"fills".padStart(7)} ${"house".padStart(7)} ${"empty".padStart(7)} ${"starts".padStart(7)} ${"done".padStart(7)} site\n`); + process.stdout.write(` ${"fills".padStart(7)} ${"house".padStart(7)} ${"empty".padStart(7)} ${"starts".padStart(7)} ${"done".padStart(7)} ${"where".padEnd(10)} site\n`); for (const s of slots) { process.stdout.write( ` ${String(s.fills).padStart(7)} ${String(s.houseFills).padStart(7)} ${String(s.unfilled).padStart(7)}` + - ` ${String(s.starts).padStart(7)} ${String(s.completes).padStart(7)} ${s.projectName || s.slotId}\n`, + ` ${String(s.starts).padStart(7)} ${String(s.completes).padStart(7)}` + + ` ${label(s.placement).padEnd(10)} ${s.projectName || s.slotId}\n`, ); } } diff --git a/components/ads/video-funnel-card.tsx b/components/ads/video-funnel-card.tsx index dafc1cf..cfce8bd 100644 --- a/components/ads/video-funnel-card.tsx +++ b/components/ads/video-funnel-card.tsx @@ -9,6 +9,7 @@ import type { VideoFunnelRow } from "@/lib/ads/video/stats"; * fill every break it is offered and never play a single frame. */ export function VideoFunnelCard({ row }: { row: VideoFunnelRow | null }) { + const where = row?.placement === "in_banner" ? "in-banner" : "pre-roll"; // Nothing filled means nothing to explain; the render card above already // says whether the campaign even has a video. if (!row || row.fills === 0) return null; @@ -27,9 +28,11 @@ export function VideoFunnelCard({ row }: { row: VideoFunnelRow | null }) { return (
-

Playback

+

Playback · {where}

- A fill is a break this campaign won. A start is one that actually played. Last 30 days. + {row.placement === "in_banner" + ? "A muted unit that loops in the page. Only its first loop is counted, and a start needs the unit at least half visible. Last 30 days." + : "A fill is a break this campaign won. A start is one that actually played. Last 30 days."}

diff --git a/lib/ads/video/bannerBeacon.ts b/lib/ads/video/bannerBeacon.ts new file mode 100644 index 0000000..dadc0f5 --- /dev/null +++ b/lib/ads/video/bannerBeacon.ts @@ -0,0 +1,146 @@ +// Measuring an in-banner video, which is a different thing from a pre-roll. +// +// A pre-roll is something a listener waits through: it starts because they +// asked for content, it plays once, and "complete" means they sat through it. +// An in-banner video is muted, autoplaying and LOOPING inside somebody else's +// page, and every one of those differences would corrupt the same numbers if +// they were measured the same way: +// +// * It loops, so only the FIRST loop is counted. A unit left on screen for +// three minutes would otherwise report thirty-odd completions and a +// completion rate over 100%, which this network has already been bitten by +// once on impressions. +// * It autoplays below the fold, so a `start` requires the unit to be at +// least half visible. A muted video playing where nobody can see it is not +// a view, and counting it as one makes the whole funnel a measure of how +// much inventory is off-screen. +// * Nobody chose to watch it, so `abandon` is not a signal of rejection the +// way it is for a pre-roll. It is recorded, but it means "the page went +// away", not "the viewer bailed". +// +// Delivered as an inline script in OUR document — the /api/ads/frame path, +// which is a real cross-origin page with its own CSP. It deliberately does not +// go into the ad.js srcdoc unit: that iframe is sandboxed without +// `allow-scripts`, so nothing here would run, and granting scripts to +// advertiser-derived markup to collect a statistic is not a trade worth making. + +/** Events reported through the pixel, in the order a full play produces them. */ +export const BANNER_EVENTS = [ + "asset_requested", + "start", + "first_quartile", + "midpoint", + "third_quartile", + "complete", +] as const; + +/** + * The inline measurement script for a banner document. + * + * Beacons are image requests, not fetches. This document's own CSP would allow + * either, but the pixel is what also works if the same markup is ever rendered + * somewhere governed by a publisher's policy, and one code path that works + * everywhere beats two that each work somewhere. + */ +export function bannerBeaconScript(decisionId: string, origin: string): string { + const endpoint = `${origin.replace(/\/$/, "")}/api/ads/video/events`; + return ``; +} + +/** + * Put the script in the document. + * + * Before `` so the media element exists when it runs, and appended + * rather than templated into the renderer: the creative renderers in + * lib/ads/creative.ts are pure and client-safe, and a decision id is neither. + */ +export function injectBannerBeacon(html: string, script: string): string { + if (!html.includes("")) return html + script; + return html.replace("", `${script}`); +} diff --git a/lib/ads/video/decisions.ts b/lib/ads/video/decisions.ts index 58dd5d2..b8c7216 100644 --- a/lib/ads/video/decisions.ts +++ b/lib/ads/video/decisions.ts @@ -32,7 +32,7 @@ export function normalizeSurface(v: string | null | undefined): string { * a query string would let a client mint unlimited pre-rolls for one session * by varying it. The list is what we actually serve. */ -const PLACEMENTS = new Set(["preroll", "midroll", "postroll"]); +const PLACEMENTS = new Set(["preroll", "midroll", "postroll", "in_banner"]); export function normalizePlacement(v: string | null | undefined): string { return v && PLACEMENTS.has(v) ? v : "preroll"; diff --git a/lib/ads/video/stats.ts b/lib/ads/video/stats.ts index 401d3b9..a28e1b4 100644 --- a/lib/ads/video/stats.ts +++ b/lib/ads/video/stats.ts @@ -15,9 +15,19 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +/** + * Where the video played, and why it is never aggregated away. + * + * 'preroll' is a break someone waited through. 'in_banner' is a muted, looping + * unit in the corner of a page nobody opened for it, measured on its first loop + * only. Averaging the two produces a completion rate that describes neither. + */ +export type VideoPlacement = "preroll" | "midroll" | "postroll" | "in_banner"; + export type VideoFunnelRow = { campaignId: string; campaignName: string; + placement: string; fills: number; starts: number; firstQuartile: number; @@ -37,6 +47,7 @@ export type VideoFunnelRow = { export type VideoSlotFunnelRow = { slotId: string; projectName: string; + placement: string; fills: number; houseFills: number; unfilled: number; @@ -65,6 +76,7 @@ export function videoFunnelRow(raw: Record): VideoFunnelRow { return { campaignId: String(raw.campaign_id ?? ""), campaignName: String(raw.campaign_name ?? ""), + placement: String(raw.placement ?? "preroll"), fills, starts, firstQuartile: n(raw.first_quartile), @@ -87,6 +99,7 @@ export function videoSlotFunnelRow(raw: Record): VideoSlotFunne return { slotId: String(raw.slot_id ?? ""), projectName: String(raw.project_name ?? ""), + placement: String(raw.placement ?? "preroll"), fills, houseFills: n(raw.house_fills), unfilled: n(raw.unfilled), @@ -159,3 +172,12 @@ export function parseDays(v: string | null | undefined, fallback = 7): number { if (!Number.isFinite(num)) return fallback; return Math.min(Math.max(Math.round(num), 1), 365); } + +/** How a placement is written in a report. */ +export function placementLabel(p: string): string { + if (p === "in_banner") return "in-banner"; + if (p === "preroll") return "pre-roll"; + if (p === "midroll") return "mid-roll"; + if (p === "postroll") return "post-roll"; + return p; +} diff --git a/lib/mcp/stats.ts b/lib/mcp/stats.ts index 99f02fe..bb9ee72 100644 --- a/lib/mcp/stats.ts +++ b/lib/mcp/stats.ts @@ -174,7 +174,7 @@ export function registerStatsTools(server: McpServer): void { lines.push("Campaigns:"); for (const c of campaigns) { lines.push( - `- ${c.campaignName}: ${c.fills} filled, ${c.starts} started (${pct(c.startRate)}), ` + + `- ${c.campaignName} (${c.placement === "in_banner" ? "in-banner" : "pre-roll"}): ${c.fills} filled, ${c.starts} started (${pct(c.startRate)}), ` + `${c.completes} completed (${pct(c.completionRate)} of starts), ${c.clicks} click(s), ${c.errors} error(s)`, ); } @@ -183,7 +183,7 @@ export function registerStatsTools(server: McpServer): void { lines.push("Slots:"); for (const s of slots) { lines.push( - `- ${s.projectName || s.slotId}: ${s.fills} filled (${s.houseFills} house, ${s.unfilled} empty), ` + + `- ${s.projectName || s.slotId} (${s.placement === "in_banner" ? "in-banner" : "pre-roll"}): ${s.fills} filled (${s.houseFills} house, ${s.unfilled} empty), ` + `${s.starts} started, ${s.completes} completed`, ); } diff --git a/supabase/migrations/20260925160000_ad_video_funnel_placement.sql b/supabase/migrations/20260925160000_ad_video_funnel_placement.sql new file mode 100644 index 0000000..79217d1 --- /dev/null +++ b/supabase/migrations/20260925160000_ad_video_funnel_placement.sql @@ -0,0 +1,234 @@ +-- Split the video funnel by placement, because a pre-roll and an in-banner +-- video are not the same product and must not share a completion rate. +-- +-- 20260925130000 built the funnel when the only video ad was the streaming +-- pre-roll. In-banner video (20260925120000's media rotation, serving now) +-- reports through the same tables, and merging them would produce a number +-- that means nothing: +-- +-- * a pre-roll is watched by someone waiting for their content; an in-banner +-- video is muted, autoplaying and looping in the corner of a page nobody +-- opened for it. Completion means something different in each. +-- * only the first loop of a banner is counted, so its "complete" is a +-- different event from a pre-roll's, produced by different logic. +-- +-- So `placement` becomes an output column and a grouping key. The functions are +-- DROPped rather than replaced: `create or replace` cannot change a function's +-- return type. +-- +-- NOTE: prod migration history diverged — apply this single file via psql over +-- the pooler, do NOT `supabase db push`. + +drop function if exists public.ad_video_funnel(integer); +drop function if exists public.ad_video_funnel_for(uuid, integer); +drop function if exists public.ad_video_slot_funnel(integer); +drop function if exists public.ad_video_slot_funnel_for(uuid, integer); + +create or replace function public.ad_video_funnel_for(p_owner uuid, p_days integer default 7) +returns table( + campaign_id uuid, + campaign_name text, + placement text, + fills bigint, + starts bigint, + first_quartile bigint, + midpoint bigint, + third_quartile bigint, + completes bigint, + clicks bigint, + errors bigint, + abandons bigint, + played_ms bigint +) +language plpgsql +stable +security definer +set search_path to 'public' +as $$ +declare + since timestamptz := now() - make_interval(days => greatest(coalesce(p_days, 7), 1)); +begin + if p_owner is null then + return; + end if; + + return query + with mine as ( + select d.id, d.campaign_id, d.placement + from public.ad_video_decisions d + join public.ad_campaigns c on c.id = d.campaign_id + where c.owner_id = p_owner and d.created_at >= since + ), + ev as ( + select m.campaign_id, m.placement, e.decision_id, e.event_type, e.played_ms + from mine m + join public.ad_video_events e on e.decision_id = m.id + ), + furthest as ( + select f.campaign_id, f.placement, sum(f.mx)::bigint as played_ms + from ( + select ev.campaign_id, ev.placement, ev.decision_id, max(coalesce(ev.played_ms, 0)) as mx + from ev group by 1, 2, 3 + ) f + group by 1, 2 + ), + agg as ( + select + m.campaign_id as cid, + m.placement as plc, + count(distinct m.id)::bigint as fills, + count(distinct e.decision_id) filter (where e.event_type = 'start')::bigint as starts, + count(distinct e.decision_id) filter (where e.event_type = 'first_quartile')::bigint as q1, + count(distinct e.decision_id) filter (where e.event_type = 'midpoint')::bigint as mid, + count(distinct e.decision_id) filter (where e.event_type = 'third_quartile')::bigint as q3, + count(distinct e.decision_id) filter (where e.event_type = 'complete')::bigint as completes, + count(distinct e.decision_id) filter (where e.event_type = 'click')::bigint as clicks, + count(distinct e.decision_id) filter (where e.event_type = 'error')::bigint as errors, + count(distinct e.decision_id) filter (where e.event_type = 'abandon')::bigint as abandons + from mine m + left join ev e on e.decision_id = m.id + group by 1, 2 + ) + select + c.id, c.name, a.plc, a.fills, a.starts, a.q1, a.mid, a.q3, + a.completes, a.clicks, a.errors, a.abandons, + coalesce(f.played_ms, 0)::bigint + from agg a + join public.ad_campaigns c on c.id = a.cid + left join furthest f on f.campaign_id = a.cid and f.placement = a.plc + order by a.fills desc, c.name, a.plc; +end; +$$; + +create or replace function public.ad_video_funnel(p_days integer default 7) +returns table( + campaign_id uuid, + campaign_name text, + placement text, + fills bigint, + starts bigint, + first_quartile bigint, + midpoint bigint, + third_quartile bigint, + completes bigint, + clicks bigint, + errors bigint, + abandons bigint, + played_ms bigint +) +language sql +stable +security definer +set search_path to 'public' +as $$ + select * from public.ad_video_funnel_for(auth.uid(), p_days); +$$; + +create or replace function public.ad_video_slot_funnel_for(p_owner uuid, p_days integer default 7) +returns table( + slot_id uuid, + project_name text, + placement text, + fills bigint, + house_fills bigint, + unfilled bigint, + starts bigint, + completes bigint, + clicks bigint, + errors bigint, + abandons bigint, + played_ms bigint +) +language plpgsql +stable +security definer +set search_path to 'public' +as $$ +declare + since timestamptz := now() - make_interval(days => greatest(coalesce(p_days, 7), 1)); +begin + if p_owner is null then + return; + end if; + + return query + with mine as ( + select d.id, d.slot_id, d.tier, d.result, d.placement + from public.ad_video_decisions d + join public.ad_slots s on s.id = d.slot_id + where s.owner_id = p_owner and d.created_at >= since + ), + ev as ( + select m.slot_id, m.placement, e.decision_id, e.event_type, e.played_ms + from mine m + join public.ad_video_events e on e.decision_id = m.id + ), + furthest as ( + select f.slot_id, f.placement, sum(f.mx)::bigint as played_ms + from ( + select ev.slot_id, ev.placement, ev.decision_id, max(coalesce(ev.played_ms, 0)) as mx + from ev group by 1, 2, 3 + ) f + group by 1, 2 + ), + agg as ( + select + m.slot_id as sid, + m.placement as plc, + count(distinct m.id)::bigint as fills, + count(distinct m.id) filter (where m.tier = 'house')::bigint as house_fills, + count(distinct m.id) filter (where m.result = 'no_ad')::bigint as unfilled, + count(distinct e.decision_id) filter (where e.event_type = 'start')::bigint as starts, + count(distinct e.decision_id) filter (where e.event_type = 'complete')::bigint as completes, + count(distinct e.decision_id) filter (where e.event_type = 'click')::bigint as clicks, + count(distinct e.decision_id) filter (where e.event_type = 'error')::bigint as errors, + count(distinct e.decision_id) filter (where e.event_type = 'abandon')::bigint as abandons + from mine m + left join ev e on e.decision_id = m.id + group by 1, 2 + ) + select + s.id, coalesce(p.name, ''), a.plc, a.fills, a.house_fills, a.unfilled, + a.starts, a.completes, a.clicks, a.errors, a.abandons, + coalesce(f.played_ms, 0)::bigint + from agg a + join public.ad_slots s on s.id = a.sid + left join public.projects p on p.id = s.project_id + left join furthest f on f.slot_id = a.sid and f.placement = a.plc + order by a.fills desc, a.plc; +end; +$$; + +create or replace function public.ad_video_slot_funnel(p_days integer default 7) +returns table( + slot_id uuid, + project_name text, + placement text, + fills bigint, + house_fills bigint, + unfilled bigint, + starts bigint, + completes bigint, + clicks bigint, + errors bigint, + abandons bigint, + played_ms bigint +) +language sql +stable +security definer +set search_path to 'public' +as $$ + select * from public.ad_video_slot_funnel_for(auth.uid(), p_days); +$$; + +revoke all on function public.ad_video_funnel_for(uuid, integer) from public, anon, authenticated; +revoke all on function public.ad_video_slot_funnel_for(uuid, integer) from public, anon, authenticated; +grant execute on function public.ad_video_funnel_for(uuid, integer) to service_role; +grant execute on function public.ad_video_slot_funnel_for(uuid, integer) to service_role; +grant execute on function public.ad_video_funnel(integer) to authenticated; +grant execute on function public.ad_video_slot_funnel(integer) to authenticated; + +-- PostgREST caches the schema; a hand-applied migration that changes a +-- function signature is invisible to the API until it is told. +notify pgrst, 'reload schema'; diff --git a/tests/ads-video-banner-beacon.test.ts b/tests/ads-video-banner-beacon.test.ts new file mode 100644 index 0000000..28081ab --- /dev/null +++ b/tests/ads-video-banner-beacon.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { bannerBeaconScript, injectBannerBeacon } from "@/lib/ads/video/bannerBeacon"; +import { normalizePlacement } from "@/lib/ads/video/decisions"; +import { placementLabel, videoFunnelRow } from "@/lib/ads/video/stats"; + +const DECISION = "11111111-2222-4333-8444-555555555555"; + +describe("bannerBeaconScript", () => { + const script = bannerBeaconScript(DECISION, "https://crawlproof.com/"); + + it("beacons with an image, not a fetch", () => { + // An is governed by the publisher's img-src, which an ad unit + // already needs. A fetch would need a connect-src entry nobody granted. + expect(script).toContain("new Image()"); + expect(script).not.toContain("fetch("); + expect(script).not.toContain("XMLHttpRequest"); + }); + + it("points at the pixel endpoint with the decision id, and trims the origin slash", () => { + expect(script).toContain("https://crawlproof.com/api/ads/video/events"); + expect(script).not.toContain("crawlproof.com//api"); + expect(script).toContain(DECISION); + }); + + it("gates the start on visibility, so an off-screen autoplay is not a view", () => { + expect(script).toContain("IntersectionObserver"); + expect(script).toContain("0.5"); + }); + + it("counts one loop only", () => { + // currentTime going backwards is the wrap; a looping element never fires + // 'ended', so without this a banner would report a completion per loop. + expect(script).toContain("t + 0.25 < last"); + expect(script).toContain("done = true"); + }); + + it("guards every event so a retry cannot double-count", () => { + expect(script).toContain("if (sent[t]) return;"); + }); + + it("cannot throw into the publisher's page", () => { + expect(script.startsWith(""); + expect(out).toBe(""); + }); + + it("appends when there is no body to find, rather than dropping the script", () => { + expect(injectBannerBeacon("
x
", "")).toBe("
x
"); + }); +}); + +describe("in_banner placement", () => { + it("is accepted, and anything invented is not", () => { + expect(normalizePlacement("in_banner")).toBe("in_banner"); + expect(normalizePlacement("in_banner_2")).toBe("preroll"); + }); + + it("reads as its own product in a report", () => { + expect(placementLabel("in_banner")).toBe("in-banner"); + expect(placementLabel("preroll")).toBe("pre-roll"); + }); + + it("carries placement through the funnel row, defaulting to preroll", () => { + expect(videoFunnelRow({ placement: "in_banner" }).placement).toBe("in_banner"); + // A row from before the split is a pre-roll; that is what existed then. + expect(videoFunnelRow({}).placement).toBe("preroll"); + }); +});