Skip to content

Commit 145bcb7

Browse files
authored
Analytics event tracking (#1208)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Browser-side event tracker with batching, navigation & click capture and background/keepalive delivery * Server endpoint to accept batched analytics events and associate them with session replay segments * Client APIs to send analytics batches and integrate with session replay * **Bug Fixes / UX** * Pausing replay now uses the UI-facing playback time for more accurate pause positions * Replay endpoint now returns a clear analytics-disabled error (ANALYTICS_NOT_ENABLED) when analytics is off * **Tests** * End-to-end tests covering batch ingestion, validation, and replay timing behavior <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fd79f62 commit 145bcb7

17 files changed

Lines changed: 939 additions & 43 deletions

File tree

apps/backend/scripts/clickhouse-migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export async function runClickhouseMigrations() {
2020
await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL });
2121
await client.exec({ query: BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL });
2222
await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL });
23+
// Recreate the events view so SELECT * picks up columns added by EVENTS_ADD_REPLAY_COLUMNS_SQL
24+
await client.exec({ query: EVENTS_VIEW_SQL });
2325
const queries = [
2426
"REVOKE ALL PRIVILEGES ON *.* FROM limited_user;",
2527
"REVOKE ALL FROM limited_user;",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { getClickhouseAdminClient } from "@/lib/clickhouse";
2+
import { findRecentSessionReplay } from "@/lib/session-replays";
3+
import { getPrismaClientForTenancy } from "@/prisma-client";
4+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
8+
9+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10+
11+
const MAX_EVENTS = 500;
12+
13+
export const POST = createSmartRouteHandler({
14+
metadata: {
15+
summary: "Upload analytics event batch",
16+
description: "Uploads a batch of auto-captured analytics events ($page-view, $click).",
17+
tags: ["Analytics Events"],
18+
hidden: true,
19+
},
20+
request: yupObject({
21+
auth: yupObject({
22+
type: clientOrHigherAuthTypeSchema,
23+
tenancy: adaptSchema,
24+
user: adaptSchema,
25+
refreshTokenId: adaptSchema,
26+
}).defined(),
27+
body: yupObject({
28+
session_replay_segment_id: yupString().defined().matches(UUID_RE, "Invalid session_replay_segment_id"),
29+
batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"),
30+
sent_at_ms: yupNumber().defined().integer().min(0),
31+
events: yupArray(
32+
yupObject({
33+
event_type: yupString().defined().oneOf(["$page-view", "$click"]),
34+
event_at_ms: yupNumber().defined().integer().min(0),
35+
data: yupMixed().defined(),
36+
}).defined(),
37+
).defined().min(1).max(MAX_EVENTS),
38+
}).defined(),
39+
}),
40+
response: yupObject({
41+
statusCode: yupNumber().oneOf([200]).defined(),
42+
bodyType: yupString().oneOf(["json"]).defined(),
43+
body: yupObject({
44+
inserted: yupNumber().defined(),
45+
}).defined(),
46+
}),
47+
async handler({ auth, body }) {
48+
if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) {
49+
throw new KnownErrors.AnalyticsNotEnabled();
50+
}
51+
if (!auth.user) {
52+
throw new KnownErrors.UserAuthenticationRequired();
53+
}
54+
if (!auth.refreshTokenId) {
55+
throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events");
56+
}
57+
58+
const projectId = auth.tenancy.project.id;
59+
const branchId = auth.tenancy.branchId;
60+
const userId = auth.user.id;
61+
const refreshTokenId = auth.refreshTokenId;
62+
const tenancyId = auth.tenancy.id;
63+
64+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
65+
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
66+
67+
const clickhouseClient = getClickhouseAdminClient();
68+
69+
const rows = body.events.map((event) => ({
70+
event_type: event.event_type,
71+
event_at: new Date(event.event_at_ms),
72+
data: event.data,
73+
project_id: projectId,
74+
branch_id: branchId,
75+
user_id: userId,
76+
team_id: null,
77+
refresh_token_id: refreshTokenId,
78+
session_replay_id: recentSession?.id ?? null,
79+
session_replay_segment_id: body.session_replay_segment_id,
80+
}));
81+
82+
await clickhouseClient.insert({
83+
table: "analytics_internal.events",
84+
values: rows,
85+
format: "JSONEachRow",
86+
clickhouse_settings: {
87+
date_time_input_format: "best_effort",
88+
async_insert: 1,
89+
},
90+
});
91+
92+
return {
93+
statusCode: 200,
94+
bodyType: "json",
95+
body: { inserted: body.events.length },
96+
};
97+
},
98+
});

apps/backend/src/app/api/latest/session-replays/batch/route.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client";
22
import { uploadBytes } from "@/s3";
33
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
44
import { Prisma } from "@/generated/prisma/client";
5+
import { findRecentSessionReplay } from "@/lib/session-replays";
56
import { KnownErrors } from "@stackframe/stack-shared";
67
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
78
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@@ -15,8 +16,6 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0
1516

1617
const MAX_BODY_BYTES = 5_000_000;
1718
const MAX_EVENTS = 5_000;
18-
const SESSION_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
19-
const MAX_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
2019

2120
function extractEventTimesMs(events: unknown[], fallbackMs: number) {
2221
let minTs = Infinity;
@@ -72,16 +71,7 @@ export const POST = createSmartRouteHandler({
7271
}),
7372
async handler({ auth, body }, fullReq) {
7473
if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) {
75-
return {
76-
statusCode: 200,
77-
bodyType: "json",
78-
body: {
79-
session_replay_id: "",
80-
batch_id: body.batch_id,
81-
s3_key: "",
82-
deduped: false,
83-
},
84-
};
74+
throw new KnownErrors.AnalyticsNotEnabled();
8575
}
8676
if (!auth.user) {
8777
throw new KnownErrors.UserAuthenticationRequired();
@@ -114,22 +104,7 @@ export const POST = createSmartRouteHandler({
114104
const { firstMs, lastMs } = extractEventTimesMs(body.events, body.sent_at_ms);
115105

116106
const prisma = await getPrismaClientForTenancy(auth.tenancy);
117-
118-
// Find a recent session replay for this refresh token (temporal grouping).
119-
// If the last batch arrived within SESSION_IDLE_TIMEOUT_MS, reuse that replay.
120-
// Also enforce a max session duration so replays don't grow indefinitely.
121-
const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS);
122-
const maxDurationCutoff = new Date(Date.now() - MAX_SESSION_DURATION_MS);
123-
const recentSession = await prisma.sessionReplay.findFirst({
124-
where: {
125-
tenancyId,
126-
refreshTokenId,
127-
updatedAt: { gte: cutoff },
128-
startedAt: { gte: maxDurationCutoff },
129-
},
130-
orderBy: { updatedAt: "desc" },
131-
select: { id: true, startedAt: true, lastEventAt: true },
132-
});
107+
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
133108

134109
const replayId = recentSession?.id ?? randomUUID();
135110
const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${batchId}.json.gz`;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { PrismaClient } from "@/generated/prisma/client";
2+
import { PrismaClientWithReplica } from "@/prisma-client";
3+
4+
export const SESSION_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
5+
export const MAX_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
6+
7+
export async function findRecentSessionReplay(prisma: PrismaClientWithReplica<PrismaClient>, options: {
8+
tenancyId: string,
9+
refreshTokenId: string,
10+
}) {
11+
const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS);
12+
const maxDurationCutoff = new Date(Date.now() - MAX_SESSION_DURATION_MS);
13+
return await prisma.sessionReplay.findFirst({
14+
where: {
15+
tenancyId: options.tenancyId,
16+
refreshTokenId: options.refreshTokenId,
17+
updatedAt: { gte: cutoff },
18+
startedAt: { gte: maxDurationCutoff },
19+
},
20+
orderBy: { updatedAt: "desc" },
21+
select: { id: true, startedAt: true, lastEventAt: true },
22+
});
23+
}

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1149,7 +1149,7 @@ export default function PageClient() {
11491149
return (
11501150
<AppEnabledGuard appId="analytics">
11511151
<PageLayout title="Session Replays" fillWidth>
1152-
<PanelGroup direction="horizontal" className="h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
1152+
<PanelGroup direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
11531153
<Panel defaultSize={25} minSize={16}>
11541154
<div className="h-full flex flex-col">
11551155
<div className="shrink-0 px-3 py-2 border-b border-border/30 flex items-center h-10">

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,16 +557,18 @@ describe("session-replay-machine", () => {
557557

558558
describe("TOGGLE_PLAY_PAUSE", () => {
559559
it("pauses from playing", () => {
560-
const state = twoTabReadyState({ playbackMode: "playing" });
560+
const state = twoTabReadyState({ playbackMode: "playing", currentGlobalTimeMsForUi: 2500 });
561561
const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
562562
expect(s.playbackMode).toBe("paused");
563+
expect(s.pausedAtGlobalMs).toBe(2500);
563564
expect(hasEffect(effects, "pause_all")).toBe(true);
564565
});
565566

566567
it("pauses from gap_fast_forward", () => {
567-
const state = twoTabReadyState({ playbackMode: "gap_fast_forward" });
568+
const state = twoTabReadyState({ playbackMode: "gap_fast_forward", currentGlobalTimeMsForUi: 3000 });
568569
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
569570
expect(s.playbackMode).toBe("paused");
571+
expect(s.pausedAtGlobalMs).toBe(3000);
570572
expect(s.gapFastForward).toBeNull();
571573
});
572574

@@ -575,9 +577,11 @@ describe("session-replay-machine", () => {
575577
playbackMode: "buffering",
576578
bufferingAtGlobalMs: 1000,
577579
autoResumeAfterBuffering: true,
580+
currentGlobalTimeMsForUi: 1500,
578581
});
579582
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
580583
expect(s.playbackMode).toBe("paused");
584+
expect(s.pausedAtGlobalMs).toBe(1500);
581585
expect(s.bufferingAtGlobalMs).toBeNull();
582586
expect(s.autoResumeAfterBuffering).toBe(false);
583587
});

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
761761
state: {
762762
...state,
763763
playbackMode: "paused",
764+
pausedAtGlobalMs: state.currentGlobalTimeMsForUi,
764765
gapFastForward: null,
765766
bufferingAtGlobalMs: null,
766767
autoResumeAfterBuffering: false,

0 commit comments

Comments
 (0)