Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/(app)/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ type DeviceRow = {
os: string;
count: number;
};
type ExitRow = {
page_path: string;
count: number;
};

const WINDOW_DAYS = 30;

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -299,6 +313,7 @@ export default async function ProjectStatsPage({
events={eventMix}
sources={topSources}
pages={topPages}
exitPages={exitPages}
referrers={topReferrers}
actions={topActions}
countries={topCountries}
Expand Down Expand Up @@ -454,6 +469,22 @@ function topGeoItems(
.slice(0, 10);
}

function topExitItems(
rows: ExitRow[],
labelFor: (row: ExitRow) => string,
): TrackerListItem[] {
const map = new Map<string, number>();
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,
Expand Down
12 changes: 12 additions & 0 deletions app/api/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
24 changes: 17 additions & 7 deletions components/charts/tracker-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function TrackerAnalytics({
events,
sources,
pages,
exitPages,
referrers,
actions,
countries,
Expand All @@ -56,6 +57,7 @@ export function TrackerAnalytics({
events: TrackerListItem[];
sources: TrackerListItem[];
pages: TrackerListItem[];
exitPages: TrackerListItem[];
referrers: TrackerListItem[];
actions: TrackerListItem[];
countries: TrackerListItem[];
Expand Down Expand Up @@ -157,40 +159,48 @@ export function TrackerAnalytics({
<div className="grid gap-4 lg:grid-cols-2">
<RankedList title="Top pages" data={pages} empty="No page paths yet." />
<RankedList
title="Top interactions"
data={actions}
empty="No click or form interactions yet."
title="Exit pages"
data={exitPages}
empty="No exit pages yet."
/>
</div>

<div className="grid gap-4 lg:grid-cols-2">
<RankedList
title="Top interactions"
data={actions}
empty="No click or form interactions yet."
/>
<RankedList
title="Referrer hosts"
data={referrers}
empty="No external referrers yet."
/>
</div>

<div className="grid gap-4 lg:grid-cols-2">
<RankedList
title="Countries"
data={countries}
empty="No location data yet."
/>
</div>

<div className="grid gap-4 lg:grid-cols-2">
<RankedList
title="Cities"
data={cities}
empty="No city data yet."
/>
<Breakdown title="Devices" data={devices} />
</div>

<div className="grid gap-4 lg:grid-cols-2">
<Breakdown title="Devices" data={devices} />
<RankedList
title="Browsers"
data={browsers}
empty="No browser data yet."
/>
</div>

<div className="grid gap-4 lg:grid-cols-2">
<RankedList
title="Operating systems"
data={operatingSystems}
Expand Down
110 changes: 110 additions & 0 deletions lib/tracker/exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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 (tracker_exit_sessions) and *move* the +1 exit marker forward as the
// session advances: decrement the page the session was previously counted on,
// increment the new one. tracker_exit_daily_stats therefore always reflects,
// for each session, exactly one exit page — its latest pageview so far.
//
// Lives here (rather than inline in /api/track) so it's unit-testable without
// dragging in the full ingest route, mirroring lib/tracker/device.ts & geo.ts.

import type { SupabaseClient } from "@supabase/supabase-js";

// The service client is typed permissively (DB = any) in this repo, so accept
// any Supabase client here too.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Sb = SupabaseClient<any>;

// 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(),
);
}
62 changes: 62 additions & 0 deletions supabase/migrations/20260704120000_tracker_exit_rollups.sql
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading