Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +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";
30 changes: 16 additions & 14 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -280,7 +280,7 @@ model ProjectUserOAuthAccount {
@@index([tenancyId, projectUserId])
}

model SessionRecording {
model SessionReplay {
id String @db.Uuid

tenancyId String @db.Uuid
Expand All @@ -296,26 +296,27 @@ 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

// Ephemeral in-memory id generated by the client. Stored for future tab separation if needed.
tabId String
// Ephemeral in-memory id generated by the client. Used to group recording chunks into per-tab replay segments.
sessionReplaySegmentId String

// Client-generated session id from localStorage, stored as metadata.
browserSessionId String
Expand All @@ -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 {
Expand Down
28 changes: 14 additions & 14 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<string, string>,
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()),
Expand All @@ -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`);
}
18 changes: 18 additions & 0 deletions apps/backend/scripts/clickhouse-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export async function runClickhouseMigrations() {
await client.exec({ query: EVENTS_VIEW_SQL });
await client.exec({ query: USERS_TABLE_BASE_SQL });
await client.exec({ query: USERS_VIEW_SQL });
await client.exec({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL });
await client.exec({ query: TOKEN_REFRESH_EVENT_ROW_FORMAT_MUTATION_SQL });
await client.exec({ query: BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL });
await client.exec({ query: SIGN_UP_RULE_TRIGGER_EVENT_ROW_FORMAT_MUTATION_SQL });
const queries = [
"REVOKE ALL PRIVILEGES ON *.* FROM limited_user;",
Expand Down Expand Up @@ -177,6 +179,22 @@ ENGINE ReplacingMergeTree(updated_at)
ORDER BY (tenancy_id, mapping_name);
`;

const EVENTS_ADD_REPLAY_COLUMNS_SQL = `
ALTER TABLE analytics_internal.events
ADD COLUMN IF NOT EXISTS refresh_token_id Nullable(String) AFTER team_id,
ADD COLUMN IF NOT EXISTS session_replay_id Nullable(String) AFTER refresh_token_id,
ADD COLUMN IF NOT EXISTS session_replay_segment_id Nullable(String) AFTER session_replay_id;
`;

// Backfill refresh_token_id from data.refresh_token_id for existing $token-refresh rows
const BACKFILL_REFRESH_TOKEN_ID_COLUMN_SQL = `
ALTER TABLE analytics_internal.events
UPDATE refresh_token_id = data.refresh_token_id::Nullable(String)
WHERE event_type = '$token-refresh'
AND refresh_token_id IS NULL
AND data.refresh_token_id::Nullable(String) IS NOT NULL;
`;

const EXTERNAL_ANALYTICS_DB_SQL = `
CREATE DATABASE IF NOT EXISTS analytics_internal;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
Expand All @@ -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: {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -29,7 +29,7 @@ export const GET = createSmartRouteHandler({
items: yupArray(yupObject({
id: yupString().defined(),
batch_id: yupString().defined(),
tab_id: yupString().nullable().defined(),
session_replay_segment_id: yupString().nullable().defined(),
browser_session_id: yupString().nullable().defined(),
event_count: yupNumber().defined(),
byte_length: yupNumber().defined(),
Expand All @@ -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);
Expand All @@ -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 },
Expand All @@ -74,25 +74,25 @@ 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" }],
take: limit + 1,
select: {
id: true,
batchId: true,
tabId: true,
sessionReplaySegmentId: true,
browserSessionId: true,
eventCount: true,
byteLength: true,
Expand All @@ -113,7 +113,7 @@ export const GET = createSmartRouteHandler({
items: page.map((c) => ({
id: c.id,
batch_id: c.batchId,
tab_id: c.tabId,
session_replay_segment_id: c.sessionReplaySegmentId,
browser_session_id: c.browserSessionId,
event_count: c.eventCount,
byte_length: c.byteLength,
Expand Down
Loading
Loading