mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 00:27:11 +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
|
# production
|
||||||
/build
|
/build
|
||||||
/public/script.js
|
/public/script.js
|
||||||
|
/public/recorder.js
|
||||||
/geo
|
/geo
|
||||||
/dist
|
/dist
|
||||||
/generated
|
/generated
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3001 --turbo",
|
"dev": "next dev -p 3002 --turbo",
|
||||||
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
|
"build": "npm-run-all check-env build-db check-db build-tracker build-recorder build-geo build-app",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
"build-docker": "npm-run-all build-db build-tracker build-geo build-app",
|
||||||
"start-docker": "npm-run-all check-db update-tracker start-server",
|
"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-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
||||||
"build-components": "tsup",
|
"build-components": "tsup",
|
||||||
"build-tracker": "rollup -c rollup.tracker.config.js",
|
"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-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-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
|
||||||
"build-geo": "node scripts/build-geo.js",
|
"build-geo": "node scripts/build-geo.js",
|
||||||
|
|
@ -117,6 +118,8 @@
|
||||||
"react-use-measure": "^2.0.4",
|
"react-use-measure": "^2.0.4",
|
||||||
"react-window": "^1.8.6",
|
"react-window": "^1.8.6",
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
|
"rrweb": "2.0.0-alpha.4",
|
||||||
|
"rrweb-player": "1.0.0-alpha.4",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serialize-error": "^12.0.0",
|
"serialize-error": "^12.0.0",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
|
|
|
||||||
78
pnpm-lock.yaml
generated
78
pnpm-lock.yaml
generated
|
|
@ -176,6 +176,12 @@ importers:
|
||||||
request-ip:
|
request-ip:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 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:
|
semver:
|
||||||
specifier: ^7.7.4
|
specifier: ^7.7.4
|
||||||
version: 7.7.4
|
version: 7.7.4
|
||||||
|
|
@ -328,8 +334,6 @@ importers:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
dist: {}
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
|
|
@ -2648,6 +2652,9 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@rrweb/types@2.0.0-alpha.20':
|
||||||
|
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.8':
|
'@sinclair/typebox@0.27.8':
|
||||||
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
||||||
|
|
||||||
|
|
@ -2782,6 +2789,9 @@ packages:
|
||||||
'@tsconfig/node16@1.0.4':
|
'@tsconfig/node16@1.0.4':
|
||||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
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':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
|
|
@ -2794,6 +2804,9 @@ packages:
|
||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
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':
|
'@types/estree@0.0.50':
|
||||||
resolution: {integrity: sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==}
|
resolution: {integrity: sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==}
|
||||||
|
|
||||||
|
|
@ -2927,6 +2940,9 @@ packages:
|
||||||
'@vue/shared@3.5.18':
|
'@vue/shared@3.5.18':
|
||||||
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
|
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
|
||||||
|
|
||||||
|
'@xstate/fsm@1.6.5':
|
||||||
|
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
||||||
|
|
||||||
acorn-walk@8.3.4:
|
acorn-walk@8.3.4:
|
||||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
@ -3110,6 +3126,10 @@ packages:
|
||||||
balanced-match@2.0.0:
|
balanced-match@2.0.0:
|
||||||
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
|
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:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
|
|
@ -3991,6 +4011,9 @@ packages:
|
||||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||||
engines: {node: ^12.20 || >= 14.13}
|
engines: {node: ^12.20 || >= 14.13}
|
||||||
|
|
||||||
|
fflate@0.4.8:
|
||||||
|
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||||
|
|
||||||
figures@3.2.0:
|
figures@3.2.0:
|
||||||
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
|
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -5168,6 +5191,9 @@ packages:
|
||||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
mitt@3.0.1:
|
||||||
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
mkdirp@1.0.4:
|
mkdirp@1.0.4:
|
||||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -6330,6 +6356,18 @@ packages:
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
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:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
|
|
@ -9835,6 +9873,8 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@rrweb/types@2.0.0-alpha.20': {}
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.8': {}
|
'@sinclair/typebox@0.27.8': {}
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.40': {}
|
'@sinclair/typebox@0.34.40': {}
|
||||||
|
|
@ -9977,6 +10017,8 @@ snapshots:
|
||||||
|
|
||||||
'@tsconfig/node16@1.0.4': {}
|
'@tsconfig/node16@1.0.4': {}
|
||||||
|
|
||||||
|
'@tsconfig/svelte@1.0.13': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.28.3
|
'@babel/parser': 7.28.3
|
||||||
|
|
@ -9998,6 +10040,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.2
|
'@babel/types': 7.28.2
|
||||||
|
|
||||||
|
'@types/css-font-loading-module@0.0.7': {}
|
||||||
|
|
||||||
'@types/estree@0.0.50': {}
|
'@types/estree@0.0.50': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
@ -10173,6 +10217,8 @@ snapshots:
|
||||||
|
|
||||||
'@vue/shared@3.5.18': {}
|
'@vue/shared@3.5.18': {}
|
||||||
|
|
||||||
|
'@xstate/fsm@1.6.5': {}
|
||||||
|
|
||||||
acorn-walk@8.3.4:
|
acorn-walk@8.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|
@ -10381,6 +10427,8 @@ snapshots:
|
||||||
|
|
||||||
balanced-match@2.0.0: {}
|
balanced-match@2.0.0: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
baseline-browser-mapping@2.9.19: {}
|
||||||
|
|
@ -11431,6 +11479,8 @@ snapshots:
|
||||||
node-domexception: 1.0.0
|
node-domexception: 1.0.0
|
||||||
web-streams-polyfill: 3.3.3
|
web-streams-polyfill: 3.3.3
|
||||||
|
|
||||||
|
fflate@0.4.8: {}
|
||||||
|
|
||||||
figures@3.2.0:
|
figures@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 1.0.5
|
escape-string-regexp: 1.0.5
|
||||||
|
|
@ -12815,6 +12865,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
||||||
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mkdirp@1.0.4: {}
|
mkdirp@1.0.4: {}
|
||||||
|
|
||||||
mlly@1.8.0:
|
mlly@1.8.0:
|
||||||
|
|
@ -14092,6 +14144,28 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.57.1
|
'@rollup/rollup-win32-x64-msvc': 4.57.1
|
||||||
fsevents: 2.3.3
|
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:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
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)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
user User? @relation("user", fields: [userId], references: [id])
|
recordingEnabled Boolean @default(false) @map("recording_enabled")
|
||||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
recordingConfig Json? @map("recording_config")
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
|
||||||
eventData EventData[]
|
user User? @relation("user", fields: [userId], references: [id])
|
||||||
reports Report[]
|
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||||
revenue Revenue[]
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
segments Segment[]
|
eventData EventData[]
|
||||||
sessionData SessionData[]
|
reports Report[]
|
||||||
|
revenue Revenue[]
|
||||||
|
segments Segment[]
|
||||||
|
sessionData SessionData[]
|
||||||
|
sessionRecordings SessionRecording[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
|
|
@ -350,3 +354,24 @@ model Share {
|
||||||
@@index([entityId])
|
@@index([entityId])
|
||||||
@@map("share")
|
@@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`}
|
src={`${process.env.basePath || ''}/script.js`}
|
||||||
data-cache="true"
|
data-cache="true"
|
||||||
/>
|
/>
|
||||||
|
<Script
|
||||||
|
async
|
||||||
|
data-website-id={websiteId}
|
||||||
|
src={`${process.env.basePath || ''}/recorder.js`}
|
||||||
|
/>
|
||||||
<Panel>
|
<Panel>
|
||||||
<Grid columns="1fr 1fr 1fr" gap>
|
<Grid columns="1fr 1fr 1fr" gap>
|
||||||
<Column 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,
|
TextField,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { RecordingPlayer } from '@/app/(main)/websites/[websiteId]/recordings/[sessionId]/RecordingPlayer';
|
||||||
import { Avatar } from '@/components/common/Avatar';
|
import { Avatar } from '@/components/common/Avatar';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
|
import { useMessages, useRecordingQuery, useWebsiteSessionQuery } from '@/components/hooks';
|
||||||
import { SessionActivity } from './SessionActivity';
|
import { SessionActivity } from './SessionActivity';
|
||||||
import { SessionData } from './SessionData';
|
import { SessionData } from './SessionData';
|
||||||
import { SessionInfo } from './SessionInfo';
|
import { SessionInfo } from './SessionInfo';
|
||||||
|
|
@ -28,6 +29,7 @@ export function SessionProfile({
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
|
const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
|
||||||
|
const { data: recording } = useRecordingQuery(websiteId, sessionId);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,6 +65,9 @@ export function SessionProfile({
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||||
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
||||||
|
{recording?.events?.length > 0 && (
|
||||||
|
<Tab id="recording">{formatMessage(labels.recording)}</Tab>
|
||||||
|
)}
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="activity">
|
<TabPanel id="activity">
|
||||||
<SessionActivity
|
<SessionActivity
|
||||||
|
|
@ -75,6 +80,11 @@ export function SessionProfile({
|
||||||
<TabPanel id="properties">
|
<TabPanel id="properties">
|
||||||
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
<SessionData sessionId={sessionId} websiteId={websiteId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
{recording?.events?.length > 0 && (
|
||||||
|
<TabPanel id="recording">
|
||||||
|
<RecordingPlayer events={recording.events} />
|
||||||
|
</TabPanel>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Column>
|
</Column>
|
||||||
</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 { Panel } from '@/components/common/Panel';
|
||||||
import { WebsiteData } from './WebsiteData';
|
import { WebsiteData } from './WebsiteData';
|
||||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||||
|
import { WebsiteRecordingSettings } from './WebsiteRecordingSettings';
|
||||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||||
|
|
||||||
|
|
@ -14,6 +15,9 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
|
||||||
<Panel>
|
<Panel>
|
||||||
<WebsiteTrackingCode websiteId={websiteId} />
|
<WebsiteTrackingCode websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<WebsiteRecordingSettings websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
<WebsiteShareForm websiteId={websiteId} />
|
<WebsiteShareForm websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Column, Label, Text, TextField } from '@umami/react-zen';
|
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 SCRIPT_NAME = 'script.js';
|
||||||
|
const RECORDER_NAME = 'recorder.js';
|
||||||
|
|
||||||
export function WebsiteTrackingCode({
|
export function WebsiteTrackingCode({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -12,23 +13,29 @@ export function WebsiteTrackingCode({
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, messages, labels } = useMessages();
|
const { formatMessage, messages, labels } = useMessages();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const website = useWebsite();
|
||||||
|
|
||||||
const trackerScriptName =
|
const trackerScriptName =
|
||||||
config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
|
config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
|
||||||
|
|
||||||
const getUrl = () => {
|
const getUrl = (scriptName: string) => {
|
||||||
if (config?.cloudMode) {
|
if (config?.cloudMode) {
|
||||||
return `${process.env.cloudUrl}/${trackerScriptName}`;
|
return `${process.env.cloudUrl}/${scriptName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${hostUrl || window?.location?.origin || ''}${
|
return `${hostUrl || window?.location?.origin || ''}${
|
||||||
process.env.basePath || ''
|
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 (
|
return (
|
||||||
<Column gap>
|
<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(),
|
name: z.string().optional(),
|
||||||
domain: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
shareId: z.string().max(50).nullable().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);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
@ -51,14 +62,19 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
const { name, domain, shareId } = body;
|
const { name, domain, shareId, recordingEnabled, recordingConfig } = body;
|
||||||
|
|
||||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const website = await updateWebsite(websiteId, { name, domain });
|
const website = await updateWebsite(websiteId, {
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
...(recordingEnabled !== undefined && { recordingEnabled }),
|
||||||
|
...(recordingConfig !== undefined && { recordingConfig }),
|
||||||
|
});
|
||||||
|
|
||||||
if (shareId === null) {
|
if (shareId === null) {
|
||||||
await deleteSharesByEntityId(website.id);
|
await deleteSharesByEntityId(website.id);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export * from './queries/useLoginQuery';
|
||||||
export * from './queries/usePixelQuery';
|
export * from './queries/usePixelQuery';
|
||||||
export * from './queries/usePixelsQuery';
|
export * from './queries/usePixelsQuery';
|
||||||
export * from './queries/useRealtimeQuery';
|
export * from './queries/useRealtimeQuery';
|
||||||
|
export * from './queries/useRecordingQuery';
|
||||||
|
export * from './queries/useRecordingsQuery';
|
||||||
export * from './queries/useReportQuery';
|
export * from './queries/useReportQuery';
|
||||||
export * from './queries/useReportsQuery';
|
export * from './queries/useReportsQuery';
|
||||||
export * from './queries/useResultQuery';
|
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,
|
Tag,
|
||||||
User,
|
User,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Video,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
|
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
|
||||||
import { useMessages } from './useMessages';
|
import { useMessages } from './useMessages';
|
||||||
|
|
@ -94,6 +95,12 @@ export function useWebsiteNavItems(websiteId: string) {
|
||||||
icon: <Magnet />,
|
icon: <Magnet />,
|
||||||
path: renderPath('/retention'),
|
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' },
|
support: { id: 'label.support', defaultMessage: 'Support' },
|
||||||
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
|
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
|
||||||
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
|
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({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,10 @@ export async function resetWebsite(websiteId: string) {
|
||||||
|
|
||||||
return transaction(
|
return transaction(
|
||||||
async tx => {
|
async tx => {
|
||||||
|
await tx.sessionRecording.deleteMany({
|
||||||
|
where: { websiteId },
|
||||||
|
});
|
||||||
|
|
||||||
await tx.revenue.deleteMany({
|
await tx.revenue.deleteMany({
|
||||||
where: { websiteId },
|
where: { websiteId },
|
||||||
});
|
});
|
||||||
|
|
@ -183,6 +187,10 @@ export async function deleteWebsite(websiteId: string) {
|
||||||
|
|
||||||
return transaction(
|
return transaction(
|
||||||
async tx => {
|
async tx => {
|
||||||
|
await tx.sessionRecording.deleteMany({
|
||||||
|
where: { websiteId },
|
||||||
|
});
|
||||||
|
|
||||||
await tx.revenue.deleteMany({
|
await tx.revenue.deleteMany({
|
||||||
where: { websiteId },
|
where: { websiteId },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ export * from './getWeeklyTraffic';
|
||||||
export * from './pageviews/getPageviewExpandedMetrics';
|
export * from './pageviews/getPageviewExpandedMetrics';
|
||||||
export * from './pageviews/getPageviewMetrics';
|
export * from './pageviews/getPageviewMetrics';
|
||||||
export * from './pageviews/getPageviewStats';
|
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/getBreakdown';
|
||||||
export * from './reports/getFunnel';
|
export * from './reports/getFunnel';
|
||||||
export * from './reports/getJourney';
|
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 = {
|
window.umami = {
|
||||||
track,
|
track,
|
||||||
identify,
|
identify,
|
||||||
|
getSession: () => ({ cache, website }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue