mirror of
https://github.com/umami-software/umami.git
synced 2026-02-09 07:07:17 +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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ package-lock.json
|
|||
# production
|
||||
/build
|
||||
/public/script.js
|
||||
/public/recorder.js
|
||||
/geo
|
||||
/dist
|
||||
/generated
|
||||
|
|
|
|||
|
|
@ -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
78
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
25
prisma/migrations/18_add_session_recording/migration.sql
Normal file
25
prisma/migrations/18_add_session_recording/migration.sql
Normal 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");
|
||||
|
|
@ -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
24
rollup.recorder.config.js
Normal 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 } }),
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
13
src/components/hooks/queries/useRecordingQuery.ts
Normal file
13
src/components/hooks/queries/useRecordingQuery.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
25
src/components/hooks/queries/useRecordingsQuery.ts
Normal file
25
src/components/hooks/queries/useRecordingsQuery.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
11
src/queries/sql/recordings/deleteRecordingsByWebsite.ts
Normal file
11
src/queries/sql/recordings/deleteRecordingsByWebsite.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
24
src/queries/sql/recordings/getRecordingChunks.ts
Normal file
24
src/queries/sql/recordings/getRecordingChunks.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
67
src/queries/sql/recordings/getSessionRecordings.ts
Normal file
67
src/queries/sql/recordings/getSessionRecordings.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
4
src/queries/sql/recordings/index.ts
Normal file
4
src/queries/sql/recordings/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './deleteRecordingsByWebsite';
|
||||
export * from './getRecordingChunks';
|
||||
export * from './getSessionRecordings';
|
||||
export * from './saveRecordingChunk';
|
||||
39
src/queries/sql/recordings/saveRecordingChunk.ts
Normal file
39
src/queries/sql/recordings/saveRecordingChunk.ts
Normal 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
171
src/recorder/index.js
Normal 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);
|
||||
|
|
@ -225,6 +225,7 @@
|
|||
window.umami = {
|
||||
track,
|
||||
identify,
|
||||
getSession: () => ({ cache, website }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue