mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
fd79f626d3
commit
145bcb7e92
@ -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;",
|
||||
|
||||
@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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`;
|
||||
|
||||
23
apps/backend/src/lib/session-replays.tsx
Normal file
23
apps/backend/src/lib/session-replays.tsx
Normal 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 },
|
||||
});
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user