Skip to content

Commit 8a8dcac

Browse files
BilalG1N2D4
authored andcommitted
rename tabId to sessionReplaySegmentId (#1206)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added new session replay analytics columns to ClickHouse for enhanced tracking and reporting * **Refactor** * Renamed session recording segment identifier across APIs and data models from `tab_id` to `session_replay_segment_id` * Updated internal data structures and type definitions to align with new naming convention <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 51a4dcd commit 8a8dcac

25 files changed

Lines changed: 326 additions & 270 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
ALTER TABLE "SessionRecordingChunk" RENAME COLUMN "tabId" TO "sessionReplaySegmentId";
2+
3+
ALTER TABLE "SessionRecording" RENAME TO "SessionReplay";
4+
ALTER TABLE "SessionRecordingChunk" RENAME TO "SessionReplayChunk";
5+
ALTER TABLE "SessionReplayChunk" RENAME COLUMN "sessionRecordingId" TO "sessionReplayId";
6+
7+
-- Rename primary key constraints
8+
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_pkey" TO "SessionReplay_pkey";
9+
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_pkey" TO "SessionReplayChunk_pkey";
10+
11+
-- Rename foreign key constraints
12+
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_fkey" TO "SessionReplay_tenancyId_fkey";
13+
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey" TO "SessionReplay_tenancyId_projectUserId_fkey";
14+
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_fkey" TO "SessionReplayChunk_tenancyId_fkey";
15+
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey" TO "SessionReplayChunk_tenancyId_sessionReplayId_fkey";
16+
17+
-- Rename indexes
18+
ALTER INDEX "SessionRecording_tenancyId_lastEventAt_idx" RENAME TO "SessionReplay_tenancyId_lastEventAt_idx";
19+
ALTER INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" RENAME TO "SessionReplay_tenancyId_projectUserId_startedAt_idx";
20+
ALTER INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx" RENAME TO "SessionReplay_tenancyId_refreshTokenId_updatedAt_idx";
21+
ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_batchId_key";
22+
ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_createdAt_idx";

apps/backend/prisma/schema.prisma

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ model Tenancy {
6060
organizationId String? @db.Uuid
6161
hasNoOrganization BooleanTrue?
6262
emailOutboxes EmailOutbox[]
63-
sessionRecordings SessionRecording[]
64-
sessionRecordingChunks SessionRecordingChunk[]
63+
sessionReplays SessionReplay[]
64+
sessionReplayChunks SessionReplayChunk[]
6565
6666
@@unique([projectId, branchId, organizationId])
6767
@@unique([projectId, branchId, hasNoOrganization])
@@ -236,7 +236,7 @@ model ProjectUser {
236236
Project Project? @relation(fields: [projectId], references: [id])
237237
projectId String?
238238
userNotificationPreference UserNotificationPreference[]
239-
sessionRecordings SessionRecording[]
239+
sessionReplays SessionReplay[]
240240
241241
@@id([tenancyId, projectUserId])
242242
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
@@ -280,7 +280,7 @@ model ProjectUserOAuthAccount {
280280
@@index([tenancyId, projectUserId])
281281
}
282282

283-
model SessionRecording {
283+
model SessionReplay {
284284
id String @db.Uuid
285285
286286
tenancyId String @db.Uuid
@@ -296,26 +296,27 @@ model SessionRecording {
296296
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
297297
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
298298
299-
chunks SessionRecordingChunk[]
299+
chunks SessionReplayChunk[]
300300
301301
@@id([tenancyId, id])
302+
@@map("SessionReplay")
302303
@@index([tenancyId, projectUserId, startedAt])
303304
@@index([tenancyId, lastEventAt])
304305
// index by updatedAt instead of lastEventAt because event timing can be spoofed
305306
@@index([tenancyId, refreshTokenId, updatedAt])
306307
}
307308

308-
model SessionRecordingChunk {
309+
model SessionReplayChunk {
309310
id String @id @default(uuid()) @db.Uuid
310311
311-
tenancyId String @db.Uuid
312-
sessionRecordingId String @db.Uuid
312+
tenancyId String @db.Uuid
313+
sessionReplayId String @db.Uuid @map("sessionReplayId")
313314
314315
// Unique per uploaded batch for a given session id.
315316
batchId String @db.Uuid
316317
317-
// Ephemeral in-memory id generated by the client. Stored for future tab separation if needed.
318-
tabId String
318+
// Ephemeral in-memory id generated by the client. Used to group recording chunks into per-tab replay segments.
319+
sessionReplaySegmentId String
319320
320321
// Client-generated session id from localStorage, stored as metadata.
321322
browserSessionId String
@@ -329,11 +330,12 @@ model SessionRecordingChunk {
329330
330331
createdAt DateTime @default(now())
331332
332-
sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade)
333-
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
333+
sessionReplay SessionReplay @relation(fields: [tenancyId, sessionReplayId], references: [tenancyId, id], onDelete: Cascade)
334+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
334335
335-
@@unique([tenancyId, sessionRecordingId, batchId])
336-
@@index([tenancyId, sessionRecordingId, createdAt])
336+
@@unique([tenancyId, sessionReplayId, batchId])
337+
@@map("SessionReplayChunk")
338+
@@index([tenancyId, sessionReplayId, createdAt])
337339
}
338340

339341
enum ContactChannelType {

apps/backend/prisma/seed.ts

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

1121-
await seedDummySessionRecordings({
1121+
await seedDummySessionReplays({
11221122
prisma: dummyPrisma,
11231123
tenancyId: dummyTenancy.id,
11241124
userEmailToId,
1125-
targetSessionRecordingCount: 75
1125+
targetSessionReplayCount: 75
11261126
});
11271127

11281128
console.log('Seeded dummy project data');
@@ -1773,43 +1773,43 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO
17731773
console.log('Finished seeding session activity events');
17741774
}
17751775

1776-
type SessionRecordingSeedOptions = {
1776+
type SessionReplaySeedOptions = {
17771777
prisma: PrismaClientTransaction,
17781778
tenancyId: string,
17791779
userEmailToId: Map<string, string>,
1780-
targetSessionRecordingCount?: number,
1780+
targetSessionReplayCount?: number,
17811781
};
17821782

1783-
async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) {
1783+
async function seedDummySessionReplays(options: SessionReplaySeedOptions) {
17841784
const {
17851785
prisma,
17861786
tenancyId,
17871787
userEmailToId,
1788-
targetSessionRecordingCount = 250,
1788+
targetSessionReplayCount = 250,
17891789
} = options;
17901790

1791-
const existingCount = await prisma.sessionRecording.count({
1791+
const existingCount = await prisma.sessionReplay.count({
17921792
where: {
17931793
tenancyId,
17941794
},
17951795
});
17961796

1797-
if (existingCount >= targetSessionRecordingCount) {
1798-
console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`);
1797+
if (existingCount >= targetSessionReplayCount) {
1798+
console.log(`Dummy project already has ${existingCount} session replays, skipping seeding`);
17991799
return;
18001800
}
18011801

1802-
const toCreate = targetSessionRecordingCount - existingCount;
1802+
const toCreate = targetSessionReplayCount - existingCount;
18031803
const userIds = Array.from(userEmailToId.values());
18041804
if (userIds.length === 0) {
1805-
throw new Error('Cannot seed session recordings: no dummy project users exist');
1805+
throw new Error('Cannot seed session replays: no dummy project users exist');
18061806
}
18071807

18081808
const now = new Date();
18091809
const twoWeeksAgo = new Date(now);
18101810
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
18111811

1812-
const seeds: Prisma.SessionRecordingCreateManyInput[] = [];
1812+
const seeds: Prisma.SessionReplayCreateManyInput[] = [];
18131813
for (let i = 0; i < toCreate; i++) {
18141814
const startedAt = new Date(
18151815
twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()),
@@ -1828,9 +1828,9 @@ async function seedDummySessionRecordings(options: SessionRecordingSeedOptions)
18281828
});
18291829
}
18301830

1831-
await prisma.sessionRecording.createMany({
1831+
await prisma.sessionReplay.createMany({
18321832
data: seeds,
18331833
});
18341834

1835-
console.log(`Seeded ${toCreate} session recordings`);
1835+
console.log(`Seeded ${toCreate} session replays`);
18361836
}

apps/backend/scripts/clickhouse-migrations.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export async function runClickhouseMigrations() {
1616
await client.exec({ query: EVENTS_VIEW_SQL });
1717
await client.exec({ query: USERS_TABLE_BASE_SQL });
1818
await client.exec({ query: USERS_VIEW_SQL });
19+
await client.exec({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL });
1920
await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL });
21+
await client.exec({ query: BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL });
2022
await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL });
2123
const queries = [
2224
"REVOKE ALL PRIVILEGES ON *.* FROM limited_user;",
@@ -177,6 +179,22 @@ ENGINE ReplacingMergeTree(updated_at)
177179
ORDER BY (tenancy_id, mapping_name);
178180
`;
179181

182+
const EVENTS_ADD_REPLAY_COLUMNS_SQL = `
183+
ALTER TABLE analytics_internal.events
184+
ADD COLUMN IF NOT EXISTS refresh_token_id Nullable(String) AFTER team_id,
185+
ADD COLUMN IF NOT EXISTS session_replay_id Nullable(String) AFTER refresh_token_id,
186+
ADD COLUMN IF NOT EXISTS session_replay_segment_id Nullable(String) AFTER session_replay_id;
187+
`;
188+
189+
// Backfill refresh_token_id from data.refresh_token_id for existing $token-refresh rows
190+
const BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL = `
191+
ALTER TABLE analytics_internal.events
192+
UPDATE refresh_token_id = data.refresh_token_id::Nullable(String)
193+
WHERE event_type = '$token-refresh'
194+
AND refresh_token_id IS NULL
195+
AND data.refresh_token_id::Nullable(String) IS NOT NULL;
196+
`;
197+
180198
const EXTERNAL_ANALYTICS_DB_SQL = `
181199
CREATE DATABASE IF NOT EXISTS analytics_internal;
182200
`;

apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx renamed to apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/[chunk_id]/events/route.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const GET = createSmartRouteHandler({
1717
tenancy: adaptSchema.defined(),
1818
}).defined(),
1919
params: yupObject({
20-
session_recording_id: yupString().defined(),
20+
session_replay_id: yupString().defined(),
2121
chunk_id: yupString().defined(),
2222
}).defined(),
2323
}),
@@ -31,13 +31,13 @@ export const GET = createSmartRouteHandler({
3131
async handler({ auth, params }) {
3232
const prisma = await getPrismaClientForTenancy(auth.tenancy);
3333

34-
const sessionRecordingId = params.session_recording_id;
34+
const sessionReplayId = params.session_replay_id;
3535
const chunkId = params.chunk_id;
3636

37-
const chunk = await prisma.sessionRecordingChunk.findFirst({
37+
const chunk = await prisma.sessionReplayChunk.findFirst({
3838
where: {
3939
tenancyId: auth.tenancy.id,
40-
sessionRecordingId,
40+
sessionReplayId,
4141
id: chunkId,
4242
},
4343
select: {
@@ -64,20 +64,20 @@ export const GET = createSmartRouteHandler({
6464
try {
6565
parsed = JSON.parse(new TextDecoder().decode(unzipped));
6666
} catch (e) {
67-
throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e });
67+
throw new StackAssertionError("Failed to decode session replay chunk JSON", { cause: e });
6868
}
6969

7070
if (typeof parsed !== "object" || parsed === null) {
71-
throw new StackAssertionError("Decoded session recording chunk is not an object");
71+
throw new StackAssertionError("Decoded session replay chunk is not an object");
7272
}
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,
73+
if (parsed.session_replay_id !== sessionReplayId) {
74+
throw new StackAssertionError("Decoded session replay chunk session_replay_id mismatch", {
75+
expected: sessionReplayId,
76+
actual: parsed.session_replay_id,
7777
});
7878
}
7979
if (!Array.isArray(parsed.events)) {
80-
throw new StackAssertionError("Decoded session recording chunk events is not an array");
80+
throw new StackAssertionError("Decoded session replay chunk events is not an array");
8181
}
8282

8383
return {

apps/backend/src/app/api/latest/internal/session-recordings/[session_recording_id]/chunks/route.tsx renamed to apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/chunks/route.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const GET = createSmartRouteHandler({
1515
tenancy: adaptSchema.defined(),
1616
}).defined(),
1717
params: yupObject({
18-
session_recording_id: yupString().defined(),
18+
session_replay_id: yupString().defined(),
1919
}).defined(),
2020
query: yupObject({
2121
cursor: yupString().optional(),
@@ -29,7 +29,7 @@ export const GET = createSmartRouteHandler({
2929
items: yupArray(yupObject({
3030
id: yupString().defined(),
3131
batch_id: yupString().defined(),
32-
tab_id: yupString().nullable().defined(),
32+
session_replay_segment_id: yupString().nullable().defined(),
3333
browser_session_id: yupString().nullable().defined(),
3434
event_count: yupNumber().defined(),
3535
byte_length: yupNumber().defined(),
@@ -45,13 +45,13 @@ export const GET = createSmartRouteHandler({
4545
async handler({ auth, params, query }) {
4646
const prisma = await getPrismaClientForTenancy(auth.tenancy);
4747

48-
const sessionRecordingId = params.session_recording_id;
49-
const exists = await prisma.sessionRecording.findUnique({
50-
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionRecordingId } },
48+
const sessionReplayId = params.session_replay_id;
49+
const exists = await prisma.sessionReplay.findUnique({
50+
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionReplayId } },
5151
select: { id: true },
5252
});
5353
if (!exists) {
54-
throw new KnownErrors.ItemNotFound(sessionRecordingId);
54+
throw new KnownErrors.ItemNotFound(sessionReplayId);
5555
}
5656

5757
const rawLimit = query.limit ?? String(DEFAULT_LIMIT);
@@ -61,10 +61,10 @@ export const GET = createSmartRouteHandler({
6161
const cursorId = query.cursor;
6262
let cursorPivot: { firstEventAt: Date } | null = null;
6363
if (cursorId) {
64-
cursorPivot = await prisma.sessionRecordingChunk.findFirst({
64+
cursorPivot = await prisma.sessionReplayChunk.findFirst({
6565
where: {
6666
tenancyId: auth.tenancy.id,
67-
sessionRecordingId,
67+
sessionReplayId,
6868
id: cursorId,
6969
},
7070
select: { firstEventAt: true },
@@ -74,25 +74,25 @@ export const GET = createSmartRouteHandler({
7474
}
7575
}
7676

77-
const cursorWhere: Prisma.SessionRecordingChunkWhereInput = cursorId && cursorPivot ? {
77+
const cursorWhere: Prisma.SessionReplayChunkWhereInput = cursorId && cursorPivot ? {
7878
OR: [
7979
{ firstEventAt: { gt: cursorPivot.firstEventAt } },
8080
{ AND: [{ firstEventAt: { equals: cursorPivot.firstEventAt } }, { id: { gt: cursorId } }] },
8181
],
8282
} : {};
8383

84-
const chunks = await prisma.sessionRecordingChunk.findMany({
84+
const chunks = await prisma.sessionReplayChunk.findMany({
8585
where: {
8686
tenancyId: auth.tenancy.id,
87-
sessionRecordingId,
87+
sessionReplayId,
8888
...cursorWhere,
8989
},
9090
orderBy: [{ firstEventAt: "asc" }, { id: "asc" }],
9191
take: limit + 1,
9292
select: {
9393
id: true,
9494
batchId: true,
95-
tabId: true,
95+
sessionReplaySegmentId: true,
9696
browserSessionId: true,
9797
eventCount: true,
9898
byteLength: true,
@@ -113,7 +113,7 @@ export const GET = createSmartRouteHandler({
113113
items: page.map((c) => ({
114114
id: c.id,
115115
batch_id: c.batchId,
116-
tab_id: c.tabId,
116+
session_replay_segment_id: c.sessionReplaySegmentId,
117117
browser_session_id: c.browserSessionId,
118118
event_count: c.eventCount,
119119
byte_length: c.byteLength,

0 commit comments

Comments
 (0)