ads: server-side impression beacon in middleware (AdSanity parity)

Fires one impression per page render, per slot, server-side — mirrors
AdSanity's WP-level counting model. Picks one banner from each slot's
rotation pool per request. Edge-worker-cached pool refresh, 5min TTL.

Closes the structural gap with historical AdSanity numbers
(client-side counting was dropping 25-40% to ad blockers, plus the
beacon failed silently on fast navigations).
This commit is contained in:
Ryan Salazar
2026-05-24 23:26:02 +00:00
parent 88edf33155
commit 78d992fda7

View File

@@ -1,8 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
// PR firms / writers authenticate at the distribute platform, not here.
// Anything that looks like a WordPress login or the bare /login path
// redirects there so old bookmarks don't dead-end.
const AUTH_TARGET = "https://distribution.relevantmediaproperties.com/login";
const REDIRECT_PATHS = new Set<string>([
@@ -14,8 +12,84 @@ const REDIRECT_PATHS = new Set<string>([
"/register",
]);
export function middleware(req: NextRequest) {
// ─── Server-side impression beacon (AdSanity-equivalent) ────────────────────
//
// Why: client-side beacons drop 20-50% to ad blockers, JS-disabled clients,
// and lost beacons on fast navigation. AdSanity counted server-side — every
// WP page render = 1 impression per zone. To get parity with the historical
// numbers our advertisers expect, we fire impressions here on every HTML
// page request.
//
// For each slot (728x90, 300x600, 300x250) we pick ONE banner from the
// rotation pool per page render — same model AdSanity used. The rotation
// pool is fetched from advertising-rmp and cached per-edge-worker for 5min.
const RMP_BASE =
(process.env.NEXT_PUBLIC_RMP_ADS_API
|| "https://advertising.relevantmediaproperties.com").replace(/\/$/, "");
const PROPERTY = "broadcastbeat";
const SLOTS = ["728x90", "300x600", "300x250"] as const;
type Slot = typeof SLOTS[number];
// Edge worker-local cache. Each region's edge will refresh independently.
let poolCache: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
let poolLoadedAt = 0;
const POOL_TTL_MS = 5 * 60 * 1000;
async function loadPool() {
if (Date.now() - poolLoadedAt < POOL_TTL_MS) return;
try {
const results = await Promise.all(
SLOTS.map(async (size) => {
const res = await fetch(`${RMP_BASE}/api/banners/${PROPERTY}/${size}`, {
cache: "no-store",
});
if (!res.ok) return [size, [] as string[]] as const;
const d = (await res.json()) as { banners?: Array<{ campaign_id?: string }> };
return [size, (d.banners || []).map((b) => b.campaign_id).filter(Boolean) as string[]] as const;
})
);
const next: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
for (const [size, ids] of results) next[size] = ids;
poolCache = next;
poolLoadedAt = Date.now();
} catch {
// keep stale cache on failure
}
}
// Paths that don't count as a "page view" — assets, APIs, edge probes.
const SKIP_RE = /^\/(api|_next|legacy|favicon|robots\.txt|sitemap|opensearch|.*\.(?:css|js|map|png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|mp4|webm|xml|txt|json))$/i;
function fireImpression(req: NextRequest, campaign_id: string) {
const url = new URL(req.url);
fetch(`${RMP_BASE}/api/track/impression`, {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": req.headers.get("user-agent") || "",
"x-forwarded-for":
req.headers.get("x-forwarded-for") ||
req.headers.get("cf-connecting-ip") ||
"",
"referer": req.headers.get("referer") || "",
},
body: JSON.stringify({
campaign_id,
page_path: url.pathname,
page_url: req.url,
viewport: false,
}),
cache: "no-store",
keepalive: true,
}).catch(() => undefined);
}
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
// 1) auth redirects (legacy WP-style logins)
if (
REDIRECT_PATHS.has(path) ||
path.startsWith("/wp-admin/") ||
@@ -23,11 +97,27 @@ export function middleware(req: NextRequest) {
) {
return NextResponse.redirect(AUTH_TARGET, 301);
}
// 2) server-side impression beacons per page render
if (!SKIP_RE.test(path)) {
await loadPool();
for (const slot of SLOTS) {
const pool = poolCache[slot];
if (pool.length === 0) continue;
const picked = pool[Math.floor(Math.random() * pool.length)];
fireImpression(req, picked);
}
}
return NextResponse.next();
}
// Match every path (impression beacon needs site-wide coverage) — the
// SKIP_RE inside the handler filters out asset/API requests so we only
// count real page views.
export const config = {
matcher: [
"/((?!api/|_next/|legacy/|.*\\.).*)",
"/login",
"/wp-login",
"/wp-login.php",