Skip to content

feat(ads): flag repeat impressions so prefetch stops reading as delivery - #192

Merged
ralyodio merged 1 commit into
masterfrom
ads-impression-dedupe
Aug 10, 2026
Merged

ralyodio merged 1 commit into
masterfrom
ads-impression-dedupe

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Closes the impression-side half of the ad metering work started in #191.

The problem

Clicks have had a 6h dedupe window since the ad network shipped (lib/ads/fraud.ts). Impressions had none at all — and they're metered server-side in serveAd at fill time, on all three serving paths (/api/ads/serve, /api/ads/frame, /api/ads/motd). None of that requires a browser, and the terminal path deliberately treats curl as a real client.

So anything fetching a slot on a schedule books an advertiser impression per fetch, with no human involved. Measured on prod today:

surface impressions (90 min) distinct IPs
terminal, src=userdirs 108 1
banner_300x250 79 50

That's a pool refresher firing 12 fetches in ~3 seconds every 10 minutes — roughly 1,700 impressions/day from one machine.

Two deliberate differences from the click rules

  • Keyed on the slot, not the campaign. Each fetch in a burst draws a different campaign at random (12 fetches → 12 campaigns), so campaign-keyed dedupe — the shape used for clicks — would collapse none of it.
  • 60 seconds, not 6 hours. A repeat view an hour later is real delivery and must keep counting. Only a machine lands twice inside a minute. The observed burst spans ~3s, so 60s swallows it whole.

Flagged, never dropped

The impression row is what /a/<short_code> resolves a terminal click back to. Skipping the insert would serve a real advertiser's creative with a click link pointing at nothing — unbilled click, unpaid publisher, which is strictly worse than an inflated count. And since each fetch renders a different campaign, there's no single earlier row that could stand in for the rest without misattributing every later click.

So the row is always written, with a new duplicate boolean, and reporting excludes flagged rows. All five surfaces that read impressions are updated, or the spike just reappears on a different screen: ad_account_series, ad_campaign_totals, ad_campaign_daily_series, ad_campaign_stats, ad_slot_stats.

The probe is best-effort and wrapped in try/catch — it runs on the hot path of every fill, and if it throws the right answer is "count it" rather than losing the impression and the click that may follow. (This also matters because existing tests mock ad_impressions with only an insert method.)

Existing rows default to duplicate=false, so no historical figure moves.

Deploy order

The migration must be applied before this code ships, since the insert writes the new column. Per the repo convention it's applied by hand via psql/MCP, not db push. The code does retry the insert without the new columns if they're missing, so a wrong-order deploy degrades rather than breaks — but the retry also drops short_code, so terminal click URLs would fall back to the long UUID form until the migration lands.

Testing

  • npm run typecheck — clean
  • npm test — 1,426 passed, 1 failed
  • The failure is tracker-geo, which needs the GeoLite2 mmdb. Pre-existing and environmental; verified against the baseline in feat(ads): share the visitor id with /ad.js, salt + rotate IP hashes #191. CI installs it via npm ci.
  • 8 new tests covering slot-keying, rotating-hash matching, the injection guard, and the throw-safe path.

Note on the alternative fix

Marking the pool refresher as a crawler (-A 'sponsor-ad-monitor/1.0') was considered and rejected: serveAd returns houseFill(format) for bot devices, so the per-user pages would fill with CrawlProof house ads and stop showing real advertisers entirely. This PR fixes the counting without touching what gets served.

🤖 Generated with Claude Code

Clicks have had a 6h dedupe window since the ad network shipped. Impressions
had none at all, and they are metered server-side at fill time on all three
serving paths -- so anything fetching a slot on a schedule books an advertiser
impression per fetch, with no human involved. The observed case is a pool
refresher firing 12 fetches in ~3 seconds every 10 minutes: ~1,700 impressions
a day from one machine, against ~39 real web impressions in the same window.

Two deliberate differences from the click rules:

  * Keyed on the SLOT, not the campaign. Each fetch in a burst draws a
    different campaign at random, so campaign-keyed dedupe -- the shape used
    for clicks -- collapses none of it.
  * 60 seconds, not 6 hours. A repeat view an hour later is real delivery and
    has to keep counting; only a machine lands twice inside a minute.

Flagged, never dropped. The impression row is what /a/<short_code> resolves a
terminal click back to, so skipping the insert would serve a real advertiser's
creative with a click link pointing at nothing -- unbilled click, unpaid
publisher, which is strictly worse than an inflated count. Reporting excludes
flagged rows instead, across all five surfaces that read impressions:
ad_account_series, ad_campaign_totals, ad_campaign_daily_series,
ad_campaign_stats, and ad_slot_stats.

The probe is best-effort and wrapped: it runs on the hot path of every fill,
and if it throws the right answer is "count it" rather than losing the
impression and the click that may follow.

Existing rows default to duplicate=false, so no historical figure moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

52 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 45 | LOW: 3

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH manifest-typosquat package.json:59
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:102
MEDIUM sql-template-interpolation scripts/lx-republish-todays-articles.mjs:102
MEDIUM js-dynamic-code-execution tests/careers-page-templates.test.ts:21
MEDIUM js-dynamic-code-execution tests/careers-widget-script.test.ts:69
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/coinpay.test.ts:4

…and 2 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 10, 2026 12:09
@ralyodio
ralyodio merged commit c9ae5dd into master Aug 10, 2026
8 checks passed
ralyodio added a commit that referenced this pull request Aug 10, 2026
#192 shipped a 60s window picked by reasoning about human behaviour rather
than by measurement. Backtesting the exact rule against 7 days of real
impressions shows that was wrong: the machine bursts it targets are compressed
into seconds, so a wide window costs a great deal of real delivery to catch
almost nothing extra.

  window   terminal flagged (target)   web flagged (cost)
   3s              84.5%                     12.2%
   5s              84.6%                     14.2%
  10s              84.7%                     17.5%
  60s              85.4%                     36.6%

60s bought +0.9pp on the target and suppressed 36.6% of real web impressions.
5s keeps essentially all of the burst suppression -- the observed burst spans
~2.5s -- with margin for a slower run, since each fetch in the loop can take up
to its own timeout.

Worth recording why the web number is not noise. Of the 36.6% flagged at 60s,
only 0.1pp came from distinct visitors colliding on a shared ip_hash; the rest
was the same visitor_id re-fetching the same slot. Broken down by gap, the
sub-second repeats are /ad.js clearing data-cp-filled in its .catch() path and
SPA callers re-firing scan() -- a real double-count bug, which the 5s window
still catches. The 10-60s repeats are human reloads and SPA navigation, which
are genuine delivery and should never have been suppressed.

No migration: the stored column comment does not name a duration, and existing
flagged rows are left as they are.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant