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); }); });