diff --git a/apps/backend/prisma/migrations/20260216000000_rename_tab_id_to_session_replay_segment_id/migration.sql b/apps/backend/prisma/migrations/20260216000000_rename_tab_id_to_session_replay_segment_id/migration.sql index 11a201f1dc..4319d95770 100644 --- a/apps/backend/prisma/migrations/20260216000000_rename_tab_id_to_session_replay_segment_id/migration.sql +++ b/apps/backend/prisma/migrations/20260216000000_rename_tab_id_to_session_replay_segment_id/migration.sql @@ -1 +1,22 @@ ALTER TABLE "SessionRecordingChunk" RENAME COLUMN "tabId" TO "sessionReplaySegmentId"; + +ALTER TABLE "SessionRecording" RENAME TO "SessionReplay"; +ALTER TABLE "SessionRecordingChunk" RENAME TO "SessionReplayChunk"; +ALTER TABLE "SessionReplayChunk" RENAME COLUMN "sessionRecordingId" TO "sessionReplayId"; + +-- Rename primary key constraints +ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_pkey" TO "SessionReplay_pkey"; +ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_pkey" TO "SessionReplayChunk_pkey"; + +-- Rename foreign key constraints +ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_fkey" TO "SessionReplay_tenancyId_fkey"; +ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey" TO "SessionReplay_tenancyId_projectUserId_fkey"; +ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_fkey" TO "SessionReplayChunk_tenancyId_fkey"; +ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey" TO "SessionReplayChunk_tenancyId_sessionReplayId_fkey"; + +-- Rename indexes +ALTER INDEX "SessionRecording_tenancyId_lastEventAt_idx" RENAME TO "SessionReplay_tenancyId_lastEventAt_idx"; +ALTER INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" RENAME TO "SessionReplay_tenancyId_projectUserId_startedAt_idx"; +ALTER INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx" RENAME TO "SessionReplay_tenancyId_refreshTokenId_updatedAt_idx"; +ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_batchId_key"; +ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_createdAt_idx"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 4a1226d2a7..93d73842c9 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -60,8 +60,8 @@ model Tenancy { organizationId String? @db.Uuid hasNoOrganization BooleanTrue? emailOutboxes EmailOutbox[] - sessionRecordings SessionRecording[] - sessionRecordingChunks SessionRecordingChunk[] + sessionReplays SessionReplay[] + sessionReplayChunks SessionReplayChunk[] @@unique([projectId, branchId, organizationId]) @@unique([projectId, branchId, hasNoOrganization]) @@ -236,7 +236,7 @@ model ProjectUser { Project Project? @relation(fields: [projectId], references: [id]) projectId String? userNotificationPreference UserNotificationPreference[] - sessionRecordings SessionRecording[] + sessionReplays SessionReplay[] @@id([tenancyId, projectUserId]) @@unique([mirroredProjectId, mirroredBranchId, projectUserId]) @@ -280,7 +280,7 @@ model ProjectUserOAuthAccount { @@index([tenancyId, projectUserId]) } -model SessionRecording { +model SessionReplay { id String @db.Uuid tenancyId String @db.Uuid @@ -296,20 +296,21 @@ model SessionRecording { projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) - chunks SessionRecordingChunk[] + chunks SessionReplayChunk[] @@id([tenancyId, id]) + @@map("SessionReplay") @@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 { +model SessionReplayChunk { id String @id @default(uuid()) @db.Uuid - tenancyId String @db.Uuid - sessionRecordingId String @db.Uuid + tenancyId String @db.Uuid + sessionReplayId String @db.Uuid @map("sessionReplayId") // Unique per uploaded batch for a given session id. batchId String @db.Uuid @@ -329,11 +330,12 @@ model SessionRecordingChunk { createdAt DateTime @default(now()) - sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade) - tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + sessionReplay SessionReplay @relation(fields: [tenancyId, sessionReplayId], references: [tenancyId, id], onDelete: Cascade) + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) - @@unique([tenancyId, sessionRecordingId, batchId]) - @@index([tenancyId, sessionRecordingId, createdAt]) + @@unique([tenancyId, sessionReplayId, batchId]) + @@map("SessionReplayChunk") + @@index([tenancyId, sessionReplayId, createdAt]) } enum ContactChannelType { diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 301bf21944..93df1dd7b1 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1118,11 +1118,11 @@ async function seedDummyProject(options: DummyProjectSeedOptions) { userEmailToId, }); - await seedDummySessionRecordings({ + await seedDummySessionReplays({ prisma: dummyPrisma, tenancyId: dummyTenancy.id, userEmailToId, - targetSessionRecordingCount: 75 + targetSessionReplayCount: 75 }); console.log('Seeded dummy project data'); @@ -1773,43 +1773,43 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO console.log('Finished seeding session activity events'); } -type SessionRecordingSeedOptions = { +type SessionReplaySeedOptions = { prisma: PrismaClientTransaction, tenancyId: string, userEmailToId: Map, - targetSessionRecordingCount?: number, + targetSessionReplayCount?: number, }; -async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) { +async function seedDummySessionReplays(options: SessionReplaySeedOptions) { const { prisma, tenancyId, userEmailToId, - targetSessionRecordingCount = 250, + targetSessionReplayCount = 250, } = options; - const existingCount = await prisma.sessionRecording.count({ + const existingCount = await prisma.sessionReplay.count({ where: { tenancyId, }, }); - if (existingCount >= targetSessionRecordingCount) { - console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`); + if (existingCount >= targetSessionReplayCount) { + console.log(`Dummy project already has ${existingCount} session replays, skipping seeding`); return; } - const toCreate = targetSessionRecordingCount - existingCount; + const toCreate = targetSessionReplayCount - existingCount; const userIds = Array.from(userEmailToId.values()); if (userIds.length === 0) { - throw new Error('Cannot seed session recordings: no dummy project users exist'); + throw new Error('Cannot seed session replays: no dummy project users exist'); } const now = new Date(); const twoWeeksAgo = new Date(now); twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); - const seeds: Prisma.SessionRecordingCreateManyInput[] = []; + const seeds: Prisma.SessionReplayCreateManyInput[] = []; for (let i = 0; i < toCreate; i++) { const startedAt = new Date( twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()), @@ -1828,9 +1828,9 @@ async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) }); } - await prisma.sessionRecording.createMany({ + await prisma.sessionReplay.createMany({ data: seeds, }); - console.log(`Seeded ${toCreate} session recordings`); + console.log(`Seeded ${toCreate} session replays`); } 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-replays/[session_replay_id]/chunks/[chunk_id]/events/route.tsx similarity index 77% rename from apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx rename to apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/[chunk_id]/events/route.tsx index d8d2aaf479..3b47cd4cb2 100644 --- a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/[chunk_id]/events/route.tsx @@ -17,7 +17,7 @@ export const GET = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), params: yupObject({ - session_recording_id: yupString().defined(), + session_replay_id: yupString().defined(), chunk_id: yupString().defined(), }).defined(), }), @@ -31,13 +31,13 @@ export const GET = createSmartRouteHandler({ async handler({ auth, params }) { const prisma = await getPrismaClientForTenancy(auth.tenancy); - const sessionRecordingId = params.session_recording_id; + const sessionReplayId = params.session_replay_id; const chunkId = params.chunk_id; - const chunk = await prisma.sessionRecordingChunk.findFirst({ + const chunk = await prisma.sessionReplayChunk.findFirst({ where: { tenancyId: auth.tenancy.id, - sessionRecordingId, + sessionReplayId, id: chunkId, }, select: { @@ -64,20 +64,20 @@ export const GET = createSmartRouteHandler({ try { parsed = JSON.parse(new TextDecoder().decode(unzipped)); } catch (e) { - throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e }); + throw new StackAssertionError("Failed to decode session replay chunk JSON", { cause: e }); } if (typeof parsed !== "object" || parsed === null) { - throw new StackAssertionError("Decoded session recording chunk is not an object"); + throw new StackAssertionError("Decoded session replay 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 (parsed.session_replay_id !== sessionReplayId) { + throw new StackAssertionError("Decoded session replay chunk session_replay_id mismatch", { + expected: sessionReplayId, + actual: parsed.session_replay_id, }); } if (!Array.isArray(parsed.events)) { - throw new StackAssertionError("Decoded session recording chunk events is not an array"); + throw new StackAssertionError("Decoded session replay chunk events is not an array"); } return { 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-replays/[session_replay_id]/chunks/route.tsx similarity index 87% rename from apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx rename to apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/route.tsx index 2e4c72b78f..39e85cf8ab 100644 --- a/apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/route.tsx @@ -15,7 +15,7 @@ export const GET = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), params: yupObject({ - session_recording_id: yupString().defined(), + session_replay_id: yupString().defined(), }).defined(), query: yupObject({ cursor: yupString().optional(), @@ -45,13 +45,13 @@ export const GET = createSmartRouteHandler({ 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 } }, + const sessionReplayId = params.session_replay_id; + const exists = await prisma.sessionReplay.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionReplayId } }, select: { id: true }, }); if (!exists) { - throw new KnownErrors.ItemNotFound(sessionRecordingId); + throw new KnownErrors.ItemNotFound(sessionReplayId); } const rawLimit = query.limit ?? String(DEFAULT_LIMIT); @@ -61,10 +61,10 @@ export const GET = createSmartRouteHandler({ const cursorId = query.cursor; let cursorPivot: { firstEventAt: Date } | null = null; if (cursorId) { - cursorPivot = await prisma.sessionRecordingChunk.findFirst({ + cursorPivot = await prisma.sessionReplayChunk.findFirst({ where: { tenancyId: auth.tenancy.id, - sessionRecordingId, + sessionReplayId, id: cursorId, }, select: { firstEventAt: true }, @@ -74,17 +74,17 @@ export const GET = createSmartRouteHandler({ } } - const cursorWhere: Prisma.SessionRecordingChunkWhereInput = cursorId && cursorPivot ? { + const cursorWhere: Prisma.SessionReplayChunkWhereInput = cursorId && cursorPivot ? { OR: [ { firstEventAt: { gt: cursorPivot.firstEventAt } }, { AND: [{ firstEventAt: { equals: cursorPivot.firstEventAt } }, { id: { gt: cursorId } }] }, ], } : {}; - const chunks = await prisma.sessionRecordingChunk.findMany({ + const chunks = await prisma.sessionReplayChunk.findMany({ where: { tenancyId: auth.tenancy.id, - sessionRecordingId, + sessionReplayId, ...cursorWhere, }, orderBy: [{ firstEventAt: "asc" }, { id: "asc" }], 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-replays/[session_replay_id]/events/route.tsx similarity index 85% rename from apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/events/route.tsx rename to apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/events/route.tsx index 16ac617047..3d38cb6462 100644 --- 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-replays/[session_replay_id]/events/route.tsx @@ -19,7 +19,7 @@ export const GET = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), params: yupObject({ - session_recording_id: yupString().defined(), + session_replay_id: yupString().defined(), }).defined(), query: yupObject({ offset: yupString().optional(), @@ -49,19 +49,19 @@ export const GET = createSmartRouteHandler({ 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 } }, + const sessionReplayId = params.session_replay_id; + const exists = await prisma.sessionReplay.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionReplayId } }, select: { id: true }, }); if (!exists) { - throw new KnownErrors.ItemNotFound(sessionRecordingId); + throw new KnownErrors.ItemNotFound(sessionReplayId); } - const chunks = await prisma.sessionRecordingChunk.findMany({ + const chunks = await prisma.sessionReplayChunk.findMany({ where: { tenancyId: auth.tenancy.id, - sessionRecordingId, + sessionReplayId, }, orderBy: [{ firstEventAt: "asc" }, { id: "asc" }], select: { @@ -110,20 +110,20 @@ export const GET = createSmartRouteHandler({ try { parsed = JSON.parse(new TextDecoder().decode(unzipped)); } catch (e) { - throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e }); + throw new StackAssertionError("Failed to decode session replay chunk JSON", { cause: e }); } if (typeof parsed !== "object" || parsed === null) { - throw new StackAssertionError("Decoded session recording chunk is not an object"); + throw new StackAssertionError("Decoded session replay 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 (parsed.session_replay_id !== sessionReplayId) { + throw new StackAssertionError("Decoded session replay chunk session_replay_id mismatch", { + expected: sessionReplayId, + actual: parsed.session_replay_id, }); } if (!Array.isArray(parsed.events)) { - throw new StackAssertionError("Decoded session recording chunk events is not an array"); + throw new StackAssertionError("Decoded session replay chunk events is not an array"); } chunkEvents[idx] = { chunk_id: chunk.id, events: parsed.events as any[] }; diff --git a/apps/backend/src/app/api/latest/internal/session-recordings/route.tsx b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx similarity index 89% rename from apps/backend/src/app/api/latest/internal/session-recordings/route.tsx rename to apps/backend/src/app/api/latest/internal/session-replays/route.tsx index 55d41c696b..45f45290b6 100644 --- a/apps/backend/src/app/api/latest/internal/session-recordings/route.tsx +++ b/apps/backend/src/app/api/latest/internal/session-replays/route.tsx @@ -50,7 +50,7 @@ export const GET = createSmartRouteHandler({ const cursorId = query.cursor; let cursorPivot: { lastEventAt: Date } | null = null; if (cursorId) { - cursorPivot = await prisma.sessionRecording.findUnique({ + cursorPivot = await prisma.sessionReplay.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, select: { lastEventAt: true }, }); @@ -59,14 +59,14 @@ export const GET = createSmartRouteHandler({ } } - const where: Prisma.SessionRecordingWhereInput = cursorId && cursorPivot ? { + const where: Prisma.SessionReplayWhereInput = cursorId && cursorPivot ? { OR: [ { lastEventAt: { lt: cursorPivot.lastEventAt } }, { AND: [{ lastEventAt: { equals: cursorPivot.lastEventAt } }, { id: { lt: cursorId } }] }, ], } : {}; - const sessions = await prisma.sessionRecording.findMany({ + const sessions = await prisma.sessionReplay.findMany({ where: { tenancyId: auth.tenancy.id, ...where }, orderBy: [{ lastEventAt: "desc" }, { id: "desc" }], take: limit + 1, @@ -86,12 +86,12 @@ export const GET = createSmartRouteHandler({ 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 } }, + sessionIds.length ? prisma.sessionReplayChunk.groupBy({ + by: ["sessionReplayId"], + where: { tenancyId: auth.tenancy.id, sessionReplayId: { in: sessionIds } }, _count: { _all: true }, _sum: { eventCount: true }, - }) : Promise.resolve([] as Array<{ sessionRecordingId: string, _count: { _all: number }, _sum: { eventCount: number | null } }>), + }) : Promise.resolve([] as Array<{ sessionReplayId: string, _count: { _all: number }, _sum: { eventCount: number | null } }>), userIds.length ? prisma.projectUser.findMany({ where: { tenancyId: auth.tenancy.id, projectUserId: { in: userIds } }, select: { @@ -108,7 +108,7 @@ export const GET = createSmartRouteHandler({ const aggBySessionId = new Map(); for (const a of chunkAggs) { - aggBySessionId.set(a.sessionRecordingId, { + aggBySessionId.set(a.sessionReplayId, { chunkCount: a._count._all, eventCount: a._sum.eventCount ?? 0, }); diff --git a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx similarity index 84% rename from apps/backend/src/app/api/latest/session-recordings/batch/route.tsx rename to apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 21a509bfc4..3ec4c6d0cf 100644 --- a/apps/backend/src/app/api/latest/session-recordings/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -39,9 +39,9 @@ function extractEventTimesMs(events: unknown[], fallbackMs: number) { 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"], + summary: "Upload rrweb session replay batch", + description: "Uploads a batch of rrweb events for a cross-tab session replay.", + tags: ["Session Replays"], hidden: true }, request: yupObject({ @@ -64,7 +64,7 @@ export const POST = createSmartRouteHandler({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ - session_recording_id: yupString().defined(), + session_replay_id: yupString().defined(), batch_id: yupString().defined(), s3_key: yupString().defined(), deduped: yupMixed().defined(), @@ -76,7 +76,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_recording_id: "", + session_replay_id: "", batch_id: body.batch_id, s3_key: "", deduped: false, @@ -87,7 +87,7 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.UserAuthenticationRequired(); } if (!auth.refreshTokenId) { - throw new StatusError(StatusError.BadRequest, "A refresh token is required for session recordings"); + throw new StatusError(StatusError.BadRequest, "A refresh token is required for session replays"); } const projectUserId = auth.user.id; const refreshTokenId = auth.refreshTokenId; @@ -115,12 +115,12 @@ export const POST = createSmartRouteHandler({ 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. + // Find a recent session replay for this refresh token (temporal grouping). + // If the last batch arrived within SESSION_IDLE_TIMEOUT_MS, reuse that replay. + // Also enforce a max session duration so replays don't grow indefinitely. const cutoff = new Date(Date.now() - SESSION_IDLE_TIMEOUT_MS); const maxDurationCutoff = new Date(Date.now() - MAX_SESSION_DURATION_MS); - const recentSession = await prisma.sessionRecording.findFirst({ + const recentSession = await prisma.sessionReplay.findFirst({ where: { tenancyId, refreshTokenId, @@ -131,15 +131,15 @@ export const POST = createSmartRouteHandler({ select: { id: true, startedAt: true, lastEventAt: true }, }); - const recordingId = recentSession?.id ?? randomUUID(); - const s3Key = `session-recordings/${projectId}/${branchId}/${recordingId}/${batchId}.json.gz`; + const replayId = recentSession?.id ?? randomUUID(); + const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${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 } }, + await prisma.sessionReplay.upsert({ + where: { tenancyId_id: { tenancyId, id: replayId } }, create: { - id: recordingId, + id: replayId, tenancyId, projectUserId, refreshTokenId, @@ -153,8 +153,8 @@ export const POST = createSmartRouteHandler({ }); // If we already have this batch for this session, return deduped without touching S3. - const existingChunk = await prisma.sessionRecordingChunk.findUnique({ - where: { tenancyId_sessionRecordingId_batchId: { tenancyId, sessionRecordingId: recordingId, batchId } }, + const existingChunk = await prisma.sessionReplayChunk.findUnique({ + where: { tenancyId_sessionReplayId_batchId: { tenancyId, sessionReplayId: replayId, batchId } }, select: { s3Key: true }, }); if (existingChunk) { @@ -162,7 +162,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_recording_id: recordingId, + session_replay_id: replayId, batch_id: batchId, s3_key: existingChunk.s3Key, deduped: true, @@ -172,7 +172,7 @@ export const POST = createSmartRouteHandler({ const payload = { v: 1, - session_recording_id: recordingId, + session_replay_id: replayId, browser_session_id: browserSessionId, session_replay_segment_id: sessionReplaySegmentId, batch_id: batchId, @@ -192,10 +192,10 @@ export const POST = createSmartRouteHandler({ }); try { - await prisma.sessionRecordingChunk.create({ + await prisma.sessionReplayChunk.create({ data: { tenancyId, - sessionRecordingId: recordingId, + sessionReplayId: replayId, batchId, sessionReplaySegmentId, browserSessionId, @@ -212,7 +212,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_recording_id: recordingId, + session_replay_id: replayId, batch_id: batchId, s3_key: s3Key, deduped: true, @@ -226,7 +226,7 @@ export const POST = createSmartRouteHandler({ statusCode: 200, bodyType: "json", body: { - session_recording_id: recordingId, + session_replay_id: replayId, batch_id: batchId, s3_key: s3Key, deduped: false, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index f5a30c473e..e0a4a3b771 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -68,12 +68,12 @@ type ChunkRow = { createdAt: Date, }; -type AdminAppWithSessionRecordings = ReturnType & { - listSessionRecordings: (options?: { limit?: number, cursor?: string }) => Promise<{ +type AdminAppWithSessionReplays = ReturnType & { + listSessionReplays: (options?: { limit?: number, cursor?: string }) => Promise<{ items: RecordingRow[], nextCursor: string | null, }>, - getSessionRecordingEvents: (sessionRecordingId: string, options?: { offset?: number, limit?: number }) => Promise<{ + getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise<{ chunks: ChunkRow[], chunkEvents: Array<{ chunkId: string, events: unknown[] }>, }>, @@ -337,7 +337,7 @@ function useReplayMachine(initialSettings: ReplaySettings) { // --------------------------------------------------------------------------- export default function PageClient() { - const adminApp = useAdminApp() as AdminAppWithSessionRecordings; + const adminApp = useAdminApp() as AdminAppWithSessionReplays; // ---- Recording list state (unchanged from original) ---- @@ -371,7 +371,7 @@ export default function PageClient() { setListError(null); try { - const res = await adminApp.listSessionRecordings({ limit: PAGE_SIZE, cursor: cursor ?? undefined }); + const res = await adminApp.listSessionReplays({ limit: PAGE_SIZE, cursor: cursor ?? undefined }); const items = cursor ? [...recordings, ...res.items] : res.items; setRecordings(items); setNextCursor(res.nextCursor); @@ -854,7 +854,7 @@ export default function PageClient() { try { // Phase 1: Fetch initial batch (fast start). - const initialResponse = await adminApp.getSessionRecordingEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); + const initialResponse = await adminApp.getSessionReplayEvents(recordingId, { offset: 0, limit: INITIAL_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; const allChunkRows: ChunkRow[] = initialResponse.chunks.map((c) => ({ @@ -942,7 +942,7 @@ export default function PageClient() { while (offset < totalChunks) { if (msRef.current.generation !== gen) return; - const batchResponse = await adminApp.getSessionRecordingEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); + const batchResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts similarity index 84% rename from apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts rename to apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index ca7c7e1f6a..a01ce8312b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-recordings.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -10,7 +10,7 @@ async function uploadBatch(options: { events: unknown[], sessionReplaySegmentId?: string, }) { - return await niceBackendFetch("/api/v1/session-recordings/batch", { + return await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -29,7 +29,7 @@ it("requires a user token", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); backendContext.set({ userAuth: null }); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -51,7 +51,7 @@ it("returns 200 no-op when analytics is not enabled", async ({ expect }) => { // Analytics is disabled by default - do NOT call Project.updateConfig await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -65,11 +65,11 @@ it("returns 200 no-op when analytics is not enabled", async ({ expect }) => { }); expect(res.status).toBe(200); - expect(res.body?.session_recording_id).toBe(""); + expect(res.body?.session_replay_id).toBe(""); expect(res.body?.s3_key).toBe(""); }); -it("stores session recording batch metadata and dedupes by (session_recording_id, batch_id)", async ({ expect }) => { +it("stores session replay batch metadata and dedupes by (session_replay_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(); @@ -79,7 +79,7 @@ it("stores session recording batch metadata and dedupes by (session_recording_id const batchId = randomUUID(); const sessionReplaySegmentId = randomUUID(); - const first = await niceBackendFetch("/api/v1/session-recordings/batch", { + const first = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -96,16 +96,16 @@ it("stores session recording batch metadata and dedupes by (session_recording_id }); expect(first.status).toBe(200); - expect(typeof first.body?.session_recording_id).toBe("string"); + expect(typeof first.body?.session_replay_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 recordingId = first.body?.session_replay_id; - const second = await niceBackendFetch("/api/v1/session-recordings/batch", { + const second = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -120,7 +120,7 @@ it("stores session recording batch metadata and dedupes by (session_recording_id expect(second.status).toBe(200); expect(second.body).toMatchObject({ - session_recording_id: recordingId, + session_replay_id: recordingId, batch_id: batchId, deduped: true, }); @@ -131,7 +131,7 @@ it("rejects empty events", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -155,7 +155,7 @@ it("rejects too many events", async ({ expect }) => { const tooManyEvents = Array.from({ length: 5001 }, (_, i) => ({ timestamp: 1_700_000_000_000 + i })); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -177,7 +177,7 @@ it("rejects invalid browser_session_id", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -199,7 +199,7 @@ it("rejects invalid batch_id", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -221,7 +221,7 @@ it("rejects invalid session_replay_segment_id", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -246,7 +246,7 @@ it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expe const browserSessionId = randomUUID(); const batchId = randomUUID(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -260,7 +260,7 @@ it("accepts events without timestamps (falls back to sent_at_ms)", async ({ expe }); expect(res.status).toBe(200); - expect(typeof res.body?.session_recording_id).toBe("string"); + expect(typeof res.body?.session_replay_id).toBe("string"); expect(res.body).toMatchObject({ batch_id: batchId, deduped: false, @@ -272,7 +272,7 @@ it("rejects non-integer started_at_ms", async ({ expect }) => { await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); - const res = await niceBackendFetch("/api/v1/session-recordings/batch", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -297,7 +297,7 @@ it("rejects oversized payloads", async ({ expect }) => { // 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", { + const res = await niceBackendFetch("/api/v1/session-replays/batch", { method: "POST", accessType: "client", body: { @@ -313,7 +313,7 @@ it("rejects oversized payloads", async ({ expect }) => { expect(res.status).toBe(413); }); -it("admin can list session recordings, list chunks, and fetch events", async ({ expect }) => { +it("admin can list session replays, 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(); @@ -333,17 +333,17 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ events, }); expect(uploadRes.status).toBe(200); - const recordingId = uploadRes.body?.session_recording_id; + const recordingId = uploadRes.body?.session_replay_id; expect(typeof recordingId).toBe("string"); - const listRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + const listRes = await niceBackendFetch("/api/v1/internal/session-replays", { 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`, { + const chunksRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}/chunks`, { method: "GET", accessType: "admin", }); @@ -351,10 +351,10 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ 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."); + throw new Error("Expected session replay chunks response to include an item id."); } - const eventsRes = await niceBackendFetch(`/api/v1/internal/session-recordings/${recordingId}/chunks/${chunkId}/events`, { + const eventsRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}/chunks/${chunkId}/events`, { method: "GET", accessType: "admin", }); @@ -362,11 +362,11 @@ it("admin can list session recordings, list chunks, and fetch events", async ({ expect(eventsRes.body?.events?.length).toBe(events.length); }); -it("admin list session recordings paginates without skipping items", async ({ expect }) => { +it("admin list session replays 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. + // Use separate sign-ins to get different refresh tokens → different session replays. await Auth.Otp.signIn(); const uploadA = await uploadBatch({ browserSessionId: randomUUID(), @@ -376,7 +376,7 @@ it("admin list session recordings paginates without skipping items", async ({ ex events: [{ type: 1, timestamp: 1_700_000_000_100 }], }); expect(uploadA.status).toBe(200); - const recordingA = uploadA.body?.session_recording_id; + const recordingA = uploadA.body?.session_replay_id; await Auth.Otp.signIn(); const uploadB = await uploadBatch({ @@ -387,9 +387,9 @@ it("admin list session recordings paginates without skipping items", async ({ ex events: [{ type: 1, timestamp: 1_700_000_000_200 }], }); expect(uploadB.status).toBe(200); - const recordingB = uploadB.body?.session_recording_id; + const recordingB = uploadB.body?.session_replay_id; - const first = await niceBackendFetch("/api/v1/internal/session-recordings?limit=1", { + const first = await niceBackendFetch("/api/v1/internal/session-replays?limit=1", { method: "GET", accessType: "admin", }); @@ -404,7 +404,7 @@ it("admin list session recordings paginates without skipping items", async ({ ex throw new Error("Expected next_cursor to be a string."); } - const second = await niceBackendFetch(`/api/v1/internal/session-recordings?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { + const second = await niceBackendFetch(`/api/v1/internal/session-replays?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { method: "GET", accessType: "admin", }); @@ -415,12 +415,12 @@ it("admin list session recordings paginates without skipping items", async ({ ex expect(secondId).not.toBe(firstId); }); -it("admin list session recordings rejects unknown cursor", async ({ expect }) => { +it("admin list session replays 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)}`, { + const res = await niceBackendFetch(`/api/v1/internal/session-replays?cursor=${encodeURIComponent(cursor)}`, { method: "GET", accessType: "admin", }); @@ -445,7 +445,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn events: [{ type: 1, timestamp: now + 10 }], }); expect(upload1a.status).toBe(200); - const recording1 = upload1a.body?.session_recording_id; + const recording1 = upload1a.body?.session_replay_id; await uploadBatch({ browserSessionId: randomUUID(), @@ -465,9 +465,9 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn events: [{ type: 1, timestamp: now + 30 }], }); expect(upload2.status).toBe(200); - const recording2 = upload2.body?.session_recording_id; + const recording2 = upload2.body?.session_replay_id; - const first = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?limit=1`, { + const first = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks?limit=1`, { method: "GET", accessType: "admin", }); @@ -480,7 +480,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn throw new Error("Expected next_cursor to be a string."); } - const second = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { + const second = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks?limit=1&cursor=${encodeURIComponent(nextCursor)}`, { method: "GET", accessType: "admin", }); @@ -489,7 +489,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn expect(second.body?.items?.[0]?.id).not.toBe(first.body?.items?.[0]?.id); // Cursor from another session should be rejected. - const otherChunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording2}/chunks?limit=1`, { + const otherChunks = await niceBackendFetch(`/api/v1/internal/session-replays/${recording2}/chunks?limit=1`, { method: "GET", accessType: "admin", }); @@ -500,7 +500,7 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn throw new Error("Expected otherCursor to be a string."); } - const bad = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks?cursor=${encodeURIComponent(otherCursor)}`, { + const bad = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks?cursor=${encodeURIComponent(otherCursor)}`, { method: "GET", accessType: "admin", }); @@ -523,7 +523,7 @@ it("admin events endpoint does not allow fetching a chunk via the wrong session events: [{ type: 1, timestamp: 1_700_000_000_010 }], }); expect(upload1.status).toBe(200); - const recording1 = upload1.body?.session_recording_id; + const recording1 = upload1.body?.session_replay_id; // session2: upload under a different refresh token await Auth.Otp.signIn(); @@ -535,9 +535,9 @@ it("admin events endpoint does not allow fetching a chunk via the wrong session events: [{ type: 1, timestamp: 1_700_000_000_020 }], }); expect(upload2.status).toBe(200); - const recording2 = upload2.body?.session_recording_id; + const recording2 = upload2.body?.session_replay_id; - const chunks = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording1}/chunks`, { + const chunks = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks`, { method: "GET", accessType: "admin", }); @@ -548,7 +548,7 @@ it("admin events endpoint does not allow fetching a chunk via the wrong session throw new Error("Expected chunk id."); } - const wrong = await niceBackendFetch(`/api/v1/internal/session-recordings/${recording2}/chunks/${chunkId}/events`, { + const wrong = await niceBackendFetch(`/api/v1/internal/session-replays/${recording2}/chunks/${chunkId}/events`, { method: "GET", accessType: "admin", }); @@ -556,18 +556,18 @@ it("admin events endpoint does not allow fetching a chunk via the wrong session expect(wrong.body?.code).toBe("ITEM_NOT_FOUND"); }); -it("non-admin access cannot call internal session recordings endpoints", async ({ expect }) => { +it("non-admin access cannot call internal session replays endpoints", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Auth.Otp.signIn(); - const clientRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + const clientRes = await niceBackendFetch("/api/v1/internal/session-replays", { method: "GET", accessType: "client", }); expect(clientRes.status).toBeGreaterThanOrEqual(400); expect(clientRes.status).toBeLessThan(500); - const serverRes = await niceBackendFetch("/api/v1/internal/session-recordings", { + const serverRes = await niceBackendFetch("/api/v1/internal/session-replays", { method: "GET", accessType: "server", }); @@ -575,7 +575,7 @@ it("non-admin access cannot call internal session recordings endpoints", async ( expect(serverRes.status).toBeLessThan(500); }); -it("groups batches from same refresh token into one session recording", async ({ expect }) => { +it("groups batches from same refresh token into one session replay", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); await Auth.Otp.signIn(); @@ -601,6 +601,6 @@ it("groups batches from same refresh token into one session recording", async ({ }); 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); + // Same refresh token within idle timeout → same session replay + expect(upload1.body?.session_replay_id).toBe(upload2.body?.session_replay_id); }); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index f08895923a..0c10351d31 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -12,13 +12,13 @@ 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"; + AdminGetSessionReplayAllEventsResponse, + AdminGetSessionReplayChunkEventsResponse, + AdminListSessionReplayChunksOptions, + AdminListSessionReplayChunksResponse, + AdminListSessionReplaysOptions, + AdminListSessionReplaysResponse +} from "./crud/session-replays"; import { SvixTokenCrud } from "./crud/svix-token"; import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions"; import type { Transaction, TransactionType } from "./crud/transactions"; @@ -783,45 +783,45 @@ export class StackAdminInterface extends StackServerInterface { return { transactions: json.transactions, nextCursor: json.next_cursor }; } - async listSessionRecordings(params?: AdminListSessionRecordingsOptions): Promise { + async listSessionReplays(params?: AdminListSessionReplaysOptions): 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()}` : ""}`, + `/internal/session-replays${qs.size ? `?${qs.toString()}` : ""}`, { method: "GET" }, null, ); return await response.json(); } - async listSessionRecordingChunks(sessionRecordingId: string, params?: AdminListSessionRecordingChunksOptions): Promise { + async listSessionReplayChunks(sessionReplayId: string, params?: AdminListSessionReplayChunksOptions): 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()}` : ""}`, + `/internal/session-replays/${encodeURIComponent(sessionReplayId)}/chunks${qs.size ? `?${qs.toString()}` : ""}`, { method: "GET" }, null, ); return await response.json(); } - async getSessionRecordingChunkEvents(sessionRecordingId: string, chunkId: string): Promise { + async getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise { const response = await this.sendAdminRequest( - `/internal/session-recordings/${encodeURIComponent(sessionRecordingId)}/chunks/${encodeURIComponent(chunkId)}/events`, + `/internal/session-replays/${encodeURIComponent(sessionReplayId)}/chunks/${encodeURIComponent(chunkId)}/events`, { method: "GET" }, null, ); return await response.json(); } - async getSessionRecordingEvents(sessionRecordingId: string, options?: { offset?: number, limit?: number }): Promise { + async getSessionReplayEvents(sessionReplayId: 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()}` : ""}`, + `/internal/session-replays/${encodeURIComponent(sessionReplayId)}/events${qs.size ? `?${qs.toString()}` : ""}`, { method: "GET" }, null, ); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 319824ad98..23419b4fcf 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -244,14 +244,14 @@ export class StackClientInterface { return session; } - async sendSessionRecordingBatch( + async sendSessionReplayBatch( body: string, session: InternalSession | null, options: { keepalive: boolean }, ): Promise> { try { const response = await this.sendClientRequest( - "/session-recordings/batch", + "/session-replays/batch", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/packages/stack-shared/src/interface/crud/session-recordings.ts b/packages/stack-shared/src/interface/crud/session-replays.ts similarity index 77% rename from packages/stack-shared/src/interface/crud/session-recordings.ts rename to packages/stack-shared/src/interface/crud/session-replays.ts index 138e0ad94d..4c7faee297 100644 --- a/packages/stack-shared/src/interface/crud/session-recordings.ts +++ b/packages/stack-shared/src/interface/crud/session-replays.ts @@ -1,9 +1,9 @@ -export type AdminListSessionRecordingsOptions = { +export type AdminListSessionReplaysOptions = { limit?: number, cursor?: string, }; -export type AdminListSessionRecordingsResponse = { +export type AdminListSessionReplaysResponse = { items: Array<{ id: string, project_user: { @@ -21,12 +21,12 @@ export type AdminListSessionRecordingsResponse = { }, }; -export type AdminListSessionRecordingChunksOptions = { +export type AdminListSessionReplayChunksOptions = { limit?: number, cursor?: string, }; -export type AdminListSessionRecordingChunksResponse = { +export type AdminListSessionReplayChunksResponse = { items: Array<{ id: string, batch_id: string, @@ -43,11 +43,11 @@ export type AdminListSessionRecordingChunksResponse = { }, }; -export type AdminGetSessionRecordingChunkEventsResponse = { +export type AdminGetSessionReplayChunkEventsResponse = { events: unknown[], }; -export type AdminGetSessionRecordingAllEventsResponse = { +export type AdminGetSessionReplayAllEventsResponse = { chunks: Array<{ id: string, batch_id: string, @@ -63,4 +63,3 @@ export type AdminGetSessionRecordingAllEventsResponse = { events: unknown[], }>, }; - diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index a64b0cfc96..f97202d24a 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -2,7 +2,7 @@ export * from './lib/stack-app'; export { getConvexProvidersConfig } from "./integrations/convex"; // IF_PLATFORM react-like -export type { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/stack-app/apps/implementations/session-recording"; +export type { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/stack-app/apps/implementations/session-replay"; 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 9fd48d44b3..389f53db74 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -2,7 +2,7 @@ import { StackAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; -import type { AdminGetSessionRecordingAllEventsResponse, AdminGetSessionRecordingChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; +import type { AdminGetSessionReplayAllEventsResponse, AdminGetSessionReplayChunkEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays"; import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -19,7 +19,7 @@ import { AdminEmailTemplate } from "../../email-templates"; import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; -import type { AdminSessionRecording, AdminSessionRecordingChunk, ListSessionRecordingChunksOptions, ListSessionRecordingChunksResult, ListSessionRecordingsOptions, ListSessionRecordingsResult, SessionRecordingAllEventsResult } from "../../session-recordings"; +import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; 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"; @@ -1042,13 +1042,13 @@ export class _StackAdminAppImplIncomplete { - const response = await this._interface.listSessionRecordings({ + async listSessionReplays(options?: ListSessionReplaysOptions): Promise { + const response = await this._interface.listSessionReplays({ cursor: options?.cursor, limit: options?.limit, }); - const items: AdminSessionRecording[] = response.items.map((r) => ({ + const items: AdminSessionReplay[] = response.items.map((r) => ({ id: r.id, projectUser: { id: r.project_user.id, @@ -1067,13 +1067,13 @@ export class _StackAdminAppImplIncomplete { - const response = await this._interface.listSessionRecordingChunks(sessionRecordingId, { + async listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise { + const response = await this._interface.listSessionReplayChunks(sessionReplayId, { cursor: options?.cursor, limit: options?.limit, }); - const items: AdminSessionRecordingChunk[] = response.items.map((c) => ({ + const items: AdminSessionReplayChunk[] = response.items.map((c) => ({ id: c.id, batchId: c.batch_id, sessionReplaySegmentId: c.session_replay_segment_id, @@ -1091,12 +1091,12 @@ export class _StackAdminAppImplIncomplete { - return await this._interface.getSessionRecordingChunkEvents(sessionRecordingId, chunkId); + async getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise { + return await this._interface.getSessionReplayChunkEvents(sessionReplayId, chunkId); } - async getSessionRecordingEvents(sessionRecordingId: string, options?: { offset?: number, limit?: number }): Promise { - const response = await this._interface.getSessionRecordingEvents(sessionRecordingId, options); + async getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise { + const response = await this._interface.getSessionReplayEvents(sessionReplayId, options); return { chunks: response.chunks.map((c) => ({ id: c.id, 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 0612286105..2c9d8e3b44 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,7 +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"; +import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay"; // IF_PLATFORM react-like import { useAsyncCache } from "./common"; @@ -446,7 +446,7 @@ export class _StackClientAppImplIncomplete { - return await this._interface.sendSessionRecordingBatch(body, await this._getSession(), opts); + return await this._interface.sendSessionReplayBatch(body, await this._getSession(), opts); }, }, this._analyticsOptions.replays); this._sessionRecorder.start(); @@ -2853,8 +2853,8 @@ export class _StackClientAppImplIncomplete this._options, - sendSessionRecordingBatch: async (body: string, options: { keepalive: boolean }) => { - return await this._interface.sendSessionRecordingBatch(body, await this._getSession(), options); + sendSessionReplayBatch: async (body: string, options: { keepalive: boolean }) => { + return await this._interface.sendSessionReplayBatch(body, await this._getSession(), options); }, sendRequest: async ( path: string, diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-recording.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts similarity index 99% rename from packages/template/src/lib/stack-app/apps/implementations/session-recording.ts rename to packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index 226df280bf..70bd146c16 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-recording.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -79,7 +79,7 @@ export function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): An // ---------- Recording internals ---------- -const LOCAL_STORAGE_PREFIX = "stack:session-recording:v1"; +const LOCAL_STORAGE_PREFIX = "stack:session-replay:v1"; const IDLE_TTL_MS = 3 * 60 * 1000; const FLUSH_INTERVAL_MS = 5_000; 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 ea3e0dc088..5e3c6db5f3 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,6 +1,6 @@ import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; -import type { AdminGetSessionRecordingChunkEventsResponse, AdminGetSessionRecordingAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-recordings"; +import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays"; 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"; @@ -32,8 +32,8 @@ 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"; +import type { ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; +export type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplaysOptions, ListSessionReplaysResult, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, SessionReplayAllEventsResult } from "../../session-replays"; export type StackAdminAppConstructorOptions = ( @@ -136,10 +136,10 @@ 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, + listSessionReplays(options?: ListSessionReplaysOptions): Promise, + listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise, + getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise, + getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise, // Email Outbox methods listOutboxEmails(options?: EmailOutboxListOptions): 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 4c6261483a..79006b412e 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,7 +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"; +import { AnalyticsOptions } from "../implementations/session-replay"; export type StackClientAppConstructorOptions = { baseUrl?: string | { browser: string, server: string }, @@ -100,7 +100,7 @@ export type StackClientApp, setCurrentUser(userJsonPromise: Promise): void, getConstructorOptions(): StackClientAppConstructorOptions & { inheritsFrom?: undefined }, - sendSessionRecordingBatch(body: string, options: { keepalive: boolean }): Promise>, + sendSessionReplayBatch(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-replays/index.ts similarity index 68% rename from packages/template/src/lib/stack-app/session-recordings/index.ts rename to packages/template/src/lib/stack-app/session-replays/index.ts index 71f7be6f0e..781cb86012 100644 --- a/packages/template/src/lib/stack-app/session-recordings/index.ts +++ b/packages/template/src/lib/stack-app/session-replays/index.ts @@ -1,4 +1,4 @@ -export type AdminSessionRecording = { +export type AdminSessionReplay = { id: string, projectUser: { id: string, @@ -11,7 +11,7 @@ export type AdminSessionRecording = { eventCount: number, }; -export type AdminSessionRecordingChunk = { +export type AdminSessionReplayChunk = { id: string, batchId: string, sessionReplaySegmentId: string | null, @@ -23,27 +23,27 @@ export type AdminSessionRecordingChunk = { createdAt: Date, }; -export type ListSessionRecordingsOptions = { +export type ListSessionReplaysOptions = { limit?: number, cursor?: string, }; -export type ListSessionRecordingsResult = { - items: AdminSessionRecording[], +export type ListSessionReplaysResult = { + items: AdminSessionReplay[], nextCursor: string | null, }; -export type ListSessionRecordingChunksOptions = { +export type ListSessionReplayChunksOptions = { limit?: number, cursor?: string, }; -export type ListSessionRecordingChunksResult = { - items: AdminSessionRecordingChunk[], +export type ListSessionReplayChunksResult = { + items: AdminSessionReplayChunk[], nextCursor: string | null, }; -export type SessionRecordingAllEventsResult = { +export type SessionReplayAllEventsResult = { chunks: Array<{ id: string, batchId: string,