Skip to content

fix(ads): tune the impression dedupe window from 60s to 5s - #193

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

ralyodio merged 1 commit into
masterfrom
ads-dedupe-window-tuning

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Corrects a number I picked badly in #192.

What was wrong

#192 shipped a 60 second impression dedupe window. I chose that by reasoning about human behaviour — "no human pattern lands inside a minute twice by accident" — not by measuring. Backtesting the exact shipped rule (same slot, same visitor or same ip_hash, within the window) against 7 days of real impressions shows it is wrong:

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

Terminal is essentially flat — the machine bursts are compressed into ~2.5s, so widening the window catches almost nothing extra. Web triples. 60s bought +0.9pp on the thing it was aimed at and suppressed 36.6% of real web impressions.

This is live right now, so advertisers are currently seeing web impression counts about a third lower than they should be.

Why the web number is not noise

Two checks before concluding:

  • Not IP collisions. Only 0.1pp of the 36.6% came from distinct visitors sharing an ip_hash behind NAT. The rest is the same visitor_id re-fetching the same slot. (I initially assumed the opposite and was wrong.)
  • Split by gap, the repeats fall into two populations:
    • sub-second (517 events) — /ad.js clears data-cp-filled in its .catch() path, and SPA callers re-fire scan(). That is a genuine double-count bug, and the 5s window still catches it.
    • 10–60s (1,139 events) — human reloads and SPA navigation. That is real delivery, and suppressing it was simply wrong.

5s sits cleanly between the two, with margin for a slower burst since each curl in the refresher loop can run up to its own timeout.

Not included

The underlying /ad.js double-fetch bug is left alone. Its effect on counts is already absorbed by the dedupe; what remains is a wasted HTTP request per occurrence. Fixing it properly means deciding whether a slot whose fetch failed should ever retry — an impression may already have been metered server-side, so retrying double-counts while not retrying leaves the slot blank on a transient blip. That trade-off deserves its own change.

Testing

  • npm run typecheck — clean
  • npm test — 1,426 passed, 1 failed (tracker-geo, pre-existing/environmental, needs the GeoLite2 mmdb; CI installs it)
  • Dedupe tests updated to pin 5s and assert it stays well below the click window.

No migration. The stored column comment does not name a duration, and rows already flagged at 60s are left as they are.

🤖 Generated with Claude Code

#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>
@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 merged commit e194ef3 into master Aug 10, 2026
8 checks passed
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