Skip to content

fix(ads): count free-tier delivery in the stats box, not just paid - #199

Merged
ralyodio merged 1 commit into
masterfrom
fix/ads-stats-box-free-tier
Aug 18, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/ads-stats-box-free-tier

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What was wrong

/dashboard/ads?range=1w — and 1D, 4H, 1H — showed 0 impressions, 0 clicks, — CTR, $0.00 spend while the "Delivery over time" chart right underneath drew thousands of impressions. 1M / 3M / 1Y / ALL looked fine, which is what made it read as a short-range loading bug.

Nothing was failing to load. The tiles counted paid inventory only, and there has been no paid inventory since 2026-07-31:

last valid click last paid impression day
prod 2026-07-29 2026-07-31

serveAd demotes a self-deal (same account owns the slot and the campaign) to the free tier instead of dropping it — #177, correct and deliberate. But while every slot and every campaign on the network belong to one account, every fill is a self-deal, so 100% of delivery books as tier='free'. 1M and wider still reach back past 2026-07-31 to genuinely paid days. That's the whole "1 week and smaller" pattern.

What this changes

1. The stats box reports delivery, not just revenue. Impressions and Clicks now count paid + free with the split named underneath (1,200 paid · 300 free, or all free backfill), and CTR uses the same totals. Spend stays strictly paid — it's money — and says nothing billable when there was delivery but no spend. Per-campaign rows follow the same rule so a row can't contradict the header above it.

2. Self-deal clicks were invisible, not merely unpaid. ad_charge_click wrote them as valid=false, tier='paid'. Reporting reads exactly two kinds of click:

clicks      = valid                      -- billed
free_clicks = not valid and tier='free'  -- real, unbillable

not valid and tier='paid' is neither — it's what the bot/duplicate/forged path writes. The branches on either side of the self-deal check already write 'free' for the same situation, so this was an inconsistency rather than a policy. The migration fixes the branch and reclassifies the 661 rows it mislabelled, scoped by charged_cents = 0 + slot-owner = campaign-owner + device <> 'bot' so no genuine fraud row moves. (Duplicate rows can't be caught by accident: that check requires an existing valid=true click within 6h, and this bug is why none exists.)

3. bucketAxis dropped the first bucket of every range. The RPC filters on ts >= p_since and then date_bins, so it emits a partial leading bucket that starts before the window. The axis stopped one bucket short and getAccountSeries silently skips any row without a matching point — up to a full bucket of real delivery vanished from both the chart and the totals.

Migration

