Add rrweb-based session recording feature.

Implements full session recording with rrweb for DOM capture and rrweb-player
for playback. Includes: Prisma schema for SessionRecording model, chunked
gzip-compressed storage, recorder script built via Rollup, collection API
endpoint, recordings list/playback UI pages, website recording settings,
and cascade delete support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike CaoandClaude Opus 4.6 committed 2026-02-06 15:49:59 -08:00
1 parent b9eb5f9800
commit 72b5c658e2
36 files changed
+1131 -21

No files matched your search

+4
View File
@@ -22,6 +22,10 @@ export * from './getWeeklyTraffic';
export * from './pageviews/getPageviewExpandedMetrics';
export * from './pageviews/getPageviewMetrics';
export * from './pageviews/getPageviewStats';
export * from './recordings/deleteRecordingsByWebsite';
export * from './recordings/getRecordingChunks';
export * from './recordings/getSessionRecordings';
export * from './recordings/saveRecordingChunk';
export * from './reports/getBreakdown';
export * from './reports/getFunnel';
export * from './reports/getJourney';
@@ -0,0 +1,11 @@
import prisma from '@/lib/prisma';
export async function deleteRecordingsByWebsite(websiteId: string) {
return relationalQuery(websiteId);
}
async function relationalQuery(websiteId: string) {
return prisma.client.sessionRecording.deleteMany({
where: { websiteId },
});
}
@@ -0,0 +1,24 @@
import prisma from '@/lib/prisma';
export async function getRecordingChunks(websiteId: string, sessionId: string) {
return relationalQuery(websiteId, sessionId);
}
async function relationalQuery(websiteId: string, sessionId: string) {
return prisma.client.sessionRecording.findMany({
where: {
websiteId,
sessionId,
},
orderBy: {
chunkIndex: 'asc',
},
select: {
events: true,
chunkIndex: true,
eventCount: true,
startedAt: true,
endedAt: true,
},
});
}
@@ -0,0 +1,67 @@
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getSessionRecordings';
export async function getSessionRecordings(...args: [websiteId: string, filters: QueryFilters]) {
return relationalQuery(...args);
}
async function relationalQuery(websiteId: string, filters: QueryFilters) {
const { pagedRawQuery, parseFilters } = prisma;
const { search, startDate, endDate } = filters;
const { queryParams } = parseFilters({
...filters,
websiteId,
search: search ? `%${search}%` : undefined,
});
let dateQuery = '';
if (startDate && endDate) {
dateQuery = `and sr.created_at between {{startDate}} and {{endDate}}`;
} else if (startDate) {
dateQuery = `and sr.created_at >= {{startDate}}`;
}
const searchQuery = search
? `and (session.distinct_id ilike {{search}}
or session.city ilike {{search}}
or session.browser ilike {{search}})`
: '';
return pagedRawQuery(
`
select
sr.session_id as "id",
sr.website_id as "websiteId",
session.browser,
session.os,
session.device,
session.country,
session.city,
sum(sr.event_count) as "eventCount",
count(sr.recording_id) as "chunkCount",
min(sr.started_at) as "startedAt",
max(sr.ended_at) as "endedAt",
(extract(epoch from max(sr.ended_at) - min(sr.started_at)) * 1000)::bigint as "duration",
max(sr.created_at) as "createdAt"
from session_recording sr
left join session on session.session_id = sr.session_id
and session.website_id = sr.website_id
where sr.website_id = {{websiteId::uuid}}
${dateQuery}
${searchQuery}
group by sr.session_id,
sr.website_id,
session.browser,
session.os,
session.device,
session.country,
session.city
order by max(sr.created_at) desc
`,
queryParams,
filters,
FUNCTION_NAME,
);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './deleteRecordingsByWebsite';
export * from './getRecordingChunks';
export * from './getSessionRecordings';
export * from './saveRecordingChunk';
@@ -0,0 +1,39 @@
import { uuid } from '@/lib/crypto';
import prisma from '@/lib/prisma';
export interface SaveRecordingChunkArgs {
websiteId: string;
sessionId: string;
chunkIndex: number;
events: Uint8Array;
eventCount: number;
startedAt: Date;
endedAt: Date;
}
export async function saveRecordingChunk(args: SaveRecordingChunkArgs) {
return relationalQuery(args);
}
async function relationalQuery({
websiteId,
sessionId,
chunkIndex,
events,
eventCount,
startedAt,
endedAt,
}: SaveRecordingChunkArgs) {
return prisma.client.sessionRecording.create({
data: {
id: uuid(),
websiteId,
sessionId,
chunkIndex,
events: new Uint8Array(events) as any,
eventCount,
startedAt,
endedAt,
},
});
}