Skip to content

tracker: count people, not beacons (visitor rollup + scripted cap) - #263

Merged
ralyodio merged 1 commit into
masterfrom
worktree-tracker-visitor-rollup
Sep 21, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-tracker-visitor-rollup

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Why

datafa.st and crawlproof disagreed by ~100x on four sites: crawlproof said 50k humans, datafa.st said 400. datafa.st was right.

"Human visits" was sum(count) where bucket not like 'bot:%' over tracker_daily_stats, which /api/track bumps for every beacon stats.js auto-fires (pageview, scroll_25/50/75/100, button_click, form_submit, internal_click…), 3–4 per page view, from any user agent that does not announce itself as a bot. Same four sites, from the tracker's own tables on 2026-09-21:

"Human visits", 7d 51,531
Human pageviews, 7d 18,631
Events, last 24h 5,796
Distinct visitor ids, last 24h 420

That 420 is datafa.st's 400. And tracker_events is pruned at 24h with no rollup keeping visitor ids, so weekly uniques could not be stated at all.

What

  • tracker_visitor_daily_stats — one row per (project, UTC day, visitor id) with event/pageview counts and the visitor's side of the human/bot line. tracker_touch_visitor upserts it per beacon in one round trip.
  • Scripted cap — more than 500 events or 200 page views from one visitor in a day flips the row to bot for the day and the route counts every later beacon under bot:scripted. The largest "human" source on bittorrented today is a stock-UA driven browser (398 events on 4 page views, CN, yopmail/jubt*.xyz referrers) that the UA classifier cannot see. Fails open: no visitor id or a failed RPC leaves the UA verdict standing.
  • tracker_visitor_totals / tracker_visitor_daily_series — exact distinct visitors over a window (+ the window before) and per day.
  • UI — stats page, /dashboard cards and /dashboard/analytics lead with Human visitors and Page views; the old number stays as Human events with a definition that says what it is. A failed rollup read drops the tile instead of showing 0. Windows reaching before 2026-09-21 carry a caption.
  • API/CLI — /api/tracker/v1/stats totals is now { visitors, pageviews, events } with visitors/pageviews from the rollup (null when unreadable, never 0). crawlproof stats prints all three. totalsFromSeries no longer returns beacons as visitors, so crawlproof dashboard cost-per-visitor is per visitor.

Migration

20260921120000_tracker_visitor_rollup.sql — applied to production via the Supabase MCP on 2026-09-21 and smoke-tested (touch increments, cap flips at N+1 and is sticky, totals exclude the demoted visitor, test rows deleted). Rows begin today; nothing to backfill from.

Verification

  • tsc --noEmit clean; vitest run 2,258 passed (19 new in tests/tracker-visitor-rollup.test.ts).
  • npm run lint is broken on master independently of this PR (next lint no longer exists in this Next version).

Not in this PR

  • Datacenter/ASN check on visitors (114 of 420 visitor ids today have no resolvable city). Needs an ASN database; the scripted cap covers the worst of it.
  • Session (visit) counts.

🤖 Generated with Claude Code

@ralyodio
ralyodio marked this pull request as ready for review September 21, 2026 18:54
"Human visits" was every beacon from a non-crawler user agent: the page
view plus four scroll depths, every click and every form submit, three to
four per page view. On four properties it read 51,531 in a week while the
raw event table held 420 distinct visitor ids in a day, which is also what
datafa.st reported for the same sites. Nothing outside the 24h raw table
kept a visitor id, so weekly uniques could not be stated at all.

- tracker_visitor_daily_stats: one row per (project, UTC day, visitor id)
  with event and pageview counts and which side of the human/bot line the
  visitor ended the day on. tracker_touch_visitor upserts it per beacon in
  one round trip and applies the scripted cap: more than 500 events or 200
  page views from one visitor in a day flips it to bot for the day, and the
  ingest route counts every later beacon from it under bot:scripted. A
  stock-UA headless browser was the largest "human" source and nothing in
  the classifier could see it.
- tracker_visitor_totals / tracker_visitor_daily_series: exact distinct
  visitors over a window (and the window before) and per day.
- Stats page, /dashboard cards and /dashboard/analytics lead with "Human
  visitors" and "Page views" from the rollup; the old figure stays as
  "Human events" with a definition that says what it is. A failed rollup
  read leaves the tile out rather than showing 0.
- /api/tracker/v1/stats totals: visitors and pageviews come from the
  rollup (null when unreadable, never 0), events is the old number. The
  CLI line prints all three. totalsFromSeries no longer returns beacons as
  visitors, so `crawlproof dashboard` cost-per-visitor is finally per
  visitor.

Migration applied to production 2026-09-21 via the Supabase MCP; rows
begin that day and the UI captions any window that reaches further back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ralyodio
ralyodio force-pushed the worktree-tracker-visitor-rollup branch from 478aeb5 to 5005f4a Compare September 21, 2026 18:57
@ralyodio
ralyodio merged commit 99532f8 into master Sep 21, 2026
8 checks passed
@ralyodio
ralyodio deleted the worktree-tracker-visitor-rollup branch September 21, 2026 18:57
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

48 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 31 | 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-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.

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