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 -->
This commit is contained in:
BilalG1 2026-02-17 18:33:01 -08:00 committed by GitHub
parent fd79f626d3
commit 145bcb7e92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 939 additions and 43 deletions

View File

@ -20,6 +20,8 @@ export async function runClickhouseMigrations() {
await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL });
await client.exec({ query: BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL });
await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL });
// Recreate the events view so SELECT * picks up columns added by EVENTS_ADD_REPLAY_COLUMNS_SQL
await client.exec({ query: EVENTS_VIEW_SQL });
const queries = [
"REVOKE ALL PRIVILEGES ON *.* FROM limited_user;",
"REVOKE ALL FROM limited_user;",

View File

@ -0,0 +1,98 @@
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { findRecentSessionReplay } from "@/lib/session-replays";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
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;
const MAX_EVENTS = 500;
export const POST = createSmartRouteHandler({
metadata: {
summary: "Upload analytics event batch",
description: "Uploads a batch of auto-captured analytics events ($page-view, $click).",
tags: ["Analytics Events"],
hidden: true,
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema,
tenancy: adaptSchema,
user: adaptSchema,
refreshTokenId: adaptSchema,
}).defined(),
body: yupObject({
session_replay_segment_id: yupString().defined().matches(UUID_RE, "Invalid session_replay_segment_id"),
batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"),
sent_at_ms: yupNumber().defined().integer().min(0),
events: yupArray(
yupObject({
event_type: yupString().defined().oneOf(["$page-view", "$click"]),
event_at_ms: yupNumber().defined().integer().min(0),
data: yupMixed().defined(),
}).defined(),
).defined().min(1).max(MAX_EVENTS),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
inserted: yupNumber().defined(),
}).defined(),
}),
async handler({ auth, body }) {
if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) {
throw new KnownErrors.AnalyticsNotEnabled();
}
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
}
if (!auth.refreshTokenId) {
throw new StatusError(StatusError.BadRequest, "A refresh token is required for analytics events");
}
const projectId = auth.tenancy.project.id;
const branchId = auth.tenancy.branchId;
const userId = auth.user.id;
const refreshTokenId = auth.refreshTokenId;
const tenancyId = auth.tenancy.id;
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
const clickhouseClient = getClickhouseAdminClient();
const rows = body.events.map((event) => ({
event_type: event.event_type,
event_at: new Date(event.event_at_ms),
data: event.data,
project_id: projectId,
branch_id: branchId,
user_id: userId,
team_id: null,
refresh_token_id: refreshTokenId,
session_replay_id: recentSession?.id ?? null,
session_replay_segment_id: body.session_replay_segment_id,
}));
await clickhouseClient.insert({
table: "analytics_internal.events",
values: rows,
format: "JSONEachRow",
clickhouse_settings: {
date_time_input_format: "best_effort",
async_insert: 1,
},
});
return {
statusCode: 200,
bodyType: "json",
body: { inserted: body.events.length },
};
},
});

View File

@ -2,6 +2,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client";
import { uploadBytes } from "@/s3";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { Prisma } from "@/generated/prisma/client";
import { findRecentSessionReplay } from "@/lib/session-replays";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
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
const MAX_BODY_BYTES = 5_000_000;
const MAX_EVENTS = 5_000;
const SESSION_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
const MAX_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
function extractEventTimesMs(events: unknown[], fallbackMs: number) {
let minTs = Infinity;
@ -72,16 +71,7 @@ export const POST = createSmartRouteHandler({
}),
async handler({ auth, body }, fullReq) {
if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) {
return {
statusCode: 200,
bodyType: "json",
body: {
session_replay_id: "",
batch_id: body.batch_id,
s3_key: "",
deduped: false,
},
};
throw new KnownErrors.AnalyticsNotEnabled();
}
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
@ -114,22 +104,7 @@ export const POST = createSmartRouteHandler({
const { firstMs, lastMs } = extractEventTimesMs(body.events, body.sent_at_ms);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
// Find a recent session replay for this refresh token (temporal grouping).
// If the last batch arrived within SESSION_IDLE_TIMEOUT_MS, reuse that replay.
// Also enforce a max session duration so replays don't grow indefinitely.
const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS);
const maxDurationCutoff = new Date(Date.now() - MAX_SESSION_DURATION_MS);
const recentSession = await prisma.sessionReplay.findFirst({
where: {
tenancyId,
refreshTokenId,
updatedAt: { gte: cutoff },
startedAt: { gte: maxDurationCutoff },
},
orderBy: { updatedAt: "desc" },
select: { id: true, startedAt: true, lastEventAt: true },
});
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
const replayId = recentSession?.id ?? randomUUID();
const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${batchId}.json.gz`;

View File

@ -0,0 +1,23 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaClientWithReplica } from "@/prisma-client";
export const SESSION_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
export const MAX_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
export async function findRecentSessionReplay(prisma: PrismaClientWithReplica<PrismaClient>, options: {
tenancyId: string,
refreshTokenId: string,
}) {
const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS);
const maxDurationCutoff = new Date(Date.now() - MAX_SESSION_DURATION_MS);
return await prisma.sessionReplay.findFirst({
where: {
tenancyId: options.tenancyId,
refreshTokenId: options.refreshTokenId,
updatedAt: { gte: cutoff },
startedAt: { gte: maxDurationCutoff },
},
orderBy: { updatedAt: "desc" },
select: { id: true, startedAt: true, lastEventAt: true },
});
}

View File

@ -1149,7 +1149,7 @@ export default function PageClient() {
return (
<AppEnabledGuard appId="analytics">
<PageLayout title="Session Replays" fillWidth>
<PanelGroup direction="horizontal" className="h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
<PanelGroup direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
<Panel defaultSize={25} minSize={16}>
<div className="h-full flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-border/30 flex items-center h-10">

View File

@ -557,16 +557,18 @@ describe("session-replay-machine", () => {
describe("TOGGLE_PLAY_PAUSE", () => {
it("pauses from playing", () => {
const state = twoTabReadyState({ playbackMode: "playing" });
const state = twoTabReadyState({ playbackMode: "playing", currentGlobalTimeMsForUi: 2500 });
const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
expect(s.playbackMode).toBe("paused");
expect(s.pausedAtGlobalMs).toBe(2500);
expect(hasEffect(effects, "pause_all")).toBe(true);
});
it("pauses from gap_fast_forward", () => {
const state = twoTabReadyState({ playbackMode: "gap_fast_forward" });
const state = twoTabReadyState({ playbackMode: "gap_fast_forward", currentGlobalTimeMsForUi: 3000 });
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
expect(s.playbackMode).toBe("paused");
expect(s.pausedAtGlobalMs).toBe(3000);
expect(s.gapFastForward).toBeNull();
});
@ -575,9 +577,11 @@ describe("session-replay-machine", () => {
playbackMode: "buffering",
bufferingAtGlobalMs: 1000,
autoResumeAfterBuffering: true,
currentGlobalTimeMsForUi: 1500,
});
const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 });
expect(s.playbackMode).toBe("paused");
expect(s.pausedAtGlobalMs).toBe(1500);
expect(s.bufferingAtGlobalMs).toBeNull();
expect(s.autoResumeAfterBuffering).toBe(false);
});

View File

@ -761,6 +761,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer
state: {
...state,
playbackMode: "paused",
pausedAtGlobalMs: state.currentGlobalTimeMsForUi,
gapFastForward: null,
bufferingAtGlobalMs: null,
autoResumeAfterBuffering: false,

View File

@ -0,0 +1,430 @@
import { randomUUID } from "node:crypto";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { it } from "../../../../helpers";
import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers";
async function uploadEventBatch(options: {
sessionReplaySegmentId: string,
batchId: string,
sentAtMs: number,
events: { event_type: string, event_at_ms: number, data: unknown }[],
}) {
return await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
body: {
session_replay_segment_id: options.sessionReplaySegmentId,
batch_id: options.batchId,
sent_at_ms: options.sentAtMs,
events: options.events,
},
});
}
it("requires a user token", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
backendContext.set({ userAuth: null });
const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
body: {
session_replay_segment_id: randomUUID(),
batch_id: randomUUID(),
sent_at_ms: Date.now(),
events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }],
},
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "USER_AUTHENTICATION_REQUIRED",
"error": "User authentication required for this endpoint.",
},
"headers": Headers {
"x-stack-known-error": "USER_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("throws error when analytics is not enabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
// Analytics is disabled by default - do NOT call Project.updateConfig
await Auth.Otp.signIn();
const res = await uploadEventBatch({
sessionReplaySegmentId: randomUUID(),
batchId: randomUUID(),
sentAtMs: Date.now(),
events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }],
});
expect(res.status).toBe(400);
expect(res.body?.code).toBe("ANALYTICS_NOT_ENABLED");
});
it("accepts valid $page-view events", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const now = Date.now();
const res = await uploadEventBatch({
sessionReplaySegmentId: randomUUID(),
batchId: randomUUID(),
sentAtMs: now,
events: [
{
event_type: "$page-view",
event_at_ms: now - 100,
data: {
url: "https://example.com/page",
path: "/page",
referrer: "",
title: "Test Page",
entry_type: "initial",
viewport_width: 1920,
viewport_height: 1080,
screen_width: 1920,
screen_height: 1080,
},
},
{
event_type: "$page-view",
event_at_ms: now - 50,
data: {
url: "https://example.com/other",
path: "/other",
referrer: "https://example.com/page",
title: "Other Page",
entry_type: "push",
viewport_width: 1920,
viewport_height: 1080,
screen_width: 1920,
screen_height: 1080,
},
},
],
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 2 },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("accepts valid $click events", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const now = Date.now();
const res = await uploadEventBatch({
sessionReplaySegmentId: randomUUID(),
batchId: randomUUID(),
sentAtMs: now,
events: [
{
event_type: "$click",
event_at_ms: now - 50,
data: {
tag_name: "button",
text: "Submit",
href: null,
selector: "div > form > button.submit-btn",
x: 100,
y: 200,
page_x: 100,
page_y: 500,
viewport_width: 1920,
viewport_height: 1080,
},
},
],
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 1 },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects empty events array", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const res = await uploadEventBatch({
sessionReplaySegmentId: randomUUID(),
batchId: randomUUID(),
sentAtMs: Date.now(),
events: [],
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have at least 1 items
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have at least 1 items
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("rejects too many events (>500)", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const tooManyEvents = Array.from({ length: 501 }, (_, i) => ({
event_type: "$page-view",
event_at_ms: 1_700_000_000_000 + i,
data: { url: `https://example.com/page-${i}`, path: `/page-${i}` },
}));
const res = await uploadEventBatch({
sessionReplaySegmentId: randomUUID(),
batchId: randomUUID(),
sentAtMs: 1_700_000_000_100,
events: tooManyEvents,
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have less than or equal to 500 items
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events field must have less than or equal to 500 items
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("rejects invalid session_replay_segment_id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
body: {
session_replay_segment_id: "not-a-uuid",
batch_id: randomUUID(),
sent_at_ms: Date.now(),
events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }],
},
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- Invalid session_replay_segment_id
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- Invalid session_replay_segment_id
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("rejects invalid batch_id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
body: {
session_replay_segment_id: randomUUID(),
batch_id: "not-a-uuid",
sent_at_ms: Date.now(),
events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }],
},
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- Invalid batch_id
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- Invalid batch_id
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("rejects invalid event_type", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const res = await niceBackendFetch("/api/v1/analytics/events/batch", {
method: "POST",
accessType: "client",
body: {
session_replay_segment_id: randomUUID(),
batch_id: randomUUID(),
sent_at_ms: Date.now(),
events: [{ event_type: "$invalid-type", event_at_ms: Date.now(), data: {} }],
},
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events[0].event_type must be one of the following values: $page-view, $click
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/analytics/events/batch:
- body.events[0].event_type must be one of the following values: $page-view, $click
\`,
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});
it("inserted events are queryable via analytics query endpoint", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const sessionReplaySegmentId = randomUUID();
const now = Date.now();
const uploadRes = await uploadEventBatch({
sessionReplaySegmentId,
batchId: randomUUID(),
sentAtMs: now,
events: [
{
event_type: "$page-view",
event_at_ms: now - 200,
data: { url: "https://example.com/test-query", path: "/test-query" },
},
{
event_type: "$click",
event_at_ms: now - 100,
data: { tag_name: "a", text: "Link", selector: "a.link" },
},
],
});
expect(uploadRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "inserted": 2 },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Retry query because async inserts may have a flush delay
let queryRes;
for (let attempt = 0; attempt < 15; attempt++) {
await wait(500);
queryRes = await niceBackendFetch("/api/v1/internal/analytics/query", {
method: "POST",
accessType: "admin",
body: {
query: "SELECT event_type, session_replay_segment_id FROM events WHERE session_replay_segment_id = {segId:String} ORDER BY event_at",
params: { segId: sessionReplaySegmentId },
},
});
if (queryRes.status === 200 && queryRes.body?.result?.length === 2) {
break;
}
}
expect(queryRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"query_id": "<stripped UUID>:main:<stripped UUID>",
"result": [
{
"event_type": "$page-view",
"session_replay_segment_id": "<stripped UUID>",
},
{
"event_type": "$click",
"session_replay_segment_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -46,7 +46,7 @@ it("requires a user token", async ({ expect }) => {
expect(res.status).toBeLessThan(500);
});
it("returns 200 no-op when analytics is not enabled", async ({ expect }) => {
it("throws error when analytics is not enabled", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
// Analytics is disabled by default - do NOT call Project.updateConfig
await Auth.Otp.signIn();
@ -64,9 +64,8 @@ it("returns 200 no-op when analytics is not enabled", async ({ expect }) => {
},
});
expect(res.status).toBe(200);
expect(res.body?.session_replay_id).toBe("");
expect(res.body?.s3_key).toBe("");
expect(res.status).toBe(400);
expect(res.body?.code).toBe("ANALYTICS_NOT_ENABLED");
});
it("stores session replay batch metadata and dedupes by (session_replay_id, batch_id)", async ({ expect }) => {

View File

@ -17,6 +17,7 @@ type BrowserEnv = {
hostname: string,
href: string,
origin: string,
pathname: string,
protocol: string,
},
};
@ -42,16 +43,27 @@ function setupBrowserCookieEnv(options: BrowserEnvOptions = {}): BrowserEnv {
hostname: host,
href: `${protocol}//${host}/`,
origin: `${protocol}//${host}`,
pathname: "/",
protocol,
};
const noop = () => {};
const fakeWindow = {
location,
sessionStorage: fakeSessionStorage,
screen: { width: 1920, height: 1080 },
innerWidth: 1920,
innerHeight: 1080,
addEventListener: noop,
removeEventListener: noop,
} as any;
const fakeDocument: any = {
createElement: () => ({}),
referrer: "",
title: "",
addEventListener: noop,
removeEventListener: noop,
};
Object.defineProperty(fakeDocument, "cookie", {
configurable: true,
@ -76,6 +88,7 @@ function setupBrowserCookieEnv(options: BrowserEnvOptions = {}): BrowserEnv {
vi.stubGlobal("window", fakeWindow);
vi.stubGlobal("document", fakeDocument);
vi.stubGlobal("sessionStorage", fakeSessionStorage);
vi.stubGlobal("history", { pushState: noop, replaceState: noop });
return {
cookieStore,

View File

@ -36,6 +36,7 @@ export type ClientInterfaceOptions = {
clientVersion: string,
// This is a function instead of a string because it might be different based on the environment (for example client vs server)
getBaseUrl: () => string,
getAnalyticsBaseUrl?: () => string,
extraRequestHeaders: Record<string, string>,
projectId: string,
prepareRequest?: () => Promise<void>,
@ -60,6 +61,10 @@ export class StackClientInterface {
return this.options.getBaseUrl() + "/api/v1";
}
getAnalyticsApiUrl() {
return (this.options.getAnalyticsBaseUrl ?? this.options.getBaseUrl)() + "/api/v1";
}
public async runNetworkDiagnostics(session?: InternalSession | null, requestType?: "client" | "server" | "admin") {
if (this.pendingNetworkDiagnostics) {
return await this.pendingNetworkDiagnostics;
@ -224,13 +229,14 @@ export class StackClientInterface {
requestOptions: RequestInit,
session: InternalSession | null,
requestType: "client" | "server" | "admin" = "client",
apiUrlOverride?: string,
) {
session ??= this.createSession({
refreshToken: null,
});
return await this._networkRetry(
() => this.sendClientRequestInner(path, requestOptions, session!, requestType),
() => this.sendClientRequestInner(path, requestOptions, session!, requestType, apiUrlOverride),
session,
requestType,
);
@ -259,6 +265,32 @@ export class StackClientInterface {
keepalive: options.keepalive,
},
session,
"client",
this.getAnalyticsApiUrl(),
);
return Result.ok(response);
} catch (e) {
return Result.error(e instanceof Error ? e : new Error(String(e)));
}
}
async sendAnalyticsEventBatch(
body: string,
session: InternalSession | null,
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const response = await this.sendClientRequest(
"/analytics/events/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: options.keepalive,
},
session,
"client",
this.getAnalyticsApiUrl(),
);
return Result.ok(response);
} catch (e) {
@ -297,6 +329,7 @@ export class StackClientInterface {
options: RequestInit,
session: InternalSession,
requestType: "client" | "server" | "admin",
apiUrlOverride?: string,
): Promise<Result<Response & {
usedTokens: {
accessToken: AccessToken,
@ -331,7 +364,7 @@ export class StackClientInterface {
// all requests should be dynamic to prevent Next.js caching
await this.options.prepareRequest?.();
let url = this.getApiUrl() + path;
let url = (apiUrlOverride ?? this.getApiUrl()) + path;
if (url.endsWith("/")) {
url = url.slice(0, -1);
}

View File

@ -1736,6 +1736,16 @@ const AnalyticsQueryError = createKnownErrorConstructor(
(json) => [json.error] as const,
);
const AnalyticsNotEnabled = createKnownErrorConstructor(
KnownError,
"ANALYTICS_NOT_ENABLED",
() => [
400,
"Analytics is not enabled for this project.",
] as const,
() => [] as const,
);
const DefaultPaymentMethodRequired = createKnownErrorConstructor(
KnownError,
"DEFAULT_PAYMENT_METHOD_REQUIRED",
@ -1900,6 +1910,7 @@ export const KnownErrors = {
DataVaultStoreHashedKeyDoesNotExist,
AnalyticsQueryTimeout,
AnalyticsQueryError,
AnalyticsNotEnabled,
} satisfies Record<string, KnownErrorConstructor<any, any>>;

View File

@ -52,7 +52,8 @@ import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation,
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveConstructorOptions } from "./common";
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveConstructorOptions } from "./common";
import { EventTracker } from "./event-tracker";
import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay";
// IF_PLATFORM react-like
@ -98,6 +99,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
private readonly _analyticsOptions: AnalyticsOptions | undefined;
private _sessionRecorder: SessionRecorder | null = null;
private _eventTracker: EventTracker | null = null;
private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete<false, string>>();
@ -418,6 +420,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
} else {
this._interface = new StackClientInterface({
getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl),
getAnalyticsBaseUrl: () => getAnalyticsBaseUrl(getBaseUrl(resolvedOptions.baseUrl)),
extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(),
projectId,
clientVersion,
@ -454,6 +457,22 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}, this._analyticsOptions.replays);
this._sessionRecorder.start();
}
// for now we only track events for internal project
if (isBrowserLike() && this.projectId === "internal") {
this._eventTracker = new EventTracker({
projectId: this.projectId,
getAccessToken: async () => {
const session = await this._getSession();
const tokens = await session.getOrFetchLikelyValidTokens(20_000, 75_000);
return tokens?.accessToken.token ?? null;
},
sendBatch: async (body, opts) => {
return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), opts);
},
});
this._eventTracker.start();
}
}
protected _initUniqueIdentifier() {
@ -2892,6 +2911,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
sendSessionReplayBatch: async (body: string, options: { keepalive: boolean }) => {
return await this._interface.sendSessionReplayBatch(body, await this._getSession(), options);
},
sendAnalyticsEventBatch: async (body: string, options: { keepalive: boolean }) => {
return await this._interface.sendAnalyticsEventBatch(body, await this._getSession(), options);
},
sendRequest: async (
path: string,
requestOptions: RequestInit,

View File

@ -144,6 +144,11 @@ export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, ser
return replaceStackPortPrefix(url.endsWith('/') ? url.slice(0, -1) : url);
}
export const defaultBaseUrl = "https://api.stack-auth.com";
export const defaultAnalyticsBaseUrl = "https://r.stack-auth.com";
export function getAnalyticsBaseUrl(regularBaseUrl: string): string {
return regularBaseUrl === defaultBaseUrl ? defaultAnalyticsBaseUrl : regularBaseUrl;
}
export type TokenObject = {
accessToken: string | null,

View File

@ -0,0 +1,279 @@
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { generateUuid } from "./session-replay";
const FLUSH_INTERVAL_MS = 10_000;
const MAX_EVENTS_PER_BATCH = 50;
const MAX_APPROX_BYTES_PER_BATCH = 64_000;
const MAX_PREAUTH_BUFFER_EVENTS = 500;
const MAX_PREAUTH_BUFFER_BYTES = 500_000;
export type EventTrackerDeps = {
projectId: string,
getAccessToken: () => Promise<string | null>,
sendBatch: (body: string, options: { keepalive: boolean }) => Promise<Result<Response, Error>>,
};
type TrackedEvent = {
event_type: "$page-view" | "$click",
event_at_ms: number,
data: Record<string, unknown>,
};
export class EventTracker {
private _started = false;
private _cancelled = false;
private _detachListeners: (() => void) | null = null;
private _flushTimer: ReturnType<typeof setInterval> | null = null;
private _events: TrackedEvent[] = [];
private _approxBytes = 0;
private _lastKnownAccessToken: string | null = null;
private _wasAuthenticated = false;
private _lastUrl: string | null = null;
private readonly _sessionReplaySegmentId: string;
private readonly _deps: EventTrackerDeps;
private _originalPushState: typeof history.pushState | null = null;
private _originalReplaceState: typeof history.replaceState | null = null;
constructor(deps: EventTrackerDeps) {
this._deps = deps;
this._sessionReplaySegmentId = generateUuid();
}
start() {
if (this._started) return;
if (!isBrowserLike()) return;
this._started = true;
this._setupPageViewCapture();
this._setupClickCapture();
this._setupPageHideListeners();
this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS);
}
stop() {
this._cancelled = true;
if (this._flushTimer !== null) {
clearInterval(this._flushTimer);
this._flushTimer = null;
}
runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true });
this._teardown();
}
private _pushEvent(event: TrackedEvent) {
this._events.push(event);
this._approxBytes += JSON.stringify(event).length;
if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true });
}
// Cap pre-auth buffer
if (!this._lastKnownAccessToken && (this._events.length > MAX_PREAUTH_BUFFER_EVENTS || this._approxBytes > MAX_PREAUTH_BUFFER_BYTES)) {
this._events = [];
this._approxBytes = 0;
}
}
private _capturePageView(entryType: "initial" | "push" | "replace" | "pop") {
const url = window.location.href;
if (url === this._lastUrl && entryType !== "initial") return;
this._lastUrl = url;
this._pushEvent({
event_type: "$page-view",
event_at_ms: Date.now(),
data: {
url,
path: window.location.pathname,
referrer: document.referrer,
title: document.title,
entry_type: entryType,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
screen_width: window.screen.width,
screen_height: window.screen.height,
},
});
}
private _setupPageViewCapture() {
// Fire initial page-view
this._capturePageView("initial");
// Monkey-patch history.pushState
this._originalPushState = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
this._originalPushState!(...args);
this._capturePageView("push");
};
// Monkey-patch history.replaceState
this._originalReplaceState = history.replaceState.bind(history);
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
this._originalReplaceState!(...args);
this._capturePageView("replace");
};
// Listen for popstate (back/forward navigation)
window.addEventListener("popstate", this._onPopState);
}
private readonly _onPopState = () => {
this._capturePageView("pop");
};
private _buildSelector(element: Element): string {
const parts: string[] = [];
let current: Element | null = element;
let depth = 0;
while (current && depth < 5) {
let part = current.tagName.toLowerCase();
if (current.id) {
part += `#${current.id}`;
parts.unshift(part);
break;
}
if (current.className && typeof current.className === "string") {
const classes = current.className.trim().split(/\s+/).filter(Boolean);
if (classes.length > 0) {
part += `.${classes.join(".")}`;
}
}
parts.unshift(part);
current = current.parentElement;
depth++;
}
return parts.join(" > ");
}
private _findNearestAnchorHref(element: Element): string | null {
let current: Element | null = element;
while (current) {
if (current.tagName === "A" && current.hasAttribute("href")) {
return current.getAttribute("href");
}
current = current.parentElement;
}
return null;
}
private readonly _onClickCapture = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
this._pushEvent({
event_type: "$click",
event_at_ms: Date.now(),
data: {
tag_name: target.tagName.toLowerCase(),
text: target.textContent?.trim().substring(0, 200) ?? null,
href: this._findNearestAnchorHref(target),
selector: this._buildSelector(target),
x: event.clientX,
y: event.clientY,
page_x: event.pageX,
page_y: event.pageY,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
},
});
};
private _setupClickCapture() {
document.addEventListener("click", this._onClickCapture, { capture: true });
}
private readonly _onPageHide = () => {
runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true });
};
private _setupPageHideListeners() {
window.addEventListener("pagehide", this._onPageHide);
document.addEventListener("visibilitychange", this._onPageHide);
this._detachListeners = () => {
window.removeEventListener("pagehide", this._onPageHide);
document.removeEventListener("visibilitychange", this._onPageHide);
};
}
private _teardown() {
if (this._detachListeners) {
this._detachListeners();
this._detachListeners = null;
}
// Restore history methods
if (this._originalPushState) {
history.pushState = this._originalPushState;
this._originalPushState = null;
}
if (this._originalReplaceState) {
history.replaceState = this._originalReplaceState;
this._originalReplaceState = null;
}
window.removeEventListener("popstate", this._onPopState);
document.removeEventListener("click", this._onClickCapture, { capture: true });
this._events = [];
this._approxBytes = 0;
}
private async _flush(options: { keepalive: boolean }) {
if (!this._lastKnownAccessToken) return;
if (this._events.length === 0) return;
const nowMs = Date.now();
const batchId = generateUuid();
const payload = {
session_replay_segment_id: this._sessionReplaySegmentId,
batch_id: batchId,
sent_at_ms: nowMs,
events: this._events,
};
this._events = [];
this._approxBytes = 0;
const res = await this._deps.sendBatch(
JSON.stringify(payload),
{ keepalive: options.keepalive },
);
if (res.status === "error") {
console.warn("EventTracker flush failed:", res.error);
return;
}
if (!res.data.ok) {
console.warn("EventTracker flush failed:", res.data.status, await res.data.text());
}
}
private _tick() {
if (this._cancelled) return;
runAsynchronously(async () => {
this._lastKnownAccessToken = await this._deps.getAccessToken();
}, { noErrorLogging: true });
const hasAuth = !!this._lastKnownAccessToken;
// Clear buffer on logout to prevent cross-user event leakage
if (this._wasAuthenticated && !hasAuth) {
this._events = [];
this._approxBytes = 0;
}
this._wasAuthenticated = hasAuth;
if (hasAuth && this._events.length > 0) {
runAsynchronously(() => this._flush({ keepalive: false }), { noErrorLogging: true });
}
}
}

View File

@ -89,13 +89,13 @@ const MAX_APPROX_BYTES_PER_BATCH = 512_000;
const MAX_PREAUTH_BUFFER_EVENTS = 10_000;
const MAX_PREAUTH_BUFFER_BYTES = 5_000_000;
type StoredSession = {
export type StoredSession = {
session_id: string,
created_at_ms: number,
last_activity_ms: number,
};
function safeParseStoredSession(raw: string | null): StoredSession | null {
export function safeParseStoredSession(raw: string | null): StoredSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
@ -109,15 +109,15 @@ function safeParseStoredSession(raw: string | null): StoredSession | null {
}
}
function makeStorageKey(projectId: string) {
export function makeStorageKey(projectId: string) {
return `${LOCAL_STORAGE_PREFIX}:${projectId}`;
}
function generateUuid() {
export function generateUuid() {
return crypto.randomUUID();
}
function getOrRotateSession(options: { key: string, nowMs: number }): StoredSession {
export function getOrRotateSession(options: { key: string, nowMs: number }): StoredSession {
const existing = safeParseStoredSession(localStorage.getItem(options.key));
if (existing && options.nowMs - existing.last_activity_ms <= IDLE_TTL_MS) {
return existing;

View File

@ -106,6 +106,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
setCurrentUser(userJsonPromise: Promise<CurrentUserCrud['Client']['Read'] | null>): void,
getConstructorOptions(): StackClientAppConstructorOptions<HasTokenStore, ProjectId> & { inheritsFrom?: undefined },
sendSessionReplayBatch(body: string, options: { keepalive: boolean }): Promise<Result<Response, Error>>,
sendAnalyticsEventBatch(body: string, options: { keepalive: boolean }): Promise<Result<Response, Error>>,
},
}
& AsyncStoreProperty<"project", [], Project, false>