supabase/migrations/20260818120000_ad_selfdeal_clicks_free_tier.sql — already applied to prod (migrations here are applied by hand; applying before merge is the repo's rule). 661 rows reclassified.

Verification

Prod, as the account, before → after:

range impressions clicks
1W 1 → 16,220 0 → 154
1D 1 → 5,448 0 → 31
4H 0 → 936 0 → 4
1H 0 → 215 0 → 2

pnpm typecheck clean, next build clean, 1,466 unit tests pass, 12 new ones in tests/ads-stats-box.test.ts covering the delivered totals, the split note, and a per-range regression test that the leading bucket survives.

Worth knowing separately

This makes the dashboard honest, it doesn't make the network solvent: no click has billed since 2026-07-29 because there is still only one participant on both sides. That's a business state, not a bug — flagging it because the dashboard will now say all free backfill out loud.

🤖 Generated with Claude Code

The four tiles on /dashboard/ads read 0 for 1W and every shorter range
while the chart directly beneath them drew thousands of impressions.
Nothing failed to load. The tiles counted paid inventory only, and since
2026-07-31 there has been no paid inventory: PR #177 demotes a self-owned
campaign to the free tier rather than dropping it, and while every slot
and every campaign belong to one account, every single fill is a
self-deal. 1M and wider still reached back to genuinely paid days, which
is exactly why the break looked like a short-range bug.

Three fixes, one per layer:

* The tiles now report delivery — paid plus free — with the split named
  underneath, and CTR is computed on the same totals. Spend stays
  strictly paid, because it is money. The per-campaign rows follow the
  same rule so a row can't contradict the header above it.

* ad_charge_click recorded a self-deal click as `valid=false,
  tier='paid'`. Reporting counts `valid` as billed clicks and
  `not valid and tier='free'` as real-but-unbillable ones, so that
  combination — the one the bot/duplicate/forged path writes — made every
  self-deal click invisible. The branches either side of it already write
  'free' for the same situation. Migration fixes the branch and
  reclassifies the 661 rows it mislabelled, scoped so no genuine fraud
  row moves.

* bucketAxis stopped one bucket short of the window start. The RPC
  filters on `ts >= p_since` then date_bins, so it emits a partial
  leading bucket; getAccountSeries skips any row without a matching
  point, so up to a full bucket of real delivery was dropped from both
  the chart and the totals.

Verified against prod: 1W now reports 16,220 impressions and 154 clicks
where it reported 1 and 0.

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

Copy link
Copy Markdown

ThreatCrush Security Scan

35 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 23 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
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 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:98
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 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 6d96595 into master Aug 18, 2026
8 checks passed
@ralyodio
ralyodio deleted the fix/ads-stats-box-free-tier branch August 18, 2026 10:17
ralyodio added a commit that referenced this pull request Sep 1, 2026
The earnings page and the slots page read `impressions` and `clicks` off
ad_slot_stats / ad_campaign_stats. Those are the tier-'paid' halves of the
views; the free halves sit in `free_impressions` / `free_clicks`, which neither
page ever selected. Once every slot and every campaign belonged to one account,
ad_charge_click took its self-deal branch on every fill and paid delivery
stopped: 17,303 paid impressions in July, 3 in August, 0 in September. So both
pages have read zero ever since, while the campaigns dashboard — which sums both
halves after #199 — showed six figures over the same window. rssamplifier.com
delivered 116,071 impressions and its row said 0.

#199 fixed this measure on the campaigns dashboard and did not reach here.

The same views are also lifetime, with no window at all, while the page and the
PDF header both promise "last N days" — so the figures were wrong twice over:
the wrong tier, for the wrong period. loadEarnings now reads ad_campaign_totals
and a new ad_slot_totals, both windowed and both returning each tier, and the
window is the same whole-UTC-day span the chart above the tables already drew.

Money stays lifetime on purpose, and the page now says so. "Available to
withdraw" is lifetime earnings minus lifetime payouts; scoping either side to 30
days would under-report a balance the account is actually owed. The tiles are
grouped under "Balance · all time" and the tables under "Delivery · last 30
days" rather than one heading claiming a period for both.

Invalid clicks were the third gap. A click we refuse to bill is recorded with
valid = false, and resolveClick's insert left `tier` at its 'paid' default, so
the row matched neither the billed bucket (valid) nor the free bucket (not valid
and tier = 'free'). 57,060 clicks had collected there, visible to nothing. They
stay out of the delivery figures deliberately — a bot click is not delivery, and
folding it in would put a 16% CTR on the page — but ad_slot_totals returns the
count and the page reports it in a line of its own. The insert now writes `tier`
explicitly, so the bucket is a decision rather than a default.

Verified against prod: ad_slot_totals over the last 30 days returns 174,959
impressions / 5,550 clicks / 57,063 invalid, matching a raw count over
ad_impressions and ad_clicks exactly, in ~360ms against an 8s statement timeout.

The migration adds a function and alters nothing, so it is already applied.


Claude-Session: https://claude.ai/code/session_0147H2VoYJS2WUz4JQmaLQKV

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Sep 1, 2026
)

/dashboard/ads read 0 for everything, intermittently, while the account
was delivering 176,264 impressions over the window. The measure was
right this time -- #199 and #225 both hold -- and the data was there.
The RPCs were being cancelled.

ad_account_series, ad_campaign_totals and the two daily-series functions
are security invoker, so the RLS policy on ad_impressions ("slot is mine
OR campaign is mine") joins the plan. With it the planner abandons the
hash join for a nested loop: one index scan per owned campaign, 139
loops, ~176k random heap fetches, 401,791 buffers (~3GB) touched per
page load. ad_impressions passed 364k rows / 154MB and traffic ran 10x
baseline on 2026-09-01, which tipped it over the 8s statement_timeout on
`authenticated` -- 34 cancellations in two hours, surfacing as HTTP 500
on three RPCs.

Each function already did its own authorisation and never relied on RLS
for it: every read is gated by `<x>_id in (select id from owned)` where
owned is `owner_id = auth.uid()`. Running them as definer drops the RLS
subplans and the planner picks the hash join again: 11,818 buffers /
208ms against 401,791 / 932ms, byte-identical output. Verified with a
stranger's JWT that all five still return 0 rows. Note the guard is `in`
and not `not in`, so an anon caller gets an empty `owned` rather than
everything.

The second half is why this took a log dive to find. Every loader
swallowed the error into a zero-filled result, so a cancelled query and
a genuinely quiet range produced identical output and the page reported
four confident zeros over a live network. The zero-fill stays -- one bad
panel should not take the page down -- but the loaders now return
Loaded<T> carrying `failed`, log the error instead of discarding it, and
the four ad surfaces render "couldn't load" in place of the zeros. The
PDF report says so too: that document goes to accountants, where a
silent zero is read as fact.

Migration is already applied to prod.


Claude-Session: https://claude.ai/code/session_01318XDMF7H8AtH7h4ZjweTS

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