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
58 changes: 57 additions & 1 deletion lib/ads/fraud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code> 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";
}
Expand Down Expand Up @@ -89,13 +142,16 @@ async function checkClickValidity(input: Parameters<typeof assessClickValidity>[
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.
Expand Down
49 changes: 44 additions & 5 deletions tests/contract/ads-click-validity.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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;
Expand All @@ -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);
});
});
Loading