From e4dae7761e7a78b65c03a2fbfcaffb2d35e24e5e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 22 Sep 2026 08:52:50 +0000 Subject: [PATCH] ads: refuse a click whose impression is too old for its audience A distributed headless-browser crawler fetches rssamplifier.com's feed documents with a plain Chrome user agent, harvests the /a/ link out of the injected feed_item, and requests it a median of 54 hours later from a different address. Every other check passed: the impression exists, the campaign matches, the address never repeats inside the dedupe window. It booked as free-tier delivery at ~1,100 clicks every four hours, and /dashboard/ads?range=4h read a 101% CTR because those impressions sat two days outside the window and were mostly flagged duplicate. Nothing billed, but the paper auction was being steered by it. Click validity now also refuses a cited impression that is older than its audience could plausibly still be looking at: 6h when it was served to a browser (real browser clicks over 30 days: p90 2.7 min, none past 6h), 24h for a terminal (non-crawler terminal clicks: 96% within a day), and no ceiling for a feed reader, which keeps an unread item for as long as the subscriber leaves it. Refused clicks land in the existing invalid bucket that ad_slot_totals already reports, so they stay visible without counting as delivery. Co-Authored-By: Claude Fable 5.1 --- lib/ads/fraud.ts | 58 ++++++++++++++++++++++- tests/contract/ads-click-validity.test.ts | 49 +++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/lib/ads/fraud.ts b/lib/ads/fraud.ts index 0867963..3acdb4e 100644 --- a/lib/ads/fraud.ts +++ b/lib/ads/fraud.ts @@ -9,6 +9,59 @@ import { serviceClient } from "@/lib/supabase/service"; // stored under across the same span. export const CLICK_DEDUPE_WINDOW_MS = 6 * 60 * 60 * 1000; // 6h +// How long after its impression a click can still count as delivery, by what +// kind of client the impression was served to. +// +// This exists because of a distributed headless-browser crawler that fetches +// rssamplifier.com's feed documents with a plain Chrome user agent, harvests +// the /a/ link out of the injected feed_item, and requests it a median +// of 54 hours later from a different address. Nothing else about the click is +// wrong: the impression exists, the campaign matches, the address never +// repeats inside the dedupe window. It passed as delivery at ~1,100 clicks +// every four hours, and the ads dashboard read a 101% CTR because the +// impressions those clicks pointed at sat two days outside the window and +// were mostly flagged duplicate. Nothing billed (the network is self-deal), +// but the paper auction was being steered by it. +// +// Measured over 30 days of clicks that did not carry a bot user agent: +// +// impression served to which clicks delay from impression +// a browser all formats p90 2.7 min, none past 6h +// a terminal (curl) non-crawler 85% within 6h, 96% within 24h +// a feed reader non-crawler spread over days +// +// A browser has nothing holding the item once the page is gone, so a click +// hours later did not come from a person looking at that page. A terminal +// shows the MOTD at the next login, so a day is generous. A feed reader keeps +// the item for as long as the subscriber leaves it unread, so there is no +// ceiling a real reader could not exceed. +export const CLICK_MAX_AGE_BROWSER_MS = CLICK_DEDUPE_WINDOW_MS; +export const CLICK_MAX_AGE_TERMINAL_MS = 24 * 60 * 60 * 1000; + +/** Longest a click may trail the impression it cites, or null for no ceiling. */ +export function maxClickAgeMs(impressionDevice?: string | null): number | null { + switch (impressionDevice) { + case "feed": + return null; + case "terminal": + return CLICK_MAX_AGE_TERMINAL_MS; + default: + return CLICK_MAX_AGE_BROWSER_MS; + } +} + +/** Is a click at `now` too long after an impression served at `ts` to `device`? */ +export function isStaleImpression( + imp: { ts?: string | null; device?: string | null }, + now: number = Date.now(), +): boolean { + const max = maxClickAgeMs(imp.device); + if (max == null || !imp.ts) return false; + const served = Date.parse(imp.ts); + if (Number.isNaN(served)) return false; + return now - served > max; +} + export function isBotDevice(device?: string | null): boolean { return device === "bot"; } @@ -89,13 +142,16 @@ async function checkClickValidity(input: Parameters[ if (input.impressionId) { const { data: imp, error } = await sb .from("ad_impressions") - .select("campaign_id, slot_id") + .select("campaign_id, slot_id, ts, device") .eq("id", input.impressionId) .maybeSingle(); if (error) return { valid: false, reason: "validation_unavailable" }; if (!imp) return { valid: false, reason: "no_impression" }; if (imp.campaign_id !== input.campaignId) return { valid: false, reason: "impression_mismatch" }; if (input.slotId && imp.slot_id !== input.slotId) return { valid: false, reason: "impression_mismatch" }; + // A real impression, but too old for whoever saw it to still be the one + // clicking. See maxClickAgeMs for where each ceiling comes from. + if (isStaleImpression(imp)) return { valid: false, reason: "stale_impression" }; } // 3. Dedupe on this campaign by visitor id or ip hash within the window. diff --git a/tests/contract/ads-click-validity.test.ts b/tests/contract/ads-click-validity.test.ts index a1ca99b..3f29c82 100644 --- a/tests/contract/ads-click-validity.test.ts +++ b/tests/contract/ads-click-validity.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const state = vi.hoisted(() => ({ error: false, throws: false, duplicate: false, filter: "", impression: { campaign_id: "campaign", slot_id: "slot" } })); +const HOUR = 60 * 60 * 1000; +const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); +const fresh = () => ({ campaign_id: "campaign", slot_id: "slot", ts: ago(60_000), device: "desktop" as string | null }); + +const state = vi.hoisted(() => ({ error: false, throws: false, duplicate: false, filter: "", impression: { campaign_id: "campaign", slot_id: "slot", ts: "", device: "desktop" as string | null } })); vi.mock("@/lib/supabase/service", () => ({ serviceClient: () => ({ from() { if (state.throws) throw new Error("database unavailable"); @@ -15,12 +19,13 @@ vi.mock("@/lib/supabase/service", () => ({ serviceClient: () => ({ return q; }, }) })); -import { assessClickValidity } from "@/lib/ads/fraud"; +import { assessClickValidity, isStaleImpression, maxClickAgeMs } from "@/lib/ads/fraud"; beforeEach(() => { state.error = false; state.throws = false; state.duplicate = false; state.filter = ""; - state.impression = { campaign_id: "campaign", slot_id: "slot" }; + state.impression = fresh(); }); const input = { campaignId: "campaign", slotId: "slot", visitorId: "visitor", ipHashes: ["abc123"] }; +const cited = { ...input, impressionId: "impression" }; describe("click validation", () => { it("deduplicates both paid and free delivery while excluding invalid traffic", async () => { state.duplicate = true; @@ -30,17 +35,51 @@ describe("click validation", () => { it("withholds billing if a database lookup fails or throws", async () => { state.error = true; expect((await assessClickValidity(input)).reason).toBe("validation_unavailable"); - expect((await assessClickValidity({ ...input, impressionId: "impression" })).reason).toBe("validation_unavailable"); + expect((await assessClickValidity(cited)).reason).toBe("validation_unavailable"); state.throws = true; expect((await assessClickValidity(input)).reason).toBe("validation_unavailable"); }); it("rejects bots, mismatched impressions and missing identities", async () => { expect((await assessClickValidity({ ...input, device: "bot" })).reason).toBe("bot"); state.impression.campaign_id = "forged"; - expect((await assessClickValidity({ ...input, impressionId: "impression" })).reason).toBe("impression_mismatch"); + expect((await assessClickValidity(cited)).reason).toBe("impression_mismatch"); expect((await assessClickValidity({ campaignId: "campaign", visitorId: "v),injected" })).reason).toBe("missing_identity"); }); it("admits a new recognized visitor after successful validation", async () => { expect(await assessClickValidity(input)).toEqual({ valid: true }); + expect(await assessClickValidity(cited)).toEqual({ valid: true }); + }); +}); + +// The crawler shape: a feed document fetched with a browser user agent, its ad +// link requested two days later. Everything else about the click checks out, +// so the impression's age is the only thing that can refuse it. +describe("stale impressions", () => { + it("refuses a click that trails a browser impression by more than the click window", async () => { + state.impression = { ...fresh(), ts: ago(54 * HOUR) }; + expect(await assessClickValidity(cited)).toEqual({ valid: false, reason: "stale_impression" }); + state.impression = { ...fresh(), ts: ago(6 * HOUR + 1000), device: "mobile" }; + expect((await assessClickValidity(cited)).reason).toBe("stale_impression"); + }); + it("keeps a browser click inside the window, whatever the format", async () => { + state.impression = { ...fresh(), ts: ago(3 * HOUR) }; + expect(await assessClickValidity(cited)).toEqual({ valid: true }); + }); + it("gives a terminal a day, because the MOTD is read at the next login", async () => { + state.impression = { ...fresh(), ts: ago(12 * HOUR), device: "terminal" }; + expect(await assessClickValidity(cited)).toEqual({ valid: true }); + state.impression = { ...fresh(), ts: ago(2 * 24 * HOUR), device: "terminal" }; + expect((await assessClickValidity(cited)).reason).toBe("stale_impression"); + }); + it("never ages out a feed reader's click; unread items sit for days", async () => { + state.impression = { ...fresh(), ts: ago(9 * 24 * HOUR), device: "feed" }; + expect(await assessClickValidity(cited)).toEqual({ valid: true }); + }); + it("treats an impression with no device or no timestamp conservatively", () => { + expect(maxClickAgeMs(null)).toBe(6 * HOUR); + expect(maxClickAgeMs("feed")).toBeNull(); + expect(isStaleImpression({ ts: null, device: "desktop" })).toBe(false); + expect(isStaleImpression({ ts: "not a date", device: "desktop" })).toBe(false); + expect(isStaleImpression({ ts: ago(7 * HOUR), device: null })).toBe(true); }); });