From 5005f4a82e49bcdcb393d21ecb385a6fe95068ea Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Mon, 21 Sep 2026 18:31:25 +0000
Subject: [PATCH] =?UTF-8?q?tracker:=20count=20people,=20not=20beacons=20?=
=?UTF-8?q?=E2=80=94=20visitor=20rollup=20+=20scripted=20cap?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"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
---
app/(app)/dashboard/analytics/page.tsx | 77 ++++++-
app/(app)/dashboard/page.tsx | 46 +++-
.../dashboard/projects/[id]/stats/page.tsx | 48 ++--
app/api/track/route.ts | 42 +++-
lib/dashboard/stats-text.ts | 10 +-
lib/tracker/apiStats.ts | 55 ++++-
lib/tracker/humans.ts | 22 +-
lib/tracker/scripted.ts | 74 ++++++
lib/tracker/visitors.ts | 139 ++++++++++++
lib/tracker/who.ts | 63 +++++-
.../20260921120000_tracker_visitor_rollup.sql | 213 +++++++++++++++++
tests/tracker-api-stats.test.ts | 18 +-
tests/tracker-visitor-rollup.test.ts | 214 ++++++++++++++++++
13 files changed, 951 insertions(+), 70 deletions(-)
create mode 100644 lib/tracker/scripted.ts
create mode 100644 lib/tracker/visitors.ts
create mode 100644 supabase/migrations/20260921120000_tracker_visitor_rollup.sql
create mode 100644 tests/tracker-visitor-rollup.test.ts
diff --git a/app/(app)/dashboard/analytics/page.tsx b/app/(app)/dashboard/analytics/page.tsx
index 4c8edad..7b7a649 100644
--- a/app/(app)/dashboard/analytics/page.tsx
+++ b/app/(app)/dashboard/analytics/page.tsx
@@ -30,8 +30,17 @@ import {
BOTS_LABEL,
HUMANS_DEFINITION,
HUMANS_LABEL,
+ VISITORS_DEFINITION,
+ VISITORS_LABEL,
humansFrom,
} from "@/lib/tracker/humans";
+import {
+ VISITORS_CAPTION,
+ fetchVisitorTotals,
+ sumVisitorTotals,
+ visitorsPartial,
+ type VisitorTotals,
+} from "@/lib/tracker/visitors";
import {
TrackerAnalytics,
type TrackerListItem,
@@ -71,14 +80,18 @@ type PortfolioProject = {
organization_id?: string | null;
};
-// Every figure on this page leads with humans (bucket not `bot:`, AI referrals
-// included) and shows bot crawls apart. See lib/tracker/humans.ts.
+// Every figure on this page leads with people — distinct visitors from the
+// visitor rollup — then human EVENTS (bucket not `bot:`, AI referrals
+// included; several beacons per page view) and shows bot crawls apart. See
+// lib/tracker/humans.ts and lib/tracker/visitors.ts.
type ProjectRow = {
project: PortfolioProject;
totals: ProjectTotals;
- /** Human visits, this window vs the one before. */
+ /** Distinct human visitors this window vs the one before; null when the rollup is unavailable. */
+ visitors: VisitorTotals | null;
+ /** Human events, this window vs the one before. */
trend: Trend;
- /** Daily human visits for the sparkline; null when over the row budget. */
+ /** Daily human events for the sparkline; null when over the row budget. */
samples: number[] | null;
};
@@ -217,6 +230,22 @@ export default async function PortfolioAnalyticsPage({
const portfolio = sumTotals(totalsByProject.values());
const trends = totalsTrends(portfolio);
+ // People. One more RPC, kept apart from the eleven above because its
+ // failure means "no visitor figure", not "no traffic": the tile and the
+ // column are left out rather than zeroed.
+ const visitorsByProject = await fetchVisitorTotals(supabase, projectIds, days, "human");
+ const portfolioVisitors = visitorsByProject
+ ? sumVisitorTotals(visitorsByProject.values())
+ : null;
+ const visitorsTrend = portfolioVisitors
+ ? computeTrend(portfolioVisitors.visitors, portfolioVisitors.prevVisitors)
+ : null;
+ const visitorsCaption = !visitorsByProject
+ ? "Visitor counts are unavailable right now; the figures below count events."
+ : visitorsPartial(days)
+ ? VISITORS_CAPTION
+ : null;
+
// Ranked by current-window HUMAN volume: the biggest properties get the
// chart bands, and the daily-detail budget is spent on them first. Ranking
// by events would hand the top band to whichever site a crawler is hitting.
@@ -264,6 +293,9 @@ export default async function PortfolioAnalyticsPage({
return {
project,
totals,
+ visitors: visitorsByProject
+ ? (visitorsByProject.get(project.id) ?? { visitors: 0, prevVisitors: 0, pageviews: 0, prevPageviews: 0 })
+ : null,
trend: computeTrend(totals.humans, totals.prevHumans),
samples: byDay ? axis.map((day) => byDay.get(day) ?? 0) : null,
};
@@ -444,11 +476,19 @@ export default async function PortfolioAnalyticsPage({
-
+
+ {visitorsTrend && (
+
+ )}
+ {visitorsCaption && (
+
{visitorsCaption}
+ )}
{portfolio.events === 0 && portfolio.prevEvents === 0 ? (
@@ -485,13 +528,21 @@ export default async function PortfolioAnalyticsPage({
Portfolio trend
- Daily human visits, stacked by property. Bot crawls across
+ Daily human events, stacked by property. Bot crawls across
every property are the dashed line, kept out of the stack.
+ {portfolioVisitors && (
+ <>
+
+ {portfolioVisitors.visitors.toLocaleString()} human visitors
+
+ {" · "}
+ >
+ )}
- {portfolio.humans.toLocaleString()} human visits
+ {portfolio.humans.toLocaleString()} human events
{" · "}
@@ -633,8 +684,11 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
| Property |
+
+ Visitors
+ |
- Humans
+ Events
|
Previous
@@ -650,7 +704,7 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
|
- {rows.map(({ project, totals, trend, samples }) => (
+ {rows.map(({ project, totals, visitors, trend, samples }) => (
+ |
+ {visitors ? visitors.visitors.toLocaleString() : "—"}
+ |
{totals.humans.toLocaleString()}
|
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index 5bd7b40..2a541e4 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -3,7 +3,8 @@ import { createClient } from "@/lib/supabase/server";
import { rpcFailed, type Loaded } from "@/lib/loaded";
import { ScoreBadge } from "@/components/score-badge";
import { FontSparkline } from "@/components/font-sparkline";
-import { BOTS_DEFINITION, HUMANS_DEFINITION } from "@/lib/tracker/humans";
+import { BOTS_DEFINITION, HUMANS_DEFINITION, VISITORS_DEFINITION } from "@/lib/tracker/humans";
+import { fetchVisitorDailySeries } from "@/lib/tracker/visitors";
import { ProjectLogo } from "@/components/project-logo";
import { StatsUnavailable } from "@/components/stats-unavailable";
import { backfillProjectLogo } from "@/app/actions/createProject";
@@ -121,11 +122,12 @@ export default async function DashboardPage({
// project has an lx_site row in status=active; social is "on" when at
// least one social account is linked at the project level.
const projectIds = (projects ?? []).map((p) => p.id);
- const [autoblogIds, socialIds, latestPosts, traffic] = await Promise.all([
+ const [autoblogIds, socialIds, latestPosts, traffic, visitorsByProject] = await Promise.all([
fetchEnabledProjectIds(supabase, "lx_site", projectIds, { status: "active" }),
fetchEnabledProjectIds(supabase, "sp_site_account", projectIds),
fetchLatestBlogPostByProject(supabase, projectIds),
fetchSevenDayTraffic(supabase, projectIds),
+ fetchSevenDayVisitors(supabase, projectIds),
]);
const trafficByProject = traffic.data;
const trafficFailed = traffic.failed;
@@ -266,23 +268,27 @@ export default async function DashboardPage({
{trafficFailed
? "Traffic unavailable"
- : `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human visits`}
+ : visitorsByProject
+ ? `${sum(visitorsByProject.get(p.id)).toLocaleString()} human visitors`
+ : `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events`}
{trafficFailed
? "Query failed \u2014 not zero"
- : `${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`}
+ : `${visitorsByProject ? `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events \u00b7 ` : ""}${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`}
{!trafficFailed && (
-
+
)}
{orgSchemaReady && (
@@ -436,6 +442,32 @@ async function fetchSevenDayTraffic(
return { data: out, failed: false };
}
+// Distinct human visitors per day for the past seven UTC days, per project:
+// the number the card leads with. The visitor rollup only began on
+// lib/tracker/visitors.ts VISITORS_SINCE, and it counts people where
+// dashboard_project_traffic counts beacons (several per page view), which
+// is why the two figures on a card differ by a hundredfold and both are
+// shown. null when the RPC failed or is not deployed: the card then leads
+// with events under their real name rather than showing "0 visitors".
+async function fetchSevenDayVisitors(
+ supabase: Awaited>,
+ projectIds: string[],
+): Promise