mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 20:15:41 +01:00
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:
parent
b9eb5f9800
commit
72b5c658e2
36 changed files with 1131 additions and 21 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
12
src/app/(main)/websites/[websiteId]/recordings/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/recordings/page.tsx
Normal 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',
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
104
src/app/api/record/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
35
src/app/api/websites/[websiteId]/recordings/route.ts
Normal file
35
src/app/api/websites/[websiteId]/recordings/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue