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 Cao 2026-02-06 15:49:59 -08:00
parent b9eb5f9800
commit 72b5c658e2
36 changed files with 1131 additions and 21 deletions

View file

@ -116,6 +116,11 @@ export function TestConsolePage({ websiteId }: { websiteId: string }) {
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
/>
<Script
async
data-website-id={websiteId}
src={`${process.env.basePath || ''}/recorder.js`}
/>
<Panel>
<Grid columns="1fr 1fr 1fr" gap>
<Column gap>

View file

@ -0,0 +1,15 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useRecordingsQuery } from '@/components/hooks';
import { RecordingsTable } from './RecordingsTable';
export function RecordingsDataTable({ websiteId }: { websiteId: string }) {
const queryResult = useRecordingsQuery(websiteId);
return (
<DataGrid query={queryResult} allowPaging allowSearch>
{({ data }) => {
return <RecordingsTable data={data} websiteId={websiteId} />;
}}
</DataGrid>
);
}

View file

@ -0,0 +1,16 @@
'use client';
import { Column } from '@umami/react-zen';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { RecordingsDataTable } from './RecordingsDataTable';
export function RecordingsPage({ websiteId }: { websiteId: string }) {
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<Panel>
<RecordingsDataTable websiteId={websiteId} />
</Panel>
</Column>
);
}

View file

@ -0,0 +1,66 @@
import { Button, DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import { Play } from 'lucide-react';
import Link from 'next/link';
import { Avatar } from '@/components/common/Avatar';
import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useFormat, useMessages } from '@/components/hooks';
function formatDuration(ms: number) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
export function RecordingsTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
return (
<DataTable {...props}>
<DataColumn id="id" label={formatMessage(labels.session)} width="100px">
{(row: any) => <Avatar seed={row.id} size={32} />}
</DataColumn>
<DataColumn id="duration" label={formatMessage(labels.duration)} width="100px">
{(row: any) => formatDuration(row.duration || 0)}
</DataColumn>
<DataColumn id="eventCount" label={formatMessage(labels.events)} width="80px" />
<DataColumn id="country" label={formatMessage(labels.country)}>
{(row: any) => (
<TypeIcon type="country" value={row.country}>
{formatValue(row.country, 'country')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="browser" label={formatMessage(labels.browser)}>
{(row: any) => (
<TypeIcon type="browser" value={row.browser}>
{formatValue(row.browser, 'browser')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="os" label={formatMessage(labels.os)}>
{(row: any) => (
<TypeIcon type="os" value={row.os}>
{formatValue(row.os, 'os')}
</TypeIcon>
)}
</DataColumn>
<DataColumn id="createdAt" label={formatMessage(labels.recordedAt)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="play" label="" width="80px">
{(row: any) => (
<Link href={`/websites/${websiteId}/recordings/${row.id}`}>
<Button variant="quiet">
<Icon>
<Play />
</Icon>
</Button>
</Link>
)}
</DataColumn>
</DataTable>
);
}

View file

@ -0,0 +1,47 @@
'use client';
import { Column, Row, Text } from '@umami/react-zen';
import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo';
import { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useRecordingQuery, useWebsiteSessionQuery } from '@/components/hooks';
import { RecordingPlayer } from './RecordingPlayer';
export function RecordingPlayback({
websiteId,
sessionId,
}: {
websiteId: string;
sessionId: string;
}) {
const { data: recording, isLoading, error } = useRecordingQuery(websiteId, sessionId);
const { data: session } = useWebsiteSessionQuery(websiteId, sessionId);
const { formatMessage, labels } = useMessages();
return (
<LoadingPanel
data={recording}
isLoading={isLoading}
error={error}
loadingIcon="spinner"
loadingPlacement="absolute"
>
{recording && (
<Column gap="6">
{session && (
<Row alignItems="center" gap="4">
<Avatar seed={sessionId} size={48} />
<Column>
<Text weight="bold">{formatMessage(labels.recording)}</Text>
<Text color="muted">
{recording.eventCount} {formatMessage(labels.events).toLowerCase()}
</Text>
</Column>
</Row>
)}
<RecordingPlayer events={recording.events} />
{session && <SessionInfo data={session} />}
</Column>
)}
</LoadingPanel>
);
}

View file

@ -0,0 +1,77 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect, useRef, useState } from 'react';
import 'rrweb-player/dist/style.css';
export function RecordingPlayer({ events }: { events: any[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!containerRef.current || !events?.length) return;
// Debug: log event info
const typeCounts: Record<number, number> = {};
events.forEach((e: any) => {
typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
});
const timestamps = events.map((e: any) => e.timestamp).filter(Boolean);
console.log('[RecordingPlayer] Events:', events.length, 'Types:', typeCounts);
console.log(
'[RecordingPlayer] Time range:',
timestamps.length
? `${Math.min(...timestamps)} - ${Math.max(...timestamps)} (${Math.max(...timestamps) - Math.min(...timestamps)}ms)`
: 'no timestamps',
);
console.log('[RecordingPlayer] First 3 events:', events.slice(0, 3));
// Dynamically import rrweb-player to avoid SSR issues
import('rrweb-player').then(mod => {
const RRWebPlayer = mod.default;
// Clear any previous player
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
playerRef.current = new RRWebPlayer({
target: containerRef.current!,
props: {
events,
width: 1024,
height: 576,
autoPlay: false,
showController: true,
speedOption: [1, 2, 4, 8],
},
});
setLoaded(true);
});
return () => {
if (playerRef.current) {
playerRef.current.$destroy?.();
playerRef.current = null;
}
};
}, [events]);
return (
<Column alignItems="center">
<div
ref={containerRef}
style={{
minWidth: 1024,
minHeight: loaded ? undefined : 576,
maxWidth: '100%',
overflow: 'hidden',
borderRadius: '8px',
border: '1px solid var(--base300)',
background: 'var(--base75)',
}}
/>
</Column>
);
}

View file

@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { RecordingPlayback } from './RecordingPlayback';
export default async function ({
params,
}: {
params: Promise<{ websiteId: string; sessionId: string }>;
}) {
const { websiteId, sessionId } = await params;
return <RecordingPlayback websiteId={websiteId} sessionId={sessionId} />;
}
export const metadata: Metadata = {
title: 'Recording Playback',
};

View file

@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { RecordingsPage } from './RecordingsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <RecordingsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Recordings',
};

View file

@ -10,9 +10,10 @@ import {
TextField,
} from '@umami/react-zen';
import { X } from 'lucide-react';
import { RecordingPlayer } from '@/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer';
import { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
import { useMessages, useRecordingQuery, useWebsiteSessionQuery } from '@/components/hooks';
import { SessionActivity } from './SessionActivity';
import { SessionData } from './SessionData';
import { SessionInfo } from './SessionInfo';
@ -28,6 +29,7 @@ export function SessionProfile({
onClose?: () => void;
}) {
const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
const { data: recording } = useRecordingQuery(websiteId, sessionId);
const { formatMessage, labels } = useMessages();
return (
@ -63,6 +65,9 @@ export function SessionProfile({
<TabList>
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
{recording?.events?.length > 0 && (
<Tab id="recording">{formatMessage(labels.recording)}</Tab>
)}
</TabList>
<TabPanel id="activity">
<SessionActivity
@ -75,6 +80,11 @@ export function SessionProfile({
<TabPanel id="properties">
<SessionData sessionId={sessionId} websiteId={websiteId} />
</TabPanel>
{recording?.events?.length > 0 && (
<TabPanel id="recording">
<RecordingPlayer events={recording.events} />
</TabPanel>
)}
</Tabs>
</Column>
</Column>

View file

@ -0,0 +1,96 @@
import {
Column,
Form,
FormButtons,
FormField,
FormSubmitButton,
Label,
Switch,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks';
interface RecordingConfig {
sampleRate?: number;
maskLevel?: string;
maxDuration?: number;
blockSelector?: string;
retentionDays?: number;
}
export function WebsiteRecordingSettings({ websiteId }: { websiteId: string }) {
const website = useWebsite();
const { formatMessage, labels, messages } = useMessages();
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
const [enabled, setEnabled] = useState(website?.recordingEnabled ?? false);
const config = (website?.recordingConfig as RecordingConfig) || {};
const handleSubmit = async (data: any) => {
await mutateAsync(
{
recordingEnabled: enabled,
recordingConfig: {
sampleRate: parseFloat(data.sampleRate) || 1,
maskLevel: data.maskLevel || 'moderate',
maxDuration: parseInt(data.maxDuration, 10) || 600000,
blockSelector: data.blockSelector || '',
retentionDays: parseInt(data.retentionDays, 10) || 30,
},
},
{
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('websites');
touch(`website:${website.id}`);
},
},
);
};
return (
<Form
onSubmit={handleSubmit}
values={{
sampleRate: String(config.sampleRate ?? 1),
maskLevel: config.maskLevel ?? 'moderate',
maxDuration: String(config.maxDuration ?? 600000),
blockSelector: config.blockSelector ?? '',
retentionDays: String(config.retentionDays ?? 30),
}}
>
<Column gap="4">
<Label>{formatMessage(labels.recordings)}</Label>
<Switch isSelected={enabled} onChange={setEnabled}>
{formatMessage(labels.recordingEnabled)}
</Switch>
{enabled && (
<>
<FormField name="sampleRate" label={formatMessage(labels.sampleRate)}>
<TextField />
</FormField>
<FormField
name="maskLevel"
label={`${formatMessage(labels.maskLevel)} (strict / moderate / relaxed)`}
>
<TextField />
</FormField>
<FormField name="maxDuration" label={`${formatMessage(labels.maxDuration)} (ms)`}>
<TextField />
</FormField>
<FormField name="blockSelector" label={formatMessage(labels.blockSelector)}>
<TextField />
</FormField>
<FormField name="retentionDays" label={formatMessage(labels.retentionDays)}>
<TextField />
</FormField>
</>
)}
</Column>
<FormButtons>
<FormSubmitButton variant="primary">{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -2,6 +2,7 @@ import { Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel';
import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteRecordingSettings } from './WebsiteRecordingSettings';
import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
@ -14,6 +15,9 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
<Panel>
<WebsiteTrackingCode websiteId={websiteId} />
</Panel>
<Panel>
<WebsiteRecordingSettings websiteId={websiteId} />
</Panel>
<Panel>
<WebsiteShareForm websiteId={websiteId} />
</Panel>

View file

@ -1,7 +1,8 @@
import { Column, Label, Text, TextField } from '@umami/react-zen';
import { useConfig, useMessages } from '@/components/hooks';
import { useConfig, useMessages, useWebsite } from '@/components/hooks';
const SCRIPT_NAME = 'script.js';
const RECORDER_NAME = 'recorder.js';
export function WebsiteTrackingCode({
websiteId,
@ -12,23 +13,29 @@ export function WebsiteTrackingCode({
}) {
const { formatMessage, messages, labels } = useMessages();
const config = useConfig();
const website = useWebsite();
const trackerScriptName =
config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
const getUrl = () => {
const getUrl = (scriptName: string) => {
if (config?.cloudMode) {
return `${process.env.cloudUrl}/${trackerScriptName}`;
return `${process.env.cloudUrl}/${scriptName}`;
}
return `${hostUrl || window?.location?.origin || ''}${
process.env.basePath || ''
}/${trackerScriptName}`;
}/${scriptName}`;
};
const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl();
const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl(trackerScriptName);
const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
let code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
if (website?.recordingEnabled) {
const recorderUrl = getUrl(RECORDER_NAME);
code += `\n<script defer src="${recorderUrl}" data-website-id="${websiteId}"></script>`;
}
return (
<Column gap>

104
src/app/api/record/route.ts Normal file
View file

@ -0,0 +1,104 @@
import { gzipSync } from 'node:zlib';
import { isbot } from 'isbot';
import { serializeError } from 'serialize-error';
import { z } from 'zod';
import { secret } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { parseToken } from '@/lib/jwt';
import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
import { saveRecordingChunk } from '@/queries/sql';
const schema = z.object({
website: z.uuid(),
events: z.array(z.any()).max(200),
timestamp: z.coerce.number().int().optional(),
});
export async function POST(request: Request) {
try {
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
return error();
}
const { website: websiteId, events, timestamp } = body;
if (!events?.length) {
return json({ ok: true });
}
// Parse cache token to get session info
const cacheHeader = request.headers.get('x-umami-cache');
if (!cacheHeader) {
return badRequest({ message: 'Missing session token.' });
}
const cache = await parseToken(cacheHeader, secret());
if (!cache || !cache.sessionId) {
return badRequest({ message: 'Invalid session token.' });
}
const { sessionId } = cache;
// Query directly to avoid stale Redis cache for recordingEnabled
const website = await getWebsite(websiteId);
if (!website) {
return badRequest({ message: 'Website not found.' });
}
if (!website.recordingEnabled) {
return json({ ok: false, reason: 'recording_disabled' });
}
// Client info for bot/IP checks
const { ip, userAgent } = await getClientInfo(request, {});
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
return json({ beep: 'boop' });
}
if (hasBlockedIp(ip)) {
return forbidden();
}
// Compute timestamps from events
const eventTimestamps = events
.map((e: any) => e.timestamp)
.filter((t: any) => typeof t === 'number');
const startedAt = new Date(Math.min(...eventTimestamps));
const endedAt = new Date(Math.max(...eventTimestamps));
// Compress events
const eventsJson = JSON.stringify(events);
const compressed = gzipSync(Buffer.from(eventsJson, 'utf-8'));
// Use timestamp-based chunk index for ordering
const chunkIndex = timestamp || Math.floor(Date.now() / 1000);
await saveRecordingChunk({
websiteId,
sessionId,
chunkIndex,
events: compressed,
eventCount: events.length,
startedAt,
endedAt,
});
return json({ ok: true });
} catch (e) {
const error = serializeError(e);
// eslint-disable-next-line no-console
console.log(error);
return serverError({ errorObject: error });
}
}

View file

@ -0,0 +1,41 @@
import { gunzipSync } from 'node:zlib';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsite } from '@/permissions';
import { getRecordingChunks } from '@/queries/sql';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId, sessionId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const chunks = await getRecordingChunks(websiteId, sessionId);
// Decompress and concatenate all chunks
const allEvents = chunks.flatMap(chunk => {
const decompressed = gunzipSync(Buffer.from(chunk.events));
return JSON.parse(decompressed.toString('utf-8'));
});
const startedAt = chunks.length > 0 ? chunks[0].startedAt : null;
const endedAt = chunks.length > 0 ? chunks[chunks.length - 1].endedAt : null;
return json({
events: allEvents,
startedAt,
endedAt,
eventCount: allEvents.length,
chunkCount: chunks.length,
});
}

View file

@ -0,0 +1,35 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, pagingParams, searchParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getSessionRecordings } from '@/queries/sql';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
...pagingParams,
...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const data = await getSessionRecordings(websiteId, filters);
return json(data);
}

View file

@ -42,6 +42,17 @@ export async function POST(
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().max(50).nullable().optional(),
recordingEnabled: z.boolean().optional(),
recordingConfig: z
.object({
sampleRate: z.number().min(0).max(1).optional(),
maskLevel: z.enum(['strict', 'moderate', 'relaxed']).optional(),
maxDuration: z.number().int().positive().optional(),
blockSelector: z.string().optional(),
retentionDays: z.number().int().positive().optional(),
})
.nullable()
.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -51,14 +62,19 @@ export async function POST(
}
const { websiteId } = await params;
const { name, domain, shareId } = body;
const { name, domain, shareId, recordingEnabled, recordingConfig } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
try {
const website = await updateWebsite(websiteId, { name, domain });
const website = await updateWebsite(websiteId, {
name,
domain,
...(recordingEnabled !== undefined && { recordingEnabled }),
...(recordingConfig !== undefined && { recordingConfig }),
});
if (shareId === null) {
await deleteSharesByEntityId(website.id);