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

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ package-lock.json
# production
/build
/public/script.js
/public/recorder.js
/geo
/dist
/generated

View file

@ -11,8 +11,8 @@
},
"type": "module",
"scripts": {
"dev": "next dev -p 3001 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"dev": "next dev -p 3002 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-recorder build-geo build-app",
"start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
"start-docker": "npm-run-all check-db update-tracker start-server",
@ -22,6 +22,7 @@
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
"build-components": "tsup",
"build-tracker": "rollup -c rollup.tracker.config.js",
"build-recorder": "rollup -c rollup.recorder.config.js",
"build-prisma-client": "node scripts/build-prisma-client.js",
"build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
"build-geo": "node scripts/build-geo.js",
@ -117,6 +118,8 @@
"react-use-measure": "^2.0.4",
"react-window": "^1.8.6",
"request-ip": "^3.3.0",
"rrweb": "2.0.0-alpha.4",
"rrweb-player": "1.0.0-alpha.4",
"semver": "^7.7.4",
"serialize-error": "^12.0.0",
"thenby": "^1.3.4",

78
pnpm-lock.yaml generated
View file

@ -176,6 +176,12 @@ importers:
request-ip:
specifier: ^3.3.0
version: 3.3.0
rrweb:
specifier: 2.0.0-alpha.4
version: 2.0.0-alpha.4
rrweb-player:
specifier: 1.0.0-alpha.4
version: 1.0.0-alpha.4
semver:
specifier: ^7.7.4
version: 7.7.4
@ -328,8 +334,6 @@ importers:
specifier: ^5.9.3
version: 5.9.3
dist: {}
packages:
'@ampproject/remapping@2.3.0':
@ -2648,6 +2652,9 @@ packages:
cpu: [x64]
os: [win32]
'@rrweb/types@2.0.0-alpha.20':
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@ -2782,6 +2789,9 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@tsconfig/svelte@1.0.13':
resolution: {integrity: sha512-5lYJP45Xllo4yE/RUBccBT32eBlRDbqN8r1/MIvQbKxW3aFqaYPCNgm8D5V20X4ShHcwvYWNlKg3liDh1MlBoA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -2794,6 +2804,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/css-font-loading-module@0.0.7':
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
'@types/estree@0.0.50':
resolution: {integrity: sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==}
@ -2927,6 +2940,9 @@ packages:
'@vue/shared@3.5.18':
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
@ -3110,6 +3126,10 @@ packages:
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -3991,6 +4011,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@ -5168,6 +5191,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@ -6330,6 +6356,18 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rrdom@0.1.7:
resolution: {integrity: sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==}
rrweb-player@1.0.0-alpha.4:
resolution: {integrity: sha512-Wlmn9GZ5Fdqa37vd3TzsYdLl/JWEvXNUrLCrYpnOwEgmY409HwVIvvA5aIo7k582LoKgdRCsB87N+f0oWAR0Kg==}
rrweb-snapshot@2.0.0-alpha.4:
resolution: {integrity: sha512-KQ2OtPpXO5jLYqg1OnXS/Hf+EzqnZyP5A+XPqBCjYpj3XIje/Od4gdUwjbFo3cVuWq5Cw5Y1d3/xwgIS7/XpQQ==}
rrweb@2.0.0-alpha.4:
resolution: {integrity: sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -9835,6 +9873,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.57.1':
optional: true
'@rrweb/types@2.0.0-alpha.20': {}
'@sinclair/typebox@0.27.8': {}
'@sinclair/typebox@0.34.40': {}
@ -9977,6 +10017,8 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@tsconfig/svelte@1.0.13': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.3
@ -9998,6 +10040,8 @@ snapshots:
dependencies:
'@babel/types': 7.28.2
'@types/css-font-loading-module@0.0.7': {}
'@types/estree@0.0.50': {}
'@types/estree@1.0.8': {}
@ -10173,6 +10217,8 @@ snapshots:
'@vue/shared@3.5.18': {}
'@xstate/fsm@1.6.5': {}
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
@ -10381,6 +10427,8 @@ snapshots:
balanced-match@2.0.0: {}
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.19: {}
@ -11431,6 +11479,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.4.8: {}
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@ -12815,6 +12865,8 @@ snapshots:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mkdirp@1.0.4: {}
mlly@1.8.0:
@ -14092,6 +14144,28 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.57.1
fsevents: 2.3.3
rrdom@0.1.7:
dependencies:
rrweb-snapshot: 2.0.0-alpha.4
rrweb-player@1.0.0-alpha.4:
dependencies:
'@tsconfig/svelte': 1.0.13
rrweb: 2.0.0-alpha.4
rrweb-snapshot@2.0.0-alpha.4: {}
rrweb@2.0.0-alpha.4:
dependencies:
'@rrweb/types': 2.0.0-alpha.20
'@types/css-font-loading-module': 0.0.7
'@xstate/fsm': 1.6.5
base64-arraybuffer: 1.0.2
fflate: 0.4.8
mitt: 3.0.1
rrdom: 0.1.7
rrweb-snapshot: 2.0.0-alpha.4
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3

View file

@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "website" ADD COLUMN "recording_enabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "website" ADD COLUMN "recording_config" JSONB;
-- CreateTable
CREATE TABLE "session_recording" (
"recording_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"chunk_index" INTEGER NOT NULL,
"events" BYTEA NOT NULL,
"event_count" INTEGER NOT NULL,
"started_at" TIMESTAMPTZ(6) NOT NULL,
"ended_at" TIMESTAMPTZ(6) NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "session_recording_pkey" PRIMARY KEY ("recording_id")
);
-- CreateIndex
CREATE INDEX "session_recording_website_id_idx" ON "session_recording"("website_id");
CREATE INDEX "session_recording_session_id_idx" ON "session_recording"("session_id");
CREATE INDEX "session_recording_website_id_session_id_idx" ON "session_recording"("website_id", "session_id");
CREATE INDEX "session_recording_website_id_created_at_idx" ON "session_recording"("website_id", "created_at");
CREATE INDEX "session_recording_session_id_chunk_index_idx" ON "session_recording"("session_id", "chunk_index");

View file

@ -75,14 +75,18 @@ model Website {
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
user User? @relation("user", fields: [userId], references: [id])
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
reports Report[]
revenue Revenue[]
segments Segment[]
sessionData SessionData[]
recordingEnabled Boolean @default(false) @map("recording_enabled")
recordingConfig Json? @map("recording_config")
user User? @relation("user", fields: [userId], references: [id])
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
reports Report[]
revenue Revenue[]
segments Segment[]
sessionData SessionData[]
sessionRecordings SessionRecording[]
@@index([userId])
@@index([teamId])
@ -350,3 +354,24 @@ model Share {
@@index([entityId])
@@map("share")
}
model SessionRecording {
id String @id() @map("recording_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
chunkIndex Int @map("chunk_index") @db.Integer
events Bytes @map("events")
eventCount Int @map("event_count") @db.Integer
startedAt DateTime @map("started_at") @db.Timestamptz(6)
endedAt DateTime @map("ended_at") @db.Timestamptz(6)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@index([websiteId, sessionId])
@@index([websiteId, createdAt])
@@index([sessionId, chunkIndex])
@@map("session_recording")
}

24
rollup.recorder.config.js Normal file
View file

@ -0,0 +1,24 @@
import 'dotenv/config';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/recorder/index.js',
output: {
file: 'public/recorder.js',
format: 'iife',
},
plugins: [
resolve({ browser: true }),
commonjs(),
replace({
__COLLECT_API_HOST__: process.env.COLLECT_API_HOST || '',
__COLLECT_RECORDING_ENDPOINT__: process.env.COLLECT_RECORDING_ENDPOINT || '/api/record',
delimiters: ['', ''],
preventAssignment: true,
}),
terser({ compress: { evaluate: false } }),
],
};

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);

View file

@ -25,6 +25,8 @@ export * from './queries/useLoginQuery';
export * from './queries/usePixelQuery';
export * from './queries/usePixelsQuery';
export * from './queries/useRealtimeQuery';
export * from './queries/useRecordingQuery';
export * from './queries/useRecordingsQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useResultQuery';

View file

@ -0,0 +1,13 @@
import { useApi } from '../useApi';
export function useRecordingQuery(websiteId: string, sessionId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['recording', { websiteId, sessionId }],
queryFn: () => {
return get(`/websites/${websiteId}/recordings/${sessionId}`);
},
enabled: Boolean(websiteId && sessionId),
});
}

View file

@ -0,0 +1,25 @@
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useRecordingsQuery(websiteId: string, params?: Record<string, string | number>) {
const { get } = useApi();
const { modified } = useModified('recordings');
const { startAt, endAt, unit, timezone } = useDateParameters();
return usePagedQuery({
queryKey: ['recordings', { websiteId, modified, startAt, endAt, unit, timezone, ...params }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/recordings`, {
startAt,
endAt,
unit,
timezone,
...pageParams,
...params,
pageSize: 20,
});
},
});
}

View file

@ -7,6 +7,7 @@ import {
Tag,
User,
UserPlus,
Video,
} from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { useMessages } from './useMessages';
@ -94,6 +95,12 @@ export function useWebsiteNavItems(websiteId: string) {
icon: <Magnet />,
path: renderPath('/retention'),
},
{
id: 'recordings',
label: formatMessage(labels.recordings),
icon: <Video />,
path: renderPath('/recordings'),
},
],
},
{

View file

@ -378,6 +378,21 @@ export const labels = defineMessages({
support: { id: 'label.support', defaultMessage: 'Support' },
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
recordings: { id: 'label.recordings', defaultMessage: 'Recordings' },
recording: { id: 'label.recording', defaultMessage: 'Recording' },
playRecording: { id: 'label.play-recording', defaultMessage: 'Play recording' },
recordingEnabled: { id: 'label.recording-enabled', defaultMessage: 'Recording enabled' },
sampleRate: { id: 'label.sample-rate', defaultMessage: 'Sample rate' },
maskLevel: { id: 'label.mask-level', defaultMessage: 'Mask level' },
maxDuration: { id: 'label.max-duration', defaultMessage: 'Max duration' },
retentionDays: { id: 'label.retention-days', defaultMessage: 'Retention days' },
duration: { id: 'label.duration', defaultMessage: 'Duration' },
recordedAt: { id: 'label.recorded-at', defaultMessage: 'Recorded at' },
noRecordings: { id: 'label.no-recordings', defaultMessage: 'No recordings' },
strict: { id: 'label.strict', defaultMessage: 'Strict' },
moderate: { id: 'label.moderate', defaultMessage: 'Moderate' },
relaxed: { id: 'label.relaxed', defaultMessage: 'Relaxed' },
blockSelector: { id: 'label.block-selector', defaultMessage: 'Block selector' },
});
export const messages = defineMessages({

View file

@ -136,6 +136,10 @@ export async function resetWebsite(websiteId: string) {
return transaction(
async tx => {
await tx.sessionRecording.deleteMany({
where: { websiteId },
});
await tx.revenue.deleteMany({
where: { websiteId },
});
@ -183,6 +187,10 @@ export async function deleteWebsite(websiteId: string) {
return transaction(
async tx => {
await tx.sessionRecording.deleteMany({
where: { websiteId },
});
await tx.revenue.deleteMany({
where: { websiteId },
});

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';

View file

@ -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 },
});
}

View file

@ -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,
},
});
}

View file

@ -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,
);
}

View file

@ -0,0 +1,4 @@
export * from './deleteRecordingsByWebsite';
export * from './getRecordingChunks';
export * from './getSessionRecordings';
export * from './saveRecordingChunk';

View file

@ -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,
},
});
}

171
src/recorder/index.js Normal file
View file

@ -0,0 +1,171 @@
import { record } from 'rrweb';
(window => {
const { document } = window;
const { currentScript } = document;
if (!currentScript) return;
const _data = 'data-';
const attr = currentScript.getAttribute.bind(currentScript);
const website = attr(`${_data}website-id`);
const hostUrl = attr(`${_data}host-url`);
const sampleRate = parseFloat(attr(`${_data}sample-rate`) || '1');
const maskLevel = attr(`${_data}mask-level`) || 'moderate';
const maxDuration = parseInt(attr(`${_data}max-duration`) || '600000', 10);
const blockSelector = attr(`${_data}block-selector`) || '';
if (!website) return;
// Sample rate check
if (sampleRate < 1 && Math.random() > sampleRate) return;
const host =
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
const endpoint = `${host.replace(/\/$/, '')}__COLLECT_RECORDING_ENDPOINT__`;
const FLUSH_EVENT_COUNT = 50;
const FLUSH_INTERVAL = 10000;
let eventBuffer = [];
let stopFn = null;
let flushTimer = null;
let startTime = null;
let stopped = false;
const sendEvents = (events, useKeepalive = false) => {
const session = window.umami?.getSession?.();
if (!session?.cache) return;
const body = JSON.stringify({
website,
events,
timestamp: Math.floor(Date.now() / 1000),
});
// keepalive has a 64KB body limit — only use it for small payloads on unload
const keepalive = useKeepalive && body.length < 60000;
return fetch(endpoint, {
keepalive,
method: 'POST',
body,
headers: {
'Content-Type': 'application/json',
'x-umami-cache': session.cache,
},
credentials: 'omit',
}).catch(() => {});
};
const flush = (useKeepalive = false) => {
if (!eventBuffer.length) return;
const events = eventBuffer;
eventBuffer = [];
sendEvents(events, useKeepalive);
};
const scheduleFlush = () => {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flush, FLUSH_INTERVAL);
};
const stop = () => {
if (stopped) return;
stopped = true;
if (flushTimer) clearTimeout(flushTimer);
flush();
if (stopFn) stopFn();
};
const getMaskConfig = level => {
switch (level) {
case 'strict':
return {
maskAllInputs: true,
maskTextContent: true,
maskAllText: true,
};
case 'relaxed':
return {
maskAllInputs: true,
maskTextContent: false,
maskAllText: false,
};
default:
return {
maskAllInputs: true,
maskTextContent: false,
maskAllText: false,
};
}
};
const waitForSession = (attempts = 0) => {
if (attempts > 50) return;
const session = window.umami?.getSession?.();
if (session?.cache) {
beginRecording();
} else {
setTimeout(() => waitForSession(attempts + 1), 100);
}
};
const beginRecording = () => {
startTime = Date.now();
const maskConfig = getMaskConfig(maskLevel);
stopFn = record({
emit(event) {
if (stopped) return;
if (Date.now() - startTime > maxDuration) {
stop();
return;
}
eventBuffer.push(event);
if (eventBuffer.length >= FLUSH_EVENT_COUNT) {
flush();
}
scheduleFlush();
},
...maskConfig,
inlineStylesheet: true,
slimDOMOptions: {
script: true,
comment: true,
headMetaDescKeywords: true,
headMetaSocial: true,
headMetaRobots: true,
headMetaHttpEquiv: true,
headMetaAuthorship: true,
headMetaVerification: true,
},
recordCanvas: false,
recordCrossOriginIframes: false,
checkoutEveryNms: 30000,
...(blockSelector && { blockSelector }),
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush(true);
});
window.addEventListener('beforeunload', () => flush(true));
};
if (document.readyState === 'complete') {
waitForSession();
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') waitForSession();
});
}
})(window);

View file

@ -225,6 +225,7 @@
window.umami = {
track,
identify,
getSession: () => ({ cache, website }),
};
}