From 72b5c658e2388ac51136d8247b9b407d89ce75d2 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 6 Feb 2026 15:49:59 -0800 Subject: [PATCH] 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 --- .gitignore | 1 + package.json | 7 +- pnpm-lock.yaml | 78 +++++++- .../18_add_session_recording/migration.sql | 25 +++ prisma/schema.prisma | 41 ++++- rollup.recorder.config.js | 24 +++ .../console/[websiteId]/TestConsolePage.tsx | 5 + .../recordings/RecordingsDataTable.tsx | 15 ++ .../[websiteId]/recordings/RecordingsPage.tsx | 16 ++ .../recordings/RecordingsTable.tsx | 66 +++++++ .../[sessionId]/RecordingPlayback.tsx | 47 +++++ .../[sessionId]/RecordingPlayer.tsx | 77 ++++++++ .../recordings/[sessionId]/page.tsx | 16 ++ .../websites/[websiteId]/recordings/page.tsx | 12 ++ .../[websiteId]/sessions/SessionProfile.tsx | 12 +- .../settings/WebsiteRecordingSettings.tsx | 96 ++++++++++ .../[websiteId]/settings/WebsiteSettings.tsx | 4 + .../settings/WebsiteTrackingCode.tsx | 19 +- src/app/api/record/route.ts | 104 +++++++++++ .../recordings/[sessionId]/route.ts | 41 +++++ .../websites/[websiteId]/recordings/route.ts | 35 ++++ src/app/api/websites/[websiteId]/route.ts | 20 +- src/components/hooks/index.ts | 2 + .../hooks/queries/useRecordingQuery.ts | 13 ++ .../hooks/queries/useRecordingsQuery.ts | 25 +++ src/components/hooks/useWebsiteNavItems.tsx | 7 + src/components/messages.ts | 15 ++ src/queries/prisma/website.ts | 8 + src/queries/sql/index.ts | 4 + .../recordings/deleteRecordingsByWebsite.ts | 11 ++ .../sql/recordings/getRecordingChunks.ts | 24 +++ .../sql/recordings/getSessionRecordings.ts | 67 +++++++ src/queries/sql/recordings/index.ts | 4 + .../sql/recordings/saveRecordingChunk.ts | 39 ++++ src/recorder/index.js | 171 ++++++++++++++++++ src/tracker/index.js | 1 + 36 files changed, 1131 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/18_add_session_recording/migration.sql create mode 100644 rollup.recorder.config.js create mode 100644 src/app/(main)/websites/[websiteId]/recordings/RecordingsDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/RecordingsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/RecordingsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayback.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/[sessionId]/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/recordings/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteRecordingSettings.tsx create mode 100644 src/app/api/record/route.ts create mode 100644 src/app/api/websites/[websiteId]/recordings/[sessionId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/recordings/route.ts create mode 100644 src/components/hooks/queries/useRecordingQuery.ts create mode 100644 src/components/hooks/queries/useRecordingsQuery.ts create mode 100644 src/queries/sql/recordings/deleteRecordingsByWebsite.ts create mode 100644 src/queries/sql/recordings/getRecordingChunks.ts create mode 100644 src/queries/sql/recordings/getSessionRecordings.ts create mode 100644 src/queries/sql/recordings/index.ts create mode 100644 src/queries/sql/recordings/saveRecordingChunk.ts create mode 100644 src/recorder/index.js 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" /> + `; + 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: