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:
1 parent
b9eb5f9800
commit
72b5c658e2
36 files changed
+1131
-21
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user