Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -289,11 +290,11 @@ export default async function CampaignDetailPage({
<VideoRenderCard jobId={videoJobId} campaignKind={classifyCampaign(campaign.destination_url)} />
</div>

{videoFunnel && (
<div className="mt-4">
<VideoFunnelCard row={videoFunnel} />
{videoFunnel.map((row) => (
<div key={`${row.campaignId}:${row.placement}`} className="mt-4">
<VideoFunnelCard row={row} />
</div>
)}
))}

{creatives.length > 0 && (
<div className="mt-6">
Expand Down
39 changes: 38 additions & 1 deletion app/api/ads/frame/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
69 changes: 67 additions & 2 deletions app/api/ads/video/events/route.ts
Original file line number Diff line number Diff line change
@@ -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=<decision>&t=<type>&m=<mediaMs>&p=<playedMs> -> 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
Expand All @@ -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";
Expand All @@ -25,7 +33,7 @@ function cors(request: Request): Record<string, string> {
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",
Expand Down Expand Up @@ -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 `<img>` 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<string, string>) {
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);
}
16 changes: 12 additions & 4 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,27 +490,35 @@ async function cmdAds(args: Args): Promise<number> {
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<string, unknown>[]) ?? [];
const slots = (json.slots as Record<string, unknown>[]) ?? [];

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`,
);
}
}
Expand Down
7 changes: 5 additions & 2 deletions components/ads/video-funnel-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,9 +28,11 @@ export function VideoFunnelCard({ row }: { row: VideoFunnelRow | null }) {

return (
<div className="card p-4">
<h2 className="font-semibold">Playback</h2>
<h2 className="font-semibold">Playback · {where}</h2>
<p className="mt-1 text-xs text-[var(--color-muted)]">
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."}
</p>

<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
Expand Down
146 changes: 146 additions & 0 deletions lib/ads/video/bannerBeacon.ts
Original file line number Diff line number Diff line change
@@ -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 `<script>(function(){
try {
var D = ${JSON.stringify(decisionId)};
var E = ${JSON.stringify(endpoint)};
var v = document.querySelector('video, audio');
if (!v) return;
var sent = {};
function beacon(t, extra) {
if (sent[t]) return;
sent[t] = 1;
try {
var q = E + '?d=' + encodeURIComponent(D) + '&t=' + encodeURIComponent(t);
if (extra && extra.m != null) q += '&m=' + Math.round(extra.m);
if (extra && extra.p != null) q += '&p=' + Math.round(extra.p);
if (extra && extra.e) q += '&e=' + encodeURIComponent(extra.e);
new Image().src = q;
} catch (_) {}
}

beacon('asset_requested');

// Half the unit on screen, which is the line the rest of the industry
// draws too. Without IntersectionObserver we assume visible rather than
// assume hidden: the fallback should under-report nothing it can see.
var visible = true;
try {
if (window.IntersectionObserver) {
visible = false;
new IntersectionObserver(function(entries){
for (var i = 0; i < entries.length; i++) visible = entries[i].intersectionRatio >= 0.5;
}, { threshold: [0, 0.5, 1] }).observe(v);
}
} catch (_) {}

// The first loop only. currentTime resets to 0 when it wraps, so the wrap
// is detectable without waiting for 'ended' — which a looping element
// never fires at all.
var done = false, last = 0, started = 0, playedMs = 0, tick = 0;
function progress() {
if (done) return;
var now = Date.now();
if (started && tick && !v.paused) playedMs += Math.min(now - tick, 2000);
tick = now;

var t = v.currentTime || 0;
var dur = v.duration;
if (!dur || !isFinite(dur) || dur <= 0) { last = t; return; }

if (!started && visible && !v.paused) {
started = now;
beacon('start', { m: 0, p: 0 });
}
if (!started) { last = t; return; }

// Wrapped: the first loop finished.
if (t + 0.25 < last) {
beacon('complete', { m: Math.round(dur * 1000), p: playedMs });
done = true;
return;
}
last = t;

var pct = t / dur;
if (pct >= 0.25) beacon('first_quartile', { m: t * 1000, p: playedMs });
if (pct >= 0.5) beacon('midpoint', { m: t * 1000, p: playedMs });
if (pct >= 0.75) beacon('third_quartile', { m: t * 1000, p: playedMs });
}

v.addEventListener('timeupdate', progress);
v.addEventListener('ended', function(){
if (done) return;
beacon('complete', { m: (v.duration || 0) * 1000, p: playedMs });
done = true;
});
v.addEventListener('error', function(){
beacon('error', { e: (v.error && v.error.code) ? ('media_' + v.error.code) : 'media_error' });
});

// The page going away before the first loop finished. Not a rejection —
// nobody chose to start this — but it is the difference between a unit
// that played through and one that was scrolled past.
window.addEventListener('pagehide', function(){
if (done || !started) return;
beacon('abandon', { m: (v.currentTime || 0) * 1000, p: playedMs });
});
} catch (_) {}
})();</script>`;
}

/**
* Put the script in the document.
*
* Before `</body>` 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("</body>")) return html + script;
return html.replace("</body>", `${script}</body>`);
}
Loading