Skip to content

Stop the dashboard reporting a timed-out tracker query as 0 pageviews - #227

Open
ralyodio wants to merge 1 commit into
masterfrom
worktree-tracker-stats-timeouts
Open

Stop the dashboard reporting a timed-out tracker query as 0 pageviews#227
ralyodio wants to merge 1 commit into
masterfrom
worktree-tracker-stats-timeouts

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Every project on /dashboard read "0 pageviews" while ingest was writing a row a second. Same shape as the ad dashboard bug (#226), different cause — and the fix that worked there would have been unsafe here.

What was happening

dashboard_project_pageviews was cancelled by the 8s statement_timeout and returned HTTP 500 on roughly half of loads. /dashboard/analytics fires eleven of these RPCs concurrently and was failing on nearly all of them (tracker_top_actions_multi, tracker_event_mix_multi and tracker_top_referrers_multi were at 7/7 in 24h).

Every loader read only dataconst { data } = await supabase.rpc(...) — so a cancelled query and a genuinely quiet week were byte-identical, and the error object was discarded. 55 "canceling statement due to statement timeout" in Postgres' logs was the only surviving evidence.

Not #226 again

Worth stating plainly, because the obvious move is to flip these to security definer as #226 did. That is both wrong and unsafe here:

  • Wrong — RLS is not what costs. The same query as postgres, with no policy in the plan, still took 8.4s. tracker_event_daily_stats_project_event_idx is (project_id, event) with no day, so for project in (...) and event = 'pageview' and day >= X the planner matched 373,506 index entries, heap-fetched every one to read day and count, and threw 228,872 away on the filter.
  • Unsafe — the ad RPCs each authorised themselves (<x>_id in (select id from owned)). Every tracker_*_multi takes p_projects straight from the caller and leans entirely on RLS. Made definer as they stand, any authenticated user could pass another account's project ids and read their analytics.

They stay security invoker. A stranger's JWT was verified to return 0 rows from all of them after the change.

The fix

Two covering indexes so the aggregates run index-only, the trap index dropped, and work_mem raised (per function, not globally — eleven run concurrently) on the three panels that spilled their HashAggregate to disk.

Measured on prod as the 48-project owner, with RLS on:

RPC before after
dashboard_project_pageviews 9,401ms / 152,894 buf 115ms / 19,647
tracker_top_pages_multi 5,764ms / 77,663 buf 752ms / 22,764
tracker_top_actions_multi 1,927ms / 326,441 buf 635ms / 41,379
tracker_top_referrers_multi 1,518ms / 328,030 buf ~1.7s / 41,358

page_path rides in the INCLUDE of the (project_id, event, day) index specifically so tracker_top_pages_multi is index-only; without it the planner still picks that index for the event predicate but heap-fetches all 373,710 matching rows — measured at 111,534 buffers, worse than before the index existed.

Trade-off worth knowing: count is now an indexed value, so the per-event upsert can no longer take the HOT path. Ingest is ~1-3 events/sec against 1.2M rows, so this is the right side of the trade, but it is the thing to watch if ingest grows an order of magnitude. Longer term this is still the rollup problem #226 flagged.

So it cannot happen silently a fifth time

The zero-fill stays — one dead panel must not take the page down — but Loaded<T> now carries a failed flag, the error is logged, and both surfaces render the "couldn't load" banner instead of a confident 0. That helper and the banner move out of lib/ads / components/ads, since both halves of the product have now had this same bug (#199, #225, #226, this).

Deploy

The migration is already applied to prod (2026-09-02) — the indexes were built CONCURRENTLY against the live 326MB table under ingest, so the file is if not exists and replay is a no-op. Prod is already fast; merging ships the error-surfacing UI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CT7T7ZR3v1VuV6VRH93cdT

Every project on /dashboard read "0 pageviews" while ingest was writing a
row a second. Same shape as the ad dashboard bug (#226), different cause,
and the fix that worked there would have been unsafe here.

dashboard_project_pageviews was being cancelled by the 8s statement_timeout
and returning HTTP 500 on roughly half of loads; /dashboard/analytics fires
eleven of these RPCs concurrently and was failing on nearly all of them.
Every loader read only `data` -- `const { data } = await supabase.rpc(...)`
-- so a cancelled query and a genuinely quiet week were byte-identical and
nothing was logged.

Not RLS this time. The same query as `postgres`, with no policy in the plan,
still took 8.4s: tracker_event_daily_stats_project_event_idx is
(project_id, event) with no `day`, so the planner matched 373,506 index
entries, heap-fetched every one, and discarded 228,872 on the day filter.
So this is NOT a repeat of #226, and making these definer would also be
unsafe -- the ad RPCs each authorised themselves, while every
tracker_*_multi takes p_projects straight from the caller and leans entirely
on RLS. Definer as they stand, any authenticated user could read another
account's analytics by passing their project ids.

Two covering indexes instead, so the aggregates run index-only, plus
work_mem raised on the three panels that spilled their HashAggregate to
disk. Measured on prod as the 48-project owner with RLS on:

  dashboard_project_pageviews   9,401ms / 152,894 buf  ->  115ms / 19,647
  tracker_top_pages_multi       5,764ms /  77,663 buf  ->  752ms / 22,764
  tracker_top_actions_multi     1,927ms / 326,441 buf  ->  635ms / 41,379
  tracker_top_referrers_multi   1,518ms / 328,030 buf  ->  ~1.7s / 41,358

A stranger's JWT still returns 0 rows from all of them; the functions stay
security invoker.

The zero-fill itself stays -- one dead panel must not take the page down --
but `Loaded<T>` now carries a `failed` flag and both surfaces render the
"couldn't load" banner instead of a confident 0. That helper and the banner
move out of lib/ads and components/ads, since both halves of the product
have now had this same bug.

Migration applied to prod 2026-09-02; indexes were built CONCURRENTLY
against the live table, so the file is `if not exists`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT7T7ZR3v1VuV6VRH93cdT
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

39 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
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 redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
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
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.

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