Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/backend/.env
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ STACK_S3_REGION=
STACK_S3_ACCESS_KEY_ID=
STACK_S3_SECRET_ACCESS_KEY=
STACK_S3_BUCKET=
STACK_S3_PRIVATE_BUCKET=

# AWS configuration
STACK_AWS_REGION=
Expand Down
1 change: 1 addition & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ STACK_S3_REGION=us-east-1
STACK_S3_ACCESS_KEY_ID=s3mockroot
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
STACK_S3_BUCKET=stack-storage
STACK_S3_PRIVATE_BUCKET=stack-storage-private

# AWS region defaults to LocalStack
STACK_AWS_REGION=us-east-1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- Session recording MVP: store session metadata in Postgres and rrweb events in S3.

CREATE TABLE "SessionRecording" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"projectUserId" UUID NOT NULL,
"refreshTokenId" UUID NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL,
"lastEventAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SessionRecording_pkey" PRIMARY KEY ("tenancyId","id")
);

CREATE TABLE "SessionRecordingChunk" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenancyId" UUID NOT NULL,
"sessionRecordingId" UUID NOT NULL,
"batchId" UUID NOT NULL,
"tabId" TEXT NOT NULL,
"s3Key" TEXT NOT NULL,
"eventCount" INTEGER NOT NULL,
"byteLength" INTEGER NOT NULL,
"firstEventAt" TIMESTAMP(3) NOT NULL,
"lastEventAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SessionRecordingChunk_pkey" PRIMARY KEY ("id")
);

ALTER TABLE "SessionRecording"
ADD CONSTRAINT "SessionRecording_tenancyId_fkey"
FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SessionRecording"
ADD CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey"
FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SessionRecordingChunk"
ADD CONSTRAINT "SessionRecordingChunk_tenancyId_fkey"
FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "SessionRecordingChunk"
ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey"
FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE;

CREATE UNIQUE INDEX "SessionRecording_tenancyId_refreshTokenId_key"
ON "SessionRecording"("tenancyId", "refreshTokenId");

CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx"
ON "SessionRecording"("tenancyId", "projectUserId", "startedAt");

CREATE INDEX "SessionRecording_tenancyId_lastEventAt_idx"
ON "SessionRecording"("tenancyId", "lastEventAt");

CREATE UNIQUE INDEX "SessionRecordingChunk_sessionRecordingId_batchId_key"
ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "batchId");

CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdAt_idx"
ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt");
56 changes: 56 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ model Tenancy {
organizationId String? @db.Uuid
hasNoOrganization BooleanTrue?
emailOutboxes EmailOutbox[]
sessionRecordings SessionRecording[]
sessionRecordingChunks SessionRecordingChunk[]

@@unique([projectId, branchId, organizationId])
@@unique([projectId, branchId, hasNoOrganization])
Expand Down Expand Up @@ -234,6 +236,7 @@ model ProjectUser {
Project Project? @relation(fields: [projectId], references: [id])
projectId String?
userNotificationPreference UserNotificationPreference[]
sessionRecordings SessionRecording[]

@@id([tenancyId, projectUserId])
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
Expand Down Expand Up @@ -277,6 +280,59 @@ model ProjectUserOAuthAccount {
@@index([tenancyId, projectUserId])
}

model SessionRecording {
// Cross-tab session id generated by the SDK and stored in localStorage.
id String @db.Uuid

tenancyId String @db.Uuid
projectUserId String @db.Uuid
refreshTokenId String @db.Uuid

startedAt DateTime
lastEventAt DateTime

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)

chunks SessionRecordingChunk[]

@@id([tenancyId, id])
@@unique([tenancyId, refreshTokenId])
@@index([tenancyId, projectUserId, startedAt])
@@index([tenancyId, lastEventAt])
}

model SessionRecordingChunk {
id String @id @default(uuid()) @db.Uuid

tenancyId String @db.Uuid
sessionRecordingId String @db.Uuid

// Unique per uploaded batch for a given session id.
batchId String @db.Uuid

// Ephemeral in-memory id generated by the client. Stored for future tab separation if needed.
tabId String

s3Key String
eventCount Int
byteLength Int

firstEventAt DateTime
lastEventAt DateTime

createdAt DateTime @default(now())

sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade)
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)

@@unique([tenancyId, sessionRecordingId, batchId])
@@index([tenancyId, sessionRecordingId, createdAt])
}

enum ContactChannelType {
EMAIL
// PHONE
Expand Down
69 changes: 69 additions & 0 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,13 @@ async function seedDummyProject(options: DummyProjectSeedOptions) {
userEmailToId,
});

await seedDummySessionRecordings({
prisma: dummyPrisma,
tenancyId: dummyTenancy.id,
userEmailToId,
targetSessionRecordingCount: 75
});

console.log('Seeded dummy project data');
}

Expand Down Expand Up @@ -1765,3 +1772,65 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO

console.log('Finished seeding session activity events');
}

type SessionRecordingSeedOptions = {
prisma: PrismaClientTransaction,
tenancyId: string,
userEmailToId: Map<string, string>,
targetSessionRecordingCount?: number,
};

async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) {
const {
prisma,
tenancyId,
userEmailToId,
targetSessionRecordingCount = 250,
} = options;

const existingCount = await prisma.sessionRecording.count({
where: {
tenancyId,
},
});

if (existingCount >= targetSessionRecordingCount) {
console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`);
return;
}

const toCreate = targetSessionRecordingCount - existingCount;
const userIds = Array.from(userEmailToId.values());
if (userIds.length === 0) {
throw new Error('Cannot seed session recordings: no dummy project users exist');
}

const now = new Date();
const twoWeeksAgo = new Date(now);
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);

const seeds: Prisma.SessionRecordingCreateManyInput[] = [];
for (let i = 0; i < toCreate; i++) {
const startedAt = new Date(
twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()),
);
const durationMs = 10_000 + Math.floor(Math.random() * (20 * 60 * 1000)); // 10s..20m
const lastEventAt = new Date(startedAt.getTime() + durationMs);
const projectUserId = userIds[Math.floor(Math.random() * userIds.length)]!;

seeds.push({
tenancyId,
refreshTokenId: generateUuid(),
projectUserId,
id: generateUuid(),
startedAt,
lastEventAt,
});
}

await prisma.sessionRecording.createMany({
data: seeds,
});

console.log(`Seeded ${toCreate} session recordings`);
}
Loading
Loading