diff --git a/app/(app)/projects/[id]/stats/page.tsx b/app/(app)/projects/[id]/stats/page.tsx index 24c50ad0..fa856198 100644 --- a/app/(app)/projects/[id]/stats/page.tsx +++ b/app/(app)/projects/[id]/stats/page.tsx @@ -50,6 +50,10 @@ type DeviceRow = { os: string; count: number; }; +type ExitRow = { + page_path: string; + count: number; +}; const WINDOW_DAYS = 30; @@ -88,6 +92,15 @@ export default async function ProjectStatsPage({ const eventRows = (eventStats ?? []) as EventRow[]; + const { data: exitStats } = await supabase + .from("tracker_exit_daily_stats") + .select("page_path, count") + .eq("project_id", id) + .gte("day", since) + .gt("count", 0); + + const exitRows = (exitStats ?? []) as ExitRow[]; + const { data: geoStats } = await supabase .from("tracker_geo_daily_stats") .select("country_code, country_name, region_code, region_name, city, timezone, count") @@ -130,6 +143,7 @@ export default async function ProjectStatsPage({ eventRows.filter((row) => row.event === "pageview"), (row) => row.page_path || "/", ); + const exitPages = topExitItems(exitRows, (row) => row.page_path || "/"); const topReferrers = topItems( eventRows.filter((row) => row.referrer_host), (row) => row.referrer_host, @@ -299,6 +313,7 @@ export default async function ProjectStatsPage({ events={eventMix} sources={topSources} pages={topPages} + exitPages={exitPages} referrers={topReferrers} actions={topActions} countries={topCountries} @@ -454,6 +469,22 @@ function topGeoItems( .slice(0, 10); } +function topExitItems( + rows: ExitRow[], + labelFor: (row: ExitRow) => string, +): TrackerListItem[] { + const map = new Map(); + for (const row of rows) { + const label = labelFor(row); + if (!label) continue; + map.set(label, (map.get(label) ?? 0) + row.count); + } + return Array.from(map.entries()) + .map(([label, value]) => ({ label, value })) + .sort((a, b) => b.value - a.value) + .slice(0, 10); +} + function topDeviceItems( rows: DeviceRow[], labelFor: (row: DeviceRow) => string, diff --git a/app/api/track/route.ts b/app/api/track/route.ts index 7fc9381d..6a4141bd 100644 --- a/app/api/track/route.ts +++ b/app/api/track/route.ts @@ -9,6 +9,7 @@ import { categorize } from "@/lib/tracker/categorize"; import { parseDevice } from "@/lib/tracker/device"; import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; import { enqueuePostHogEvent } from "@/lib/posthog/events"; +import { pruneExitSessions, updateExitRollup } from "@/lib/tracker/exit"; export const runtime = "nodejs"; @@ -233,6 +234,17 @@ async function ingest(request: NextRequest, parseBody: boolean) { }); } + // Exit-page rollup: move this session's exit marker to the current pageview. + // Best-effort — analytics writes must never break the beacon response. + if (event === "pageview" && parsed.data.sessionId) { + try { + await updateExitRollup(sb, site, parsed.data.sessionId, pagePath, today); + await pruneExitSessions(sb, site); + } catch { + // Silent — never affect the 204. + } + } + // Device / browser / OS rollup, derived from the request User-Agent. Like // the geo rollup we store only aggregate counts, never the raw UA string. const device = parseDevice(userAgent); diff --git a/components/charts/tracker-analytics.tsx b/components/charts/tracker-analytics.tsx index ab419852..07986e3b 100644 --- a/components/charts/tracker-analytics.tsx +++ b/components/charts/tracker-analytics.tsx @@ -44,6 +44,7 @@ export function TrackerAnalytics({ events, sources, pages, + exitPages, referrers, actions, countries, @@ -56,6 +57,7 @@ export function TrackerAnalytics({ events: TrackerListItem[]; sources: TrackerListItem[]; pages: TrackerListItem[]; + exitPages: TrackerListItem[]; referrers: TrackerListItem[]; actions: TrackerListItem[]; countries: TrackerListItem[]; @@ -157,40 +159,48 @@ export function TrackerAnalytics({
+ +
+ +
-
- -
-
+ +
+ +
; + +// Bump a single exit-page counter by `delta` using the same read-modify-write +// upsert pattern as the other tracker rollups. Counts are clamped at 0 so a +// decrement racing ahead of its matching increment can never go negative. +export async function bumpExit( + sb: Sb, + project: string, + day: string, + pagePath: string, + delta: number, +) { + const { data: existing } = await sb + .from("tracker_exit_daily_stats") + .select("count") + .eq("project_id", project) + .eq("day", day) + .eq("page_path", pagePath) + .maybeSingle(); + + if (existing) { + await sb + .from("tracker_exit_daily_stats") + .update({ + count: Math.max(0, (existing.count ?? 0) + delta), + updated_at: new Date().toISOString(), + }) + .eq("project_id", project) + .eq("day", day) + .eq("page_path", pagePath); + } else if (delta > 0) { + await sb + .from("tracker_exit_daily_stats") + .insert({ project_id: project, day, page_path: pagePath, count: delta }); + } +} + +// Move this session's exit marker to the current pageview. Call only for +// pageview events that carry a session id. +export async function updateExitRollup( + sb: Sb, + project: string, + sessionId: string, + pagePath: string, + today: string, +) { + const { data: session } = await sb + .from("tracker_exit_sessions") + .select("last_page_path, last_day") + .eq("project_id", project) + .eq("session_id", sessionId) + .maybeSingle(); + + if (session) { + // Already counted on the same page and day — nothing to move. + if (session.last_page_path === pagePath && session.last_day === today) return; + await bumpExit(sb, project, session.last_day, session.last_page_path, -1); + await bumpExit(sb, project, today, pagePath, 1); + await sb + .from("tracker_exit_sessions") + .update({ + last_page_path: pagePath, + last_day: today, + updated_at: new Date().toISOString(), + }) + .eq("project_id", project) + .eq("session_id", sessionId); + } else { + await bumpExit(sb, project, today, pagePath, 1); + await sb + .from("tracker_exit_sessions") + .insert({ + project_id: project, + session_id: sessionId, + last_page_path: pagePath, + last_day: today, + }); + } +} + +// Prune session bookkeeping well past the client-side 30-min session TTL so a +// stale session id can never be resurrected and double-counted. +export async function pruneExitSessions(sb: Sb, project: string) { + await sb + .from("tracker_exit_sessions") + .delete() + .eq("project_id", project) + .lt( + "updated_at", + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + ); +} diff --git a/supabase/migrations/20260704120000_tracker_exit_rollups.sql b/supabase/migrations/20260704120000_tracker_exit_rollups.sql new file mode 100644 index 00000000..48b1b93b --- /dev/null +++ b/supabase/migrations/20260704120000_tracker_exit_rollups.sql @@ -0,0 +1,62 @@ +-- Daily exit-page rollups for the drop-in stats tracker. The "exit page" of a +-- session is its most recent pageview: the last page a visitor was on before +-- leaving. We can't know at ingest time whether a given pageview is the last +-- one, so we keep a small per-session "current last page" record and *move* the +-- exit marker as the session advances (decrement the old page, increment the +-- new one). tracker_exit_daily_stats therefore always reflects, for each +-- session, exactly one exit page — its latest pageview so far. +-- +-- Consistent with the other tracker_*_daily_stats tables: cookie-free, no raw +-- PII, only aggregate counts. +create table if not exists public.tracker_exit_daily_stats ( + project_id uuid not null references public.projects(id) on delete cascade, + day date not null, + page_path text not null default '', + count int not null default 0, + updated_at timestamptz not null default now(), + primary key (project_id, day, page_path) +); + +create index if not exists tracker_exit_daily_stats_project_day_idx + on public.tracker_exit_daily_stats(project_id, day desc); + +alter table public.tracker_exit_daily_stats enable row level security; + +create policy "tracker_exit_daily_stats owner select" + on public.tracker_exit_daily_stats for select + using ( + project_id in (select id from public.projects where owner_id = auth.uid()) + ); + +-- Members of a project may read its exit stats (mirrors the member-select +-- policies on the other tracker_*_daily_stats tables). +create policy "tracker_exit_daily_stats member select" + on public.tracker_exit_daily_stats for select + using ( + exists( + select 1 from public.project_members + where project_id = tracker_exit_daily_stats.project_id + and user_id = auth.uid() + ) + ); + +-- Internal bookkeeping: the current last pageview per session, used to move the +-- exit marker in tracker_exit_daily_stats. Written only by the service role +-- from /api/track; never read by end users, so we enable RLS with no select +-- policy (the service role bypasses RLS). Rows are pruned once well past the +-- client-side 30-minute session TTL so a session id can never be re-counted. +create table if not exists public.tracker_exit_sessions ( + project_id uuid not null references public.projects(id) on delete cascade, + session_id text not null, + last_page_path text not null default '', + last_day date not null, + updated_at timestamptz not null default now(), + primary key (project_id, session_id) +); + +create index if not exists tracker_exit_sessions_project_updated_idx + on public.tracker_exit_sessions(project_id, updated_at); + +alter table public.tracker_exit_sessions enable row level security; + +-- Service role writes from /api/track; no public read access. diff --git a/tests/contract/tracker-exit.test.ts b/tests/contract/tracker-exit.test.ts new file mode 100644 index 00000000..5e64300a --- /dev/null +++ b/tests/contract/tracker-exit.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { updateExitRollup } from "@/lib/tracker/exit"; + +// Minimal in-memory fake of the two tables the exit rollup touches, exposing +// just the subset of the Supabase query builder the module uses. It records +// exit counts and per-session last-page state so we can assert the "exit +// marker moves as the session advances" behavior end to end. +function makeFakeSb() { + const exits = new Map(); + const sessions = new Map(); + const key = (...parts: string[]) => parts.join("|"); + + function table(name: string) { + const filters: Record = {}; + let pending: Record | null = null; // insert/update payload + let op: "select" | "insert" | "update" | "delete" = "select"; + + const chain: Record = { + select() { op = "select"; return chain; }, + insert(row: Record) { op = "insert"; pending = row; return exec(); }, + update(row: Record) { op = "update"; pending = row; return chain; }, + delete() { op = "delete"; return chain; }, + eq(col: string, val: string) { + filters[col] = val; + // Terminal for update/delete once all filters are applied; Supabase + // resolves on await, so return a thenable that also stays chainable. + return chain; + }, + lt() { return chain; }, + async maybeSingle() { + if (name === "tracker_exit_daily_stats") { + const row = exits.get(key(filters.project_id, filters.day, filters.page_path)); + return { data: row ? { count: row.count } : null, error: null }; + } + const s = sessions.get(key(filters.project_id, filters.session_id)); + return { data: s ? { last_page_path: s.last_page_path, last_day: s.last_day } : null, error: null }; + }, + then(resolve: (v: unknown) => unknown) { + return Promise.resolve(exec()).then(resolve); + }, + }; + + function exec() { + if (op === "insert" && pending) { + if (name === "tracker_exit_daily_stats") { + exits.set(key(pending.project_id as string, pending.day as string, pending.page_path as string), { + project: pending.project_id as string, day: pending.day as string, page: pending.page_path as string, count: pending.count as number, + }); + } else { + sessions.set(key(pending.project_id as string, pending.session_id as string), { + project: pending.project_id as string, session: pending.session_id as string, + last_page_path: pending.last_page_path as string, last_day: pending.last_day as string, + }); + } + } else if (op === "update" && pending) { + if (name === "tracker_exit_daily_stats") { + const k = key(filters.project_id, filters.day, filters.page_path); + const row = exits.get(k); + if (row) row.count = pending.count as number; + } else { + const k = key(filters.project_id, filters.session_id); + const s = sessions.get(k); + if (s) { s.last_page_path = pending.last_page_path as string; s.last_day = pending.last_day as string; } + } + } + return { data: null, error: null }; + } + + return chain; + } + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sb: { from: (name: string) => table(name) } as any, + exitCount: (project: string, day: string, page: string) => + exits.get(key(project, day, page))?.count ?? 0, + }; +} + +const P = "proj-1"; +const DAY = "2026-07-04"; + +describe("updateExitRollup", () => { + it("counts the first pageview of a session as its exit page", async () => { + const { sb, exitCount } = makeFakeSb(); + await updateExitRollup(sb, P, "s1", "/pricing", DAY); + expect(exitCount(P, DAY, "/pricing")).toBe(1); + }); + + it("moves the exit marker to the session's latest page", async () => { + const { sb, exitCount } = makeFakeSb(); + await updateExitRollup(sb, P, "s1", "/pricing", DAY); + await updateExitRollup(sb, P, "s1", "/checkout", DAY); + expect(exitCount(P, DAY, "/pricing")).toBe(0); // no longer the exit + expect(exitCount(P, DAY, "/checkout")).toBe(1); + }); + + it("does not change counts when the same page repeats within a session", async () => { + const { sb, exitCount } = makeFakeSb(); + await updateExitRollup(sb, P, "s1", "/pricing", DAY); + await updateExitRollup(sb, P, "s1", "/pricing", DAY); // reload + expect(exitCount(P, DAY, "/pricing")).toBe(1); + }); + + it("counts distinct sessions independently", async () => { + const { sb, exitCount } = makeFakeSb(); + await updateExitRollup(sb, P, "s1", "/pricing", DAY); + await updateExitRollup(sb, P, "s2", "/pricing", DAY); + await updateExitRollup(sb, P, "s2", "/checkout", DAY); + expect(exitCount(P, DAY, "/pricing")).toBe(1); // s1 still exits here + expect(exitCount(P, DAY, "/checkout")).toBe(1); // s2 moved here + }); + + it("never lets a decrement drive a count below zero", async () => { + const { sb, exitCount } = makeFakeSb(); + // Two sessions land on /a, then both move to /b. + await updateExitRollup(sb, P, "s1", "/a", DAY); + await updateExitRollup(sb, P, "s2", "/a", DAY); + await updateExitRollup(sb, P, "s1", "/b", DAY); + await updateExitRollup(sb, P, "s2", "/b", DAY); + expect(exitCount(P, DAY, "/a")).toBe(0); + expect(exitCount(P, DAY, "/b")).toBe(2); + }); +});