diff --git a/.gitignore b/.gitignore
index fcb577bad..0bcc359bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ package-lock.json
# production
/build
/public/script.js
+/public/recorder.js
/geo
/dist
/generated
diff --git a/package.json b/package.json
index 48b5481d0..01077c90b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 480d489dd..f77f9076f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/prisma/migrations/18_add_session_recording/migration.sql b/prisma/migrations/18_add_session_recording/migration.sql
new file mode 100644
index 000000000..830892716
--- /dev/null
+++ b/prisma/migrations/18_add_session_recording/migration.sql
@@ -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");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6e456a675..273e67fb7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
+}
diff --git a/rollup.recorder.config.js b/rollup.recorder.config.js
new file mode 100644
index 000000000..7662e4218
--- /dev/null
+++ b/rollup.recorder.config.js
@@ -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 } }),
+ ],
+};
diff --git a/src/app/(main)/console/[websiteId]/TestConsolePage.tsx b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx
index 56cc49523..979ffb862 100644
--- a/src/app/(main)/console/[websiteId]/TestConsolePage.tsx
+++ b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx
@@ -116,6 +116,11 @@ export function TestConsolePage({ websiteId }: { websiteId: string }) {
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
/>
+
diff --git a/src/app/(main)/websites/[websiteId]/recordings/RecordingsDataTable.tsx b/src/app/(main)/websites/[websiteId]/recordings/RecordingsDataTable.tsx
new file mode 100644
index 000000000..a19f6b1a3
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/RecordingsDataTable.tsx
@@ -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 (
+
+ {({ data }) => {
+ return ;
+ }}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/recordings/RecordingsPage.tsx b/src/app/(main)/websites/[websiteId]/recordings/RecordingsPage.tsx
new file mode 100644
index 000000000..824e2ea71
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/RecordingsPage.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/recordings/RecordingsTable.tsx b/src/app/(main)/websites/[websiteId]/recordings/RecordingsTable.tsx
new file mode 100644
index 000000000..2416f9fbe
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/RecordingsTable.tsx
@@ -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 (
+
+
+ {(row: any) => }
+
+
+ {(row: any) => formatDuration(row.duration || 0)}
+
+
+
+ {(row: any) => (
+
+ {formatValue(row.country, 'country')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.browser, 'browser')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.os, 'os')}
+
+ )}
+
+
+ {(row: any) => }
+
+
+ {(row: any) => (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayback.tsx b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayback.tsx
new file mode 100644
index 000000000..92496ddd2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayback.tsx
@@ -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 (
+
+ {recording && (
+
+ {session && (
+
+
+
+ {formatMessage(labels.recording)}
+
+ {recording.eventCount} {formatMessage(labels.events).toLowerCase()}
+
+
+
+ )}
+
+ {session && }
+
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer.tsx b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer.tsx
new file mode 100644
index 000000000..06a97f416
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer.tsx
@@ -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(null);
+ const playerRef = useRef(null);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ if (!containerRef.current || !events?.length) return;
+
+ // Debug: log event info
+ const typeCounts: Record = {};
+ 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 (
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/page.tsx b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/page.tsx
new file mode 100644
index 000000000..253c7c4f6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/[sessionId]/page.tsx
@@ -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 ;
+}
+
+export const metadata: Metadata = {
+ title: 'Recording Playback',
+};
diff --git a/src/app/(main)/websites/[websiteId]/recordings/page.tsx b/src/app/(main)/websites/[websiteId]/recordings/page.tsx
new file mode 100644
index 000000000..8cd74b0c2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/recordings/page.tsx
@@ -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 ;
+}
+
+export const metadata: Metadata = {
+ title: 'Recordings',
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
index 6624d439d..a74cfa018 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
@@ -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({
{formatMessage(labels.activity)}
{formatMessage(labels.properties)}
+ {recording?.events?.length > 0 && (
+ {formatMessage(labels.recording)}
+ )}
+ {recording?.events?.length > 0 && (
+
+
+
+ )}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteRecordingSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteRecordingSettings.tsx
new file mode 100644
index 000000000..3af73220a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteRecordingSettings.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
index d39c45315..33734582a 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -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
+
+
+
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
index d24f9485d..71fc407fe 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
@@ -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 = ``;
+ let code = ``;
+
+ if (website?.recordingEnabled) {
+ const recorderUrl = getUrl(RECORDER_NAME);
+ code += `\n`;
+ }
return (
diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts
new file mode 100644
index 000000000..96f39a4e4
--- /dev/null
+++ b/src/app/api/record/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/websites/[websiteId]/recordings/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/recordings/[sessionId]/route.ts
new file mode 100644
index 000000000..07671f122
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/recordings/[sessionId]/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/websites/[websiteId]/recordings/route.ts b/src/app/api/websites/[websiteId]/recordings/route.ts
new file mode 100644
index 000000000..4b2babdc9
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/recordings/route.ts
@@ -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);
+}
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
index 1443a541b..a148c168d 100644
--- a/src/app/api/websites/[websiteId]/route.ts
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -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);
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index b92d4ed82..beb1a6859 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -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';
diff --git a/src/components/hooks/queries/useRecordingQuery.ts b/src/components/hooks/queries/useRecordingQuery.ts
new file mode 100644
index 000000000..bbbab19c0
--- /dev/null
+++ b/src/components/hooks/queries/useRecordingQuery.ts
@@ -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),
+ });
+}
diff --git a/src/components/hooks/queries/useRecordingsQuery.ts b/src/components/hooks/queries/useRecordingsQuery.ts
new file mode 100644
index 000000000..3b4a0ef0d
--- /dev/null
+++ b/src/components/hooks/queries/useRecordingsQuery.ts
@@ -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) {
+ 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,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/useWebsiteNavItems.tsx b/src/components/hooks/useWebsiteNavItems.tsx
index eb43eea49..e208f673c 100644
--- a/src/components/hooks/useWebsiteNavItems.tsx
+++ b/src/components/hooks/useWebsiteNavItems.tsx
@@ -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: ,
path: renderPath('/retention'),
},
+ {
+ id: 'recordings',
+ label: formatMessage(labels.recordings),
+ icon: ,
+ path: renderPath('/recordings'),
+ },
],
},
{
diff --git a/src/components/messages.ts b/src/components/messages.ts
index e2a92112d..9c98370c2 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -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({
diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts
index 4460be624..9caf0abcf 100644
--- a/src/queries/prisma/website.ts
+++ b/src/queries/prisma/website.ts
@@ -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 },
});
diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts
index 1573bdefd..74ac57e54 100644
--- a/src/queries/sql/index.ts
+++ b/src/queries/sql/index.ts
@@ -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';
diff --git a/src/queries/sql/recordings/deleteRecordingsByWebsite.ts b/src/queries/sql/recordings/deleteRecordingsByWebsite.ts
new file mode 100644
index 000000000..931067ee0
--- /dev/null
+++ b/src/queries/sql/recordings/deleteRecordingsByWebsite.ts
@@ -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 },
+ });
+}
diff --git a/src/queries/sql/recordings/getRecordingChunks.ts b/src/queries/sql/recordings/getRecordingChunks.ts
new file mode 100644
index 000000000..7549a5567
--- /dev/null
+++ b/src/queries/sql/recordings/getRecordingChunks.ts
@@ -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,
+ },
+ });
+}
diff --git a/src/queries/sql/recordings/getSessionRecordings.ts b/src/queries/sql/recordings/getSessionRecordings.ts
new file mode 100644
index 000000000..5647304a0
--- /dev/null
+++ b/src/queries/sql/recordings/getSessionRecordings.ts
@@ -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,
+ );
+}
diff --git a/src/queries/sql/recordings/index.ts b/src/queries/sql/recordings/index.ts
new file mode 100644
index 000000000..87a8fabba
--- /dev/null
+++ b/src/queries/sql/recordings/index.ts
@@ -0,0 +1,4 @@
+export * from './deleteRecordingsByWebsite';
+export * from './getRecordingChunks';
+export * from './getSessionRecordings';
+export * from './saveRecordingChunk';
diff --git a/src/queries/sql/recordings/saveRecordingChunk.ts b/src/queries/sql/recordings/saveRecordingChunk.ts
new file mode 100644
index 000000000..f73e5385f
--- /dev/null
+++ b/src/queries/sql/recordings/saveRecordingChunk.ts
@@ -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,
+ },
+ });
+}
diff --git a/src/recorder/index.js b/src/recorder/index.js
new file mode 100644
index 000000000..c9c678944
--- /dev/null
+++ b/src/recorder/index.js
@@ -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);
diff --git a/src/tracker/index.js b/src/tracker/index.js
index 85d274301..23a26d068 100644
--- a/src/tracker/index.js
+++ b/src/tracker/index.js
@@ -225,6 +225,7 @@
window.umami = {
track,
identify,
+ getSession: () => ({ cache, website }),
};
}