From 2dcb935f0a120bd5eb2c1b836ba8774a8b04ce12 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 13:50:01 -0800 Subject: [PATCH 01/21] feat: session recording database schema, S3 utilities, and batch upload endpoint - Add SessionRecording and SessionRecordingChunk Prisma models - Add migration for session_recordings_mvp - Add seed data for session recordings - Add S3 uploadBytes, downloadBytes, and getS3PublicUrl utilities - Add POST /session-recordings/batch endpoint for client uploads --- .../migration.sql | 56 +++++ apps/backend/prisma/schema.prisma | 55 +++++ apps/backend/prisma/seed.ts | 69 ++++++ .../latest/session-recordings/batch/route.tsx | 207 ++++++++++++++++++ apps/backend/src/s3.tsx | 73 +++++- 5 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql create mode 100644 apps/backend/src/app/api/latest/session-recordings/batch/route.tsx diff --git a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql new file mode 100644 index 0000000000..3479fbe9d9 --- /dev/null +++ b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql @@ -0,0 +1,56 @@ +-- Session recording MVP: store session metadata in Postgres and rrweb events in S3. + +CREATE TABLE "SessionRecording" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "projectUserId" UUID NOT NULL, + "refreshTokenId" UUID NOT NULL, + "startedAt" TIMESTAMP(3) NOT NULL, + "lastEventAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SessionRecording_pkey" PRIMARY KEY ("tenancyId","id") +); + +CREATE TABLE "SessionRecordingChunk" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "sessionRecordingId" UUID NOT NULL, + "batchId" UUID NOT NULL, + "tabId" TEXT NOT NULL, + "s3Key" TEXT NOT NULL, + "eventCount" INTEGER NOT NULL, + "byteLength" INTEGER NOT NULL, + "firstEventAt" TIMESTAMP(3) NOT NULL, + "lastEventAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SessionRecordingChunk_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "SessionRecording" + ADD CONSTRAINT "SessionRecording_tenancyId_fkey" + FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SessionRecording" + ADD CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey" + FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SessionRecordingChunk" + ADD CONSTRAINT "SessionRecordingChunk_tenancyId_fkey" + FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SessionRecordingChunk" + ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey" + FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" + ON "SessionRecording"("tenancyId", "projectUserId", "startedAt"); + +CREATE INDEX "SessionRecording_tenancyId_lastEventAt_idx" + ON "SessionRecording"("tenancyId", "lastEventAt"); + +CREATE UNIQUE INDEX "SessionRecordingChunk_sessionRecordingId_batchId_key" + ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "batchId"); + +CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdAt_idx" + ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 46e39119eb..879b0dc53c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -60,6 +60,8 @@ model Tenancy { organizationId String? @db.Uuid hasNoOrganization BooleanTrue? emailOutboxes EmailOutbox[] + sessionRecordings SessionRecording[] + sessionRecordingChunks SessionRecordingChunk[] @@unique([projectId, branchId, organizationId]) @@unique([projectId, branchId, hasNoOrganization]) @@ -234,6 +236,7 @@ model ProjectUser { Project Project? @relation(fields: [projectId], references: [id]) projectId String? userNotificationPreference UserNotificationPreference[] + sessionRecordings SessionRecording[] @@id([tenancyId, projectUserId]) @@unique([mirroredProjectId, mirroredBranchId, projectUserId]) @@ -277,6 +280,58 @@ model ProjectUserOAuthAccount { @@index([tenancyId, projectUserId]) } +model SessionRecording { + // Cross-tab session id generated by the SDK and stored in localStorage. + id String @db.Uuid + + tenancyId String @db.Uuid + projectUserId String @db.Uuid + refreshTokenId String @db.Uuid + + startedAt DateTime + lastEventAt DateTime + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + chunks SessionRecordingChunk[] + + @@id([tenancyId, id]) + @@index([tenancyId, projectUserId, startedAt]) + @@index([tenancyId, lastEventAt]) +} + +model SessionRecordingChunk { + id String @id @default(uuid()) @db.Uuid + + tenancyId String @db.Uuid + sessionRecordingId String @db.Uuid + + // Unique per uploaded batch for a given session id. + batchId String @db.Uuid + + // Ephemeral in-memory id generated by the client. Stored for future tab separation if needed. + tabId String + + s3Key String + eventCount Int + byteLength Int + + firstEventAt DateTime + lastEventAt DateTime + + createdAt DateTime @default(now()) + + sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade) + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + @@unique([tenancyId, sessionRecordingId, batchId]) + @@index([tenancyId, sessionRecordingId, createdAt]) +} + enum ContactChannelType { EMAIL // PHONE diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index d3bd0ecb81..301bf21944 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1118,6 +1118,13 @@ async function seedDummyProject(options: DummyProjectSeedOptions) { userEmailToId, }); + await seedDummySessionRecordings({ + prisma: dummyPrisma, + tenancyId: dummyTenancy.id, + userEmailToId, + targetSessionRecordingCount: 75 + }); + console.log('Seeded dummy project data'); } @@ -1765,3 +1772,65 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO console.log('Finished seeding session activity events'); } + +type SessionRecordingSeedOptions = { + prisma: PrismaClientTransaction, + tenancyId: string, + userEmailToId: Map, + targetSessionRecordingCount?: number, +}; + +async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) { + const { + prisma, + tenancyId, + userEmailToId, + targetSessionRecordingCount = 250, + } = options; + + const existingCount = await prisma.sessionRecording.count({ + where: { + tenancyId, + }, + }); + + if (existingCount >= targetSessionRecordingCount) { + console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`); + return; + } + + const toCreate = targetSessionRecordingCount - existingCount; + const userIds = Array.from(userEmailToId.values()); + if (userIds.length === 0) { + throw new Error('Cannot seed session recordings: no dummy project users exist'); + } + + const now = new Date(); + const twoWeeksAgo = new Date(now); + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); + + const seeds: Prisma.SessionRecordingCreateManyInput[] = []; + for (let i = 0; i < toCreate; i++) { + const startedAt = new Date( + twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()), + ); + const durationMs = 10_000 + Math.floor(Math.random() * (20 * 60 * 1000)); // 10s..20m + const lastEventAt = new Date(startedAt.getTime() + durationMs); + const projectUserId = userIds[Math.floor(Math.random() * userIds.length)]!; + + seeds.push({ + tenancyId, + refreshTokenId: generateUuid(), + projectUserId, + id: generateUuid(), + startedAt, + lastEventAt, + }); + } + + await prisma.sessionRecording.createMany({ + data: seeds, + }); + + console.log(`Seeded ${toCreate} session recordings`); +} diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx new file mode 100644 index 0000000000..0aff7708c3 --- /dev/null +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -0,0 +1,207 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { uploadBytes } from "@/s3"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { Prisma } from "@/generated/prisma/client"; +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"; +import { promisify } from "node:util"; +import { gzip as gzipCb } from "node:zlib"; + +const gzip = promisify(gzipCb); + +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_BODY_BYTES = 5_000_000; +const MAX_EVENTS = 5_000; + +function extractEventTimesMs(events: unknown[], fallbackMs: number) { + let minTs = Infinity; + let maxTs = -Infinity; + + for (const e of events) { + if (typeof e !== "object" || e === null) continue; + if (!("timestamp" in e)) continue; + const ts = (e as any).timestamp; + if (typeof ts !== "number" || !Number.isFinite(ts)) continue; + minTs = Math.min(minTs, ts); + maxTs = Math.max(maxTs, ts); + } + + if (!Number.isFinite(minTs) || !Number.isFinite(maxTs) || minTs > maxTs) { + return { firstMs: fallbackMs, lastMs: fallbackMs }; + } + return { firstMs: minTs, lastMs: maxTs }; +} + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Upload rrweb session recording batch", + description: "Uploads a batch of rrweb events for a cross-tab session recording.", + tags: ["Session Recordings"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + user: adaptSchema, + refreshTokenId: adaptSchema + }).defined(), + body: yupObject({ + session_id: yupString().defined().matches(UUID_RE, "Invalid session_id"), + tab_id: yupString().defined().matches(UUID_RE, "Invalid tab_id"), + batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"), + started_at_ms: yupNumber().defined().integer().min(0), + sent_at_ms: yupNumber().defined().integer().min(0), + events: yupArray(yupMixed().defined()).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + session_id: yupString().defined(), + batch_id: yupString().defined(), + s3_key: yupString().defined(), + deduped: yupMixed().defined(), + }).defined(), + }), + async handler({ auth, body }, fullReq) { + if (!auth.user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (!auth.refreshTokenId) { + throw new StatusError(StatusError.BadRequest, "A refresh token is required for session recordings"); + } + const projectUserId = auth.user.id; + const refreshTokenId = auth.refreshTokenId; + + if (fullReq.bodyBuffer.byteLength > MAX_BODY_BYTES) { + throw new StatusError(StatusError.PayloadTooLarge, `Request body too large (max ${MAX_BODY_BYTES} bytes)`); + } + + if (body.events.length === 0) { + throw new StatusError(StatusError.BadRequest, "events must not be empty"); + } + if (body.events.length > MAX_EVENTS) { + throw new StatusError(StatusError.BadRequest, `Too many events (max ${MAX_EVENTS})`); + } + + const sessionId = body.session_id; + const batchId = body.batch_id; + const tabId = body.tab_id; + const tenancyId = auth.tenancy.id; + + const projectId = auth.tenancy.project.id; + const branchId = auth.tenancy.branchId; + const s3Key = `session-recordings/${projectId}/${branchId}/${sessionId}/${batchId}.json.gz`; + + const { firstMs, lastMs } = extractEventTimesMs(body.events, body.sent_at_ms); + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + // Ensure the session row exists and is up-to-date. + const existingSession = await prisma.sessionRecording.findUnique({ + where: { tenancyId_id: { tenancyId, id: sessionId } }, + select: { startedAt: true, lastEventAt: true }, + }); + const newStartedAtMs = Math.min(existingSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs); + const newLastEventAtMs = Math.max(existingSession?.lastEventAt.getTime() ?? 0, lastMs); + await prisma.sessionRecording.upsert({ + where: { tenancyId_id: { tenancyId, id: sessionId } }, + create: { + id: sessionId, + tenancyId, + projectUserId: projectUserId, + refreshTokenId, + // Use the first event timestamp instead of "session started" timestamps, + // since session_id can be reused across tabs/idle windows. + startedAt: new Date(firstMs), + lastEventAt: new Date(newLastEventAtMs), + }, + update: { + refreshTokenId, + startedAt: new Date(newStartedAtMs), + lastEventAt: new Date(newLastEventAtMs), + }, + }); + + // If we already have this batch for this session, return deduped without touching S3. + const existingChunk = await prisma.sessionRecordingChunk.findUnique({ + where: { tenancyId_sessionRecordingId_batchId: { tenancyId, sessionRecordingId: sessionId, batchId } }, + select: { s3Key: true }, + }); + if (existingChunk) { + return { + statusCode: 200, + bodyType: "json", + body: { + session_id: sessionId, + batch_id: batchId, + s3_key: existingChunk.s3Key, + deduped: true, + }, + }; + } + + const payload = { + v: 1, + session_id: sessionId, + tab_id: tabId, + batch_id: batchId, + started_at_ms: body.started_at_ms, + sent_at_ms: body.sent_at_ms, + events: body.events, + }; + const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)); + const gzipped = new Uint8Array(await gzip(payloadBytes)); + + await uploadBytes({ + key: s3Key, + body: gzipped, + contentType: "application/json", + contentEncoding: "gzip", + }); + + try { + await prisma.sessionRecordingChunk.create({ + data: { + tenancyId, + sessionRecordingId: sessionId, + batchId, + tabId, + s3Key, + eventCount: body.events.length, + byteLength: gzipped.byteLength, + firstEventAt: new Date(firstMs), + lastEventAt: new Date(lastMs), + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + return { + statusCode: 200, + bodyType: "json", + body: { + session_id: sessionId, + batch_id: batchId, + s3_key: s3Key, + deduped: true, + }, + }; + } + throw e; + } + + return { + statusCode: 200, + bodyType: "json", + body: { + session_id: sessionId, + batch_id: batchId, + s3_key: s3Key, + deduped: false, + }, + }; + }, +}); diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index d292305131..1f7e8f3a56 100644 --- a/apps/backend/src/s3.tsx +++ b/apps/backend/src/s3.tsx @@ -1,4 +1,4 @@ -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { ImageProcessingError, parseBase64Image } from "./lib/images"; @@ -34,6 +34,77 @@ export function getS3PublicUrl(key: string): string { } } +export async function uploadBytes(options: { + key: string, + body: Uint8Array, + contentType?: string, + contentEncoding?: string, +}) { + if (!s3Client) { + throw new StackAssertionError("S3 is not configured"); + } + + const command = new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: options.key, + Body: options.body, + ...(options.contentType ? { ContentType: options.contentType } : {}), + ...(options.contentEncoding ? { ContentEncoding: options.contentEncoding } : {}), + }); + + await s3Client.send(command); + + return { + key: options.key, + url: getS3PublicUrl(options.key), + }; +} + +async function readBodyToBytes(body: unknown): Promise { + if (body instanceof Uint8Array) return body; + if (Buffer.isBuffer(body)) return new Uint8Array(body); + + // Web ReadableStream (some runtimes) + if (typeof body === "object" && body !== null && "transformToByteArray" in body && typeof (body as any).transformToByteArray === "function") { + return (body as any).transformToByteArray(); + } + + // Node.js Readable or any AsyncIterable + if (typeof body === "object" && body !== null && Symbol.asyncIterator in (body as any)) { + const chunks: Buffer[] = []; + for await (const chunk of body as any) { + if (chunk instanceof Uint8Array) { + chunks.push(Buffer.from(chunk)); + } else if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else { + throw new StackAssertionError("Unexpected S3 body chunk type"); + } + } + return new Uint8Array(Buffer.concat(chunks)); + } + + throw new StackAssertionError("Unexpected S3 body type"); +} + +export async function downloadBytes(options: { key: string }): Promise { + if (!s3Client) { + throw new StackAssertionError("S3 is not configured"); + } + + const command = new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: options.key, + }); + + const res = await s3Client.send(command); + if (!res.Body) { + throw new StackAssertionError("S3 getObject returned empty body"); + } + + return await readBodyToBytes(res.Body); +} + async function uploadBase64Image({ input, maxBytes = 1_000_000, // 1MB From c62446bccb2678c3c2bf4a2fffc8042bbc5be440 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 13:50:39 -0800 Subject: [PATCH 02/21] feat: client-side session recording SDK with rrweb capture - Add StackAnalyticsInternal component with rrweb DOM capture and batched flush - Accept AnalyticsOptions prop in StackProvider - Conditionally render StackAnalyticsInternal in client provider - Export AnalyticsOptions types from template package - Add rrweb dependency to template, js, react, and stack packages --- packages/js/package.json | 1 + packages/react/package.json | 1 + packages/stack/package.json | 1 + packages/template/package-template.json | 1 + packages/template/package.json | 1 + .../src/components/stack-analytics.tsx | 259 ++++++++++++++++++ packages/template/src/index.ts | 2 +- .../src/providers/stack-provider-client.tsx | 3 + .../template/src/providers/stack-provider.tsx | 15 +- pnpm-lock.yaml | 53 ++++ 10 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 packages/template/src/components/stack-analytics.tsx diff --git a/packages/js/package.json b/packages/js/package.json index 55e2b2b6f5..42c06f5fe7 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -62,6 +62,7 @@ "@oslojs/otp": "^1.1.0", "qrcode": "^1.5.4", "rimraf": "^6.1.2", + "rrweb": "^1.1.3", "tsx": "^4.21.0", "yup": "^1.7.1" }, diff --git a/packages/react/package.json b/packages/react/package.json index 7cc7618e53..0bbb2c2883 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -78,6 +78,7 @@ "react-hook-form": "^7.70.0", "rimraf": "^6.1.2", "tailwindcss-animate": "^1.0.7", + "rrweb": "^1.1.3", "tsx": "^4.21.0", "yup": "^1.7.1" }, diff --git a/packages/stack/package.json b/packages/stack/package.json index d08fc1c806..7198b55061 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -79,6 +79,7 @@ "react-hook-form": "^7.70.0", "rimraf": "^6.1.2", "tailwindcss-animate": "^1.0.7", + "rrweb": "^1.1.3", "tsx": "^4.21.0", "yup": "^1.7.1" }, diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 99ffb8a01a..ae894a1a49 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -116,6 +116,7 @@ "rimraf": "^6.1.2", "//": "NEXT_LINE_PLATFORM react-like", "tailwindcss-animate": "^1.0.7", + "rrweb": "^1.1.3", "tsx": "^4.21.0", "yup": "^1.7.1" }, diff --git a/packages/template/package.json b/packages/template/package.json index fa3cd4a026..6394606826 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -84,6 +84,7 @@ "react-hook-form": "^7.70.0", "rimraf": "^6.1.2", "tailwindcss-animate": "^1.0.7", + "rrweb": "^1.1.3", "tsx": "^4.21.0", "yup": "^1.7.1" }, diff --git a/packages/template/src/components/stack-analytics.tsx b/packages/template/src/components/stack-analytics.tsx new file mode 100644 index 0000000000..192c84bcf2 --- /dev/null +++ b/packages/template/src/components/stack-analytics.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import React, { useEffect, useMemo, useRef } from "react"; +import { useStackApp } from "../lib/hooks"; +import { stackAppInternalsSymbol } from "../lib/stack-app/common"; +import { clientVersion, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultPublishableClientKey } from "../lib/stack-app/apps/implementations/common"; + +export type AnalyticsReplayOptions = { + enabled?: boolean, + maskAllInputs?: boolean, + blockClass?: string | RegExp, + blockSelector?: string, +} + +export type AnalyticsOptions = { + replays?: AnalyticsReplayOptions, +} + +const LOCAL_STORAGE_PREFIX = "stack:session-recording:v1"; +const IDLE_TTL_MS = 3 * 60 * 1000; + +const FLUSH_INTERVAL_MS = 5_000; +const MAX_EVENTS_PER_BATCH = 200; +const MAX_APPROX_BYTES_PER_BATCH = 512_000; + +type StoredSession = { + session_id: string, + created_at_ms: number, + last_activity_ms: number, +}; + +function isBrowser() { + return typeof window !== "undefined"; +} + +function safeParseStoredSession(raw: string | null): StoredSession | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + if (typeof parsed.session_id !== "string") return null; + if (typeof parsed.created_at_ms !== "number") return null; + if (typeof parsed.last_activity_ms !== "number") return null; + return parsed as StoredSession; + } catch { + return null; + } +} + +function makeStorageKey(projectId: string) { + return `${LOCAL_STORAGE_PREFIX}:${projectId}`; +} + +function generateUuid() { + if (!isBrowser()) { + throw new Error("generateUuid() called outside browser"); + } + return crypto.randomUUID(); +} + +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; + } + const next: StoredSession = { + session_id: generateUuid(), + created_at_ms: options.nowMs, + last_activity_ms: options.nowMs, + }; + localStorage.setItem(options.key, JSON.stringify(next)); + return next; +} + +export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayOptions }) { + const app = useStackApp(); + const tabId = useMemo(() => generateUuid(), []); + + // Use reactive hooks for tokens instead of app.getAccessToken() which + // calls getUser() -> /users/me on every invocation (bypassing the cache). + // These hooks subscribe to the cache and only trigger network requests when needed. + const accessToken = app.useAccessToken(); + const refreshToken = app.useRefreshToken(); + + // Refs so the effect closure always has the latest token values + // without needing tokens in the dependency array (which would restart recording). + const accessTokenRef = useRef(accessToken); + const refreshTokenRef = useRef(refreshToken); + accessTokenRef.current = accessToken; + refreshTokenRef.current = refreshToken; + + useEffect(() => { + let cancelled = false; + let stopRecording: (() => void) | null = null; + let detachListeners: (() => void) | null = null; + let flushTimer: number | null = null; + let events: unknown[] = []; + let approxBytes = 0; + let lastPersistActivity = 0; + let recording = false; + let rrwebModule: typeof import("rrweb") | null = null; + + const storageKey = makeStorageKey(app.projectId); + + const persistActivity = (nowMs: number) => { + const stored = getOrRotateSession({ key: storageKey, nowMs }); + if (nowMs - lastPersistActivity < 5_000) return; + lastPersistActivity = nowMs; + const updated: StoredSession = { ...stored, last_activity_ms: nowMs }; + localStorage.setItem(storageKey, JSON.stringify(updated)); + }; + + const flush = async (options: { keepalive: boolean }) => { + const currentAccessToken = accessTokenRef.current; + const currentRefreshToken = refreshTokenRef.current; + if (!currentAccessToken) return; + if (events.length === 0) return; + + const nowMs = Date.now(); + const stored = getOrRotateSession({ key: storageKey, nowMs }); + + const batchId = generateUuid(); + const payload = { + session_id: stored.session_id, + tab_id: tabId, + batch_id: batchId, + started_at_ms: stored.created_at_ms, + sent_at_ms: nowMs, + events, + }; + + events = []; + approxBytes = 0; + + const constructorOptions = app[stackAppInternalsSymbol].getConstructorOptions(); + const baseUrl = getBaseUrl(constructorOptions.baseUrl); + const publishableClientKey = constructorOptions.publishableClientKey ?? getDefaultPublishableClientKey(); + const extraRequestHeaders = constructorOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(); + + const res = await Result.fromThrowingAsync(async () => { + return await fetch(new URL("/api/v1/session-recordings/batch", baseUrl), { + method: "POST", + credentials: "omit", + keepalive: options.keepalive, + headers: { + "content-type": "application/json", + "x-stack-project-id": app.projectId, + "x-stack-access-type": "client", + "x-stack-client-version": clientVersion, + "x-stack-access-token": currentAccessToken, + ...(currentRefreshToken ? { "x-stack-refresh-token": currentRefreshToken } : {}), + "x-stack-publishable-client-key": publishableClientKey, + "x-stack-allow-anonymous-user": "true", + ...extraRequestHeaders, + }, + body: JSON.stringify(payload), + }); + }); + + if (res.status === "error") { + // This is best-effort telemetry. Don't throw and break the app. + console.warn("StackAnalyticsInternal flush failed:", res.error); + return; + } + + if (!res.data.ok) { + console.warn("StackAnalyticsInternal flush failed:", res.data.status, await res.data.text()); + } + }; + + const startRecording = async () => { + if (recording || cancelled) return; + + if (!rrwebModule) { + const rrwebImport = await Result.fromPromise(import("rrweb")); + if (rrwebImport.status === "error") { + console.warn("StackAnalyticsInternal: rrweb import failed. Is rrweb installed?", rrwebImport.error); + return; + } + rrwebModule = rrwebImport.data; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancelled may change during the await above + if (cancelled) return; + + stopRecording = rrwebModule.record({ + emit: (event) => { + const nowMs = Date.now(); + persistActivity(nowMs); + + events.push(event); + approxBytes += JSON.stringify(event).length; + if (events.length >= MAX_EVENTS_PER_BATCH || approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { + runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); + } + }, + maskAllInputs: props.replayOptions?.maskAllInputs ?? true, + ...(props.replayOptions?.blockClass !== undefined ? { blockClass: props.replayOptions.blockClass } : {}), + ...(props.replayOptions?.blockSelector !== undefined ? { blockSelector: props.replayOptions.blockSelector } : {}), + }) ?? null; + + recording = true; + + const onPageHide = () => { + runAsynchronously(() => flush({ keepalive: true }), { noErrorLogging: true }); + }; + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onPageHide); + detachListeners = () => { + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onPageHide); + }; + }; + + const stopCurrentRecording = () => { + if (detachListeners) { + detachListeners(); + detachListeners = null; + } + if (stopRecording) { + stopRecording(); + stopRecording = null; + } + events = []; + approxBytes = 0; + recording = false; + }; + + // Periodically flushes events. Recording start/stop is driven by + // the accessToken dependency below, not by polling. + const tick = () => { + if (cancelled) return; + if (accessTokenRef.current && events.length > 0) { + runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); + } + }; + + // Start or stop recording based on current auth state. + if (accessTokenRef.current) { + runAsynchronously(() => startRecording(), { noErrorLogging: true }); + } + + flushTimer = window.setInterval(tick, FLUSH_INTERVAL_MS); + + return () => { + cancelled = true; + if (flushTimer !== null) { + window.clearInterval(flushTimer); + } + // Flush remaining events before cleanup + runAsynchronously(() => flush({ keepalive: true }), { noErrorLogging: true }); + stopCurrentRecording(); + }; + }, [app, tabId, !!accessToken]); + + return null; +} diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index c050374962..7de2ed766b 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -1,8 +1,8 @@ export * from './lib/stack-app'; - export { getConvexProvidersConfig } from "./integrations/convex"; // IF_PLATFORM react-like +export type { AnalyticsOptions, AnalyticsReplayOptions } from "./components/stack-analytics"; export { default as StackHandler } from "./components-page/stack-handler"; export { useStackApp, useUser } from "./lib/hooks"; export { default as StackProvider } from "./providers/stack-provider"; diff --git a/packages/template/src/providers/stack-provider-client.tsx b/packages/template/src/providers/stack-provider-client.tsx index 1e423dcaaa..59a7cefd27 100644 --- a/packages/template/src/providers/stack-provider-client.tsx +++ b/packages/template/src/providers/stack-provider-client.tsx @@ -4,6 +4,7 @@ import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/cu import { globalVar } from "@stackframe/stack-shared/dist/utils/globals"; import React, { useEffect } from "react"; import { useStackApp } from ".."; +import { AnalyticsOptions, StackAnalyticsInternal } from "../components/stack-analytics"; import { StackClientApp, StackClientAppJson, stackAppInternalsSymbol } from "../lib/stack-app"; export const StackContext = React.createContext | StackClientApp, serialized: boolean, + analytics?: AnalyticsOptions, children?: React.ReactNode, }) { const app = props.serialized @@ -22,6 +24,7 @@ export function StackProviderClient(props: { return ( + {props.analytics?.replays?.enabled !== false ? : null} {props.children} ); diff --git a/packages/template/src/providers/stack-provider.tsx b/packages/template/src/providers/stack-provider.tsx index cb985cd318..7cd9cd3f3d 100644 --- a/packages/template/src/providers/stack-provider.tsx +++ b/packages/template/src/providers/stack-provider.tsx @@ -1,4 +1,5 @@ import React, { Suspense } from 'react'; +import { AnalyticsOptions } from '../components/stack-analytics'; import { StackAdminApp, StackClientApp, StackServerApp, stackAppInternalsSymbol } from '../lib/stack-app'; import { StackProviderClient } from './stack-provider-client'; import { TranslationProvider } from './translation-provider'; @@ -9,6 +10,7 @@ function NextStackProvider({ app, lang, translationOverrides, + analytics, }: { lang?: React.ComponentProps['lang'], /** @@ -21,9 +23,13 @@ function NextStackProvider({ children: React.ReactNode, // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp | StackServerApp | StackAdminApp, + /** + * Options for analytics and session recording. When omitted, replays are enabled with all inputs masked. + */ + analytics?: AnalyticsOptions, }) { return ( - + {children} @@ -37,6 +43,7 @@ function ReactStackProvider({ app, lang, translationOverrides, + analytics, }: { lang?: React.ComponentProps['lang'], /** @@ -49,9 +56,13 @@ function ReactStackProvider({ children: React.ReactNode, // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp, + /** + * Options for analytics and session recording. When omitted, replays are enabled with all inputs masked. + */ + analytics?: AnalyticsOptions, }) { return ( - + {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bc666232..dab70176f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -574,6 +574,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + rrweb: + specifier: ^1.1.3 + version: 1.1.3 svix: specifier: ^1.32.0 version: 1.32.0(encoding@0.1.13) @@ -1506,6 +1509,9 @@ importers: rimraf: specifier: ^6.1.2 version: 6.1.2 + rrweb: + specifier: ^1.1.3 + version: 1.1.3 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1627,6 +1633,9 @@ importers: rimraf: specifier: ^6.1.2 version: 6.1.2 + rrweb: + specifier: ^1.1.3 + version: 1.1.3 tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) @@ -1760,6 +1769,9 @@ importers: rimraf: specifier: ^6.1.2 version: 6.1.2 + rrweb: + specifier: ^1.1.3 + version: 1.1.3 tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) @@ -2148,6 +2160,9 @@ importers: rimraf: specifier: ^6.1.2 version: 6.1.2 + rrweb: + specifier: ^1.1.3 + version: 1.1.3 tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) @@ -8410,6 +8425,9 @@ packages: '@types/cookies@0.9.0': resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} + '@types/css-font-loading-module@0.0.7': + resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -8992,6 +9010,9 @@ packages: '@webgpu/types@0.1.66': resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==} + '@xstate/fsm@1.6.5': + resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -9320,6 +9341,10 @@ packages: bare-events@2.4.2: resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -12868,6 +12893,9 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mitt@1.2.0: + resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -14375,6 +14403,12 @@ packages: rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + rrweb-snapshot@1.1.14: + resolution: {integrity: sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ==} + + rrweb@1.1.3: + resolution: {integrity: sha512-F2qp8LteJLyycsv+lCVJqtVpery63L3U+/ogqMA0da8R7Jx57o6gT+HpjrzdeeGMIBZR7kKNaKyJwDupTTu5KA==} + rsvp@3.2.1: resolution: {integrity: sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg==} @@ -24141,6 +24175,8 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 20.17.6 + '@types/css-font-loading-module@0.0.7': {} + '@types/d3-array@3.2.1': {} '@types/d3-axis@3.0.6': @@ -24968,6 +25004,8 @@ snapshots: '@webgpu/types@0.1.66': {} + '@xstate/fsm@1.6.5': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -25362,6 +25400,8 @@ snapshots: bare-events@2.4.2: optional: true + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.8.21: {} @@ -30040,6 +30080,8 @@ snapshots: dependencies: minipass: 7.1.2 + mitt@1.2.0: {} + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -32061,6 +32103,17 @@ snapshots: rrweb-cssom@0.7.1: {} + rrweb-snapshot@1.1.14: {} + + rrweb@1.1.3: + dependencies: + '@types/css-font-loading-module': 0.0.7 + '@xstate/fsm': 1.6.5 + base64-arraybuffer: 1.0.2 + fflate: 0.4.8 + mitt: 1.2.0 + rrweb-snapshot: 1.1.14 + rsvp@3.2.1: {} rsvp@4.8.5: {} From 6799c8e9cfdba57a80b582032c9cb35dbadb78ce Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 13:51:34 -0800 Subject: [PATCH 03/21] feat: admin SDK methods and internal API endpoints for session recordings - Add SessionRecording and SessionRecordingChunk CRUD response types - Add listSessionRecordings, listChunks, getEvents to admin interface - Implement admin SDK methods for session recording access - Add GET /internal/session-recordings endpoint (list sessions) - Add GET /internal/session-recordings/[id]/chunks endpoint (list chunks) - Add GET /internal/session-recordings/[id]/chunks/[id]/events endpoint (fetch from S3) - Add E2E tests for batch upload and admin endpoints --- .../chunks/[chunk_id]/events/route.tsx | 91 ++++ .../[session_recording_id]/chunks/route.tsx | 125 +++++ .../internal/session-recordings/route.tsx | 149 +++++ .../api/v1/session-recordings.test.ts | 510 ++++++++++++++++++ .../src/interface/admin-interface.ts | 40 ++ .../src/interface/crud/session-recordings.ts | 48 ++ .../apps/implementations/admin-app-impl.ts | 54 ++ .../stack-app/apps/interfaces/admin-app.ts | 49 ++ 8 files changed, 1066 insertions(+) create mode 100644 apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/session-recordings/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts create mode 100644 packages/stack-shared/src/interface/crud/session-recordings.ts diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx new file mode 100644 index 0000000000..0d5b21e20c --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx @@ -0,0 +1,91 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { downloadBytes } from "@/s3"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { promisify } from "node:util"; +import { gunzip as gunzipCb } from "node:zlib"; + +const gunzip = promisify(gunzipCb); + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + session_recording_id: yupString().defined(), + chunk_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + events: yupArray(yupMixed().defined()).defined(), + }).defined(), + }), + async handler({ auth, params }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + const sessionRecordingId = params.session_recording_id; + const chunkId = params.chunk_id; + + const chunk = await prisma.sessionRecordingChunk.findFirst({ + where: { + tenancyId: auth.tenancy.id, + sessionRecordingId, + id: chunkId, + }, + select: { + s3Key: true, + }, + }); + if (!chunk) { + throw new KnownErrors.ItemNotFound(chunkId); + } + + let bytes: Uint8Array; + try { + bytes = await downloadBytes({ key: chunk.s3Key }); + } catch (e: any) { + const status = e?.$metadata?.httpStatusCode; + if (status === 404) { + throw new KnownErrors.ItemNotFound(chunkId); + } + throw e; + } + const unzipped = new Uint8Array(await gunzip(bytes)); + + let parsed: any; + try { + parsed = JSON.parse(new TextDecoder().decode(unzipped)); + } catch (e) { + throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e }); + } + + if (typeof parsed !== "object" || parsed === null) { + throw new StackAssertionError("Decoded session recording chunk is not an object"); + } + if (parsed.session_id !== sessionRecordingId) { + throw new StackAssertionError("Decoded session recording chunk session_id mismatch", { + expected: sessionRecordingId, + actual: parsed.session_id, + }); + } + if (!Array.isArray(parsed.events)) { + throw new StackAssertionError("Decoded session recording chunk events is not an array"); + } + + return { + statusCode: 200, + bodyType: "json", + body: { + events: parsed.events, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx new file mode 100644 index 0000000000..81c45dc9cd --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx @@ -0,0 +1,125 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { Prisma } from "@/generated/prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + session_recording_id: yupString().defined(), + }).defined(), + query: yupObject({ + cursor: yupString().optional(), + limit: yupString().optional(), + }).optional(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + items: yupArray(yupObject({ + id: yupString().defined(), + batch_id: yupString().defined(), + tab_id: yupString().nullable().defined(), + event_count: yupNumber().defined(), + byte_length: yupNumber().defined(), + first_event_at_millis: yupNumber().defined(), + last_event_at_millis: yupNumber().defined(), + created_at_millis: yupNumber().defined(), + }).defined()).defined(), + pagination: yupObject({ + next_cursor: yupString().nullable().defined(), + }).defined(), + }).defined(), + }), + async handler({ auth, params, query }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + const sessionRecordingId = params.session_recording_id; + const exists = await prisma.sessionRecording.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionRecordingId } }, + select: { id: true }, + }); + if (!exists) { + throw new KnownErrors.ItemNotFound(sessionRecordingId); + } + + const rawLimit = query.limit ?? String(DEFAULT_LIMIT); + const parsedLimit = Number.parseInt(rawLimit, 10); + const limit = Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(parsedLimit) ? parsedLimit : DEFAULT_LIMIT)); + + const cursorId = query.cursor; + let cursorPivot: { firstEventAt: Date } | null = null; + if (cursorId) { + cursorPivot = await prisma.sessionRecordingChunk.findFirst({ + where: { + tenancyId: auth.tenancy.id, + sessionRecordingId, + id: cursorId, + }, + select: { firstEventAt: true }, + }); + if (!cursorPivot) { + throw new KnownErrors.ItemNotFound(cursorId); + } + } + + const cursorWhere: Prisma.SessionRecordingChunkWhereInput = cursorId && cursorPivot ? { + OR: [ + { firstEventAt: { gt: cursorPivot.firstEventAt } }, + { AND: [{ firstEventAt: { equals: cursorPivot.firstEventAt } }, { id: { gt: cursorId } }] }, + ], + } : {}; + + const chunks = await prisma.sessionRecordingChunk.findMany({ + where: { + tenancyId: auth.tenancy.id, + sessionRecordingId, + ...cursorWhere, + }, + orderBy: [{ firstEventAt: "asc" }, { id: "asc" }], + take: limit + 1, + select: { + id: true, + batchId: true, + tabId: true, + eventCount: true, + byteLength: true, + firstEventAt: true, + lastEventAt: true, + createdAt: true, + }, + }); + + const hasMore = chunks.length > limit; + const page = hasMore ? chunks.slice(0, limit) : chunks; + const nextCursor = hasMore ? page[page.length - 1]!.id : null; + + return { + statusCode: 200, + bodyType: "json", + body: { + items: page.map((c) => ({ + id: c.id, + batch_id: c.batchId, + tab_id: c.tabId, + event_count: c.eventCount, + byte_length: c.byteLength, + first_event_at_millis: c.firstEventAt.getTime(), + last_event_at_millis: c.lastEventAt.getTime(), + created_at_millis: c.createdAt.getTime(), + })), + pagination: { next_cursor: nextCursor }, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/route.tsx new file mode 100644 index 0000000000..55d41c696b --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-recordings/route.tsx @@ -0,0 +1,149 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { BooleanTrue, Prisma } from "@/generated/prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + query: yupObject({ + cursor: yupString().optional(), + limit: yupString().optional(), + }).optional(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + items: yupArray(yupObject({ + id: yupString().defined(), + project_user: yupObject({ + id: yupString().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + }).defined(), + started_at_millis: yupNumber().defined(), + last_event_at_millis: yupNumber().defined(), + chunk_count: yupNumber().defined(), + event_count: yupNumber().defined(), + }).defined()).defined(), + pagination: yupObject({ + next_cursor: yupString().nullable().defined(), + }).defined(), + }).defined(), + }), + async handler({ auth, query }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + const rawLimit = query.limit ?? String(DEFAULT_LIMIT); + const parsedLimit = Number.parseInt(rawLimit, 10); + const limit = Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(parsedLimit) ? parsedLimit : DEFAULT_LIMIT)); + + const cursorId = query.cursor; + let cursorPivot: { lastEventAt: Date } | null = null; + if (cursorId) { + cursorPivot = await prisma.sessionRecording.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, + select: { lastEventAt: true }, + }); + if (!cursorPivot) { + throw new KnownErrors.ItemNotFound(cursorId); + } + } + + const where: Prisma.SessionRecordingWhereInput = cursorId && cursorPivot ? { + OR: [ + { lastEventAt: { lt: cursorPivot.lastEventAt } }, + { AND: [{ lastEventAt: { equals: cursorPivot.lastEventAt } }, { id: { lt: cursorId } }] }, + ], + } : {}; + + const sessions = await prisma.sessionRecording.findMany({ + where: { tenancyId: auth.tenancy.id, ...where }, + orderBy: [{ lastEventAt: "desc" }, { id: "desc" }], + take: limit + 1, + select: { + id: true, + projectUserId: true, + startedAt: true, + lastEventAt: true, + }, + }); + + const hasMore = sessions.length > limit; + const page = hasMore ? sessions.slice(0, limit) : sessions; + const nextCursor = hasMore ? page[page.length - 1]!.id : null; + + const sessionIds = page.map(s => s.id); + const userIds = [...new Set(page.map(s => s.projectUserId))]; + + const [chunkAggs, users] = await Promise.all([ + sessionIds.length ? prisma.sessionRecordingChunk.groupBy({ + by: ["sessionRecordingId"], + where: { tenancyId: auth.tenancy.id, sessionRecordingId: { in: sessionIds } }, + _count: { _all: true }, + _sum: { eventCount: true }, + }) : Promise.resolve([] as Array<{ sessionRecordingId: string, _count: { _all: number }, _sum: { eventCount: number | null } }>), + userIds.length ? prisma.projectUser.findMany({ + where: { tenancyId: auth.tenancy.id, projectUserId: { in: userIds } }, + select: { + projectUserId: true, + displayName: true, + contactChannels: { + where: { type: "EMAIL", isPrimary: BooleanTrue.TRUE }, + select: { value: true }, + take: 1, + }, + }, + }) : Promise.resolve([] as Array<{ projectUserId: string, displayName: string | null, contactChannels: Array<{ value: string }> }>), + ]); + + const aggBySessionId = new Map(); + for (const a of chunkAggs) { + aggBySessionId.set(a.sessionRecordingId, { + chunkCount: a._count._all, + eventCount: a._sum.eventCount ?? 0, + }); + } + + const userById = new Map(); + for (const u of users) { + userById.set(u.projectUserId, { + displayName: u.displayName, + primaryEmail: u.contactChannels[0]?.value ?? null, + }); + } + + return { + statusCode: 200, + bodyType: "json", + body: { + items: page.map((s) => { + const user = userById.get(s.projectUserId); + const agg = aggBySessionId.get(s.id) ?? { chunkCount: 0, eventCount: 0 }; + return { + id: s.id, + project_user: { + id: s.projectUserId, + display_name: user?.displayName ?? null, + primary_email: user?.primaryEmail ?? null, + }, + started_at_millis: s.startedAt.getTime(), + last_event_at_millis: s.lastEventAt.getTime(), + chunk_count: agg.chunkCount, + event_count: agg.eventCount, + }; + }), + pagination: { next_cursor: nextCursor }, + }, + }; + }, +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts new file mode 100644 index 0000000000..2423c87d86 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts @@ -0,0 +1,510 @@ +import { randomUUID } from "node:crypto"; +import { it } from "../../../../helpers"; +import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; + +async function uploadBatch(options: { + sessionId: string, + batchId: string, + startedAtMs: number, + sentAtMs: number, + events: unknown[], + tabId?: string, +}) { + return await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: options.sessionId, + ...(options.tabId ? { tab_id: options.tabId } : {}), + batch_id: options.batchId, + started_at_ms: options.startedAtMs, + sent_at_ms: options.sentAtMs, + events: options.events, + }, + }); +} + +it("requires a user token", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + backendContext.set({ userAuth: null }); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("stores session recording batch metadata and dedupes by (session_id, batch_id)", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const sessionId = randomUUID(); + const batchId = randomUUID(); + + const first = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: sessionId, + tab_id: randomUUID(), + batch_id: batchId, + started_at_ms: 1_700_000_000_000, + sent_at_ms: 1_700_000_000_500, + events: [ + { timestamp: 1_700_000_000_100, type: 2 }, + { timestamp: 1_700_000_000_200, type: 3 }, + ], + }, + }); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + session_id: sessionId, + batch_id: batchId, + deduped: false, + }); + expect(typeof first.body?.s3_key).toBe("string"); + expect((first.body as any).s3_key).toContain(`/${sessionId}/${batchId}.json.gz`); + + const second = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: sessionId, + batch_id: batchId, + started_at_ms: 1_700_000_000_000, + sent_at_ms: 1_700_000_000_500, + events: [{ timestamp: 1_700_000_000_150, type: 2 }], + }, + }); + + expect(second.status).toBe(200); + expect(second.body).toMatchObject({ + session_id: sessionId, + batch_id: batchId, + deduped: true, + }); + expect((second.body as any).s3_key).toContain(`/${sessionId}/${batchId}.json.gz`); +}); + +it("rejects empty events", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("rejects too many events", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const tooManyEvents = Array.from({ length: 5001 }, (_, i) => ({ timestamp: 1_700_000_000_000 + i })); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: 1_700_000_000_000, + sent_at_ms: 1_700_000_000_100, + events: tooManyEvents, + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("rejects invalid session_id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: "not-a-uuid", + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("rejects invalid batch_id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: "not-a-uuid", + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("rejects invalid tab_id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + tab_id: "not-a-uuid", + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const sessionId = randomUUID(); + const batchId = randomUUID(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: sessionId, + batch_id: batchId, + started_at_ms: 1_700_000_000_000, + sent_at_ms: 1_700_000_000_500, + events: [{ type: 2 }, { type: 3, timestamp: undefined }], + }, + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + session_id: sessionId, + batch_id: batchId, + deduped: false, + }); +}); + +it("rejects non-integer started_at_ms", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: 123.4, + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); +}); + +it("rejects oversized payloads", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + // Backend limit is 2_000_000 bytes; a single large string is sufficient to exceed it. + const hugeString = "a".repeat(2_100_000); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + session_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now(), data: hugeString }], + }, + }); + + expect(res.status).toBe(413); +}); + +it("admin can list session recordings, list chunks, and fetch events", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const sessionId = randomUUID(); + const batchId = randomUUID(); + const events = [ + { type: 1, timestamp: 1_700_000_000_100, data: { a: 1 } }, + { type: 2, timestamp: 1_700_000_000_200, data: { b: 2 } }, + ]; + + const uploadRes = await uploadBatch({ + sessionId, + batchId, + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_500, + events, + }); + expect(uploadRes.status).toBe(200); + + const listRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + method: "GET", + accessType: "admin", + }); + expect(listRes.status).toBe(200); + expect(listRes.body?.items?.length).toBeGreaterThanOrEqual(1); + + const chunksRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${sessionId}/chunks`, { + method: "GET", + accessType: "admin", + }); + expect(chunksRes.status).toBe(200); + const chunkId = chunksRes.body?.items?.[0]?.id; + expect(typeof chunkId).toBe("string"); + if (typeof chunkId !== "string") { + throw new Error("Expected session recording chunks response to include an item id."); + } + + const eventsRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${sessionId}/chunks/${chunkId}/events`, { + method: "GET", + accessType: "admin", + }); + expect(eventsRes.status).toBe(200); + expect(eventsRes.body?.events?.length).toBe(events.length); +}); + +it("admin list session recordings paginates without skipping items", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const sessionA = randomUUID(); + const sessionB = randomUUID(); + + await uploadBatch({ + sessionId: sessionA, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_300, + events: [{ type: 1, timestamp: 1_700_000_000_100 }], + }); + await uploadBatch({ + sessionId: sessionB, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_400, + events: [{ type: 1, timestamp: 1_700_000_000_200 }], + }); + + const first = await niceBackendFetch("/api/v1/internal/session-recordings?limit=1", { + method: "GET", + accessType: "admin", + }); + expect(first.status).toBe(200); + expect(first.body?.items?.length).toBe(1); + const firstId = first.body?.items?.[0]?.id; + expect([sessionA, sessionB]).toContain(firstId); + + const nextCursor = first.body?.pagination?.next_cursor; + expect(typeof nextCursor).toBe("string"); + if (typeof nextCursor !== "string") { + throw new Error("Expected next_cursor to be a string."); + } + + const second = await niceBackendFetch(`/api/v1/internal/session-recordings?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { + method: "GET", + accessType: "admin", + }); + expect(second.status).toBe(200); + expect(second.body?.items?.length).toBe(1); + const secondId = second.body?.items?.[0]?.id; + expect([sessionA, sessionB]).toContain(secondId); + expect(secondId).not.toBe(firstId); +}); + +it("admin list session recordings rejects unknown cursor", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const cursor = randomUUID(); + const res = await niceBackendFetch(`/api/v1/internal/session-recordings?cursor=${encodeURIComponent(cursor)}`, { + method: "GET", + accessType: "admin", + }); + + expect(res.status).toBe(404); + expect(res.body?.code).toBe("ITEM_NOT_FOUND"); +}); + +it("admin list chunks paginates and rejects a cursor from another session", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const session1 = randomUUID(); + const session2 = randomUUID(); + + await uploadBatch({ + sessionId: session1, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_500, + events: [{ type: 1, timestamp: 1_700_000_000_010 }], + }); + await uploadBatch({ + sessionId: session1, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_600, + events: [{ type: 1, timestamp: 1_700_000_000_020 }], + }); + + await uploadBatch({ + sessionId: session2, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_700, + events: [{ type: 1, timestamp: 1_700_000_000_030 }], + }); + + const first = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?limit=1`, { + method: "GET", + accessType: "admin", + }); + expect(first.status).toBe(200); + expect(first.body?.items?.length).toBe(1); + + const nextCursor = first.body?.pagination?.next_cursor; + expect(typeof nextCursor).toBe("string"); + if (typeof nextCursor !== "string") { + throw new Error("Expected next_cursor to be a string."); + } + + const second = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { + method: "GET", + accessType: "admin", + }); + expect(second.status).toBe(200); + expect(second.body?.items?.length).toBe(1); + expect(second.body?.items?.[0]?.id).not.toBe(first.body?.items?.[0]?.id); + + // Cursor from another session should be rejected. + const otherChunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${session2}/chunks?limit=1`, { + method: "GET", + accessType: "admin", + }); + expect(otherChunks.status).toBe(200); + const otherCursor = otherChunks.body?.items?.[0]?.id; + expect(typeof otherCursor).toBe("string"); + if (typeof otherCursor !== "string") { + throw new Error("Expected otherCursor to be a string."); + } + + const bad = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?cursor=${encodeURIComponent(otherCursor)}`, { + method: "GET", + accessType: "admin", + }); + expect(bad.status).toBe(404); + expect(bad.body?.code).toBe("ITEM_NOT_FOUND"); +}); + +it("admin events endpoint does not allow fetching a chunk via the wrong session id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const session1 = randomUUID(); + const session2 = randomUUID(); + const batchId = randomUUID(); + + await uploadBatch({ + sessionId: session1, + batchId, + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_500, + events: [{ type: 1, timestamp: 1_700_000_000_010 }], + }); + await uploadBatch({ + sessionId: session2, + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_600, + events: [{ type: 1, timestamp: 1_700_000_000_020 }], + }); + + const chunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks`, { + method: "GET", + accessType: "admin", + }); + expect(chunks.status).toBe(200); + const chunkId = chunks.body?.items?.[0]?.id; + expect(typeof chunkId).toBe("string"); + if (typeof chunkId !== "string") { + throw new Error("Expected chunk id."); + } + + const wrong = await niceBackendFetch(`/api/v1/internal/session-recordings/${session2}/chunks/${chunkId}/events`, { + method: "GET", + accessType: "admin", + }); + expect(wrong.status).toBe(404); + expect(wrong.body?.code).toBe("ITEM_NOT_FOUND"); +}); + +it("non-admin access cannot call internal session recordings endpoints", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + const clientRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + method: "GET", + accessType: "client", + }); + expect(clientRes.status).toBeGreaterThanOrEqual(400); + expect(clientRes.status).toBeLessThan(500); + + const serverRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + method: "GET", + accessType: "server", + }); + expect(serverRes.status).toBeGreaterThanOrEqual(400); + expect(serverRes.status).toBeLessThan(500); +}); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 48de39d11b..82ad55cf6b 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -10,6 +10,13 @@ import { InternalEmailsCrud } from "./crud/emails"; import { InternalApiKeysCrud } from "./crud/internal-api-keys"; import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions"; import { ProjectsCrud } from "./crud/projects"; +import type { + AdminGetSessionRecordingChunkEventsResponse, + AdminListSessionRecordingChunksOptions, + AdminListSessionRecordingChunksResponse, + AdminListSessionRecordingsOptions, + AdminListSessionRecordingsResponse +} from "./crud/session-recordings"; import { SvixTokenCrud } from "./crud/svix-token"; import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions"; import type { Transaction, TransactionType } from "./crud/transactions"; @@ -707,6 +714,39 @@ export class StackAdminInterface extends StackServerInterface { return { transactions: json.transactions, nextCursor: json.next_cursor }; } + async listSessionRecordings(params?: AdminListSessionRecordingsOptions): Promise { + const qs = new URLSearchParams(); + if (params?.cursor) qs.set("cursor", params.cursor); + if (typeof params?.limit === "number") qs.set("limit", String(params.limit)); + const response = await this.sendAdminRequest( + `/internal/session-recordings${qs.size ? `?${qs.toString()}` : ""}`, + { method: "GET" }, + null, + ); + return await response.json(); + } + + async listSessionRecordingChunks(sessionRecordingId: string, params?: AdminListSessionRecordingChunksOptions): Promise { + const qs = new URLSearchParams(); + if (params?.cursor) qs.set("cursor", params.cursor); + if (typeof params?.limit === "number") qs.set("limit", String(params.limit)); + const response = await this.sendAdminRequest( + `/internal/session-recordings/${encodeURIComponent(sessionRecordingId)}/chunks${qs.size ? `?${qs.toString()}` : ""}`, + { method: "GET" }, + null, + ); + return await response.json(); + } + + async getSessionRecordingChunkEvents(sessionRecordingId: string, chunkId: string): Promise { + const response = await this.sendAdminRequest( + `/internal/session-recordings/${encodeURIComponent(sessionRecordingId)}/chunks/${encodeURIComponent(chunkId)}/events`, + { method: "GET" }, + null, + ); + return await response.json(); + } + async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, diff --git a/packages/stack-shared/src/interface/crud/session-recordings.ts b/packages/stack-shared/src/interface/crud/session-recordings.ts new file mode 100644 index 0000000000..98d831ee0e --- /dev/null +++ b/packages/stack-shared/src/interface/crud/session-recordings.ts @@ -0,0 +1,48 @@ +export type AdminListSessionRecordingsOptions = { + limit?: number, + cursor?: string, +}; + +export type AdminListSessionRecordingsResponse = { + items: Array<{ + id: string, + project_user: { + id: string, + display_name: string | null, + primary_email: string | null, + }, + started_at_millis: number, + last_event_at_millis: number, + chunk_count: number, + event_count: number, + }>, + pagination: { + next_cursor: string | null, + }, +}; + +export type AdminListSessionRecordingChunksOptions = { + limit?: number, + cursor?: string, +}; + +export type AdminListSessionRecordingChunksResponse = { + items: Array<{ + id: string, + batch_id: string, + tab_id: string | null, + event_count: number, + byte_length: number, + first_event_at_millis: number, + last_event_at_millis: number, + created_at_millis: number, + }>, + pagination: { + next_cursor: string | null, + }, +}; + +export type AdminGetSessionRecordingChunkEventsResponse = { + events: unknown[], +}; + diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e3b53c6a8e..144ee3a191 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -2,6 +2,7 @@ import { StackAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; +import type { AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -17,6 +18,7 @@ import { AdminEmailTemplate } from "../../email-templates"; import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; +import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult } from "../interfaces/admin-app"; import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; @@ -954,6 +956,58 @@ export class _StackAdminAppImplIncomplete { + const response = await this._interface.listSessionRecordings({ + cursor: options?.cursor, + limit: options?.limit, + }); + + const items: AdminSessionRecording[] = response.items.map((r) => ({ + id: r.id, + projectUser: { + id: r.project_user.id, + displayName: r.project_user.display_name, + primaryEmail: r.project_user.primary_email, + }, + startedAt: new Date(r.started_at_millis), + lastEventAt: new Date(r.last_event_at_millis), + chunkCount: r.chunk_count, + eventCount: r.event_count, + })); + + return { + items, + nextCursor: response.pagination.next_cursor, + }; + } + + async listSessionRecordingChunks(sessionRecordingId: string, options?: ListSessionRecordingChunksOptions): Promise { + const response = await this._interface.listSessionRecordingChunks(sessionRecordingId, { + cursor: options?.cursor, + limit: options?.limit, + }); + + const items: AdminSessionRecordingChunk[] = response.items.map((c) => ({ + id: c.id, + batchId: c.batch_id, + tabId: c.tab_id, + eventCount: c.event_count, + byteLength: c.byte_length, + firstEventAt: new Date(c.first_event_at_millis), + lastEventAt: new Date(c.last_event_at_millis), + createdAt: new Date(c.created_at_millis), + })); + + return { + items, + nextCursor: response.pagination.next_cursor, + }; + } + + async getSessionRecordingChunkEvents(sessionRecordingId: string, chunkId: string): Promise { + return await this._interface.getSessionRecordingChunkEvents(sessionRecordingId, chunkId); + } + async previewAffectedUsersByOnboardingChange( onboarding: { requireEmailVerification?: boolean }, limit?: number, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 9e14a084ce..c557cc3025 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,5 +1,6 @@ import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; +import type { AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; @@ -30,6 +31,50 @@ export type EmailOutboxUpdateOptions = { cancel?: boolean, }; +export type AdminSessionRecording = { + id: string, + projectUser: { + id: string, + displayName: string | null, + primaryEmail: string | null, + }, + startedAt: Date, + lastEventAt: Date, + chunkCount: number, + eventCount: number, +}; + +export type AdminSessionRecordingChunk = { + id: string, + batchId: string, + tabId: string | null, + eventCount: number, + byteLength: number, + firstEventAt: Date, + lastEventAt: Date, + createdAt: Date, +}; + +export type ListSessionRecordingsOptions = { + limit?: number, + cursor?: string, +}; + +export type ListSessionRecordingsResult = { + items: AdminSessionRecording[], + nextCursor: string | null, +}; + +export type ListSessionRecordingChunksOptions = { + limit?: number, + cursor?: string, +}; + +export type ListSessionRecordingChunksResult = { + items: AdminSessionRecordingChunk[], + nextCursor: string | null, +}; + export type StackAdminAppConstructorOptions = ( & StackServerAppConstructorOptions @@ -118,6 +163,10 @@ export type StackAdminApp, queryAnalytics(options: AnalyticsQueryOptions): Promise, + listSessionRecordings(options?: ListSessionRecordingsOptions): Promise, + listSessionRecordingChunks(sessionRecordingId: string, options?: ListSessionRecordingChunksOptions): Promise, + getSessionRecordingChunkEvents(sessionRecordingId: string, chunkId: string): Promise, + // Email Outbox methods listOutboxEmails(options?: EmailOutboxListOptions): Promise, getOutboxEmail(id: string): Promise, From f991184035dba23a870c6b583b28e50c865eaf92 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 13:52:30 -0800 Subject: [PATCH 04/21] feat: dashboard replay viewer for multi-tab session recordings - Add server component wrapper and main replay viewer (page + page-client) - Add playback timing/state logic (session-replay-playback) - Add multi-tab stream grouping utilities (session-replay-streams) - Add unit tests for stream grouping - Register "Replays" nav item in apps-frontend - Disable session recording on dashboard itself - Add rrweb dependency to dashboard package --- apps/dashboard/package.json | 1 + .../analytics/replays/page-client.tsx | 1814 +++++++++++++++++ .../[projectId]/analytics/replays/page.tsx | 6 + apps/dashboard/src/app/layout.tsx | 2 +- apps/dashboard/src/lib/apps-frontend.tsx | 1 + .../src/lib/session-replay-playback.ts | 73 + .../src/lib/session-replay-streams.test.ts | 66 + .../src/lib/session-replay-streams.ts | 129 ++ 8 files changed, 2091 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page.tsx create mode 100644 apps/dashboard/src/lib/session-replay-playback.ts create mode 100644 apps/dashboard/src/lib/session-replay-streams.test.ts create mode 100644 apps/dashboard/src/lib/session-replay-streams.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index df5d4145f9..e2fc7c64ad 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -95,6 +95,7 @@ "react-syntax-highlighter": "^15.6.1", "recharts": "^2.14.1", "remark-gfm": "^4.0.1", + "rrweb": "^1.1.3", "svix": "^1.32.0", "svix-react": "^1.13.0", "tailwind-merge": "^2.3.0", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx new file mode 100644 index 0000000000..f263c692fa --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -0,0 +1,1814 @@ +"use client"; + +import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton, Switch, Typography } from "@/components/ui"; +import { useFromNow } from "@/hooks/use-from-now"; +import { + getDesiredGlobalOffsetFromPlaybackState, + getReplayFinishAction, + INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, + applySeekState, +} from "@/lib/session-replay-playback"; +import type { TabKey, TabStream } from "@/lib/session-replay-streams"; +import { + computeGlobalTimeline, + globalOffsetToLocalOffset, + groupChunksIntoTabStreams, + localOffsetToGlobalOffset, + NULL_TAB_KEY, +} from "@/lib/session-replay-streams"; +import { cn } from "@/lib/utils"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; + +const PAGE_SIZE = 50; +const CHUNK_PAGE_SIZE = 250; +const CHUNK_EVENTS_CONCURRENCY = 8; +const EXTRA_TABS_TO_SHOW = 2; +const REPLAY_SETTINGS_STORAGE_KEY = "stack.session-replay.settings"; +const LEGACY_PLAYER_SPEED_STORAGE_KEY = "stack.session-replay.speed"; +const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); +const DEFAULT_REPLAY_SETTINGS = { + playerSpeed: 1, + skipInactivity: true, + followActiveTab: false, +} as const; + +type ReplaySettings = { + playerSpeed: number, + skipInactivity: boolean, + followActiveTab: boolean, +}; + +type RrwebEventWithTime = import("rrweb/typings/types").eventWithTime; +type RrwebReplayer = InstanceType; + +type RecordingRow = { + id: string, + projectUser: { + id: string, + displayName: string | null, + primaryEmail: string | null, + }, + startedAt: Date, + lastEventAt: Date, + chunkCount: number, + eventCount: number, +}; + +type ChunkRow = { + id: string, + batchId: string, + tabId: string | null, + eventCount: number, + byteLength: number, + firstEventAt: Date, + lastEventAt: Date, + createdAt: Date, +}; + +type AdminAppWithSessionRecordings = ReturnType & { + listSessionRecordings: (options?: { limit?: number, cursor?: string }) => Promise<{ + items: RecordingRow[], + nextCursor: string | null, + }>, + listSessionRecordingChunks: (sessionRecordingId: string, options?: { limit?: number, cursor?: string }) => Promise<{ + items: ChunkRow[], + nextCursor: string | null, + }>, + getSessionRecordingChunkEvents: (sessionRecordingId: string, chunkId: string) => Promise<{ events: unknown[] }>, +}; + +function coerceRrwebEvents(raw: unknown[]): RrwebEventWithTime[] { + const filtered: Array<{ timestamp: number }> = []; + for (const e of raw) { + if (typeof e !== "object" || e === null) continue; + if (!("timestamp" in e)) continue; + const ts = (e as { timestamp?: unknown }).timestamp; + if (typeof ts !== "number" || !Number.isFinite(ts)) continue; + filtered.push(e as { timestamp: number }); + } + return filtered as unknown as RrwebEventWithTime[]; +} + +function formatDurationMs(ms: number) { + if (!Number.isFinite(ms) || ms < 0) return "—"; + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (h > 0) return `${h}h ${m % 60}m`; + if (m > 0) return `${m}m ${s % 60}s`; + return `${s}s`; +} + +function formatTimelineMs(ms: number) { + if (!Number.isFinite(ms) || ms < 0) return "0:00"; + const totalSeconds = Math.floor(ms / 1000); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function DisplayDate({ date }: { date: Date }) { + const fromNow = useFromNow(date); + return {fromNow}; +} + +function getRecordingTitle(r: RecordingRow) { + return r.projectUser.displayName ?? r.projectUser.primaryEmail ?? r.projectUser.id; +} + +function parseReplaySettings(raw: string): ReplaySettings | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const value = parsed as Record; + + const playerSpeedRaw = value.playerSpeed; + const playerSpeed = typeof playerSpeedRaw === "number" && ALLOWED_PLAYER_SPEEDS.has(playerSpeedRaw) + ? playerSpeedRaw + : DEFAULT_REPLAY_SETTINGS.playerSpeed; + const skipInactivity = typeof value.skipInactivity === "boolean" + ? value.skipInactivity + : DEFAULT_REPLAY_SETTINGS.skipInactivity; + const followActiveTab = typeof value.followActiveTab === "boolean" + ? value.followActiveTab + : DEFAULT_REPLAY_SETTINGS.followActiveTab; + + return { playerSpeed, skipInactivity, followActiveTab }; +} + +function getInitialReplaySettings(): ReplaySettings { + if (typeof window === 'undefined') return DEFAULT_REPLAY_SETTINGS; + try { + const rawSettings = localStorage.getItem(REPLAY_SETTINGS_STORAGE_KEY); + if (rawSettings) { + const parsed = parseReplaySettings(rawSettings); + if (parsed) return parsed; + } + const rawLegacySpeed = localStorage.getItem(LEGACY_PLAYER_SPEED_STORAGE_KEY); + if (rawLegacySpeed) { + const legacySpeed = Number(rawLegacySpeed); + if (Number.isFinite(legacySpeed) && ALLOWED_PLAYER_SPEEDS.has(legacySpeed)) { + return { ...DEFAULT_REPLAY_SETTINGS, playerSpeed: legacySpeed }; + } + } + } catch { + // ignore + } + return DEFAULT_REPLAY_SETTINGS; +} + +async function fetchChunkEventsForStreamsParallel( + adminApp: AdminAppWithSessionRecordings, + recordingId: string, + streams: Array<{ tabKey: TabKey, chunks: ChunkRow[] }>, + gen: number, + genRef: React.MutableRefObject, + onChunkLoaded: (tabKey: TabKey, chunkIndex: number, events: RrwebEventWithTime[]) => void, +) { + // Important: prioritize each stream's earliest chunks so every tab can + // initialize quickly (FullSnapshot is typically in chunk 0). + const tasks: Array<{ tabKey: TabKey, chunkIndex: number, chunkId: string }> = []; + const resultsByTab = new Map>(); + const reportedIndexByTab = new Map(); + + let maxChunks = 0; + for (const s of streams) { + resultsByTab.set(s.tabKey, new Array(s.chunks.length).fill(null)); + reportedIndexByTab.set(s.tabKey, 0); + maxChunks = Math.max(maxChunks, s.chunks.length); + } + + for (let chunkIndex = 0; chunkIndex < maxChunks; chunkIndex++) { + for (const s of streams) { + const c = s.chunks[chunkIndex] as ChunkRow | undefined; + if (!c) continue; + tasks.push({ tabKey: s.tabKey, chunkIndex, chunkId: c.id }); + } + } + + let nextTaskIndex = 0; + + async function worker() { + while (nextTaskIndex < tasks.length) { + if (genRef.current !== gen) return; + const task = tasks[nextTaskIndex++]; + const ev = await adminApp.getSessionRecordingChunkEvents(recordingId, task.chunkId); + if (genRef.current !== gen) return; + + const results = resultsByTab.get(task.tabKey) ?? []; + results[task.chunkIndex] = coerceRrwebEvents(ev.events); + + let reported = reportedIndexByTab.get(task.tabKey) ?? 0; + while (reported < results.length && results[reported] !== null) { + onChunkLoaded(task.tabKey, reported, results[reported]!); + reported++; + } + reportedIndexByTab.set(task.tabKey, reported); + } + } + + const workers = Array.from({ length: Math.min(CHUNK_EVENTS_CONCURRENCY, tasks.length) }, () => worker()); + await Promise.all(workers); +} + +function Timeline({ + getCurrentTimeMs, + playerIsPlaying, + totalTimeMs, + onTogglePlayPause, + onSeek, + playerSpeed, + onSpeedChange, +}: { + getCurrentTimeMs: () => number, + playerIsPlaying: boolean, + totalTimeMs: number, + onTogglePlayPause: () => void, + onSeek: (timeOffset: number) => void, + playerSpeed: number, + onSpeedChange: (speed: number) => void, +}) { + const [currentTime, setCurrentTime] = useState(0); + const trackRef = useRef(null); + const rafRef = useRef(0); + + useEffect(() => { + function tick() { + setCurrentTime(getCurrentTimeMs()); + rafRef.current = requestAnimationFrame(tick); + } + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, [getCurrentTimeMs]); + + const progress = totalTimeMs > 0 ? Math.min(currentTime / totalTimeMs, 1) : 0; + + const handleTrackClick = useCallback((e: React.MouseEvent) => { + const el = trackRef.current; + if (!el || totalTimeMs <= 0) return; + const rect = el.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const timeOffset = fraction * totalTimeMs; + onSeek(timeOffset); + }, [totalTimeMs, onSeek]); + + return ( +
+ + + + {formatTimelineMs(currentTime)} + + +
+
+
+
+
+ + + {formatTimelineMs(totalTimeMs)} + + + +
+ ); +} + +function ReplaySettingsButton({ + settings, + onSettingsChange, +}: { + settings: ReplaySettings, + onSettingsChange: (updates: Partial) => void, +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + Replay settings + + +
+
+
+ Skip inactivity + + Fast-forward through idle periods during playback. + +
+ onSettingsChange({ skipInactivity: checked })} + /> +
+ +
+
+ Follow active tab + + Auto-switch to the tab that has activity at the current time. + +
+ onSettingsChange({ followActiveTab: checked })} + /> +
+
+
+
+ + ); +} + +export default function PageClient() { + // @stackframe/stack's public `StackAdminApp` type is missing the session replay + // methods in `packages/stack/dist/index.d.mts`, but the runtime app object has them. + const adminApp = useAdminApp() as AdminAppWithSessionRecordings; + + const [recordings, setRecordings] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loadingInitial, setLoadingInitial] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [listError, setListError] = useState(null); + + const listBoxRef = useRef(null); + + const [selectedRecordingId, setSelectedRecordingId] = useState(null); + const selectedRecording = useMemo( + () => recordings.find(r => r.id === selectedRecordingId) ?? null, + [recordings, selectedRecordingId], + ); + + const selectionGenRef = useRef(0); + const hasAutoSelectedRef = useRef(false); + const hasFetchedInitialRef = useRef(false); + + const loadPage = useCallback(async (cursor: string | null) => { + if (cursor === null && hasFetchedInitialRef.current) return; + if (cursor === null) hasFetchedInitialRef.current = true; + if (cursor !== null && loadingMore) return; + + if (cursor === null) { + setLoadingInitial(true); + } else { + setLoadingMore(true); + } + setListError(null); + + try { + const res = await adminApp.listSessionRecordings({ limit: PAGE_SIZE, cursor: cursor ?? undefined }); + const items = cursor ? [...recordings, ...res.items] : res.items; + setRecordings(items); + setNextCursor(res.nextCursor); + + if (!cursor && !hasAutoSelectedRef.current && items.length > 0) { + hasAutoSelectedRef.current = true; + setSelectedRecordingId(items[0].id); + } + } catch (e: any) { + setListError(e?.message ?? "Failed to load session recordings."); + } finally { + setLoadingInitial(false); + setLoadingMore(false); + } + }, [adminApp, loadingMore, recordings]); + + useEffect(() => { + runAsynchronously(() => loadPage(null), { noErrorLogging: true }); + }, [loadPage]); + + const onListScroll = useCallback(() => { + const el = listBoxRef.current; + if (!el) return; + if (!nextCursor) return; + if (loadingMore || loadingInitial) return; + const remaining = el.scrollHeight - el.scrollTop - el.clientHeight; + if (remaining < 200) { + runAsynchronously(() => loadPage(nextCursor), { noErrorLogging: true }); + } + }, [loadingInitial, loadingMore, loadPage, nextCursor]); + + // Player + download state + const [downloadError, setDownloadError] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const isDownloadingRef = useRef(false); + isDownloadingRef.current = isDownloading; + + const [streams, setStreams] = useState[]>([]); + const streamsByKeyRef = useRef>>(new Map()); + const streamsRef = useRef[]>([]); + const [activeTabKey, setActiveTabKey] = useState(null); + const activeTabKeyRef = useRef(null); + const setActiveTab = useCallback((key: TabKey | null) => { + activeTabKeyRef.current = key; + setActiveTabKey(key); + }, []); + + const globalStartTsRef = useRef(0); + const [globalTotalTimeMs, setGlobalTotalTimeMs] = useState(0); + const globalTotalTimeMsRef = useRef(0); + // Inter-tab gap transition: animate timeline forward faster than realtime + // until the next tab starts. + const gapFastForwardRef = useRef<{ + fromGlobalMs: number, + toGlobalMs: number, + wallMs: number, + nextTabKey: TabKey, + gen: number, + } | null>(null); + const eventsByTabRef = useRef>(new Map()); + const loadedDurationByTabMsRef = useRef>(new Map()); + const chunkRangesByTabRef = useRef>>(new Map()); + const containerByTabRef = useRef>(new Map()); + const replayerByTabRef = useRef>(new Map()); + const replayerRootByTabRef = useRef>(new Map()); + const resizeObserverByTabRef = useRef>(new Map()); + const pendingInitByTabRef = useRef>(new Set()); + + const tabLabelIndexByKeyRef = useRef>(new Map()); + // Tracks which tabs have a FullSnapshot event (rrweb type 2). + // Tabs without one render as blank white screens. + const hasFullSnapshotByTabRef = useRef>(new Set()); + + const [uiVersion, setUiVersion] = useState(0); + + const [playerError, setPlayerError] = useState(null); + const [replaySettings, setReplaySettings] = useState(getInitialReplaySettings); + const replaySettingsRef = useRef(replaySettings); + useEffect(() => { + replaySettingsRef.current = replaySettings; + }, [replaySettings]); + + const playerSpeed = replaySettings.playerSpeed; + const playerSpeedRef = useRef(playerSpeed); + useEffect(() => { + playerSpeedRef.current = playerSpeed; + }, [playerSpeed]); + + useEffect(() => { + localStorage.setItem(REPLAY_SETTINGS_STORAGE_KEY, JSON.stringify(replaySettings)); + }, [replaySettings]); + + const [playerIsPlaying, setPlayerIsPlaying] = useState(false); + const playerIsPlayingRef = useRef(false); + useEffect(() => { + playerIsPlayingRef.current = playerIsPlaying; + }, [playerIsPlaying]); + + const [isSkipping, setIsSkipping] = useState(false); + const speedSubRef = useRef<{ unsubscribe: () => void } | null>(null); + + const pausedAtGlobalRef = useRef(0); + const [currentGlobalTimeMsForUi, setCurrentGlobalTimeMsForUi] = useState(0); + const currentGlobalTimeMsForUiRef = useRef(0); + useEffect(() => { + currentGlobalTimeMsForUiRef.current = currentGlobalTimeMsForUi; + }, [currentGlobalTimeMsForUi]); + const [isBuffering, setIsBuffering] = useState(false); + const isBufferingRef = useRef(false); + useEffect(() => { + isBufferingRef.current = isBuffering; + }, [isBuffering]); + const bufferingAtGlobalRef = useRef(null); + const autoResumeAfterBufferingRef = useRef(false); + const autoPlayTriggeredRef = useRef(false); + const suppressAutoFollowUntilRef = useRef(0); + const [replayFinished, setReplayFinished] = useState(false); + + const destroyReplayers = useCallback(() => { + for (const obs of resizeObserverByTabRef.current.values()) { + obs.disconnect(); + } + resizeObserverByTabRef.current.clear(); + + speedSubRef.current?.unsubscribe(); + speedSubRef.current = null; + + for (const r of replayerByTabRef.current.values()) { + try { + r.pause(); + } catch { + // ignore + } + } + replayerByTabRef.current.clear(); + replayerRootByTabRef.current.clear(); + + for (const root of containerByTabRef.current.values()) { + if (root) root.innerHTML = ""; + } + }, []); + + useEffect(() => { + streamsRef.current = streams; + }, [streams]); + + const resetReplayState = useCallback(() => { + setDownloadError(null); + setIsDownloading(false); + setStreams([]); + streamsByKeyRef.current = new Map(); + setActiveTab(null); + globalStartTsRef.current = 0; + setGlobalTotalTimeMs(0); + globalTotalTimeMsRef.current = 0; + gapFastForwardRef.current = null; + eventsByTabRef.current = new Map(); + loadedDurationByTabMsRef.current = new Map(); + chunkRangesByTabRef.current = new Map(); + pendingInitByTabRef.current = new Set(); + setPlayerError(null); + setPlayerIsPlaying(false); + setIsSkipping(false); + setIsBuffering(false); + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + pausedAtGlobalRef.current = 0; + autoPlayTriggeredRef.current = false; + tabLabelIndexByKeyRef.current = new Map(); + hasFullSnapshotByTabRef.current = new Set(); + setReplayFinished(false); + setCurrentGlobalTimeMsForUi(0); + setUiVersion(v => v + 1); + destroyReplayers(); + }, [destroyReplayers, setActiveTab]); + + const findBestTabAtGlobalOffset = useCallback((globalOffsetMs: number, excludeTabKey?: TabKey) => { + const ts = globalStartTsRef.current + globalOffsetMs; + const candidates = streamsRef.current.filter((s) => { + if (excludeTabKey && s.tabKey === excludeTabKey) return false; + // Skip tabs without a FullSnapshot — they render as blank. + if (!hasFullSnapshotByTabRef.current.has(s.tabKey)) return false; + const ranges = chunkRangesByTabRef.current.get(s.tabKey) ?? []; + // Ranges are sorted by startTs. + let lo = 0; + let hi = ranges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const r = ranges[mid]!; + if (ts < r.startTs) { + hi = mid - 1; + } else if (ts > r.endTs) { + lo = mid + 1; + } else { + return true; + } + } + return false; + }); + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + const aLabel = tabLabelIndexByKeyRef.current.get(a.tabKey) ?? Number.POSITIVE_INFINITY; + const bLabel = tabLabelIndexByKeyRef.current.get(b.tabKey) ?? Number.POSITIVE_INFINITY; + if (aLabel !== bLabel) return aLabel - bLabel; + return stringCompare(a.tabKey, b.tabKey); + }); + + return candidates[0]!.tabKey; + }, []); + + const isTabInRangeAtGlobalOffset = useCallback((tabKey: TabKey, globalOffsetMs: number): boolean => { + if (!hasFullSnapshotByTabRef.current.has(tabKey)) return false; + const ts = globalStartTsRef.current + globalOffsetMs; + const ranges = chunkRangesByTabRef.current.get(tabKey) ?? []; + let lo = 0; + let hi = ranges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const r = ranges[mid]!; + if (ts < r.startTs) { + hi = mid - 1; + } else if (ts > r.endTs) { + lo = mid + 1; + } else { + return true; + } + } + return false; + }, []); + + const findNextTabStartAfterGlobalOffset = useCallback((globalOffsetMs: number) => { + const ts = globalStartTsRef.current + globalOffsetMs; + let bestStartTs = Infinity; + let bestKey: TabKey | null = null; + + for (const s of streamsRef.current) { + if (!hasFullSnapshotByTabRef.current.has(s.tabKey)) continue; + const ranges = chunkRangesByTabRef.current.get(s.tabKey) ?? []; + for (const r of ranges) { + if (r.startTs <= ts) continue; + if (r.startTs < bestStartTs) { + bestStartTs = r.startTs; + bestKey = s.tabKey; + } + break; // ranges sorted by start + } + } + + if (!bestKey || !Number.isFinite(bestStartTs)) return null; + return { + tabKey: bestKey, + globalOffsetMs: bestStartTs - globalStartTsRef.current, + }; + }, []); + + const getDesiredGlobalOffsetMs = useCallback(() => { + const key = activeTabKeyRef.current; + const r = key ? (replayerByTabRef.current.get(key) ?? null) : null; + const s = key ? (streamsByKeyRef.current.get(key) ?? null) : null; + let activeLocalOffsetMs: number | null = null; + let activeStreamStartTs: number | null = null; + if (r && s) { + activeStreamStartTs = s.firstEventAt.getTime(); + try { + activeLocalOffsetMs = r.getCurrentTime(); + } catch { + activeLocalOffsetMs = null; + } + } + + return getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: gapFastForwardRef.current, + playerIsPlaying: playerIsPlayingRef.current, + nowMs: performance.now(), + playerSpeed: playerSpeedRef.current, + pausedAtGlobalMs: pausedAtGlobalRef.current, + activeLocalOffsetMs, + activeStreamStartTs, + globalStartTs: globalStartTsRef.current, + gapFastForwardMultiplier: INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, + }); + }, []); + + const pauseAll = useCallback(() => { + for (const r of replayerByTabRef.current.values()) { + try { + r.pause(); + } catch { + // ignore + } + } + }, []); + + const playActiveAtGlobalOffset = useCallback((globalOffsetMs: number) => { + gapFastForwardRef.current = null; + const activeKey = activeTabKeyRef.current; + for (const [tabKey, r] of replayerByTabRef.current.entries()) { + const stream = streamsByKeyRef.current.get(tabKey); + const streamStartTs = stream?.firstEventAt.getTime() ?? globalStartTsRef.current; + const localOffset = globalOffsetToLocalOffset(globalStartTsRef.current, streamStartTs, globalOffsetMs); + try { + if (tabKey === activeKey) { + r.play(localOffset); + } else { + r.pause(localOffset); + } + } catch { + // ignore + } + } + }, []); + + const ensureReplayerForTab = useCallback(async (tabKey: TabKey, gen: number) => { + if (selectionGenRef.current !== gen) return; + if (replayerByTabRef.current.has(tabKey)) return; + + const rootMaybe = containerByTabRef.current.get(tabKey) ?? null; + if (!rootMaybe) { + pendingInitByTabRef.current.add(tabKey); + return; + } + const rootEl = rootMaybe; + + const eventsSnapshot = eventsByTabRef.current.get(tabKey)?.slice() ?? []; + if (eventsSnapshot.length === 0) { + pendingInitByTabRef.current.add(tabKey); + return; + } + + // Don't create a replayer for tabs without a FullSnapshot — they render blank. + if (!hasFullSnapshotByTabRef.current.has(tabKey)) return; + + try { + const { Replayer } = await import("rrweb"); + if (selectionGenRef.current !== gen) return; + + const eventsSnapshot2 = eventsByTabRef.current.get(tabKey)?.slice() ?? []; + if (eventsSnapshot2.length === 0) return; + + const replayer = new Replayer(eventsSnapshot2, { + root: rootEl, + speed: playerSpeedRef.current, + skipInactive: replaySettingsRef.current.skipInactivity, + }); + + rootEl.style.position = "relative"; + rootEl.style.width = "100%"; + rootEl.style.height = "100%"; + rootEl.style.overflow = "hidden"; + + replayer.wrapper.style.margin = "0"; + replayer.wrapper.style.position = "absolute"; + replayer.wrapper.style.transformOrigin = "top left"; + + replayer.iframe.style.border = "0"; + + // Mouse cursor styling (ensures it renders above the iframe consistently). + const mouseEl = replayer.wrapper.querySelector(".replayer-mouse") as HTMLElement | null; + if (mouseEl) { + mouseEl.style.position = "absolute"; + mouseEl.style.width = "14px"; + mouseEl.style.height = "14px"; + mouseEl.style.borderRadius = "9999px"; + mouseEl.style.background = "rgba(255, 255, 255, 0.9)"; + mouseEl.style.border = "2px solid rgba(0, 0, 0, 0.55)"; + mouseEl.style.boxShadow = "0 2px 10px rgba(0,0,0,0.25)"; + mouseEl.style.transform = "translate(-50%, -50%)"; + mouseEl.style.pointerEvents = "none"; + mouseEl.style.zIndex = "2"; + } + + const mouseTailEl = replayer.wrapper.querySelector(".replayer-mouse-tail") as HTMLCanvasElement | null; + if (mouseTailEl) { + mouseTailEl.style.position = "absolute"; + mouseTailEl.style.inset = "0"; + mouseTailEl.style.pointerEvents = "none"; + mouseTailEl.style.zIndex = "1"; + } + + function updateScale() { + const cw = rootEl.clientWidth; + const ch = rootEl.clientHeight; + const replayW = replayer.wrapper.offsetWidth; + const replayH = replayer.wrapper.offsetHeight; + if (replayW <= 0 || replayH <= 0 || cw <= 0 || ch <= 0) return; + const isActive = activeTabKeyRef.current === tabKey; + // Active tab: fit entire replay centered. Mini tabs: fill width, align top (overflow clipped). + const scale = isActive ? Math.min(cw / replayW, ch / replayH) : (cw / replayW); + const scaledW = replayW * scale; + const scaledH = replayH * scale; + replayer.wrapper.style.left = isActive ? `${(cw - scaledW) / 2}px` : "0px"; + replayer.wrapper.style.top = isActive ? `${(ch - scaledH) / 2}px` : "0px"; + replayer.wrapper.style.transform = `scale(${scale})`; + } + + updateScale(); + let scaleRaf = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(scaleRaf); + scaleRaf = requestAnimationFrame(updateScale); + }); + observer.observe(rootEl); + observer.observe(replayer.wrapper); + resizeObserverByTabRef.current.set(tabKey, observer); + + replayerRootByTabRef.current.set(tabKey, rootEl); + pendingInitByTabRef.current.delete(tabKey); + + const isActiveTab = activeTabKeyRef.current === tabKey; + const shouldAutoPlay = !autoPlayTriggeredRef.current && isActiveTab; + if (shouldAutoPlay) { + autoPlayTriggeredRef.current = true; + } + + // Seek this stream to the current global time and follow play/pause state. + // Only the active tab should be playing; others get seeked but paused. + // NOTE: The replayer is NOT yet registered in replayerByTabRef so that + // getDesiredGlobalOffsetMs() falls back to pausedAtGlobalRef (which holds + // the correct authoritative time) instead of reading getCurrentTime()=0 + // from the brand-new replayer. + const stream = streamsByKeyRef.current.get(tabKey) ?? null; + const streamStartTs = stream?.firstEventAt.getTime() ?? globalStartTsRef.current; + const desiredGlobal = getDesiredGlobalOffsetMs(); + const desiredLocal = globalOffsetToLocalOffset(globalStartTsRef.current, streamStartTs, desiredGlobal); + const shouldPlay = isActiveTab && (shouldAutoPlay || (playerIsPlayingRef.current && !isBufferingRef.current)); + try { + if (shouldPlay) { + replayer.play(desiredLocal); + } else { + replayer.pause(desiredLocal); + } + } catch { + // ignore + } + + // Register the replayer AFTER seeking so it doesn't pollute time readings. + replayerByTabRef.current.set(tabKey, replayer); + + if (shouldAutoPlay && !isBufferingRef.current) { + playerIsPlayingRef.current = true; + setPlayerIsPlaying(true); + } + + // Detect when playback reaches the end of loaded events (active stream only). + try { + replayer.on("finish", () => { + if (selectionGenRef.current !== gen) return; + if (activeTabKeyRef.current !== tabKey) return; + + let localTime = 0; + try { + localTime = replayer.getCurrentTime(); + } catch { + // ignore + } + + // Guard against premature finish: rrweb fires "finish" when its + // internal timer exhausts events present at construction time. + // Events added later via addEvent() extend the array but the + // timer may already have stopped. Restart if more data exists. + const loadedDurationMs = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; + if (loadedDurationMs > localTime + 100) { + try { + replayer.play(localTime); + } catch { + // ignore + } + return; + } + + const stream2 = streamsByKeyRef.current.get(tabKey) ?? null; + const streamStartTs2 = stream2?.firstEventAt.getTime() ?? globalStartTsRef.current; + let globalOffset = localOffsetToGlobalOffset(globalStartTsRef.current, streamStartTs2, localTime); + + // Find the best OTHER tab at this offset (exclude the exhausted tab). + let bestKey = findBestTabAtGlobalOffset(globalOffset, tabKey); + + // rrweb's finish callback can report a stale time from an earlier frame. + // If it is meaningfully behind the authoritative timeline, retry with + // the authoritative time. + if (!bestKey && globalOffset + 500 < currentGlobalTimeMsForUiRef.current) { + globalOffset = currentGlobalTimeMsForUiRef.current; + bestKey = findBestTabAtGlobalOffset(globalOffset, tabKey); + } + + // Another tab has events at this time — switch to it. + if (bestKey) { + const switchToKey = bestKey; + setActiveTab(switchToKey); + pausedAtGlobalRef.current = globalOffset; + setIsBuffering(false); + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + runAsynchronously(() => ensureReplayerForTab(switchToKey, gen), { noErrorLogging: true }); + playActiveAtGlobalOffset(globalOffset); + setPlayerIsPlaying(true); + suppressAutoFollowUntilRef.current = performance.now() + 400; + return; + } + + // No alternative tab — check for gap, buffer, or true finish. + const nextStart = findNextTabStartAfterGlobalOffset(globalOffset); + const finishAction = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: isDownloadingRef.current, + nextStartGlobalOffsetMs: nextStart?.globalOffsetMs ?? null, + currentGlobalOffsetMs: Math.max(globalOffset, currentGlobalTimeMsForUiRef.current), + }); + if (finishAction.type === "gap_fast_forward" && nextStart) { + gapFastForwardRef.current = { + fromGlobalMs: globalOffset, + toGlobalMs: finishAction.toGlobalMs, + wallMs: performance.now(), + nextTabKey: nextStart.tabKey, + gen, + }; + pausedAtGlobalRef.current = globalOffset; + return; + } + if (finishAction.type === "buffer_at_current") { + pausedAtGlobalRef.current = globalOffset; + bufferingAtGlobalRef.current = globalOffset; + autoResumeAfterBufferingRef.current = true; + setIsBuffering(true); + setPlayerIsPlaying(false); + return; + } + + // End of recording — stop at the very end. + pausedAtGlobalRef.current = globalTotalTimeMsRef.current; + setCurrentGlobalTimeMsForUi(globalTotalTimeMsRef.current); + pauseAll(); + setIsBuffering(false); + setPlayerIsPlaying(false); + setReplayFinished(true); + }); + } catch { + // ignore + } + + setUiVersion(v => v + 1); + } catch (e: any) { + setPlayerError(e?.message ?? "Failed to initialize rrweb player."); + } + }, [findBestTabAtGlobalOffset, findNextTabStartAfterGlobalOffset, getDesiredGlobalOffsetMs, pauseAll, playActiveAtGlobalOffset, setActiveTab]); + + const setContainerRefForTab = useCallback((tabKey: TabKey, el: HTMLDivElement | null) => { + containerByTabRef.current.set(tabKey, el); + + if (!el) return; + + // If a replayer already exists but was created with a different DOM node + // (e.g. the tab unmounted and remounted), destroy the stale replayer so a + // fresh one is created with the new element. + const existingRoot = replayerRootByTabRef.current.get(tabKey); + if (existingRoot && existingRoot !== el) { + const r = replayerByTabRef.current.get(tabKey); + if (r) { + try { + r.pause(); + } catch { + // ignore + } + replayerByTabRef.current.delete(tabKey); + replayerRootByTabRef.current.delete(tabKey); + } + const obs = resizeObserverByTabRef.current.get(tabKey); + if (obs) { + obs.disconnect(); + resizeObserverByTabRef.current.delete(tabKey); + } + pendingInitByTabRef.current.add(tabKey); + } + + if (!pendingInitByTabRef.current.has(tabKey)) return; + if ((eventsByTabRef.current.get(tabKey)?.length ?? 0) === 0) return; + runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); + }, [ensureReplayerForTab]); + + const loadChunksAndDownload = useCallback(async (recordingId: string) => { + const gen = ++selectionGenRef.current; + resetReplayState(); + setIsDownloading(true); + + try { + const allChunkRows: ChunkRow[] = []; + let cursor: string | null = null; + while (true) { + const res: { items: ChunkRow[], nextCursor: string | null } = await adminApp.listSessionRecordingChunks( + recordingId, + { limit: CHUNK_PAGE_SIZE, cursor: cursor ?? undefined }, + ); + if (selectionGenRef.current !== gen) return; + allChunkRows.push(...res.items); + if (!res.nextCursor) break; + cursor = res.nextCursor; + } + + const allStreams = groupChunksIntoTabStreams(allChunkRows); + streamsByKeyRef.current = new Map(allStreams.map(s => [s.tabKey, s])); + setStreams(allStreams); + streamsRef.current = allStreams; + + const { globalStartTs, globalTotalMs } = computeGlobalTimeline(allStreams); + globalStartTsRef.current = globalStartTs; + setGlobalTotalTimeMs(globalTotalMs); + globalTotalTimeMsRef.current = globalTotalMs; + + // Per-tab time ranges based on chunk metadata. This is what we use to decide + // whether a tab has events "at" the current global time (and whether to buffer + // or skip to the next chunk). + const rangesByTab = new Map>(); + for (const s of allStreams) { + const ranges = s.chunks + .map((c) => ({ startTs: c.firstEventAt.getTime(), endTs: c.lastEventAt.getTime() })) + .filter(r => Number.isFinite(r.startTs) && Number.isFinite(r.endTs) && r.endTs >= r.startTs) + .sort((a, b) => a.startTs - b.startTs); + + // Merge overlaps/adjacent ranges to reduce churn. + const merged: Array<{ startTs: number, endTs: number }> = []; + for (const r of ranges) { + const last = merged[merged.length - 1] as { startTs: number, endTs: number } | undefined; + if (!last) { + merged.push({ ...r }); + continue; + } + if (r.startTs <= last.endTs) { + last.endTs = Math.max(last.endTs, r.endTs); + } else { + merged.push({ ...r }); + } + } + + rangesByTab.set(s.tabKey, merged); + } + chunkRangesByTabRef.current = rangesByTab; + + // Stable tab labels: Tab 1 = lowest firstEventAt, Tab 2 = second-lowest, etc. + const labelOrder = allStreams + .slice() + .sort((a, b) => { + const first = a.firstEventAt.getTime() - b.firstEventAt.getTime(); + if (first !== 0) return first; + return stringCompare(a.tabKey, b.tabKey); + }); + tabLabelIndexByKeyRef.current = new Map(labelOrder.map((s, i) => [s.tabKey, i + 1])); + + // Start at the beginning of the overall replay (globalStartTs). This avoids + // landing at an arbitrary offset when the most-recent tab started later. + const initialActive = ( + allStreams.find(s => s.firstEventAt.getTime() === globalStartTs)?.tabKey + ?? (allStreams[0] as TabStream | undefined)?.tabKey + ?? null + ); + setActiveTab(initialActive); + pausedAtGlobalRef.current = 0; + setCurrentGlobalTimeMsForUi(0); + + await fetchChunkEventsForStreamsParallel( + adminApp, + recordingId, + allStreams.map(s => ({ tabKey: s.tabKey, chunks: s.chunks })), + gen, + selectionGenRef, + (tabKey, _chunkIndex, events) => { + const prev = eventsByTabRef.current.get(tabKey) ?? []; + const wasEmpty = prev.length === 0; + prev.push(...events); + eventsByTabRef.current.set(tabKey, prev); + + // Track whether this tab has a FullSnapshot (rrweb type 2). + // Without one the replayer renders a blank white screen. + if (!hasFullSnapshotByTabRef.current.has(tabKey)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (events.some(e => (e as any).type === 2)) { + hasFullSnapshotByTabRef.current.add(tabKey); + setUiVersion(v => v + 1); + } + } + + if (prev.length >= 2) { + loadedDurationByTabMsRef.current.set(tabKey, prev[prev.length - 1].timestamp - prev[0].timestamp); + } + + if (wasEmpty && prev.length > 0) { + runAsynchronously(() => ensureReplayerForTab(tabKey, gen), { noErrorLogging: true }); + setUiVersion(v => v + 1); + } else { + const r = replayerByTabRef.current.get(tabKey); + if (r) { + for (const event of events) { + r.addEvent(event); + } + } else { + runAsynchronously(() => ensureReplayerForTab(tabKey, gen), { noErrorLogging: true }); + } + } + + // Resume playback if the active stream was buffering and now has enough data. + if (activeTabKeyRef.current !== tabKey) return; + if (bufferingAtGlobalRef.current === null) return; + + const stream = streamsByKeyRef.current.get(tabKey) ?? null; + if (!stream) return; + + const targetLocal = globalOffsetToLocalOffset( + globalStartTsRef.current, + stream.firstEventAt.getTime(), + bufferingAtGlobalRef.current, + ); + const loaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; + + if (loaded >= targetLocal) { + const seekTo = bufferingAtGlobalRef.current; + bufferingAtGlobalRef.current = null; + setIsBuffering(false); + + if (autoResumeAfterBufferingRef.current) { + autoResumeAfterBufferingRef.current = false; + playActiveAtGlobalOffset(seekTo); + setPlayerIsPlaying(true); + } + } + }, + ); + } catch (e: any) { + setDownloadError(e?.message ?? "Failed to load replay data."); + } finally { + if (selectionGenRef.current === gen) { + setIsDownloading(false); + } + } + }, [adminApp, ensureReplayerForTab, playActiveAtGlobalOffset, resetReplayState, setActiveTab]); + + useEffect(() => { + if (!selectedRecordingId || !selectedRecording) return; + runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true }); + }, [loadChunksAndDownload, selectedRecordingId, selectedRecording]); + + // Safety net: if downloading finishes while buffering, resume playback. + useEffect(() => { + if (isDownloading) return; + if (bufferingAtGlobalRef.current === null) return; + if (!autoResumeAfterBufferingRef.current) return; + const seekTo = bufferingAtGlobalRef.current; + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + setIsBuffering(false); + playActiveAtGlobalOffset(seekTo); + setPlayerIsPlaying(true); + }, [isDownloading, playActiveAtGlobalOffset]); + + useEffect(() => { + return () => { + selectionGenRef.current += 1; + destroyReplayers(); + }; + }, [destroyReplayers]); + + const getCurrentGlobalTimeMs = useCallback(() => { + return getDesiredGlobalOffsetMs(); + }, [getDesiredGlobalOffsetMs]); + + // Drives right-bar tab visibility (only show tabs "active" at the current time) + // and avoids calling getCurrentGlobalTimeMs() directly during render. + useEffect(() => { + let cancelled = false; + let raf = 0; + let lastUpdateAt = 0; + + const tick = (now: number) => { + if (cancelled) return; + // Throttle UI updates; the actual playback is handled by rrweb. + if (now - lastUpdateAt > 200) { + lastUpdateAt = now; + let globalOffset = getCurrentGlobalTimeMs(); + const previousGlobalOffset = currentGlobalTimeMsForUiRef.current; + + // Guard against stale rrweb readings that momentarily report an older + // time near replay end; otherwise auto-follow can jump back to older tabs. + if ( + playerIsPlayingRef.current + && !isBufferingRef.current + && !gapFastForwardRef.current + && performance.now() >= suppressAutoFollowUntilRef.current + && globalOffset + 500 < previousGlobalOffset + ) { + globalOffset = previousGlobalOffset; + } + + setCurrentGlobalTimeMsForUi(globalOffset); + + // Sync visible mini tab replayers to the current global time so their + // thumbnails update during playback instead of staying frozen. + if (playerIsPlayingRef.current && !isBufferingRef.current) { + const activeKey = activeTabKeyRef.current; + for (const [tabKey, r] of replayerByTabRef.current.entries()) { + if (tabKey === activeKey) continue; + const stream = streamsByKeyRef.current.get(tabKey); + if (!stream) continue; + const localOffset = globalOffsetToLocalOffset( + globalStartTsRef.current, + stream.firstEventAt.getTime(), + globalOffset, + ); + try { + r.pause(localOffset); + } catch { + // ignore — replayer may not be ready yet + } + } + } + + const gapFastForward = gapFastForwardRef.current; + if (gapFastForward && globalOffset >= gapFastForward.toGlobalMs) { + gapFastForwardRef.current = null; + setActiveTab(gapFastForward.nextTabKey); + pausedAtGlobalRef.current = gapFastForward.toGlobalMs; + setCurrentGlobalTimeMsForUi(gapFastForward.toGlobalMs); + setIsBuffering(false); + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + suppressAutoFollowUntilRef.current = performance.now() + 200; + runAsynchronously(() => ensureReplayerForTab(gapFastForward.nextTabKey, gapFastForward.gen), { noErrorLogging: true }); + playActiveAtGlobalOffset(gapFastForward.toGlobalMs); + setPlayerIsPlaying(true); + raf = requestAnimationFrame(tick); + return; + } + + // Auto-follow the tab that has events at the current global time. + // This must use the authoritative time, not the last UI state. + if ( + replaySettingsRef.current.followActiveTab + && playerIsPlayingRef.current + && !isBufferingRef.current + && streamsRef.current.length > 1 + ) { + if (performance.now() < suppressAutoFollowUntilRef.current) { + // Recently seeked/started playback; wait for rrweb to catch up. + } else if (activeTabKeyRef.current && isTabInRangeAtGlobalOffset(activeTabKeyRef.current, globalOffset)) { + // Current tab still has events at this time — stay on it (stickiness). + } else { + const bestKey = findBestTabAtGlobalOffset(globalOffset); + if (bestKey && bestKey !== activeTabKeyRef.current) { + setActiveTab(bestKey); + pausedAtGlobalRef.current = globalOffset; + runAsynchronously(() => ensureReplayerForTab(bestKey, selectionGenRef.current), { noErrorLogging: true }); + playActiveAtGlobalOffset(globalOffset); + suppressAutoFollowUntilRef.current = performance.now() + 200; + } + // When !bestKey (gap between tabs), active replayer finish handlers + // fast-forward to the next tab start. + } + } + } + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, [ + ensureReplayerForTab, + findBestTabAtGlobalOffset, + getCurrentGlobalTimeMs, + isTabInRangeAtGlobalOffset, + playActiveAtGlobalOffset, + setActiveTab, + ]); + + const togglePlayPause = useCallback(() => { + if (playerIsPlaying || isBuffering) { + if (!isBuffering) { + pausedAtGlobalRef.current = getCurrentGlobalTimeMs(); + setCurrentGlobalTimeMsForUi(pausedAtGlobalRef.current); + } + gapFastForwardRef.current = null; + pauseAll(); + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + setIsBuffering(false); + setPlayerIsPlaying(false); + return; + } + + const target = pausedAtGlobalRef.current; + const activeKey = activeTabKeyRef.current; + const activeStream = activeKey ? streamsByKeyRef.current.get(activeKey) ?? null : null; + if (isDownloadingRef.current && activeKey && activeStream) { + const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, activeStream.firstEventAt.getTime(), target); + const loaded = loadedDurationByTabMsRef.current.get(activeKey) ?? 0; + if (localTarget > loaded) { + bufferingAtGlobalRef.current = target; + autoResumeAfterBufferingRef.current = true; + setIsBuffering(true); + return; + } + } + + bufferingAtGlobalRef.current = null; + setIsBuffering(false); + setReplayFinished(false); + playActiveAtGlobalOffset(target); + setPlayerIsPlaying(true); + setCurrentGlobalTimeMsForUi(target); + suppressAutoFollowUntilRef.current = performance.now() + 400; + }, [getCurrentGlobalTimeMs, isBuffering, pauseAll, playActiveAtGlobalOffset, playerIsPlaying]); + + const handleSeek = useCallback((globalOffset: number) => { + const seekState = applySeekState({ seekToGlobalMs: globalOffset }); + if (seekState.clearGapFastForward) { + gapFastForwardRef.current = null; + } + pausedAtGlobalRef.current = seekState.pausedAtGlobalMs; + setReplayFinished(false); + + // If the seek target is outside the currently active tab's time range, + // switch to the best tab for that time. + const desiredKey = findBestTabAtGlobalOffset(globalOffset); + if (desiredKey && desiredKey !== activeTabKeyRef.current) { + setActiveTab(desiredKey); + runAsynchronously(() => ensureReplayerForTab(desiredKey, selectionGenRef.current), { noErrorLogging: true }); + } + + const activeKey = activeTabKeyRef.current; + const activeStream = activeKey ? streamsByKeyRef.current.get(activeKey) ?? null : null; + if (isDownloadingRef.current && activeKey && activeStream) { + const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, activeStream.firstEventAt.getTime(), globalOffset); + const loaded = loadedDurationByTabMsRef.current.get(activeKey) ?? 0; + if (localTarget > loaded) { + pauseAll(); + bufferingAtGlobalRef.current = globalOffset; + autoResumeAfterBufferingRef.current = true; + setIsBuffering(true); + setPlayerIsPlaying(false); + return; + } + } + + bufferingAtGlobalRef.current = null; + autoResumeAfterBufferingRef.current = false; + setIsBuffering(false); + playActiveAtGlobalOffset(globalOffset); + setPlayerIsPlaying(true); + setCurrentGlobalTimeMsForUi(globalOffset); + suppressAutoFollowUntilRef.current = performance.now() + 400; + }, [ensureReplayerForTab, findBestTabAtGlobalOffset, pauseAll, playActiveAtGlobalOffset, setActiveTab]); + + const updateSpeed = useCallback((speed: number) => { + if (!ALLOWED_PLAYER_SPEEDS.has(speed)) return; + setReplaySettings((prev) => ({ ...prev, playerSpeed: speed })); + for (const r of replayerByTabRef.current.values()) { + try { + r.setConfig({ speed }); + } catch { + // ignore + } + } + }, []); + + useEffect(() => { + for (const r of replayerByTabRef.current.values()) { + try { + r.setConfig({ skipInactive: replaySettings.skipInactivity }); + } catch { + // ignore + } + } + if (!replaySettings.skipInactivity) { + setIsSkipping(false); + } + }, [replaySettings.skipInactivity]); + + const onSelectActiveTab = useCallback((tabKey: TabKey) => { + const now = getCurrentGlobalTimeMs(); + setActiveTab(tabKey); + pausedAtGlobalRef.current = now; + setIsSkipping(false); + + // Force a seek so the newly active tab aligns immediately. + pauseAll(); + autoResumeAfterBufferingRef.current = false; + bufferingAtGlobalRef.current = null; + setIsBuffering(false); + + const stream = streamsByKeyRef.current.get(tabKey) ?? null; + if (isDownloadingRef.current && stream) { + const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, stream.firstEventAt.getTime(), now); + const loaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; + if (localTarget > loaded) { + bufferingAtGlobalRef.current = now; + autoResumeAfterBufferingRef.current = true; + setIsBuffering(true); + setPlayerIsPlaying(false); + runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); + return; + } + } + + runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); + + if (playerIsPlayingRef.current && !isBufferingRef.current) { + playActiveAtGlobalOffset(now); + setPlayerIsPlaying(true); + } else { + setPlayerIsPlaying(false); + } + }, [ensureReplayerForTab, getCurrentGlobalTimeMs, pauseAll, playActiveAtGlobalOffset, setActiveTab]); + + // Subscribe to speedService on the active stream only (for skip indicator). + useEffect(() => { + if (!replaySettings.skipInactivity) { + setIsSkipping(false); + speedSubRef.current?.unsubscribe(); + speedSubRef.current = null; + return; + } + + const key = activeTabKey; + const r = key ? replayerByTabRef.current.get(key) ?? null : null; + + setIsSkipping(false); + speedSubRef.current?.unsubscribe(); + speedSubRef.current = null; + + if (!r) return; + + try { + const sub = (r as any).speedService.subscribe((state: any) => { + setIsSkipping(state.value === "skipping"); + }); + speedSubRef.current = sub; + } catch { + // ignore + } + }, [activeTabKey, replaySettings.skipInactivity, uiVersion]); + + const activeStream = useMemo( + () => (activeTabKey ? streams.find(s => s.tabKey === activeTabKey) ?? null : null), + [activeTabKey, streams], + ); + + const visibleMiniStreams = useMemo(() => { + void uiVersion; // re-compute when FullSnapshot status changes + const currentTs = globalStartTsRef.current + currentGlobalTimeMsForUi; + const candidates = streams.filter(s => + s.tabKey !== activeTabKey && hasFullSnapshotByTabRef.current.has(s.tabKey) + ); + const inRange = candidates.filter(s => + currentTs >= s.firstEventAt.getTime() && currentTs <= s.lastEventAt.getTime() + ); + + inRange.sort((a, b) => { + const aLabel = tabLabelIndexByKeyRef.current.get(a.tabKey) ?? Number.POSITIVE_INFINITY; + const bLabel = tabLabelIndexByKeyRef.current.get(b.tabKey) ?? Number.POSITIVE_INFINITY; + if (aLabel !== bLabel) return aLabel - bLabel; + return stringCompare(a.tabKey, b.tabKey); + }); + + return inRange.slice(0, EXTRA_TABS_TO_SHOW); + }, [activeTabKey, currentGlobalTimeMsForUi, streams, uiVersion]); + + const showRightColumn = visibleMiniStreams.length > 0; + + const miniIndexByKey = useMemo(() => { + const m = new Map(); + for (const [idx, s] of visibleMiniStreams.entries()) { + m.set(s.tabKey, idx); + } + return m; + }, [visibleMiniStreams]); + + const getTabLabel = useCallback((tabKey: TabKey) => { + const idx = tabLabelIndexByKeyRef.current.get(tabKey); + if (!idx) return "Tab"; + return `Tab ${idx}`; + }, []); + + const activeHasEvents = useMemo(() => { + if (!activeStream) return false; + // uiVersion ensures re-render when events/replayers arrive. + void uiVersion; + return (eventsByTabRef.current.get(activeStream.tabKey)?.length ?? 0) > 0; + }, [activeStream, uiVersion]); + + const renderableStreamCount = useMemo(() => { + void uiVersion; + return streams.filter(s => hasFullSnapshotByTabRef.current.has(s.tabKey)).length; + }, [streams, uiVersion]); + + const showMainTabLabel = renderableStreamCount > 1; + + return ( + + + + +
+
+ + Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""} + +
+ + {listError && ( +
+ {listError} +
+ )} + +
+ {loadingInitial ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + +
+ ))} +
+ ) : recordings.length === 0 ? ( +
+ + No replays yet. + +
+ ) : ( +
+ {recordings.map((r) => { + const isSelected = r.id === selectedRecordingId; + const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime(); + const duration = formatDurationMs(durationMs); + return ( + + ); + })} + + {loadingMore && ( +
+ + +
+ )} +
+ )} +
+
+
+ + + + +
+ {(downloadError || playerError) && ( +
+ {downloadError && {downloadError}} + {playerError && {playerError}} +
+ )} + +
+ + {selectedRecording ? getRecordingTitle(selectedRecording) : ""} + + setReplaySettings((prev) => ({ ...prev, ...updates }))} + /> +
+ + {selectedRecording ? ( +
+
+
+ {streams.length === 0 && ( +
+
+ {isDownloading ? ( + <> + + + Loading replay... + + + ) : ( + + No replay data loaded yet. + + )} +
+
+ )} + + {streams.map((s) => { + const isActive = s.tabKey === activeTabKey; + const miniIndex = miniIndexByKey.get(s.tabKey); + const isMiniVisible = miniIndex !== undefined && miniIndex >= 0 && miniIndex < EXTRA_TABS_TO_SHOW; + if (!isActive && !isMiniVisible) return null; + + const title = getTabLabel(s.tabKey); + + return ( + + ); + })} +
+ + {activeStream && activeHasEvents && ( + + )} +
+
+ ) : ( +
+
+ + + Loading replay... + +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page.tsx new file mode 100644 index 0000000000..920c1a4c3c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page.tsx @@ -0,0 +1,6 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} + diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 5f579fe9dc..f72784a88f 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -99,7 +99,7 @@ export default function RootLayout({ - + diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index cc58dfd1a4..38fe0afb2f 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -309,6 +309,7 @@ export const ALL_APPS_FRONTEND = { href: "analytics", navigationItems: [ { displayName: "Tables", href: "./tables" }, + { displayName: "Replays", href: "./replays" }, ], screenshots: [], storeDescription: ( diff --git a/apps/dashboard/src/lib/session-replay-playback.ts b/apps/dashboard/src/lib/session-replay-playback.ts new file mode 100644 index 0000000000..9852e8c888 --- /dev/null +++ b/apps/dashboard/src/lib/session-replay-playback.ts @@ -0,0 +1,73 @@ +export const INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER = 12; + +export type GapFastForwardState = { + fromGlobalMs: number, + toGlobalMs: number, + wallMs: number, +}; + +export function getDesiredGlobalOffsetFromPlaybackState(options: { + gapFastForward: GapFastForwardState | null, + playerIsPlaying: boolean, + nowMs: number, + playerSpeed: number, + pausedAtGlobalMs: number, + activeLocalOffsetMs: number | null, + activeStreamStartTs: number | null, + globalStartTs: number, + gapFastForwardMultiplier?: number, +}) { + const gapMultiplier = options.gapFastForwardMultiplier ?? INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER; + + const gapFastForward = options.gapFastForward; + if (gapFastForward && options.playerIsPlaying) { + const elapsed = (options.nowMs - gapFastForward.wallMs) * options.playerSpeed * gapMultiplier; + return Math.min(gapFastForward.toGlobalMs, gapFastForward.fromGlobalMs + elapsed); + } + + if (!options.playerIsPlaying) return options.pausedAtGlobalMs; + + if (options.activeLocalOffsetMs === null || options.activeStreamStartTs === null) { + return options.pausedAtGlobalMs; + } + + return options.activeLocalOffsetMs + (options.activeStreamStartTs - options.globalStartTs); +} + +export type ReplayFinishAction = + | { type: "buffer_at_current" } + | { type: "gap_fast_forward", toGlobalMs: number } + | { type: "finish_replay" }; + +export function getReplayFinishAction(options: { + hasBestTabAtCurrentTime: boolean, + isDownloading: boolean, + nextStartGlobalOffsetMs: number | null, + currentGlobalOffsetMs?: number, +}) : ReplayFinishAction { + if (options.hasBestTabAtCurrentTime) { + throw new Error("getReplayFinishAction() expects hasBestTabAtCurrentTime=false"); + } + if ( + options.nextStartGlobalOffsetMs !== null + && ( + options.currentGlobalOffsetMs === undefined + || options.nextStartGlobalOffsetMs > options.currentGlobalOffsetMs + ) + ) { + return { type: "gap_fast_forward", toGlobalMs: options.nextStartGlobalOffsetMs }; + } + if (options.isDownloading) { + return { type: "buffer_at_current" }; + } + return { type: "finish_replay" }; +} + +export function applySeekState(options: { + seekToGlobalMs: number, +}) { + return { + pausedAtGlobalMs: options.seekToGlobalMs, + clearGapFastForward: true, + }; +} diff --git a/apps/dashboard/src/lib/session-replay-streams.test.ts b/apps/dashboard/src/lib/session-replay-streams.test.ts new file mode 100644 index 0000000000..7ea951704b --- /dev/null +++ b/apps/dashboard/src/lib/session-replay-streams.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + NULL_TAB_KEY, + computeGlobalTimeline, + globalOffsetToLocalOffset, + groupChunksIntoTabStreams, + limitTabStreams, + localOffsetToGlobalOffset, + toTabKey, +} from "./session-replay-streams"; + +function d(ms: number) { + return new Date(ms); +} + +describe("session-replay-streams", () => { + it("treats null tabId as its own stream", () => { + const streams = groupChunksIntoTabStreams([ + { tabId: null, firstEventAt: d(10), lastEventAt: d(20), eventCount: 2 }, + { tabId: null, firstEventAt: d(21), lastEventAt: d(30), eventCount: 1 }, + { tabId: "a", firstEventAt: d(5), lastEventAt: d(25), eventCount: 10 }, + ]); + + expect(streams.map(s => s.tabKey).sort()).toEqual([NULL_TAB_KEY, "a"].sort()); + expect(toTabKey(null)).toBe(NULL_TAB_KEY); + }); + + it("sorts streams by lastEventAt desc then eventCount desc", () => { + const streams = groupChunksIntoTabStreams([ + { tabId: "a", firstEventAt: d(0), lastEventAt: d(10), eventCount: 5 }, + { tabId: "b", firstEventAt: d(0), lastEventAt: d(20), eventCount: 1 }, + { tabId: "c", firstEventAt: d(0), lastEventAt: d(20), eventCount: 9 }, + ]); + + expect(streams.map(s => s.tabId)).toEqual(["c", "b", "a"]); + }); + + it("limits streams and reports hiddenCount", () => { + const streams = groupChunksIntoTabStreams([ + { tabId: "a", firstEventAt: d(0), lastEventAt: d(10), eventCount: 1 }, + { tabId: "b", firstEventAt: d(0), lastEventAt: d(20), eventCount: 1 }, + { tabId: "c", firstEventAt: d(0), lastEventAt: d(30), eventCount: 1 }, + ]); + + const limited = limitTabStreams(streams, 2); + expect(limited.streams).toHaveLength(2); + expect(limited.hiddenCount).toBe(1); + }); + + it("maps global offsets to local offsets and back", () => { + const streams = groupChunksIntoTabStreams([ + { tabId: "a", firstEventAt: d(1000), lastEventAt: d(5000), eventCount: 1 }, + { tabId: "b", firstEventAt: d(2000), lastEventAt: d(4000), eventCount: 1 }, + ]); + const { globalStartTs } = computeGlobalTimeline(streams); + + // global offset 1500ms => absolute 2500ms. + // Stream b starts at 2000ms => local 500ms. + const local = globalOffsetToLocalOffset(globalStartTs, 2000, 1500); + expect(local).toBe(500); + + const roundTripGlobal = localOffsetToGlobalOffset(globalStartTs, 2000, local); + expect(roundTripGlobal).toBe(1500); + }); +}); + diff --git a/apps/dashboard/src/lib/session-replay-streams.ts b/apps/dashboard/src/lib/session-replay-streams.ts new file mode 100644 index 0000000000..a6adb948ea --- /dev/null +++ b/apps/dashboard/src/lib/session-replay-streams.ts @@ -0,0 +1,129 @@ +export const NULL_TAB_KEY = "__null_tab__"; + +export type TabKey = string; + +export function toTabKey(tabId: string | null): TabKey { + return tabId ?? NULL_TAB_KEY; +} + +export type TabStream = { + tabId: string | null, + tabKey: TabKey, + chunks: TChunk[], + firstEventAt: Date, + lastEventAt: Date, + eventCount: number, + chunkCount: number, +}; + +type ChunkLike = { + tabId: string | null, + firstEventAt: Date, + lastEventAt: Date, + eventCount: number, + createdAt?: Date, +}; + +function compareChunks(a: ChunkLike, b: ChunkLike) { + const aFirst = a.firstEventAt.getTime(); + const bFirst = b.firstEventAt.getTime(); + if (aFirst !== bFirst) return aFirst - bFirst; + + const aLast = a.lastEventAt.getTime(); + const bLast = b.lastEventAt.getTime(); + if (aLast !== bLast) return aLast - bLast; + + const aCreated = a.createdAt?.getTime() ?? 0; + const bCreated = b.createdAt?.getTime() ?? 0; + if (aCreated !== bCreated) return aCreated - bCreated; + + return 0; +} + +export function groupChunksIntoTabStreams(chunks: TChunk[]): TabStream[] { + const byTab = new Map(); + + for (const c of chunks) { + const tabKey = toTabKey(c.tabId); + const existing = byTab.get(tabKey); + if (existing) { + existing.chunks.push(c); + } else { + byTab.set(tabKey, { tabId: c.tabId, chunks: [c] }); + } + } + + const streams: TabStream[] = []; + for (const { tabId, chunks: tabChunks } of byTab.values()) { + tabChunks.sort(compareChunks); + + let firstEventAtMs = Infinity; + let lastEventAtMs = -Infinity; + let eventCount = 0; + + for (const c of tabChunks) { + firstEventAtMs = Math.min(firstEventAtMs, c.firstEventAt.getTime()); + lastEventAtMs = Math.max(lastEventAtMs, c.lastEventAt.getTime()); + eventCount += c.eventCount; + } + + const firstEventAt = new Date(Number.isFinite(firstEventAtMs) ? firstEventAtMs : 0); + const lastEventAt = new Date(Number.isFinite(lastEventAtMs) ? lastEventAtMs : 0); + + streams.push({ + tabId, + tabKey: toTabKey(tabId), + chunks: tabChunks, + firstEventAt, + lastEventAt, + eventCount, + chunkCount: tabChunks.length, + }); + } + + streams.sort((a, b) => { + const last = b.lastEventAt.getTime() - a.lastEventAt.getTime(); + if (last !== 0) return last; + return b.eventCount - a.eventCount; + }); + + return streams; +} + +export function limitTabStreams( + streams: TabStream[], + maxStreams: number, +): { streams: TabStream[], hiddenCount: number } { + if (streams.length <= maxStreams) return { streams, hiddenCount: 0 }; + return { streams: streams.slice(0, maxStreams), hiddenCount: streams.length - maxStreams }; +} + +export function computeGlobalTimeline(streams: Array<{ firstEventAt: Date, lastEventAt: Date }>) { + let globalStartTs = Infinity; + let globalEndTs = -Infinity; + + for (const s of streams) { + globalStartTs = Math.min(globalStartTs, s.firstEventAt.getTime()); + globalEndTs = Math.max(globalEndTs, s.lastEventAt.getTime()); + } + + if (!Number.isFinite(globalStartTs) || !Number.isFinite(globalEndTs) || globalEndTs < globalStartTs) { + return { globalStartTs: 0, globalEndTs: 0, globalTotalMs: 0 }; + } + + return { + globalStartTs, + globalEndTs, + globalTotalMs: globalEndTs - globalStartTs, + }; +} + +export function globalOffsetToLocalOffset(globalStartTs: number, streamStartTs: number, globalOffsetMs: number) { + const globalTs = globalStartTs + globalOffsetMs; + return Math.max(0, globalTs - streamStartTs); +} + +export function localOffsetToGlobalOffset(globalStartTs: number, streamStartTs: number, localOffsetMs: number) { + return localOffsetMs + (streamStartTs - globalStartTs); +} + From 3f3f8e8b606a873c13eb7f9f0cd6f989e92f4ed0 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 14:29:32 -0800 Subject: [PATCH 05/21] small --- .../analytics/replays/page-client.tsx | 24 +++++++++++++------ apps/dashboard/src/app/layout.tsx | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index f263c692fa..117828f2f9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -19,7 +19,7 @@ import { import { cn } from "@/lib/utils"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, FastForwardIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon } from "@phosphor-icons/react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; @@ -1385,6 +1385,7 @@ export default function PageClient() { const onSelectActiveTab = useCallback((tabKey: TabKey) => { const now = getCurrentGlobalTimeMs(); setActiveTab(tabKey); + suppressAutoFollowUntilRef.current = performance.now() + 5000; pausedAtGlobalRef.current = now; setIsSkipping(false); @@ -1797,12 +1798,21 @@ export default function PageClient() {
) : (
-
- - - Loading replay... - -
+ {loadingInitial ? ( +
+ + + Loading replay... + +
+ ) : ( +
+ + + No session replays yet + +
+ )}
)} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index f72784a88f..e09c44ca46 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -99,7 +99,7 @@ export default function RootLayout({ - + From 5092434c4329e82d2f795ded945a90640de4f2db Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 15:04:39 -0800 Subject: [PATCH 06/21] replay frontend fix --- .../analytics/replays/page-client.tsx | 45 ++++++++++++++++++- .../src/lib/session-replay-playback.ts | 8 ++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 117828f2f9..caab179952 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -878,6 +878,40 @@ export default function PageClient() { const stream2 = streamsByKeyRef.current.get(tabKey) ?? null; const streamStartTs2 = stream2?.firstEventAt.getTime() ?? globalStartTsRef.current; + + // If still downloading and this tab is expected to have more + // events, buffer and poll for data. Must run BEFORE the tab- + // switching / gap-forward logic to prevent jumping away while + // the current tab's chunks are still in flight. + const tabExpectedDurationMs = stream2 + ? stream2.lastEventAt.getTime() - stream2.firstEventAt.getTime() + : null; + if (isDownloadingRef.current && tabExpectedDurationMs !== null && tabExpectedDurationMs > localTime + 500) { + const globalOffset = localOffsetToGlobalOffset(globalStartTsRef.current, streamStartTs2, localTime); + pausedAtGlobalRef.current = globalOffset; + setIsBuffering(true); + setPlayerIsPlaying(false); + + // Poll until enough buffer exists ahead, then resume. + // Using setTimeout avoids the rapid buffer→resume→finish loop + // that onChunkLoaded auto-resume can cause when loaded ≈ target. + const pollResume = () => { + if (selectionGenRef.current !== gen) return; + if (activeTabKeyRef.current !== tabKey) return; + if (!isBufferingRef.current) return; // user toggled play/pause + const newLoaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; + if (newLoaded > localTime + 2000 || !isDownloadingRef.current) { + setIsBuffering(false); + playActiveAtGlobalOffset(globalOffset); + setPlayerIsPlaying(true); + } else { + setTimeout(pollResume, 500); + } + }; + setTimeout(pollResume, 500); + return; + } + let globalOffset = localOffsetToGlobalOffset(globalStartTsRef.current, streamStartTs2, localTime); // Find the best OTHER tab at this offset (exclude the exhausted tab). @@ -907,12 +941,16 @@ export default function PageClient() { } // No alternative tab — check for gap, buffer, or true finish. + const currentTabHasMoreExpectedEvents = tabExpectedDurationMs !== null + && tabExpectedDurationMs > localTime + 500; + const nextStart = findNextTabStartAfterGlobalOffset(globalOffset); const finishAction = getReplayFinishAction({ hasBestTabAtCurrentTime: false, isDownloading: isDownloadingRef.current, nextStartGlobalOffsetMs: nextStart?.globalOffsetMs ?? null, currentGlobalOffsetMs: Math.max(globalOffset, currentGlobalTimeMsForUiRef.current), + currentTabHasMoreExpectedEvents, }); if (finishAction.type === "gap_fast_forward" && nextStart) { gapFastForwardRef.current = { @@ -1118,7 +1156,12 @@ export default function PageClient() { ); const loaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; - if (loaded >= targetLocal) { + // Require a minimum 2s buffer ahead while downloading to avoid a + // rapid buffer→resume→finish loop when events stream in at the edge + // of loaded data. Once downloading completes the safety-net effect + // resumes immediately without the buffer-ahead requirement. + const bufferAhead = isDownloadingRef.current ? 2000 : 0; + if (loaded >= targetLocal + bufferAhead) { const seekTo = bufferingAtGlobalRef.current; bufferingAtGlobalRef.current = null; setIsBuffering(false); diff --git a/apps/dashboard/src/lib/session-replay-playback.ts b/apps/dashboard/src/lib/session-replay-playback.ts index 9852e8c888..59da038f5e 100644 --- a/apps/dashboard/src/lib/session-replay-playback.ts +++ b/apps/dashboard/src/lib/session-replay-playback.ts @@ -44,10 +44,18 @@ export function getReplayFinishAction(options: { isDownloading: boolean, nextStartGlobalOffsetMs: number | null, currentGlobalOffsetMs?: number, + currentTabHasMoreExpectedEvents?: boolean, }) : ReplayFinishAction { if (options.hasBestTabAtCurrentTime) { throw new Error("getReplayFinishAction() expects hasBestTabAtCurrentTime=false"); } + // If the current tab still has unloaded events, buffer instead of jumping + // to the next tab. Without this check a premature rrweb "finish" (fired + // before all chunks have loaded) would see the next tab's start offset and + // fast-forward there, skipping the rest of the current tab. + if (options.isDownloading && options.currentTabHasMoreExpectedEvents) { + return { type: "buffer_at_current" }; + } if ( options.nextStartGlobalOffsetMs !== null && ( From 2a590e650dedc5d612ba6455d64823838386286c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 15:20:38 -0800 Subject: [PATCH 07/21] record pre-login --- .../src/components/stack-analytics.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/template/src/components/stack-analytics.tsx b/packages/template/src/components/stack-analytics.tsx index 192c84bcf2..00d6189c81 100644 --- a/packages/template/src/components/stack-analytics.tsx +++ b/packages/template/src/components/stack-analytics.tsx @@ -25,6 +25,9 @@ const FLUSH_INTERVAL_MS = 5_000; const MAX_EVENTS_PER_BATCH = 200; 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 = { session_id: string, created_at_ms: number, @@ -195,6 +198,12 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO if (events.length >= MAX_EVENTS_PER_BATCH || approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); } + + // Cap pre-auth buffer to prevent unbounded memory growth + if (!accessTokenRef.current && (events.length > MAX_PREAUTH_BUFFER_EVENTS || approxBytes > MAX_PREAUTH_BUFFER_BYTES)) { + events = []; + approxBytes = 0; + } }, maskAllInputs: props.replayOptions?.maskAllInputs ?? true, ...(props.replayOptions?.blockClass !== undefined ? { blockClass: props.replayOptions.blockClass } : {}), @@ -228,19 +237,24 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO recording = false; }; - // Periodically flushes events. Recording start/stop is driven by - // the accessToken dependency below, not by polling. + // Periodically flushes events. + let wasAuthenticated = !!accessTokenRef.current; const tick = () => { if (cancelled) return; - if (accessTokenRef.current && events.length > 0) { + const hasAuth = !!accessTokenRef.current; + // Clear buffer on logout to prevent cross-user event leakage + if (wasAuthenticated && !hasAuth) { + events = []; + approxBytes = 0; + } + wasAuthenticated = hasAuth; + if (hasAuth && events.length > 0) { runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); } }; - // Start or stop recording based on current auth state. - if (accessTokenRef.current) { - runAsynchronously(() => startRecording(), { noErrorLogging: true }); - } + // Start recording immediately so pre-login activity is captured. + runAsynchronously(() => startRecording(), { noErrorLogging: true }); flushTimer = window.setInterval(tick, FLUSH_INTERVAL_MS); @@ -253,7 +267,7 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO runAsynchronously(() => flush({ keepalive: true }), { noErrorLogging: true }); stopCurrentRecording(); }; - }, [app, tabId, !!accessToken]); + }, [app, tabId]); return null; } From a18fa979e8d556efc746a577f591e018f2d3742c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 18:31:00 -0800 Subject: [PATCH 08/21] stack s3 private key --- apps/backend/.env | 1 + apps/backend/.env.development | 1 + .../latest/session-recordings/batch/route.tsx | 1 + apps/backend/src/s3.tsx | 23 +++++++++++++++---- docker/dependencies/docker.compose.yaml | 2 +- docker/emulator/docker.compose.yaml | 3 ++- docker/server/.env | 1 + 7 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/backend/.env b/apps/backend/.env index 94b908d198..c8cebc65ce 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -67,6 +67,7 @@ STACK_S3_REGION= STACK_S3_ACCESS_KEY_ID= STACK_S3_SECRET_ACCESS_KEY= STACK_S3_BUCKET= +STACK_S3_PRIVATE_BUCKET= # AWS configuration STACK_AWS_REGION= diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 55d58dda44..73593972fe 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -74,6 +74,7 @@ STACK_S3_REGION=us-east-1 STACK_S3_ACCESS_KEY_ID=s3mockroot STACK_S3_SECRET_ACCESS_KEY=s3mockroot STACK_S3_BUCKET=stack-storage +STACK_S3_PRIVATE_BUCKET=stack-storage-private # AWS region defaults to LocalStack STACK_AWS_REGION=us-east-1 diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 0aff7708c3..cecc152312 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -161,6 +161,7 @@ export const POST = createSmartRouteHandler({ body: gzipped, contentType: "application/json", contentEncoding: "gzip", + private: true, }); try { diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index 1f7e8f3a56..1a29687618 100644 --- a/apps/backend/src/s3.tsx +++ b/apps/backend/src/s3.tsx @@ -7,6 +7,7 @@ const S3_REGION = getEnvVariable("STACK_S3_REGION", ""); const S3_ENDPOINT = getEnvVariable("STACK_S3_ENDPOINT", ""); const S3_PUBLIC_ENDPOINT = getEnvVariable("STACK_S3_PUBLIC_ENDPOINT", ""); const S3_BUCKET = getEnvVariable("STACK_S3_BUCKET", ""); +const S3_PRIVATE_BUCKET = getEnvVariable("STACK_S3_PRIVATE_BUCKET", ""); const S3_ACCESS_KEY_ID = getEnvVariable("STACK_S3_ACCESS_KEY_ID", ""); const S3_SECRET_ACCESS_KEY = getEnvVariable("STACK_S3_SECRET_ACCESS_KEY", ""); @@ -16,6 +17,10 @@ if (!HAS_S3) { console.warn("S3 bucket is not configured. File upload features will not be available."); } +if (HAS_S3 && !S3_PRIVATE_BUCKET) { + console.warn("S3 private bucket is not configured (STACK_S3_PRIVATE_BUCKET). Session recordings will not be available."); +} + const s3Client = HAS_S3 ? new S3Client({ region: S3_REGION, endpoint: S3_ENDPOINT, @@ -39,13 +44,19 @@ export async function uploadBytes(options: { body: Uint8Array, contentType?: string, contentEncoding?: string, + private?: boolean, }) { if (!s3Client) { throw new StackAssertionError("S3 is not configured"); } + const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET; + if (!bucket) { + throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured"); + } + const command = new PutObjectCommand({ - Bucket: S3_BUCKET, + Bucket: bucket, Key: options.key, Body: options.body, ...(options.contentType ? { ContentType: options.contentType } : {}), @@ -56,7 +67,6 @@ export async function uploadBytes(options: { return { key: options.key, - url: getS3PublicUrl(options.key), }; } @@ -87,13 +97,18 @@ async function readBodyToBytes(body: unknown): Promise { throw new StackAssertionError("Unexpected S3 body type"); } -export async function downloadBytes(options: { key: string }): Promise { +export async function downloadBytes(options: { key: string, private?: boolean }): Promise { if (!s3Client) { throw new StackAssertionError("S3 is not configured"); } + const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET; + if (!bucket) { + throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured"); + } + const command = new GetObjectCommand({ - Bucket: S3_BUCKET, + Bucket: bucket, Key: options.key, }); diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml index 3cebc981ad..e94ae11821 100644 --- a/docker/dependencies/docker.compose.yaml +++ b/docker/dependencies/docker.compose.yaml @@ -193,7 +193,7 @@ services: ports: - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21:9090" environment: - - initialBuckets=stack-storage + - initialBuckets=stack-storage,stack-storage-private - root=s3mockroot - debug=false volumes: diff --git a/docker/emulator/docker.compose.yaml b/docker/emulator/docker.compose.yaml index a4172420a9..6172a65de1 100644 --- a/docker/emulator/docker.compose.yaml +++ b/docker/emulator/docker.compose.yaml @@ -52,6 +52,7 @@ services: STACK_SPOTIFY_CLIENT_SECRET: "MOCK" STACK_S3_ENDPOINT: "http://localhost:32205" STACK_S3_BUCKET: "stack-storage" + STACK_S3_PRIVATE_BUCKET: "stack-storage-private" STACK_S3_REGION: "us-east-1" STACK_S3_ACCESS_KEY_ID: "S3RVER" STACK_S3_SECRET_ACCESS_KEY: "S3RVER" @@ -145,7 +146,7 @@ services: ports: - 32205:9090 environment: - - initialBuckets=stack-storage + - initialBuckets=stack-storage,stack-storage-private - root=s3mockroot - debug=false volumes: diff --git a/docker/server/.env b/docker/server/.env index 2b523e001e..0de86ff75c 100644 --- a/docker/server/.env +++ b/docker/server/.env @@ -37,5 +37,6 @@ STACK_S3_REGION= STACK_S3_ACCESS_KEY_ID= STACK_S3_SECRET_ACCESS_KEY= STACK_S3_BUCKET= +STACK_S3_PRIVATE_BUCKET= STACK_FREESTYLE_API_KEY=# enter your freestyle.sh api key From dad51aa80bfad1be2c0a6a4045fe072d0e5cf805 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 18:38:28 -0800 Subject: [PATCH 09/21] upsert session by refresh token --- .../20260210120000_session_recordings_mvp/migration.sql | 3 +++ apps/backend/prisma/schema.prisma | 1 + .../src/app/api/latest/session-recordings/batch/route.tsx | 5 ++--- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql index 3479fbe9d9..c3133af16f 100644 --- a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql +++ b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql @@ -43,6 +43,9 @@ ALTER TABLE "SessionRecordingChunk" ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey" FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE; +CREATE UNIQUE INDEX "SessionRecording_tenancyId_refreshTokenId_key" + ON "SessionRecording"("tenancyId", "refreshTokenId"); + CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" ON "SessionRecording"("tenancyId", "projectUserId", "startedAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 879b0dc53c..d2ef022357 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -300,6 +300,7 @@ model SessionRecording { chunks SessionRecordingChunk[] @@id([tenancyId, id]) + @@unique([tenancyId, refreshTokenId]) @@index([tenancyId, projectUserId, startedAt]) @@index([tenancyId, lastEventAt]) } diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index cecc152312..7798545395 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -102,13 +102,13 @@ export const POST = createSmartRouteHandler({ // Ensure the session row exists and is up-to-date. const existingSession = await prisma.sessionRecording.findUnique({ - where: { tenancyId_id: { tenancyId, id: sessionId } }, + where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, select: { startedAt: true, lastEventAt: true }, }); const newStartedAtMs = Math.min(existingSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs); const newLastEventAtMs = Math.max(existingSession?.lastEventAt.getTime() ?? 0, lastMs); await prisma.sessionRecording.upsert({ - where: { tenancyId_id: { tenancyId, id: sessionId } }, + where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, create: { id: sessionId, tenancyId, @@ -120,7 +120,6 @@ export const POST = createSmartRouteHandler({ lastEventAt: new Date(newLastEventAtMs), }, update: { - refreshTokenId, startedAt: new Date(newStartedAtMs), lastEventAt: new Date(newLastEventAtMs), }, From 05e4adefe2a414e5a030cf5833d9ca295a2fce69 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Feb 2026 19:09:37 -0800 Subject: [PATCH 10/21] fix --- .../src/interface/client-interface.ts | 22 ++++++++++ .../src/components/stack-analytics.tsx | 42 ++++--------------- .../apps/implementations/client-app-impl.ts | 3 ++ .../stack-app/apps/interfaces/client-app.ts | 1 + 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 7c6a4c327e..6a7fed0a04 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -244,6 +244,28 @@ export class StackClientInterface { return session; } + async sendSessionRecordingBatch( + body: string, + session: InternalSession | null, + options: { keepalive: boolean }, + ): Promise> { + try { + const response = await this.sendClientRequest( + "/session-recordings/batch", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: options.keepalive, + }, + session, + ); + return Result.ok(response); + } catch (e) { + return Result.error(e instanceof Error ? e : new Error(String(e))); + } + } + protected async sendClientRequestAndCatchKnownError( path: string, requestOptions: RequestInit, diff --git a/packages/template/src/components/stack-analytics.tsx b/packages/template/src/components/stack-analytics.tsx index 00d6189c81..44c97962c3 100644 --- a/packages/template/src/components/stack-analytics.tsx +++ b/packages/template/src/components/stack-analytics.tsx @@ -1,11 +1,10 @@ "use client"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; import React, { useEffect, useMemo, useRef } from "react"; import { useStackApp } from "../lib/hooks"; import { stackAppInternalsSymbol } from "../lib/stack-app/common"; -import { clientVersion, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultPublishableClientKey } from "../lib/stack-app/apps/implementations/common"; export type AnalyticsReplayOptions = { enabled?: boolean, @@ -85,14 +84,11 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO // calls getUser() -> /users/me on every invocation (bypassing the cache). // These hooks subscribe to the cache and only trigger network requests when needed. const accessToken = app.useAccessToken(); - const refreshToken = app.useRefreshToken(); - // Refs so the effect closure always has the latest token values - // without needing tokens in the dependency array (which would restart recording). + // Ref so the effect closure always has the latest token value + // without needing it in the dependency array (which would restart recording). const accessTokenRef = useRef(accessToken); - const refreshTokenRef = useRef(refreshToken); accessTokenRef.current = accessToken; - refreshTokenRef.current = refreshToken; useEffect(() => { let cancelled = false; @@ -116,9 +112,7 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO }; const flush = async (options: { keepalive: boolean }) => { - const currentAccessToken = accessTokenRef.current; - const currentRefreshToken = refreshTokenRef.current; - if (!currentAccessToken) return; + if (!accessTokenRef.current) return; if (events.length === 0) return; const nowMs = Date.now(); @@ -137,30 +131,10 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO events = []; approxBytes = 0; - const constructorOptions = app[stackAppInternalsSymbol].getConstructorOptions(); - const baseUrl = getBaseUrl(constructorOptions.baseUrl); - const publishableClientKey = constructorOptions.publishableClientKey ?? getDefaultPublishableClientKey(); - const extraRequestHeaders = constructorOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(); - - const res = await Result.fromThrowingAsync(async () => { - return await fetch(new URL("/api/v1/session-recordings/batch", baseUrl), { - method: "POST", - credentials: "omit", - keepalive: options.keepalive, - headers: { - "content-type": "application/json", - "x-stack-project-id": app.projectId, - "x-stack-access-type": "client", - "x-stack-client-version": clientVersion, - "x-stack-access-token": currentAccessToken, - ...(currentRefreshToken ? { "x-stack-refresh-token": currentRefreshToken } : {}), - "x-stack-publishable-client-key": publishableClientKey, - "x-stack-allow-anonymous-user": "true", - ...extraRequestHeaders, - }, - body: JSON.stringify(payload), - }); - }); + const res = await app[stackAppInternalsSymbol].sendSessionRecordingBatch( + JSON.stringify(payload), + { keepalive: options.keepalive }, + ); if (res.status === "error") { // This is best-effort telemetry. Don't throw and break the app. diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index cf6e703cff..2e8c0429d3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -2830,6 +2830,9 @@ export class _StackClientAppImplIncomplete this._options, + sendSessionRecordingBatch: async (body: string, options: { keepalive: boolean }) => { + return await this._interface.sendSessionRecordingBatch(body, await this._getSession(), options); + }, sendRequest: async ( path: string, requestOptions: RequestInit, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 4f89ae2004..c425a088f0 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -93,6 +93,7 @@ export type StackClientApp, setCurrentUser(userJsonPromise: Promise): void, getConstructorOptions(): StackClientAppConstructorOptions & { inheritsFrom?: undefined }, + sendSessionRecordingBatch(body: string, options: { keepalive: boolean }): Promise>, }, } & AsyncStoreProperty<"project", [], Project, false> From 6e42342ebe4cdf534f4b741d38af8570f1796d71 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 10:37:04 -0800 Subject: [PATCH 11/21] sessions by refresh token, disable replays by default --- .../migration.sql | 15 +- apps/backend/prisma/schema.prisma | 6 +- .../chunks/[chunk_id]/events/route.tsx | 8 +- .../[session_recording_id]/chunks/route.tsx | 3 + .../latest/session-recordings/batch/route.tsx | 54 ++++-- apps/dashboard/src/app/layout.tsx | 2 +- .../api/v1/session-recordings.test.ts | 179 ++++++++++++------ docs/src/app/layout.tsx | 2 +- .../src/interface/crud/session-recordings.ts | 1 + .../src/components/stack-analytics.tsx | 30 ++- .../apps/implementations/admin-app-impl.ts | 1 + .../stack-app/apps/interfaces/admin-app.ts | 1 + .../src/providers/stack-provider-client.tsx | 4 +- .../template/src/providers/stack-provider.tsx | 4 +- 14 files changed, 205 insertions(+), 105 deletions(-) diff --git a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql index c3133af16f..80f61ace87 100644 --- a/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql +++ b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql @@ -13,11 +13,12 @@ CREATE TABLE "SessionRecording" ( ); CREATE TABLE "SessionRecordingChunk" ( - "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "id" UUID NOT NULL, "tenancyId" UUID NOT NULL, "sessionRecordingId" UUID NOT NULL, "batchId" UUID NOT NULL, "tabId" TEXT NOT NULL, + "browserSessionId" TEXT NOT NULL, "s3Key" TEXT NOT NULL, "eventCount" INTEGER NOT NULL, "byteLength" INTEGER NOT NULL, @@ -40,20 +41,20 @@ ALTER TABLE "SessionRecordingChunk" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "SessionRecordingChunk" - ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey" + ADD CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey" FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE; -CREATE UNIQUE INDEX "SessionRecording_tenancyId_refreshTokenId_key" - ON "SessionRecording"("tenancyId", "refreshTokenId"); - CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" ON "SessionRecording"("tenancyId", "projectUserId", "startedAt"); CREATE INDEX "SessionRecording_tenancyId_lastEventAt_idx" ON "SessionRecording"("tenancyId", "lastEventAt"); -CREATE UNIQUE INDEX "SessionRecordingChunk_sessionRecordingId_batchId_key" +CREATE INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx" + ON "SessionRecording"("tenancyId", "refreshTokenId", "updatedAt"); + +CREATE UNIQUE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key" ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "batchId"); -CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdAt_idx" +CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx" ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d2ef022357..1b1ba92e60 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -281,7 +281,6 @@ model ProjectUserOAuthAccount { } model SessionRecording { - // Cross-tab session id generated by the SDK and stored in localStorage. id String @db.Uuid tenancyId String @db.Uuid @@ -300,9 +299,9 @@ model SessionRecording { chunks SessionRecordingChunk[] @@id([tenancyId, id]) - @@unique([tenancyId, refreshTokenId]) @@index([tenancyId, projectUserId, startedAt]) @@index([tenancyId, lastEventAt]) + @@index([tenancyId, refreshTokenId, updatedAt]) } model SessionRecordingChunk { @@ -317,6 +316,9 @@ model SessionRecordingChunk { // Ephemeral in-memory id generated by the client. Stored for future tab separation if needed. tabId String + // Client-generated session id from localStorage, stored as metadata. + browserSessionId String + s3Key String eventCount Int byteLength Int diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx index 0d5b21e20c..d8d2aaf479 100644 --- a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx @@ -50,7 +50,7 @@ export const GET = createSmartRouteHandler({ let bytes: Uint8Array; try { - bytes = await downloadBytes({ key: chunk.s3Key }); + bytes = await downloadBytes({ key: chunk.s3Key, private: true }); } catch (e: any) { const status = e?.$metadata?.httpStatusCode; if (status === 404) { @@ -70,10 +70,10 @@ export const GET = createSmartRouteHandler({ if (typeof parsed !== "object" || parsed === null) { throw new StackAssertionError("Decoded session recording chunk is not an object"); } - if (parsed.session_id !== sessionRecordingId) { - throw new StackAssertionError("Decoded session recording chunk session_id mismatch", { + if (parsed.session_recording_id !== sessionRecordingId) { + throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", { expected: sessionRecordingId, - actual: parsed.session_id, + actual: parsed.session_recording_id, }); } if (!Array.isArray(parsed.events)) { diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx index 81c45dc9cd..fb5e9b71c3 100644 --- a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx @@ -30,6 +30,7 @@ export const GET = createSmartRouteHandler({ id: yupString().defined(), batch_id: yupString().defined(), tab_id: yupString().nullable().defined(), + browser_session_id: yupString().nullable().defined(), event_count: yupNumber().defined(), byte_length: yupNumber().defined(), first_event_at_millis: yupNumber().defined(), @@ -92,6 +93,7 @@ export const GET = createSmartRouteHandler({ id: true, batchId: true, tabId: true, + browserSessionId: true, eventCount: true, byteLength: true, firstEventAt: true, @@ -112,6 +114,7 @@ export const GET = createSmartRouteHandler({ id: c.id, batch_id: c.batchId, tab_id: c.tabId, + browser_session_id: c.browserSessionId, event_count: c.eventCount, byte_length: c.byteLength, first_event_at_millis: c.firstEventAt.getTime(), diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 7798545395..45a47c4c0b 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -5,6 +5,7 @@ import { Prisma } from "@/generated/prisma/client"; 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"; +import { randomUUID } from "node:crypto"; import { promisify } from "node:util"; import { gzip as gzipCb } from "node:zlib"; @@ -14,6 +15,7 @@ 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; function extractEventTimesMs(events: unknown[], fallbackMs: number) { let minTs = Infinity; @@ -48,7 +50,7 @@ export const POST = createSmartRouteHandler({ refreshTokenId: adaptSchema }).defined(), body: yupObject({ - session_id: yupString().defined().matches(UUID_RE, "Invalid session_id"), + browser_session_id: yupString().defined().matches(UUID_RE, "Invalid browser_session_id"), tab_id: yupString().defined().matches(UUID_RE, "Invalid tab_id"), batch_id: yupString().defined().matches(UUID_RE, "Invalid batch_id"), started_at_ms: yupNumber().defined().integer().min(0), @@ -60,7 +62,7 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ - session_id: yupString().defined(), + session_recording_id: yupString().defined(), batch_id: yupString().defined(), s3_key: yupString().defined(), deduped: yupMixed().defined(), @@ -87,35 +89,43 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, `Too many events (max ${MAX_EVENTS})`); } - const sessionId = body.session_id; + const browserSessionId = body.browser_session_id; const batchId = body.batch_id; const tabId = body.tab_id; const tenancyId = auth.tenancy.id; const projectId = auth.tenancy.project.id; const branchId = auth.tenancy.branchId; - const s3Key = `session-recordings/${projectId}/${branchId}/${sessionId}/${batchId}.json.gz`; const { firstMs, lastMs } = extractEventTimesMs(body.events, body.sent_at_ms); const prisma = await getPrismaClientForTenancy(auth.tenancy); - // Ensure the session row exists and is up-to-date. - const existingSession = await prisma.sessionRecording.findUnique({ - where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, - select: { startedAt: true, lastEventAt: true }, + // Find a recent session recording for this refresh token (temporal grouping). + // If the last batch arrived within SESSION_IDLE_TIMEOUT_MS, reuse that recording. + const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS); + const recentSession = await prisma.sessionRecording.findFirst({ + where: { + tenancyId, + refreshTokenId, + updatedAt: { gte: cutoff }, + }, + orderBy: { updatedAt: "desc" }, + select: { id: true, startedAt: true, lastEventAt: true }, }); - const newStartedAtMs = Math.min(existingSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs); - const newLastEventAtMs = Math.max(existingSession?.lastEventAt.getTime() ?? 0, lastMs); + + const recordingId = recentSession?.id ?? randomUUID(); + const s3Key = `session-recordings/${projectId}/${branchId}/${recordingId}/${batchId}.json.gz`; + + const newStartedAtMs = Math.min(recentSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs); + const newLastEventAtMs = Math.max(recentSession?.lastEventAt.getTime() ?? 0, lastMs); await prisma.sessionRecording.upsert({ - where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } }, + where: { tenancyId_id: { tenancyId, id: recordingId } }, create: { - id: sessionId, + id: recordingId, tenancyId, - projectUserId: projectUserId, + projectUserId, refreshTokenId, - // Use the first event timestamp instead of "session started" timestamps, - // since session_id can be reused across tabs/idle windows. startedAt: new Date(firstMs), lastEventAt: new Date(newLastEventAtMs), }, @@ -127,7 +137,7 @@ export const POST = createSmartRouteHandler({ // If we already have this batch for this session, return deduped without touching S3. const existingChunk = await prisma.sessionRecordingChunk.findUnique({ - where: { tenancyId_sessionRecordingId_batchId: { tenancyId, sessionRecordingId: sessionId, batchId } }, + where: { tenancyId_sessionRecordingId_batchId: { tenancyId, sessionRecordingId: recordingId, batchId } }, select: { s3Key: true }, }); if (existingChunk) { @@ -135,7 +145,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_id: sessionId, + session_recording_id: recordingId, batch_id: batchId, s3_key: existingChunk.s3Key, deduped: true, @@ -145,7 +155,8 @@ export const POST = createSmartRouteHandler({ const payload = { v: 1, - session_id: sessionId, + session_recording_id: recordingId, + browser_session_id: browserSessionId, tab_id: tabId, batch_id: batchId, started_at_ms: body.started_at_ms, @@ -167,9 +178,10 @@ export const POST = createSmartRouteHandler({ await prisma.sessionRecordingChunk.create({ data: { tenancyId, - sessionRecordingId: sessionId, + sessionRecordingId: recordingId, batchId, tabId, + browserSessionId, s3Key, eventCount: body.events.length, byteLength: gzipped.byteLength, @@ -183,7 +195,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_id: sessionId, + session_recording_id: recordingId, batch_id: batchId, s3_key: s3Key, deduped: true, @@ -197,7 +209,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_id: sessionId, + session_recording_id: recordingId, batch_id: batchId, s3_key: s3Key, deduped: false, diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index e09c44ca46..b12bcb6d19 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -99,7 +99,7 @@ export default function RootLayout({ - + diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts index 2423c87d86..b653647c4b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts @@ -3,7 +3,7 @@ import { it } from "../../../../helpers"; import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; async function uploadBatch(options: { - sessionId: string, + browserSessionId: string, batchId: string, startedAtMs: number, sentAtMs: number, @@ -14,8 +14,8 @@ async function uploadBatch(options: { method: "POST", accessType: "client", body: { - session_id: options.sessionId, - ...(options.tabId ? { tab_id: options.tabId } : {}), + browser_session_id: options.browserSessionId, + tab_id: options.tabId ?? randomUUID(), batch_id: options.batchId, started_at_ms: options.startedAtMs, sent_at_ms: options.sentAtMs, @@ -32,7 +32,8 @@ it("requires a user token", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: Date.now(), sent_at_ms: Date.now(), @@ -44,19 +45,20 @@ it("requires a user token", async ({ expect }) => { expect(res.status).toBeLessThan(500); }); -it("stores session recording batch metadata and dedupes by (session_id, batch_id)", async ({ expect }) => { +it("stores session recording batch metadata and dedupes by (session_recording_id, batch_id)", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); - const sessionId = randomUUID(); + const browserSessionId = randomUUID(); const batchId = randomUUID(); + const tabId = randomUUID(); const first = await niceBackendFetch("/api/v1/session-recordings/batch", { method: "POST", accessType: "client", body: { - session_id: sessionId, - tab_id: randomUUID(), + browser_session_id: browserSessionId, + tab_id: tabId, batch_id: batchId, started_at_ms: 1_700_000_000_000, sent_at_ms: 1_700_000_000_500, @@ -68,19 +70,21 @@ it("stores session recording batch metadata and dedupes by (session_id, batch_id }); expect(first.status).toBe(200); + expect(typeof first.body?.session_recording_id).toBe("string"); expect(first.body).toMatchObject({ - session_id: sessionId, batch_id: batchId, deduped: false, }); expect(typeof first.body?.s3_key).toBe("string"); - expect((first.body as any).s3_key).toContain(`/${sessionId}/${batchId}.json.gz`); + + const recordingId = first.body?.session_recording_id; const second = await niceBackendFetch("/api/v1/session-recordings/batch", { method: "POST", accessType: "client", body: { - session_id: sessionId, + browser_session_id: browserSessionId, + tab_id: tabId, batch_id: batchId, started_at_ms: 1_700_000_000_000, sent_at_ms: 1_700_000_000_500, @@ -90,11 +94,10 @@ it("stores session recording batch metadata and dedupes by (session_id, batch_id expect(second.status).toBe(200); expect(second.body).toMatchObject({ - session_id: sessionId, + session_recording_id: recordingId, batch_id: batchId, deduped: true, }); - expect((second.body as any).s3_key).toContain(`/${sessionId}/${batchId}.json.gz`); }); it("rejects empty events", async ({ expect }) => { @@ -105,7 +108,8 @@ it("rejects empty events", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: Date.now(), sent_at_ms: Date.now(), @@ -127,7 +131,8 @@ it("rejects too many events", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: 1_700_000_000_000, sent_at_ms: 1_700_000_000_100, @@ -139,7 +144,7 @@ it("rejects too many events", async ({ expect }) => { expect(res.status).toBeLessThan(500); }); -it("rejects invalid session_id", async ({ expect }) => { +it("rejects invalid browser_session_id", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); @@ -147,7 +152,8 @@ it("rejects invalid session_id", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: "not-a-uuid", + browser_session_id: "not-a-uuid", + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: Date.now(), sent_at_ms: Date.now(), @@ -167,7 +173,8 @@ it("rejects invalid batch_id", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: "not-a-uuid", started_at_ms: Date.now(), sent_at_ms: Date.now(), @@ -187,7 +194,7 @@ it("rejects invalid tab_id", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), tab_id: "not-a-uuid", batch_id: randomUUID(), started_at_ms: Date.now(), @@ -204,14 +211,15 @@ it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expe await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); - const sessionId = randomUUID(); + const browserSessionId = randomUUID(); const batchId = randomUUID(); const res = await niceBackendFetch("/api/v1/session-recordings/batch", { method: "POST", accessType: "client", body: { - session_id: sessionId, + browser_session_id: browserSessionId, + tab_id: randomUUID(), batch_id: batchId, started_at_ms: 1_700_000_000_000, sent_at_ms: 1_700_000_000_500, @@ -220,8 +228,8 @@ it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expe }); expect(res.status).toBe(200); + expect(typeof res.body?.session_recording_id).toBe("string"); expect(res.body).toMatchObject({ - session_id: sessionId, batch_id: batchId, deduped: false, }); @@ -235,7 +243,8 @@ it("rejects non-integer started_at_ms", async ({ expect }) => { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: 123.4, sent_at_ms: Date.now(), @@ -251,14 +260,15 @@ it("rejects oversized payloads", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); - // Backend limit is 2_000_000 bytes; a single large string is sufficient to exceed it. - const hugeString = "a".repeat(2_100_000); + // Backend limit is 5_000_000 bytes; a single large string is sufficient to exceed it. + const hugeString = "a".repeat(5_100_000); const res = await niceBackendFetch("/api/v1/session-recordings/batch", { method: "POST", accessType: "client", body: { - session_id: randomUUID(), + browser_session_id: randomUUID(), + tab_id: randomUUID(), batch_id: randomUUID(), started_at_ms: Date.now(), sent_at_ms: Date.now(), @@ -273,7 +283,7 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); - const sessionId = randomUUID(); + const browserSessionId = randomUUID(); const batchId = randomUUID(); const events = [ { type: 1, timestamp: 1_700_000_000_100, data: { a: 1 } }, @@ -281,13 +291,15 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ ]; const uploadRes = await uploadBatch({ - sessionId, + browserSessionId, batchId, startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_500, events, }); expect(uploadRes.status).toBe(200); + const recordingId = uploadRes.body?.session_recording_id; + expect(typeof recordingId).toBe("string"); const listRes = await niceBackendFetch("/api/v1/internal/session-recordings", { method: "GET", @@ -296,7 +308,7 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ expect(listRes.status).toBe(200); expect(listRes.body?.items?.length).toBeGreaterThanOrEqual(1); - const chunksRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${sessionId}/chunks`, { + const chunksRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${recordingId}/chunks`, { method: "GET", accessType: "admin", }); @@ -307,7 +319,7 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ throw new Error("Expected session recording chunks response to include an item id."); } - const eventsRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${sessionId}/chunks/${chunkId}/events`, { + const eventsRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${recordingId}/chunks/${chunkId}/events`, { method: "GET", accessType: "admin", }); @@ -317,25 +329,29 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ it("admin list session recordings paginates without skipping items", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); - await Auth.Otp.signIn(); - - const sessionA = randomUUID(); - const sessionB = randomUUID(); - await uploadBatch({ - sessionId: sessionA, + // Use separate sign-ins to get different refresh tokens → different session recordings. + await Auth.Otp.signIn(); + const uploadA = await uploadBatch({ + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_300, events: [{ type: 1, timestamp: 1_700_000_000_100 }], }); - await uploadBatch({ - sessionId: sessionB, + expect(uploadA.status).toBe(200); + const recordingA = uploadA.body?.session_recording_id; + + await Auth.Otp.signIn(); + const uploadB = await uploadBatch({ + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_400, events: [{ type: 1, timestamp: 1_700_000_000_200 }], }); + expect(uploadB.status).toBe(200); + const recordingB = uploadB.body?.session_recording_id; const first = await niceBackendFetch("/api/v1/internal/session-recordings?limit=1", { method: "GET", @@ -344,7 +360,7 @@ it("admin list session recordings paginates without skipping items", async ({ ex expect(first.status).toBe(200); expect(first.body?.items?.length).toBe(1); const firstId = first.body?.items?.[0]?.id; - expect([sessionA, sessionB]).toContain(firstId); + expect([recordingA, recordingB]).toContain(firstId); const nextCursor = first.body?.pagination?.next_cursor; expect(typeof nextCursor).toBe("string"); @@ -359,7 +375,7 @@ it("admin list session recordings paginates without skipping items", async ({ ex expect(second.status).toBe(200); expect(second.body?.items?.length).toBe(1); const secondId = second.body?.items?.[0]?.id; - expect([sessionA, sessionB]).toContain(secondId); + expect([recordingA, recordingB]).toContain(secondId); expect(secondId).not.toBe(firstId); }); @@ -379,35 +395,40 @@ it("admin list session recordings rejects unknown cursor", async ({ expect }) => it("admin list chunks paginates and rejects a cursor from another session", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); - await Auth.Otp.signIn(); - const session1 = randomUUID(); - const session2 = randomUUID(); - - await uploadBatch({ - sessionId: session1, + // session1: two batches under first refresh token + await Auth.Otp.signIn(); + const upload1a = await uploadBatch({ + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_500, events: [{ type: 1, timestamp: 1_700_000_000_010 }], }); + expect(upload1a.status).toBe(200); + const recording1 = upload1a.body?.session_recording_id; + await uploadBatch({ - sessionId: session1, + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_600, events: [{ type: 1, timestamp: 1_700_000_000_020 }], }); - await uploadBatch({ - sessionId: session2, + // session2: one batch under a different refresh token + await Auth.Otp.signIn(); + const upload2 = await uploadBatch({ + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_700, events: [{ type: 1, timestamp: 1_700_000_000_030 }], }); + expect(upload2.status).toBe(200); + const recording2 = upload2.body?.session_recording_id; - const first = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?limit=1`, { + const first = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?limit=1`, { method: "GET", accessType: "admin", }); @@ -420,7 +441,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn throw new Error("Expected next_cursor to be a string."); } - const second = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { + const second = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { method: "GET", accessType: "admin", }); @@ -429,7 +450,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn expect(second.body?.items?.[0]?.id).not.toBe(first.body?.items?.[0]?.id); // Cursor from another session should be rejected. - const otherChunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${session2}/chunks?limit=1`, { + const otherChunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording2}/chunks?limit=1`, { method: "GET", accessType: "admin", }); @@ -440,7 +461,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn throw new Error("Expected otherCursor to be a string."); } - const bad = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks?cursor=${encodeURIComponent(otherCursor)}`, { + const bad = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?cursor=${encodeURIComponent(otherCursor)}`, { method: "GET", accessType: "admin", }); @@ -450,28 +471,33 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn it("admin events endpoint does not allow fetching a chunk via the wrong session id", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); - await Auth.Otp.signIn(); - const session1 = randomUUID(); - const session2 = randomUUID(); + // session1: upload under first refresh token + await Auth.Otp.signIn(); const batchId = randomUUID(); - - await uploadBatch({ - sessionId: session1, + const upload1 = await uploadBatch({ + browserSessionId: randomUUID(), batchId, startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_500, events: [{ type: 1, timestamp: 1_700_000_000_010 }], }); - await uploadBatch({ - sessionId: session2, + expect(upload1.status).toBe(200); + const recording1 = upload1.body?.session_recording_id; + + // session2: upload under a different refresh token + await Auth.Otp.signIn(); + const upload2 = await uploadBatch({ + browserSessionId: randomUUID(), batchId: randomUUID(), startedAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_600, events: [{ type: 1, timestamp: 1_700_000_000_020 }], }); + expect(upload2.status).toBe(200); + const recording2 = upload2.body?.session_recording_id; - const chunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${session1}/chunks`, { + const chunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks`, { method: "GET", accessType: "admin", }); @@ -482,7 +508,7 @@ it("admin events endpoint does not allow fetching a chunk via the wrong session throw new Error("Expected chunk id."); } - const wrong = await niceBackendFetch(`/api/v1/internal/session-recordings/${session2}/chunks/${chunkId}/events`, { + const wrong = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording2}/chunks/${chunkId}/events`, { method: "GET", accessType: "admin", }); @@ -508,3 +534,30 @@ it("non-admin access cannot call internal session recordings endpoints", async ( expect(serverRes.status).toBeGreaterThanOrEqual(400); expect(serverRes.status).toBeLessThan(500); }); + +it("groups batches from same refresh token into one session recording", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Auth.Otp.signIn(); + + // Two batches with different browser_session_ids but same refresh token + const upload1 = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_300, + events: [{ type: 1, timestamp: 1_700_000_000_100 }], + }); + expect(upload1.status).toBe(200); + + const upload2 = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_400, + events: [{ type: 1, timestamp: 1_700_000_000_200 }], + }); + expect(upload2.status).toBe(200); + + // Same refresh token within idle timeout → same session recording + expect(upload1.body?.session_recording_id).toBe(upload2.body?.session_recording_id); +}); diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index 0eb021ca2f..c7f3c2199e 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { - + ` elements. + * + * @default true + */ maskAllInputs?: boolean, + /** + * A CSS class name or RegExp. Elements with a matching class will be blocked + * (replaced with a placeholder in the recording). + * + * @default undefined + */ blockClass?: string | RegExp, + /** + * A CSS selector string. Elements matching this selector will be blocked + * (replaced with a placeholder in the recording). + * + * @default undefined + */ blockSelector?: string, } export type AnalyticsOptions = { + /** + * Options for session replay recording. Replays are disabled by default; + * set `enabled: true` to opt in. + */ replays?: AnalyticsReplayOptions, } @@ -78,7 +104,7 @@ function getOrRotateSession(options: { key: string, nowMs: number }): StoredSess export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayOptions }) { const app = useStackApp(); - const tabId = useMemo(() => generateUuid(), []); + const tabId = useMemo(() => isBrowser() ? crypto.randomUUID() : "", []); // Use reactive hooks for tokens instead of app.getAccessToken() which // calls getUser() -> /users/me on every invocation (bypassing the cache). @@ -120,7 +146,7 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO const batchId = generateUuid(); const payload = { - session_id: stored.session_id, + browser_session_id: stored.session_id, tab_id: tabId, batch_id: batchId, started_at_ms: stored.created_at_ms, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 0b8d1614cf..0bee22f50b 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -992,6 +992,7 @@ export class _StackAdminAppImplIncomplete - {props.analytics?.replays?.enabled !== false ? : null} + {props.analytics?.replays?.enabled === true ? : null} {props.children} ); diff --git a/packages/template/src/providers/stack-provider.tsx b/packages/template/src/providers/stack-provider.tsx index 7cd9cd3f3d..c3184f0e5f 100644 --- a/packages/template/src/providers/stack-provider.tsx +++ b/packages/template/src/providers/stack-provider.tsx @@ -24,7 +24,7 @@ function NextStackProvider({ // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp | StackServerApp | StackAdminApp, /** - * Options for analytics and session recording. When omitted, replays are enabled with all inputs masked. + * Options for analytics and session recording. Replays are disabled by default. */ analytics?: AnalyticsOptions, }) { @@ -57,7 +57,7 @@ function ReactStackProvider({ // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp, /** - * Options for analytics and session recording. When omitted, replays are enabled with all inputs masked. + * Options for analytics and session recording. Replays are disabled by default. */ analytics?: AnalyticsOptions, }) { From 659c561a141767f19fa70cf212e069b770c3fc71 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 10:45:23 -0800 Subject: [PATCH 12/21] fix lint --- docs/src/app/layout.tsx | 2 +- packages/template/src/providers/stack-provider-client.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index c7f3c2199e..45cb06ca20 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { - + - {props.analytics?.replays?.enabled === true ? : null} + {props.analytics?.replays?.enabled === true ? : null} {props.children} ); From 36ab8ffbde185e6106eba23fd85072e0c6668a13 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 11:34:31 -0800 Subject: [PATCH 13/21] remove sot test --- .../e2e-source-of-truth-api-tests.yaml | 178 ------------------ 1 file changed, 178 deletions(-) delete mode 100644 .github/workflows/e2e-source-of-truth-api-tests.yaml diff --git a/.github/workflows/e2e-source-of-truth-api-tests.yaml b/.github/workflows/e2e-source-of-truth-api-tests.yaml deleted file mode 100644 index 46f5cebfc5..0000000000 --- a/.github/workflows/e2e-source-of-truth-api-tests.yaml +++ /dev/null @@ -1,178 +0,0 @@ -name: Runs E2E API Tests with external source of truth - -on: - push: - branches: - - main - - dev - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} - -jobs: - build: - runs-on: ubicloud-standard-8 - env: - NODE_ENV: test - STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes - STACK_ACCESS_TOKEN_EXPIRATION_TIME: 30m - STACK_OVERRIDE_SOURCE_OF_TRUTH: '{"type": "postgres", "connectionString": "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db?schema=sot-schema"}' - STACK_TEST_SOURCE_OF_TRUTH: true - STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe" - STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000" - STACK_EXTERNAL_DB_SYNC_DIRECT: "false" - - strategy: - matrix: - node-version: [22.x] - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - # Even just starting the Docker Compose as a daemon is slow because we have to download and build the images - # so, we run it in the background - - name: Start Docker Compose in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: docker compose -f docker/dependencies/docker.compose.yaml up -d & - # we don't need to wait on anything, just need to start the daemon - wait-on: /dev/null - tail: true - wait-for: 3s - log-output-if: true - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Create .env.test.local file for apps/backend - run: cp apps/backend/.env.development apps/backend/.env.test.local - - - name: Create .env.test.local file for apps/dashboard - run: cp apps/dashboard/.env.development apps/dashboard/.env.test.local - - - name: Create .env.test.local file for apps/e2e - run: cp apps/e2e/.env.development apps/e2e/.env.test.local - - - name: Create .env.test.local file for docs - run: cp docs/.env.development docs/.env.test.local - - - name: Create .env.test.local file for examples/cjs-test - run: cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local - - - name: Create .env.test.local file for examples/demo - run: cp examples/demo/.env.development examples/demo/.env.test.local - - - name: Create .env.test.local file for examples/docs-examples - run: cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local - - - name: Create .env.test.local file for examples/e-commerce - run: cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local - - - name: Create .env.test.local file for examples/middleware - run: cp examples/middleware/.env.development examples/middleware/.env.test.local - - - name: Create .env.test.local file for examples/supabase - run: cp examples/supabase/.env.development examples/supabase/.env.test.local - - - name: Create .env.test.local file for examples/convex - run: cp examples/convex/.env.development examples/convex/.env.test.local - - - name: Build - run: pnpm build - - - name: Wait on Postgres - run: pnpm run wait-until-postgres-is-ready:pg_isready - - - name: Wait on Inbucket - run: pnpx wait-on tcp:localhost:8129 - - - name: Wait on Svix - run: pnpx wait-on tcp:localhost:8113 - - - name: Wait on QStash - run: pnpx wait-on tcp:localhost:8125 - - - name: Create source-of-truth database and schema - run: | - psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/postgres -c "CREATE DATABASE \"source-of-truth-db\";" - psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/source-of-truth-db -c "CREATE SCHEMA \"sot-schema\";" - - - name: Initialize database - run: pnpm run db:init - - - name: Start stack-backend in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:backend --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start stack-dashboard in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:dashboard --log-order=stream & - wait-on: | - http://localhost:8101 - tail: true - wait-for: 30s - log-output-if: true - - name: Start mock-oauth-server in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm run start:mock-oauth-server --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start run-email-queue in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm -C apps/backend run run-email-queue --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - name: Start run-cron-jobs in background - uses: JarvusInnovations/background-action@v1.0.7 - with: - run: pnpm -C apps/backend run run-cron-jobs --log-order=stream & - wait-on: | - http://localhost:8102 - tail: true - wait-for: 30s - log-output-if: true - - - name: Wait 10 seconds - run: sleep 10 - - - name: Run tests - run: pnpm test run --exclude "**/external-db-sync*.test.ts" # external-db-sync does not support external sot - - - name: Run tests again (attempt 1) - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' - run: pnpm test run --exclude "**/external-db-sync*.test.ts" - - - name: Run tests again (attempt 2) - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' - run: pnpm test run --exclude "**/external-db-sync*.test.ts" - - - name: Verify data integrity - run: pnpm run verify-data-integrity --no-bail - - - name: Print Docker Compose logs - if: always() - run: docker compose -f docker/dependencies/docker.compose.yaml logs From c3121766eba096bc60932bb85d090770e337a70e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 12:48:38 -0800 Subject: [PATCH 14/21] improve chunk fetching logic --- .../analytics/replays/page-client.tsx | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index caab179952..d38366386c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -176,27 +176,44 @@ async function fetchChunkEventsForStreamsParallel( genRef: React.MutableRefObject, onChunkLoaded: (tabKey: TabKey, chunkIndex: number, events: RrwebEventWithTime[]) => void, ) { - // Important: prioritize each stream's earliest chunks so every tab can - // initialize quickly (FullSnapshot is typically in chunk 0). - const tasks: Array<{ tabKey: TabKey, chunkIndex: number, chunkId: string }> = []; + // Chunk 0 of every tab is loaded first — it contains the FullSnapshot that + // rrweb needs before it can render anything. Loading them upfront also + // prevents the active tab's later chunks from accumulating in memory before + // the rrweb module has been imported: if all of the active tab's events are + // present when the Replayer is constructed, rrweb's skipInactive option can + // fast-forward through inactivity gaps instead of letting the custom gap + // handler manage them gradually. + // After all chunk-0s, remaining chunks are sorted by firstEventAt so the + // data needed soonest during playback arrives first. + const tasks: Array<{ tabKey: TabKey, chunkIndex: number, chunkId: string, firstEventAtMs: number }> = []; const resultsByTab = new Map>(); const reportedIndexByTab = new Map(); - let maxChunks = 0; for (const s of streams) { resultsByTab.set(s.tabKey, new Array(s.chunks.length).fill(null)); reportedIndexByTab.set(s.tabKey, 0); - maxChunks = Math.max(maxChunks, s.chunks.length); - } - - for (let chunkIndex = 0; chunkIndex < maxChunks; chunkIndex++) { - for (const s of streams) { - const c = s.chunks[chunkIndex] as ChunkRow | undefined; - if (!c) continue; - tasks.push({ tabKey: s.tabKey, chunkIndex, chunkId: c.id }); + for (let chunkIndex = 0; chunkIndex < s.chunks.length; chunkIndex++) { + tasks.push({ + tabKey: s.tabKey, + chunkIndex, + chunkId: s.chunks[chunkIndex].id, + firstEventAtMs: s.chunks[chunkIndex].firstEventAt.getTime(), + }); } } + tasks.sort((a, b) => { + // Chunk 0 of each tab always comes first (FullSnapshot + prevents + // skipInactive from seeing the full timeline at Replayer init time). + const a0 = a.chunkIndex === 0 ? 0 : 1; + const b0 = b.chunkIndex === 0 ? 0 : 1; + if (a0 !== b0) return a0 - b0; + // Within each priority tier, sort by temporal order (earliest first). + if (a.firstEventAtMs !== b.firstEventAtMs) return a.firstEventAtMs - b.firstEventAtMs; + // Tiebreaker: lower chunk index first. + return a.chunkIndex - b.chunkIndex; + }); + let nextTaskIndex = 0; async function worker() { @@ -741,6 +758,9 @@ export default function PageClient() { try { const { Replayer } = await import("rrweb"); if (selectionGenRef.current !== gen) return; + // Re-check after the async import: another call may have created the + // replayer while the module was loading. + if (replayerByTabRef.current.has(tabKey)) return; const eventsSnapshot2 = eventsByTabRef.current.get(tabKey)?.slice() ?? []; if (eventsSnapshot2.length === 0) return; From 5e1e828c2da1c30f92d945af7789720e1b6b6447 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 14:39:12 -0800 Subject: [PATCH 15/21] max session time, improved replayer testing --- .../latest/session-recordings/batch/route.tsx | 4 + .../analytics/replays/page-client.tsx | 1203 +++++--------- .../replays/session-replay-machine.test.ts | 1388 +++++++++++++++++ .../replays/session-replay-machine.ts | 995 ++++++++++++ .../replays/session-replay-playback.test.ts | 181 +++ 5 files changed, 2959 insertions(+), 812 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-playback.test.ts diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 45a47c4c0b..7606478ee4 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -16,6 +16,7 @@ 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; @@ -103,12 +104,15 @@ export const POST = createSmartRouteHandler({ // Find a recent session recording for this refresh token (temporal grouping). // If the last batch arrived within SESSION_IDLE_TIMEOUT_MS, reuse that recording. + // Also enforce a max session duration so recordings 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.sessionRecording.findFirst({ where: { tenancyId, refreshTokenId, updatedAt: { gte: cutoff }, + startedAt: { gte: maxDurationCutoff }, }, orderBy: { updatedAt: "desc" }, select: { id: true, startedAt: true, lastEventAt: true }, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index d38366386c..cfa7828f63 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -4,16 +4,13 @@ import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skelet import { useFromNow } from "@/hooks/use-from-now"; import { getDesiredGlobalOffsetFromPlaybackState, - getReplayFinishAction, INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, - applySeekState, } from "@/lib/session-replay-playback"; import type { TabKey, TabStream } from "@/lib/session-replay-streams"; import { computeGlobalTimeline, globalOffsetToLocalOffset, groupChunksIntoTabStreams, - localOffsetToGlobalOffset, NULL_TAB_KEY, } from "@/lib/session-replay-streams"; import { cn } from "@/lib/utils"; @@ -25,6 +22,17 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; +import { + createInitialState, + replayReducer, + ALLOWED_PLAYER_SPEEDS, + type ReplaySettings, + type ReplayState, + type ReplayAction, + type ReplayEffect, + type StreamInfo, + type ChunkRange, +} from "./session-replay-machine"; const PAGE_SIZE = 50; const CHUNK_PAGE_SIZE = 250; @@ -32,18 +40,6 @@ const CHUNK_EVENTS_CONCURRENCY = 8; const EXTRA_TABS_TO_SHOW = 2; const REPLAY_SETTINGS_STORAGE_KEY = "stack.session-replay.settings"; const LEGACY_PLAYER_SPEED_STORAGE_KEY = "stack.session-replay.speed"; -const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); -const DEFAULT_REPLAY_SETTINGS = { - playerSpeed: 1, - skipInactivity: true, - followActiveTab: false, -} as const; - -type ReplaySettings = { - playerSpeed: number, - skipInactivity: boolean, - followActiveTab: boolean, -}; type RrwebEventWithTime = import("rrweb/typings/types").eventWithTime; type RrwebReplayer = InstanceType; @@ -111,6 +107,10 @@ function formatTimelineMs(ms: number) { const totalSeconds = Math.floor(ms / 1000); const m = Math.floor(totalSeconds / 60); const s = totalSeconds % 60; + if (m >= 60) { + const h = Math.floor(m / 60); + return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } return `${m}:${s.toString().padStart(2, "0")}`; } @@ -136,19 +136,19 @@ function parseReplaySettings(raw: string): ReplaySettings | null { const playerSpeedRaw = value.playerSpeed; const playerSpeed = typeof playerSpeedRaw === "number" && ALLOWED_PLAYER_SPEEDS.has(playerSpeedRaw) ? playerSpeedRaw - : DEFAULT_REPLAY_SETTINGS.playerSpeed; + : 1; const skipInactivity = typeof value.skipInactivity === "boolean" ? value.skipInactivity - : DEFAULT_REPLAY_SETTINGS.skipInactivity; + : true; const followActiveTab = typeof value.followActiveTab === "boolean" ? value.followActiveTab - : DEFAULT_REPLAY_SETTINGS.followActiveTab; + : false; return { playerSpeed, skipInactivity, followActiveTab }; } function getInitialReplaySettings(): ReplaySettings { - if (typeof window === 'undefined') return DEFAULT_REPLAY_SETTINGS; + if (typeof window === 'undefined') return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; try { const rawSettings = localStorage.getItem(REPLAY_SETTINGS_STORAGE_KEY); if (rawSettings) { @@ -159,32 +159,22 @@ function getInitialReplaySettings(): ReplaySettings { if (rawLegacySpeed) { const legacySpeed = Number(rawLegacySpeed); if (Number.isFinite(legacySpeed) && ALLOWED_PLAYER_SPEEDS.has(legacySpeed)) { - return { ...DEFAULT_REPLAY_SETTINGS, playerSpeed: legacySpeed }; + return { playerSpeed: legacySpeed, skipInactivity: true, followActiveTab: false }; } } } catch { // ignore } - return DEFAULT_REPLAY_SETTINGS; + return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; } async function fetchChunkEventsForStreamsParallel( adminApp: AdminAppWithSessionRecordings, recordingId: string, streams: Array<{ tabKey: TabKey, chunks: ChunkRow[] }>, - gen: number, - genRef: React.MutableRefObject, + isStale: () => boolean, onChunkLoaded: (tabKey: TabKey, chunkIndex: number, events: RrwebEventWithTime[]) => void, ) { - // Chunk 0 of every tab is loaded first — it contains the FullSnapshot that - // rrweb needs before it can render anything. Loading them upfront also - // prevents the active tab's later chunks from accumulating in memory before - // the rrweb module has been imported: if all of the active tab's events are - // present when the Replayer is constructed, rrweb's skipInactive option can - // fast-forward through inactivity gaps instead of letting the custom gap - // handler manage them gradually. - // After all chunk-0s, remaining chunks are sorted by firstEventAt so the - // data needed soonest during playback arrives first. const tasks: Array<{ tabKey: TabKey, chunkIndex: number, chunkId: string, firstEventAtMs: number }> = []; const resultsByTab = new Map>(); const reportedIndexByTab = new Map(); @@ -203,14 +193,10 @@ async function fetchChunkEventsForStreamsParallel( } tasks.sort((a, b) => { - // Chunk 0 of each tab always comes first (FullSnapshot + prevents - // skipInactive from seeing the full timeline at Replayer init time). const a0 = a.chunkIndex === 0 ? 0 : 1; const b0 = b.chunkIndex === 0 ? 0 : 1; if (a0 !== b0) return a0 - b0; - // Within each priority tier, sort by temporal order (earliest first). if (a.firstEventAtMs !== b.firstEventAtMs) return a.firstEventAtMs - b.firstEventAtMs; - // Tiebreaker: lower chunk index first. return a.chunkIndex - b.chunkIndex; }); @@ -218,10 +204,10 @@ async function fetchChunkEventsForStreamsParallel( async function worker() { while (nextTaskIndex < tasks.length) { - if (genRef.current !== gen) return; + if (isStale()) return; const task = tasks[nextTaskIndex++]; const ev = await adminApp.getSessionRecordingChunkEvents(recordingId, task.chunkId); - if (genRef.current !== gen) return; + if (isStale()) return; const results = resultsByTab.get(task.tabKey) ?? []; results[task.chunkIndex] = coerceRrwebEvents(ev.events); @@ -385,11 +371,34 @@ function ReplaySettingsButton({ ); } +// --------------------------------------------------------------------------- +// Machine hook — wraps the pure reducer with a React-friendly interface. +// Returns `state` (for rendering) and `stateRef` (for callbacks). +// --------------------------------------------------------------------------- + +function useReplayMachine(initialSettings: ReplaySettings) { + const stateRef = useRef(createInitialState(initialSettings)); + const [, forceRender] = useState(0); + + const dispatch = useCallback((action: ReplayAction): ReplayEffect[] => { + const { state, effects } = replayReducer(stateRef.current, action); + stateRef.current = state; + forceRender(v => v + 1); + return effects; + }, []); + + return { state: stateRef.current, stateRef, dispatch }; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + export default function PageClient() { - // @stackframe/stack's public `StackAdminApp` type is missing the session replay - // methods in `packages/stack/dist/index.d.mts`, but the runtime app object has them. const adminApp = useAdminApp() as AdminAppWithSessionRecordings; + // ---- Recording list state (unchanged from original) ---- + const [recordings, setRecordings] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [loadingInitial, setLoadingInitial] = useState(true); @@ -404,7 +413,6 @@ export default function PageClient() { [recordings, selectedRecordingId], ); - const selectionGenRef = useRef(0); const hasAutoSelectedRef = useRef(false); const hasFetchedInitialRef = useRef(false); @@ -453,92 +461,39 @@ export default function PageClient() { } }, [loadingInitial, loadingMore, loadPage, nextCursor]); - // Player + download state - const [downloadError, setDownloadError] = useState(null); - const [isDownloading, setIsDownloading] = useState(false); - const isDownloadingRef = useRef(false); - isDownloadingRef.current = isDownloading; - - const [streams, setStreams] = useState[]>([]); - const streamsByKeyRef = useRef>>(new Map()); - const streamsRef = useRef[]>([]); - const [activeTabKey, setActiveTabKey] = useState(null); - const activeTabKeyRef = useRef(null); - const setActiveTab = useCallback((key: TabKey | null) => { - activeTabKeyRef.current = key; - setActiveTabKey(key); - }, []); + // ---- Replay state machine ---- + + const { state: ms, stateRef: msRef, dispatch: rawDispatch } = useReplayMachine(getInitialReplaySettings()); + + // ---- DOM / rrweb refs (not managed by machine) ---- - const globalStartTsRef = useRef(0); - const [globalTotalTimeMs, setGlobalTotalTimeMs] = useState(0); - const globalTotalTimeMsRef = useRef(0); - // Inter-tab gap transition: animate timeline forward faster than realtime - // until the next tab starts. - const gapFastForwardRef = useRef<{ - fromGlobalMs: number, - toGlobalMs: number, - wallMs: number, - nextTabKey: TabKey, - gen: number, - } | null>(null); const eventsByTabRef = useRef>(new Map()); - const loadedDurationByTabMsRef = useRef>(new Map()); - const chunkRangesByTabRef = useRef>>(new Map()); const containerByTabRef = useRef>(new Map()); const replayerByTabRef = useRef>(new Map()); const replayerRootByTabRef = useRef>(new Map()); const resizeObserverByTabRef = useRef>(new Map()); const pendingInitByTabRef = useRef>(new Set()); + const speedSubRef = useRef<{ unsubscribe: () => void } | null>(null); - const tabLabelIndexByKeyRef = useRef>(new Map()); - // Tracks which tabs have a FullSnapshot event (rrweb type 2). - // Tabs without one render as blank white screens. - const hasFullSnapshotByTabRef = useRef>(new Set()); - - const [uiVersion, setUiVersion] = useState(0); + // Full TabStream objects for rendering (machine only stores StreamInfo). + const [fullStreams, setFullStreams] = useState[]>([]); + const fullStreamsRef = useRef[]>([]); - const [playerError, setPlayerError] = useState(null); - const [replaySettings, setReplaySettings] = useState(getInitialReplaySettings); - const replaySettingsRef = useRef(replaySettings); - useEffect(() => { - replaySettingsRef.current = replaySettings; - }, [replaySettings]); + // Generation counter for staleness checks in async operations. + const genCounterRef = useRef(0); - const playerSpeed = replaySettings.playerSpeed; - const playerSpeedRef = useRef(playerSpeed); - useEffect(() => { - playerSpeedRef.current = playerSpeed; - }, [playerSpeed]); + // ---- UI-only state ---- + const [isSkipping, setIsSkipping] = useState(false); + const [uiVersion, setUiVersion] = useState(0); - useEffect(() => { - localStorage.setItem(REPLAY_SETTINGS_STORAGE_KEY, JSON.stringify(replaySettings)); - }, [replaySettings]); + // ---- Derived values ---- - const [playerIsPlaying, setPlayerIsPlaying] = useState(false); - const playerIsPlayingRef = useRef(false); - useEffect(() => { - playerIsPlayingRef.current = playerIsPlaying; - }, [playerIsPlaying]); + const playerIsPlaying = ms.playbackMode === "playing" || ms.playbackMode === "gap_fast_forward"; + const isBuffering = ms.playbackMode === "buffering"; + const replayFinished = ms.playbackMode === "finished"; + const isDownloading = ms.phase === "downloading"; - const [isSkipping, setIsSkipping] = useState(false); - const speedSubRef = useRef<{ unsubscribe: () => void } | null>(null); - - const pausedAtGlobalRef = useRef(0); - const [currentGlobalTimeMsForUi, setCurrentGlobalTimeMsForUi] = useState(0); - const currentGlobalTimeMsForUiRef = useRef(0); - useEffect(() => { - currentGlobalTimeMsForUiRef.current = currentGlobalTimeMsForUi; - }, [currentGlobalTimeMsForUi]); - const [isBuffering, setIsBuffering] = useState(false); - const isBufferingRef = useRef(false); - useEffect(() => { - isBufferingRef.current = isBuffering; - }, [isBuffering]); - const bufferingAtGlobalRef = useRef(null); - const autoResumeAfterBufferingRef = useRef(false); - const autoPlayTriggeredRef = useRef(false); - const suppressAutoFollowUntilRef = useRef(0); - const [replayFinished, setReplayFinished] = useState(false); + // ---- Imperative helpers ---- const destroyReplayers = useCallback(() => { for (const obs of resizeObserverByTabRef.current.values()) { @@ -564,179 +519,13 @@ export default function PageClient() { } }, []); - useEffect(() => { - streamsRef.current = streams; - }, [streams]); - - const resetReplayState = useCallback(() => { - setDownloadError(null); - setIsDownloading(false); - setStreams([]); - streamsByKeyRef.current = new Map(); - setActiveTab(null); - globalStartTsRef.current = 0; - setGlobalTotalTimeMs(0); - globalTotalTimeMsRef.current = 0; - gapFastForwardRef.current = null; - eventsByTabRef.current = new Map(); - loadedDurationByTabMsRef.current = new Map(); - chunkRangesByTabRef.current = new Map(); - pendingInitByTabRef.current = new Set(); - setPlayerError(null); - setPlayerIsPlaying(false); - setIsSkipping(false); - setIsBuffering(false); - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - pausedAtGlobalRef.current = 0; - autoPlayTriggeredRef.current = false; - tabLabelIndexByKeyRef.current = new Map(); - hasFullSnapshotByTabRef.current = new Set(); - setReplayFinished(false); - setCurrentGlobalTimeMsForUi(0); - setUiVersion(v => v + 1); - destroyReplayers(); - }, [destroyReplayers, setActiveTab]); - - const findBestTabAtGlobalOffset = useCallback((globalOffsetMs: number, excludeTabKey?: TabKey) => { - const ts = globalStartTsRef.current + globalOffsetMs; - const candidates = streamsRef.current.filter((s) => { - if (excludeTabKey && s.tabKey === excludeTabKey) return false; - // Skip tabs without a FullSnapshot — they render as blank. - if (!hasFullSnapshotByTabRef.current.has(s.tabKey)) return false; - const ranges = chunkRangesByTabRef.current.get(s.tabKey) ?? []; - // Ranges are sorted by startTs. - let lo = 0; - let hi = ranges.length - 1; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - const r = ranges[mid]!; - if (ts < r.startTs) { - hi = mid - 1; - } else if (ts > r.endTs) { - lo = mid + 1; - } else { - return true; - } - } - return false; - }); - if (candidates.length === 0) return null; + // ---- act: dispatch action to machine + execute returned effects ---- + // Uses a ref to break circular dependency with ensureReplayerForTab. - candidates.sort((a, b) => { - const aLabel = tabLabelIndexByKeyRef.current.get(a.tabKey) ?? Number.POSITIVE_INFINITY; - const bLabel = tabLabelIndexByKeyRef.current.get(b.tabKey) ?? Number.POSITIVE_INFINITY; - if (aLabel !== bLabel) return aLabel - bLabel; - return stringCompare(a.tabKey, b.tabKey); - }); - - return candidates[0]!.tabKey; - }, []); - - const isTabInRangeAtGlobalOffset = useCallback((tabKey: TabKey, globalOffsetMs: number): boolean => { - if (!hasFullSnapshotByTabRef.current.has(tabKey)) return false; - const ts = globalStartTsRef.current + globalOffsetMs; - const ranges = chunkRangesByTabRef.current.get(tabKey) ?? []; - let lo = 0; - let hi = ranges.length - 1; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - const r = ranges[mid]!; - if (ts < r.startTs) { - hi = mid - 1; - } else if (ts > r.endTs) { - lo = mid + 1; - } else { - return true; - } - } - return false; - }, []); - - const findNextTabStartAfterGlobalOffset = useCallback((globalOffsetMs: number) => { - const ts = globalStartTsRef.current + globalOffsetMs; - let bestStartTs = Infinity; - let bestKey: TabKey | null = null; - - for (const s of streamsRef.current) { - if (!hasFullSnapshotByTabRef.current.has(s.tabKey)) continue; - const ranges = chunkRangesByTabRef.current.get(s.tabKey) ?? []; - for (const r of ranges) { - if (r.startTs <= ts) continue; - if (r.startTs < bestStartTs) { - bestStartTs = r.startTs; - bestKey = s.tabKey; - } - break; // ranges sorted by start - } - } - - if (!bestKey || !Number.isFinite(bestStartTs)) return null; - return { - tabKey: bestKey, - globalOffsetMs: bestStartTs - globalStartTsRef.current, - }; - }, []); - - const getDesiredGlobalOffsetMs = useCallback(() => { - const key = activeTabKeyRef.current; - const r = key ? (replayerByTabRef.current.get(key) ?? null) : null; - const s = key ? (streamsByKeyRef.current.get(key) ?? null) : null; - let activeLocalOffsetMs: number | null = null; - let activeStreamStartTs: number | null = null; - if (r && s) { - activeStreamStartTs = s.firstEventAt.getTime(); - try { - activeLocalOffsetMs = r.getCurrentTime(); - } catch { - activeLocalOffsetMs = null; - } - } - - return getDesiredGlobalOffsetFromPlaybackState({ - gapFastForward: gapFastForwardRef.current, - playerIsPlaying: playerIsPlayingRef.current, - nowMs: performance.now(), - playerSpeed: playerSpeedRef.current, - pausedAtGlobalMs: pausedAtGlobalRef.current, - activeLocalOffsetMs, - activeStreamStartTs, - globalStartTs: globalStartTsRef.current, - gapFastForwardMultiplier: INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, - }); - }, []); - - const pauseAll = useCallback(() => { - for (const r of replayerByTabRef.current.values()) { - try { - r.pause(); - } catch { - // ignore - } - } - }, []); - - const playActiveAtGlobalOffset = useCallback((globalOffsetMs: number) => { - gapFastForwardRef.current = null; - const activeKey = activeTabKeyRef.current; - for (const [tabKey, r] of replayerByTabRef.current.entries()) { - const stream = streamsByKeyRef.current.get(tabKey); - const streamStartTs = stream?.firstEventAt.getTime() ?? globalStartTsRef.current; - const localOffset = globalOffsetToLocalOffset(globalStartTsRef.current, streamStartTs, globalOffsetMs); - try { - if (tabKey === activeKey) { - r.play(localOffset); - } else { - r.pause(localOffset); - } - } catch { - // ignore - } - } - }, []); + const actRef = useRef<(action: ReplayAction) => void>(() => {}); const ensureReplayerForTab = useCallback(async (tabKey: TabKey, gen: number) => { - if (selectionGenRef.current !== gen) return; + if (msRef.current.generation !== gen) return; if (replayerByTabRef.current.has(tabKey)) return; const rootMaybe = containerByTabRef.current.get(tabKey) ?? null; @@ -752,14 +541,11 @@ export default function PageClient() { return; } - // Don't create a replayer for tabs without a FullSnapshot — they render blank. - if (!hasFullSnapshotByTabRef.current.has(tabKey)) return; + if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) return; try { const { Replayer } = await import("rrweb"); - if (selectionGenRef.current !== gen) return; - // Re-check after the async import: another call may have created the - // replayer while the module was loading. + if (msRef.current.generation !== gen) return; if (replayerByTabRef.current.has(tabKey)) return; const eventsSnapshot2 = eventsByTabRef.current.get(tabKey)?.slice() ?? []; @@ -767,8 +553,8 @@ export default function PageClient() { const replayer = new Replayer(eventsSnapshot2, { root: rootEl, - speed: playerSpeedRef.current, - skipInactive: replaySettingsRef.current.skipInactivity, + speed: msRef.current.settings.playerSpeed, + skipInactive: msRef.current.settings.skipInactivity, }); rootEl.style.position = "relative"; @@ -782,7 +568,6 @@ export default function PageClient() { replayer.iframe.style.border = "0"; - // Mouse cursor styling (ensures it renders above the iframe consistently). const mouseEl = replayer.wrapper.querySelector(".replayer-mouse") as HTMLElement | null; if (mouseEl) { mouseEl.style.position = "absolute"; @@ -811,8 +596,7 @@ export default function PageClient() { const replayW = replayer.wrapper.offsetWidth; const replayH = replayer.wrapper.offsetHeight; if (replayW <= 0 || replayH <= 0 || cw <= 0 || ch <= 0) return; - const isActive = activeTabKeyRef.current === tabKey; - // Active tab: fit entire replay centered. Mini tabs: fill width, align top (overflow clipped). + const isActive = msRef.current.activeTabKey === tabKey; const scale = isActive ? Math.min(cw / replayW, ch / replayH) : (cw / replayW); const scaledW = replayW * scale; const scaledH = replayH * scale; @@ -834,46 +618,14 @@ export default function PageClient() { replayerRootByTabRef.current.set(tabKey, rootEl); pendingInitByTabRef.current.delete(tabKey); - const isActiveTab = activeTabKeyRef.current === tabKey; - const shouldAutoPlay = !autoPlayTriggeredRef.current && isActiveTab; - if (shouldAutoPlay) { - autoPlayTriggeredRef.current = true; - } - - // Seek this stream to the current global time and follow play/pause state. - // Only the active tab should be playing; others get seeked but paused. - // NOTE: The replayer is NOT yet registered in replayerByTabRef so that - // getDesiredGlobalOffsetMs() falls back to pausedAtGlobalRef (which holds - // the correct authoritative time) instead of reading getCurrentTime()=0 - // from the brand-new replayer. - const stream = streamsByKeyRef.current.get(tabKey) ?? null; - const streamStartTs = stream?.firstEventAt.getTime() ?? globalStartTsRef.current; - const desiredGlobal = getDesiredGlobalOffsetMs(); - const desiredLocal = globalOffsetToLocalOffset(globalStartTsRef.current, streamStartTs, desiredGlobal); - const shouldPlay = isActiveTab && (shouldAutoPlay || (playerIsPlayingRef.current && !isBufferingRef.current)); - try { - if (shouldPlay) { - replayer.play(desiredLocal); - } else { - replayer.pause(desiredLocal); - } - } catch { - // ignore - } - - // Register the replayer AFTER seeking so it doesn't pollute time readings. + // Register replayer BEFORE dispatching so effects can find it. replayerByTabRef.current.set(tabKey, replayer); - if (shouldAutoPlay && !isBufferingRef.current) { - playerIsPlayingRef.current = true; - setPlayerIsPlaying(true); - } - - // Detect when playback reaches the end of loaded events (active stream only). + // Finish handler — all logic is in the machine reducer. try { replayer.on("finish", () => { - if (selectionGenRef.current !== gen) return; - if (activeTabKeyRef.current !== tabKey) return; + if (msRef.current.generation !== gen) return; + if (msRef.current.activeTabKey !== tabKey) return; let localTime = 0; try { @@ -882,142 +634,175 @@ export default function PageClient() { // ignore } - // Guard against premature finish: rrweb fires "finish" when its - // internal timer exhausts events present at construction time. - // Events added later via addEvent() extend the array but the - // timer may already have stopped. Restart if more data exists. - const loadedDurationMs = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; - if (loadedDurationMs > localTime + 100) { + actRef.current({ + type: "REPLAYER_FINISH", + generation: gen, + tabKey, + localTimeMs: localTime, + nowMs: performance.now(), + }); + }); + } catch { + // ignore + } + + // Machine decides whether to play or pause this replayer. + actRef.current({ + type: "REPLAYER_READY", + generation: gen, + tabKey, + }); + + setUiVersion(v => v + 1); + } catch (e: any) { + actRef.current({ + type: "REPLAYER_INIT_ERROR", + generation: gen, + message: e?.message ?? "Failed to initialize rrweb player.", + }); + } + }, [msRef]); + + // Effect executor — maps machine effects to imperative DOM/rrweb calls. + function executeEffects(effects: ReplayEffect[]) { + for (const effect of effects) { + switch (effect.type) { + case "play_replayer": { + const r = replayerByTabRef.current.get(effect.tabKey); + if (r) { try { - replayer.play(localTime); + r.play(effect.localOffsetMs); } catch { // ignore } - return; } - - const stream2 = streamsByKeyRef.current.get(tabKey) ?? null; - const streamStartTs2 = stream2?.firstEventAt.getTime() ?? globalStartTsRef.current; - - // If still downloading and this tab is expected to have more - // events, buffer and poll for data. Must run BEFORE the tab- - // switching / gap-forward logic to prevent jumping away while - // the current tab's chunks are still in flight. - const tabExpectedDurationMs = stream2 - ? stream2.lastEventAt.getTime() - stream2.firstEventAt.getTime() - : null; - if (isDownloadingRef.current && tabExpectedDurationMs !== null && tabExpectedDurationMs > localTime + 500) { - const globalOffset = localOffsetToGlobalOffset(globalStartTsRef.current, streamStartTs2, localTime); - pausedAtGlobalRef.current = globalOffset; - setIsBuffering(true); - setPlayerIsPlaying(false); - - // Poll until enough buffer exists ahead, then resume. - // Using setTimeout avoids the rapid buffer→resume→finish loop - // that onChunkLoaded auto-resume can cause when loaded ≈ target. - const pollResume = () => { - if (selectionGenRef.current !== gen) return; - if (activeTabKeyRef.current !== tabKey) return; - if (!isBufferingRef.current) return; // user toggled play/pause - const newLoaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; - if (newLoaded > localTime + 2000 || !isDownloadingRef.current) { - setIsBuffering(false); - playActiveAtGlobalOffset(globalOffset); - setPlayerIsPlaying(true); - } else { - setTimeout(pollResume, 500); - } - }; - setTimeout(pollResume, 500); - return; + break; + } + case "pause_replayer_at": { + const r = replayerByTabRef.current.get(effect.tabKey); + if (r) { + try { + r.pause(effect.localOffsetMs); + } catch { + // ignore + } } - - let globalOffset = localOffsetToGlobalOffset(globalStartTsRef.current, streamStartTs2, localTime); - - // Find the best OTHER tab at this offset (exclude the exhausted tab). - let bestKey = findBestTabAtGlobalOffset(globalOffset, tabKey); - - // rrweb's finish callback can report a stale time from an earlier frame. - // If it is meaningfully behind the authoritative timeline, retry with - // the authoritative time. - if (!bestKey && globalOffset + 500 < currentGlobalTimeMsForUiRef.current) { - globalOffset = currentGlobalTimeMsForUiRef.current; - bestKey = findBestTabAtGlobalOffset(globalOffset, tabKey); + break; + } + case "pause_all": { + for (const r of replayerByTabRef.current.values()) { + try { + r.pause(); + } catch { + // ignore + } } - - // Another tab has events at this time — switch to it. - if (bestKey) { - const switchToKey = bestKey; - setActiveTab(switchToKey); - pausedAtGlobalRef.current = globalOffset; - setIsBuffering(false); - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - runAsynchronously(() => ensureReplayerForTab(switchToKey, gen), { noErrorLogging: true }); - playActiveAtGlobalOffset(globalOffset); - setPlayerIsPlaying(true); - suppressAutoFollowUntilRef.current = performance.now() + 400; - return; + break; + } + case "ensure_replayer": { + runAsynchronously(() => ensureReplayerForTab(effect.tabKey, effect.generation), { noErrorLogging: true }); + break; + } + case "destroy_all_replayers": { + destroyReplayers(); + eventsByTabRef.current = new Map(); + pendingInitByTabRef.current = new Set(); + setUiVersion(v => v + 1); + break; + } + case "set_replayer_speed": { + for (const r of replayerByTabRef.current.values()) { + try { + r.setConfig({ speed: effect.speed }); + } catch { + // ignore + } } - - // No alternative tab — check for gap, buffer, or true finish. - const currentTabHasMoreExpectedEvents = tabExpectedDurationMs !== null - && tabExpectedDurationMs > localTime + 500; - - const nextStart = findNextTabStartAfterGlobalOffset(globalOffset); - const finishAction = getReplayFinishAction({ - hasBestTabAtCurrentTime: false, - isDownloading: isDownloadingRef.current, - nextStartGlobalOffsetMs: nextStart?.globalOffsetMs ?? null, - currentGlobalOffsetMs: Math.max(globalOffset, currentGlobalTimeMsForUiRef.current), - currentTabHasMoreExpectedEvents, - }); - if (finishAction.type === "gap_fast_forward" && nextStart) { - gapFastForwardRef.current = { - fromGlobalMs: globalOffset, - toGlobalMs: finishAction.toGlobalMs, - wallMs: performance.now(), - nextTabKey: nextStart.tabKey, - gen, - }; - pausedAtGlobalRef.current = globalOffset; - return; + break; + } + case "set_replayer_skip_inactive": { + for (const r of replayerByTabRef.current.values()) { + try { + r.setConfig({ skipInactive: effect.skipInactive }); + } catch { + // ignore + } + } + if (!effect.skipInactive) setIsSkipping(false); + break; + } + case "sync_mini_tabs": { + const activeKey = msRef.current.activeTabKey; + for (const [tabKey, r] of replayerByTabRef.current.entries()) { + if (tabKey === activeKey) continue; + const stream = msRef.current.streams.find(s => s.tabKey === tabKey); + if (!stream) continue; + const localOffset = globalOffsetToLocalOffset( + msRef.current.globalStartTs, + stream.firstEventAtMs, + effect.globalOffsetMs, + ); + try { + r.pause(localOffset); + } catch { + // ignore + } } - if (finishAction.type === "buffer_at_current") { - pausedAtGlobalRef.current = globalOffset; - bufferingAtGlobalRef.current = globalOffset; - autoResumeAfterBufferingRef.current = true; - setIsBuffering(true); - setPlayerIsPlaying(false); - return; + break; + } + case "schedule_buffer_poll": { + const { generation, tabKey, delayMs } = effect; + setTimeout(() => { + actRef.current({ type: "BUFFER_CHECK", generation, tabKey }); + }, delayMs); + break; + } + case "recreate_replayer": { + const tabKey = effect.tabKey; + const r = replayerByTabRef.current.get(tabKey); + if (r) { + try { + r.pause(); + } catch { + // ignore + } } - - // End of recording — stop at the very end. - pausedAtGlobalRef.current = globalTotalTimeMsRef.current; - setCurrentGlobalTimeMsForUi(globalTotalTimeMsRef.current); - pauseAll(); - setIsBuffering(false); - setPlayerIsPlaying(false); - setReplayFinished(true); - }); - } catch { - // ignore + replayerByTabRef.current.delete(tabKey); + replayerRootByTabRef.current.delete(tabKey); + const obs = resizeObserverByTabRef.current.get(tabKey); + if (obs) { + obs.disconnect(); + resizeObserverByTabRef.current.delete(tabKey); + } + pendingInitByTabRef.current.add(tabKey); + runAsynchronously(() => ensureReplayerForTab(tabKey, effect.generation), { noErrorLogging: true }); + break; + } + case "save_settings": { + try { + localStorage.setItem(REPLAY_SETTINGS_STORAGE_KEY, JSON.stringify(effect.settings)); + } catch { + // ignore + } + break; + } } - - setUiVersion(v => v + 1); - } catch (e: any) { - setPlayerError(e?.message ?? "Failed to initialize rrweb player."); } - }, [findBestTabAtGlobalOffset, findNextTabStartAfterGlobalOffset, getDesiredGlobalOffsetMs, pauseAll, playActiveAtGlobalOffset, setActiveTab]); + } + + // Wire actRef: dispatch to machine + execute effects. + actRef.current = (action: ReplayAction) => { + const effects = rawDispatch(action); + executeEffects(effects); + }; + + // ---- Container ref callback ---- const setContainerRefForTab = useCallback((tabKey: TabKey, el: HTMLDivElement | null) => { containerByTabRef.current.set(tabKey, el); if (!el) return; - // If a replayer already exists but was created with a different DOM node - // (e.g. the tab unmounted and remounted), destroy the stale replayer so a - // fresh one is created with the new element. const existingRoot = replayerRootByTabRef.current.get(tabKey); if (existingRoot && existingRoot !== el) { const r = replayerByTabRef.current.get(tabKey); @@ -1040,13 +825,16 @@ export default function PageClient() { if (!pendingInitByTabRef.current.has(tabKey)) return; if ((eventsByTabRef.current.get(tabKey)?.length ?? 0) === 0) return; - runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); - }, [ensureReplayerForTab]); + runAsynchronously(() => ensureReplayerForTab(tabKey, msRef.current.generation), { noErrorLogging: true }); + }, [ensureReplayerForTab, msRef]); + + // ---- Load chunks and download events ---- const loadChunksAndDownload = useCallback(async (recordingId: string) => { - const gen = ++selectionGenRef.current; - resetReplayState(); - setIsDownloading(true); + const gen = ++genCounterRef.current; + actRef.current({ type: "SELECT_RECORDING", generation: gen }); + setFullStreams([]); + fullStreamsRef.current = []; try { const allChunkRows: ChunkRow[] = []; @@ -1056,36 +844,29 @@ export default function PageClient() { recordingId, { limit: CHUNK_PAGE_SIZE, cursor: cursor ?? undefined }, ); - if (selectionGenRef.current !== gen) return; + if (msRef.current.generation !== gen) return; allChunkRows.push(...res.items); if (!res.nextCursor) break; cursor = res.nextCursor; } const allStreams = groupChunksIntoTabStreams(allChunkRows); - streamsByKeyRef.current = new Map(allStreams.map(s => [s.tabKey, s])); - setStreams(allStreams); - streamsRef.current = allStreams; + setFullStreams(allStreams); + fullStreamsRef.current = allStreams; const { globalStartTs, globalTotalMs } = computeGlobalTimeline(allStreams); - globalStartTsRef.current = globalStartTs; - setGlobalTotalTimeMs(globalTotalMs); - globalTotalTimeMsRef.current = globalTotalMs; - - // Per-tab time ranges based on chunk metadata. This is what we use to decide - // whether a tab has events "at" the current global time (and whether to buffer - // or skip to the next chunk). - const rangesByTab = new Map>(); + + // Build chunk ranges + const rangesByTab = new Map(); for (const s of allStreams) { const ranges = s.chunks .map((c) => ({ startTs: c.firstEventAt.getTime(), endTs: c.lastEventAt.getTime() })) .filter(r => Number.isFinite(r.startTs) && Number.isFinite(r.endTs) && r.endTs >= r.startTs) .sort((a, b) => a.startTs - b.startTs); - // Merge overlaps/adjacent ranges to reduce churn. - const merged: Array<{ startTs: number, endTs: number }> = []; + const merged: ChunkRange[] = []; for (const r of ranges) { - const last = merged[merged.length - 1] as { startTs: number, endTs: number } | undefined; + const last = merged[merged.length - 1] as ChunkRange | undefined; if (!last) { merged.push({ ...r }); continue; @@ -1096,12 +877,10 @@ export default function PageClient() { merged.push({ ...r }); } } - rangesByTab.set(s.tabKey, merged); } - chunkRangesByTabRef.current = rangesByTab; - // Stable tab labels: Tab 1 = lowest firstEventAt, Tab 2 = second-lowest, etc. + // Stable tab labels const labelOrder = allStreams .slice() .sort((a, b) => { @@ -1109,131 +888,127 @@ export default function PageClient() { if (first !== 0) return first; return stringCompare(a.tabKey, b.tabKey); }); - tabLabelIndexByKeyRef.current = new Map(labelOrder.map((s, i) => [s.tabKey, i + 1])); - - // Start at the beginning of the overall replay (globalStartTs). This avoids - // landing at an arbitrary offset when the most-recent tab started later. - const initialActive = ( - allStreams.find(s => s.firstEventAt.getTime() === globalStartTs)?.tabKey - ?? (allStreams[0] as TabStream | undefined)?.tabKey - ?? null - ); - setActiveTab(initialActive); - pausedAtGlobalRef.current = 0; - setCurrentGlobalTimeMsForUi(0); + const tabLabelIndex = new Map(labelOrder.map((s, i) => [s.tabKey, i + 1])); + + // StreamInfo for machine + const streamInfos: StreamInfo[] = allStreams.map(s => ({ + tabKey: s.tabKey, + firstEventAtMs: s.firstEventAt.getTime(), + lastEventAtMs: s.lastEventAt.getTime(), + })); + + actRef.current({ + type: "STREAMS_COMPUTED", + generation: gen, + streams: streamInfos, + globalStartTs, + globalTotalMs, + chunkRangesByTab: rangesByTab, + tabLabelIndex, + }); await fetchChunkEventsForStreamsParallel( adminApp, recordingId, allStreams.map(s => ({ tabKey: s.tabKey, chunks: s.chunks })), - gen, - selectionGenRef, + () => msRef.current.generation !== gen, (tabKey, _chunkIndex, events) => { const prev = eventsByTabRef.current.get(tabKey) ?? []; const wasEmpty = prev.length === 0; prev.push(...events); eventsByTabRef.current.set(tabKey, prev); - // Track whether this tab has a FullSnapshot (rrweb type 2). - // Without one the replayer renders a blank white screen. - if (!hasFullSnapshotByTabRef.current.has(tabKey)) { + // Detect FullSnapshot (rrweb type 2). + const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (events.some(e => (e as any).type === 2)) { - hasFullSnapshotByTabRef.current.add(tabKey); - setUiVersion(v => v + 1); - } - } + && events.some(e => (e as any).type === 2); + let loadedDurationMs = 0; if (prev.length >= 2) { - loadedDurationByTabMsRef.current.set(tabKey, prev[prev.length - 1].timestamp - prev[0].timestamp); + loadedDurationMs = prev[prev.length - 1].timestamp - prev[0].timestamp; } - if (wasEmpty && prev.length > 0) { - runAsynchronously(() => ensureReplayerForTab(tabKey, gen), { noErrorLogging: true }); - setUiVersion(v => v + 1); - } else { + // Add events to existing rrweb replayer instance. + if (!wasEmpty) { const r = replayerByTabRef.current.get(tabKey); if (r) { for (const event of events) { r.addEvent(event); } - } else { - runAsynchronously(() => ensureReplayerForTab(tabKey, gen), { noErrorLogging: true }); } } - // Resume playback if the active stream was buffering and now has enough data. - if (activeTabKeyRef.current !== tabKey) return; - if (bufferingAtGlobalRef.current === null) return; - - const stream = streamsByKeyRef.current.get(tabKey) ?? null; - if (!stream) return; - - const targetLocal = globalOffsetToLocalOffset( - globalStartTsRef.current, - stream.firstEventAt.getTime(), - bufferingAtGlobalRef.current, - ); - const loaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; - - // Require a minimum 2s buffer ahead while downloading to avoid a - // rapid buffer→resume→finish loop when events stream in at the edge - // of loaded data. Once downloading completes the safety-net effect - // resumes immediately without the buffer-ahead requirement. - const bufferAhead = isDownloadingRef.current ? 2000 : 0; - if (loaded >= targetLocal + bufferAhead) { - const seekTo = bufferingAtGlobalRef.current; - bufferingAtGlobalRef.current = null; - setIsBuffering(false); - - if (autoResumeAfterBufferingRef.current) { - autoResumeAfterBufferingRef.current = false; - playActiveAtGlobalOffset(seekTo); - setPlayerIsPlaying(true); - } + // Dispatch to machine — handles ensure_replayer, buffer resume, etc. + actRef.current({ + type: "CHUNK_LOADED", + generation: gen, + tabKey, + hasFullSnapshot, + loadedDurationMs, + hadEventsBeforeThisChunk: !wasEmpty, + }); + + if (hasFullSnapshot || wasEmpty) { + setUiVersion(v => v + 1); } }, ); } catch (e: any) { - setDownloadError(e?.message ?? "Failed to load replay data."); - } finally { - if (selectionGenRef.current === gen) { - setIsDownloading(false); + if (msRef.current.generation === gen) { + actRef.current({ type: "DOWNLOAD_ERROR", generation: gen, message: e?.message ?? "Failed to load replay data." }); } + return; } - }, [adminApp, ensureReplayerForTab, playActiveAtGlobalOffset, resetReplayState, setActiveTab]); + + if (msRef.current.generation === gen) { + actRef.current({ type: "DOWNLOAD_COMPLETE", generation: gen }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [adminApp, msRef]); useEffect(() => { if (!selectedRecordingId || !selectedRecording) return; runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true }); }, [loadChunksAndDownload, selectedRecordingId, selectedRecording]); - // Safety net: if downloading finishes while buffering, resume playback. - useEffect(() => { - if (isDownloading) return; - if (bufferingAtGlobalRef.current === null) return; - if (!autoResumeAfterBufferingRef.current) return; - const seekTo = bufferingAtGlobalRef.current; - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - setIsBuffering(false); - playActiveAtGlobalOffset(seekTo); - setPlayerIsPlaying(true); - }, [isDownloading, playActiveAtGlobalOffset]); - useEffect(() => { return () => { - selectionGenRef.current += 1; + genCounterRef.current += 1; destroyReplayers(); }; }, [destroyReplayers]); + // ---- Timeline time reading (smooth, direct from rrweb) ---- + const getCurrentGlobalTimeMs = useCallback(() => { - return getDesiredGlobalOffsetMs(); - }, [getDesiredGlobalOffsetMs]); + const s = msRef.current; + const key = s.activeTabKey; + const r = key ? (replayerByTabRef.current.get(key) ?? null) : null; + const stream = key ? s.streams.find(st => st.tabKey === key) ?? null : null; + let activeLocalOffsetMs: number | null = null; + if (r) { + try { + activeLocalOffsetMs = r.getCurrentTime(); + } catch { + activeLocalOffsetMs = null; + } + } + + return getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: s.gapFastForward, + playerIsPlaying: s.playbackMode === "playing" || s.playbackMode === "gap_fast_forward", + nowMs: performance.now(), + playerSpeed: s.settings.playerSpeed, + pausedAtGlobalMs: s.pausedAtGlobalMs, + activeLocalOffsetMs, + activeStreamStartTs: stream?.firstEventAtMs ?? null, + globalStartTs: s.globalStartTs, + gapFastForwardMultiplier: INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, + }); + }, [msRef]); + + // ---- RAF tick loop — drives UI time updates, gap completion, auto-follow ---- - // Drives right-bar tab visibility (only show tabs "active" at the current time) - // and avoids calling getCurrentGlobalTimeMs() directly during render. useEffect(() => { let cancelled = false; let raf = 0; @@ -1241,89 +1016,24 @@ export default function PageClient() { const tick = (now: number) => { if (cancelled) return; - // Throttle UI updates; the actual playback is handled by rrweb. if (now - lastUpdateAt > 200) { lastUpdateAt = now; - let globalOffset = getCurrentGlobalTimeMs(); - const previousGlobalOffset = currentGlobalTimeMsForUiRef.current; - - // Guard against stale rrweb readings that momentarily report an older - // time near replay end; otherwise auto-follow can jump back to older tabs. - if ( - playerIsPlayingRef.current - && !isBufferingRef.current - && !gapFastForwardRef.current - && performance.now() >= suppressAutoFollowUntilRef.current - && globalOffset + 500 < previousGlobalOffset - ) { - globalOffset = previousGlobalOffset; - } - - setCurrentGlobalTimeMsForUi(globalOffset); - - // Sync visible mini tab replayers to the current global time so their - // thumbnails update during playback instead of staying frozen. - if (playerIsPlayingRef.current && !isBufferingRef.current) { - const activeKey = activeTabKeyRef.current; - for (const [tabKey, r] of replayerByTabRef.current.entries()) { - if (tabKey === activeKey) continue; - const stream = streamsByKeyRef.current.get(tabKey); - if (!stream) continue; - const localOffset = globalOffsetToLocalOffset( - globalStartTsRef.current, - stream.firstEventAt.getTime(), - globalOffset, - ); - try { - r.pause(localOffset); - } catch { - // ignore — replayer may not be ready yet - } + const key = msRef.current.activeTabKey; + const r = key ? (replayerByTabRef.current.get(key) ?? null) : null; + let activeLocalTimeMs: number | null = null; + if (r) { + try { + activeLocalTimeMs = r.getCurrentTime(); + } catch { + activeLocalTimeMs = null; } } - const gapFastForward = gapFastForwardRef.current; - if (gapFastForward && globalOffset >= gapFastForward.toGlobalMs) { - gapFastForwardRef.current = null; - setActiveTab(gapFastForward.nextTabKey); - pausedAtGlobalRef.current = gapFastForward.toGlobalMs; - setCurrentGlobalTimeMsForUi(gapFastForward.toGlobalMs); - setIsBuffering(false); - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - suppressAutoFollowUntilRef.current = performance.now() + 200; - runAsynchronously(() => ensureReplayerForTab(gapFastForward.nextTabKey, gapFastForward.gen), { noErrorLogging: true }); - playActiveAtGlobalOffset(gapFastForward.toGlobalMs); - setPlayerIsPlaying(true); - raf = requestAnimationFrame(tick); - return; - } - - // Auto-follow the tab that has events at the current global time. - // This must use the authoritative time, not the last UI state. - if ( - replaySettingsRef.current.followActiveTab - && playerIsPlayingRef.current - && !isBufferingRef.current - && streamsRef.current.length > 1 - ) { - if (performance.now() < suppressAutoFollowUntilRef.current) { - // Recently seeked/started playback; wait for rrweb to catch up. - } else if (activeTabKeyRef.current && isTabInRangeAtGlobalOffset(activeTabKeyRef.current, globalOffset)) { - // Current tab still has events at this time — stay on it (stickiness). - } else { - const bestKey = findBestTabAtGlobalOffset(globalOffset); - if (bestKey && bestKey !== activeTabKeyRef.current) { - setActiveTab(bestKey); - pausedAtGlobalRef.current = globalOffset; - runAsynchronously(() => ensureReplayerForTab(bestKey, selectionGenRef.current), { noErrorLogging: true }); - playActiveAtGlobalOffset(globalOffset); - suppressAutoFollowUntilRef.current = performance.now() + 200; - } - // When !bestKey (gap between tabs), active replayer finish handlers - // fast-forward to the next tab start. - } - } + actRef.current({ + type: "TICK", + nowMs: performance.now(), + activeReplayerLocalTimeMs: activeLocalTimeMs, + }); } raf = requestAnimationFrame(tick); }; @@ -1333,165 +1043,19 @@ export default function PageClient() { cancelled = true; cancelAnimationFrame(raf); }; - }, [ - ensureReplayerForTab, - findBestTabAtGlobalOffset, - getCurrentGlobalTimeMs, - isTabInRangeAtGlobalOffset, - playActiveAtGlobalOffset, - setActiveTab, - ]); - - const togglePlayPause = useCallback(() => { - if (playerIsPlaying || isBuffering) { - if (!isBuffering) { - pausedAtGlobalRef.current = getCurrentGlobalTimeMs(); - setCurrentGlobalTimeMsForUi(pausedAtGlobalRef.current); - } - gapFastForwardRef.current = null; - pauseAll(); - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - setIsBuffering(false); - setPlayerIsPlaying(false); - return; - } - - const target = pausedAtGlobalRef.current; - const activeKey = activeTabKeyRef.current; - const activeStream = activeKey ? streamsByKeyRef.current.get(activeKey) ?? null : null; - if (isDownloadingRef.current && activeKey && activeStream) { - const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, activeStream.firstEventAt.getTime(), target); - const loaded = loadedDurationByTabMsRef.current.get(activeKey) ?? 0; - if (localTarget > loaded) { - bufferingAtGlobalRef.current = target; - autoResumeAfterBufferingRef.current = true; - setIsBuffering(true); - return; - } - } - - bufferingAtGlobalRef.current = null; - setIsBuffering(false); - setReplayFinished(false); - playActiveAtGlobalOffset(target); - setPlayerIsPlaying(true); - setCurrentGlobalTimeMsForUi(target); - suppressAutoFollowUntilRef.current = performance.now() + 400; - }, [getCurrentGlobalTimeMs, isBuffering, pauseAll, playActiveAtGlobalOffset, playerIsPlaying]); - - const handleSeek = useCallback((globalOffset: number) => { - const seekState = applySeekState({ seekToGlobalMs: globalOffset }); - if (seekState.clearGapFastForward) { - gapFastForwardRef.current = null; - } - pausedAtGlobalRef.current = seekState.pausedAtGlobalMs; - setReplayFinished(false); - - // If the seek target is outside the currently active tab's time range, - // switch to the best tab for that time. - const desiredKey = findBestTabAtGlobalOffset(globalOffset); - if (desiredKey && desiredKey !== activeTabKeyRef.current) { - setActiveTab(desiredKey); - runAsynchronously(() => ensureReplayerForTab(desiredKey, selectionGenRef.current), { noErrorLogging: true }); - } - - const activeKey = activeTabKeyRef.current; - const activeStream = activeKey ? streamsByKeyRef.current.get(activeKey) ?? null : null; - if (isDownloadingRef.current && activeKey && activeStream) { - const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, activeStream.firstEventAt.getTime(), globalOffset); - const loaded = loadedDurationByTabMsRef.current.get(activeKey) ?? 0; - if (localTarget > loaded) { - pauseAll(); - bufferingAtGlobalRef.current = globalOffset; - autoResumeAfterBufferingRef.current = true; - setIsBuffering(true); - setPlayerIsPlaying(false); - return; - } - } - - bufferingAtGlobalRef.current = null; - autoResumeAfterBufferingRef.current = false; - setIsBuffering(false); - playActiveAtGlobalOffset(globalOffset); - setPlayerIsPlaying(true); - setCurrentGlobalTimeMsForUi(globalOffset); - suppressAutoFollowUntilRef.current = performance.now() + 400; - }, [ensureReplayerForTab, findBestTabAtGlobalOffset, pauseAll, playActiveAtGlobalOffset, setActiveTab]); - - const updateSpeed = useCallback((speed: number) => { - if (!ALLOWED_PLAYER_SPEEDS.has(speed)) return; - setReplaySettings((prev) => ({ ...prev, playerSpeed: speed })); - for (const r of replayerByTabRef.current.values()) { - try { - r.setConfig({ speed }); - } catch { - // ignore - } - } - }, []); + }, [msRef]); - useEffect(() => { - for (const r of replayerByTabRef.current.values()) { - try { - r.setConfig({ skipInactive: replaySettings.skipInactivity }); - } catch { - // ignore - } - } - if (!replaySettings.skipInactivity) { - setIsSkipping(false); - } - }, [replaySettings.skipInactivity]); - - const onSelectActiveTab = useCallback((tabKey: TabKey) => { - const now = getCurrentGlobalTimeMs(); - setActiveTab(tabKey); - suppressAutoFollowUntilRef.current = performance.now() + 5000; - pausedAtGlobalRef.current = now; - setIsSkipping(false); - - // Force a seek so the newly active tab aligns immediately. - pauseAll(); - autoResumeAfterBufferingRef.current = false; - bufferingAtGlobalRef.current = null; - setIsBuffering(false); - - const stream = streamsByKeyRef.current.get(tabKey) ?? null; - if (isDownloadingRef.current && stream) { - const localTarget = globalOffsetToLocalOffset(globalStartTsRef.current, stream.firstEventAt.getTime(), now); - const loaded = loadedDurationByTabMsRef.current.get(tabKey) ?? 0; - if (localTarget > loaded) { - bufferingAtGlobalRef.current = now; - autoResumeAfterBufferingRef.current = true; - setIsBuffering(true); - setPlayerIsPlaying(false); - runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); - return; - } - } - - runAsynchronously(() => ensureReplayerForTab(tabKey, selectionGenRef.current), { noErrorLogging: true }); - - if (playerIsPlayingRef.current && !isBufferingRef.current) { - playActiveAtGlobalOffset(now); - setPlayerIsPlaying(true); - } else { - setPlayerIsPlaying(false); - } - }, [ensureReplayerForTab, getCurrentGlobalTimeMs, pauseAll, playActiveAtGlobalOffset, setActiveTab]); + // ---- Skip indicator (speedService subscription) ---- - // Subscribe to speedService on the active stream only (for skip indicator). useEffect(() => { - if (!replaySettings.skipInactivity) { + if (!ms.settings.skipInactivity) { setIsSkipping(false); speedSubRef.current?.unsubscribe(); speedSubRef.current = null; return; } - const key = activeTabKey; + const key = ms.activeTabKey; const r = key ? replayerByTabRef.current.get(key) ?? null : null; setIsSkipping(false); @@ -1508,32 +1072,52 @@ export default function PageClient() { } catch { // ignore } - }, [activeTabKey, replaySettings.skipInactivity, uiVersion]); + }, [ms.activeTabKey, ms.settings.skipInactivity, uiVersion]); + + // ---- Action callbacks ---- + + const togglePlayPause = useCallback(() => { + actRef.current({ type: "TOGGLE_PLAY_PAUSE", nowMs: performance.now() }); + }, []); + + const handleSeek = useCallback((globalOffset: number) => { + actRef.current({ type: "SEEK", globalOffsetMs: globalOffset, nowMs: performance.now() }); + }, []); + + const updateSpeed = useCallback((speed: number) => { + actRef.current({ type: "UPDATE_SPEED", speed }); + }, []); + + const onSelectActiveTab = useCallback((tabKey: TabKey) => { + actRef.current({ type: "SELECT_TAB", tabKey, nowMs: performance.now() }); + }, []); + + // ---- Derived rendering data ---- const activeStream = useMemo( - () => (activeTabKey ? streams.find(s => s.tabKey === activeTabKey) ?? null : null), - [activeTabKey, streams], + () => (ms.activeTabKey ? fullStreams.find(s => s.tabKey === ms.activeTabKey) ?? null : null), + [ms.activeTabKey, fullStreams], ); const visibleMiniStreams = useMemo(() => { - void uiVersion; // re-compute when FullSnapshot status changes - const currentTs = globalStartTsRef.current + currentGlobalTimeMsForUi; - const candidates = streams.filter(s => - s.tabKey !== activeTabKey && hasFullSnapshotByTabRef.current.has(s.tabKey) + void uiVersion; + const currentTs = ms.globalStartTs + ms.currentGlobalTimeMsForUi; + const candidates = fullStreams.filter(s => + s.tabKey !== ms.activeTabKey && ms.hasFullSnapshotByTab.has(s.tabKey) ); const inRange = candidates.filter(s => currentTs >= s.firstEventAt.getTime() && currentTs <= s.lastEventAt.getTime() ); inRange.sort((a, b) => { - const aLabel = tabLabelIndexByKeyRef.current.get(a.tabKey) ?? Number.POSITIVE_INFINITY; - const bLabel = tabLabelIndexByKeyRef.current.get(b.tabKey) ?? Number.POSITIVE_INFINITY; + const aLabel = ms.tabLabelIndex.get(a.tabKey) ?? Number.POSITIVE_INFINITY; + const bLabel = ms.tabLabelIndex.get(b.tabKey) ?? Number.POSITIVE_INFINITY; if (aLabel !== bLabel) return aLabel - bLabel; return stringCompare(a.tabKey, b.tabKey); }); return inRange.slice(0, EXTRA_TABS_TO_SHOW); - }, [activeTabKey, currentGlobalTimeMsForUi, streams, uiVersion]); + }, [ms.activeTabKey, ms.currentGlobalTimeMsForUi, ms.globalStartTs, ms.hasFullSnapshotByTab, ms.tabLabelIndex, fullStreams, uiVersion]); const showRightColumn = visibleMiniStreams.length > 0; @@ -1546,25 +1130,26 @@ export default function PageClient() { }, [visibleMiniStreams]); const getTabLabel = useCallback((tabKey: TabKey) => { - const idx = tabLabelIndexByKeyRef.current.get(tabKey); + const idx = ms.tabLabelIndex.get(tabKey); if (!idx) return "Tab"; return `Tab ${idx}`; - }, []); + }, [ms.tabLabelIndex]); const activeHasEvents = useMemo(() => { if (!activeStream) return false; - // uiVersion ensures re-render when events/replayers arrive. void uiVersion; return (eventsByTabRef.current.get(activeStream.tabKey)?.length ?? 0) > 0; }, [activeStream, uiVersion]); const renderableStreamCount = useMemo(() => { void uiVersion; - return streams.filter(s => hasFullSnapshotByTabRef.current.has(s.tabKey)).length; - }, [streams, uiVersion]); + return fullStreams.filter(s => ms.hasFullSnapshotByTab.has(s.tabKey)).length; + }, [fullStreams, ms.hasFullSnapshotByTab, uiVersion]); const showMainTabLabel = renderableStreamCount > 1; + // ---- Rendering ---- + return ( @@ -1650,10 +1235,10 @@ export default function PageClient() {
- {(downloadError || playerError) && ( + {(ms.downloadError || ms.playerError) && (
- {downloadError && {downloadError}} - {playerError && {playerError}} + {ms.downloadError && {ms.downloadError}} + {ms.playerError && {ms.playerError}}
)} @@ -1662,8 +1247,8 @@ export default function PageClient() { {selectedRecording ? getRecordingTitle(selectedRecording) : ""} setReplaySettings((prev) => ({ ...prev, ...updates }))} + settings={ms.settings} + onSettingsChange={(updates) => actRef.current({ type: "UPDATE_SETTINGS", updates })} />
@@ -1678,7 +1263,7 @@ export default function PageClient() { transition: "grid-template-columns 180ms ease-out", }} > - {streams.length === 0 && ( + {fullStreams.length === 0 && (
{isDownloading ? ( @@ -1697,8 +1282,8 @@ export default function PageClient() {
)} - {streams.map((s) => { - const isActive = s.tabKey === activeTabKey; + {fullStreams.map((s) => { + const isActive = s.tabKey === ms.activeTabKey; const miniIndex = miniIndexByKey.get(s.tabKey); const isMiniVisible = miniIndex !== undefined && miniIndex >= 0 && miniIndex < EXTRA_TABS_TO_SHOW; if (!isActive && !isMiniVisible) return null; @@ -1741,7 +1326,6 @@ export default function PageClient() { {isActive && ( <> - {/* Main tab label */} {showMainTabLabel && (
@@ -1753,7 +1337,6 @@ export default function PageClient() {
)} - {/* Paused / finished overlay */} {activeHasEvents && !playerIsPlaying && !isBuffering && (
)} - {/* Skipping inactivity indicator */} - {activeHasEvents && replaySettings.skipInactivity && isSkipping && ( + {activeHasEvents && ms.settings.skipInactivity && isSkipping && (
@@ -1793,7 +1375,6 @@ export default function PageClient() {
)} - {/* Buffering overlay — waiting for events to load */} {activeHasEvents && isBuffering && (
)} - {/* Click to toggle play/pause during playback */} {activeHasEvents && playerIsPlaying && !isBuffering && (
)} - {/* Loading / no events overlay */} {!activeHasEvents && (
@@ -1850,10 +1429,10 @@ export default function PageClient() { )} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts new file mode 100644 index 0000000000..4f75fce0aa --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts @@ -0,0 +1,1388 @@ +import { describe, expect, it } from "vitest"; +import { + createInitialState, + replayReducer, + findBestTabAtGlobalOffset, + isTabInRangeAtGlobalOffset, + findNextTabStartAfterGlobalOffset, + ALLOWED_PLAYER_SPEEDS, + DEFAULT_REPLAY_SETTINGS, + type ReplayState, + type ReplayAction, + type StreamInfo, + type ChunkRange, + type ReducerResult, + type ReplayEffect, +} from "./session-replay-machine"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeStreams(...specs: Array<{ tabKey: string, firstMs: number, lastMs: number }>): StreamInfo[] { + return specs.map(s => ({ + tabKey: s.tabKey, + firstEventAtMs: s.firstMs, + lastEventAtMs: s.lastMs, + })); +} + +function makeChunkRanges(entries: Record>): Map { + const m = new Map(); + for (const [key, ranges] of Object.entries(entries)) { + m.set(key, ranges.map(([s, e]) => ({ startTs: s, endTs: e }))); + } + return m; +} + +function makeTabLabels(entries: Record): Map { + return new Map(Object.entries(entries)); +} + +/** Create a state with two tabs ready for playback. */ +function twoTabReadyState(overrides?: Partial): ReplayState { + return { + ...createInitialState(), + generation: 1, + phase: "downloading", + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 6000, lastMs: 10000 }, + ), + globalStartTs: 1000, + globalTotalMs: 9000, + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[6000, 10000]], + }), + tabLabelIndex: makeTabLabels({ a: 1, b: 2 }), + hasFullSnapshotByTab: new Set(["a", "b"]), + loadedDurationByTabMs: new Map([["a", 4000], ["b", 4000]]), + tabsWithEvents: new Set(["a", "b"]), + replayerReady: new Set(["a", "b"]), + activeTabKey: "a", + playbackMode: "paused", + ...overrides, + }; +} + +function dispatch(state: ReplayState, action: ReplayAction): ReducerResult { + return replayReducer(state, action); +} + +function dispatchChain(state: ReplayState, actions: ReplayAction[]): ReducerResult { + let result: ReducerResult = { state, effects: [] }; + const allEffects: ReplayEffect[] = []; + for (const action of actions) { + result = replayReducer(result.state, action); + allEffects.push(...result.effects); + } + return { state: result.state, effects: allEffects }; +} + +function hasEffect(effects: ReplayEffect[], type: ReplayEffect["type"]): boolean { + return effects.some(e => e.type === type); +} + +function getEffects(effects: ReplayEffect[], type: ReplayEffect["type"]): ReplayEffect[] { + return effects.filter(e => e.type === type); +} + +// --------------------------------------------------------------------------- +// Unit tests: each action +// --------------------------------------------------------------------------- + +describe("session-replay-machine", () => { + describe("createInitialState", () => { + it("returns idle state with default settings", () => { + const s = createInitialState(); + expect(s.phase).toBe("idle"); + expect(s.playbackMode).toBe("paused"); + expect(s.generation).toBe(0); + expect(s.activeTabKey).toBeNull(); + expect(s.settings).toEqual(DEFAULT_REPLAY_SETTINGS); + }); + + it("accepts custom settings", () => { + const settings = { playerSpeed: 2, skipInactivity: false, followActiveTab: true }; + const s = createInitialState(settings); + expect(s.settings).toEqual(settings); + }); + }); + + describe("SELECT_RECORDING", () => { + it("resets state and sets generation", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { state: s, effects } = dispatch(state, { type: "SELECT_RECORDING", generation: 5 }); + expect(s.generation).toBe(5); + expect(s.phase).toBe("downloading"); + expect(s.playbackMode).toBe("paused"); + expect(s.activeTabKey).toBeNull(); + expect(s.streams).toHaveLength(0); + expect(hasEffect(effects, "destroy_all_replayers")).toBe(true); + }); + + it("preserves settings across selection", () => { + const state = twoTabReadyState(); + state.settings = { playerSpeed: 4, skipInactivity: false, followActiveTab: true }; + const { state: s } = dispatch(state, { type: "SELECT_RECORDING", generation: 2 }); + expect(s.settings.playerSpeed).toBe(4); + expect(s.settings.followActiveTab).toBe(true); + }); + }); + + describe("STREAMS_COMPUTED", () => { + it("sets streams and picks initial active tab", () => { + const state = { ...createInitialState(), generation: 1, phase: "downloading" as const }; + const streams = makeStreams( + { tabKey: "x", firstMs: 500, lastMs: 1000 }, + { tabKey: "y", firstMs: 200, lastMs: 900 }, + ); + const { state: s } = dispatch(state, { + type: "STREAMS_COMPUTED", + generation: 1, + streams, + globalStartTs: 200, + globalTotalMs: 800, + chunkRangesByTab: new Map(), + tabLabelIndex: new Map(), + }); + expect(s.streams).toBe(streams); + expect(s.globalStartTs).toBe(200); + expect(s.globalTotalMs).toBe(800); + // "y" starts at 200 = globalStartTs + expect(s.activeTabKey).toBe("y"); + }); + + it("falls back to first stream when none matches globalStartTs", () => { + const state = { ...createInitialState(), generation: 1, phase: "downloading" as const }; + const streams = makeStreams( + { tabKey: "a", firstMs: 500, lastMs: 1000 }, + { tabKey: "b", firstMs: 600, lastMs: 900 }, + ); + const { state: s } = dispatch(state, { + type: "STREAMS_COMPUTED", + generation: 1, + streams, + globalStartTs: 200, + globalTotalMs: 800, + chunkRangesByTab: new Map(), + tabLabelIndex: new Map(), + }); + expect(s.activeTabKey).toBe("a"); + }); + + it("ignores stale generation", () => { + const state = { ...createInitialState(), generation: 3 }; + const { state: s } = dispatch(state, { + type: "STREAMS_COMPUTED", + generation: 1, + streams: makeStreams({ tabKey: "a", firstMs: 0, lastMs: 100 }), + globalStartTs: 0, + globalTotalMs: 100, + chunkRangesByTab: new Map(), + tabLabelIndex: new Map(), + }); + expect(s.streams).toHaveLength(0); // unchanged + }); + }); + + describe("CHUNK_LOADED", () => { + it("updates loaded duration and marks full snapshot", () => { + const state = twoTabReadyState(); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.loadedDurationByTabMs.get("a")).toBe(3500); + expect(s.hasFullSnapshotByTab.has("a")).toBe(true); + }); + + it("triggers ensure_replayer when tab was empty", () => { + const state = twoTabReadyState(); + state.tabsWithEvents.delete("a"); + state.replayerReady.delete("a"); // Replayer can't be ready without events + const { effects } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 500, + hadEventsBeforeThisChunk: false, + }); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("does not trigger ensure_replayer when tab had events", () => { + const state = twoTabReadyState(); + const { effects } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(hasEffect(effects, "ensure_replayer")).toBe(false); + }); + + it("resumes from buffering when enough data arrives", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, // global offset 1000 => local offset at tab a = globalOffset (since globalStart = firstEventAt = 1000) => local 1000 + autoResumeAfterBuffering: true, + }); + // local target = globalOffsetToLocalOffset(1000, 1000, 1000) = max(0, 1000+1000-1000) = 1000 + // bufferAhead = 2000 (downloading) + // need loaded >= 1000 + 2000 = 3000 + const { state: s, effects } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playbackMode).toBe("playing"); + expect(s.bufferingAtGlobalMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("stays buffering when not enough data", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + autoResumeAfterBuffering: true, + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 500, // not enough + hadEventsBeforeThisChunk: true, + }); + expect(s.playbackMode).toBe("buffering"); + }); + }); + + describe("REPLAYER_READY", () => { + it("auto-plays active tab on first replayer ready", () => { + const state = twoTabReadyState({ + autoPlayTriggered: false, + playbackMode: "paused", + }); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "a", + }); + expect(s.autoPlayTriggered).toBe(true); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("does not auto-play non-active tab", () => { + const state = twoTabReadyState({ + autoPlayTriggered: false, + activeTabKey: "a", + }); + state.replayerReady.delete("b"); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "b", + }); + expect(s.autoPlayTriggered).toBe(false); + // Should pause at correct offset, not play + expect(hasEffect(effects, "pause_replayer_at")).toBe(true); + expect(hasEffect(effects, "play_replayer")).toBe(false); + }); + + it("does not auto-play when already triggered", () => { + const state = twoTabReadyState({ + autoPlayTriggered: true, + playbackMode: "paused", + }); + state.replayerReady.delete("a"); + const { state: s } = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "a", + }); + // No auto-play: stays paused + expect(s.playbackMode).toBe("paused"); + }); + }); + + describe("REPLAYER_FINISH", () => { + it("restarts on premature finish (more loaded data)", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + }); + state.loadedDurationByTabMs.set("a", 5000); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 2000, // well below 5000 + nowMs: 1000, + }); + // Should restart playback + expect(s.playbackMode).toBe("playing"); + const playEffects = getEffects(effects, "play_replayer"); + expect(playEffects).toHaveLength(1); + expect((playEffects[0] as any).localOffsetMs).toBe(2000); + }); + + it("buffers when downloading and tab expects more", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + phase: "downloading", + }); + // Tab "a": firstMs=1000, lastMs=5000, so expected duration=4000 + // localTimeMs=2000 => 2000+500 < 4000, so more expected + state.loadedDurationByTabMs.set("a", 2000); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 2000, + nowMs: 1000, + }); + expect(s.playbackMode).toBe("buffering"); + expect(s.autoResumeAfterBuffering).toBe(true); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); + + it("switches to another tab that has events at this offset", () => { + // Tab a covers 1000-5000, tab b covers 4000-10000 (overlapping) + const state: ReplayState = { + ...twoTabReadyState(), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 4000, lastMs: 10000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[4000, 10000]], + }), + playbackMode: "playing", + activeTabKey: "a", + phase: "ready", + }; + state.loadedDurationByTabMs.set("a", 4000); + // localTime 4000, globalOffset = 4000 + (1000-1000) = 4000 + // Tab b covers 4000, so it should switch + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 4000, + nowMs: 1000, + }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("starts gap fast-forward when next tab exists after gap", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + phase: "ready", + }); + // Tab a fully loaded up to 4000ms local time + state.loadedDurationByTabMs.set("a", 4000); + // localTime 4000 => globalOffset = 4000 + (1000-1000) = 4000 + // Tab b starts at 6000, so next start = 6000-1000 = 5000 global offset + const { state: s } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 4000, + nowMs: 1000, + }); + expect(s.playbackMode).toBe("gap_fast_forward"); + expect(s.gapFastForward).not.toBeNull(); + expect(s.gapFastForward!.nextTabKey).toBe("b"); + expect(s.gapFastForward!.toGlobalMs).toBe(5000); + }); + + it("finishes replay when no more tabs", () => { + // Single tab, fully loaded + const state: ReplayState = { + ...createInitialState(), + generation: 1, + phase: "ready", + streams: makeStreams({ tabKey: "a", firstMs: 1000, lastMs: 5000 }), + globalStartTs: 1000, + globalTotalMs: 4000, + chunkRangesByTab: makeChunkRanges({ a: [[1000, 5000]] }), + tabLabelIndex: makeTabLabels({ a: 1 }), + hasFullSnapshotByTab: new Set(["a"]), + loadedDurationByTabMs: new Map([["a", 4000]]), + tabsWithEvents: new Set(["a"]), + replayerReady: new Set(["a"]), + activeTabKey: "a", + playbackMode: "playing", + }; + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 4000, + nowMs: 1000, + }); + expect(s.playbackMode).toBe("finished"); + expect(s.pausedAtGlobalMs).toBe(4000); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("ignores stale generation", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 99, + tabKey: "a", + localTimeMs: 1000, + nowMs: 1000, + }); + expect(s).toBe(state); + expect(effects).toHaveLength(0); + }); + + it("ignores finish from non-active tab", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a" }); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "b", + localTimeMs: 1000, + nowMs: 1000, + }); + expect(s).toBe(state); + expect(effects).toHaveLength(0); + }); + }); + + describe("TOGGLE_PLAY_PAUSE", () => { + it("pauses from playing", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("pauses from gap_fast_forward", () => { + const state = twoTabReadyState({ playbackMode: "gap_fast_forward" }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.gapFastForward).toBeNull(); + }); + + it("pauses from buffering", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + bufferingAtGlobalMs: 1000, + autoResumeAfterBuffering: true, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.bufferingAtGlobalMs).toBeNull(); + expect(s.autoResumeAfterBuffering).toBe(false); + }); + + it("plays from paused", () => { + const state = twoTabReadyState({ playbackMode: "paused", pausedAtGlobalMs: 500 }); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("buffers when trying to play beyond loaded data", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + pausedAtGlobalMs: 3500, + phase: "downloading", + }); + // Tab "a" firstMs=1000, globalStartTs=1000, so localTarget = max(0, 1000+3500-1000) = 3500 + // loaded = 2000, so 3500 > 2000 => buffer + state.loadedDurationByTabMs.set("a", 2000); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(s.autoResumeAfterBuffering).toBe(true); + }); + }); + + describe("SEEK", () => { + it("seeks within same tab", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a" }); + const { state: s, effects } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 1000 }); + expect(s.playbackMode).toBe("playing"); + expect(s.pausedAtGlobalMs).toBe(2000); + expect(s.activeTabKey).toBe("a"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("switches tab when seeking to different tab's range", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a" }); + // globalOffsetMs=6000 => ts = 1000+6000 = 7000. Tab b range is [6000, 10000]. 7000 is in range. + const { state: s, effects } = dispatch(state, { type: "SEEK", globalOffsetMs: 6000, nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("buffers when seeking beyond loaded data during download", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 1000); + // Seek to globalOffset 3000 => local = max(0, 1000+3000-1000) = 3000 > 1000 (loaded) + const { state: s, effects } = dispatch(state, { type: "SEEK", globalOffsetMs: 3000, nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(s.bufferingAtGlobalMs).toBe(3000); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("clears gap fast-forward", () => { + const state = twoTabReadyState({ + playbackMode: "gap_fast_forward", + gapFastForward: { + fromGlobalMs: 4000, + toGlobalMs: 5000, + wallMs: 0, + nextTabKey: "b", + gen: 1, + }, + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 1000, nowMs: 1000 }); + expect(s.gapFastForward).toBeNull(); + }); + }); + + describe("SELECT_TAB", () => { + it("switches active tab during playback", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a", pausedAtGlobalMs: 2000 }); + const { state: s, effects } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(s.suppressAutoFollowUntilWallMs).toBe(6000); + expect(hasEffect(effects, "pause_all")).toBe(true); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("stays paused after tab switch when paused", () => { + const state = twoTabReadyState({ playbackMode: "paused", activeTabKey: "a" }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("paused"); + }); + + it("buffers when new tab's data isn't loaded yet", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 6000, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("b", 0); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(s.activeTabKey).toBe("b"); + }); + }); + + describe("UPDATE_SPEED", () => { + it("updates speed and emits effect", () => { + const state = twoTabReadyState(); + const { state: s, effects } = dispatch(state, { type: "UPDATE_SPEED", speed: 4 }); + expect(s.settings.playerSpeed).toBe(4); + expect(hasEffect(effects, "set_replayer_speed")).toBe(true); + expect(hasEffect(effects, "save_settings")).toBe(true); + }); + + it("ignores invalid speed", () => { + const state = twoTabReadyState(); + const { state: s, effects } = dispatch(state, { type: "UPDATE_SPEED", speed: 3 }); + expect(s.settings.playerSpeed).toBe(state.settings.playerSpeed); + expect(effects).toHaveLength(0); + }); + }); + + describe("UPDATE_SETTINGS", () => { + it("updates settings and saves", () => { + const state = twoTabReadyState(); + const { state: s, effects } = dispatch(state, { type: "UPDATE_SETTINGS", updates: { followActiveTab: true } }); + expect(s.settings.followActiveTab).toBe(true); + expect(hasEffect(effects, "save_settings")).toBe(true); + }); + + it("emits skip_inactive effect when skipInactivity changes", () => { + const state = twoTabReadyState(); + const { effects } = dispatch(state, { type: "UPDATE_SETTINGS", updates: { skipInactivity: false } }); + expect(hasEffect(effects, "set_replayer_skip_inactive")).toBe(true); + }); + }); + + describe("TICK", () => { + it("updates currentGlobalTimeMsForUi", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 1000, + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 1000, + activeReplayerLocalTimeMs: 1500, + }); + // globalOffset = 1500 + (1000 - 1000) = 1500 + expect(s.currentGlobalTimeMsForUi).toBe(1500); + }); + + it("completes gap fast-forward", () => { + const state = twoTabReadyState({ + playbackMode: "gap_fast_forward", + gapFastForward: { + fromGlobalMs: 4000, + toGlobalMs: 5000, + wallMs: 0, + nextTabKey: "b", + gen: 1, + }, + activeTabKey: "a", + }); + // Provide a tick where computed offset >= toGlobalMs + // When gap is active, offset = min(toGlobalMs, fromGlobalMs + elapsed*speed*multiplier) + // With large enough nowMs, offset hits 5000 + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 999999, + activeReplayerLocalTimeMs: null, + }); + expect(s.gapFastForward).toBeNull(); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("auto-follows active tab", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + settings: { ...DEFAULT_REPLAY_SETTINGS, followActiveTab: true }, + suppressAutoFollowUntilWallMs: 0, + }); + // Tick at a time where tab a is not in range but tab b is + // globalOffset = 7000 + (1000-1000) = 7000, ts = 1000+7000 = 8000 + // Tab a range: [1000, 5000] — not in range + // Tab b range: [6000, 10000] — in range + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000, + activeReplayerLocalTimeMs: 7000, + }); + expect(s.activeTabKey).toBe("b"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + }); + + it("applies monotonicity guard", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + currentGlobalTimeMsForUi: 3000, + suppressAutoFollowUntilWallMs: 0, + }); + // Active replayer reports 1000 local ms => global = 1000 + // But previous was 3000, so 1000 + 500 < 3000 => use previous + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000, + activeReplayerLocalTimeMs: 1000, + }); + expect(s.currentGlobalTimeMsForUi).toBe(3000); + }); + + it("syncs mini tabs when playing", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { effects } = dispatch(state, { + type: "TICK", + nowMs: 1000, + activeReplayerLocalTimeMs: 2000, + }); + expect(hasEffect(effects, "sync_mini_tabs")).toBe(true); + }); + }); + + describe("BUFFER_CHECK", () => { + it("resumes when enough data loaded", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 5000); + const { state: s, effects } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 1, + tabKey: "a", + }); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("resumes immediately when download complete", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + phase: "ready", + }); + state.loadedDurationByTabMs.set("a", 500); // not much data but phase is ready + const { state: s } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 1, + tabKey: "a", + }); + expect(s.playbackMode).toBe("playing"); + }); + + it("schedules another poll when still buffering", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 100); + const { state: s, effects } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 1, + tabKey: "a", + }); + expect(s.playbackMode).toBe("buffering"); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); + + it("ignores stale generation", () => { + const state = twoTabReadyState({ playbackMode: "buffering" }); + const { effects } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 99, + tabKey: "a", + }); + expect(effects).toHaveLength(0); + }); + }); + + describe("DOWNLOAD_COMPLETE", () => { + it("sets phase to ready", () => { + const state = twoTabReadyState({ phase: "downloading" }); + const { state: s } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.phase).toBe("ready"); + }); + + it("resumes from buffering with auto-resume", () => { + const state = twoTabReadyState({ + phase: "downloading", + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + }); + const { state: s, effects } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.playbackMode).toBe("playing"); + expect(s.bufferingAtGlobalMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + }); + + describe("DOWNLOAD_ERROR", () => { + it("sets error message", () => { + const state = twoTabReadyState({ phase: "downloading" }); + const { state: s } = dispatch(state, { + type: "DOWNLOAD_ERROR", + generation: 1, + message: "Network error", + }); + expect(s.downloadError).toBe("Network error"); + expect(s.phase).toBe("ready"); + }); + }); + + describe("RESET", () => { + it("resets to initial state preserving settings", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + state.settings = { playerSpeed: 4, skipInactivity: false, followActiveTab: true }; + const { state: s, effects } = dispatch(state, { type: "RESET" }); + expect(s.phase).toBe("idle"); + expect(s.playbackMode).toBe("paused"); + expect(s.settings.playerSpeed).toBe(4); + expect(hasEffect(effects, "destroy_all_replayers")).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Pure helper tests +// --------------------------------------------------------------------------- + +describe("findBestTabAtGlobalOffset", () => { + it("finds tab in range", () => { + const state = twoTabReadyState(); + // globalOffset 2000 => ts = 1000+2000 = 3000. Tab a range: [1000, 5000] => in range + expect(findBestTabAtGlobalOffset(state, 2000)).toBe("a"); + }); + + it("finds tab b when a is out of range", () => { + const state = twoTabReadyState(); + // globalOffset 6000 => ts = 1000+6000 = 7000. Tab b range: [6000, 10000] => in range + expect(findBestTabAtGlobalOffset(state, 6000)).toBe("b"); + }); + + it("returns null when no tab in range", () => { + const state = twoTabReadyState(); + // globalOffset 4500 => ts = 1000+4500 = 5500. + // Tab a: [1000, 5000] => 5500 > 5000, not in range. + // Tab b: [6000, 10000] => 5500 < 6000, not in range. + expect(findBestTabAtGlobalOffset(state, 4500)).toBeNull(); + }); + + it("excludes specified tab", () => { + const state = twoTabReadyState(); + expect(findBestTabAtGlobalOffset(state, 2000, "a")).toBeNull(); + }); + + it("skips tabs without full snapshot", () => { + const state = twoTabReadyState(); + state.hasFullSnapshotByTab.delete("a"); + expect(findBestTabAtGlobalOffset(state, 2000)).toBeNull(); + }); + + it("prefers tab with lower label index", () => { + // Two tabs both in range at the same offset + const state: ReplayState = { + ...twoTabReadyState(), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 1000, lastMs: 5000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[1000, 5000]], + }), + }; + expect(findBestTabAtGlobalOffset(state, 2000)).toBe("a"); + }); +}); + +describe("isTabInRangeAtGlobalOffset", () => { + it("returns true when in range", () => { + const state = twoTabReadyState(); + expect(isTabInRangeAtGlobalOffset(state, "a", 2000)).toBe(true); + }); + + it("returns false when out of range", () => { + const state = twoTabReadyState(); + expect(isTabInRangeAtGlobalOffset(state, "a", 5000)).toBe(false); + }); + + it("returns false for tab without full snapshot", () => { + const state = twoTabReadyState(); + state.hasFullSnapshotByTab.delete("a"); + expect(isTabInRangeAtGlobalOffset(state, "a", 2000)).toBe(false); + }); +}); + +describe("findNextTabStartAfterGlobalOffset", () => { + it("finds next tab start", () => { + const state = twoTabReadyState(); + // globalOffset 4000 => ts = 1000+4000 = 5000. Tab b starts at 6000 > 5000 + const result = findNextTabStartAfterGlobalOffset(state, 4000); + expect(result).not.toBeNull(); + expect(result!.tabKey).toBe("b"); + expect(result!.globalOffsetMs).toBe(5000); // 6000 - 1000 + }); + + it("returns null when no tab starts after offset", () => { + const state = twoTabReadyState(); + // globalOffset 9000 => ts = 1000+9000 = 10000. No tab starts after 10000 + expect(findNextTabStartAfterGlobalOffset(state, 9000)).toBeNull(); + }); + + it("skips tabs without full snapshot", () => { + const state = twoTabReadyState(); + state.hasFullSnapshotByTab.delete("b"); + expect(findNextTabStartAfterGlobalOffset(state, 4000)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario tests (multi-step sequences) +// --------------------------------------------------------------------------- + +describe("scenarios", () => { + it("single tab happy path: select -> download -> play -> finish", () => { + let state = createInitialState(); + + // Select recording + let r = dispatch(state, { type: "SELECT_RECORDING", generation: 1 }); + state = r.state; + expect(state.phase).toBe("downloading"); + + // Streams computed + r = dispatch(state, { + type: "STREAMS_COMPUTED", + generation: 1, + streams: makeStreams({ tabKey: "t1", firstMs: 0, lastMs: 5000 }), + globalStartTs: 0, + globalTotalMs: 5000, + chunkRangesByTab: makeChunkRanges({ t1: [[0, 5000]] }), + tabLabelIndex: makeTabLabels({ t1: 1 }), + }); + state = r.state; + expect(state.activeTabKey).toBe("t1"); + + // Chunk loaded + r = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "t1", + hasFullSnapshot: true, + loadedDurationMs: 5000, + hadEventsBeforeThisChunk: false, + }); + state = r.state; + expect(state.hasFullSnapshotByTab.has("t1")).toBe(true); + + // Replayer ready -> auto-plays + r = dispatch(state, { type: "REPLAYER_READY", generation: 1, tabKey: "t1" }); + state = r.state; + expect(state.playbackMode).toBe("playing"); + expect(state.autoPlayTriggered).toBe(true); + + // Download complete + r = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + state = r.state; + expect(state.phase).toBe("ready"); + + // Replayer finishes + r = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "t1", + localTimeMs: 5000, + nowMs: 10000, + }); + state = r.state; + expect(state.playbackMode).toBe("finished"); + }); + + it("two-tab with gap fast-forward between them", () => { + let state: ReplayState = { + ...createInitialState(), + generation: 1, + phase: "ready", + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 3000 }, + { tabKey: "b", firstMs: 5000, lastMs: 8000 }, + ), + globalStartTs: 1000, + globalTotalMs: 7000, + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 3000]], + b: [[5000, 8000]], + }), + tabLabelIndex: makeTabLabels({ a: 1, b: 2 }), + hasFullSnapshotByTab: new Set(["a", "b"]), + loadedDurationByTabMs: new Map([["a", 2000], ["b", 3000]]), + tabsWithEvents: new Set(["a", "b"]), + replayerReady: new Set(["a", "b"]), + activeTabKey: "a", + playbackMode: "playing", + }; + + // Tab a finishes + let r = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 2000, + nowMs: 1000, + }); + state = r.state; + expect(state.playbackMode).toBe("gap_fast_forward"); + expect(state.gapFastForward!.nextTabKey).toBe("b"); + + // Tick completes the gap + r = dispatch(state, { + type: "TICK", + nowMs: 999999, + activeReplayerLocalTimeMs: null, + }); + state = r.state; + expect(state.activeTabKey).toBe("b"); + expect(state.playbackMode).toBe("playing"); + expect(state.gapFastForward).toBeNull(); + }); + + it("seek during playback to different tab", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a" }); + // Seek to tab b's range + const r = dispatch(state, { type: "SEEK", globalOffsetMs: 6000, nowMs: 1000 }); + expect(r.state.activeTabKey).toBe("b"); + expect(r.state.playbackMode).toBe("playing"); + }); + + it("buffering during slow download -> chunk arrives -> resume", () => { + let state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 500); + + // Chunk arrives but not enough yet + let r = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 1500, + hadEventsBeforeThisChunk: true, + }); + state = r.state; + expect(state.playbackMode).toBe("buffering"); + + // Another chunk — now enough + r = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 5000, + hadEventsBeforeThisChunk: true, + }); + state = r.state; + expect(state.playbackMode).toBe("playing"); + }); + + it("user pauses during buffering", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.bufferingAtGlobalMs).toBeNull(); + expect(s.autoResumeAfterBuffering).toBe(false); + }); + + it("tab switch during playback", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a", pausedAtGlobalMs: 2000 }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(s.suppressAutoFollowUntilWallMs).toBe(6000); + }); + + it("stale REPLAYER_FINISH from old generation ignored", () => { + const state = twoTabReadyState({ generation: 5, playbackMode: "playing" }); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 3, + tabKey: "a", + localTimeMs: 1000, + nowMs: 1000, + }); + expect(s).toBe(state); + expect(effects).toHaveLength(0); + }); + + it("premature REPLAYER_FINISH (more loaded data) restarts", () => { + const state = twoTabReadyState({ playbackMode: "playing", activeTabKey: "a" }); + state.loadedDurationByTabMs.set("a", 8000); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 3000, + nowMs: 1000, + }); + expect(s.playbackMode).toBe("playing"); + expect(s.prematureFinishRetryLocalMs).toBe(3000); + const playEffects = getEffects(effects, "play_replayer"); + expect(playEffects).toHaveLength(1); + expect((playEffects[0] as any).tabKey).toBe("a"); + expect((playEffects[0] as any).localOffsetMs).toBe(3000); + }); + + it("detects premature finish loop and emits recreate_replayer", () => { + // Simulate: rrweb fires finish at localTime=5000 but loadedDuration is 300000 (5 min loaded). + // First finish → retry (play_replayer). Second finish at same position → loop detected. + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 300_000); + + // First REPLAYER_FINISH — should retry with play_replayer + const r1 = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 5000, + nowMs: 1000, + }); + expect(r1.state.prematureFinishRetryLocalMs).toBe(5000); + expect(hasEffect(r1.effects, "play_replayer")).toBe(true); + expect(hasEffect(r1.effects, "recreate_replayer")).toBe(false); + + // Second REPLAYER_FINISH at same position — should detect loop + const r2 = dispatch(r1.state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 5000, + nowMs: 1001, + }); + expect(r2.state.prematureFinishRetryLocalMs).toBeNull(); + expect(r2.state.replayerReady.has("a")).toBe(false); + expect(hasEffect(r2.effects, "recreate_replayer")).toBe(true); + expect(hasEffect(r2.effects, "play_replayer")).toBe(false); + const recreateEffects = getEffects(r2.effects, "recreate_replayer"); + expect((recreateEffects[0] as any).tabKey).toBe("a"); + expect((recreateEffects[0] as any).generation).toBe(1); + }); + + it("premature finish at different position resets retry tracking", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + prematureFinishRetryLocalMs: 5000, + }); + state.loadedDurationByTabMs.set("a", 300_000); + + // Finish at a DIFFERENT position (10000 vs tracked 5000) — should retry, not recreate + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "a", + localTimeMs: 10000, + nowMs: 2000, + }); + expect(s.prematureFinishRetryLocalMs).toBe(10000); + expect(hasEffect(effects, "play_replayer")).toBe(true); + expect(hasEffect(effects, "recreate_replayer")).toBe(false); + }); + + it("SEEK resets premature finish retry tracking", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + prematureFinishRetryLocalMs: 5000, + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 1000 }); + expect(s.prematureFinishRetryLocalMs).toBeNull(); + }); + + it("SELECT_TAB resets premature finish retry tracking", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + prematureFinishRetryLocalMs: 5000, + }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.prematureFinishRetryLocalMs).toBeNull(); + }); + + it("long session premature finish loop: retry → detect loop → recreate → resume", () => { + // Simulates a 1h20m session where rrweb's addEvent doesn't extend playable range. + // The replayer keeps firing "finish" at ~5s despite having 5 min of loaded data. + const longTabMs = 80 * 60 * 1000; // 80 minutes + let state: ReplayState = { + ...createInitialState(), + generation: 1, + phase: "downloading", + streams: makeStreams({ tabKey: "t1", firstMs: 0, lastMs: longTabMs }), + globalStartTs: 0, + globalTotalMs: longTabMs, + chunkRangesByTab: makeChunkRanges({ t1: [[0, longTabMs]] }), + tabLabelIndex: makeTabLabels({ t1: 1 }), + hasFullSnapshotByTab: new Set(["t1"]), + loadedDurationByTabMs: new Map([["t1", 300_000]]), // 5 min loaded + tabsWithEvents: new Set(["t1"]), + replayerReady: new Set(["t1"]), + activeTabKey: "t1", + playbackMode: "playing", + autoPlayTriggered: true, + }; + + // 1st REPLAYER_FINISH at 5s — premature, retry + let r = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "t1", + localTimeMs: 5000, + nowMs: 1000, + }); + state = r.state; + expect(state.prematureFinishRetryLocalMs).toBe(5000); + expect(hasEffect(r.effects, "play_replayer")).toBe(true); + + // 2nd REPLAYER_FINISH at same 5s — loop detected, recreate + r = dispatch(state, { + type: "REPLAYER_FINISH", + generation: 1, + tabKey: "t1", + localTimeMs: 5000, + nowMs: 1001, + }); + state = r.state; + expect(state.prematureFinishRetryLocalMs).toBeNull(); + expect(state.replayerReady.has("t1")).toBe(false); + expect(hasEffect(r.effects, "recreate_replayer")).toBe(true); + + // After recreation, REPLAYER_READY fires (new replayer with all 5 min of events) + r = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "t1", + }); + state = r.state; + expect(state.replayerReady.has("t1")).toBe(true); + // Already auto-played, but playbackMode was "playing" so it should play + expect(hasEffect(r.effects, "play_replayer")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Invariant tests (fuzz random action sequences) +// --------------------------------------------------------------------------- + +describe("invariants", () => { + function randomAction(state: ReplayState): ReplayAction { + const actions: ReplayAction[] = [ + { type: "SELECT_RECORDING", generation: state.generation + 1 }, + { type: "TOGGLE_PLAY_PAUSE", nowMs: Math.random() * 10000 }, + { type: "SEEK", globalOffsetMs: Math.random() * (state.globalTotalMs || 1000), nowMs: Math.random() * 10000 }, + { type: "UPDATE_SPEED", speed: [0.5, 1, 2, 4][Math.floor(Math.random() * 4)] }, + { type: "UPDATE_SETTINGS", updates: { followActiveTab: Math.random() > 0.5 } }, + { type: "TICK", nowMs: Math.random() * 10000, activeReplayerLocalTimeMs: Math.random() > 0.3 ? Math.random() * 5000 : null }, + { type: "RESET" }, + ]; + + if (state.streams.length > 0) { + const tabKey = state.streams[Math.floor(Math.random() * state.streams.length)].tabKey; + actions.push( + { type: "SELECT_TAB", tabKey, nowMs: Math.random() * 10000 }, + { type: "CHUNK_LOADED", generation: state.generation, tabKey, hasFullSnapshot: true, loadedDurationMs: Math.random() * 10000, hadEventsBeforeThisChunk: Math.random() > 0.5 }, + { type: "REPLAYER_READY", generation: state.generation, tabKey }, + { type: "REPLAYER_FINISH", generation: state.generation, tabKey, localTimeMs: Math.random() * 5000, nowMs: Math.random() * 10000 }, + { type: "BUFFER_CHECK", generation: state.generation, tabKey }, + ); + } + + if (state.phase === "downloading") { + actions.push( + { type: "DOWNLOAD_COMPLETE", generation: state.generation }, + { type: "DOWNLOAD_ERROR", generation: state.generation, message: "err" }, + ); + } + + return actions[Math.floor(Math.random() * actions.length)]; + } + + function assertInvariants(state: ReplayState) { + // playbackMode is always one variant + expect(["paused", "playing", "buffering", "gap_fast_forward", "finished"]).toContain(state.playbackMode); + + // activeTabKey is in streams or null + if (state.activeTabKey !== null) { + const tabKeys = state.streams.map(s => s.tabKey); + expect(tabKeys).toContain(state.activeTabKey); + } + + // pausedAtGlobalMs is non-negative + expect(state.pausedAtGlobalMs).toBeGreaterThanOrEqual(0); + + // gap fast-forward constraints + if (state.gapFastForward) { + expect(state.gapFastForward.toGlobalMs).toBeGreaterThan(state.gapFastForward.fromGlobalMs); + } + + // generation never decrements (relative to initial 0) + expect(state.generation).toBeGreaterThanOrEqual(0); + } + + it("survives 200 random actions without violating invariants", () => { + let state = createInitialState(); + + // Set up some state first + let r = dispatch(state, { type: "SELECT_RECORDING", generation: 1 }); + state = r.state; + r = dispatch(state, { + type: "STREAMS_COMPUTED", + generation: 1, + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 6000, lastMs: 10000 }, + ), + globalStartTs: 1000, + globalTotalMs: 9000, + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[6000, 10000]], + }), + tabLabelIndex: makeTabLabels({ a: 1, b: 2 }), + }); + state = r.state; + state = { + ...state, + hasFullSnapshotByTab: new Set(["a", "b"]), + loadedDurationByTabMs: new Map([["a", 4000], ["b", 4000]]), + tabsWithEvents: new Set(["a", "b"]), + replayerReady: new Set(["a", "b"]), + }; + + for (let i = 0; i < 200; i++) { + const action = randomAction(state); + const result = replayReducer(state, action); + state = result.state; + assertInvariants(state); + + // Effects never reference absent tabKeys + for (const effect of result.effects) { + if ("tabKey" in effect && effect.tabKey) { + const tabKeys = new Set(state.streams.map(s => s.tabKey)); + // After SELECT_RECORDING, streams may be empty — that's fine, + // the effect was issued before the reset cleared streams. + // We only check if streams exist. + if (tabKeys.size > 0) { + // Note: effects reference tabs from pre-transition state which + // might not be in post-transition state after SELECT_RECORDING. + // This is ok — the effect executor checks generation. + } + } + } + } + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts new file mode 100644 index 0000000000..65522ac46c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts @@ -0,0 +1,995 @@ +import type { TabKey, TabStream } from "@/lib/session-replay-streams"; +import { + getDesiredGlobalOffsetFromPlaybackState, + getReplayFinishAction, + INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, + applySeekState, +} from "@/lib/session-replay-playback"; +import { + globalOffsetToLocalOffset, + localOffsetToGlobalOffset, +} from "@/lib/session-replay-streams"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; + +// --------------------------------------------------------------------------- +// Shared constants (also used by the component shell) +// --------------------------------------------------------------------------- + +export const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); + +export const DEFAULT_REPLAY_SETTINGS: ReplaySettings = { + playerSpeed: 1, + skipInactivity: true, + followActiveTab: false, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ReplaySettings = { + playerSpeed: number, + skipInactivity: boolean, + followActiveTab: boolean, +}; + +export type ChunkRange = { startTs: number, endTs: number }; + +export type GapFastForward = { + fromGlobalMs: number, + toGlobalMs: number, + wallMs: number, + nextTabKey: TabKey, + gen: number, +}; + +export type PlaybackMode = + | "paused" + | "playing" + | "buffering" + | "gap_fast_forward" + | "finished"; + +export type Phase = "idle" | "downloading" | "ready"; + +/** Minimal stream info the machine needs (no DOM / rrweb refs). */ +export type StreamInfo = { + tabKey: TabKey, + firstEventAtMs: number, + lastEventAtMs: number, +}; + +export type ReplayState = { + generation: number, + phase: Phase, + + playbackMode: PlaybackMode, + activeTabKey: TabKey | null, + pausedAtGlobalMs: number, + currentGlobalTimeMsForUi: number, + + streams: StreamInfo[], + globalStartTs: number, + globalTotalMs: number, + + chunkRangesByTab: Map, + tabLabelIndex: Map, + hasFullSnapshotByTab: Set, + loadedDurationByTabMs: Map, + tabsWithEvents: Set, + + replayerReady: Set, + + settings: ReplaySettings, + autoPlayTriggered: boolean, + suppressAutoFollowUntilWallMs: number, + autoResumeAfterBuffering: boolean, + bufferingAtGlobalMs: number | null, + + gapFastForward: GapFastForward | null, + + /** Tracks the localTimeMs of the last premature-finish retry so we can + * detect an infinite loop where rrweb keeps firing "finish" at the same + * position because `addEvent` didn't extend the playable range. */ + prematureFinishRetryLocalMs: number | null, + + downloadError: string | null, + playerError: string | null, +}; + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +export type ReplayAction = + | { type: "SELECT_RECORDING", generation: number } + | { + type: "STREAMS_COMPUTED", + generation: number, + streams: StreamInfo[], + globalStartTs: number, + globalTotalMs: number, + chunkRangesByTab: Map, + tabLabelIndex: Map, + } + | { type: "DOWNLOAD_COMPLETE", generation: number } + | { type: "DOWNLOAD_ERROR", generation: number, message: string } + | { + type: "CHUNK_LOADED", + generation: number, + tabKey: TabKey, + hasFullSnapshot: boolean, + loadedDurationMs: number, + hadEventsBeforeThisChunk: boolean, + } + | { type: "REPLAYER_READY", generation: number, tabKey: TabKey } + | { type: "REPLAYER_INIT_ERROR", generation: number, message: string } + | { + type: "REPLAYER_FINISH", + generation: number, + tabKey: TabKey, + localTimeMs: number, + nowMs: number, + } + | { type: "TOGGLE_PLAY_PAUSE", nowMs: number } + | { type: "SEEK", globalOffsetMs: number, nowMs: number } + | { type: "SELECT_TAB", tabKey: TabKey, nowMs: number } + | { type: "UPDATE_SPEED", speed: number } + | { type: "UPDATE_SETTINGS", updates: Partial } + | { + type: "TICK", + nowMs: number, + activeReplayerLocalTimeMs: number | null, + } + | { type: "BUFFER_CHECK", generation: number, tabKey: TabKey } + | { type: "RESET" }; + +// --------------------------------------------------------------------------- +// Effects (data, not imperative) +// --------------------------------------------------------------------------- + +export type ReplayEffect = + | { type: "play_replayer", tabKey: TabKey, localOffsetMs: number } + | { type: "pause_replayer_at", tabKey: TabKey, localOffsetMs: number } + | { type: "pause_all" } + | { type: "ensure_replayer", tabKey: TabKey, generation: number } + | { type: "destroy_all_replayers" } + | { type: "set_replayer_speed", speed: number } + | { type: "set_replayer_skip_inactive", skipInactive: boolean } + | { type: "sync_mini_tabs", globalOffsetMs: number } + | { type: "schedule_buffer_poll", generation: number, tabKey: TabKey, localTimeMs: number, delayMs: number } + | { type: "save_settings", settings: ReplaySettings } + | { type: "recreate_replayer", tabKey: TabKey, generation: number }; + +export type ReducerResult = { + state: ReplayState, + effects: ReplayEffect[], +}; + +// --------------------------------------------------------------------------- +// Initial state +// --------------------------------------------------------------------------- + +export function createInitialState(settings?: ReplaySettings): ReplayState { + return { + generation: 0, + phase: "idle", + playbackMode: "paused", + activeTabKey: null, + pausedAtGlobalMs: 0, + currentGlobalTimeMsForUi: 0, + streams: [], + globalStartTs: 0, + globalTotalMs: 0, + chunkRangesByTab: new Map(), + tabLabelIndex: new Map(), + hasFullSnapshotByTab: new Set(), + loadedDurationByTabMs: new Map(), + tabsWithEvents: new Set(), + replayerReady: new Set(), + settings: settings ?? { ...DEFAULT_REPLAY_SETTINGS }, + autoPlayTriggered: false, + suppressAutoFollowUntilWallMs: 0, + autoResumeAfterBuffering: false, + bufferingAtGlobalMs: null, + gapFastForward: null, + prematureFinishRetryLocalMs: null, + downloadError: null, + playerError: null, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +export function findBestTabAtGlobalOffset( + state: ReplayState, + globalOffsetMs: number, + excludeTabKey?: TabKey, +): TabKey | null { + const ts = state.globalStartTs + globalOffsetMs; + const candidates = state.streams.filter((s) => { + if (excludeTabKey && s.tabKey === excludeTabKey) return false; + if (!state.hasFullSnapshotByTab.has(s.tabKey)) return false; + const ranges = state.chunkRangesByTab.get(s.tabKey) ?? []; + let lo = 0; + let hi = ranges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const r = ranges[mid]!; + if (ts < r.startTs) { + hi = mid - 1; + } else if (ts > r.endTs) { + lo = mid + 1; + } else { + return true; + } + } + return false; + }); + if (candidates.length === 0) return null; + + candidates.sort((a, b) => { + const aLabel = state.tabLabelIndex.get(a.tabKey) ?? Number.POSITIVE_INFINITY; + const bLabel = state.tabLabelIndex.get(b.tabKey) ?? Number.POSITIVE_INFINITY; + if (aLabel !== bLabel) return aLabel - bLabel; + return stringCompare(a.tabKey, b.tabKey); + }); + + return candidates[0]!.tabKey; +} + +export function isTabInRangeAtGlobalOffset( + state: ReplayState, + tabKey: TabKey, + globalOffsetMs: number, +): boolean { + if (!state.hasFullSnapshotByTab.has(tabKey)) return false; + const ts = state.globalStartTs + globalOffsetMs; + const ranges = state.chunkRangesByTab.get(tabKey) ?? []; + let lo = 0; + let hi = ranges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const r = ranges[mid]!; + if (ts < r.startTs) { + hi = mid - 1; + } else if (ts > r.endTs) { + lo = mid + 1; + } else { + return true; + } + } + return false; +} + +export function findNextTabStartAfterGlobalOffset( + state: ReplayState, + globalOffsetMs: number, +): { tabKey: TabKey, globalOffsetMs: number } | null { + const ts = state.globalStartTs + globalOffsetMs; + let bestStartTs = Infinity; + let bestKey: TabKey | null = null; + + for (const s of state.streams) { + if (!state.hasFullSnapshotByTab.has(s.tabKey)) continue; + const ranges = state.chunkRangesByTab.get(s.tabKey) ?? []; + for (const r of ranges) { + if (r.startTs <= ts) continue; + if (r.startTs < bestStartTs) { + bestStartTs = r.startTs; + bestKey = s.tabKey; + } + break; // ranges sorted by start + } + } + + if (!bestKey || !Number.isFinite(bestStartTs)) return null; + return { + tabKey: bestKey, + globalOffsetMs: bestStartTs - state.globalStartTs, + }; +} + +function getStreamInfo(state: ReplayState, tabKey: TabKey): StreamInfo | null { + return state.streams.find(s => s.tabKey === tabKey) ?? null; +} + +function computeDesiredGlobalOffset( + state: ReplayState, + nowMs: number, + activeReplayerLocalTimeMs: number | null, +): number { + const activeStream = state.activeTabKey ? getStreamInfo(state, state.activeTabKey) : null; + return getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: state.gapFastForward, + playerIsPlaying: state.playbackMode === "playing" || state.playbackMode === "gap_fast_forward", + nowMs, + playerSpeed: state.settings.playerSpeed, + pausedAtGlobalMs: state.pausedAtGlobalMs, + activeLocalOffsetMs: activeReplayerLocalTimeMs, + activeStreamStartTs: activeStream?.firstEventAtMs ?? null, + globalStartTs: state.globalStartTs, + gapFastForwardMultiplier: INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, + }); +} + +function playEffectsForAllTabs(state: ReplayState, globalOffsetMs: number): ReplayEffect[] { + const effects: ReplayEffect[] = []; + for (const s of state.streams) { + const localOffset = globalOffsetToLocalOffset(state.globalStartTs, s.firstEventAtMs, globalOffsetMs); + if (s.tabKey === state.activeTabKey) { + effects.push({ type: "play_replayer", tabKey: s.tabKey, localOffsetMs: localOffset }); + } else if (state.replayerReady.has(s.tabKey)) { + effects.push({ type: "pause_replayer_at", tabKey: s.tabKey, localOffsetMs: localOffset }); + } + } + return effects; +} + +function isStaleGeneration(state: ReplayState, generation: number): boolean { + return generation !== state.generation; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +export function replayReducer(state: ReplayState, action: ReplayAction): ReducerResult { + switch (action.type) { + case "SELECT_RECORDING": { + const newState: ReplayState = { + ...createInitialState(state.settings), + generation: action.generation, + phase: "downloading", + }; + return { + state: newState, + effects: [{ type: "destroy_all_replayers" }], + }; + } + + case "STREAMS_COMPUTED": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + const firstStream = action.streams[0] as StreamInfo | undefined; + const initialActive = ( + action.streams.find(s => s.firstEventAtMs === action.globalStartTs)?.tabKey + ?? firstStream?.tabKey + ?? null + ); + return { + state: { + ...state, + streams: action.streams, + globalStartTs: action.globalStartTs, + globalTotalMs: action.globalTotalMs, + chunkRangesByTab: action.chunkRangesByTab, + tabLabelIndex: action.tabLabelIndex, + activeTabKey: initialActive, + pausedAtGlobalMs: 0, + currentGlobalTimeMsForUi: 0, + }, + effects: [], + }; + } + + case "DOWNLOAD_COMPLETE": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + + const effects: ReplayEffect[] = []; + let newPlaybackMode = state.playbackMode; + + // Safety net: if buffering when download finishes, resume + if (state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering) { + const seekTo = state.bufferingAtGlobalMs; + newPlaybackMode = "playing"; + effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, seekTo)); + } + + return { + state: { + ...state, + phase: "ready", + playbackMode: state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering + ? "playing" + : (state.playbackMode === "buffering" ? "paused" : state.playbackMode), + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects, + }; + } + + case "DOWNLOAD_ERROR": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + return { + state: { + ...state, + phase: "ready", + downloadError: action.message, + }, + effects: [], + }; + } + + case "CHUNK_LOADED": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + + const newHasFullSnapshot = new Set(state.hasFullSnapshotByTab); + if (action.hasFullSnapshot) { + newHasFullSnapshot.add(action.tabKey); + } + + const newLoadedDuration = new Map(state.loadedDurationByTabMs); + newLoadedDuration.set(action.tabKey, action.loadedDurationMs); + + const newTabsWithEvents = new Set(state.tabsWithEvents); + newTabsWithEvents.add(action.tabKey); + + const effects: ReplayEffect[] = []; + + // Ensure replayer for any tab that has a full snapshot but no ready replayer. + // This covers first-chunk init, tabs that got a FullSnapshot in a later chunk, + // and retry after a failed/pending init. ensureReplayerForTab is idempotent. + if (newHasFullSnapshot.has(action.tabKey) && !state.replayerReady.has(action.tabKey)) { + effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: action.generation }); + } + + // Check if buffering can be resolved by new data + let newPlaybackMode = state.playbackMode; + let newBufferingAtGlobalMs = state.bufferingAtGlobalMs; + let newAutoResumeAfterBuffering = state.autoResumeAfterBuffering; + let newPausedAtGlobalMs = state.pausedAtGlobalMs; + + if ( + state.activeTabKey === action.tabKey + && state.bufferingAtGlobalMs !== null + ) { + const stream = getStreamInfo(state, action.tabKey); + if (stream) { + const targetLocal = globalOffsetToLocalOffset( + state.globalStartTs, + stream.firstEventAtMs, + state.bufferingAtGlobalMs, + ); + const bufferAhead = state.phase === "downloading" ? 2000 : 0; + if (action.loadedDurationMs >= targetLocal + bufferAhead) { + const seekTo = state.bufferingAtGlobalMs; + newBufferingAtGlobalMs = null; + + if (state.autoResumeAfterBuffering) { + newAutoResumeAfterBuffering = false; + newPlaybackMode = "playing"; + newPausedAtGlobalMs = seekTo; + effects.push(...playEffectsForAllTabs( + { ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, + seekTo, + )); + } else { + newPlaybackMode = "paused"; + } + } + } + } + + return { + state: { + ...state, + hasFullSnapshotByTab: newHasFullSnapshot, + loadedDurationByTabMs: newLoadedDuration, + tabsWithEvents: newTabsWithEvents, + playbackMode: newPlaybackMode, + bufferingAtGlobalMs: newBufferingAtGlobalMs, + autoResumeAfterBuffering: newAutoResumeAfterBuffering, + pausedAtGlobalMs: newPausedAtGlobalMs, + }, + effects, + }; + } + + case "REPLAYER_READY": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + + const newReplayerReady = new Set(state.replayerReady); + newReplayerReady.add(action.tabKey); + + const isActiveTab = state.activeTabKey === action.tabKey; + const shouldAutoPlay = !state.autoPlayTriggered && isActiveTab; + const shouldPlay = isActiveTab && (shouldAutoPlay || (state.playbackMode === "playing")); + + const effects: ReplayEffect[] = []; + const stream = getStreamInfo(state, action.tabKey); + const streamStartTs = stream?.firstEventAtMs ?? state.globalStartTs; + const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, state.pausedAtGlobalMs); + + if (shouldPlay) { + effects.push({ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: desiredLocal }); + } else { + effects.push({ type: "pause_replayer_at", tabKey: action.tabKey, localOffsetMs: desiredLocal }); + } + + return { + state: { + ...state, + replayerReady: newReplayerReady, + autoPlayTriggered: state.autoPlayTriggered || shouldAutoPlay, + playbackMode: shouldAutoPlay && state.playbackMode !== "buffering" + ? "playing" + : state.playbackMode, + }, + effects, + }; + } + + case "REPLAYER_INIT_ERROR": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + return { + state: { ...state, playerError: action.message }, + effects: [], + }; + } + + case "REPLAYER_FINISH": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + if (state.activeTabKey !== action.tabKey) return { state, effects: [] }; + + const localTime = action.localTimeMs; + const loadedDurationMs = state.loadedDurationByTabMs.get(action.tabKey) ?? 0; + + // Premature finish: rrweb fires "finish" but more data was added via addEvent. + // Guard: if we already retried at this same position, rrweb's addEvent didn't + // extend the playable range. Recreate the replayer with all loaded events + // instead of looping infinitely. + if (loadedDurationMs > localTime + 100) { + const isRepeatedAtSamePosition = + state.prematureFinishRetryLocalMs !== null + && Math.abs(state.prematureFinishRetryLocalMs - localTime) < 200; + + if (isRepeatedAtSamePosition) { + // Loop detected — recreate the rrweb Replayer with all loaded events. + const newReplayerReady = new Set(state.replayerReady); + newReplayerReady.delete(action.tabKey); + return { + state: { + ...state, + replayerReady: newReplayerReady, + prematureFinishRetryLocalMs: null, + }, + effects: [{ type: "recreate_replayer", tabKey: action.tabKey, generation: action.generation }], + }; + } + + return { + state: { ...state, prematureFinishRetryLocalMs: localTime }, + effects: [{ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: localTime }], + }; + } + + const stream = getStreamInfo(state, action.tabKey); + const streamStartTs = stream?.firstEventAtMs ?? state.globalStartTs; + const tabExpectedDurationMs = stream + ? stream.lastEventAtMs - stream.firstEventAtMs + : null; + + // Buffer if still downloading and tab expects more events + if ( + state.phase === "downloading" + && tabExpectedDurationMs !== null + && tabExpectedDurationMs > localTime + 500 + ) { + const globalOffset = localOffsetToGlobalOffset(state.globalStartTs, streamStartTs, localTime); + return { + state: { + ...state, + playbackMode: "buffering", + pausedAtGlobalMs: globalOffset, + bufferingAtGlobalMs: globalOffset, + autoResumeAfterBuffering: true, + }, + effects: [ + { type: "schedule_buffer_poll", generation: action.generation, tabKey: action.tabKey, localTimeMs: localTime, delayMs: 500 }, + ], + }; + } + + let globalOffset = localOffsetToGlobalOffset(state.globalStartTs, streamStartTs, localTime); + + // Find the best OTHER tab at this offset + let bestKey = findBestTabAtGlobalOffset(state, globalOffset, action.tabKey); + + // Retry with authoritative time if stale + if (!bestKey && globalOffset + 500 < state.currentGlobalTimeMsForUi) { + globalOffset = state.currentGlobalTimeMsForUi; + bestKey = findBestTabAtGlobalOffset(state, globalOffset, action.tabKey); + } + + // Another tab has events — switch + if (bestKey) { + const effects: ReplayEffect[] = [ + { type: "ensure_replayer", tabKey: bestKey, generation: action.generation }, + ...playEffectsForAllTabs( + { ...state, activeTabKey: bestKey }, + globalOffset, + ), + ]; + return { + state: { + ...state, + activeTabKey: bestKey, + playbackMode: "playing", + pausedAtGlobalMs: globalOffset, + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }, + effects, + }; + } + + // No alternative tab — gap, buffer, or true finish + const currentTabHasMoreExpectedEvents = tabExpectedDurationMs !== null + && tabExpectedDurationMs > localTime + 500; + + const nextStart = findNextTabStartAfterGlobalOffset(state, globalOffset); + const finishAction = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: state.phase === "downloading", + nextStartGlobalOffsetMs: nextStart?.globalOffsetMs ?? null, + currentGlobalOffsetMs: Math.max(globalOffset, state.currentGlobalTimeMsForUi), + currentTabHasMoreExpectedEvents, + }); + + if (finishAction.type === "gap_fast_forward" && nextStart) { + const gff: GapFastForward = { + fromGlobalMs: globalOffset, + toGlobalMs: finishAction.toGlobalMs, + wallMs: action.nowMs, + nextTabKey: nextStart.tabKey, + gen: action.generation, + }; + return { + state: { + ...state, + playbackMode: "gap_fast_forward", + gapFastForward: gff, + pausedAtGlobalMs: globalOffset, + }, + effects: [], + }; + } + + if (finishAction.type === "buffer_at_current") { + return { + state: { + ...state, + playbackMode: "buffering", + pausedAtGlobalMs: globalOffset, + bufferingAtGlobalMs: globalOffset, + autoResumeAfterBuffering: true, + }, + effects: [], + }; + } + + // True finish + return { + state: { + ...state, + playbackMode: "finished", + pausedAtGlobalMs: state.globalTotalMs, + currentGlobalTimeMsForUi: state.globalTotalMs, + gapFastForward: null, + bufferingAtGlobalMs: null, + }, + effects: [{ type: "pause_all" }], + }; + } + + case "TOGGLE_PLAY_PAUSE": { + const isPlaying = state.playbackMode === "playing" || state.playbackMode === "gap_fast_forward"; + const isBuffering = state.playbackMode === "buffering"; + + if (isPlaying || isBuffering) { + // Pause + return { + state: { + ...state, + playbackMode: "paused", + gapFastForward: null, + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: [{ type: "pause_all" }], + }; + } + + // Play + const target = state.pausedAtGlobalMs; + + // Check if active tab needs buffering + if (state.phase === "downloading" && state.activeTabKey) { + const stream = getStreamInfo(state, state.activeTabKey); + if (stream) { + const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, target); + const loaded = state.loadedDurationByTabMs.get(state.activeTabKey) ?? 0; + if (localTarget > loaded) { + return { + state: { + ...state, + playbackMode: "buffering", + bufferingAtGlobalMs: target, + autoResumeAfterBuffering: true, + }, + effects: [], + }; + } + } + } + + return { + state: { + ...state, + playbackMode: "playing", + bufferingAtGlobalMs: null, + gapFastForward: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }, + effects: playEffectsForAllTabs(state, target), + }; + } + + case "SEEK": { + const seekState = applySeekState({ seekToGlobalMs: action.globalOffsetMs }); + let newActiveTabKey = state.activeTabKey; + const effects: ReplayEffect[] = []; + + // Switch tab if seek target is outside active tab's range + const desiredKey = findBestTabAtGlobalOffset(state, action.globalOffsetMs); + if (desiredKey && desiredKey !== state.activeTabKey) { + newActiveTabKey = desiredKey; + effects.push({ type: "ensure_replayer", tabKey: desiredKey, generation: state.generation }); + } + + const stateWithNewTab = { ...state, activeTabKey: newActiveTabKey }; + + // Check if buffering needed + if (state.phase === "downloading" && newActiveTabKey) { + const stream = getStreamInfo(state, newActiveTabKey); + if (stream) { + const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, action.globalOffsetMs); + const loaded = state.loadedDurationByTabMs.get(newActiveTabKey) ?? 0; + if (localTarget > loaded) { + effects.push({ type: "pause_all" }); + return { + state: { + ...stateWithNewTab, + playbackMode: "buffering", + pausedAtGlobalMs: seekState.pausedAtGlobalMs, + gapFastForward: null, + bufferingAtGlobalMs: action.globalOffsetMs, + autoResumeAfterBuffering: true, + prematureFinishRetryLocalMs: null, + }, + effects, + }; + } + } + } + + effects.push(...playEffectsForAllTabs(stateWithNewTab, action.globalOffsetMs)); + + return { + state: { + ...stateWithNewTab, + playbackMode: "playing", + pausedAtGlobalMs: seekState.pausedAtGlobalMs, + gapFastForward: null, + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + currentGlobalTimeMsForUi: action.globalOffsetMs, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + prematureFinishRetryLocalMs: null, + }, + effects, + }; + } + + case "SELECT_TAB": { + const effects: ReplayEffect[] = [{ type: "pause_all" }]; + const wasPlaying = state.playbackMode === "playing" || state.playbackMode === "gap_fast_forward"; + + // Check if buffering needed for the new tab + if (state.phase === "downloading") { + const stream = getStreamInfo(state, action.tabKey); + if (stream) { + const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, state.pausedAtGlobalMs); + const loaded = state.loadedDurationByTabMs.get(action.tabKey) ?? 0; + if (localTarget > loaded) { + effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: state.generation }); + return { + state: { + ...state, + activeTabKey: action.tabKey, + playbackMode: "buffering", + gapFastForward: null, + bufferingAtGlobalMs: state.pausedAtGlobalMs, + autoResumeAfterBuffering: true, + suppressAutoFollowUntilWallMs: action.nowMs + 5000, + prematureFinishRetryLocalMs: null, + }, + effects, + }; + } + } + } + + effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: state.generation }); + + if (wasPlaying) { + effects.push(...playEffectsForAllTabs({ ...state, activeTabKey: action.tabKey }, state.pausedAtGlobalMs)); + } + + return { + state: { + ...state, + activeTabKey: action.tabKey, + playbackMode: wasPlaying ? "playing" : "paused", + gapFastForward: null, + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + suppressAutoFollowUntilWallMs: action.nowMs + 5000, + prematureFinishRetryLocalMs: null, + }, + effects, + }; + } + + case "UPDATE_SPEED": { + if (!ALLOWED_PLAYER_SPEEDS.has(action.speed)) return { state, effects: [] }; + const newSettings = { ...state.settings, playerSpeed: action.speed }; + return { + state: { ...state, settings: newSettings }, + effects: [ + { type: "set_replayer_speed", speed: action.speed }, + { type: "save_settings", settings: newSettings }, + ], + }; + } + + case "UPDATE_SETTINGS": { + const newSettings = { ...state.settings, ...action.updates }; + const effects: ReplayEffect[] = [ + { type: "save_settings", settings: newSettings }, + ]; + + if (action.updates.skipInactivity !== undefined) { + effects.push({ type: "set_replayer_skip_inactive", skipInactive: action.updates.skipInactivity }); + } + + return { + state: { ...state, settings: newSettings }, + effects, + }; + } + + case "TICK": { + let globalOffset = computeDesiredGlobalOffset(state, action.nowMs, action.activeReplayerLocalTimeMs); + const previousGlobalOffset = state.currentGlobalTimeMsForUi; + const effects: ReplayEffect[] = []; + + // Monotonicity guard: during playing, don't let stale rrweb readings jump back + if ( + state.playbackMode === "playing" + && !state.gapFastForward + && action.nowMs >= state.suppressAutoFollowUntilWallMs + && globalOffset + 500 < previousGlobalOffset + ) { + globalOffset = previousGlobalOffset; + } + + let newState = { ...state, currentGlobalTimeMsForUi: globalOffset }; + + // Sync mini tabs + if (state.playbackMode === "playing") { + effects.push({ type: "sync_mini_tabs", globalOffsetMs: globalOffset }); + } + + // Gap fast-forward completion + const gff = state.gapFastForward; + if (gff && globalOffset >= gff.toGlobalMs) { + newState = { + ...newState, + gapFastForward: null, + activeTabKey: gff.nextTabKey, + playbackMode: "playing", + pausedAtGlobalMs: gff.toGlobalMs, + currentGlobalTimeMsForUi: gff.toGlobalMs, + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + suppressAutoFollowUntilWallMs: action.nowMs + 200, + }; + effects.push( + { type: "ensure_replayer", tabKey: gff.nextTabKey, generation: gff.gen }, + ...playEffectsForAllTabs(newState, gff.toGlobalMs), + ); + return { state: newState, effects }; + } + + // Auto-follow active tab + if ( + state.settings.followActiveTab + && state.playbackMode === "playing" + && state.streams.length > 1 + ) { + if (action.nowMs >= state.suppressAutoFollowUntilWallMs) { + const activeInRange = state.activeTabKey + ? isTabInRangeAtGlobalOffset(state, state.activeTabKey, globalOffset) + : false; + if (!activeInRange) { + const bestKey = findBestTabAtGlobalOffset(state, globalOffset); + if (bestKey && bestKey !== state.activeTabKey) { + newState = { + ...newState, + activeTabKey: bestKey, + pausedAtGlobalMs: globalOffset, + suppressAutoFollowUntilWallMs: action.nowMs + 200, + }; + effects.push( + { type: "ensure_replayer", tabKey: bestKey, generation: state.generation }, + ...playEffectsForAllTabs(newState, globalOffset), + ); + } + } + } + } + + return { state: newState, effects }; + } + + case "BUFFER_CHECK": { + if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; + if (state.activeTabKey !== action.tabKey) return { state, effects: [] }; + if (state.playbackMode !== "buffering") return { state, effects: [] }; + if (state.bufferingAtGlobalMs === null) return { state, effects: [] }; + + const stream = getStreamInfo(state, action.tabKey); + if (!stream) return { state, effects: [] }; + + const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, state.bufferingAtGlobalMs); + const loaded = state.loadedDurationByTabMs.get(action.tabKey) ?? 0; + + if (loaded > localTarget + 2000 || state.phase !== "downloading") { + const seekTo = state.bufferingAtGlobalMs; + return { + state: { + ...state, + playbackMode: "playing", + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: playEffectsForAllTabs(state, seekTo), + }; + } + + // Still buffering — schedule another poll + return { + state, + effects: [ + { type: "schedule_buffer_poll", generation: action.generation, tabKey: action.tabKey, localTimeMs: localTarget, delayMs: 500 }, + ], + }; + } + + case "RESET": { + return { + state: createInitialState(state.settings), + effects: [{ type: "destroy_all_replayers" }], + }; + } + + default: { + return { state, effects: [] }; + } + } +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-playback.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-playback.test.ts new file mode 100644 index 0000000000..53bf7c1796 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-playback.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { + getDesiredGlobalOffsetFromPlaybackState, + getReplayFinishAction, + applySeekState, + INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, +} from "@/lib/session-replay-playback"; + +describe("getDesiredGlobalOffsetFromPlaybackState", () => { + it("returns pausedAtGlobalMs when paused", () => { + expect(getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: null, + playerIsPlaying: false, + nowMs: 1000, + playerSpeed: 1, + pausedAtGlobalMs: 5000, + activeLocalOffsetMs: 3000, + activeStreamStartTs: 100, + globalStartTs: 0, + })).toBe(5000); + }); + + it("computes global offset from active replayer local time when playing", () => { + // globalOffset = activeLocalOffsetMs + (activeStreamStartTs - globalStartTs) + expect(getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: null, + playerIsPlaying: true, + nowMs: 1000, + playerSpeed: 1, + pausedAtGlobalMs: 0, + activeLocalOffsetMs: 2000, + activeStreamStartTs: 500, + globalStartTs: 100, + })).toBe(2000 + (500 - 100)); + }); + + it("falls back to pausedAtGlobalMs when playing but no active replayer", () => { + expect(getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: null, + playerIsPlaying: true, + nowMs: 1000, + playerSpeed: 1, + pausedAtGlobalMs: 3000, + activeLocalOffsetMs: null, + activeStreamStartTs: null, + globalStartTs: 0, + })).toBe(3000); + }); + + it("computes gap fast-forward interpolation", () => { + const wallMs = 1000; + const nowMs = 1100; // 100ms elapsed + const speed = 2; + const multiplier = INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER; + const from = 5000; + const to = 20000; + + const result = getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: { fromGlobalMs: from, toGlobalMs: to, wallMs }, + playerIsPlaying: true, + nowMs, + playerSpeed: speed, + pausedAtGlobalMs: 0, + activeLocalOffsetMs: null, + activeStreamStartTs: null, + globalStartTs: 0, + }); + + const expected = Math.min(to, from + (nowMs - wallMs) * speed * multiplier); + expect(result).toBe(expected); + }); + + it("clamps gap fast-forward to toGlobalMs", () => { + const result = getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: { fromGlobalMs: 5000, toGlobalMs: 6000, wallMs: 0 }, + playerIsPlaying: true, + nowMs: 999999, + playerSpeed: 4, + pausedAtGlobalMs: 0, + activeLocalOffsetMs: null, + activeStreamStartTs: null, + globalStartTs: 0, + }); + + expect(result).toBe(6000); + }); + + it("uses custom gapFastForwardMultiplier", () => { + const result = getDesiredGlobalOffsetFromPlaybackState({ + gapFastForward: { fromGlobalMs: 0, toGlobalMs: 100000, wallMs: 0 }, + playerIsPlaying: true, + nowMs: 100, + playerSpeed: 1, + pausedAtGlobalMs: 0, + activeLocalOffsetMs: null, + activeStreamStartTs: null, + globalStartTs: 0, + gapFastForwardMultiplier: 1, + }); + + expect(result).toBe(100); + }); +}); + +describe("getReplayFinishAction", () => { + it("throws when hasBestTabAtCurrentTime is true", () => { + expect(() => getReplayFinishAction({ + hasBestTabAtCurrentTime: true, + isDownloading: false, + nextStartGlobalOffsetMs: null, + })).toThrow(); + }); + + it("returns buffer_at_current when downloading and tab has more expected events", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: true, + nextStartGlobalOffsetMs: 10000, + currentTabHasMoreExpectedEvents: true, + }); + expect(result).toEqual({ type: "buffer_at_current" }); + }); + + it("returns gap_fast_forward when next start exists after current offset", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: false, + nextStartGlobalOffsetMs: 10000, + currentGlobalOffsetMs: 5000, + }); + expect(result).toEqual({ type: "gap_fast_forward", toGlobalMs: 10000 }); + }); + + it("returns gap_fast_forward when no currentGlobalOffsetMs provided", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: false, + nextStartGlobalOffsetMs: 10000, + }); + expect(result).toEqual({ type: "gap_fast_forward", toGlobalMs: 10000 }); + }); + + it("returns buffer_at_current when downloading and no next start but gap-forward is not possible", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: true, + nextStartGlobalOffsetMs: null, + }); + expect(result).toEqual({ type: "buffer_at_current" }); + }); + + it("returns finish_replay when not downloading and no next tab", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: false, + nextStartGlobalOffsetMs: null, + }); + expect(result).toEqual({ type: "finish_replay" }); + }); + + it("does not gap-forward when next start is behind current offset", () => { + const result = getReplayFinishAction({ + hasBestTabAtCurrentTime: false, + isDownloading: false, + nextStartGlobalOffsetMs: 3000, + currentGlobalOffsetMs: 5000, + }); + // next start <= current => skip, falls through to finish + expect(result).toEqual({ type: "finish_replay" }); + }); +}); + +describe("applySeekState", () => { + it("returns pausedAtGlobalMs and clearGapFastForward", () => { + const result = applySeekState({ seekToGlobalMs: 7500 }); + expect(result).toEqual({ + pausedAtGlobalMs: 7500, + clearGapFastForward: true, + }); + }); +}); From 278a7455b769f04f668fcc526fdf8080f95ee5c4 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 15:22:01 -0800 Subject: [PATCH 16/21] grouped chunk event fetching --- .../[session_recording_id]/events/route.tsx | 157 +++++++++++++ .../analytics/replays/page-client.tsx | 213 ++++++++---------- .../src/interface/admin-interface.ts | 13 ++ .../src/interface/crud/session-recordings.ts | 17 ++ .../apps/implementations/admin-app-impl.ts | 24 +- .../stack-app/apps/interfaces/admin-app.ts | 20 +- 6 files changed, 321 insertions(+), 123 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/events/route.tsx diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/events/route.tsx b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/events/route.tsx new file mode 100644 index 0000000000..29bb40f6b8 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/events/route.tsx @@ -0,0 +1,157 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { downloadBytes } from "@/s3"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { promisify } from "node:util"; +import { gunzip as gunzipCb } from "node:zlib"; + +const gunzip = promisify(gunzipCb); + +const S3_CONCURRENCY = 10; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + session_recording_id: yupString().defined(), + }).defined(), + query: yupObject({ + offset: yupString().optional(), + limit: yupString().optional(), + }).optional(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + chunks: yupArray(yupObject({ + id: yupString().defined(), + batch_id: yupString().defined(), + tab_id: yupString().nullable().defined(), + event_count: yupNumber().defined(), + byte_length: yupNumber().defined(), + first_event_at_millis: yupNumber().defined(), + last_event_at_millis: yupNumber().defined(), + created_at_millis: yupNumber().defined(), + }).defined()).defined(), + chunk_events: yupArray(yupObject({ + chunk_id: yupString().defined(), + events: yupArray(yupMixed().defined()).defined(), + }).defined()).defined(), + }).defined(), + }), + async handler({ auth, params, query }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + const sessionRecordingId = params.session_recording_id; + const exists = await prisma.sessionRecording.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionRecordingId } }, + select: { id: true }, + }); + if (!exists) { + throw new KnownErrors.ItemNotFound(sessionRecordingId); + } + + const chunks = await prisma.sessionRecordingChunk.findMany({ + where: { + tenancyId: auth.tenancy.id, + sessionRecordingId, + }, + orderBy: [{ firstEventAt: "asc" }, { id: "asc" }], + select: { + id: true, + batchId: true, + tabId: true, + eventCount: true, + byteLength: true, + firstEventAt: true, + lastEventAt: true, + createdAt: true, + s3Key: true, + }, + }); + + // Determine the slice of chunks to download events for. + const parsedOffset = query.offset != null ? Number.parseInt(query.offset, 10) : 0; + const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; + const parsedLimit = query.limit != null ? Number.parseInt(query.limit, 10) : chunks.length; + const limit = Number.isFinite(parsedLimit) && parsedLimit >= 1 ? parsedLimit : chunks.length; + const sliceEnd = Math.min(offset + limit, chunks.length); + const chunksToDownload = chunks.slice(offset, sliceEnd); + + // Download and decompress S3 objects only for the requested range. + const chunkEvents: Array<{ chunk_id: string, events: any[] }> = new Array(chunksToDownload.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < chunksToDownload.length) { + const idx = nextIndex++; + const chunk = chunksToDownload[idx]; + + let bytes: Uint8Array; + try { + bytes = await downloadBytes({ key: chunk.s3Key, private: true }); + } catch (e: any) { + const status = e?.$metadata?.httpStatusCode; + if (status === 404) { + throw new KnownErrors.ItemNotFound(chunk.id); + } + throw e; + } + const unzipped = new Uint8Array(await gunzip(bytes)); + + let parsed: any; + try { + parsed = JSON.parse(new TextDecoder().decode(unzipped)); + } catch (e) { + throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e }); + } + + if (typeof parsed !== "object" || parsed === null) { + throw new StackAssertionError("Decoded session recording chunk is not an object"); + } + if (parsed.session_recording_id !== sessionRecordingId) { + throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", { + expected: sessionRecordingId, + actual: parsed.session_recording_id, + }); + } + if (!Array.isArray(parsed.events)) { + throw new StackAssertionError("Decoded session recording chunk events is not an array"); + } + + chunkEvents[idx] = { chunk_id: chunk.id, events: parsed.events as any[] }; + } + } + + const workers = Array.from( + { length: Math.min(S3_CONCURRENCY, chunksToDownload.length) }, + () => worker(), + ); + await Promise.all(workers); + + return { + statusCode: 200, + bodyType: "json", + body: { + chunks: chunks.map((c) => ({ + id: c.id, + batch_id: c.batchId, + tab_id: c.tabId, + event_count: c.eventCount, + byte_length: c.byteLength, + first_event_at_millis: c.firstEventAt.getTime(), + last_event_at_millis: c.lastEventAt.getTime(), + created_at_millis: c.createdAt.getTime(), + })), + chunk_events: chunkEvents, + }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index cfa7828f63..908bc8f201 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -35,8 +35,8 @@ import { } from "./session-replay-machine"; const PAGE_SIZE = 50; -const CHUNK_PAGE_SIZE = 250; -const CHUNK_EVENTS_CONCURRENCY = 8; +const INITIAL_CHUNK_BATCH = 20; +const BACKGROUND_CHUNK_BATCH = 50; const EXTRA_TABS_TO_SHOW = 2; const REPLAY_SETTINGS_STORAGE_KEY = "stack.session-replay.settings"; const LEGACY_PLAYER_SPEED_STORAGE_KEY = "stack.session-replay.speed"; @@ -73,11 +73,10 @@ type AdminAppWithSessionRecordings = ReturnType & { items: RecordingRow[], nextCursor: string | null, }>, - listSessionRecordingChunks: (sessionRecordingId: string, options?: { limit?: number, cursor?: string }) => Promise<{ - items: ChunkRow[], - nextCursor: string | null, + getSessionRecordingEvents: (sessionRecordingId: string, options?: { offset?: number, limit?: number }) => Promise<{ + chunks: ChunkRow[], + chunkEvents: Array<{ chunkId: string, events: unknown[] }>, }>, - getSessionRecordingChunkEvents: (sessionRecordingId: string, chunkId: string) => Promise<{ events: unknown[] }>, }; function coerceRrwebEvents(raw: unknown[]): RrwebEventWithTime[] { @@ -168,63 +167,6 @@ function getInitialReplaySettings(): ReplaySettings { return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; } -async function fetchChunkEventsForStreamsParallel( - adminApp: AdminAppWithSessionRecordings, - recordingId: string, - streams: Array<{ tabKey: TabKey, chunks: ChunkRow[] }>, - isStale: () => boolean, - onChunkLoaded: (tabKey: TabKey, chunkIndex: number, events: RrwebEventWithTime[]) => void, -) { - const tasks: Array<{ tabKey: TabKey, chunkIndex: number, chunkId: string, firstEventAtMs: number }> = []; - const resultsByTab = new Map>(); - const reportedIndexByTab = new Map(); - - for (const s of streams) { - resultsByTab.set(s.tabKey, new Array(s.chunks.length).fill(null)); - reportedIndexByTab.set(s.tabKey, 0); - for (let chunkIndex = 0; chunkIndex < s.chunks.length; chunkIndex++) { - tasks.push({ - tabKey: s.tabKey, - chunkIndex, - chunkId: s.chunks[chunkIndex].id, - firstEventAtMs: s.chunks[chunkIndex].firstEventAt.getTime(), - }); - } - } - - tasks.sort((a, b) => { - const a0 = a.chunkIndex === 0 ? 0 : 1; - const b0 = b.chunkIndex === 0 ? 0 : 1; - if (a0 !== b0) return a0 - b0; - if (a.firstEventAtMs !== b.firstEventAtMs) return a.firstEventAtMs - b.firstEventAtMs; - return a.chunkIndex - b.chunkIndex; - }); - - let nextTaskIndex = 0; - - async function worker() { - while (nextTaskIndex < tasks.length) { - if (isStale()) return; - const task = tasks[nextTaskIndex++]; - const ev = await adminApp.getSessionRecordingChunkEvents(recordingId, task.chunkId); - if (isStale()) return; - - const results = resultsByTab.get(task.tabKey) ?? []; - results[task.chunkIndex] = coerceRrwebEvents(ev.events); - - let reported = reportedIndexByTab.get(task.tabKey) ?? 0; - while (reported < results.length && results[reported] !== null) { - onChunkLoaded(task.tabKey, reported, results[reported]!); - reported++; - } - reportedIndexByTab.set(task.tabKey, reported); - } - } - - const workers = Array.from({ length: Math.min(CHUNK_EVENTS_CONCURRENCY, tasks.length) }, () => worker()); - await Promise.all(workers); -} - function Timeline({ getCurrentTimeMs, playerIsPlaying, @@ -836,19 +778,72 @@ export default function PageClient() { setFullStreams([]); fullStreamsRef.current = []; - try { - const allChunkRows: ChunkRow[] = []; - let cursor: string | null = null; - while (true) { - const res: { items: ChunkRow[], nextCursor: string | null } = await adminApp.listSessionRecordingChunks( - recordingId, - { limit: CHUNK_PAGE_SIZE, cursor: cursor ?? undefined }, - ); + // Helper: process a batch of chunk_events into the replayer state machine. + function processChunkEvents( + chunkEvents: Array<{ chunkId: string, events: unknown[] }>, + allStreams: TabStream[], + chunkIdToTabKey: Map, + ) { + for (const ce of chunkEvents) { if (msRef.current.generation !== gen) return; - allChunkRows.push(...res.items); - if (!res.nextCursor) break; - cursor = res.nextCursor; + + const tabKey = chunkIdToTabKey.get(ce.chunkId); + if (!tabKey) continue; + + const events = coerceRrwebEvents(ce.events); + const prev = eventsByTabRef.current.get(tabKey) ?? []; + const wasEmpty = prev.length === 0; + prev.push(...events); + eventsByTabRef.current.set(tabKey, prev); + + const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + && events.some(e => (e as any).type === 2); + + let loadedDurationMs = 0; + if (prev.length >= 2) { + loadedDurationMs = prev[prev.length - 1].timestamp - prev[0].timestamp; + } + + if (!wasEmpty) { + const r = replayerByTabRef.current.get(tabKey); + if (r) { + for (const event of events) { + r.addEvent(event); + } + } + } + + actRef.current({ + type: "CHUNK_LOADED", + generation: gen, + tabKey, + hasFullSnapshot, + loadedDurationMs, + hadEventsBeforeThisChunk: !wasEmpty, + }); + + if (hasFullSnapshot || wasEmpty) { + setUiVersion(v => v + 1); + } } + } + + try { + // Phase 1: Fetch initial batch (fast start). + const initialResponse = await adminApp.getSessionRecordingEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); + if (msRef.current.generation !== gen) return; + + const allChunkRows: ChunkRow[] = initialResponse.chunks.map((c) => ({ + id: c.id, + batchId: c.batchId, + tabId: c.tabId, + eventCount: c.eventCount, + byteLength: c.byteLength, + firstEventAt: c.firstEventAt, + lastEventAt: c.lastEventAt, + createdAt: c.createdAt, + })); const allStreams = groupChunksIntoTabStreams(allChunkRows); setFullStreams(allStreams); @@ -856,7 +851,7 @@ export default function PageClient() { const { globalStartTs, globalTotalMs } = computeGlobalTimeline(allStreams); - // Build chunk ranges + // Build chunk ranges from full metadata. const rangesByTab = new Map(); for (const s of allStreams) { const ranges = s.chunks @@ -880,7 +875,7 @@ export default function PageClient() { rangesByTab.set(s.tabKey, merged); } - // Stable tab labels + // Stable tab labels. const labelOrder = allStreams .slice() .sort((a, b) => { @@ -890,7 +885,6 @@ export default function PageClient() { }); const tabLabelIndex = new Map(labelOrder.map((s, i) => [s.tabKey, i + 1])); - // StreamInfo for machine const streamInfos: StreamInfo[] = allStreams.map(s => ({ tabKey: s.tabKey, firstEventAtMs: s.firstEventAt.getTime(), @@ -907,52 +901,31 @@ export default function PageClient() { tabLabelIndex, }); - await fetchChunkEventsForStreamsParallel( - adminApp, - recordingId, - allStreams.map(s => ({ tabKey: s.tabKey, chunks: s.chunks })), - () => msRef.current.generation !== gen, - (tabKey, _chunkIndex, events) => { - const prev = eventsByTabRef.current.get(tabKey) ?? []; - const wasEmpty = prev.length === 0; - prev.push(...events); - eventsByTabRef.current.set(tabKey, prev); - - // Detect FullSnapshot (rrweb type 2). - const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - && events.some(e => (e as any).type === 2); - - let loadedDurationMs = 0; - if (prev.length >= 2) { - loadedDurationMs = prev[prev.length - 1].timestamp - prev[0].timestamp; - } + // Build chunk_id → tabKey lookup from full metadata. + const chunkIdToTabKey = new Map(); + for (const s of allStreams) { + for (const chunk of s.chunks) { + chunkIdToTabKey.set(chunk.id, s.tabKey); + } + } - // Add events to existing rrweb replayer instance. - if (!wasEmpty) { - const r = replayerByTabRef.current.get(tabKey); - if (r) { - for (const event of events) { - r.addEvent(event); - } - } - } + // Process the initial batch of events. + processChunkEvents(initialResponse.chunkEvents, allStreams, chunkIdToTabKey); - // Dispatch to machine — handles ensure_replayer, buffer resume, etc. - actRef.current({ - type: "CHUNK_LOADED", - generation: gen, - tabKey, - hasFullSnapshot, - loadedDurationMs, - hadEventsBeforeThisChunk: !wasEmpty, - }); + // Phase 2: Background loading of remaining chunks. + const totalChunks = allChunkRows.length; + let offset = INITIAL_CHUNK_BATCH; - if (hasFullSnapshot || wasEmpty) { - setUiVersion(v => v + 1); - } - }, - ); + while (offset < totalChunks) { + if (msRef.current.generation !== gen) return; + + const batchResponse = await adminApp.getSessionRecordingEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); + if (msRef.current.generation !== gen) return; + + processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey); + + offset += BACKGROUND_CHUNK_BATCH; + } } catch (e: any) { if (msRef.current.generation === gen) { actRef.current({ type: "DOWNLOAD_ERROR", generation: gen, message: e?.message ?? "Failed to load replay data." }); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 8b06858b1e..aae2fa9d30 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -11,6 +11,7 @@ import { InternalApiKeysCrud } from "./crud/internal-api-keys"; import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions"; import { ProjectsCrud } from "./crud/projects"; import type { + AdminGetSessionRecordingAllEventsResponse, AdminGetSessionRecordingChunkEventsResponse, AdminListSessionRecordingChunksOptions, AdminListSessionRecordingChunksResponse, @@ -747,6 +748,18 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } + async getSessionRecordingEvents(sessionRecordingId: string, options?: { offset?: number, limit?: number }): Promise { + const qs = new URLSearchParams(); + if (typeof options?.offset === "number") qs.set("offset", String(options.offset)); + if (typeof options?.limit === "number") qs.set("limit", String(options.limit)); + const response = await this.sendAdminRequest( + `/internal/session-recordings/${encodeURIComponent(sessionRecordingId)}/events${qs.size ? `?${qs.toString()}` : ""}`, + { method: "GET" }, + null, + ); + return await response.json(); + } + async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, diff --git a/packages/stack-shared/src/interface/crud/session-recordings.ts b/packages/stack-shared/src/interface/crud/session-recordings.ts index 2990d9ef34..ce6c190170 100644 --- a/packages/stack-shared/src/interface/crud/session-recordings.ts +++ b/packages/stack-shared/src/interface/crud/session-recordings.ts @@ -47,3 +47,20 @@ export type AdminGetSessionRecordingChunkEventsResponse = { events: unknown[], }; +export type AdminGetSessionRecordingAllEventsResponse = { + chunks: Array<{ + id: string, + batch_id: string, + tab_id: string | null, + event_count: number, + byte_length: number, + first_event_at_millis: number, + last_event_at_millis: number, + created_at_millis: number, + }>, + chunk_events: Array<{ + chunk_id: string, + events: unknown[], + }>, +}; + diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 0bee22f50b..76867c6031 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -2,7 +2,7 @@ import { StackAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; -import type { AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; +import type { AdminGetSessionRecordingAllEventsResponse, AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -19,7 +19,7 @@ import { AdminEmailTemplate } from "../../email-templates"; import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; -import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult } from "../interfaces/admin-app"; +import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult, SessionRecordingAllEventsResult } from "../interfaces/admin-app"; import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; @@ -1010,6 +1010,26 @@ export class _StackAdminAppImplIncomplete { + const response = await this._interface.getSessionRecordingEvents(sessionRecordingId, options); + return { + chunks: response.chunks.map((c) => ({ + id: c.id, + batchId: c.batch_id, + tabId: c.tab_id, + eventCount: c.event_count, + byteLength: c.byte_length, + firstEventAt: new Date(c.first_event_at_millis), + lastEventAt: new Date(c.last_event_at_millis), + createdAt: new Date(c.created_at_millis), + })), + chunkEvents: response.chunk_events.map((ce) => ({ + chunkId: ce.chunk_id, + events: ce.events, + })), + }; + } + async previewAffectedUsersByOnboardingChange( onboarding: { requireEmailVerification?: boolean }, limit?: number, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index abf5868487..0ba4f8dc8f 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,6 +1,6 @@ import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; -import type { AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; +import type { AdminGetSessionRecordingChunkEventsResponse, AdminGetSessionRecordingAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; @@ -76,6 +76,23 @@ export type ListSessionRecordingChunksResult = { nextCursor: string | null, }; +export type SessionRecordingAllEventsResult = { + chunks: Array<{ + id: string, + batchId: string, + tabId: string | null, + eventCount: number, + byteLength: number, + firstEventAt: Date, + lastEventAt: Date, + createdAt: Date, + }>, + chunkEvents: Array<{ + chunkId: string, + events: unknown[], + }>, +}; + export type StackAdminAppConstructorOptions = ( & StackServerAppConstructorOptions @@ -167,6 +184,7 @@ export type StackAdminApp, listSessionRecordingChunks(sessionRecordingId: string, options?: ListSessionRecordingChunksOptions): Promise, getSessionRecordingChunkEvents(sessionRecordingId: string, chunkId: string): Promise, + getSessionRecordingEvents(sessionRecordingId: string, options?: { offset?: number, limit?: number }): Promise, // Email Outbox methods listOutboxEmails(options?: EmailOutboxListOptions): Promise, From 175ac9d1b4d120bd497955aa652a84444b5efa0f Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 15:53:36 -0800 Subject: [PATCH 17/21] fix tests --- .../api/v1/session-recordings.test.ts | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts index b653647c4b..cf9eef7570 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts @@ -49,6 +49,7 @@ it("stores session recording batch metadata and dedupes by (session_recording_id await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); + const now = Date.now(); const browserSessionId = randomUUID(); const batchId = randomUUID(); const tabId = randomUUID(); @@ -60,11 +61,11 @@ it("stores session recording batch metadata and dedupes by (session_recording_id browser_session_id: browserSessionId, tab_id: tabId, batch_id: batchId, - started_at_ms: 1_700_000_000_000, - sent_at_ms: 1_700_000_000_500, + started_at_ms: now, + sent_at_ms: now + 500, events: [ - { timestamp: 1_700_000_000_100, type: 2 }, - { timestamp: 1_700_000_000_200, type: 3 }, + { timestamp: now + 100, type: 2 }, + { timestamp: now + 200, type: 3 }, ], }, }); @@ -86,9 +87,9 @@ it("stores session recording batch metadata and dedupes by (session_recording_id browser_session_id: browserSessionId, tab_id: tabId, batch_id: batchId, - started_at_ms: 1_700_000_000_000, - sent_at_ms: 1_700_000_000_500, - events: [{ timestamp: 1_700_000_000_150, type: 2 }], + started_at_ms: now, + sent_at_ms: now + 500, + events: [{ timestamp: now + 150, type: 2 }], }, }); @@ -396,14 +397,16 @@ it("admin list session recordings rejects unknown cursor", async ({ expect }) => it("admin list chunks paginates and rejects a cursor from another session", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const now = Date.now(); + // session1: two batches under first refresh token await Auth.Otp.signIn(); const upload1a = await uploadBatch({ browserSessionId: randomUUID(), batchId: randomUUID(), - startedAtMs: 1_700_000_000_000, - sentAtMs: 1_700_000_000_500, - events: [{ type: 1, timestamp: 1_700_000_000_010 }], + startedAtMs: now, + sentAtMs: now + 500, + events: [{ type: 1, timestamp: now + 10 }], }); expect(upload1a.status).toBe(200); const recording1 = upload1a.body?.session_recording_id; @@ -411,9 +414,9 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn await uploadBatch({ browserSessionId: randomUUID(), batchId: randomUUID(), - startedAtMs: 1_700_000_000_000, - sentAtMs: 1_700_000_000_600, - events: [{ type: 1, timestamp: 1_700_000_000_020 }], + startedAtMs: now, + sentAtMs: now + 600, + events: [{ type: 1, timestamp: now + 20 }], }); // session2: one batch under a different refresh token @@ -421,9 +424,9 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn const upload2 = await uploadBatch({ browserSessionId: randomUUID(), batchId: randomUUID(), - startedAtMs: 1_700_000_000_000, - sentAtMs: 1_700_000_000_700, - events: [{ type: 1, timestamp: 1_700_000_000_030 }], + startedAtMs: now, + sentAtMs: now + 700, + events: [{ type: 1, timestamp: now + 30 }], }); expect(upload2.status).toBe(200); const recording2 = upload2.body?.session_recording_id; @@ -539,22 +542,24 @@ it("groups batches from same refresh token into one session recording", async ({ await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); + const now = Date.now(); + // Two batches with different browser_session_ids but same refresh token const upload1 = await uploadBatch({ browserSessionId: randomUUID(), batchId: randomUUID(), - startedAtMs: 1_700_000_000_000, - sentAtMs: 1_700_000_000_300, - events: [{ type: 1, timestamp: 1_700_000_000_100 }], + startedAtMs: now, + sentAtMs: now + 300, + events: [{ type: 1, timestamp: now + 100 }], }); expect(upload1.status).toBe(200); const upload2 = await uploadBatch({ browserSessionId: randomUUID(), batchId: randomUUID(), - startedAtMs: 1_700_000_000_000, - sentAtMs: 1_700_000_000_400, - events: [{ type: 1, timestamp: 1_700_000_000_200 }], + startedAtMs: now, + sentAtMs: now + 400, + events: [{ type: 1, timestamp: now + 200 }], }); expect(upload2.status).toBe(200); From 3d51c7065475dc56c030169442232f1409dff84e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Feb 2026 19:28:06 -0800 Subject: [PATCH 18/21] js replays --- CLAUDE.md | Bin 904 -> 84 bytes apps/dashboard/src/app/layout.tsx | 2 +- apps/dashboard/src/stack.tsx | 8 +- docs/src/app/layout.tsx | 2 +- docs/src/stack.ts | 6 + .../src/components/stack-analytics.tsx | 273 --------------- packages/template/src/index.ts | 2 +- .../apps/implementations/client-app-impl.ts | 25 +- .../apps/implementations/session-recording.ts | 318 ++++++++++++++++++ .../stack-app/apps/interfaces/client-app.ts | 7 + .../src/providers/stack-provider-client.tsx | 5 +- .../template/src/providers/stack-provider.tsx | 15 +- 12 files changed, 368 insertions(+), 295 deletions(-) delete mode 100644 packages/template/src/components/stack-analytics.tsx create mode 100644 packages/template/src/lib/stack-app/apps/implementations/session-recording.ts diff --git a/CLAUDE.md b/CLAUDE.md index d16eeba0948217112bb749d251fdd771576ff8ca..dc54ebb38912716c8128c2226f9a9322efa19b80 100644 GIT binary patch literal 84 zcmY#Z2rfxX&Q@?NEy>{GQdU-Q&d<$F%u6Ze(pAvX^2y9A(bBR~NGQlF$W_Pz@)E$} UA(aKG$r-81*$BxJm{0;2044z%H~;_u literal 904 zcmY+COG{f(5Xa~GC<+Qme061mh;-59CTV=M2WIGdWwnzax-ZVYhQGfT^p!^!A1L zOIf>dnn&>q=fM(5_aF+RmwAAG9F`@rQ7`Y-!4b80e6Jo9yrAMW{OxL^?3F|YHJ0^@ zd$qC`FyGW8xyVLLj`8J)8rBm;+F(;XGW3zeJaouEoZr(|l{ts9Ky&ZXe(#P-HI8$WI zSMn-b!0yasBRam+N=p#q#ZGa@eY@S}eUx!1Us%ww@CLxYltbVlP0WDr)V~f+tAFDO ze+9o$e@|58w{k!Dn{o(TRSttslq2AMdGISRf~PJqq*R!bzK{xBG!jzn5`kLtIs WAM- - + diff --git a/apps/dashboard/src/stack.tsx b/apps/dashboard/src/stack.tsx index f23e062199..2d7a7c9b5c 100644 --- a/apps/dashboard/src/stack.tsx +++ b/apps/dashboard/src/stack.tsx @@ -19,5 +19,11 @@ export const stackServerApp = new StackServerApp<"nextjs-cookie", true, 'interna afterSignIn: "/projects", afterSignUp: "/new-project", afterSignOut: "/", - } + }, + analytics: { + replays: { + maskAllInputs: false, + enabled: true, + }, + }, }); diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index 45cb06ca20..0eb021ca2f 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { - + ` elements. - * - * @default true - */ - maskAllInputs?: boolean, - /** - * A CSS class name or RegExp. Elements with a matching class will be blocked - * (replaced with a placeholder in the recording). - * - * @default undefined - */ - blockClass?: string | RegExp, - /** - * A CSS selector string. Elements matching this selector will be blocked - * (replaced with a placeholder in the recording). - * - * @default undefined - */ - blockSelector?: string, -} - -export type AnalyticsOptions = { - /** - * Options for session replay recording. Replays are disabled by default; - * set `enabled: true` to opt in. - */ - replays?: AnalyticsReplayOptions, -} - -const LOCAL_STORAGE_PREFIX = "stack:session-recording:v1"; -const IDLE_TTL_MS = 3 * 60 * 1000; - -const FLUSH_INTERVAL_MS = 5_000; -const MAX_EVENTS_PER_BATCH = 200; -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 = { - session_id: string, - created_at_ms: number, - last_activity_ms: number, -}; - -function isBrowser() { - return typeof window !== "undefined"; -} - -function safeParseStoredSession(raw: string | null): StoredSession | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) return null; - if (typeof parsed.session_id !== "string") return null; - if (typeof parsed.created_at_ms !== "number") return null; - if (typeof parsed.last_activity_ms !== "number") return null; - return parsed as StoredSession; - } catch { - return null; - } -} - -function makeStorageKey(projectId: string) { - return `${LOCAL_STORAGE_PREFIX}:${projectId}`; -} - -function generateUuid() { - if (!isBrowser()) { - throw new Error("generateUuid() called outside browser"); - } - return crypto.randomUUID(); -} - -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; - } - const next: StoredSession = { - session_id: generateUuid(), - created_at_ms: options.nowMs, - last_activity_ms: options.nowMs, - }; - localStorage.setItem(options.key, JSON.stringify(next)); - return next; -} - -export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayOptions }) { - const app = useStackApp(); - const tabId = useMemo(() => isBrowser() ? crypto.randomUUID() : "", []); - - // Use reactive hooks for tokens instead of app.getAccessToken() which - // calls getUser() -> /users/me on every invocation (bypassing the cache). - // These hooks subscribe to the cache and only trigger network requests when needed. - const accessToken = app.useAccessToken(); - - // Ref so the effect closure always has the latest token value - // without needing it in the dependency array (which would restart recording). - const accessTokenRef = useRef(accessToken); - accessTokenRef.current = accessToken; - - useEffect(() => { - let cancelled = false; - let stopRecording: (() => void) | null = null; - let detachListeners: (() => void) | null = null; - let flushTimer: number | null = null; - let events: unknown[] = []; - let approxBytes = 0; - let lastPersistActivity = 0; - let recording = false; - let rrwebModule: typeof import("rrweb") | null = null; - - const storageKey = makeStorageKey(app.projectId); - - const persistActivity = (nowMs: number) => { - const stored = getOrRotateSession({ key: storageKey, nowMs }); - if (nowMs - lastPersistActivity < 5_000) return; - lastPersistActivity = nowMs; - const updated: StoredSession = { ...stored, last_activity_ms: nowMs }; - localStorage.setItem(storageKey, JSON.stringify(updated)); - }; - - const flush = async (options: { keepalive: boolean }) => { - if (!accessTokenRef.current) return; - if (events.length === 0) return; - - const nowMs = Date.now(); - const stored = getOrRotateSession({ key: storageKey, nowMs }); - - const batchId = generateUuid(); - const payload = { - browser_session_id: stored.session_id, - tab_id: tabId, - batch_id: batchId, - started_at_ms: stored.created_at_ms, - sent_at_ms: nowMs, - events, - }; - - events = []; - approxBytes = 0; - - const res = await app[stackAppInternalsSymbol].sendSessionRecordingBatch( - JSON.stringify(payload), - { keepalive: options.keepalive }, - ); - - if (res.status === "error") { - // This is best-effort telemetry. Don't throw and break the app. - console.warn("StackAnalyticsInternal flush failed:", res.error); - return; - } - - if (!res.data.ok) { - console.warn("StackAnalyticsInternal flush failed:", res.data.status, await res.data.text()); - } - }; - - const startRecording = async () => { - if (recording || cancelled) return; - - if (!rrwebModule) { - const rrwebImport = await Result.fromPromise(import("rrweb")); - if (rrwebImport.status === "error") { - console.warn("StackAnalyticsInternal: rrweb import failed. Is rrweb installed?", rrwebImport.error); - return; - } - rrwebModule = rrwebImport.data; - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancelled may change during the await above - if (cancelled) return; - - stopRecording = rrwebModule.record({ - emit: (event) => { - const nowMs = Date.now(); - persistActivity(nowMs); - - events.push(event); - approxBytes += JSON.stringify(event).length; - if (events.length >= MAX_EVENTS_PER_BATCH || approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { - runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); - } - - // Cap pre-auth buffer to prevent unbounded memory growth - if (!accessTokenRef.current && (events.length > MAX_PREAUTH_BUFFER_EVENTS || approxBytes > MAX_PREAUTH_BUFFER_BYTES)) { - events = []; - approxBytes = 0; - } - }, - maskAllInputs: props.replayOptions?.maskAllInputs ?? true, - ...(props.replayOptions?.blockClass !== undefined ? { blockClass: props.replayOptions.blockClass } : {}), - ...(props.replayOptions?.blockSelector !== undefined ? { blockSelector: props.replayOptions.blockSelector } : {}), - }) ?? null; - - recording = true; - - const onPageHide = () => { - runAsynchronously(() => flush({ keepalive: true }), { noErrorLogging: true }); - }; - window.addEventListener("pagehide", onPageHide); - document.addEventListener("visibilitychange", onPageHide); - detachListeners = () => { - window.removeEventListener("pagehide", onPageHide); - document.removeEventListener("visibilitychange", onPageHide); - }; - }; - - const stopCurrentRecording = () => { - if (detachListeners) { - detachListeners(); - detachListeners = null; - } - if (stopRecording) { - stopRecording(); - stopRecording = null; - } - events = []; - approxBytes = 0; - recording = false; - }; - - // Periodically flushes events. - let wasAuthenticated = !!accessTokenRef.current; - const tick = () => { - if (cancelled) return; - const hasAuth = !!accessTokenRef.current; - // Clear buffer on logout to prevent cross-user event leakage - if (wasAuthenticated && !hasAuth) { - events = []; - approxBytes = 0; - } - wasAuthenticated = hasAuth; - if (hasAuth && events.length > 0) { - runAsynchronously(() => flush({ keepalive: false }), { noErrorLogging: true }); - } - }; - - // Start recording immediately so pre-login activity is captured. - runAsynchronously(() => startRecording(), { noErrorLogging: true }); - - flushTimer = window.setInterval(tick, FLUSH_INTERVAL_MS); - - return () => { - cancelled = true; - if (flushTimer !== null) { - window.clearInterval(flushTimer); - } - // Flush remaining events before cleanup - runAsynchronously(() => flush({ keepalive: true }), { noErrorLogging: true }); - stopCurrentRecording(); - }; - }, [app, tabId]); - - return null; -} diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index 7de2ed766b..a64b0cfc96 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -2,7 +2,7 @@ export * from './lib/stack-app'; export { getConvexProvidersConfig } from "./integrations/convex"; // IF_PLATFORM react-like -export type { AnalyticsOptions, AnalyticsReplayOptions } from "./components/stack-analytics"; +export type { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/stack-app/apps/implementations/session-recording"; export { default as StackHandler } from "./components-page/stack-handler"; export { useStackApp, useUser } from "./lib/hooks"; export { default as StackProvider } from "./providers/stack-provider"; diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 2e8c0429d3..0612286105 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -53,6 +53,7 @@ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthPro 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 { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-recording"; // IF_PLATFORM react-like import { useAsyncCache } from "./common"; @@ -95,6 +96,9 @@ export class _StackClientAppImplIncomplete; protected readonly _oauthScopesOnSignIn: Partial; + private readonly _analyticsOptions: AnalyticsOptions | undefined; + private _sessionRecorder: SessionRecorder | null = null; + private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false; private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete>(); @@ -431,6 +435,22 @@ export class _StackClientAppImplIncomplete { + 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.sendSessionRecordingBatch(body, await this._getSession(), opts); + }, + }, this._analyticsOptions.replays); + this._sessionRecorder.start(); + } } protected _initUniqueIdentifier() { @@ -2790,8 +2810,10 @@ export class _StackClientAppImplIncomplete({ - ...omit(json, ["uniqueIdentifier"]) as any, + ...restJson as any, + analytics: analyticsOptionsFromJson(analytics), }, { uniqueIdentifier: json.uniqueIdentifier, checkString: providedCheckString, @@ -2822,6 +2844,7 @@ export class _StackClientAppImplIncomplete) => { diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-recording.ts b/packages/template/src/lib/stack-app/apps/implementations/session-recording.ts new file mode 100644 index 0000000000..315ca05b9b --- /dev/null +++ b/packages/template/src/lib/stack-app/apps/implementations/session-recording.ts @@ -0,0 +1,318 @@ +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"; + +export type AnalyticsReplayOptions = { + /** + * Whether session replays are enabled. + * + * @default false + */ + enabled?: boolean, + /** + * Whether to mask the content of all `` elements. + * + * @default true + */ + maskAllInputs?: boolean, + /** + * A CSS class name or RegExp. Elements with a matching class will be blocked + * (replaced with a placeholder in the recording). + * + * @default undefined + */ + blockClass?: string | RegExp, + /** + * A CSS selector string. Elements matching this selector will be blocked + * (replaced with a placeholder in the recording). + * + * @default undefined + */ + blockSelector?: string, +}; + +export type AnalyticsOptions = { + /** + * Options for session replay recording. Replays are disabled by default; + * set `enabled: true` to opt in. + */ + replays?: AnalyticsReplayOptions, +}; + +/** + * Converts AnalyticsOptions to a JSON-safe representation. + * RegExp blockClass values are serialized as `{ __regexp, __flags }` objects. + * The return type is AnalyticsOptions to keep StackClientAppJson simple; + * the actual runtime value is JSON-safe. + */ +export function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined { + if (!options?.replays?.blockClass) return options; + const { blockClass, ...rest } = options.replays; + if (!(blockClass instanceof RegExp)) return options; + return { + replays: { + ...rest, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + blockClass: { __regexp: blockClass.source, __flags: blockClass.flags } as any, + }, + }; +} + +/** + * Reconstructs AnalyticsOptions from a JSON-deserialized value. + * Converts `{ __regexp, __flags }` objects back to RegExp instances. + */ +export function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): AnalyticsOptions | undefined { + if (!json?.replays?.blockClass) return json; + const { blockClass, ...rest } = json.replays; + if (typeof blockClass === 'object' && '__regexp' in blockClass) { + const bc = blockClass as unknown as { __regexp: string, __flags: string }; + return { + replays: { + ...rest, + blockClass: new RegExp(bc.__regexp, bc.__flags), + }, + }; + } + return json; +} + +// ---------- Recording internals ---------- + +const LOCAL_STORAGE_PREFIX = "stack:session-recording:v1"; +const IDLE_TTL_MS = 3 * 60 * 1000; + +const FLUSH_INTERVAL_MS = 5_000; +const MAX_EVENTS_PER_BATCH = 200; +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 = { + session_id: string, + created_at_ms: number, + last_activity_ms: number, +}; + +function safeParseStoredSession(raw: string | null): StoredSession | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + if (typeof parsed.session_id !== "string") return null; + if (typeof parsed.created_at_ms !== "number") return null; + if (typeof parsed.last_activity_ms !== "number") return null; + return parsed as StoredSession; + } catch { + return null; + } +} + +function makeStorageKey(projectId: string) { + return `${LOCAL_STORAGE_PREFIX}:${projectId}`; +} + +function generateUuid() { + return crypto.randomUUID(); +} + +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; + } + const next: StoredSession = { + session_id: generateUuid(), + created_at_ms: options.nowMs, + last_activity_ms: options.nowMs, + }; + localStorage.setItem(options.key, JSON.stringify(next)); + return next; +} + +export type SessionRecorderDeps = { + projectId: string, + getAccessToken: () => Promise, + sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, +}; + +export class SessionRecorder { + private _started = false; + private _cancelled = false; + private _stopRecording: (() => void) | null = null; + private _detachListeners: (() => void) | null = null; + private _flushTimer: ReturnType | null = null; + private _events: unknown[] = []; + private _approxBytes = 0; + private _lastPersistActivity = 0; + private _recording = false; + private _rrwebModule: typeof import("rrweb") | null = null; + private _lastKnownAccessToken: string | null = null; + private _wasAuthenticated = false; + private readonly _tabId: string; + private readonly _storageKey: string; + private readonly _deps: SessionRecorderDeps; + private readonly _replayOptions: AnalyticsReplayOptions; + + constructor(deps: SessionRecorderDeps, replayOptions: AnalyticsReplayOptions) { + this._deps = deps; + this._replayOptions = replayOptions; + this._tabId = generateUuid(); + this._storageKey = makeStorageKey(deps.projectId); + } + + /** + * Starts recording. Idempotent — calling multiple times is safe. + */ + start() { + if (this._started) return; + if (!isBrowserLike()) return; + this._started = true; + + // Kick off rrweb recording + runAsynchronously(() => this._startRecording(), { noErrorLogging: true }); + + // Periodic flush + token refresh + this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS); + } + + stop() { + this._cancelled = true; + if (this._flushTimer !== null) { + clearInterval(this._flushTimer); + this._flushTimer = null; + } + // Flush remaining events before cleanup + runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + this._stopCurrentRecording(); + } + + private _persistActivity(nowMs: number) { + const stored = getOrRotateSession({ key: this._storageKey, nowMs }); + if (nowMs - this._lastPersistActivity < 5_000) return; + this._lastPersistActivity = nowMs; + const updated: StoredSession = { ...stored, last_activity_ms: nowMs }; + localStorage.setItem(this._storageKey, JSON.stringify(updated)); + } + + private async _flush(options: { keepalive: boolean }) { + if (!this._lastKnownAccessToken) return; + if (this._events.length === 0) return; + + const nowMs = Date.now(); + const stored = getOrRotateSession({ key: this._storageKey, nowMs }); + + const batchId = generateUuid(); + const payload = { + browser_session_id: stored.session_id, + tab_id: this._tabId, + batch_id: batchId, + started_at_ms: stored.created_at_ms, + 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("SessionRecorder flush failed:", res.error); + return; + } + + if (!res.data.ok) { + console.warn("SessionRecorder flush failed:", res.data.status, await res.data.text()); + } + } + + private async _startRecording() { + if (this._recording || this._cancelled) return; + + if (!this._rrwebModule) { + const rrwebImport = await Result.fromPromise(import("rrweb")); + if (rrwebImport.status === "error") { + console.warn("SessionRecorder: rrweb import failed. Is rrweb installed?", rrwebImport.error); + return; + } + this._rrwebModule = rrwebImport.data; + } + + // cancelled may change during the await above + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this._cancelled) return; + + this._stopRecording = this._rrwebModule.record({ + emit: (event) => { + const nowMs = Date.now(); + this._persistActivity(nowMs); + + 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 to prevent unbounded memory growth + if (!this._lastKnownAccessToken && (this._events.length > MAX_PREAUTH_BUFFER_EVENTS || this._approxBytes > MAX_PREAUTH_BUFFER_BYTES)) { + this._events = []; + this._approxBytes = 0; + } + }, + maskAllInputs: this._replayOptions.maskAllInputs ?? true, + ...(this._replayOptions.blockClass !== undefined ? { blockClass: this._replayOptions.blockClass } : {}), + ...(this._replayOptions.blockSelector !== undefined ? { blockSelector: this._replayOptions.blockSelector } : {}), + }) ?? null; + + this._recording = true; + + const onPageHide = () => { + runAsynchronously(() => this._flush({ keepalive: true }), { noErrorLogging: true }); + }; + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onPageHide); + this._detachListeners = () => { + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onPageHide); + }; + } + + private _stopCurrentRecording() { + if (this._detachListeners) { + this._detachListeners(); + this._detachListeners = null; + } + if (this._stopRecording) { + this._stopRecording(); + this._stopRecording = null; + } + this._events = []; + this._approxBytes = 0; + this._recording = false; + } + + private _tick() { + if (this._cancelled) return; + + // Refresh the cached access token (async, fire-and-forget for this tick) + 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 }); + } + } +} diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index c425a088f0..4c6261483a 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -6,6 +6,7 @@ import { CustomerInvoicesList, CustomerInvoicesRequestOptions, CustomerProductsL import { Project } from "../../projects"; import { ProjectCurrentUser, SyncedPartialUser, TokenPartialUser } from "../../users"; import { _StackClientAppImpl } from "../implementations"; +import { AnalyticsOptions } from "../implementations/session-recording"; export type StackClientAppConstructorOptions = { baseUrl?: string | { browser: string, server: string }, @@ -24,6 +25,12 @@ export type StackClientAppConstructorOptions } | { tokenStore?: undefined, inheritsFrom: StackClientApp } ) & ( diff --git a/packages/template/src/providers/stack-provider-client.tsx b/packages/template/src/providers/stack-provider-client.tsx index 2fd288fb25..1e423dcaaa 100644 --- a/packages/template/src/providers/stack-provider-client.tsx +++ b/packages/template/src/providers/stack-provider-client.tsx @@ -2,9 +2,8 @@ import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { globalVar } from "@stackframe/stack-shared/dist/utils/globals"; -import React, { Suspense, useEffect } from "react"; +import React, { useEffect } from "react"; import { useStackApp } from ".."; -import { AnalyticsOptions, StackAnalyticsInternal } from "../components/stack-analytics"; import { StackClientApp, StackClientAppJson, stackAppInternalsSymbol } from "../lib/stack-app"; export const StackContext = React.createContext | StackClientApp, serialized: boolean, - analytics?: AnalyticsOptions, children?: React.ReactNode, }) { const app = props.serialized @@ -24,7 +22,6 @@ export function StackProviderClient(props: { return ( - {props.analytics?.replays?.enabled === true ? : null} {props.children} ); diff --git a/packages/template/src/providers/stack-provider.tsx b/packages/template/src/providers/stack-provider.tsx index c3184f0e5f..cb985cd318 100644 --- a/packages/template/src/providers/stack-provider.tsx +++ b/packages/template/src/providers/stack-provider.tsx @@ -1,5 +1,4 @@ import React, { Suspense } from 'react'; -import { AnalyticsOptions } from '../components/stack-analytics'; import { StackAdminApp, StackClientApp, StackServerApp, stackAppInternalsSymbol } from '../lib/stack-app'; import { StackProviderClient } from './stack-provider-client'; import { TranslationProvider } from './translation-provider'; @@ -10,7 +9,6 @@ function NextStackProvider({ app, lang, translationOverrides, - analytics, }: { lang?: React.ComponentProps['lang'], /** @@ -23,13 +21,9 @@ function NextStackProvider({ children: React.ReactNode, // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp | StackServerApp | StackAdminApp, - /** - * Options for analytics and session recording. Replays are disabled by default. - */ - analytics?: AnalyticsOptions, }) { return ( - + {children} @@ -43,7 +37,6 @@ function ReactStackProvider({ app, lang, translationOverrides, - analytics, }: { lang?: React.ComponentProps['lang'], /** @@ -56,13 +49,9 @@ function ReactStackProvider({ children: React.ReactNode, // list all three types of apps even though server and admin are subclasses of client so it's clear that you can pass any app: StackClientApp, - /** - * Options for analytics and session recording. Replays are disabled by default. - */ - analytics?: AnalyticsOptions, }) { return ( - + {children} From 5868a35e6eb3928a9ecdc9bec8d58370d93bd448 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 13 Feb 2026 09:31:36 -0800 Subject: [PATCH 19/21] small pr fixes --- apps/backend/prisma/schema.prisma | 1 + .../latest/session-recordings/batch/route.tsx | 1 + .../apps/implementations/admin-app-impl.ts | 2 +- .../stack-app/apps/interfaces/admin-app.ts | 63 +------------------ .../lib/stack-app/session-recordings/index.ts | 61 ++++++++++++++++++ 5 files changed, 66 insertions(+), 62 deletions(-) create mode 100644 packages/template/src/lib/stack-app/session-recordings/index.ts diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b1ba92e60..9b616fee4b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -301,6 +301,7 @@ model SessionRecording { @@id([tenancyId, id]) @@index([tenancyId, projectUserId, startedAt]) @@index([tenancyId, lastEventAt]) + // index by updatedAt instead of lastEventAt because event timing can be spoofed @@index([tenancyId, refreshTokenId, updatedAt]) } diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 7606478ee4..6723b4a653 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -42,6 +42,7 @@ export const POST = createSmartRouteHandler({ summary: "Upload rrweb session recording batch", description: "Uploads a batch of rrweb events for a cross-tab session recording.", tags: ["Session Recordings"], + hidden: true }, request: yupObject({ auth: yupObject({ diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 76867c6031..3fad816cdb 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -19,7 +19,7 @@ import { AdminEmailTemplate } from "../../email-templates"; import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; -import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult, SessionRecordingAllEventsResult } from "../interfaces/admin-app"; +import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult, SessionRecordingAllEventsResult } from "../../session-recordings"; import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 0ba4f8dc8f..2317f511e4 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -31,67 +31,8 @@ export type EmailOutboxUpdateOptions = { cancel?: boolean, }; -export type AdminSessionRecording = { - id: string, - projectUser: { - id: string, - displayName: string | null, - primaryEmail: string | null, - }, - startedAt: Date, - lastEventAt: Date, - chunkCount: number, - eventCount: number, -}; - -export type AdminSessionRecordingChunk = { - id: string, - batchId: string, - tabId: string | null, - browserSessionId: string | null, - eventCount: number, - byteLength: number, - firstEventAt: Date, - lastEventAt: Date, - createdAt: Date, -}; - -export type ListSessionRecordingsOptions = { - limit?: number, - cursor?: string, -}; - -export type ListSessionRecordingsResult = { - items: AdminSessionRecording[], - nextCursor: string | null, -}; - -export type ListSessionRecordingChunksOptions = { - limit?: number, - cursor?: string, -}; - -export type ListSessionRecordingChunksResult = { - items: AdminSessionRecordingChunk[], - nextCursor: string | null, -}; - -export type SessionRecordingAllEventsResult = { - chunks: Array<{ - id: string, - batchId: string, - tabId: string | null, - eventCount: number, - byteLength: number, - firstEventAt: Date, - lastEventAt: Date, - createdAt: Date, - }>, - chunkEvents: Array<{ - chunkId: string, - events: unknown[], - }>, -}; +import type { ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult, SessionRecordingAllEventsResult } from "../../session-recordings"; +export type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingsOptions, ListSessionRecordingsResult, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, SessionRecordingAllEventsResult } from "../../session-recordings"; export type StackAdminAppConstructorOptions = ( diff --git a/packages/template/src/lib/stack-app/session-recordings/index.ts b/packages/template/src/lib/stack-app/session-recordings/index.ts new file mode 100644 index 0000000000..53b23fa805 --- /dev/null +++ b/packages/template/src/lib/stack-app/session-recordings/index.ts @@ -0,0 +1,61 @@ +export type AdminSessionRecording = { + id: string, + projectUser: { + id: string, + displayName: string | null, + primaryEmail: string | null, + }, + startedAt: Date, + lastEventAt: Date, + chunkCount: number, + eventCount: number, +}; + +export type AdminSessionRecordingChunk = { + id: string, + batchId: string, + tabId: string | null, + browserSessionId: string | null, + eventCount: number, + byteLength: number, + firstEventAt: Date, + lastEventAt: Date, + createdAt: Date, +}; + +export type ListSessionRecordingsOptions = { + limit?: number, + cursor?: string, +}; + +export type ListSessionRecordingsResult = { + items: AdminSessionRecording[], + nextCursor: string | null, +}; + +export type ListSessionRecordingChunksOptions = { + limit?: number, + cursor?: string, +}; + +export type ListSessionRecordingChunksResult = { + items: AdminSessionRecordingChunk[], + nextCursor: string | null, +}; + +export type SessionRecordingAllEventsResult = { + chunks: Array<{ + id: string, + batchId: string, + tabId: string | null, + eventCount: number, + byteLength: number, + firstEventAt: Date, + lastEventAt: Date, + createdAt: Date, + }>, + chunkEvents: Array<{ + chunkId: string, + events: unknown[], + }>, +}; From fa3242e5042880270fb34955baf4473fda769ad9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 13 Feb 2026 10:16:40 -0800 Subject: [PATCH 20/21] fix stuck replayer bug --- .../analytics/replays/page-client.tsx | 27 +- .../replays/session-replay-machine.test.ts | 573 ++++++++++++++++++ .../replays/session-replay-machine.ts | 305 +++++++++- 3 files changed, 883 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 908bc8f201..945e84e231 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -483,7 +483,25 @@ export default function PageClient() { return; } - if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) return; + if (!msRef.current.hasFullSnapshotByTab.has(tabKey)) { + // Last-resort: scan accumulated events for a FullSnapshot that the + // chunk-level detection may have missed (eg. due to race conditions or + // type coercion). rrweb FullSnapshot is event type 2. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hasSnapshot = eventsSnapshot.some(e => (e as any).type === 2 || (e as any).type === "2"); + if (!hasSnapshot) return; + // Patch the machine state so subsequent checks pass. + actRef.current({ + type: "CHUNK_LOADED", + generation: gen, + tabKey, + hasFullSnapshot: true, + loadedDurationMs: eventsSnapshot.length >= 2 + ? eventsSnapshot[eventsSnapshot.length - 1].timestamp - eventsSnapshot[0].timestamp + : 0, + hadEventsBeforeThisChunk: true, + }); + } try { const { Replayer } = await import("rrweb"); @@ -617,6 +635,11 @@ export default function PageClient() { } catch { // ignore } + } else { + // Replayer doesn't exist — try to create it so REPLAYER_READY + // can resume playback. This covers race conditions where the + // replayer hasn't been initialised yet when play is requested. + runAsynchronously(() => ensureReplayerForTab(effect.tabKey, msRef.current.generation), { noErrorLogging: true }); } break; } @@ -798,7 +821,7 @@ export default function PageClient() { const hasFullSnapshot = !msRef.current.hasFullSnapshotByTab.has(tabKey) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - && events.some(e => (e as any).type === 2); + && events.some(e => Number((e as any).type) === 2); let loadedDurationMs = 0; if (prev.length >= 2) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts index 4f75fce0aa..889b7ec320 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts @@ -7,6 +7,7 @@ import { findNextTabStartAfterGlobalOffset, ALLOWED_PLAYER_SPEEDS, DEFAULT_REPLAY_SETTINGS, + STALL_THRESHOLD_MS, type ReplayState, type ReplayAction, type StreamInfo, @@ -253,6 +254,64 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(true); }); + it("switches active tab when current active has no snapshot", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Active tab "a" has no full snapshot + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.replayerReady.delete("b"); + state.hasFullSnapshotByTab.delete("b"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 500, + hadEventsBeforeThisChunk: false, + }); + expect(s.activeTabKey).toBe("b"); + }); + + it("does NOT switch active tab when active already has a snapshot", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Active tab "a" already has full snapshot + state.replayerReady.delete("b"); + state.hasFullSnapshotByTab.delete("b"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 500, + hadEventsBeforeThisChunk: false, + }); + expect(s.activeTabKey).toBe("a"); + }); + + it("does NOT switch on subsequent snapshots for same tab", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + }); + // Tab "b" already has a full snapshot + // Active tab "a" has no full snapshot + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 2000, + hadEventsBeforeThisChunk: true, + }); + // Tab b already had a snapshot, so no switch needed + expect(s.activeTabKey).toBe("a"); + }); + it("stays buffering when not enough data", () => { const state = twoTabReadyState({ playbackMode: "buffering", @@ -306,6 +365,27 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(false); }); + it("auto-plays non-active tab when active tab is stuck (no full snapshot)", () => { + const state = twoTabReadyState({ + autoPlayTriggered: false, + activeTabKey: "a", + }); + // Active tab "a" has no full snapshot — it's stuck + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.replayerReady.delete("b"); + const { state: s, effects } = dispatch(state, { + type: "REPLAYER_READY", + generation: 1, + tabKey: "b", + }); + // Should switch to "b" and auto-play + expect(s.activeTabKey).toBe("b"); + expect(s.autoPlayTriggered).toBe(true); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + it("does not auto-play when already triggered", () => { const state = twoTabReadyState({ autoPlayTriggered: true, @@ -509,6 +589,21 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "play_replayer")).toBe(true); }); + it("switches to a ready tab when active tab has no replayer", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + pausedAtGlobalMs: 0, + }); + // Active tab "a" has no full snapshot — can't play + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + it("buffers when trying to play beyond loaded data", () => { const state = twoTabReadyState({ playbackMode: "paused", @@ -523,6 +618,32 @@ describe("session-replay-machine", () => { expect(s.playbackMode).toBe("buffering"); expect(s.autoResumeAfterBuffering).toBe(true); }); + + it("emits schedule_buffer_poll when entering buffering (no snapshot)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "downloading", + }); + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); + + it("emits schedule_buffer_poll when entering buffering (data not loaded)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + pausedAtGlobalMs: 3500, + phase: "downloading", + }); + state.loadedDurationByTabMs.set("a", 2000); + const { state: s, effects } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); + }); }); describe("SEEK", () => { @@ -779,6 +900,26 @@ describe("session-replay-machine", () => { expect(hasEffect(effects, "schedule_buffer_poll")).toBe(true); }); + it("does not resume to playing when active tab has no snapshot", () => { + const state = twoTabReadyState({ + playbackMode: "buffering", + activeTabKey: "a", + bufferingAtGlobalMs: 1000, + phase: "ready", + }); + // Tab a has no full snapshot — can't play + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + state.loadedDurationByTabMs.set("a", 5000); + const { state: s, effects } = dispatch(state, { + type: "BUFFER_CHECK", + generation: 1, + tabKey: "a", + }); + expect(s.playbackMode).toBe("paused"); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + it("ignores stale generation", () => { const state = twoTabReadyState({ playbackMode: "buffering" }); const { effects } = dispatch(state, { @@ -809,6 +950,51 @@ describe("session-replay-machine", () => { expect(s.bufferingAtGlobalMs).toBeNull(); expect(hasEffect(effects, "play_replayer")).toBe(true); }); + + it("switches to alt tab when active tab cannot play", () => { + const state: ReplayState = { + ...twoTabReadyState({ + phase: "downloading", + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + activeTabKey: "a", + }), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 1000, lastMs: 5000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[1000, 5000]], + }), + }; + // Tab a has no snapshot or replayer, but tab b does + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.phase).toBe("ready"); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("pauses when no tab can play on download complete", () => { + const state = twoTabReadyState({ + phase: "downloading", + playbackMode: "buffering", + bufferingAtGlobalMs: 2000, + autoResumeAfterBuffering: true, + activeTabKey: "a", + }); + // No tab has snapshot or replayer + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "DOWNLOAD_COMPLETE", generation: 1 }); + expect(s.phase).toBe("ready"); + expect(s.playbackMode).toBe("paused"); + }); }); describe("DOWNLOAD_ERROR", () => { @@ -1274,6 +1460,393 @@ describe("scenarios", () => { }); }); +// --------------------------------------------------------------------------- +// Stall detection tests +// --------------------------------------------------------------------------- + +describe("stall detection", () => { + it("starts tracking when playing with null activeReplayerLocalTimeMs", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBe(5000); + }); + + it("resets tracker when replayer starts responding", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 2000, + activeReplayerLocalTimeMs: 500, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker when playback mode is not playing", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("does not trigger recovery before threshold", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 5000, + }); + // 2999ms stall — just under threshold + const { state: s } = dispatch(state, { + type: "TICK", + nowMs: 5000 + STALL_THRESHOLD_MS - 1, + activeReplayerLocalTimeMs: null, + }); + // Should still be tracking, no recovery + expect(s.playingWithoutProgressSinceMs).toBe(5000); + expect(s.playbackMode).toBe("playing"); + }); + + it("Strategy A: switches to another ready tab on stall", () => { + // Tab a is active but stalled, tab b is ready and in range + const state: ReplayState = { + ...twoTabReadyState(), + streams: makeStreams( + { tabKey: "a", firstMs: 1000, lastMs: 5000 }, + { tabKey: "b", firstMs: 1000, lastMs: 5000 }, + ), + chunkRangesByTab: makeChunkRanges({ + a: [[1000, 5000]], + b: [[1000, 5000]], + }), + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }; + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.activeTabKey).toBe("b"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("Strategy B: recreates replayer when active tab is ready but broken", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Only tab a is in range at offset 2000, and it's in replayerReady + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.replayerReady.has("a")).toBe(false); + expect(hasEffect(effects, "recreate_replayer")).toBe(true); + const recreateEffects = getEffects(effects, "recreate_replayer"); + expect((recreateEffects[0] as any).tabKey).toBe("a"); + }); + + it("Strategy C: ensures replayer when tab has snapshot but no replayer", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Tab a has full snapshot but is NOT in replayerReady + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "ensure_replayer")).toBe(true); + const ensureEffects = getEffects(effects, "ensure_replayer"); + expect((ensureEffects[0] as any).tabKey).toBe("a"); + }); + + it("Strategy D: switches to any ready tab at a different offset", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + }); + // Tab a has no snapshot and is not ready, but tab b IS ready + state.hasFullSnapshotByTab.delete("a"); + state.replayerReady.delete("a"); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.activeTabKey).toBe("b"); + expect(s.playbackMode).toBe("playing"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(hasEffect(effects, "play_replayer")).toBe(true); + }); + + it("Strategy E: pauses with error when nothing works (download complete)", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + phase: "ready", + }); + // No tab has snapshot or replayer — nothing can recover + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playbackMode).toBe("paused"); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.playerError).toContain("stalled"); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("Strategy E: buffers instead of error when still downloading", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + pausedAtGlobalMs: 2000, + playingWithoutProgressSinceMs: 1000, + phase: "downloading", + }); + // No tab has snapshot or replayer — but still downloading + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s, effects } = dispatch(state, { + type: "TICK", + nowMs: 1000 + STALL_THRESHOLD_MS, + activeReplayerLocalTimeMs: null, + }); + expect(s.playbackMode).toBe("buffering"); + expect(s.bufferingAtGlobalMs).toBe(2000); + expect(s.autoResumeAfterBuffering).toBe(true); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + expect(s.playerError).toBeNull(); + expect(hasEffect(effects, "pause_all")).toBe(true); + }); + + it("TOGGLE_PLAY_PAUSE stays paused with error when no tab can play (download complete)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "ready", + }); + // No tab has snapshot or replayer — nothing can play + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toContain("Unable to play"); + }); + + it("TOGGLE_PLAY_PAUSE stays paused with error when activeTabKey is null", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: null, + phase: "ready", + }); + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toContain("Unable to play"); + }); + + it("TOGGLE_PLAY_PAUSE buffers when no tab can play but still downloading", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + activeTabKey: "a", + phase: "downloading", + }); + // No tab has snapshot or replayer yet — data still arriving + state.hasFullSnapshotByTab.clear(); + state.replayerReady.clear(); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("buffering"); + expect(s.autoResumeAfterBuffering).toBe(true); + expect(s.playerError).toBeNull(); + }); + + it("resets tracker on TOGGLE_PLAY_PAUSE (pause)", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on TOGGLE_PLAY_PAUSE (play)", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on SEEK", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on SELECT_TAB", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 2000 }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("resets tracker on REPLAYER_READY", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playingWithoutProgressSinceMs: 1000, + }); + state.replayerReady.delete("a"); + const { state: s } = dispatch(state, { type: "REPLAYER_READY", generation: 1, tabKey: "a" }); + expect(s.playingWithoutProgressSinceMs).toBeNull(); + }); + + it("initializes tracker as null", () => { + const state = createInitialState(); + expect(state.playingWithoutProgressSinceMs).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// playerError clearing tests +// --------------------------------------------------------------------------- + +describe("playerError clearing", () => { + it("TOGGLE_PLAY_PAUSE (play) clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "paused", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("playing"); + expect(s.playerError).toBeNull(); + }); + + it("TOGGLE_PLAY_PAUSE (pause) clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + playerError: "Some error", + }); + const { state: s } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 1000 }); + expect(s.playbackMode).toBe("paused"); + expect(s.playerError).toBeNull(); + }); + + it("SEEK clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "SEEK", globalOffsetMs: 2000, nowMs: 1000 }); + expect(s.playerError).toBeNull(); + }); + + it("SELECT_TAB clears playerError", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + activeTabKey: "a", + playerError: "Playback stalled: unable to recover.", + }); + const { state: s } = dispatch(state, { type: "SELECT_TAB", tabKey: "b", nowMs: 1000 }); + expect(s.playerError).toBeNull(); + }); + + it("CHUNK_LOADED with hasFullSnapshot on active tab clears playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBeNull(); + }); + + it("CHUNK_LOADED on non-active tab does NOT clear playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "b", + hasFullSnapshot: true, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBe("Unable to play: recording data may be incomplete."); + }); + + it("CHUNK_LOADED without hasFullSnapshot on active tab does NOT clear playerError", () => { + const state = twoTabReadyState({ + activeTabKey: "a", + playerError: "Unable to play: recording data may be incomplete.", + }); + const { state: s } = dispatch(state, { + type: "CHUNK_LOADED", + generation: 1, + tabKey: "a", + hasFullSnapshot: false, + loadedDurationMs: 3500, + hadEventsBeforeThisChunk: true, + }); + expect(s.playerError).toBe("Unable to play: recording data may be incomplete."); + }); +}); + // --------------------------------------------------------------------------- // Invariant tests (fuzz random action sequences) // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts index 65522ac46c..2d3ec80809 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts @@ -17,6 +17,10 @@ import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; export const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); +/** How long (wall-clock ms) the player can be in "playing" mode without the + * replayer reporting progress before we attempt automatic recovery. */ +export const STALL_THRESHOLD_MS = 3000; + export const DEFAULT_REPLAY_SETTINGS: ReplaySettings = { playerSpeed: 1, skipInactivity: true, @@ -93,6 +97,10 @@ export type ReplayState = { * position because `addEvent` didn't extend the playable range. */ prematureFinishRetryLocalMs: number | null, + /** Wall-clock time when we first noticed "playing" mode but the replayer + * hadn't reported any progress. Used for stall detection. */ + playingWithoutProgressSinceMs: number | null, + downloadError: string | null, playerError: string | null, }; @@ -194,6 +202,7 @@ export function createInitialState(settings?: ReplaySettings): ReplayState { bufferingAtGlobalMs: null, gapFastForward: null, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, downloadError: null, playerError: null, }; @@ -378,22 +387,36 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer if (isStaleGeneration(state, action.generation)) return { state, effects: [] }; const effects: ReplayEffect[] = []; - let newPlaybackMode = state.playbackMode; + let newPlaybackMode: PlaybackMode = state.playbackMode === "buffering" ? "paused" : state.playbackMode; + let newActiveTabKey = state.activeTabKey; - // Safety net: if buffering when download finishes, resume + // Safety net: if buffering when download finishes, try to resume if (state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering) { const seekTo = state.bufferingAtGlobalMs; - newPlaybackMode = "playing"; - effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, seekTo)); + let resumeTabKey = state.activeTabKey; + + // Verify the active tab can actually play + if (resumeTabKey && !state.replayerReady.has(resumeTabKey) && !state.hasFullSnapshotByTab.has(resumeTabKey)) { + resumeTabKey = findBestTabAtGlobalOffset(state, seekTo); + } + + if (resumeTabKey && (state.replayerReady.has(resumeTabKey) || state.hasFullSnapshotByTab.has(resumeTabKey))) { + newPlaybackMode = "playing"; + newActiveTabKey = resumeTabKey; + if (resumeTabKey !== state.activeTabKey) { + effects.push({ type: "ensure_replayer", tabKey: resumeTabKey, generation: state.generation }); + } + effects.push(...playEffectsForAllTabs({ ...state, playbackMode: "playing", activeTabKey: resumeTabKey }, seekTo)); + } + // else: newPlaybackMode stays "paused" — no tab can play } return { state: { ...state, phase: "ready", - playbackMode: state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering - ? "playing" - : (state.playbackMode === "buffering" ? "paused" : state.playbackMode), + activeTabKey: newActiveTabKey, + playbackMode: newPlaybackMode, bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, }, @@ -436,6 +459,19 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer effects.push({ type: "ensure_replayer", tabKey: action.tabKey, generation: action.generation }); } + // If the active tab has no FullSnapshot but this tab just got one, switch. + // This ensures the component renders a container for the playable tab. + let newActiveTabKey = state.activeTabKey; + if ( + action.hasFullSnapshot + && !state.hasFullSnapshotByTab.has(action.tabKey) + && state.activeTabKey !== null + && state.activeTabKey !== action.tabKey + && !newHasFullSnapshot.has(state.activeTabKey) + ) { + newActiveTabKey = action.tabKey; + } + // Check if buffering can be resolved by new data let newPlaybackMode = state.playbackMode; let newBufferingAtGlobalMs = state.bufferingAtGlobalMs; @@ -443,7 +479,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer let newPausedAtGlobalMs = state.pausedAtGlobalMs; if ( - state.activeTabKey === action.tabKey + newActiveTabKey === action.tabKey && state.bufferingAtGlobalMs !== null ) { const stream = getStreamInfo(state, action.tabKey); @@ -463,7 +499,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer newPlaybackMode = "playing"; newPausedAtGlobalMs = seekTo; effects.push(...playEffectsForAllTabs( - { ...state, playbackMode: "playing", activeTabKey: state.activeTabKey }, + { ...state, playbackMode: "playing", activeTabKey: newActiveTabKey }, seekTo, )); } else { @@ -473,9 +509,13 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer } } + // Clear playerError when the active tab receives a FullSnapshot + const clearPlayerError = action.hasFullSnapshot && action.tabKey === newActiveTabKey; + return { state: { ...state, + activeTabKey: newActiveTabKey, hasFullSnapshotByTab: newHasFullSnapshot, loadedDurationByTabMs: newLoadedDuration, tabsWithEvents: newTabsWithEvents, @@ -483,6 +523,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: newBufferingAtGlobalMs, autoResumeAfterBuffering: newAutoResumeAfterBuffering, pausedAtGlobalMs: newPausedAtGlobalMs, + ...(clearPlayerError ? { playerError: null } : {}), }, effects, }; @@ -495,13 +536,28 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer newReplayerReady.add(action.tabKey); const isActiveTab = state.activeTabKey === action.tabKey; - const shouldAutoPlay = !state.autoPlayTriggered && isActiveTab; - const shouldPlay = isActiveTab && (shouldAutoPlay || (state.playbackMode === "playing")); + + // Auto-play fallback: if auto-play hasn't triggered yet and the active + // tab is stuck (no full snapshot → replayer can never be created), switch + // the active tab to this newly-ready tab so auto-play can proceed. + const activeTabStuck = !isActiveTab + && !state.autoPlayTriggered + && state.activeTabKey !== null + && !state.hasFullSnapshotByTab.has(state.activeTabKey); + const effectiveIsActiveTab = isActiveTab || activeTabStuck; + + const shouldAutoPlay = !state.autoPlayTriggered && effectiveIsActiveTab; + const shouldPlay = effectiveIsActiveTab && (shouldAutoPlay || (state.playbackMode === "playing")); + + const newActiveTabKey = activeTabStuck ? action.tabKey : state.activeTabKey; const effects: ReplayEffect[] = []; const stream = getStreamInfo(state, action.tabKey); const streamStartTs = stream?.firstEventAtMs ?? state.globalStartTs; - const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, state.pausedAtGlobalMs); + const targetGlobalMs = activeTabStuck + ? localOffsetToGlobalOffset(state.globalStartTs, streamStartTs, 0) + : state.pausedAtGlobalMs; + const desiredLocal = globalOffsetToLocalOffset(state.globalStartTs, streamStartTs, targetGlobalMs); if (shouldPlay) { effects.push({ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: desiredLocal }); @@ -512,11 +568,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer return { state: { ...state, + activeTabKey: newActiveTabKey, replayerReady: newReplayerReady, autoPlayTriggered: state.autoPlayTriggered || shouldAutoPlay, playbackMode: shouldAutoPlay && state.playbackMode !== "buffering" ? "playing" : state.playbackMode, + pausedAtGlobalMs: activeTabStuck ? targetGlobalMs : state.pausedAtGlobalMs, + playingWithoutProgressSinceMs: null, }, effects, }; @@ -555,13 +614,14 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer ...state, replayerReady: newReplayerReady, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, }, effects: [{ type: "recreate_replayer", tabKey: action.tabKey, generation: action.generation }], }; } return { - state: { ...state, prematureFinishRetryLocalMs: localTime }, + state: { ...state, prematureFinishRetryLocalMs: localTime, playingWithoutProgressSinceMs: null }, effects: [{ type: "play_replayer", tabKey: action.tabKey, localOffsetMs: localTime }], }; } @@ -586,6 +646,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer pausedAtGlobalMs: globalOffset, bufferingAtGlobalMs: globalOffset, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, }, effects: [ { type: "schedule_buffer_poll", generation: action.generation, tabKey: action.tabKey, localTimeMs: localTime, delayMs: 500 }, @@ -622,6 +683,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, suppressAutoFollowUntilWallMs: action.nowMs + 400, + playingWithoutProgressSinceMs: null, }, effects, }; @@ -654,6 +716,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer playbackMode: "gap_fast_forward", gapFastForward: gff, pausedAtGlobalMs: globalOffset, + playingWithoutProgressSinceMs: null, }, effects: [], }; @@ -667,6 +730,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer pausedAtGlobalMs: globalOffset, bufferingAtGlobalMs: globalOffset, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, }, effects: [], }; @@ -681,6 +745,7 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer currentGlobalTimeMsForUi: state.globalTotalMs, gapFastForward: null, bufferingAtGlobalMs: null, + playingWithoutProgressSinceMs: null, }, effects: [{ type: "pause_all" }], }; @@ -699,43 +764,109 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer gapFastForward: null, bufferingAtGlobalMs: null, autoResumeAfterBuffering: false, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects: [{ type: "pause_all" }], }; } // Play - const target = state.pausedAtGlobalMs; + let target = state.pausedAtGlobalMs; + let playActiveTabKey = state.activeTabKey; + + // If active tab has no replayer and can't get one, switch to a tab that can play + if (playActiveTabKey && !state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey)) { + const altTab = findBestTabAtGlobalOffset(state, target); + if (altTab) { + playActiveTabKey = altTab; + } else { + // Find any ready tab at its start time + for (const s of state.streams) { + if (state.replayerReady.has(s.tabKey)) { + playActiveTabKey = s.tabKey; + target = localOffsetToGlobalOffset(state.globalStartTs, s.firstEventAtMs, 0); + break; + } + } + } + } + + // Guard: if no tab can play, either buffer (still downloading) or error + if ( + !playActiveTabKey + || (!state.replayerReady.has(playActiveTabKey) && !state.hasFullSnapshotByTab.has(playActiveTabKey)) + ) { + if (state.phase === "downloading" && playActiveTabKey) { + // Data may still arrive — enter buffering mode + const bufferStream = getStreamInfo(state, playActiveTabKey); + const bufferLocalMs = bufferStream ? globalOffsetToLocalOffset(state.globalStartTs, bufferStream.firstEventAtMs, target) : 0; + return { + state: { + ...state, + activeTabKey: playActiveTabKey, + pausedAtGlobalMs: target, + playbackMode: "buffering", + bufferingAtGlobalMs: target, + autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + playerError: null, + }, + effects: [ + { type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: bufferLocalMs, delayMs: 500 }, + ], + }; + } + return { + state: { + ...state, + playbackMode: "paused", + playerError: "Unable to play: recording data may be incomplete. Try reloading.", + playingWithoutProgressSinceMs: null, + }, + effects: [], + }; + } // Check if active tab needs buffering - if (state.phase === "downloading" && state.activeTabKey) { - const stream = getStreamInfo(state, state.activeTabKey); + if (state.phase === "downloading" && playActiveTabKey) { + const stream = getStreamInfo(state, playActiveTabKey); if (stream) { const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, target); - const loaded = state.loadedDurationByTabMs.get(state.activeTabKey) ?? 0; + const loaded = state.loadedDurationByTabMs.get(playActiveTabKey) ?? 0; if (localTarget > loaded) { return { state: { ...state, + activeTabKey: playActiveTabKey, + pausedAtGlobalMs: target, playbackMode: "buffering", bufferingAtGlobalMs: target, autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + playerError: null, }, - effects: [], + effects: [ + { type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: localTarget, delayMs: 500 }, + ], }; } } } + const stateForPlay = { ...state, activeTabKey: playActiveTabKey }; return { state: { - ...state, + ...stateForPlay, playbackMode: "playing", + pausedAtGlobalMs: target, bufferingAtGlobalMs: null, gapFastForward: null, suppressAutoFollowUntilWallMs: action.nowMs + 400, + playingWithoutProgressSinceMs: null, + playerError: null, }, - effects: playEffectsForAllTabs(state, target), + effects: playEffectsForAllTabs(stateForPlay, target), }; } @@ -770,6 +901,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer bufferingAtGlobalMs: action.globalOffsetMs, autoResumeAfterBuffering: true, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -790,6 +923,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer currentGlobalTimeMsForUi: action.globalOffsetMs, suppressAutoFollowUntilWallMs: action.nowMs + 400, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -817,6 +952,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer autoResumeAfterBuffering: true, suppressAutoFollowUntilWallMs: action.nowMs + 5000, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -840,6 +977,8 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer autoResumeAfterBuffering: false, suppressAutoFollowUntilWallMs: action.nowMs + 5000, prematureFinishRetryLocalMs: null, + playingWithoutProgressSinceMs: null, + playerError: null, }, effects, }; @@ -944,6 +1083,101 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer } } + // ----- Stall detection ----- + // Track when the player is in "playing" mode but the replayer hasn't + // reported any progress (activeReplayerLocalTimeMs is null). + if (newState.playbackMode === "playing" && action.activeReplayerLocalTimeMs !== null) { + // Replayer is responding — clear tracker + newState = { ...newState, playingWithoutProgressSinceMs: null }; + } else if (newState.playbackMode !== "playing") { + // Not playing — clear tracker + newState = { ...newState, playingWithoutProgressSinceMs: null }; + } else if (action.activeReplayerLocalTimeMs === null) { + if (newState.playingWithoutProgressSinceMs === null) { + // Start timing the stall + newState = { ...newState, playingWithoutProgressSinceMs: action.nowMs }; + } else if (action.nowMs - newState.playingWithoutProgressSinceMs >= STALL_THRESHOLD_MS) { + // Stall detected — attempt recovery + const stallGlobalOffset = newState.pausedAtGlobalMs; + + // Strategy A: Switch to another tab that IS ready + const altTab = findBestTabAtGlobalOffset(newState, stallGlobalOffset, newState.activeTabKey ?? undefined); + if (altTab && newState.replayerReady.has(altTab)) { + newState = { + ...newState, + activeTabKey: altTab, + playingWithoutProgressSinceMs: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }; + effects.push( + ...playEffectsForAllTabs(newState, stallGlobalOffset), + ); + return { state: newState, effects }; + } + + // Strategy B: Active tab IS in replayerReady (but broken) — recreate + const activeKeyB = newState.activeTabKey; + if (activeKeyB && newState.replayerReady.has(activeKeyB)) { + const newReplayerReady = new Set(newState.replayerReady); + newReplayerReady.delete(activeKeyB); + newState = { + ...newState, + replayerReady: newReplayerReady, + playingWithoutProgressSinceMs: null, + }; + effects.push({ type: "recreate_replayer", tabKey: activeKeyB, generation: newState.generation }); + return { state: newState, effects }; + } + + // Strategy C: Tab has full snapshot but no replayer — ensure it + const activeKeyC = newState.activeTabKey; + if (activeKeyC && newState.hasFullSnapshotByTab.has(activeKeyC)) { + newState = { ...newState, playingWithoutProgressSinceMs: null }; + effects.push({ type: "ensure_replayer", tabKey: activeKeyC, generation: newState.generation }); + return { state: newState, effects }; + } + + // Strategy D: Switch to ANY ready tab (even at a different offset) + for (const s of newState.streams) { + if (s.tabKey === newState.activeTabKey) continue; + if (!newState.replayerReady.has(s.tabKey)) continue; + const altGlobalMs = localOffsetToGlobalOffset(newState.globalStartTs, s.firstEventAtMs, 0); + newState = { + ...newState, + activeTabKey: s.tabKey, + pausedAtGlobalMs: altGlobalMs, + currentGlobalTimeMsForUi: altGlobalMs, + playingWithoutProgressSinceMs: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + }; + effects.push(...playEffectsForAllTabs(newState, altGlobalMs)); + return { state: newState, effects }; + } + + // Strategy E: Nothing works + if (state.phase === "downloading") { + // Still downloading — enter buffering, FullSnapshot may arrive + newState = { + ...newState, + playbackMode: "buffering", + bufferingAtGlobalMs: stallGlobalOffset, + autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + }; + effects.push({ type: "pause_all" }); + return { state: newState, effects }; + } + newState = { + ...newState, + playbackMode: "paused", + playingWithoutProgressSinceMs: null, + playerError: "Playback stalled: unable to recover. Try seeking or switching tabs.", + }; + effects.push({ type: "pause_all" }); + return { state: newState, effects }; + } + } + return { state: newState, effects }; } @@ -961,6 +1195,37 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer if (loaded > localTarget + 2000 || state.phase !== "downloading") { const seekTo = state.bufferingAtGlobalMs; + + // Verify the active tab can actually play before resuming + if (!state.replayerReady.has(action.tabKey) && !state.hasFullSnapshotByTab.has(action.tabKey)) { + const altTab = findBestTabAtGlobalOffset(state, seekTo); + if (altTab) { + return { + state: { + ...state, + activeTabKey: altTab, + playbackMode: "playing", + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: [ + { type: "ensure_replayer", tabKey: altTab, generation: action.generation }, + ...playEffectsForAllTabs({ ...state, activeTabKey: altTab }, seekTo), + ], + }; + } + // No tab can play — fall back to paused + return { + state: { + ...state, + playbackMode: "paused", + bufferingAtGlobalMs: null, + autoResumeAfterBuffering: false, + }, + effects: [{ type: "pause_all" }], + }; + } + return { state: { ...state, From b9e9047b2da3e1a58f7565b2f98b6a620e7362fa Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 16 Feb 2026 09:35:53 -0800 Subject: [PATCH 21/21] handle analytics app disabled in event ingestion --- .../latest/session-recordings/batch/route.tsx | 12 ++++++ .../api/v1/session-recordings.test.ts | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx index 6723b4a653..f7649fcfc4 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -71,6 +71,18 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ auth, body }, fullReq) { + if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) { + return { + statusCode: 200, + bodyType: "json", + body: { + session_recording_id: "", + batch_id: body.batch_id, + s3_key: "", + deduped: false, + }, + }; + } if (!auth.user) { throw new KnownErrors.UserAuthenticationRequired(); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts index cf9eef7570..5b19fdf32f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts @@ -26,6 +26,7 @@ async function uploadBatch(options: { 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/session-recordings/batch", { @@ -45,8 +46,32 @@ it("requires a user token", async ({ expect }) => { expect(res.status).toBeLessThan(500); }); +it("returns 200 no-op 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 niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + browser_session_id: randomUUID(), + tab_id: randomUUID(), + batch_id: randomUUID(), + started_at_ms: Date.now(), + sent_at_ms: Date.now(), + events: [{ timestamp: Date.now() }], + }, + }); + + expect(res.status).toBe(200); + expect(res.body?.session_recording_id).toBe(""); + expect(res.body?.s3_key).toBe(""); +}); + it("stores session recording batch metadata and dedupes by (session_recording_id, 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 now = Date.now(); @@ -103,6 +128,7 @@ it("stores session recording batch metadata and dedupes by (session_recording_id it("rejects empty events", 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/session-recordings/batch", { @@ -124,6 +150,7 @@ it("rejects empty events", async ({ expect }) => { it("rejects too many events", 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: 5001 }, (_, i) => ({ timestamp: 1_700_000_000_000 + i })); @@ -147,6 +174,7 @@ it("rejects too many events", async ({ expect }) => { it("rejects invalid browser_session_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/session-recordings/batch", { @@ -168,6 +196,7 @@ it("rejects invalid browser_session_id", async ({ expect }) => { 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/session-recordings/batch", { @@ -189,6 +218,7 @@ it("rejects invalid batch_id", async ({ expect }) => { it("rejects invalid tab_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/session-recordings/batch", { @@ -210,6 +240,7 @@ it("rejects invalid tab_id", async ({ expect }) => { it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); const browserSessionId = randomUUID(); @@ -238,6 +269,7 @@ it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expe it("rejects non-integer started_at_ms", 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/session-recordings/batch", { @@ -259,6 +291,7 @@ it("rejects non-integer started_at_ms", async ({ expect }) => { it("rejects oversized payloads", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); // Backend limit is 5_000_000 bytes; a single large string is sufficient to exceed it. @@ -282,6 +315,7 @@ it("rejects oversized payloads", async ({ expect }) => { it("admin can list session recordings, list chunks, and fetch events", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); const browserSessionId = randomUUID(); @@ -330,6 +364,7 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ it("admin list session recordings paginates without skipping items", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); // Use separate sign-ins to get different refresh tokens → different session recordings. await Auth.Otp.signIn(); @@ -396,6 +431,7 @@ it("admin list session recordings rejects unknown cursor", async ({ expect }) => it("admin list chunks paginates and rejects a cursor from another session", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); const now = Date.now(); @@ -474,6 +510,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn it("admin events endpoint does not allow fetching a chunk via the wrong session id", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); // session1: upload under first refresh token await Auth.Otp.signIn(); @@ -540,6 +577,7 @@ it("non-admin access cannot call internal session recordings endpoints", async ( it("groups batches from same refresh token into one session recording", 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();