Skip to content

Commit c7ef526

Browse files
authored
session replays (#1187)
https://www.loom.com/share/3b7c9288149e4f878693281778c9d7e0 ## Todos (future PRs) - Fix pre-login recording - Better session search (filters, cmd-k, etc) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Analytics → Replays: session recording & multi-tab replay with timeline, speed, seek, and playback settings; dashboard UI for listing and viewing replays. * **Admin APIs** * Admin endpoints to list recordings, list chunks, fetch chunk events, and retrieve all events (paginated). * **Client** * Client-side rrweb recording with batching, deduplication, upload API and a send-batch client method. * **Configuration** * New STACK_S3_PRIVATE_BUCKET for private session storage. * **Tests** * Extensive unit and end-to-end tests for replay logic, streams, playback, and APIs. * **Chores** * Removed an E2E API test GitHub Actions workflow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fa360ab commit c7ef526

44 files changed

Lines changed: 7507 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

-820 Bytes
Loading

apps/backend/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ STACK_S3_REGION=
6767
STACK_S3_ACCESS_KEY_ID=
6868
STACK_S3_SECRET_ACCESS_KEY=
6969
STACK_S3_BUCKET=
70+
STACK_S3_PRIVATE_BUCKET=
7071

7172
# AWS configuration
7273
STACK_AWS_REGION=

apps/backend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ STACK_S3_REGION=us-east-1
7474
STACK_S3_ACCESS_KEY_ID=s3mockroot
7575
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
7676
STACK_S3_BUCKET=stack-storage
77+
STACK_S3_PRIVATE_BUCKET=stack-storage-private
7778

7879
# AWS region defaults to LocalStack
7980
STACK_AWS_REGION=us-east-1
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Session recording MVP: store session metadata in Postgres and rrweb events in S3.
2+
3+
CREATE TABLE "SessionRecording" (
4+
"id" UUID NOT NULL,
5+
"tenancyId" UUID NOT NULL,
6+
"projectUserId" UUID NOT NULL,
7+
"refreshTokenId" UUID NOT NULL,
8+
"startedAt" TIMESTAMP(3) NOT NULL,
9+
"lastEventAt" TIMESTAMP(3) NOT NULL,
10+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11+
"updatedAt" TIMESTAMP(3) NOT NULL,
12+
CONSTRAINT "SessionRecording_pkey" PRIMARY KEY ("tenancyId","id")
13+
);
14+
15+
CREATE TABLE "SessionRecordingChunk" (
16+
"id" UUID NOT NULL,
17+
"tenancyId" UUID NOT NULL,
18+
"sessionRecordingId" UUID NOT NULL,
19+
"batchId" UUID NOT NULL,
20+
"tabId" TEXT NOT NULL,
21+
"browserSessionId" TEXT NOT NULL,
22+
"s3Key" TEXT NOT NULL,
23+
"eventCount" INTEGER NOT NULL,
24+
"byteLength" INTEGER NOT NULL,
25+
"firstEventAt" TIMESTAMP(3) NOT NULL,
26+
"lastEventAt" TIMESTAMP(3) NOT NULL,
27+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
28+
CONSTRAINT "SessionRecordingChunk_pkey" PRIMARY KEY ("id")
29+
);
30+
31+
ALTER TABLE "SessionRecording"
32+
ADD CONSTRAINT "SessionRecording_tenancyId_fkey"
33+
FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
34+
35+
ALTER TABLE "SessionRecording"
36+
ADD CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey"
37+
FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;
38+
39+
ALTER TABLE "SessionRecordingChunk"
40+
ADD CONSTRAINT "SessionRecordingChunk_tenancyId_fkey"
41+
FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
42+
43+
ALTER TABLE "SessionRecordingChunk"
44+
ADD CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey"
45+
FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE;
46+
47+
CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx"
48+
ON "SessionRecording"("tenancyId", "projectUserId", "startedAt");
49+
50+
CREATE INDEX "SessionRecording_tenancyId_lastEventAt_idx"
51+
ON "SessionRecording"("tenancyId", "lastEventAt");
52+
53+
CREATE INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx"
54+
ON "SessionRecording"("tenancyId", "refreshTokenId", "updatedAt");
55+
56+
CREATE UNIQUE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key"
57+
ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "batchId");
58+
59+
CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx"
60+
ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt");

apps/backend/prisma/schema.prisma

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ model Tenancy {
6060
organizationId String? @db.Uuid
6161
hasNoOrganization BooleanTrue?
6262
emailOutboxes EmailOutbox[]
63+
sessionRecordings SessionRecording[]
64+
sessionRecordingChunks SessionRecordingChunk[]
6365
6466
@@unique([projectId, branchId, organizationId])
6567
@@unique([projectId, branchId, hasNoOrganization])
@@ -234,6 +236,7 @@ model ProjectUser {
234236
Project Project? @relation(fields: [projectId], references: [id])
235237
projectId String?
236238
userNotificationPreference UserNotificationPreference[]
239+
sessionRecordings SessionRecording[]
237240
238241
@@id([tenancyId, projectUserId])
239242
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
@@ -277,6 +280,62 @@ model ProjectUserOAuthAccount {
277280
@@index([tenancyId, projectUserId])
278281
}
279282

283+
model SessionRecording {
284+
id String @db.Uuid
285+
286+
tenancyId String @db.Uuid
287+
projectUserId String @db.Uuid
288+
refreshTokenId String @db.Uuid
289+
290+
startedAt DateTime
291+
lastEventAt DateTime
292+
293+
createdAt DateTime @default(now())
294+
updatedAt DateTime @updatedAt
295+
296+
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
297+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
298+
299+
chunks SessionRecordingChunk[]
300+
301+
@@id([tenancyId, id])
302+
@@index([tenancyId, projectUserId, startedAt])
303+
@@index([tenancyId, lastEventAt])
304+
// index by updatedAt instead of lastEventAt because event timing can be spoofed
305+
@@index([tenancyId, refreshTokenId, updatedAt])
306+
}
307+
308+
model SessionRecordingChunk {
309+
id String @id @default(uuid()) @db.Uuid
310+
311+
tenancyId String @db.Uuid
312+
sessionRecordingId String @db.Uuid
313+
314+
// Unique per uploaded batch for a given session id.
315+
batchId String @db.Uuid
316+
317+
// Ephemeral in-memory id generated by the client. Stored for future tab separation if needed.
318+
tabId String
319+
320+
// Client-generated session id from localStorage, stored as metadata.
321+
browserSessionId String
322+
323+
s3Key String
324+
eventCount Int
325+
byteLength Int
326+
327+
firstEventAt DateTime
328+
lastEventAt DateTime
329+
330+
createdAt DateTime @default(now())
331+
332+
sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade)
333+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
334+
335+
@@unique([tenancyId, sessionRecordingId, batchId])
336+
@@index([tenancyId, sessionRecordingId, createdAt])
337+
}
338+
280339
enum ContactChannelType {
281340
EMAIL
282341
// PHONE

apps/backend/prisma/seed.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,13 @@ async function seedDummyProject(options: DummyProjectSeedOptions) {
11181118
userEmailToId,
11191119
});
11201120

1121+
await seedDummySessionRecordings({
1122+
prisma: dummyPrisma,
1123+
tenancyId: dummyTenancy.id,
1124+
userEmailToId,
1125+
targetSessionRecordingCount: 75
1126+
});
1127+
11211128
console.log('Seeded dummy project data');
11221129
}
11231130

@@ -1765,3 +1772,65 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO
17651772

17661773
console.log('Finished seeding session activity events');
17671774
}
1775+
1776+
type SessionRecordingSeedOptions = {
1777+
prisma: PrismaClientTransaction,
1778+
tenancyId: string,
1779+
userEmailToId: Map<string, string>,
1780+
targetSessionRecordingCount?: number,
1781+
};
1782+
1783+
async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) {
1784+
const {
1785+
prisma,
1786+
tenancyId,
1787+
userEmailToId,
1788+
targetSessionRecordingCount = 250,
1789+
} = options;
1790+
1791+
const existingCount = await prisma.sessionRecording.count({
1792+
where: {
1793+
tenancyId,
1794+
},
1795+
});
1796+
1797+
if (existingCount >= targetSessionRecordingCount) {
1798+
console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`);
1799+
return;
1800+
}
1801+
1802+
const toCreate = targetSessionRecordingCount - existingCount;
1803+
const userIds = Array.from(userEmailToId.values());
1804+
if (userIds.length === 0) {
1805+
throw new Error('Cannot seed session recordings: no dummy project users exist');
1806+
}
1807+
1808+
const now = new Date();
1809+
const twoWeeksAgo = new Date(now);
1810+
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
1811+
1812+
const seeds: Prisma.SessionRecordingCreateManyInput[] = [];
1813+
for (let i = 0; i < toCreate; i++) {
1814+
const startedAt = new Date(
1815+
twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()),
1816+
);
1817+
const durationMs = 10_000 + Math.floor(Math.random() * (20 * 60 * 1000)); // 10s..20m
1818+
const lastEventAt = new Date(startedAt.getTime() + durationMs);
1819+
const projectUserId = userIds[Math.floor(Math.random() * userIds.length)]!;
1820+
1821+
seeds.push({
1822+
tenancyId,
1823+
refreshTokenId: generateUuid(),
1824+
projectUserId,
1825+
id: generateUuid(),
1826+
startedAt,
1827+
lastEventAt,
1828+
});
1829+
}
1830+
1831+
await prisma.sessionRecording.createMany({
1832+
data: seeds,
1833+
});
1834+
1835+
console.log(`Seeded ${toCreate} session recordings`);
1836+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { getPrismaClientForTenancy } from "@/prisma-client";
2+
import { downloadBytes } from "@/s3";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { KnownErrors } from "@stackframe/stack-shared";
5+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { promisify } from "node:util";
8+
import { gunzip as gunzipCb } from "node:zlib";
9+
10+
const gunzip = promisify(gunzipCb);
11+
12+
export const GET = createSmartRouteHandler({
13+
metadata: { hidden: true },
14+
request: yupObject({
15+
auth: yupObject({
16+
type: adminAuthTypeSchema.defined(),
17+
tenancy: adaptSchema.defined(),
18+
}).defined(),
19+
params: yupObject({
20+
session_recording_id: yupString().defined(),
21+
chunk_id: yupString().defined(),
22+
}).defined(),
23+
}),
24+
response: yupObject({
25+
statusCode: yupNumber().oneOf([200]).defined(),
26+
bodyType: yupString().oneOf(["json"]).defined(),
27+
body: yupObject({
28+
events: yupArray(yupMixed().defined()).defined(),
29+
}).defined(),
30+
}),
31+
async handler({ auth, params }) {
32+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
33+
34+
const sessionRecordingId = params.session_recording_id;
35+
const chunkId = params.chunk_id;
36+
37+
const chunk = await prisma.sessionRecordingChunk.findFirst({
38+
where: {
39+
tenancyId: auth.tenancy.id,
40+
sessionRecordingId,
41+
id: chunkId,
42+
},
43+
select: {
44+
s3Key: true,
45+
},
46+
});
47+
if (!chunk) {
48+
throw new KnownErrors.ItemNotFound(chunkId);
49+
}
50+
51+
let bytes: Uint8Array;
52+
try {
53+
bytes = await downloadBytes({ key: chunk.s3Key, private: true });
54+
} catch (e: any) {
55+
const status = e?.$metadata?.httpStatusCode;
56+
if (status === 404) {
57+
throw new KnownErrors.ItemNotFound(chunkId);
58+
}
59+
throw e;
60+
}
61+
const unzipped = new Uint8Array(await gunzip(bytes));
62+
63+
let parsed: any;
64+
try {
65+
parsed = JSON.parse(new TextDecoder().decode(unzipped));
66+
} catch (e) {
67+
throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e });
68+
}
69+
70+
if (typeof parsed !== "object" || parsed === null) {
71+
throw new StackAssertionError("Decoded session recording chunk is not an object");
72+
}
73+
if (parsed.session_recording_id !== sessionRecordingId) {
74+
throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", {
75+
expected: sessionRecordingId,
76+
actual: parsed.session_recording_id,
77+
});
78+
}
79+
if (!Array.isArray(parsed.events)) {
80+
throw new StackAssertionError("Decoded session recording chunk events is not an array");
81+
}
82+
83+
return {
84+
statusCode: 200,
85+
bodyType: "json",
86+
body: {
87+
events: parsed.events,
88+
},
89+
};
90+
},
91+
});

0 commit comments

Comments
 (0)