Skip to content

Report the presentation rotation, and refuse to fake the rate - #322

Merged
ralyodio merged 1 commit into
masterfrom
feat/ads-media-reporting
Sep 25, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/ads-media-reporting

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

#316 records which medium every fill was served as. Nothing read it — which made the rotation randomised delivery that taught nothing. This adds the read: a Delivery by medium card on /dashboard/ads, under the trend chart.

Why a rollup and not a query

ad_impressions is ~376k rows growing ~90k/day, a 30-day window selects about half the table, and the 8s statement_timeout on authenticated was already cancelling reporting RPCs that scanned it — the whole reason 20260902140000_ad_stats_rollups exists. A group by media over the same raw range walks straight back into it.

So: ad_stats_owner_media_daily (owner_id, day, media), refreshed on pg_cron beside the existing rollup, read by ad_owner_media_split(p_since) which blends rollup rows for closed days with raw for the live edge — the same exact split the other five reporting RPCs use, so the raw read is at most one day wide however long the window.

Daily grain, not the account series' hourly: this is a comparison table and nothing in it is plotted at 4-hour buckets, so the finer grain would cost rows and buy nothing. ~6 rows per owner per day.

The part worth arguing about: what the card does not show

CTR per medium is the obvious report, and it is structurally unreadable on this network. Every slot and every campaign belong to one account, so clicks book as free self-deal; there has not been a valid click since 2026-07-29. Verified again on prod while building this:

last_valid_click: (none)     valid_clicks_30d: 0

Five rows of 0.000% is not a neutral way to present that — it reads as a finished experiment that found motion worthless, which is the single most likely way this table gets misread. So the rate column is withheld below 30 attributed clicks and replaced by a note naming the structural cause. "Not enough data yet" would tell the reader to wait, and waiting will not fix a one-account network. The note points at playback (#320) as the signal that does discriminate today.

Two denominators that would each have read as a real number if got wrong

  • Share is denominated on rotated delivery, not on everything in the window. The pre-rotation archive is far larger than anything the rotation has served, so including it would show all five arms at ~0% indefinitely.
  • A NULL media is unknown, never static. Folding the archive into static would make static the permanent winner of an experiment it never ran in. Clicks whose impression_id no longer resolves land there too — ad_clicks is on delete set null, and serveAd synthesises an impression id when its own insert failed.

Both are pinned by tests rather than left to review.

Also: a click's medium comes from the impression it came from, not from its creative. The same creative serves several arms, so inferring it from the creative would be wrong by construction.

Verified

Migration applied on dev2 ahead of this, with the notify pgrst, 'reload schema' that a hand-applied column needs. Then exercised for real:

-- ad_stats_media_rollup_refresh() output, live rows
 2026-09-25 | unknown |  9841 impressions | 15 free clicks
 2026-09-25 | static  |  1084             |  0
 2026-09-25 | image   |     6             |  0

-- ad_owner_media_split() as the ads account, inside a txn with a spoofed JWT
 unknown | 25763 | 2116 clicks
 static  |  1121 |    0
 image   |     6 |    0
  • tsc --noEmit: clean.
  • Full suite: 2699 passed, 0 failed (212 files). 13 new tests in tests/ads-media-stats.

One live-testing gotcha worth recording

Rapid same-IP sampling cannot produce rotated rows in this report. isDuplicateImpression ORs on visitor or IP hash inside a 5s window, so a burst of probe fills all flag duplicate and the rollup (correctly) excludes them — the media columns read all-static no matter what was actually served. Sampling at 6s intervals with distinct visitor ids produces the real mix; over 21 such fills on one live 300x250 slot: image 8, static 7, gif 3, audio 2, video 1.

(And separately: curl gets unmetered house ads, which are always static. Both traps cost me a wrong conclusion before I caught them.)

Follow-ups filed

🤖 Generated with Claude Code

#316 records which medium every fill was served as. Nothing read it, which made
the rotation randomised delivery that taught nothing. This adds the read.

A rollup rather than a query over raw events, for the reason 20260902140000
exists: ad_impressions is ~376k rows growing ~90k/day, a 30-day window selects
about half the table, and the 8s statement_timeout on `authenticated` was
already cancelling reporting RPCs that scanned it. A `group by media` over the
same range walks straight back into that. Grain is (owner_id, day, media) —
daily, not the account series' hourly, because this is a comparison table and
nothing here is plotted at 4-hour buckets.

The part worth arguing about is what the card does NOT show. CTR per medium is
the obvious report and it is unreadable on this network: every slot and every
campaign belong to one account, so clicks book as free self-deal and there has
not been a valid click since 2026-07-29. Five rows of 0.000% is not a neutral
presentation of that — it reads as a finished experiment that found motion
worthless. So the rate column is withheld below 30 attributed clicks and
replaced by a note naming the structural cause, because "not enough data yet"
would tell the reader to wait and waiting will not fix it. Playback is the
signal that does discriminate today (#320), and the note says so.

Two denominators that would each have read as a real number if got wrong:

  * share is denominated on ROTATED delivery, not on everything in the window.
    The pre-rotation archive is far larger than anything the rotation has
    served, so including it would show all five arms at ~0% indefinitely.
  * a NULL media is 'unknown', never 'static'. Folding the archive into static
    would make static the permanent winner of an experiment it never ran in.
    Clicks whose impression_id no longer resolves land there too — ad_clicks is
    `on delete set null` and serveAd synthesises an id when its insert failed.

A click's medium comes from the impression it came from, not from its creative:
the same creative serves several arms, so inferring it would be wrong by
construction.

Migration applied on dev2 ahead of this, with the PostgREST schema reload.

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

Copy link
Copy Markdown

ThreatCrush Security Scan

49 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 32 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
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 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 js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-predictable-temp-path ops/selfhost/server/setup-supabase.sh:201
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit e0c1cdb into master Sep 25, 2026
10 checks passed
@ralyodio
ralyodio deleted the feat/ads-media-reporting branch September 25, 2026 06:07
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