diff --git a/CLAUDE.md b/CLAUDE.md index d16eeba094..dc54ebb389 100644 Binary files a/CLAUDE.md and b/CLAUDE.md differ 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/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..80f61ace87 --- /dev/null +++ b/apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql @@ -0,0 +1,60 @@ +-- 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, + "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, + "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_tenancyId_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 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_createdA_idx" + ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index df43c5c39c..5f22387ff3 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,62 @@ model ProjectUserOAuthAccount { @@index([tenancyId, projectUserId]) } +model SessionRecording { + 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]) + // index by updatedAt instead of lastEventAt because event timing can be spoofed + @@index([tenancyId, refreshTokenId, updatedAt]) +} + +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 + + // Client-generated session id from localStorage, stored as metadata. + browserSessionId 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/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..d8d2aaf479 --- /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, private: true }); + } 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_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"); + } + + 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..fb5e9b71c3 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx @@ -0,0 +1,128 @@ +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(), + browser_session_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, + browserSessionId: 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, + browser_session_id: c.browserSessionId, + 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/[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/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/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..f7649fcfc4 --- /dev/null +++ b/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx @@ -0,0 +1,236 @@ +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 { randomUUID } from "node:crypto"; +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; +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; + 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"], + hidden: true + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + user: adaptSchema, + refreshTokenId: adaptSchema + }).defined(), + body: yupObject({ + 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), + 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_recording_id: yupString().defined(), + batch_id: yupString().defined(), + s3_key: yupString().defined(), + deduped: yupMixed().defined(), + }).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(); + } + 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 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 { firstMs, lastMs } = extractEventTimesMs(body.events, body.sent_at_ms); + + const prisma = await getPrismaClientForTenancy(auth.tenancy); + + // 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 }, + }); + + 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_id: { tenancyId, id: recordingId } }, + create: { + id: recordingId, + tenancyId, + projectUserId, + refreshTokenId, + startedAt: new Date(firstMs), + lastEventAt: new Date(newLastEventAtMs), + }, + update: { + 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: recordingId, batchId } }, + select: { s3Key: true }, + }); + if (existingChunk) { + return { + statusCode: 200, + bodyType: "json", + body: { + session_recording_id: recordingId, + batch_id: batchId, + s3_key: existingChunk.s3Key, + deduped: true, + }, + }; + } + + const payload = { + v: 1, + session_recording_id: recordingId, + browser_session_id: browserSessionId, + 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", + private: true, + }); + + try { + await prisma.sessionRecordingChunk.create({ + data: { + tenancyId, + sessionRecordingId: recordingId, + batchId, + tabId, + browserSessionId, + 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_recording_id: recordingId, + batch_id: batchId, + s3_key: s3Key, + deduped: true, + }, + }; + } + throw e; + } + + return { + statusCode: 200, + bodyType: "json", + body: { + session_recording_id: recordingId, + batch_id: batchId, + s3_key: s3Key, + deduped: false, + }, + }; + }, +}); diff --git a/apps/backend/src/s3.tsx b/apps/backend/src/s3.tsx index d292305131..1a29687618 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"; @@ -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, @@ -34,6 +39,87 @@ export function getS3PublicUrl(key: string): string { } } +export async function uploadBytes(options: { + key: string, + 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: 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, + }; +} + +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, 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: 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 diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c41b422898..d268f512f1 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -96,6 +96,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..945e84e231 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -0,0 +1,1462 @@ +"use client"; + +import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton, Switch, Typography } from "@/components/ui"; +import { useFromNow } from "@/hooks/use-from-now"; +import { + getDesiredGlobalOffsetFromPlaybackState, + INTER_TAB_GAP_FAST_FORWARD_MULTIPLIER, +} from "@/lib/session-replay-playback"; +import type { TabKey, TabStream } from "@/lib/session-replay-streams"; +import { + computeGlobalTimeline, + globalOffsetToLocalOffset, + groupChunksIntoTabStreams, + 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, 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"; +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 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"; + +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, + }>, + getSessionRecordingEvents: (sessionRecordingId: string, options?: { offset?: number, limit?: number }) => Promise<{ + chunks: ChunkRow[], + chunkEvents: Array<{ chunkId: string, 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; + 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")}`; +} + +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 + : 1; + const skipInactivity = typeof value.skipInactivity === "boolean" + ? value.skipInactivity + : true; + const followActiveTab = typeof value.followActiveTab === "boolean" + ? value.followActiveTab + : false; + + return { playerSpeed, skipInactivity, followActiveTab }; +} + +function getInitialReplaySettings(): ReplaySettings { + if (typeof window === 'undefined') return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; + 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 { playerSpeed: legacySpeed, skipInactivity: true, followActiveTab: false }; + } + } + } catch { + // ignore + } + return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; +} + +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 })} + /> +
+
+
+
+ + ); +} + +// --------------------------------------------------------------------------- +// 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() { + 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); + 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 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]); + + // ---- Replay state machine ---- + + const { state: ms, stateRef: msRef, dispatch: rawDispatch } = useReplayMachine(getInitialReplaySettings()); + + // ---- DOM / rrweb refs (not managed by machine) ---- + + const eventsByTabRef = 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); + + // Full TabStream objects for rendering (machine only stores StreamInfo). + const [fullStreams, setFullStreams] = useState[]>([]); + const fullStreamsRef = useRef[]>([]); + + // Generation counter for staleness checks in async operations. + const genCounterRef = useRef(0); + + // ---- UI-only state ---- + const [isSkipping, setIsSkipping] = useState(false); + const [uiVersion, setUiVersion] = useState(0); + + // ---- Derived values ---- + + 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"; + + // ---- Imperative helpers ---- + + 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 = ""; + } + }, []); + + // ---- act: dispatch action to machine + execute returned effects ---- + // Uses a ref to break circular dependency with ensureReplayerForTab. + + const actRef = useRef<(action: ReplayAction) => void>(() => {}); + + const ensureReplayerForTab = useCallback(async (tabKey: TabKey, gen: number) => { + if (msRef.current.generation !== 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; + } + + 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"); + if (msRef.current.generation !== gen) return; + if (replayerByTabRef.current.has(tabKey)) return; + + const eventsSnapshot2 = eventsByTabRef.current.get(tabKey)?.slice() ?? []; + if (eventsSnapshot2.length === 0) return; + + const replayer = new Replayer(eventsSnapshot2, { + root: rootEl, + speed: msRef.current.settings.playerSpeed, + skipInactive: msRef.current.settings.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"; + + 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 = msRef.current.activeTabKey === tabKey; + 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); + + // Register replayer BEFORE dispatching so effects can find it. + replayerByTabRef.current.set(tabKey, replayer); + + // Finish handler — all logic is in the machine reducer. + try { + replayer.on("finish", () => { + if (msRef.current.generation !== gen) return; + if (msRef.current.activeTabKey !== tabKey) return; + + let localTime = 0; + try { + localTime = replayer.getCurrentTime(); + } catch { + // ignore + } + + 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 { + r.play(effect.localOffsetMs); + } 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; + } + case "pause_replayer_at": { + const r = replayerByTabRef.current.get(effect.tabKey); + if (r) { + try { + r.pause(effect.localOffsetMs); + } catch { + // ignore + } + } + break; + } + case "pause_all": { + for (const r of replayerByTabRef.current.values()) { + try { + r.pause(); + } catch { + // ignore + } + } + 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 + } + } + 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 + } + } + 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 + } + } + 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; + } + } + } + } + + // 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; + + 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, msRef.current.generation), { noErrorLogging: true }); + }, [ensureReplayerForTab, msRef]); + + // ---- Load chunks and download events ---- + + const loadChunksAndDownload = useCallback(async (recordingId: string) => { + const gen = ++genCounterRef.current; + actRef.current({ type: "SELECT_RECORDING", generation: gen }); + setFullStreams([]); + fullStreamsRef.current = []; + + // 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; + + 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 => Number((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); + fullStreamsRef.current = allStreams; + + const { globalStartTs, globalTotalMs } = computeGlobalTimeline(allStreams); + + // Build chunk ranges from full metadata. + 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); + + const merged: ChunkRange[] = []; + for (const r of ranges) { + const last = merged[merged.length - 1] as ChunkRange | 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); + } + + // Stable tab labels. + 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); + }); + const tabLabelIndex = new Map(labelOrder.map((s, i) => [s.tabKey, i + 1])); + + 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, + }); + + // 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); + } + } + + // Process the initial batch of events. + processChunkEvents(initialResponse.chunkEvents, allStreams, chunkIdToTabKey); + + // Phase 2: Background loading of remaining chunks. + const totalChunks = allChunkRows.length; + let offset = INITIAL_CHUNK_BATCH; + + 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." }); + } + return; + } + + 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]); + + useEffect(() => { + return () => { + genCounterRef.current += 1; + destroyReplayers(); + }; + }, [destroyReplayers]); + + // ---- Timeline time reading (smooth, direct from rrweb) ---- + + const getCurrentGlobalTimeMs = useCallback(() => { + 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 ---- + + useEffect(() => { + let cancelled = false; + let raf = 0; + let lastUpdateAt = 0; + + const tick = (now: number) => { + if (cancelled) return; + if (now - lastUpdateAt > 200) { + lastUpdateAt = now; + 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; + } + } + + actRef.current({ + type: "TICK", + nowMs: performance.now(), + activeReplayerLocalTimeMs: activeLocalTimeMs, + }); + } + raf = requestAnimationFrame(tick); + }; + + raf = requestAnimationFrame(tick); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, [msRef]); + + // ---- Skip indicator (speedService subscription) ---- + + useEffect(() => { + if (!ms.settings.skipInactivity) { + setIsSkipping(false); + speedSubRef.current?.unsubscribe(); + speedSubRef.current = null; + return; + } + + const key = ms.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 + } + }, [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( + () => (ms.activeTabKey ? fullStreams.find(s => s.tabKey === ms.activeTabKey) ?? null : null), + [ms.activeTabKey, fullStreams], + ); + + const visibleMiniStreams = useMemo(() => { + 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 = 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); + }, [ms.activeTabKey, ms.currentGlobalTimeMsForUi, ms.globalStartTs, ms.hasFullSnapshotByTab, ms.tabLabelIndex, fullStreams, 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 = ms.tabLabelIndex.get(tabKey); + if (!idx) return "Tab"; + return `Tab ${idx}`; + }, [ms.tabLabelIndex]); + + const activeHasEvents = useMemo(() => { + if (!activeStream) return false; + void uiVersion; + return (eventsByTabRef.current.get(activeStream.tabKey)?.length ?? 0) > 0; + }, [activeStream, uiVersion]); + + const renderableStreamCount = useMemo(() => { + void uiVersion; + return fullStreams.filter(s => ms.hasFullSnapshotByTab.has(s.tabKey)).length; + }, [fullStreams, ms.hasFullSnapshotByTab, uiVersion]); + + const showMainTabLabel = renderableStreamCount > 1; + + // ---- Rendering ---- + + 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 && ( +
+ + +
+ )} +
+ )} +
+
+
+ + + + +
+ {(ms.downloadError || ms.playerError) && ( +
+ {ms.downloadError && {ms.downloadError}} + {ms.playerError && {ms.playerError}} +
+ )} + +
+ + {selectedRecording ? getRecordingTitle(selectedRecording) : ""} + + actRef.current({ type: "UPDATE_SETTINGS", updates })} + /> +
+ + {selectedRecording ? ( +
+
+
+ {fullStreams.length === 0 && ( +
+
+ {isDownloading ? ( + <> + + + Loading replay... + + + ) : ( + + No replay data loaded yet. + + )} +
+
+ )} + + {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; + + const title = getTabLabel(s.tabKey); + + return ( + + ); + })} +
+ + {activeStream && activeHasEvents && ( + + )} +
+
+ ) : ( +
+ {loadingInitial ? ( +
+ + + Loading replay... + +
+ ) : ( +
+ + + No session replays yet + +
+ )} +
+ )} +
+
+
+
+
+ ); +} 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/(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..889b7ec320 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.test.ts @@ -0,0 +1,1961 @@ +import { describe, expect, it } from "vitest"; +import { + createInitialState, + replayReducer, + findBestTabAtGlobalOffset, + isTabInRangeAtGlobalOffset, + findNextTabStartAfterGlobalOffset, + ALLOWED_PLAYER_SPEEDS, + DEFAULT_REPLAY_SETTINGS, + STALL_THRESHOLD_MS, + 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("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", + 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("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, + 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("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", + 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); + }); + + 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", () => { + 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("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, { + 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); + }); + + 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", () => { + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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) +// --------------------------------------------------------------------------- + +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..2d3ec80809 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/session-replay-machine.ts @@ -0,0 +1,1260 @@ +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]); + +/** 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, + 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, + + /** 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, +}; + +// --------------------------------------------------------------------------- +// 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, + playingWithoutProgressSinceMs: 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: PlaybackMode = state.playbackMode === "buffering" ? "paused" : state.playbackMode; + let newActiveTabKey = state.activeTabKey; + + // Safety net: if buffering when download finishes, try to resume + if (state.bufferingAtGlobalMs !== null && state.autoResumeAfterBuffering) { + const seekTo = state.bufferingAtGlobalMs; + 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", + activeTabKey: newActiveTabKey, + playbackMode: newPlaybackMode, + 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 }); + } + + // 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; + let newAutoResumeAfterBuffering = state.autoResumeAfterBuffering; + let newPausedAtGlobalMs = state.pausedAtGlobalMs; + + if ( + newActiveTabKey === 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: newActiveTabKey }, + seekTo, + )); + } else { + newPlaybackMode = "paused"; + } + } + } + } + + // 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, + playbackMode: newPlaybackMode, + bufferingAtGlobalMs: newBufferingAtGlobalMs, + autoResumeAfterBuffering: newAutoResumeAfterBuffering, + pausedAtGlobalMs: newPausedAtGlobalMs, + ...(clearPlayerError ? { playerError: null } : {}), + }, + 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; + + // 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 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 }); + } else { + effects.push({ type: "pause_replayer_at", tabKey: action.tabKey, localOffsetMs: desiredLocal }); + } + + 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, + }; + } + + 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, + playingWithoutProgressSinceMs: null, + }, + effects: [{ type: "recreate_replayer", tabKey: action.tabKey, generation: action.generation }], + }; + } + + return { + state: { ...state, prematureFinishRetryLocalMs: localTime, playingWithoutProgressSinceMs: null }, + 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, + playingWithoutProgressSinceMs: null, + }, + 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, + playingWithoutProgressSinceMs: null, + }, + 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, + playingWithoutProgressSinceMs: null, + }, + effects: [], + }; + } + + if (finishAction.type === "buffer_at_current") { + return { + state: { + ...state, + playbackMode: "buffering", + pausedAtGlobalMs: globalOffset, + bufferingAtGlobalMs: globalOffset, + autoResumeAfterBuffering: true, + playingWithoutProgressSinceMs: null, + }, + effects: [], + }; + } + + // True finish + return { + state: { + ...state, + playbackMode: "finished", + pausedAtGlobalMs: state.globalTotalMs, + currentGlobalTimeMsForUi: state.globalTotalMs, + gapFastForward: null, + bufferingAtGlobalMs: null, + playingWithoutProgressSinceMs: 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, + playingWithoutProgressSinceMs: null, + playerError: null, + }, + effects: [{ type: "pause_all" }], + }; + } + + // Play + 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" && playActiveTabKey) { + const stream = getStreamInfo(state, playActiveTabKey); + if (stream) { + const localTarget = globalOffsetToLocalOffset(state.globalStartTs, stream.firstEventAtMs, target); + 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: [ + { type: "schedule_buffer_poll", generation: state.generation, tabKey: playActiveTabKey, localTimeMs: localTarget, delayMs: 500 }, + ], + }; + } + } + } + + const stateForPlay = { ...state, activeTabKey: playActiveTabKey }; + return { + state: { + ...stateForPlay, + playbackMode: "playing", + pausedAtGlobalMs: target, + bufferingAtGlobalMs: null, + gapFastForward: null, + suppressAutoFollowUntilWallMs: action.nowMs + 400, + playingWithoutProgressSinceMs: null, + playerError: null, + }, + effects: playEffectsForAllTabs(stateForPlay, 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, + playingWithoutProgressSinceMs: null, + playerError: 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, + playingWithoutProgressSinceMs: null, + playerError: 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, + playingWithoutProgressSinceMs: null, + playerError: 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, + playingWithoutProgressSinceMs: null, + playerError: 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), + ); + } + } + } + } + + // ----- 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 }; + } + + 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; + + // 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, + 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, + }); + }); +}); diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index db9b299a94..0d582884d1 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" }, { displayName: "Queries", href: "./queries" }, ], screenshots: [], 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..59da038f5e --- /dev/null +++ b/apps/dashboard/src/lib/session-replay-playback.ts @@ -0,0 +1,81 @@ +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, + 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 + && ( + 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); +} + 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/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..5b19fdf32f --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts @@ -0,0 +1,606 @@ +import { randomUUID } from "node:crypto"; +import { it } from "../../../../helpers"; +import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; + +async function uploadBatch(options: { + browserSessionId: string, + batchId: string, + startedAtMs: number, + sentAtMs: number, + events: unknown[], + tabId?: string, +}) { + return await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + browser_session_id: options.browserSessionId, + tab_id: options.tabId ?? randomUUID(), + 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 } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + backendContext.set({ userAuth: null }); + + 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).toBeGreaterThanOrEqual(400); + 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(); + const browserSessionId = randomUUID(); + const batchId = randomUUID(); + const tabId = randomUUID(); + + const first = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + browser_session_id: browserSessionId, + tab_id: tabId, + batch_id: batchId, + started_at_ms: now, + sent_at_ms: now + 500, + events: [ + { timestamp: now + 100, type: 2 }, + { timestamp: now + 200, type: 3 }, + ], + }, + }); + + expect(first.status).toBe(200); + expect(typeof first.body?.session_recording_id).toBe("string"); + expect(first.body).toMatchObject({ + batch_id: batchId, + deduped: false, + }); + expect(typeof first.body?.s3_key).toBe("string"); + + const recordingId = first.body?.session_recording_id; + + const second = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + browser_session_id: browserSessionId, + tab_id: tabId, + batch_id: batchId, + started_at_ms: now, + sent_at_ms: now + 500, + events: [{ timestamp: now + 150, type: 2 }], + }, + }); + + expect(second.status).toBe(200); + expect(second.body).toMatchObject({ + session_recording_id: recordingId, + batch_id: batchId, + deduped: true, + }); +}); + +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", { + 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: [], + }, + }); + + 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 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 })); + + 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: 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 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", { + method: "POST", + accessType: "client", + body: { + browser_session_id: "not-a-uuid", + tab_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("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", { + method: "POST", + accessType: "client", + body: { + browser_session_id: randomUUID(), + tab_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 Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + browser_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 Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const browserSessionId = randomUUID(); + const batchId = randomUUID(); + + const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + method: "POST", + accessType: "client", + body: { + 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, + events: [{ type: 2 }, { type: 3, timestamp: undefined }], + }, + }); + + expect(res.status).toBe(200); + expect(typeof res.body?.session_recording_id).toBe("string"); + expect(res.body).toMatchObject({ + batch_id: batchId, + deduped: false, + }); +}); + +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", { + method: "POST", + accessType: "client", + body: { + browser_session_id: randomUUID(), + tab_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 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. + const hugeString = "a".repeat(5_100_000); + + 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(), 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 Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const browserSessionId = 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({ + 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", + accessType: "admin", + }); + expect(listRes.status).toBe(200); + expect(listRes.body?.items?.length).toBeGreaterThanOrEqual(1); + + const chunksRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${recordingId}/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/${recordingId}/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 Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + + // 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 }], + }); + 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", + accessType: "admin", + }); + expect(first.status).toBe(200); + expect(first.body?.items?.length).toBe(1); + const firstId = first.body?.items?.[0]?.id; + expect([recordingA, recordingB]).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([recordingA, recordingB]).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 Project.updateConfig({ apps: { installed: { analytics: { 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: now, + sentAtMs: now + 500, + events: [{ type: 1, timestamp: now + 10 }], + }); + expect(upload1a.status).toBe(200); + const recording1 = upload1a.body?.session_recording_id; + + await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 600, + events: [{ type: 1, timestamp: now + 20 }], + }); + + // session2: one batch under a different refresh token + await Auth.Otp.signIn(); + const upload2 = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 700, + events: [{ type: 1, timestamp: now + 30 }], + }); + expect(upload2.status).toBe(200); + const recording2 = upload2.body?.session_recording_id; + + const first = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/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/${recording1}/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/${recording2}/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/${recording1}/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 Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + + // session1: upload under first refresh token + await Auth.Otp.signIn(); + const batchId = randomUUID(); + 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 }], + }); + 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/${recording1}/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/${recording2}/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); +}); + +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(); + + // Two batches with different browser_session_ids but same refresh token + const upload1 = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + 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: now, + sentAtMs: now + 400, + events: [{ type: 1, timestamp: now + 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/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 diff --git a/docs/src/stack.ts b/docs/src/stack.ts index c09159b4aa..d875427582 100644 --- a/docs/src/stack.ts +++ b/docs/src/stack.ts @@ -8,4 +8,10 @@ export const stackServerApp = new StackServerApp({ publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, secretServerKey: process.env.STACK_SECRET_SERVER_KEY, baseUrl: process.env.NEXT_PUBLIC_STACK_API_URL, + analytics: { + replays: { + enabled: true, + maskAllInputs: false, + }, + }, }); diff --git a/packages/js/package.json b/packages/js/package.json index e779049bf7..84d743ad89 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 b5a1d903d6..ff14cf67bd 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-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 881ca9db34..9695d219b0 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -10,6 +10,14 @@ 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 { + AdminGetSessionRecordingAllEventsResponse, + 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"; @@ -703,6 +711,51 @@ 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 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/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index eb21d585eb..319824ad98 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/stack-shared/src/interface/crud/session-recordings.ts b/packages/stack-shared/src/interface/crud/session-recordings.ts new file mode 100644 index 0000000000..ce6c190170 --- /dev/null +++ b/packages/stack-shared/src/interface/crud/session-recordings.ts @@ -0,0 +1,66 @@ +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, + browser_session_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[], +}; + +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/stack/package.json b/packages/stack/package.json index 7ab663ad7f..29e24af3c3 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 5aa80f8d4a..68bd73acf7 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 a238a77d23..56fe6bbccd 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/index.ts b/packages/template/src/index.ts index c050374962..a64b0cfc96 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 "./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/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index d7b0d41a93..55460b522c 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 { 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"; @@ -18,6 +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 "../../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"; @@ -990,6 +992,79 @@ 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, + browserSessionId: c.browser_session_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 getSessionRecordingEvents(sessionRecordingId: string, options?: { offset?: number, limit?: number }): Promise { + 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/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index cf6e703cff..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) => { @@ -2830,6 +2853,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/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/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 8960e70515..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 @@ -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, 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"; @@ -30,6 +31,9 @@ export type EmailOutboxUpdateOptions = { cancel?: boolean, }; +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 = ( & StackServerAppConstructorOptions @@ -118,6 +122,11 @@ export type StackAdminApp, queryAnalytics(options: AnalyticsQueryOptions): Promise, + listSessionRecordings(options?: ListSessionRecordingsOptions): Promise, + 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, getOutboxEmail(id: string): Promise, 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..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 } ) & ( @@ -93,6 +100,7 @@ export type StackClientApp, setCurrentUser(userJsonPromise: Promise): void, getConstructorOptions(): StackClientAppConstructorOptions & { inheritsFrom?: undefined }, + sendSessionRecordingBatch(body: string, options: { keepalive: boolean }): Promise>, }, } & AsyncStoreProperty<"project", [], Project, false> 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[], + }>, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb56ca66d7..80d9a97cac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -577,6 +577,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) @@ -1509,6 +1512,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 @@ -1630,6 +1636,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) @@ -1763,6 +1772,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) @@ -2151,6 +2163,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) @@ -8445,6 +8460,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==} @@ -9027,6 +9045,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==} @@ -9361,6 +9382,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==} @@ -12913,6 +12938,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'} @@ -14420,6 +14448,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==} @@ -24217,6 +24251,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': @@ -25044,6 +25080,8 @@ snapshots: '@webgpu/types@0.1.66': {} + '@xstate/fsm@1.6.5': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -25442,6 +25480,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: {} @@ -30122,6 +30162,8 @@ snapshots: dependencies: minipass: 7.1.2 + mitt@1.2.0: {} + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -32143,6 +32185,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: {}