mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Merge branch 'dev' into pnpm-11
This commit is contained in:
@@ -15,14 +15,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
version: 10.15.1
|
||||
run_install: false
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: npm install --global pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
|
||||
+4
-2
@@ -1,4 +1,5 @@
|
||||
ARG NODE_IMAGE_VERSION="22-alpine"
|
||||
ARG PNPM_VERSION="10.15.1"
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM node:${NODE_IMAGE_VERSION} AS deps
|
||||
@@ -33,7 +34,6 @@ RUN npm run build-docker
|
||||
FROM node:${NODE_IMAGE_VERSION} AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ARG PRISMA_VERSION="7.3.0"
|
||||
ARG NODE_OPTIONS
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -71,6 +71,8 @@ COPY --from=builder /app/generated ./generated
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
RUN rm -rf /app/node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
@@ -79,4 +81,4 @@ EXPOSE 3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["pnpm", "start-docker"]
|
||||
CMD ["npm", "run", "start-docker"]
|
||||
|
||||
@@ -10,6 +10,9 @@ CREATE TABLE umami.heatmap_event
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
page_x Nullable(Int32),
|
||||
page_y Nullable(Int32),
|
||||
page_w Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
@@ -23,3 +26,25 @@ ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Create heatmap_snapshot
|
||||
CREATE TABLE umami.heatmap_snapshot
|
||||
(
|
||||
snapshot_id UUID,
|
||||
website_id UUID,
|
||||
url_path String,
|
||||
viewport_w UInt32,
|
||||
viewport_h UInt32,
|
||||
page_w UInt32,
|
||||
page_h UInt32,
|
||||
status UInt8,
|
||||
mime_type LowCardinality(String),
|
||||
object_key String,
|
||||
image_size Nullable(UInt32),
|
||||
error Nullable(String),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, viewport_w, viewport_h, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
@@ -412,6 +412,9 @@ CREATE TABLE umami.heatmap_event
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
page_x Nullable(Int32),
|
||||
page_y Nullable(Int32),
|
||||
page_w Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
@@ -425,3 +428,25 @@ ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Create heatmap_snapshot
|
||||
CREATE TABLE umami.heatmap_snapshot
|
||||
(
|
||||
snapshot_id UUID,
|
||||
website_id UUID,
|
||||
url_path String,
|
||||
viewport_w UInt32,
|
||||
viewport_h UInt32,
|
||||
page_w UInt32,
|
||||
page_h UInt32,
|
||||
status UInt8,
|
||||
mime_type LowCardinality(String),
|
||||
object_key String,
|
||||
image_size Nullable(UInt32),
|
||||
error Nullable(String),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, viewport_w, viewport_h, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' ');
|
||||
|
||||
const contentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
img-src 'self' https: data:;
|
||||
img-src 'self' https: data: blob:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src ${connectSrc};
|
||||
|
||||
+12
-11
@@ -41,6 +41,7 @@
|
||||
"postbuild": "node scripts/postbuild.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"install-heatmap": "playwright install chromium",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"seed-data": "tsx scripts/seed-data.ts",
|
||||
@@ -52,6 +53,7 @@
|
||||
".next/cache"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.924.0",
|
||||
"@clickhouse/client": "^1.18.5",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
"@dicebear/collection": "^9.4.2",
|
||||
@@ -60,8 +62,9 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@prisma/extension-read-replicas": "^0.5.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@umami/react-zen": "^0.245.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -71,7 +74,7 @@
|
||||
"colord": "^2.9.2",
|
||||
"cors": "^2.8.6",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns": "^4.2.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"debug": "^4.4.3",
|
||||
"del": "^8.0.1",
|
||||
@@ -89,13 +92,13 @@
|
||||
"kafkajs": "^2.1.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"maxmind": "^5.0.5",
|
||||
"motion": "^12.38.0",
|
||||
"motion": "^12.39.0",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.9.2",
|
||||
"next-intl": "4.12.0",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.20.0",
|
||||
"pg": "^8.21.0",
|
||||
"prisma": "^7.8.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"pure-rand": "^8.4.0",
|
||||
@@ -122,7 +125,6 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@netlify/plugin-nextjs": "^5.15.11",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rollup/plugin-alias": "^6.0.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
@@ -133,21 +135,20 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.14.6",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-flexbugs-fixes": "^5.0.2",
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"postcss-preset-env": "11.3.0",
|
||||
"prompts": "2.4.2",
|
||||
"rollup": "^4.60.3",
|
||||
"rollup": "^4.60.4",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"rollup-plugin-delete": "^3.0.2",
|
||||
"rollup-plugin-dts": "^6.4.1",
|
||||
@@ -158,7 +159,7 @@
|
||||
"ts-morph": "^28.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.22.0",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="node" />
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = process.env.PORT ?? '3000';
|
||||
|
||||
Generated
+1094
-2186
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,9 @@
|
||||
ALTER TABLE "website" RENAME COLUMN "replay_enabled" TO "recorder_enabled";
|
||||
|
||||
UPDATE "website"
|
||||
SET "replay_config" = COALESCE("replay_config", '{}'::jsonb) || '{"replayEnabled": true}'::jsonb
|
||||
WHERE "recorder_enabled" = true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_event" (
|
||||
"heatmap_event_id" UUID NOT NULL,
|
||||
@@ -9,6 +15,9 @@ CREATE TABLE "heatmap_event" (
|
||||
"node_id" INTEGER,
|
||||
"x" INTEGER,
|
||||
"y" INTEGER,
|
||||
"page_x" INTEGER,
|
||||
"page_y" INTEGER,
|
||||
"page_w" INTEGER,
|
||||
"viewport_w" INTEGER,
|
||||
"viewport_h" INTEGER,
|
||||
"page_h" INTEGER,
|
||||
@@ -27,3 +36,30 @@ CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
|
||||
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_event_index_idx" ON "heatmap_event"("website_id", "visit_id", "replay_chunk_index", "replay_event_index");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_snapshot" (
|
||||
"snapshot_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"url_path" VARCHAR(500) NOT NULL,
|
||||
"viewport_w" INTEGER NOT NULL,
|
||||
"viewport_h" INTEGER NOT NULL,
|
||||
"page_w" INTEGER NOT NULL,
|
||||
"page_h" INTEGER NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"mime_type" VARCHAR(100),
|
||||
"image_data" BYTEA,
|
||||
"image_size" INTEGER,
|
||||
"error" VARCHAR(500),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "heatmap_snapshot_pkey" PRIMARY KEY ("snapshot_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "heatmap_snapshot_website_id_url_path_viewport_w_viewport_h_key"
|
||||
ON "heatmap_snapshot"("website_id", "url_path", "viewport_w", "viewport_h");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_idx" ON "heatmap_snapshot"("website_id");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_url_path_idx" ON "heatmap_snapshot"("website_id", "url_path");
|
||||
CREATE INDEX "heatmap_snapshot_website_id_updated_at_idx" ON "heatmap_snapshot"("website_id", "updated_at");
|
||||
|
||||
+31
-2
@@ -75,8 +75,8 @@ model Website {
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
replayEnabled Boolean @default(false) @map("replay_enabled")
|
||||
replayConfig Json? @map("replay_config")
|
||||
recorderEnabled Boolean @default(false) @map("recorder_enabled")
|
||||
replayConfig Json? @map("replay_config")
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||
@@ -89,6 +89,7 @@ model Website {
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
heatmapEvents HeatmapEvent[]
|
||||
heatmapSnapshots HeatmapSnapshot[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@ -411,6 +412,9 @@ model HeatmapEvent {
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
pageX Int? @map("page_x") @db.Integer
|
||||
pageY Int? @map("page_y") @db.Integer
|
||||
pageW Int? @map("page_w") @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
@@ -429,3 +433,28 @@ model HeatmapEvent {
|
||||
@@index([websiteId, visitId, replayChunkIndex, replayEventIndex])
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
|
||||
model HeatmapSnapshot {
|
||||
id String @id() @map("snapshot_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
viewportW Int @map("viewport_w") @db.Integer
|
||||
viewportH Int @map("viewport_h") @db.Integer
|
||||
pageW Int @map("page_w") @db.Integer
|
||||
pageH Int @map("page_h") @db.Integer
|
||||
status String @db.VarChar(20)
|
||||
mimeType String? @map("mime_type") @db.VarChar(100)
|
||||
imageData Bytes? @map("image_data")
|
||||
imageSize Int? @map("image_size") @db.Integer
|
||||
error String? @db.VarChar(500)
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@unique([websiteId, urlPath, viewportW, viewportH])
|
||||
@@index([websiteId])
|
||||
@@index([websiteId, urlPath])
|
||||
@@index([websiteId, updatedAt])
|
||||
@@map("heatmap_snapshot")
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reprodukuj",
|
||||
"replay": "Ponovna reprodukcija",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kod ponavljanja sesije",
|
||||
"recorder-code": "Kod ponavljanja sesije",
|
||||
"replay-enabled": "Ponovna reprodukcija omogućena",
|
||||
"replays": "Ponovne reprodukcije",
|
||||
"reports": "Izvještaji",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reprodueix",
|
||||
"replay": "Repetició",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Codi de repetició de sessió",
|
||||
"recorder-code": "Codi de repetició de sessió",
|
||||
"replay-enabled": "Repetició activada",
|
||||
"replays": "Repeticions",
|
||||
"reports": "Informes",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Přehrát",
|
||||
"replay": "Přehrávání",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kód přehrání relace",
|
||||
"recorder-code": "Kód přehrání relace",
|
||||
"replay-enabled": "Přehrávání povoleno",
|
||||
"replays": "Přehrávání",
|
||||
"reports": "Hlášení",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Afspil",
|
||||
"replay": "Afspilning",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kode til sessionsgengivelse",
|
||||
"recorder-code": "Kode til sessionsgengivelse",
|
||||
"replay-enabled": "Afspilning aktiveret",
|
||||
"replays": "Afspilninger",
|
||||
"reports": "Rapporter",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Abspielen",
|
||||
"replay": "Wiedergabe",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Session-Replay-Code",
|
||||
"recorder-code": "Session-Replay-Code",
|
||||
"replay-enabled": "Wiedergabe aktiviert",
|
||||
"replays": "Wiedergaben",
|
||||
"reports": "Brichte",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Abspielen",
|
||||
"replay": "Wiedergabe",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Session-Replay-Code",
|
||||
"recorder-code": "Session-Replay-Code",
|
||||
"replay-enabled": "Wiedergabe aktiviert",
|
||||
"replays": "Wiedergaben",
|
||||
"reports": "Berichte",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Play",
|
||||
"replay": "Replay",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Session replay code",
|
||||
"recorder-code": "Recorder code",
|
||||
"replay-enabled": "Replay enabled",
|
||||
"replays": "Replays",
|
||||
"reports": "Reports",
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"play": "Play",
|
||||
"replay": "Replay",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Session replay code",
|
||||
"recorder-code": "Recorder code",
|
||||
"replay-enabled": "Replay enabled",
|
||||
"replays": "Replays",
|
||||
"reports": "Reports",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reproducir",
|
||||
"replay": "Repetición",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Código de repetición de sesión",
|
||||
"recorder-code": "Código de repetición de sesión",
|
||||
"replay-enabled": "Repetición activada",
|
||||
"replays": "Repeticiones",
|
||||
"reports": "Informes",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Toista",
|
||||
"replay": "Toisto",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Istunnon toistokoodi",
|
||||
"recorder-code": "Istunnon toistokoodi",
|
||||
"replay-enabled": "Toisto käytössä",
|
||||
"replays": "Toistot",
|
||||
"reports": "Raportit",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Spæla",
|
||||
"replay": "Uppspæling",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kodi fyri endurspæling av setuni",
|
||||
"recorder-code": "Kodi fyri endurspæling av setuni",
|
||||
"replay-enabled": "Uppspæling virkjað",
|
||||
"replays": "Uppspælingar",
|
||||
"reports": "Frágreiðingar",
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
"remove": "Retirer",
|
||||
"remove-member": "Retirer le membre",
|
||||
"replay": "Relecture",
|
||||
"replay-code": "Code de relecture de session",
|
||||
"recorder-code": "Code de relecture de session",
|
||||
"replay-enabled": "Relecture activée",
|
||||
"replay-id": "Replay ID",
|
||||
"replays": "Relectures",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reproducir",
|
||||
"replay": "Reprodución",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Código de repetición da sesión",
|
||||
"recorder-code": "Código de repetición da sesión",
|
||||
"replay-enabled": "Reprodución activada",
|
||||
"replays": "Reproducións",
|
||||
"reports": "Reportes",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reproduciraj",
|
||||
"replay": "Ponovna reprodukcija",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kôd ponavljanja sesije",
|
||||
"recorder-code": "Kôd ponavljanja sesije",
|
||||
"replay-enabled": "Ponovna reprodukcija omogućena",
|
||||
"replays": "Ponovne reprodukcije",
|
||||
"reports": "Izvješća",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Lejátszás",
|
||||
"replay": "Visszajátszás",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Munkamenet-újrajátszási kód",
|
||||
"recorder-code": "Munkamenet-újrajátszási kód",
|
||||
"replay-enabled": "Visszajátszás engedélyezve",
|
||||
"replays": "Visszajátszások",
|
||||
"reports": "Jelentések",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Putar",
|
||||
"replay": "Pemutaran ulang",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kode putar ulang sesi",
|
||||
"recorder-code": "Kode putar ulang sesi",
|
||||
"replay-enabled": "Pemutaran ulang diaktifkan",
|
||||
"replays": "Pemutaran ulang",
|
||||
"reports": "Laporan",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Riproduci",
|
||||
"replay": "Replay",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Codice replay sessione",
|
||||
"recorder-code": "Codice replay sessione",
|
||||
"replay-enabled": "Replay abilitato",
|
||||
"replays": "Replay",
|
||||
"reports": "Report",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Leisti",
|
||||
"replay": "Pakartojimas",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Sesijos pakartojimo kodas",
|
||||
"recorder-code": "Sesijos pakartojimo kodas",
|
||||
"replay-enabled": "Pakartojimas įjungtas",
|
||||
"replays": "Pakartojimai",
|
||||
"reports": "Ataskaitos",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Main",
|
||||
"replay": "Main semula",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kod main semula sesi",
|
||||
"recorder-code": "Kod main semula sesi",
|
||||
"replay-enabled": "Main semula diaktifkan",
|
||||
"replays": "Main semula",
|
||||
"reports": "Laporan",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Spill av",
|
||||
"replay": "Avspilling",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kode for øktavspilling",
|
||||
"recorder-code": "Kode for øktavspilling",
|
||||
"replay-enabled": "Avspilling aktivert",
|
||||
"replays": "Avspillinger",
|
||||
"reports": "Rapporter",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Afspelen",
|
||||
"replay": "Herhaling",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Sessieherhalingscode",
|
||||
"recorder-code": "Sessieherhalingscode",
|
||||
"replay-enabled": "Herhaling ingeschakeld",
|
||||
"replays": "Herhalingen",
|
||||
"reports": "Rapporten",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Odtwórz",
|
||||
"replay": "Odtwarzanie",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kod odtworzenia sesji",
|
||||
"recorder-code": "Kod odtworzenia sesji",
|
||||
"replay-enabled": "Odtwarzanie włączone",
|
||||
"replays": "Odtwarzania",
|
||||
"reports": "Raporty",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reproduzir",
|
||||
"replay": "Reprodução",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Código de replay da sessão",
|
||||
"recorder-code": "Código de replay da sessão",
|
||||
"replay-enabled": "Reprodução habilitada",
|
||||
"replays": "Reproduções",
|
||||
"reports": "Relatórios",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Reproduzir",
|
||||
"replay": "Reprodução",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Código de repetição de sessão",
|
||||
"recorder-code": "Código de repetição de sessão",
|
||||
"replay-enabled": "Reprodução ativada",
|
||||
"replays": "Reproduções",
|
||||
"reports": "Relatórios",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Redă",
|
||||
"replay": "Reluare",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Cod de reluare sesiune",
|
||||
"recorder-code": "Cod de reluare sesiune",
|
||||
"replay-enabled": "Reluare activată",
|
||||
"replays": "Reluări",
|
||||
"reports": "Rapoarte",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Prehrať",
|
||||
"replay": "Prehrávanie",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kód prehrania relácie",
|
||||
"recorder-code": "Kód prehrania relácie",
|
||||
"replay-enabled": "Prehrávanie povolené",
|
||||
"replays": "Prehrávanie",
|
||||
"reports": "Správy",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Predvajaj",
|
||||
"replay": "Predvajanje",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Koda ponovitve seje",
|
||||
"recorder-code": "Koda ponovitve seje",
|
||||
"replay-enabled": "Predvajanje omogočeno",
|
||||
"replays": "Predvajanja",
|
||||
"reports": "Poročila",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Spela upp",
|
||||
"replay": "Uppspelning",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Kod för sessionsuppspelning",
|
||||
"recorder-code": "Kod för sessionsuppspelning",
|
||||
"replay-enabled": "Uppspelning aktiverad",
|
||||
"replays": "Uppspelningar",
|
||||
"reports": "Rapporter",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Oynat",
|
||||
"replay": "Tekrar oynatma",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Oturum tekrar oynatma kodu",
|
||||
"recorder-code": "Oturum tekrar oynatma kodu",
|
||||
"replay-enabled": "Tekrar oynatma etkin",
|
||||
"replays": "Tekrar oynatmalar",
|
||||
"reports": "Raporlar",
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
"play": "Ijro etish",
|
||||
"replay": "Qayta ijro",
|
||||
"replay-id": "Replay ID",
|
||||
"replay-code": "Sessiya takrorlash kodi",
|
||||
"recorder-code": "Sessiya takrorlash kodi",
|
||||
"replay-enabled": "Qayta ijro yoqilgan",
|
||||
"replays": "Qayta ijrolar",
|
||||
"reports": "Hisobotlar",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.layoutGrid {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pageList {
|
||||
@@ -78,6 +78,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.snapshotMessage {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.scrollBand {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -87,6 +91,7 @@
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 2px 8px;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.scrollBandLabel {
|
||||
@@ -102,6 +107,11 @@
|
||||
.canvasWrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.snapshotControlRow {
|
||||
@@ -114,7 +124,7 @@
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvasClip {
|
||||
@@ -125,29 +135,30 @@
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.snapshotClip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-wrapper) {
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot :global(iframe) {
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-mouse),
|
||||
.snapshot :global(.replayer-mouse-tail) {
|
||||
display: none !important;
|
||||
.snapshotImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
|
||||
@@ -1,49 +1,21 @@
|
||||
'use client';
|
||||
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useResultQuery } from '@/components/hooks';
|
||||
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
|
||||
import { getClientAuthToken } from '@/lib/client';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
||||
import styles from './Heatmap.module.css';
|
||||
import 'rrweb/dist/replay/rrweb-replay.css';
|
||||
|
||||
const MAX_RENDER_WIDTH = 1024;
|
||||
|
||||
function useElementWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setWidth(el.getBoundingClientRect().width || el.clientWidth || 0);
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
interface ReplayData {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
interface ReplayInstance {
|
||||
iframe?: HTMLIFrameElement;
|
||||
wrapper?: HTMLDivElement;
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
pause: (timeOffset?: number) => void;
|
||||
disableInteract: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
const CLICK_EDGE_PERCENT = 1.5;
|
||||
const SCROLL_BUCKET_SIZE = 10;
|
||||
|
||||
interface ViewportBucket {
|
||||
width: number;
|
||||
height: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -138,16 +110,14 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
{urlPath ? (
|
||||
mode === 'scroll' ? (
|
||||
<ScrollHeatmapView
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
scroll={scroll}
|
||||
snapshot={snapshot}
|
||||
isLoading={detailLoading}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapView
|
||||
<ClickHeatmapView
|
||||
urlPath={urlPath}
|
||||
websiteId={websiteId}
|
||||
points={points}
|
||||
snapshot={snapshot}
|
||||
isLoading={detailLoading}
|
||||
@@ -180,17 +150,19 @@ function PageList({
|
||||
<Heading size="lg">Pages</Heading>
|
||||
<Column className={styles.pageListItems} gap="2">
|
||||
{pages.length === 0 && hasSearch && <Text color="muted">No matching pages</Text>}
|
||||
{pages.map(p => (
|
||||
{pages.map(page => (
|
||||
<button
|
||||
key={p.urlPath}
|
||||
key={page.urlPath}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.urlPath)}
|
||||
title={p.urlPath}
|
||||
className={`${styles.pageButton} ${selected === p.urlPath ? styles.pageButtonSelected : ''}`}
|
||||
onClick={() => onSelect(page.urlPath)}
|
||||
title={page.urlPath}
|
||||
className={`${styles.pageButton} ${selected === page.urlPath ? styles.pageButtonSelected : ''}`}
|
||||
>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="2">
|
||||
<Text truncate>{p.urlPath}</Text>
|
||||
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
|
||||
<Text truncate>{page.urlPath}</Text>
|
||||
<Text color="muted">
|
||||
{formatLongNumber(mode === 'scroll' ? page.sessions : page.count)}
|
||||
</Text>
|
||||
</Row>
|
||||
</button>
|
||||
))}
|
||||
@@ -200,72 +172,105 @@ function PageList({
|
||||
}
|
||||
|
||||
function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
||||
if (!points.length) return null;
|
||||
const buckets = new Map<string, ViewportBucket>();
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
if (!points.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewportBuckets = new Map<
|
||||
string,
|
||||
ViewportBucket & { maxPageW: number; maxPageH: number }
|
||||
>();
|
||||
|
||||
for (const point of points) {
|
||||
const viewportKey = `${point.viewportW}x${point.viewportH}`;
|
||||
const existing = viewportBuckets.get(viewportKey);
|
||||
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
existing.count += point.count;
|
||||
existing.maxPageW = Math.max(existing.maxPageW, point.pageW);
|
||||
existing.maxPageH = Math.max(existing.maxPageH, point.pageH);
|
||||
continue;
|
||||
}
|
||||
|
||||
viewportBuckets.set(viewportKey, {
|
||||
width: point.viewportW,
|
||||
height: point.viewportH,
|
||||
pageW: point.pageW,
|
||||
pageH: point.pageH,
|
||||
count: point.count,
|
||||
maxPageW: point.pageW,
|
||||
maxPageH: point.pageH,
|
||||
});
|
||||
}
|
||||
|
||||
let best: (ViewportBucket & { maxPageW: number; maxPageH: number }) | null = null;
|
||||
|
||||
for (const bucket of viewportBuckets.values()) {
|
||||
if (!best || bucket.count > best.count) {
|
||||
best = bucket;
|
||||
}
|
||||
}
|
||||
let best: ViewportBucket | null = null;
|
||||
for (const b of buckets.values()) {
|
||||
if (!best || b.count > best.count) best = b;
|
||||
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
return best;
|
||||
|
||||
return {
|
||||
width: best.width,
|
||||
height: best.height,
|
||||
pageW: best.maxPageW,
|
||||
pageH: best.maxPageH,
|
||||
count: best.count,
|
||||
};
|
||||
}
|
||||
|
||||
function HeatmapView({
|
||||
function ClickHeatmapView({
|
||||
urlPath,
|
||||
websiteId,
|
||||
points,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
urlPath: string;
|
||||
websiteId: string;
|
||||
points: HeatmapPoint[];
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
|
||||
const viewport = useMemo(() => pickViewport(points), [points]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!viewport) return [];
|
||||
return points.filter(p => p.viewportW === viewport.width && p.viewportH === viewport.height);
|
||||
if (!viewport) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return points.filter(
|
||||
point => point.viewportW === viewport.width && point.viewportH === viewport.height,
|
||||
);
|
||||
}, [points, viewport]);
|
||||
|
||||
const maxCount = useMemo(
|
||||
() => visible.reduce((m, p) => (p.count > m ? p.count : m), 1),
|
||||
() => visible.reduce((max, point) => (point.count > max ? point.count : max), 1),
|
||||
[visible],
|
||||
);
|
||||
|
||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
const hasSnapshotImage = Boolean(snapshot?.imageUrl);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
}
|
||||
|
||||
if (!viewport || visible.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewport.width, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewport.width;
|
||||
const renderHeight = Math.round(viewport.height * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
setSnapshotReady(!(showPage && hasSnapshotImage));
|
||||
}, [hasSnapshotImage, showPage, snapshot?.id]);
|
||||
const overlayGutter = Math.max(48, Math.round((viewport?.width ?? 1920) * 0.04));
|
||||
const maxPointX = visible.reduce((max, point) => Math.max(max, point.pageX), 0);
|
||||
const maxPointY = visible.reduce((max, point) => Math.max(max, point.pageY), 0);
|
||||
const baseWidth = Math.max(snapshot?.pageW ?? 0, viewport?.pageW ?? 0, maxPointX + overlayGutter, 1200);
|
||||
const baseHeight = Math.max(snapshot?.pageH ?? 0, viewport?.pageH ?? 0, maxPointY + overlayGutter, 640);
|
||||
const overlayPageW = snapshot?.pageW ?? viewport?.pageW ?? baseWidth;
|
||||
const overlayPageH = snapshot?.pageH ?? viewport?.pageH ?? baseHeight;
|
||||
const showSnapshot = baseWidth > 0 && showPage && hasSnapshotImage;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
const totalClicks = visible.reduce((sum, point) => sum + point.count, 0);
|
||||
const showLoading = isLoading;
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
@@ -275,56 +280,91 @@ function HeatmapView({
|
||||
{urlPath}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryStats}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '}
|
||||
clicks · viewport {viewport.width}×{viewport.height}
|
||||
</Text>
|
||||
</Row>
|
||||
{showLoading ? (
|
||||
<Row alignItems="center" gap className={styles.summaryStats}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
Loading Heatmap...
|
||||
</Text>
|
||||
</Row>
|
||||
) : (
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryStats}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{viewport
|
||||
? `${visible.length} positions - ${formatLongNumber(totalClicks)} clicks - viewport ${viewport.width}x${viewport.height}`
|
||||
: 'No click data for this page yet.'}
|
||||
</Text>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
|
||||
{showPage && snapshot?.status === 'failed' && (
|
||||
<Text color="muted" className={styles.snapshotMessage}>
|
||||
{snapshot.error || 'Page screenshot unavailable.'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: baseWidth || '100%',
|
||||
aspectRatio: `${Math.max(1, baseWidth)} / ${Math.max(1, baseHeight)}`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.canvasClip}>
|
||||
{showLoading ? (
|
||||
<CanvasLoading />
|
||||
) : !viewport || visible.length === 0 ? (
|
||||
<EmptyState message="No click data for this page yet." />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.snapshotClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{visible.map((p, i) => {
|
||||
const intensity = Math.min(1, p.count / maxCount);
|
||||
const size = 24 + intensity * 36;
|
||||
return (
|
||||
<div
|
||||
key={`${p.x}-${p.y}-${i}`}
|
||||
className={styles.dot}
|
||||
style={{
|
||||
left: p.x * scale - size / 2,
|
||||
top: p.y * scale - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: 0.25 + intensity * 0.55,
|
||||
}}
|
||||
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showSnapshot && snapshot?.imageUrl && <SnapshotImage snapshot={snapshot} onReady={handleSnapshotReady} />}
|
||||
</div>
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{visible.map((point, index) => {
|
||||
const intensity = Math.min(1, point.count / maxCount);
|
||||
const desiredSize = 24 + intensity * 36;
|
||||
const pointWidth = Math.max(overlayPageW, point.pageX);
|
||||
const pointHeight = Math.max(overlayPageH, point.pageY);
|
||||
const rawCenterX = (point.pageX / Math.max(1, pointWidth)) * 100;
|
||||
const rawCenterY = (point.pageY / Math.max(1, pointHeight)) * 100;
|
||||
const size = desiredSize;
|
||||
const centerX = Math.max(
|
||||
CLICK_EDGE_PERCENT,
|
||||
Math.min(100 - CLICK_EDGE_PERCENT, rawCenterX),
|
||||
);
|
||||
const centerY = Math.max(
|
||||
CLICK_EDGE_PERCENT,
|
||||
Math.min(100 - CLICK_EDGE_PERCENT, rawCenterY),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${point.pageX}-${point.pageY}-${index}`}
|
||||
className={styles.dot}
|
||||
style={{
|
||||
left: `${centerX}%`,
|
||||
top: `${centerY}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
opacity: 0.25 + intensity * 0.55,
|
||||
}}
|
||||
title={`${point.count} click${point.count === 1 ? '' : 's'}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{snapshot && (
|
||||
|
||||
{hasSnapshotImage && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
@@ -337,65 +377,50 @@ function HeatmapView({
|
||||
|
||||
function ScrollHeatmapView({
|
||||
urlPath,
|
||||
websiteId,
|
||||
scroll,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
urlPath: string;
|
||||
websiteId: string;
|
||||
scroll: HeatmapResult['scroll'] | undefined;
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
|
||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
const hasSnapshotImage = Boolean(snapshot?.imageUrl);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (isLoading) {
|
||||
return <CanvasLoading />;
|
||||
}
|
||||
|
||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
||||
return <EmptyState message="No scroll data for this page yet." />;
|
||||
}
|
||||
|
||||
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
|
||||
const renderWidth = Math.min(containerWidth > 0 ? containerWidth : viewportW, MAX_RENDER_WIDTH);
|
||||
const scale = renderWidth / viewportW;
|
||||
const renderHeight = Math.round(pageH * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
setSnapshotReady(!(showPage && hasSnapshotImage));
|
||||
}, [hasSnapshotImage, showPage, snapshot?.id]);
|
||||
const { buckets = [], totalSessions = 0, pageW = 0, pageH = 0, viewportW = 0, viewportH = 0 } =
|
||||
scroll ?? {};
|
||||
const baseWidth = Math.max(snapshot?.pageW ?? 0, pageW, 1200);
|
||||
const baseHeight = Math.max(snapshot?.pageH ?? 0, pageH, 640);
|
||||
const showSnapshot = baseWidth > 0 && showPage && hasSnapshotImage;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
const hasScrollData = Boolean(scroll && totalSessions > 0 && pageW && pageH && viewportW);
|
||||
const showLoading = isLoading;
|
||||
|
||||
// Cumulative reach: % of sessions that scrolled at least to depth D.
|
||||
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
|
||||
let remaining = totalSessions;
|
||||
const cumulative = sortedBuckets.map(b => {
|
||||
const reached = remaining;
|
||||
remaining -= b.sessions;
|
||||
return { depth: b.depth, reached, ratio: reached / totalSessions };
|
||||
});
|
||||
|
||||
// Build page-spanning bands. Each band covers a vertical slice of the page.
|
||||
// Intensity = fraction of sessions reaching the band's TOP edge (everyone who
|
||||
// got that far saw at least the start of the slice).
|
||||
type Band = { fromPct: number; toPct: number; reached: number; ratio: number };
|
||||
const bands: Band[] = [];
|
||||
const firstDepth = cumulative[0]?.depth ?? 100;
|
||||
if (firstDepth > 0) {
|
||||
bands.push({ fromPct: 0, toPct: firstDepth, reached: totalSessions, ratio: 1 });
|
||||
}
|
||||
for (let i = 0; i < cumulative.length; i++) {
|
||||
const c = cumulative[i];
|
||||
const toPct = cumulative[i + 1]?.depth ?? 100;
|
||||
if (c.depth < toPct) {
|
||||
bands.push({ fromPct: c.depth, toPct, reached: c.reached, ratio: c.ratio });
|
||||
const sessionsByDepth = new Map(buckets.map(bucket => [bucket.depth, bucket.sessions]));
|
||||
let dropped = 0;
|
||||
|
||||
for (let depth = 0; depth < 100; depth += SCROLL_BUCKET_SIZE) {
|
||||
const reached = Math.max(0, totalSessions - dropped);
|
||||
dropped += sessionsByDepth.get(depth) ?? 0;
|
||||
const nextReached = Math.max(0, totalSessions - dropped);
|
||||
const ratio = totalSessions ? nextReached / totalSessions : 0;
|
||||
|
||||
if (reached > 0) {
|
||||
bands.push({
|
||||
fromPct: depth,
|
||||
toPct: Math.min(100, depth + SCROLL_BUCKET_SIZE),
|
||||
reached: nextReached,
|
||||
ratio,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,60 +429,76 @@ function ScrollHeatmapView({
|
||||
<Text color="muted" title={urlPath} className={styles.summaryPath}>
|
||||
{urlPath}
|
||||
</Text>
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryHeader}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH}
|
||||
{viewportH ? ` · viewport ${viewportH}` : ''}
|
||||
{showLoading ? (
|
||||
<Row alignItems="center" gap className={styles.summaryStats}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
Loading Heatmap...
|
||||
</Text>
|
||||
</Row>
|
||||
) : (
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.summaryHeader}>
|
||||
<Text color="muted" className={styles.summaryStat}>
|
||||
{hasScrollData
|
||||
? `${formatLongNumber(totalSessions)} sessions - page ${pageW}x${pageH}${viewportH ? ` - viewport ${viewportW}x${viewportH}` : ''}`
|
||||
: 'No scroll data for this page yet.'}
|
||||
</Text>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{showPage && snapshot?.status === 'failed' && (
|
||||
<Text color="muted" className={styles.snapshotMessage}>
|
||||
{snapshot.error || 'Page screenshot unavailable.'}
|
||||
</Text>
|
||||
</Row>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
)}
|
||||
|
||||
<div className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: baseWidth || '100%',
|
||||
aspectRatio: `${Math.max(1, baseWidth)} / ${Math.max(1, baseHeight)}`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.canvasClip}>
|
||||
{showLoading ? (
|
||||
<CanvasLoading />
|
||||
) : !hasScrollData ? (
|
||||
<EmptyState message="No scroll data for this page yet." />
|
||||
) : (
|
||||
<div className={styles.canvasClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewportW}
|
||||
height={pageH}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showSnapshot && snapshot?.imageUrl && <SnapshotImage snapshot={snapshot} onReady={handleSnapshotReady} />}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{bands.map(b => {
|
||||
const top = Math.round((b.fromPct / 100) * renderHeight);
|
||||
const bottom = Math.round((b.toPct / 100) * renderHeight);
|
||||
const height = Math.max(0, bottom - top);
|
||||
const intensity = b.ratio;
|
||||
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
|
||||
return (
|
||||
<div
|
||||
key={b.fromPct}
|
||||
className={styles.scrollBand}
|
||||
style={{
|
||||
top,
|
||||
height,
|
||||
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
|
||||
}}
|
||||
title={`${b.fromPct}–${b.toPct}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
|
||||
>
|
||||
<span className={styles.scrollBandLabel}>
|
||||
{b.fromPct}% · {Math.round(intensity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{bands.map(band => {
|
||||
const intensity = band.ratio;
|
||||
const hue = Math.round(60 - intensity * 60);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={band.fromPct}
|
||||
className={styles.scrollBand}
|
||||
style={{
|
||||
top: `${band.fromPct}%`,
|
||||
height: `${Math.max(0, band.toPct - band.fromPct)}%`,
|
||||
background: intensity > 0 ? `hsla(${hue}, 90%, 55%, ${0.12 + intensity * 0.45})` : 'none',
|
||||
}}
|
||||
title={`${band.toPct}% depth • ${formatLongNumber(band.reached)} sessions reached`}
|
||||
>
|
||||
<span className={styles.scrollBandLabel}>
|
||||
{band.toPct}% depth • {Math.round(intensity * 100)}% reached
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{snapshot && (
|
||||
|
||||
{hasSnapshotImage && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
@@ -468,136 +509,68 @@ function ScrollHeatmapView({
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaySnapshot({
|
||||
websiteId,
|
||||
function SnapshotImage({
|
||||
snapshot,
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
onReady,
|
||||
}: {
|
||||
websiteId: string;
|
||||
snapshot: HeatmapSnapshot;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
onReady: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const replayerRef = useRef<ReplayInstance | null>(null);
|
||||
const { data } = useReplayQuery(websiteId, snapshot.replayId, {
|
||||
until: snapshot.timestamp,
|
||||
chunkIndex: snapshot.chunkIndex,
|
||||
eventIndex: snapshot.eventIndex,
|
||||
}) as { data?: ReplayData };
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const events = data?.events;
|
||||
if (!container || !events?.length) return;
|
||||
if (!snapshot.imageUrl) {
|
||||
setSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const token = getClientAuthToken();
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
import('rrweb').then(({ Replayer }) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
setSrc(null);
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const replayer = new Replayer(events, {
|
||||
root: container,
|
||||
showWarning: false,
|
||||
mouseTail: false,
|
||||
triggerFocus: false,
|
||||
pauseAnimation: true,
|
||||
useVirtualDom: false,
|
||||
loadTimeout: 3000,
|
||||
}) as ReplayInstance;
|
||||
|
||||
replayerRef.current = replayer;
|
||||
let rebuilt = false;
|
||||
let waitingForStyles = false;
|
||||
let settled = false;
|
||||
|
||||
const freeze = () => {
|
||||
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
replayer.pause(offset);
|
||||
replayer.disableInteract();
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (settled || waitingForStyles || !rebuilt || cancelled) {
|
||||
return;
|
||||
fetch(snapshot.imageUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot image request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
settled = true;
|
||||
|
||||
await waitForReplayLayout(replayer);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
await waitForAnimationFrames(2);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
freeze();
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
setSrc(null);
|
||||
onReady();
|
||||
};
|
||||
|
||||
replayer.on('load-stylesheet-start', () => {
|
||||
waitingForStyles = true;
|
||||
});
|
||||
|
||||
replayer.on('load-stylesheet-end', () => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
});
|
||||
|
||||
replayer.on('fullsnapshot-rebuilded', () => {
|
||||
rebuilt = true;
|
||||
void finalize();
|
||||
});
|
||||
|
||||
replayer.on('resize', () => {
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
waitingForStyles = false;
|
||||
void finalize();
|
||||
}, 3500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (replayerRef.current) {
|
||||
replayerRef.current.destroy();
|
||||
replayerRef.current = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
controller.abort();
|
||||
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [data?.events, height, onReady, snapshot.timestamp, width]);
|
||||
}, [snapshot.id, snapshot.imageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replayerRef.current) {
|
||||
resizeReplayFrame(replayerRef.current, width, height);
|
||||
}
|
||||
}, [height, width]);
|
||||
const handleLoad = useCallback(() => onReady(), [onReady]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.snapshot}
|
||||
style={{ width, height, transform: `scale(${scale})` }}
|
||||
/>
|
||||
<div className={styles.snapshot}>
|
||||
<img
|
||||
className={styles.snapshotImage}
|
||||
src={src || undefined}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -609,83 +582,6 @@ function CanvasLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
function waitForAnimationFrames(count = 2) {
|
||||
return new Promise<void>(resolve => {
|
||||
const step = (remaining: number) => {
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(remaining - 1));
|
||||
};
|
||||
|
||||
step(count);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReplayLayout(replayer: ReplayInstance) {
|
||||
const fonts = replayer.iframe?.contentDocument?.fonts;
|
||||
|
||||
if (fonts?.ready) {
|
||||
try {
|
||||
await Promise.race([
|
||||
fonts.ready.then(() => undefined),
|
||||
new Promise(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
} catch {
|
||||
// Ignore font readiness failures and fall back to frame settling.
|
||||
}
|
||||
}
|
||||
|
||||
await waitForAnimationFrames(3);
|
||||
}
|
||||
|
||||
function syncReplayDocumentViewport(replayer: ReplayInstance, width: number, height: number) {
|
||||
const doc = replayer.iframe?.contentDocument;
|
||||
const html = doc?.documentElement;
|
||||
const body = doc?.body;
|
||||
|
||||
if (html) {
|
||||
html.style.margin = '0';
|
||||
}
|
||||
|
||||
if (body) {
|
||||
body.style.margin = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function resizeReplayFrame(replayer: ReplayInstance, width: number, height: number) {
|
||||
const { iframe, wrapper } = replayer;
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
wrapper.style.minWidth = `${width}px`;
|
||||
wrapper.style.minHeight = `${height}px`;
|
||||
wrapper.style.maxWidth = `${width}px`;
|
||||
wrapper.style.maxHeight = `${height}px`;
|
||||
wrapper.style.margin = '0';
|
||||
wrapper.style.padding = '0';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
if (iframe) {
|
||||
iframe.setAttribute('width', String(width));
|
||||
iframe.setAttribute('height', String(height));
|
||||
iframe.style.width = `${width}px`;
|
||||
iframe.style.height = `${height}px`;
|
||||
iframe.style.minWidth = `${width}px`;
|
||||
iframe.style.minHeight = `${height}px`;
|
||||
iframe.style.maxWidth = `${width}px`;
|
||||
iframe.style.maxHeight = `${height}px`;
|
||||
iframe.style.margin = '0';
|
||||
iframe.style.display = 'block';
|
||||
}
|
||||
|
||||
syncReplayDocumentViewport(replayer, width, height);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<Column alignItems="center" justifyContent="center" minHeight="360px" gap>
|
||||
|
||||
@@ -10,48 +10,59 @@ import {
|
||||
Text,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { useMessages, useSubscription, useUpdateQuery, useWebsite } from '@/components/hooks';
|
||||
import { Video } from '@/components/icons';
|
||||
import { getRecorderConfig, type RecorderConfig } from '@/lib/recorder';
|
||||
|
||||
const RECORDER_NAME = 'recorder.js';
|
||||
|
||||
interface ReplayConfig {
|
||||
sampleRate?: number;
|
||||
maskLevel?: string;
|
||||
maxDuration?: number;
|
||||
blockSelector?: string;
|
||||
}
|
||||
|
||||
export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
|
||||
const website = useWebsite();
|
||||
const { t, labels, messages } = useMessages();
|
||||
const { hasFeature, cloudMode } = useSubscription(website?.teamId);
|
||||
const { mutateAsync, touch, toast, isPending } = useUpdateQuery(`/websites/${websiteId}`);
|
||||
const [enabled, setEnabled] = useState(website?.replayEnabled ?? false);
|
||||
|
||||
const config = (website?.replayConfig as ReplayConfig) || {};
|
||||
const config = getRecorderConfig(website?.replayConfig);
|
||||
|
||||
const [replayEnabled, setReplayEnabled] = useState(config.replayEnabled === true);
|
||||
const [heatmapEnabled, setHeatmapEnabled] = useState(config.heatmapEnabled === true);
|
||||
const [sampleRate, setSampleRate] = useState(config.sampleRate ?? 0.15);
|
||||
const [heatmapSampleRate, setHeatmapSampleRate] = useState(config.heatmapSampleRate ?? 0.15);
|
||||
const [maskLevel, setMaskLevel] = useState(config.maskLevel ?? 'moderate');
|
||||
const [maxDuration, setMaxDuration] = useState(String(config.maxDuration ?? 300000));
|
||||
const [blockSelector, setBlockSelector] = useState(config.blockSelector ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setReplayEnabled(config.replayEnabled === true);
|
||||
setHeatmapEnabled(config.heatmapEnabled === true);
|
||||
setSampleRate(config.sampleRate ?? 0.15);
|
||||
setHeatmapSampleRate(config.heatmapSampleRate ?? 0.15);
|
||||
setMaskLevel(config.maskLevel ?? 'moderate');
|
||||
setMaxDuration(String(config.maxDuration ?? 300000));
|
||||
setBlockSelector(config.blockSelector ?? '');
|
||||
}, [
|
||||
config.blockSelector,
|
||||
config.heatmapEnabled,
|
||||
config.heatmapSampleRate,
|
||||
config.maskLevel,
|
||||
config.maxDuration,
|
||||
config.replayEnabled,
|
||||
config.sampleRate,
|
||||
]);
|
||||
|
||||
const recorderUrl = cloudMode
|
||||
? `${process.env.cloudUrl}/${RECORDER_NAME}`
|
||||
: `${window?.location?.origin || ''}${process.env.basePath || ''}/${RECORDER_NAME}`;
|
||||
|
||||
const recorderCode = `<script defer src="${recorderUrl}" data-website-id="${websiteId}"></script>`;
|
||||
const sectionLabel = `${t(labels.replays)} & ${t(labels.heatmaps)}`;
|
||||
|
||||
const handleToggle = async (value: boolean) => {
|
||||
const previous = enabled;
|
||||
setEnabled(value);
|
||||
|
||||
const saveRecorderConfig = async (nextConfig: RecorderConfig, rollback?: () => void) => {
|
||||
try {
|
||||
await mutateAsync(
|
||||
{
|
||||
replayEnabled: value,
|
||||
replayConfig: nextConfig,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
@@ -62,39 +73,48 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
setEnabled(previous);
|
||||
rollback?.();
|
||||
}
|
||||
};
|
||||
|
||||
const getNextConfig = (overrides: Partial<RecorderConfig> = {}): RecorderConfig => ({
|
||||
...config,
|
||||
replayEnabled,
|
||||
heatmapEnabled,
|
||||
sampleRate,
|
||||
heatmapSampleRate,
|
||||
maskLevel,
|
||||
maxDuration: parseInt(maxDuration, 10) || 300000,
|
||||
blockSelector,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const handleReplayToggle = async (value: boolean) => {
|
||||
const previous = replayEnabled;
|
||||
|
||||
setReplayEnabled(value);
|
||||
await saveRecorderConfig(getNextConfig({ replayEnabled: value }), () => setReplayEnabled(previous));
|
||||
};
|
||||
|
||||
const handleHeatmapToggle = async (value: boolean) => {
|
||||
const previous = heatmapEnabled;
|
||||
|
||||
setHeatmapEnabled(value);
|
||||
await saveRecorderConfig(getNextConfig({ heatmapEnabled: value }), () => setHeatmapEnabled(previous));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await mutateAsync(
|
||||
{
|
||||
replayEnabled: enabled,
|
||||
replayConfig: {
|
||||
sampleRate,
|
||||
maskLevel,
|
||||
maxDuration: parseInt(maxDuration, 10) || 300000,
|
||||
...(blockSelector && { blockSelector }),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast(t(messages.saved));
|
||||
touch('websites');
|
||||
touch(`website:${websiteId}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
await saveRecorderConfig(getNextConfig());
|
||||
};
|
||||
|
||||
if (cloudMode && !hasFeature('replays')) {
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Label>{t(labels.replays)}</Label>
|
||||
<Label>{sectionLabel}</Label>
|
||||
<EmptyPlaceholder
|
||||
icon={<Video />}
|
||||
title={t(messages.upgradeRequired, { plan: 'Business' })}
|
||||
description="Watch real user sessions to see exactly how visitors interact with your site."
|
||||
description="Watch real user sessions and build heatmaps from real visitor behavior."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -109,46 +129,73 @@ export function WebsiteReplaySettings({ websiteId }: { websiteId: string }) {
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
<Label>{t(labels.replays)}</Label>
|
||||
<Switch isSelected={enabled} onChange={handleToggle} isDisabled={isPending}>
|
||||
{t(labels.replayEnabled)}
|
||||
<Label>{sectionLabel}</Label>
|
||||
<Switch isSelected={replayEnabled} onChange={handleReplayToggle} isDisabled={isPending}>
|
||||
{t(labels.replays)}
|
||||
</Switch>
|
||||
{enabled && (
|
||||
<Switch isSelected={heatmapEnabled} onChange={handleHeatmapToggle} isDisabled={isPending}>
|
||||
{t(labels.heatmaps)}
|
||||
</Switch>
|
||||
{(replayEnabled || heatmapEnabled) && (
|
||||
<>
|
||||
<Label>{t(labels.replayCode)}</Label>
|
||||
<Label>{t(labels.recorderCode)}</Label>
|
||||
<Text color="muted">{t(messages.trackingCode)}</Text>
|
||||
<TextField value={recorderCode} isReadOnly allowCopy asTextArea resize="none" className="code-textarea" />
|
||||
<Slider
|
||||
label={t(labels.sampleRate)}
|
||||
minValue={0.05}
|
||||
maxValue={1}
|
||||
step={0.05}
|
||||
value={sampleRate}
|
||||
onChange={v => setSampleRate(Array.isArray(v) ? v[0] : v)}
|
||||
showValue
|
||||
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
||||
style={{ maxWidth: '360px' }}
|
||||
<TextField
|
||||
value={recorderCode}
|
||||
isReadOnly
|
||||
allowCopy
|
||||
asTextArea
|
||||
resize="none"
|
||||
className="code-textarea"
|
||||
/>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.maskLevel)}</Label>
|
||||
<Select value={maskLevel} onChange={setMaskLevel} style={{ maxWidth: '360px' }}>
|
||||
<ListItem id="strict">strict</ListItem>
|
||||
<ListItem id="moderate">moderate</ListItem>
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.maxDuration)}</Label>
|
||||
<Select value={maxDuration} onChange={setMaxDuration} style={{ maxWidth: '360px' }}>
|
||||
<ListItem id="300000">5 minutes</ListItem>
|
||||
<ListItem id="600000">10 minutes</ListItem>
|
||||
<ListItem id="900000">15 minutes</ListItem>
|
||||
<ListItem id="1200000">20 minutes</ListItem>
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.blockSelector)}</Label>
|
||||
<TextField value={blockSelector} onChange={setBlockSelector} />
|
||||
</Column>
|
||||
{heatmapEnabled && (
|
||||
<Slider
|
||||
label={`Heatmap ${t(labels.sampleRate).toLowerCase()}`}
|
||||
minValue={0.05}
|
||||
maxValue={1}
|
||||
step={0.05}
|
||||
value={heatmapSampleRate}
|
||||
onChange={v => setHeatmapSampleRate(Array.isArray(v) ? v[0] : v)}
|
||||
showValue
|
||||
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
||||
style={{ maxWidth: '360px' }}
|
||||
/>
|
||||
)}
|
||||
{replayEnabled && (
|
||||
<>
|
||||
<Slider
|
||||
label={`Replay ${t(labels.sampleRate).toLowerCase()}`}
|
||||
minValue={0.05}
|
||||
maxValue={1}
|
||||
step={0.05}
|
||||
value={sampleRate}
|
||||
onChange={v => setSampleRate(Array.isArray(v) ? v[0] : v)}
|
||||
showValue
|
||||
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
||||
style={{ maxWidth: '360px' }}
|
||||
/>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.maskLevel)}</Label>
|
||||
<Select value={maskLevel} onChange={setMaskLevel} style={{ maxWidth: '360px' }}>
|
||||
<ListItem id="strict">strict</ListItem>
|
||||
<ListItem id="moderate">moderate</ListItem>
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.maxDuration)}</Label>
|
||||
<Select value={maxDuration} onChange={setMaxDuration} style={{ maxWidth: '360px' }}>
|
||||
<ListItem id="300000">5 minutes</ListItem>
|
||||
<ListItem id="600000">10 minutes</ListItem>
|
||||
<ListItem id="900000">15 minutes</ListItem>
|
||||
<ListItem id="1200000">20 minutes</ListItem>
|
||||
</Select>
|
||||
</Column>
|
||||
<Column gap="1">
|
||||
<Label>{t(labels.blockSelector)}</Label>
|
||||
<TextField value={blockSelector} onChange={setBlockSelector} />
|
||||
</Column>
|
||||
</>
|
||||
)}
|
||||
<Row>
|
||||
<Button variant="primary" onPress={handleSave} isDisabled={isPending}>
|
||||
{t(labels.save)}
|
||||
|
||||
+116
-38
@@ -5,11 +5,12 @@ import { secret } from '@/lib/crypto';
|
||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||
import { parseToken } from '@/lib/jwt';
|
||||
import { fetchAccount, fetchTeam } from '@/lib/load';
|
||||
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||
import { getRecorderConfig } from '@/lib/recorder';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, forbidden, json, serverError } from '@/lib/response';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
import { saveRecording } from '@/queries/sql';
|
||||
import { extractHeatmapEvents } from '@/queries/sql/heatmap/extractHeatmapEvents';
|
||||
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
||||
|
||||
interface Cache {
|
||||
@@ -17,14 +18,60 @@ interface Cache {
|
||||
visitId: string;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
type: z.literal('record'),
|
||||
payload: z.object({
|
||||
website: z.uuid(),
|
||||
events: z.array(z.any()).max(200),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
const schema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('record'),
|
||||
payload: z.object({
|
||||
website: z.uuid(),
|
||||
events: z.array(z.any()).max(200),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
z.object({
|
||||
type: z.literal('heatmap'),
|
||||
payload: z.object({
|
||||
website: z.uuid(),
|
||||
events: z
|
||||
.array(
|
||||
z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('click'),
|
||||
url: z.string(),
|
||||
x: z.coerce.number().optional(),
|
||||
y: z.coerce.number().optional(),
|
||||
pageX: z.coerce.number().optional(),
|
||||
pageY: z.coerce.number().optional(),
|
||||
pageW: z.coerce.number().optional(),
|
||||
pageH: z.coerce.number().optional(),
|
||||
viewportW: z.coerce.number().optional(),
|
||||
viewportH: z.coerce.number().optional(),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('scroll'),
|
||||
url: z.string(),
|
||||
scrollPct: z.coerce.number().optional(),
|
||||
pageW: z.coerce.number().optional(),
|
||||
pageH: z.coerce.number().optional(),
|
||||
viewportW: z.coerce.number().optional(),
|
||||
viewportH: z.coerce.number().optional(),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.max(200),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
function getUrlPath(url: string) {
|
||||
try {
|
||||
return new URL(url).pathname || '/';
|
||||
} catch {
|
||||
return url.startsWith('/') ? url.split(/[?#]/)[0] || '/' : '/';
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -34,7 +81,9 @@ export async function POST(request: Request) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { website: websiteId, events, timestamp } = body.payload;
|
||||
const { website: websiteId } = body.payload;
|
||||
const events = body.payload.events;
|
||||
const timestamp = body.payload.timestamp;
|
||||
|
||||
if (!events?.length) {
|
||||
return json({ ok: true });
|
||||
@@ -55,15 +104,19 @@ export async function POST(request: Request) {
|
||||
|
||||
const { sessionId, visitId } = cache;
|
||||
|
||||
// Query directly to avoid stale Redis cache for recordingEnabled
|
||||
// Query directly to avoid stale Redis cache for recorderEnabled
|
||||
const website = await getWebsite(websiteId);
|
||||
|
||||
if (!website) {
|
||||
return badRequest({ message: 'Website not found.' });
|
||||
}
|
||||
|
||||
if (!website.replayEnabled) {
|
||||
return json({ ok: false, reason: 'replay_disabled' });
|
||||
const recorderConfig = getRecorderConfig(website.replayConfig);
|
||||
const replayEnabled = recorderConfig.replayEnabled === true;
|
||||
const heatmapEnabled = recorderConfig.heatmapEnabled === true;
|
||||
|
||||
if (!website.recorderEnabled) {
|
||||
return json({ ok: false, reason: 'recorder_disabled' });
|
||||
}
|
||||
|
||||
if (process.env.CLOUD_MODE) {
|
||||
@@ -89,38 +142,63 @@ export async function POST(request: Request) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
// Compute timestamps from events
|
||||
const eventTimestamps = events
|
||||
.map((e: any) => Number(e?.timestamp))
|
||||
.filter((t: number) => Number.isFinite(t) && t > 0);
|
||||
if (body.type === 'record') {
|
||||
if (!replayEnabled) {
|
||||
return json({ ok: false, reason: 'replay_disabled' });
|
||||
}
|
||||
|
||||
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
||||
const minTimestamp = eventTimestamps.length ? Math.min(...eventTimestamps) : fallbackMs;
|
||||
const maxTimestamp = eventTimestamps.length ? Math.max(...eventTimestamps) : fallbackMs;
|
||||
const eventTimestamps = events
|
||||
.map((e: any) => Number(e?.timestamp))
|
||||
.filter((t: number) => Number.isFinite(t) && t > 0);
|
||||
|
||||
const startedAt = new Date(minTimestamp);
|
||||
const endedAt = new Date(maxTimestamp);
|
||||
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
||||
const minTimestamp = eventTimestamps.length ? Math.min(...eventTimestamps) : fallbackMs;
|
||||
const maxTimestamp = eventTimestamps.length ? Math.max(...eventTimestamps) : fallbackMs;
|
||||
|
||||
// Use timestamp-based chunk index for ordering
|
||||
const chunkIndex = timestamp || Math.floor(Date.now() / 1000);
|
||||
const startedAt = new Date(minTimestamp);
|
||||
const endedAt = new Date(maxTimestamp);
|
||||
const chunkIndex = timestamp || Math.floor(Date.now() / 1000);
|
||||
|
||||
await saveRecording({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
chunkIndex,
|
||||
events,
|
||||
eventCount: events.length,
|
||||
startedAt,
|
||||
endedAt,
|
||||
});
|
||||
|
||||
try {
|
||||
const heatmapRows = extractHeatmapEvents(events, { chunkIndex }).map(e => ({
|
||||
await saveRecording({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
...e,
|
||||
chunkIndex,
|
||||
events,
|
||||
eventCount: events.length,
|
||||
startedAt,
|
||||
endedAt,
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (!heatmapEnabled) {
|
||||
return json({ ok: false, reason: 'heatmap_disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
||||
const heatmapRows = events.map(event => ({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
eventType: event.type === 'click' ? HEATMAP_EVENT_TYPE.click : HEATMAP_EVENT_TYPE.scroll,
|
||||
nodeId: null,
|
||||
x: event.type === 'click' ? event.x ?? null : null,
|
||||
y: event.type === 'click' ? event.y ?? null : null,
|
||||
pageX: event.type === 'click' ? event.pageX ?? null : null,
|
||||
pageY: event.type === 'click' ? event.pageY ?? null : null,
|
||||
pageW: event.pageW ?? null,
|
||||
viewportW: event.viewportW ?? null,
|
||||
viewportH: event.viewportH ?? null,
|
||||
pageH: event.pageH ?? null,
|
||||
scrollPct: event.type === 'scroll' ? event.scrollPct ?? null : null,
|
||||
urlPath: getUrlPath(event.url),
|
||||
createdAt: new Date(event.timestamp ?? fallbackMs),
|
||||
replayChunkIndex: null,
|
||||
replayEventIndex: null,
|
||||
replayTimeMs: null,
|
||||
}));
|
||||
|
||||
if (heatmapRows.length) {
|
||||
@@ -128,7 +206,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('heatmap extraction failed', serializeError(e));
|
||||
console.log('heatmap save failed', serializeError(e));
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notFound, unauthorized } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getHeatmapSnapshotImage } from '@/queries/sql/heatmap/ensureHeatmapSnapshot';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string; snapshotId: string }> },
|
||||
) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
const { websiteId, snapshotId } = await params;
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const snapshot = await getHeatmapSnapshotImage(websiteId, snapshotId);
|
||||
|
||||
if (!snapshot) {
|
||||
return notFound({ message: 'Snapshot not found.' });
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(snapshot.imageData), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': snapshot.mimeType,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { getRecorderConfig } from '@/lib/recorder';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
|
||||
interface ReplayConfig {
|
||||
sampleRate?: number;
|
||||
maskLevel?: string;
|
||||
maxDuration?: number;
|
||||
blockSelector?: string;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
@@ -26,16 +19,19 @@ export async function GET(
|
||||
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
|
||||
};
|
||||
|
||||
if (!website || !website.replayEnabled) {
|
||||
if (!website || !website.recorderEnabled) {
|
||||
return Response.json({ enabled: false }, { headers });
|
||||
}
|
||||
|
||||
const config = (website.replayConfig as ReplayConfig) || {};
|
||||
const config = getRecorderConfig(website.replayConfig);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
enabled: true,
|
||||
replayEnabled: config.replayEnabled === true,
|
||||
heatmapEnabled: config.heatmapEnabled === true,
|
||||
sampleRate: config.sampleRate ?? 0.15,
|
||||
heatmapSampleRate: config.heatmapSampleRate ?? 0.15,
|
||||
maskLevel: config.maskLevel ?? 'moderate',
|
||||
maxDuration: config.maxDuration ?? 300000,
|
||||
blockSelector: config.blockSelector ?? '',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getRecorderConfig, getRecorderEnabled } from '@/lib/recorder';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||
@@ -42,10 +44,12 @@ export async function POST(
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
shareId: z.string().max(50).nullable().optional(),
|
||||
replayEnabled: z.boolean().optional(),
|
||||
replayConfig: z
|
||||
.object({
|
||||
replayEnabled: z.boolean().optional(),
|
||||
heatmapEnabled: z.boolean().optional(),
|
||||
sampleRate: z.number().min(0).max(1).optional(),
|
||||
heatmapSampleRate: z.number().min(0).max(1).optional(),
|
||||
maskLevel: z.enum(['strict', 'moderate']).optional(),
|
||||
maxDuration: z.number().int().positive().optional(),
|
||||
blockSelector: z.string().optional(),
|
||||
@@ -61,18 +65,35 @@ export async function POST(
|
||||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { name, domain, shareId, replayEnabled, replayConfig } = body;
|
||||
const { name, domain, shareId, replayConfig } = body;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const currentWebsite = await getWebsite(websiteId);
|
||||
|
||||
if (!currentWebsite) {
|
||||
return badRequest({ message: 'Website not found.' });
|
||||
}
|
||||
|
||||
const nextReplayConfig = getRecorderConfig(
|
||||
replayConfig === null
|
||||
? {}
|
||||
: {
|
||||
...getRecorderConfig(currentWebsite.replayConfig),
|
||||
...(replayConfig ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
const website = await updateWebsite(websiteId, {
|
||||
name,
|
||||
domain,
|
||||
...(replayEnabled !== undefined && { replayEnabled }),
|
||||
...(replayConfig !== undefined && { replayConfig }),
|
||||
...(replayConfig !== undefined && {
|
||||
replayConfig: nextReplayConfig as Prisma.InputJsonObject,
|
||||
recorderEnabled: getRecorderEnabled(nextReplayConfig),
|
||||
}),
|
||||
});
|
||||
|
||||
if (shareId === null) {
|
||||
|
||||
@@ -13,7 +13,7 @@ ChartJS.defaults.font.family = 'Inter';
|
||||
|
||||
export interface ChartProps extends BoxProps {
|
||||
type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
|
||||
chartData?: ChartData & { focusLabel?: string };
|
||||
chartData?: ChartData<any, any, unknown> & { focusLabel?: string };
|
||||
chartOptions?: ChartOptions;
|
||||
updateMode?: UpdateMode;
|
||||
animationDuration?: number;
|
||||
@@ -66,8 +66,6 @@ export function Chart({
|
||||
|
||||
const handleLegendClick = (item: LegendItem) => {
|
||||
if (onLegendClick && type === 'bar') {
|
||||
// Controlled mode: caller owns the hidden state. We report the click
|
||||
// and let the parent push a new hiddenLabels set on the next render.
|
||||
const { datasetIndex } = item;
|
||||
const ds = chart.current.data.datasets[datasetIndex];
|
||||
onLegendClick(ds.label, !hiddenLabels?.has(ds.label));
|
||||
@@ -124,16 +122,11 @@ export function Chart({
|
||||
});
|
||||
}
|
||||
|
||||
// Re-apply caller-driven hidden flags after focusLabel handling so a
|
||||
// dataset stays hidden across data changes (e.g. date-range switches)
|
||||
// even though Chart.js regenerates dataset meta on every replace.
|
||||
if (hiddenLabels) {
|
||||
chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => {
|
||||
if (hiddenLabels.has(ds.label)) {
|
||||
ds.hidden = true;
|
||||
} else if (!chartData.focusLabel) {
|
||||
// Explicitly reset so un-hiding a label is always reflected,
|
||||
// regardless of whether the focusLabel pass ran above.
|
||||
ds.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface PieChartProps extends ChartProps {
|
||||
type?: 'doughnut' | 'pie';
|
||||
}
|
||||
|
||||
export function PieChart({ type = 'pie', ...props }: PieChartProps) {
|
||||
export function PieChart({ type = 'pie', height = '300px', ...props }: PieChartProps) {
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
|
||||
const handleTooltip = ({ tooltip }) => {
|
||||
@@ -24,7 +24,7 @@ export function PieChart({ type = 'pie', ...props }: PieChartProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chart {...props} type={type} onTooltip={handleTooltip} />
|
||||
<Chart {...props} type={type} height={height} onTooltip={handleTooltip} />
|
||||
{tooltip && <ChartTooltip {...tooltip} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -58,16 +58,11 @@ export function DataGrid({
|
||||
const showPager = allowPaging && data && data.count > 0;
|
||||
const { isMobile } = useMobile();
|
||||
const [userDisplayMode, setUserDisplayMode] = useState<DisplayMode | null>(() => {
|
||||
// localStorage can hold anything (extensions, manual edits, schema drift),
|
||||
// so accept only the two values we know how to render and otherwise fall
|
||||
// back to the useMobile-driven default.
|
||||
const stored = getItem(DISPLAY_MODE_STORAGE_KEY);
|
||||
return stored === 'table' || stored === 'cards' ? stored : null;
|
||||
});
|
||||
|
||||
// Effective mode: explicit user choice wins, otherwise fall back to the
|
||||
// mobile-driven default (cards on small viewports, table elsewhere).
|
||||
const displayMode: DisplayMode | undefined = userDisplayMode ?? (isMobile ? 'cards' : undefined);
|
||||
const displayMode: DisplayMode | undefined = isMobile ? 'cards' : userDisplayMode ?? undefined;
|
||||
|
||||
const handleToggleDisplayMode = () => {
|
||||
const next: DisplayMode = displayMode === 'cards' ? 'table' : 'cards';
|
||||
@@ -116,7 +111,7 @@ export function DataGrid({
|
||||
)}
|
||||
<Row alignItems="center" gap style={{ marginLeft: 'auto' }}>
|
||||
{renderActions?.()}
|
||||
{viewToggleButton}
|
||||
{!isMobile && viewToggleButton}
|
||||
</Row>
|
||||
</Row>
|
||||
<LoadingPanel
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { render, screen } from '@/test/render';
|
||||
import { Empty } from './Empty';
|
||||
|
||||
test('renders the default empty state message', () => {
|
||||
render(<Empty />);
|
||||
|
||||
expect(screen.getByText('No data available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a custom empty state message', () => {
|
||||
render(<Empty message="Nothing matched the current filters." />);
|
||||
|
||||
expect(screen.getByText('Nothing matched the current filters.')).toBeInTheDocument();
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export function WebsiteSelect({
|
||||
const { user } = useLoginQuery();
|
||||
const { data, isLoading } = useUserWebsitesQuery(
|
||||
{ userId: user?.id, teamId },
|
||||
{ search, pageSize: 20, includeTeams },
|
||||
{ search, pageSize: 100, includeTeams },
|
||||
);
|
||||
const listItems: { id: string; name: string }[] = data?.data || [];
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export const labels: Record<string, string> = {
|
||||
replay: 'label.replay',
|
||||
replayId: 'label.replay-id',
|
||||
replayEnabled: 'label.replay-enabled',
|
||||
replayCode: 'label.replay-code',
|
||||
recorderCode: 'label.recorder-code',
|
||||
sampleRate: 'label.sample-rate',
|
||||
maskLevel: 'label.mask-level',
|
||||
maxDuration: 'label.max-duration',
|
||||
|
||||
@@ -62,13 +62,6 @@ export function EventsChart({ websiteId, focusLabel, limit }: EventsChartProps)
|
||||
],
|
||||
};
|
||||
} else {
|
||||
// Each label has a preferred palette slot derived from a hash of the
|
||||
// label, so the same event tends to get the same color across reloads
|
||||
// and date-range changes. We walk labels in hash order and, when two
|
||||
// labels prefer the same slot, the later one steps to the next free
|
||||
// slot, so the visible set of <=12 events all get distinct colors.
|
||||
// The right shift on hex6 sidesteps the FNV-1a low-bit bias mod 12
|
||||
// (the FNV prime is close to 2^24).
|
||||
const colorByKey: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
const hashOf = Object.fromEntries(
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { getApiUrl } from '../api-url';
|
||||
|
||||
test('uses the default api path', () => {
|
||||
expect(getApiUrl('/websites', { apiUrl: '', basePath: '' })).toBe('/api/websites');
|
||||
});
|
||||
|
||||
test('uses basePath with the default api path', () => {
|
||||
expect(getApiUrl('/websites', { apiUrl: '', basePath: '/analytics' })).toBe(
|
||||
'/analytics/api/websites',
|
||||
);
|
||||
});
|
||||
|
||||
test('routes calls through a relative API_URL', () => {
|
||||
expect(getApiUrl('/websites', { apiUrl: '/backend/api', basePath: '' })).toBe(
|
||||
'/backend/api/websites',
|
||||
);
|
||||
});
|
||||
|
||||
test('routes calls through basePath with a relative API_URL', () => {
|
||||
expect(getApiUrl('/websites', { apiUrl: '/backend/api', basePath: '/analytics' })).toBe(
|
||||
'/analytics/backend/api/websites',
|
||||
);
|
||||
});
|
||||
|
||||
test('routes calls through an absolute API_URL', () => {
|
||||
expect(getApiUrl('/websites', { apiUrl: 'https://api.example.com/api', basePath: '' })).toBe(
|
||||
'https://api.example.com/api/websites',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves absolute request URLs unchanged', () => {
|
||||
expect(getApiUrl('https://example.com/custom', { apiUrl: '/backend/api', basePath: '' })).toBe(
|
||||
'https://example.com/custom',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps /auth/* on the default api path', () => {
|
||||
expect(
|
||||
getApiUrl('/auth/login', { apiUrl: 'https://api.example.com/api', basePath: '' }),
|
||||
).toBe('/api/auth/login');
|
||||
});
|
||||
|
||||
test('keeps /config on the default api path', () => {
|
||||
expect(getApiUrl('/config', { apiUrl: 'https://api.example.com/api', basePath: '' })).toBe(
|
||||
'/api/config',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps /auth/* on the default api path with basePath', () => {
|
||||
expect(
|
||||
getApiUrl('/auth/verify', {
|
||||
apiUrl: 'https://api.example.com/api',
|
||||
basePath: '/analytics',
|
||||
}),
|
||||
).toBe('/analytics/api/auth/verify');
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { DATA_TYPE } from '../constants';
|
||||
import {
|
||||
flattenJSON,
|
||||
createKeyValue,
|
||||
isValidDateValue,
|
||||
getDataType,
|
||||
getStringValue,
|
||||
objectToArray,
|
||||
type KeyValueData,
|
||||
} from '../data';
|
||||
|
||||
describe('isValidDateValue', () => {
|
||||
test.each([
|
||||
['2024-01-15T10:30:00Z', true],
|
||||
['2024-01-15T10:30:00.123Z', true],
|
||||
['2024-01-15T10:30:00+02:00', true],
|
||||
['not-a-date', false],
|
||||
['2024/01/15', false],
|
||||
['', false],
|
||||
])('validates datetime strings correctly (%s → %s)', (input, expected) => {
|
||||
expect(isValidDateValue(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('returns false for non-string values', () => {
|
||||
expect(isValidDateValue(123 as any)).toBe(false);
|
||||
expect(isValidDateValue(null as any)).toBe(false);
|
||||
expect(isValidDateValue(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataType', () => {
|
||||
test.each([
|
||||
['string', 'string'],
|
||||
[123, 'number'],
|
||||
[true, 'boolean'],
|
||||
[null, 'object'],
|
||||
[[], 'object'],
|
||||
[{}, 'object'],
|
||||
['2024-01-15T10:30:00Z', 'date'],
|
||||
])('detects type correctly (%s → %s)', (input, expected) => {
|
||||
expect(getDataType(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyValue', () => {
|
||||
test('handles string values', () => {
|
||||
const result = createKeyValue('name', 'test');
|
||||
expect(result).toEqual({
|
||||
key: 'name',
|
||||
value: 'test',
|
||||
dataType: DATA_TYPE.string,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles number values', () => {
|
||||
const result = createKeyValue('count', 42);
|
||||
expect(result).toEqual({
|
||||
key: 'count',
|
||||
value: 42,
|
||||
dataType: DATA_TYPE.number,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles boolean values and converts to string', () => {
|
||||
expect(createKeyValue('active', true)).toEqual({
|
||||
key: 'active',
|
||||
value: 'true',
|
||||
dataType: DATA_TYPE.boolean,
|
||||
});
|
||||
expect(createKeyValue('active', false)).toEqual({
|
||||
key: 'active',
|
||||
value: 'false',
|
||||
dataType: DATA_TYPE.boolean,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles date strings', () => {
|
||||
const dateStr = '2024-01-15T10:30:00Z';
|
||||
const result = createKeyValue('timestamp', dateStr);
|
||||
expect(result).toEqual({
|
||||
key: 'timestamp',
|
||||
value: dateStr,
|
||||
dataType: DATA_TYPE.date,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles arrays and converts to JSON string', () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = createKeyValue('items', arr);
|
||||
expect(result).toEqual({
|
||||
key: 'items',
|
||||
value: '[1,2,3]',
|
||||
dataType: DATA_TYPE.array,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles null values', () => {
|
||||
const result = createKeyValue('nullable', null);
|
||||
expect(result.dataType).toBe(DATA_TYPE.array);
|
||||
expect(result.value).toBe('null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStringValue', () => {
|
||||
test('formats number values with 4 decimal places', () => {
|
||||
expect(getStringValue('42', DATA_TYPE.number)).toBe('42.0000');
|
||||
expect(getStringValue('3.14159', DATA_TYPE.number)).toBe('3.1416');
|
||||
});
|
||||
|
||||
test('converts date values to ISO string', () => {
|
||||
const result = getStringValue('2024-01-15T10:30:00', DATA_TYPE.date);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test('returns string values as-is for other types', () => {
|
||||
expect(getStringValue('test', DATA_TYPE.string)).toBe('test');
|
||||
expect(getStringValue('true', DATA_TYPE.boolean)).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectToArray', () => {
|
||||
test('converts object values to array', () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
const result = objectToArray(obj);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('returns empty array for empty object', () => {
|
||||
expect(objectToArray({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenJSON', () => {
|
||||
test('flattens simple object', () => {
|
||||
const input = {
|
||||
name: 'test',
|
||||
count: 42,
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'name', value: 'test' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'count', value: 42 }));
|
||||
});
|
||||
|
||||
test('flattens nested object with dot notation', () => {
|
||||
const input = {
|
||||
user: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'user.name', value: 'John' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ key: 'user.age', value: 30 }));
|
||||
});
|
||||
|
||||
test('flattens deeply nested object', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('level1.level2.level3.value');
|
||||
expect(result[0].value).toBe('deep');
|
||||
});
|
||||
|
||||
test('treats arrays as leaf values (converts to JSON string)', () => {
|
||||
const input = {
|
||||
tags: ['a', 'b', 'c'],
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('tags');
|
||||
expect(result[0].value).toBe('["a","b","c"]');
|
||||
expect(result[0].dataType).toBe(DATA_TYPE.array);
|
||||
});
|
||||
|
||||
test('treats date strings as leaf values', () => {
|
||||
const input = {
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe('createdAt');
|
||||
expect(result[0].dataType).toBe(DATA_TYPE.date);
|
||||
});
|
||||
|
||||
test('handles mixed nested and flat structure', () => {
|
||||
const input = {
|
||||
id: '123',
|
||||
metadata: {
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
source: 'web',
|
||||
details: {
|
||||
referrer: 'google',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
const keys = result.map(r => r.key);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(keys).toContain('id');
|
||||
expect(keys).toContain('metadata.timestamp');
|
||||
expect(keys).toContain('metadata.source');
|
||||
expect(keys).toContain('metadata.details.referrer');
|
||||
});
|
||||
|
||||
test('returns empty array for empty object', () => {
|
||||
expect(flattenJSON({})).toEqual([]);
|
||||
});
|
||||
|
||||
test('converts boolean values to strings', () => {
|
||||
const input = {
|
||||
isActive: true,
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const result = flattenJSON(input);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'isActive', value: 'true', dataType: DATA_TYPE.boolean }),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'isAdmin', value: 'false', dataType: DATA_TYPE.boolean }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
BOARD_ENTITY_TYPES,
|
||||
isBoardComponentSupported,
|
||||
} from '../boards';
|
||||
import { expect, test } from 'vitest';
|
||||
import {
|
||||
BOARD_COMPONENT_COMPATIBILITY_MATRIX,
|
||||
getSupportedBoardComponentEntityTypes,
|
||||
} from '../boardComponentCompatibility';
|
||||
} from './boardComponentCompatibility';
|
||||
import { BOARD_ENTITY_TYPES, isBoardComponentSupported } from './boards';
|
||||
|
||||
test('isBoardComponentSupported allows events chart on website boards', () => {
|
||||
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.website)).toBe(true);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderNumberLabels } from '../charts';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderNumberLabels } from './charts';
|
||||
|
||||
// test for renderNumberLabels
|
||||
|
||||
+17
-3
@@ -1,7 +1,7 @@
|
||||
import { CLICKHOUSE } from '@/lib/db';
|
||||
import { type ClickHouseClient, createClient } from '@clickhouse/client';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import debug from 'debug';
|
||||
import { CLICKHOUSE } from '@/lib/db';
|
||||
import { DATA_TYPE, DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
|
||||
import { filtersObjectToArray } from './params';
|
||||
import type { Operator, PropertyFilter, QueryFilters, QueryOptions } from './types';
|
||||
@@ -79,7 +79,13 @@ function getSearchSQL(column: string, param: string = 'search'): string {
|
||||
return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
|
||||
}
|
||||
|
||||
function mapFilter(column: string, operator: Operator, name: string, type: string = 'String', paramName?: string) {
|
||||
function mapFilter(
|
||||
column: string,
|
||||
operator: Operator,
|
||||
name: string,
|
||||
type: string = 'String',
|
||||
paramName?: string,
|
||||
) {
|
||||
const param = paramName ?? name;
|
||||
const value = `{${param}:${type}}`;
|
||||
|
||||
@@ -388,7 +394,15 @@ async function pagedRawQuery(
|
||||
const count = await rawQuery(countQuery, queryParams).then(res => res[0].num);
|
||||
const data = await rawQuery(`${query}${statements}`, queryParams, name);
|
||||
|
||||
return { data, count, page: +page, pageSize: size, orderBy, search, isCapped: !!maxResults && +count >= +maxResults };
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
page: +page,
|
||||
pageSize: size,
|
||||
orderBy,
|
||||
search,
|
||||
isCapped: !!maxResults && +count >= +maxResults,
|
||||
};
|
||||
}
|
||||
|
||||
async function rawQuery<T = unknown>(
|
||||
|
||||
@@ -262,6 +262,9 @@ export const DATETIME_REGEX =
|
||||
export const URL_LENGTH = 500;
|
||||
export const PAGE_TITLE_LENGTH = 500;
|
||||
export const EVENT_NAME_LENGTH = 50;
|
||||
export const TAG_LENGTH = 50;
|
||||
export const HOSTNAME_LENGTH = 100;
|
||||
export const FIELD_VALUE_LENGTH = 255;
|
||||
|
||||
export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term'];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getIpAddress } from '../ip';
|
||||
import { expect, test } from 'vitest';
|
||||
import { getIpAddress } from './ip';
|
||||
|
||||
const IP = '127.0.0.1';
|
||||
const BAD_IP = '127.127.127.127';
|
||||
|
||||
test('getIpAddress: Custom header', () => {
|
||||
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as format from '../format';
|
||||
import { expect, test } from 'vitest';
|
||||
import * as format from './format';
|
||||
|
||||
test('parseTime', () => {
|
||||
expect(format.parseTime(86400 + 3600 + 60 + 1)).toEqual({
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HOMEPAGE_URL } from '../constants';
|
||||
import { getBaseUrl } from '../get-base-url';
|
||||
import { expect, test } from 'vitest';
|
||||
import { HOMEPAGE_URL } from './constants';
|
||||
import { getBaseUrl } from './get-base-url';
|
||||
|
||||
function createHeaders(entries: Record<string, string>) {
|
||||
return {
|
||||
@@ -0,0 +1,100 @@
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
let client: S3Client | null = null;
|
||||
|
||||
function getBucket() {
|
||||
const bucket = process.env.R2_BUCKET;
|
||||
|
||||
if (!bucket) {
|
||||
throw new Error('R2_BUCKET is not set.');
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function getAccountId() {
|
||||
const accountId = process.env.R2_ACCOUNT_ID;
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error('R2_ACCOUNT_ID is not set.');
|
||||
}
|
||||
|
||||
return accountId;
|
||||
}
|
||||
|
||||
function getCredentials() {
|
||||
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||
|
||||
if (!accessKeyId) {
|
||||
throw new Error('R2_ACCESS_KEY_ID is not set.');
|
||||
}
|
||||
|
||||
if (!secretAccessKey) {
|
||||
throw new Error('R2_SECRET_ACCESS_KEY is not set.');
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (!client) {
|
||||
client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${getAccountId()}.r2.cloudflarestorage.com`,
|
||||
credentials: getCredentials(),
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function putHeatmapSnapshot(
|
||||
objectKey: string,
|
||||
imageData: Buffer,
|
||||
mimeType: string,
|
||||
) {
|
||||
await getClient().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: objectKey,
|
||||
Body: imageData,
|
||||
ContentType: mimeType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getHeatmapSnapshot(objectKey: string) {
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await getClient().send(
|
||||
new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: objectKey,
|
||||
}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error?.name === 'NoSuchKey' ||
|
||||
error?.Code === 'NoSuchKey' ||
|
||||
error?.$metadata?.httpStatusCode === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.Body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: response.ContentType || 'application/octet-stream',
|
||||
imageData: Buffer.from(await response.Body.transformToByteArray()),
|
||||
};
|
||||
}
|
||||
@@ -16,11 +16,6 @@ export const IP_ADDRESS_HEADERS = [
|
||||
'x-forwarded',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize IP strings to a canonical form:
|
||||
* - strips IPv4-mapped IPv6 (e.g. ::ffff:192.0.2.1 -> 192.0.2.1)
|
||||
* - keeps valid IPv4/IPv6 as-is (canonically formatted by ipaddr.js)
|
||||
*/
|
||||
function normalizeIp(ip?: string | null) {
|
||||
if (!ip) return ip;
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { matchesConfiguredPath } from '../match-configured-path';
|
||||
import { expect, test } from 'vitest';
|
||||
import { matchesConfiguredPath } from './match-configured-path';
|
||||
|
||||
test('matches the exact configured path', () => {
|
||||
expect(matchesConfiguredPath('/d.js', 'd.js')).toBe(true);
|
||||
+21
-4
@@ -1,8 +1,14 @@
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||
import debug from 'debug';
|
||||
import { DATA_TYPE, DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants';
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import {
|
||||
DATA_TYPE,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
FILTER_COLUMNS,
|
||||
OPERATORS,
|
||||
SESSION_COLUMNS,
|
||||
} from './constants';
|
||||
import { filtersObjectToArray } from './params';
|
||||
import type { Operator, PropertyFilter, QueryFilters, QueryOptions } from './types';
|
||||
|
||||
@@ -68,7 +74,11 @@ function getDateSQL(field: string, unit: string, timezone?: string): string {
|
||||
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
|
||||
}
|
||||
|
||||
function getDateStringSQL(field: string, unit: keyof typeof DATE_STRING_FORMATS = 'utc', timezone?: string): string {
|
||||
function getDateStringSQL(
|
||||
field: string,
|
||||
unit: keyof typeof DATE_STRING_FORMATS = 'utc',
|
||||
timezone?: string,
|
||||
): string {
|
||||
if (timezone && !isUtcTimezone(timezone)) {
|
||||
return `to_char(${field} at time zone '${timezone}', '${DATE_STRING_FORMATS[unit]}')`;
|
||||
}
|
||||
@@ -478,7 +488,14 @@ async function pagedRawQuery(
|
||||
const count = await rawQuery(countQuery, queryParams).then(res => Number(res[0].num));
|
||||
const data = await rawQuery(`${query}${statements}`, queryParams, name);
|
||||
|
||||
return { data, count, page: +page, pageSize: size, orderBy, isCapped: !!maxResults && +count >= +maxResults };
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
page: +page,
|
||||
pageSize: size,
|
||||
orderBy,
|
||||
isCapped: !!maxResults && +count >= +maxResults,
|
||||
};
|
||||
}
|
||||
|
||||
function getSearchParameters(query: string, filters: Record<string, any>[]) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface RecorderConfig {
|
||||
replayEnabled?: boolean;
|
||||
heatmapEnabled?: boolean;
|
||||
sampleRate?: number;
|
||||
heatmapSampleRate?: number;
|
||||
maskLevel?: 'strict' | 'moderate';
|
||||
maxDuration?: number;
|
||||
blockSelector?: string;
|
||||
}
|
||||
|
||||
export function getRecorderConfig(value: unknown): RecorderConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = value as Record<string, unknown>;
|
||||
const nextConfig: RecorderConfig = {};
|
||||
|
||||
if (config.replayEnabled === true) {
|
||||
nextConfig.replayEnabled = true;
|
||||
}
|
||||
|
||||
if (config.heatmapEnabled === true) {
|
||||
nextConfig.heatmapEnabled = true;
|
||||
}
|
||||
|
||||
if (typeof config.sampleRate === 'number') {
|
||||
nextConfig.sampleRate = config.sampleRate;
|
||||
}
|
||||
|
||||
if (typeof config.heatmapSampleRate === 'number') {
|
||||
nextConfig.heatmapSampleRate = config.heatmapSampleRate;
|
||||
}
|
||||
|
||||
if (config.maskLevel === 'strict' || config.maskLevel === 'moderate') {
|
||||
nextConfig.maskLevel = config.maskLevel;
|
||||
}
|
||||
|
||||
if (typeof config.maxDuration === 'number' && Number.isFinite(config.maxDuration)) {
|
||||
nextConfig.maxDuration = Math.round(config.maxDuration);
|
||||
}
|
||||
|
||||
if (typeof config.blockSelector === 'string') {
|
||||
nextConfig.blockSelector = config.blockSelector;
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export function getRecorderEnabled(config: unknown) {
|
||||
const { replayEnabled, heatmapEnabled } = getRecorderConfig(config);
|
||||
|
||||
return replayEnabled === true || heatmapEnabled === true;
|
||||
}
|
||||
@@ -130,9 +130,6 @@ export async function deleteUser(userId: string) {
|
||||
|
||||
const teamIds = teams.map(a => a.id);
|
||||
|
||||
// Cloud mode keeps owned teams (and their team-owned content), so cleanup
|
||||
// only covers user-direct rows. Non-cloud hard-deletes owned teams below,
|
||||
// so we must also clean up team-owned content.
|
||||
const ownedFilter = cloudMode
|
||||
? { userId }
|
||||
: { OR: [{ userId }, { teamId: { in: teamIds } }] };
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { EVENT_NAME_LENGTH, PAGE_TITLE_LENGTH, URL_LENGTH } from '@/lib/constants';
|
||||
import {
|
||||
EVENT_NAME_LENGTH,
|
||||
FIELD_VALUE_LENGTH,
|
||||
HOSTNAME_LENGTH,
|
||||
PAGE_TITLE_LENGTH,
|
||||
TAG_LENGTH,
|
||||
URL_LENGTH,
|
||||
} from '@/lib/constants';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import kafka from '@/lib/kafka';
|
||||
@@ -69,6 +76,10 @@ export async function saveEvent(args: SaveEventArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(value: string | null | undefined, maxLength: number) {
|
||||
return value ? value.substring(0, maxLength) : value;
|
||||
}
|
||||
|
||||
async function relationalQuery({
|
||||
websiteId,
|
||||
sessionId,
|
||||
@@ -110,27 +121,27 @@ async function relationalQuery({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||
urlQuery: urlQuery?.substring(0, URL_LENGTH),
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
utmContent,
|
||||
utmTerm,
|
||||
referrerPath: referrerPath?.substring(0, URL_LENGTH),
|
||||
referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
|
||||
referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
|
||||
pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
||||
gclid,
|
||||
fbclid,
|
||||
msclkid,
|
||||
ttclid,
|
||||
lifatid,
|
||||
twclid,
|
||||
urlPath: truncate(urlPath, URL_LENGTH),
|
||||
urlQuery: truncate(urlQuery, URL_LENGTH),
|
||||
utmSource: truncate(utmSource, FIELD_VALUE_LENGTH),
|
||||
utmMedium: truncate(utmMedium, FIELD_VALUE_LENGTH),
|
||||
utmCampaign: truncate(utmCampaign, FIELD_VALUE_LENGTH),
|
||||
utmContent: truncate(utmContent, FIELD_VALUE_LENGTH),
|
||||
utmTerm: truncate(utmTerm, FIELD_VALUE_LENGTH),
|
||||
referrerPath: truncate(referrerPath, URL_LENGTH),
|
||||
referrerQuery: truncate(referrerQuery, URL_LENGTH),
|
||||
referrerDomain: truncate(referrerDomain, URL_LENGTH),
|
||||
pageTitle: truncate(pageTitle, PAGE_TITLE_LENGTH),
|
||||
gclid: truncate(gclid, FIELD_VALUE_LENGTH),
|
||||
fbclid: truncate(fbclid, FIELD_VALUE_LENGTH),
|
||||
msclkid: truncate(msclkid, FIELD_VALUE_LENGTH),
|
||||
ttclid: truncate(ttclid, FIELD_VALUE_LENGTH),
|
||||
lifatid: truncate(lifatid, FIELD_VALUE_LENGTH),
|
||||
twclid: truncate(twclid, FIELD_VALUE_LENGTH),
|
||||
eventType,
|
||||
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||
tag,
|
||||
hostname,
|
||||
eventName: truncate(eventName, EVENT_NAME_LENGTH) ?? null,
|
||||
tag: truncate(tag, TAG_LENGTH),
|
||||
hostname: truncate(hostname, HOSTNAME_LENGTH),
|
||||
lcp,
|
||||
inp,
|
||||
cls,
|
||||
@@ -145,8 +156,8 @@ async function relationalQuery({
|
||||
websiteId,
|
||||
sessionId,
|
||||
eventId: websiteEventId,
|
||||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||
urlPath: truncate(urlPath, URL_LENGTH),
|
||||
eventName: truncate(eventName, EVENT_NAME_LENGTH),
|
||||
eventData,
|
||||
createdAt,
|
||||
});
|
||||
@@ -158,7 +169,7 @@ async function relationalQuery({
|
||||
websiteId,
|
||||
sessionId,
|
||||
eventId: websiteEventId,
|
||||
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||
eventName: truncate(eventName, EVENT_NAME_LENGTH),
|
||||
currency,
|
||||
revenue,
|
||||
createdAt,
|
||||
@@ -221,26 +232,26 @@ async function clickhouseQuery({
|
||||
country: country,
|
||||
region: country && region ? (region.includes('-') ? region : `${country}-${region}`) : null,
|
||||
city: city,
|
||||
url_path: urlPath?.substring(0, URL_LENGTH),
|
||||
url_query: urlQuery?.substring(0, URL_LENGTH),
|
||||
utm_source: utmSource,
|
||||
utm_medium: utmMedium,
|
||||
utm_campaign: utmCampaign,
|
||||
utm_content: utmContent,
|
||||
utm_term: utmTerm,
|
||||
referrer_path: referrerPath?.substring(0, URL_LENGTH),
|
||||
referrer_query: referrerQuery?.substring(0, URL_LENGTH),
|
||||
referrer_domain: referrerDomain?.substring(0, URL_LENGTH),
|
||||
page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
|
||||
gclid: gclid,
|
||||
fbclid: fbclid,
|
||||
msclkid: msclkid,
|
||||
ttclid: ttclid,
|
||||
li_fat_id: lifatid,
|
||||
twclid: twclid,
|
||||
url_path: truncate(urlPath, URL_LENGTH),
|
||||
url_query: truncate(urlQuery, URL_LENGTH),
|
||||
utm_source: truncate(utmSource, FIELD_VALUE_LENGTH),
|
||||
utm_medium: truncate(utmMedium, FIELD_VALUE_LENGTH),
|
||||
utm_campaign: truncate(utmCampaign, FIELD_VALUE_LENGTH),
|
||||
utm_content: truncate(utmContent, FIELD_VALUE_LENGTH),
|
||||
utm_term: truncate(utmTerm, FIELD_VALUE_LENGTH),
|
||||
referrer_path: truncate(referrerPath, URL_LENGTH),
|
||||
referrer_query: truncate(referrerQuery, URL_LENGTH),
|
||||
referrer_domain: truncate(referrerDomain, URL_LENGTH),
|
||||
page_title: truncate(pageTitle, PAGE_TITLE_LENGTH),
|
||||
gclid: truncate(gclid, FIELD_VALUE_LENGTH),
|
||||
fbclid: truncate(fbclid, FIELD_VALUE_LENGTH),
|
||||
msclkid: truncate(msclkid, FIELD_VALUE_LENGTH),
|
||||
ttclid: truncate(ttclid, FIELD_VALUE_LENGTH),
|
||||
li_fat_id: truncate(lifatid, FIELD_VALUE_LENGTH),
|
||||
twclid: truncate(twclid, FIELD_VALUE_LENGTH),
|
||||
event_type: eventType,
|
||||
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||
tag: tag,
|
||||
event_name: truncate(eventName, EVENT_NAME_LENGTH) ?? null,
|
||||
tag: truncate(tag, TAG_LENGTH),
|
||||
distinct_id: distinctId,
|
||||
created_at: getUTCString(createdAt),
|
||||
browser: browser,
|
||||
@@ -248,7 +259,7 @@ async function clickhouseQuery({
|
||||
device: device,
|
||||
screen: screen,
|
||||
language: language,
|
||||
hostname: hostname,
|
||||
hostname: truncate(hostname, HOSTNAME_LENGTH),
|
||||
lcp: lcp,
|
||||
inp: inp,
|
||||
cls: cls,
|
||||
@@ -267,8 +278,8 @@ async function clickhouseQuery({
|
||||
websiteId,
|
||||
sessionId,
|
||||
eventId,
|
||||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||
urlPath: truncate(urlPath, URL_LENGTH),
|
||||
eventName: truncate(eventName, EVENT_NAME_LENGTH),
|
||||
eventData,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getHeatmapSnapshot, putHeatmapSnapshot } from '@/lib/heatmap-r2';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
|
||||
const SNAPSHOT_STATUS = {
|
||||
pending: 'pending',
|
||||
ready: 'ready',
|
||||
failed: 'failed',
|
||||
} as const;
|
||||
|
||||
const SNAPSHOT_RETRY_DELAY_MS = 15 * 60 * 1000;
|
||||
const SNAPSHOT_PENDING_WINDOW_MS = 30 * 1000;
|
||||
const SNAPSHOT_DEVICE_SCALE_FACTOR = 1;
|
||||
export type HeatmapSnapshotStatus = (typeof SNAPSHOT_STATUS)[keyof typeof SNAPSHOT_STATUS];
|
||||
|
||||
export interface HeatmapSnapshotImage {
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SnapshotRecord {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
objectKey: string | null;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
hasImage: boolean;
|
||||
updatedAt: Date | string | null;
|
||||
}
|
||||
|
||||
interface EnsureHeatmapSnapshotOptions {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
imageData: Buffer;
|
||||
mimeType: string;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
}
|
||||
|
||||
const CLICKHOUSE_SNAPSHOT_STATUS = {
|
||||
pending: 0,
|
||||
ready: 1,
|
||||
failed: 2,
|
||||
} as const;
|
||||
|
||||
async function measurePage(page: any) {
|
||||
return page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
const body = document.body;
|
||||
const root = document.scrollingElement || doc || body;
|
||||
const rootClientWidth = root?.clientWidth || 0;
|
||||
const docClientWidth = doc?.clientWidth || 0;
|
||||
const bodyClientWidth = body?.clientWidth || 0;
|
||||
const visibleWidth = Math.max(window.innerWidth, rootClientWidth, docClientWidth, bodyClientWidth);
|
||||
const rootScrollWidth = root?.scrollWidth || 0;
|
||||
const docScrollWidth = doc?.scrollWidth || 0;
|
||||
const bodyScrollWidth = body?.scrollWidth || 0;
|
||||
const horizontalOverflow = Math.max(
|
||||
rootScrollWidth - rootClientWidth,
|
||||
docScrollWidth - docClientWidth,
|
||||
bodyScrollWidth - bodyClientWidth,
|
||||
0,
|
||||
);
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
if (body) {
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode as Element | null;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
}
|
||||
|
||||
node = walker.nextNode() as Element | null;
|
||||
}
|
||||
}
|
||||
|
||||
const pageW =
|
||||
horizontalOverflow > 24
|
||||
? Math.max(visibleWidth, rootScrollWidth, docScrollWidth, bodyScrollWidth)
|
||||
: visibleWidth;
|
||||
const pageH = Math.max(
|
||||
window.innerHeight,
|
||||
root?.scrollHeight || 0,
|
||||
doc?.scrollHeight || 0,
|
||||
body?.scrollHeight || 0,
|
||||
Math.ceil(maxBottom),
|
||||
);
|
||||
|
||||
return {
|
||||
pageW: Math.ceil(pageW),
|
||||
pageH: Math.ceil(pageH),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function warmLazyContent(page: any) {
|
||||
await page.evaluate(async () => {
|
||||
const root = document.scrollingElement || document.documentElement;
|
||||
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(0, root.scrollHeight - window.innerHeight);
|
||||
|
||||
if (maxScrollTop <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = [
|
||||
Math.min(window.innerHeight, maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.25), maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.5), maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.75), maxScrollTop),
|
||||
maxScrollTop,
|
||||
].filter((value, index, values) => value > 0 && values.indexOf(value) === index);
|
||||
|
||||
for (const top of targets) {
|
||||
window.scrollTo(0, top);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 350));
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 250));
|
||||
});
|
||||
}
|
||||
|
||||
function getSchema() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const connectionUrl = new URL(databaseUrl);
|
||||
|
||||
return connectionUrl.searchParams.get('schema');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rawExecute(sql: string, data: Record<string, any> = {}) {
|
||||
const params: any[] = [];
|
||||
const schema = getSchema();
|
||||
|
||||
if (schema) {
|
||||
await prisma.client.$executeRawUnsafe(`SET search_path TO "${schema}";`);
|
||||
}
|
||||
|
||||
const query = sql.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
|
||||
const [, name, type] = args;
|
||||
|
||||
params.push(data[name]);
|
||||
|
||||
return `$${params.length}${type ?? ''}`;
|
||||
});
|
||||
|
||||
return prisma.client.$executeRawUnsafe(query, ...params);
|
||||
}
|
||||
|
||||
async function findSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
if (clickhouse.enabled) {
|
||||
return findClickhouseSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
}
|
||||
|
||||
return findRelationalSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
}
|
||||
|
||||
async function findRelationalSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
snapshot_id as id,
|
||||
website_id as "websiteId",
|
||||
url_path as "urlPath",
|
||||
viewport_w as "viewportW",
|
||||
viewport_h as "viewportH",
|
||||
page_w as "pageW",
|
||||
page_h as "pageH",
|
||||
status,
|
||||
mime_type as "mimeType",
|
||||
null as "objectKey",
|
||||
image_size as "imageSize",
|
||||
error,
|
||||
image_data is not null as "hasImage",
|
||||
updated_at as "updatedAt"
|
||||
from heatmap_snapshot
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and url_path = {{urlPath}}
|
||||
and viewport_w = {{viewportW}}
|
||||
and viewport_h = {{viewportH}}
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, urlPath, viewportW, viewportH },
|
||||
'findHeatmapSnapshot',
|
||||
);
|
||||
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function findClickhouseSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
const rows = await clickhouse.rawQuery<
|
||||
{
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: number;
|
||||
mimeType: string | null;
|
||||
objectKey: string;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
snapshot_id as id,
|
||||
website_id as websiteId,
|
||||
url_path as urlPath,
|
||||
viewport_w as viewportW,
|
||||
viewport_h as viewportH,
|
||||
page_w as pageW,
|
||||
page_h as pageH,
|
||||
status,
|
||||
mime_type as mimeType,
|
||||
object_key as objectKey,
|
||||
image_size as imageSize,
|
||||
error,
|
||||
created_at as createdAt
|
||||
from heatmap_snapshot
|
||||
where website_id = {websiteId:UUID}
|
||||
and url_path = {urlPath:String}
|
||||
and viewport_w = {viewportW:UInt32}
|
||||
and viewport_h = {viewportH:UInt32}
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, urlPath, viewportW, viewportH },
|
||||
'findHeatmapSnapshot',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = Object.entries(CLICKHOUSE_SNAPSHOT_STATUS).find(([, value]) => value === row.status)?.[0];
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
status: SNAPSHOT_STATUS[status as keyof typeof SNAPSHOT_STATUS],
|
||||
mimeType: row.mimeType || null,
|
||||
objectKey: row.objectKey || null,
|
||||
hasImage: row.status === CLICKHOUSE_SNAPSHOT_STATUS.ready && Boolean(row.objectKey),
|
||||
updatedAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshotImageUrl(websiteId: string, snapshotId: string) {
|
||||
return `/api/websites/${websiteId}/heatmaps/snapshots/${snapshotId}`;
|
||||
}
|
||||
|
||||
function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage {
|
||||
return {
|
||||
id: row.id,
|
||||
imageUrl: row.status === SNAPSHOT_STATUS.ready && row.hasImage ? getSnapshotImageUrl(websiteId, row.id) : null,
|
||||
status: row.status,
|
||||
mimeType: row.mimeType,
|
||||
pageW: Number(row.pageW),
|
||||
pageH: Number(row.pageH),
|
||||
viewportW: Number(row.viewportW),
|
||||
viewportH: Number(row.viewportH),
|
||||
error: row.error,
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstDomain(domain?: string | null) {
|
||||
return domain?.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getWebsiteOrigin(domain?: string | null) {
|
||||
const host = getFirstDomain(domain);
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.startsWith('http://') || host.startsWith('https://')) {
|
||||
return new URL(host);
|
||||
}
|
||||
|
||||
const protocol =
|
||||
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]')
|
||||
? 'http'
|
||||
: 'https';
|
||||
|
||||
return new URL(`${protocol}://${host}`);
|
||||
}
|
||||
|
||||
function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
|
||||
const origin = getWebsiteOrigin(domain);
|
||||
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(urlPath || '/', origin).toString();
|
||||
}
|
||||
|
||||
export function shouldSkipSnapshot(urlPath: string) {
|
||||
// Internal Umami app routes cannot be rendered from the tracked website domain.
|
||||
return urlPath.startsWith('/teams/');
|
||||
}
|
||||
|
||||
async function upsertSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
objectKey,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageData: Buffer | null;
|
||||
objectKey?: string | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (clickhouse.enabled) {
|
||||
return insertClickhouseSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
objectKey: objectKey ?? null,
|
||||
imageSize: imageData?.byteLength ?? null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertRelationalSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRelationalSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageData: Buffer | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
return rawExecute(
|
||||
`
|
||||
insert into heatmap_snapshot (
|
||||
snapshot_id,
|
||||
website_id,
|
||||
url_path,
|
||||
viewport_w,
|
||||
viewport_h,
|
||||
page_w,
|
||||
page_h,
|
||||
status,
|
||||
mime_type,
|
||||
image_data,
|
||||
image_size,
|
||||
error,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
{{id::uuid}},
|
||||
{{websiteId::uuid}},
|
||||
{{urlPath}},
|
||||
{{viewportW}},
|
||||
{{viewportH}},
|
||||
{{pageW}},
|
||||
{{pageH}},
|
||||
{{status}},
|
||||
{{mimeType}},
|
||||
{{imageData}},
|
||||
{{imageSize}},
|
||||
{{error}},
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (website_id, url_path, viewport_w, viewport_h) do update
|
||||
set
|
||||
page_w = excluded.page_w,
|
||||
page_h = excluded.page_h,
|
||||
status = excluded.status,
|
||||
mime_type = excluded.mime_type,
|
||||
image_data = excluded.image_data,
|
||||
image_size = excluded.image_size,
|
||||
error = excluded.error,
|
||||
updated_at = now()
|
||||
`,
|
||||
{
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
imageSize: imageData?.byteLength ?? null,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function insertClickhouseSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
objectKey,
|
||||
imageSize,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
objectKey: string | null;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
return clickhouse.insert('heatmap_snapshot', [
|
||||
{
|
||||
snapshot_id: id,
|
||||
website_id: websiteId,
|
||||
url_path: urlPath,
|
||||
viewport_w: viewportW,
|
||||
viewport_h: viewportH,
|
||||
page_w: pageW,
|
||||
page_h: pageH,
|
||||
status: CLICKHOUSE_SNAPSHOT_STATUS[status],
|
||||
mime_type: mimeType || '',
|
||||
object_key: objectKey || '',
|
||||
image_size: imageSize,
|
||||
error,
|
||||
created_at: clickhouse.getUTCString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function getSnapshotObjectKey(websiteId: string, snapshotId: string, viewportW: number, viewportH: number) {
|
||||
return `${websiteId}/${viewportW}x${viewportH}/${snapshotId}.png`;
|
||||
}
|
||||
|
||||
async function captureSnapshot(
|
||||
url: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
pageW?: number,
|
||||
): Promise<CaptureResult> {
|
||||
const { chromium } = await import('@playwright/test');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const initialViewportW = viewportW;
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: initialViewportW, height: viewportH },
|
||||
screen: { width: initialViewportW, height: viewportH },
|
||||
deviceScaleFactor: SNAPSHOT_DEVICE_SCALE_FACTOR,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(500);
|
||||
await warmLazyContent(page);
|
||||
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
let dimensions = await measurePage(page);
|
||||
let currentWidth = initialViewportW;
|
||||
let captureWidth =
|
||||
dimensions.pageW > initialViewportW + 24 ? Math.max(initialViewportW, dimensions.pageW) : initialViewportW;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (captureWidth <= currentWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = captureWidth;
|
||||
|
||||
await page.setViewportSize({
|
||||
width: currentWidth,
|
||||
height: viewportH,
|
||||
});
|
||||
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
dimensions = await measurePage(page);
|
||||
captureWidth = Math.max(currentWidth, dimensions.pageW);
|
||||
}
|
||||
|
||||
const imageData = Buffer.from(await page.screenshot({ fullPage: true, type: 'png' }));
|
||||
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
imageData,
|
||||
mimeType: 'image/png',
|
||||
pageW: dimensions.pageW,
|
||||
pageH: dimensions.pageH,
|
||||
};
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
}: EnsureHeatmapSnapshotOptions): Promise<HeatmapSnapshotImage | null> {
|
||||
if (!urlPath || !viewportW || !viewportH || !pageW || !pageH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldSkipSnapshot(urlPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
if (existing?.status === SNAPSHOT_STATUS.ready && existing.hasImage) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null;
|
||||
const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (
|
||||
existing?.status === SNAPSHOT_STATUS.pending &&
|
||||
ageMs < SNAPSHOT_PENDING_WINDOW_MS
|
||||
) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
if (
|
||||
existing?.status === SNAPSHOT_STATUS.failed &&
|
||||
ageMs < SNAPSHOT_RETRY_DELAY_MS
|
||||
) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const snapshotId = existing?.id ?? uuid();
|
||||
const website = await getWebsite(websiteId);
|
||||
const captureUrl = buildCaptureUrl(website?.domain, urlPath);
|
||||
|
||||
if (!captureUrl) {
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: 'Website domain is not configured for screenshot capture.',
|
||||
});
|
||||
|
||||
const failed = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return failed ? mapSnapshot(websiteId, failed) : null;
|
||||
}
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.pending,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const capture = await captureSnapshot(captureUrl, viewportW, viewportH, pageW);
|
||||
const objectKey =
|
||||
clickhouse.enabled
|
||||
? getSnapshotObjectKey(websiteId, snapshotId, viewportW, viewportH)
|
||||
: null;
|
||||
|
||||
if (objectKey) {
|
||||
await putHeatmapSnapshot(objectKey, capture.imageData, capture.mimeType);
|
||||
}
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW: capture.pageW,
|
||||
pageH: capture.pageH,
|
||||
status: SNAPSHOT_STATUS.ready,
|
||||
mimeType: capture.mimeType,
|
||||
imageData: clickhouse.enabled ? null : capture.imageData,
|
||||
objectKey,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: error instanceof Error ? error.message.slice(0, 500) : 'Screenshot capture failed.',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return snapshot ? mapSnapshot(websiteId, snapshot) : null;
|
||||
}
|
||||
|
||||
export async function getHeatmapSnapshotImage(
|
||||
websiteId: string,
|
||||
snapshotId: string,
|
||||
): Promise<{ mimeType: string; imageData: Buffer } | null> {
|
||||
if (clickhouse.enabled) {
|
||||
const rows = await clickhouse.rawQuery<
|
||||
{ mimeType: string; objectKey: string }[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
mime_type as mimeType,
|
||||
object_key as objectKey
|
||||
from heatmap_snapshot
|
||||
where snapshot_id = {snapshotId:UUID}
|
||||
and website_id = {websiteId:UUID}
|
||||
and status = {status:UInt8}
|
||||
and object_key != ''
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
websiteId,
|
||||
snapshotId,
|
||||
status: CLICKHOUSE_SNAPSHOT_STATUS.ready,
|
||||
},
|
||||
'getHeatmapSnapshotImage',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row?.objectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getHeatmapSnapshot(row.objectKey);
|
||||
}
|
||||
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
mime_type as "mimeType",
|
||||
image_data as "imageData"
|
||||
from heatmap_snapshot
|
||||
where snapshot_id = {{snapshotId::uuid}}
|
||||
and website_id = {{websiteId::uuid}}
|
||||
and status = 'ready'
|
||||
and image_data is not null
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, snapshotId },
|
||||
'getHeatmapSnapshotImage',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row?.imageData || !row?.mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: row.mimeType,
|
||||
imageData: Buffer.from(row.imageData),
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import type { QueryFilters } from '@/lib/types';
|
||||
import {
|
||||
type HeatmapSnapshotImage,
|
||||
ensureHeatmapSnapshot,
|
||||
shouldSkipSnapshot,
|
||||
} from './ensureHeatmapSnapshot';
|
||||
|
||||
const FUNCTION_NAME = 'getHeatmap';
|
||||
|
||||
const POINT_LIMIT = 5000;
|
||||
const PAGE_LIMIT = 100;
|
||||
const SCROLL_BUCKET_SIZE = 5;
|
||||
const SCROLL_BUCKET_SIZE = 10;
|
||||
|
||||
export type HeatmapMode = 'click' | 'scroll';
|
||||
|
||||
@@ -27,6 +32,10 @@ export interface HeatmapPoint {
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: number;
|
||||
@@ -37,12 +46,7 @@ export interface HeatmapScrollBucket {
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface HeatmapSnapshot {
|
||||
replayId: string;
|
||||
timestamp: number;
|
||||
chunkIndex: number | null;
|
||||
eventIndex: number | null;
|
||||
}
|
||||
export type HeatmapSnapshot = HeatmapSnapshotImage;
|
||||
|
||||
export interface HeatmapResult {
|
||||
mode: HeatmapMode;
|
||||
@@ -52,6 +56,7 @@ export interface HeatmapResult {
|
||||
scroll: {
|
||||
buckets: HeatmapScrollBucket[];
|
||||
totalSessions: number;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -68,23 +73,11 @@ export async function getHeatmap(
|
||||
});
|
||||
}
|
||||
|
||||
interface SnapshotRow {
|
||||
replayId: string;
|
||||
timestamp: number | string;
|
||||
chunkIndex: number | string | null;
|
||||
eventIndex: number | string | null;
|
||||
}
|
||||
|
||||
interface HeatmapFilterContext {
|
||||
joinQuery: string;
|
||||
queryParams: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SnapshotPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
@@ -93,17 +86,26 @@ async function relationalQuery(
|
||||
const { startDate, endDate, urlPath, mode = 'click' } = parameters;
|
||||
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
|
||||
const filterContext = getRelationalHeatmapFilterContext(websiteId, parameters);
|
||||
const clickPageFilter =
|
||||
const pageFilter =
|
||||
mode === 'click'
|
||||
? `
|
||||
and x is not null
|
||||
and y is not null
|
||||
and page_x is not null
|
||||
and page_y is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
`
|
||||
: '';
|
||||
: `
|
||||
and scroll_pct is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
`;
|
||||
|
||||
const pages: HeatmapPage[] = await rawQuery(
|
||||
const rawPages: HeatmapPage[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.url_path as "urlPath",
|
||||
@@ -114,7 +116,7 @@ async function relationalQuery(
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${clickPageFilter}
|
||||
${pageFilter}
|
||||
group by h.url_path
|
||||
order by sessions desc, count desc
|
||||
limit ${PAGE_LIMIT}
|
||||
@@ -122,6 +124,7 @@ async function relationalQuery(
|
||||
{ ...filterContext.queryParams, websiteId, eventType, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
const pages = rawPages.filter(page => !shouldSkipSnapshot(page.urlPath));
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
@@ -153,6 +156,7 @@ async function relationalQuery(
|
||||
|
||||
const dimRows: {
|
||||
totalSessions: number | string;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -160,6 +164,7 @@ async function relationalQuery(
|
||||
`
|
||||
select
|
||||
count(distinct h.visit_id)::int as "totalSessions",
|
||||
(mode() within group (order by h.page_w))::int as "pageW",
|
||||
(mode() within group (order by h.page_h))::int as "pageH",
|
||||
(mode() within group (order by h.viewport_w))::int as "viewportW",
|
||||
(mode() within group (order by h.viewport_h))::int as "viewportH"
|
||||
@@ -179,20 +184,18 @@ async function relationalQuery(
|
||||
const scroll = {
|
||||
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
|
||||
totalSessions: Number(dim?.totalSessions ?? 0),
|
||||
pageW: dim?.pageW ?? null,
|
||||
pageH: dim?.pageH ?? null,
|
||||
viewportW: dim?.viewportW ?? null,
|
||||
viewportH: dim?.viewportH ?? null,
|
||||
};
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -210,6 +213,10 @@ async function relationalQuery(
|
||||
h.node_id as "nodeId",
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as "pageX",
|
||||
h.page_y as "pageY",
|
||||
h.page_w as "pageW",
|
||||
h.page_h as "pageH",
|
||||
h.viewport_w as "viewportW",
|
||||
h.viewport_h as "viewportH",
|
||||
count(*)::int as count
|
||||
@@ -221,9 +228,22 @@ async function relationalQuery(
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
and h.x is not null
|
||||
and h.y is not null
|
||||
and h.page_x is not null
|
||||
and h.page_y is not null
|
||||
and h.page_w is not null
|
||||
and h.page_h is not null
|
||||
and h.viewport_w is not null
|
||||
and h.viewport_h is not null
|
||||
group by h.node_id, h.x, h.y, h.viewport_w, h.viewport_h
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
h.page_y,
|
||||
h.page_w,
|
||||
h.page_h,
|
||||
h.viewport_w,
|
||||
h.viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
@@ -232,123 +252,18 @@ async function relationalQuery(
|
||||
);
|
||||
|
||||
const viewport = pickSnapshotViewport(rawPoints);
|
||||
const point = pickRepresentativePoint(rawPoints, viewport);
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
pageW: viewport?.pageW ?? null,
|
||||
pageH: viewport?.pageH ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getRelationalSnapshot(
|
||||
rawQuery: typeof prisma.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {{viewportW}}
|
||||
and h.viewport_h = {{viewportH}}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {{pointX}}
|
||||
and h.y = {{pointY}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
with best_visit as (
|
||||
select
|
||||
h.visit_id as visit_id,
|
||||
count(*) as event_count,
|
||||
min(h.created_at) as first_seen
|
||||
from heatmap_event h
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
)
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
coalesce(h.replay_time_ms, (extract(epoch from h.created_at) * 1000)::bigint) as "timestamp",
|
||||
h.replay_chunk_index as "chunkIndex",
|
||||
h.replay_event_index as "eventIndex"
|
||||
from heatmap_event h
|
||||
inner join best_visit bv on bv.visit_id = h.visit_id
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
case when h.replay_chunk_index is null then 1 else 0 end asc,
|
||||
h.replay_chunk_index asc nulls last,
|
||||
h.replay_event_index asc nulls last,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
@@ -357,15 +272,24 @@ async function clickhouseQuery(
|
||||
const { startDate, endDate, urlPath, mode = 'click' } = parameters;
|
||||
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
|
||||
const filterContext = getClickhouseHeatmapFilterContext(websiteId, parameters);
|
||||
const clickPageFilter =
|
||||
const pageFilter =
|
||||
mode === 'click'
|
||||
? `
|
||||
and x is not null
|
||||
and y is not null
|
||||
and page_x is not null
|
||||
and page_y is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
`
|
||||
: '';
|
||||
: `
|
||||
and scroll_pct is not null
|
||||
and page_w is not null
|
||||
and page_h is not null
|
||||
and viewport_w is not null
|
||||
`;
|
||||
|
||||
const pageRows = await rawQuery<
|
||||
{ urlPath: string; count: string | number; sessions: string | number }[]
|
||||
@@ -380,7 +304,7 @@ async function clickhouseQuery(
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${clickPageFilter}
|
||||
${pageFilter}
|
||||
group by h.url_path
|
||||
order by sessions desc, count desc
|
||||
limit ${PAGE_LIMIT}
|
||||
@@ -389,11 +313,13 @@ async function clickhouseQuery(
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const pages: HeatmapPage[] = pageRows.map(p => ({
|
||||
urlPath: p.urlPath,
|
||||
count: Number(p.count),
|
||||
sessions: Number(p.sessions),
|
||||
}));
|
||||
const pages: HeatmapPage[] = pageRows
|
||||
.map(p => ({
|
||||
urlPath: p.urlPath,
|
||||
count: Number(p.count),
|
||||
sessions: Number(p.sessions),
|
||||
}))
|
||||
.filter(page => !shouldSkipSnapshot(page.urlPath));
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
@@ -426,6 +352,7 @@ async function clickhouseQuery(
|
||||
const dimRows = await rawQuery<
|
||||
{
|
||||
totalSessions: number | string;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
@@ -434,6 +361,7 @@ async function clickhouseQuery(
|
||||
`
|
||||
select
|
||||
uniq(h.visit_id) as totalSessions,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(h.page_w), 1))) as pageW,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(h.page_h), 1))) as pageH,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_w), 1))) as viewportW,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(h.viewport_h), 1))) as viewportH
|
||||
@@ -453,22 +381,20 @@ async function clickhouseQuery(
|
||||
const scroll = {
|
||||
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
|
||||
totalSessions: Number(dim?.totalSessions ?? 0),
|
||||
pageW: dim?.pageW === null || dim?.pageW === undefined ? null : Number(dim.pageW),
|
||||
pageH: dim?.pageH === null || dim?.pageH === undefined ? null : Number(dim.pageH),
|
||||
viewportW:
|
||||
dim?.viewportW === null || dim?.viewportW === undefined ? null : Number(dim.viewportW),
|
||||
viewportH:
|
||||
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
|
||||
};
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
pageW: scroll.pageW,
|
||||
pageH: scroll.pageH,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -485,6 +411,10 @@ async function clickhouseQuery(
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: string | number;
|
||||
@@ -495,6 +425,10 @@ async function clickhouseQuery(
|
||||
h.node_id as nodeId,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as pageX,
|
||||
h.page_y as pageY,
|
||||
h.page_w as pageW,
|
||||
h.page_h as pageH,
|
||||
h.viewport_w as viewportW,
|
||||
h.viewport_h as viewportH,
|
||||
count() as count
|
||||
@@ -506,9 +440,22 @@ async function clickhouseQuery(
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and h.x is not null
|
||||
and h.y is not null
|
||||
and h.page_x is not null
|
||||
and h.page_y is not null
|
||||
and h.page_w is not null
|
||||
and h.page_h is not null
|
||||
and h.viewport_w is not null
|
||||
and h.viewport_h is not null
|
||||
group by h.node_id, h.x, h.y, h.viewport_w, h.viewport_h
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
h.page_y,
|
||||
h.page_w,
|
||||
h.page_h,
|
||||
h.viewport_w,
|
||||
h.viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
@@ -520,189 +467,97 @@ async function clickhouseQuery(
|
||||
nodeId: p.nodeId === null || p.nodeId === undefined ? null : Number(p.nodeId),
|
||||
x: Number(p.x),
|
||||
y: Number(p.y),
|
||||
pageX: Number(p.pageX),
|
||||
pageY: Number(p.pageY),
|
||||
pageW: Number(p.pageW),
|
||||
pageH: Number(p.pageH),
|
||||
viewportW: Number(p.viewportW),
|
||||
viewportH: Number(p.viewportH),
|
||||
count: Number(p.count),
|
||||
}));
|
||||
|
||||
const viewport = pickSnapshotViewport(points);
|
||||
const point = pickRepresentativePoint(points, viewport);
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
const snapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
pageW: viewport?.pageW ?? null,
|
||||
pageH: viewport?.pageH ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getClickhouseSnapshot(
|
||||
rawQuery: typeof clickhouse.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {viewportW:UInt32}
|
||||
and h.viewport_h = {viewportH:UInt32}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {pointX:UInt32}
|
||||
and h.y = {pointY:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
with best_visit as (
|
||||
select
|
||||
h.visit_id as visit_id,
|
||||
count() as event_count,
|
||||
min(h.created_at) as first_seen
|
||||
from heatmap_event h
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
)
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
ifNull(h.replay_time_ms, toInt64(toUnixTimestamp(h.created_at)) * 1000) as timestamp,
|
||||
h.replay_chunk_index as chunkIndex,
|
||||
h.replay_event_index as eventIndex
|
||||
from heatmap_event h
|
||||
inner join best_visit bv on bv.visit_id = h.visit_id
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
${filterContext.joinQuery}
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
isNull(h.replay_chunk_index) asc,
|
||||
h.replay_chunk_index asc,
|
||||
h.replay_event_index asc,
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
return mapSnapshot(rows[0]);
|
||||
}
|
||||
|
||||
function emptyScroll(): HeatmapResult['scroll'] {
|
||||
return {
|
||||
buckets: [],
|
||||
totalSessions: 0,
|
||||
pageW: null,
|
||||
pageH: null,
|
||||
viewportW: null,
|
||||
viewportH: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickSnapshotViewport(points: HeatmapPoint[]): { width: number; height: number } | null {
|
||||
const buckets = new Map<string, { width: number; height: number; count: number }>();
|
||||
function pickSnapshotViewport(
|
||||
points: HeatmapPoint[],
|
||||
): { width: number; height: number; pageW: number; pageH: number } | null {
|
||||
const viewportBuckets = new Map<
|
||||
string,
|
||||
{
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
maxPageW: number;
|
||||
maxPageH: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
const viewportKey = `${p.viewportW}x${p.viewportH}`;
|
||||
const viewportBucket = viewportBuckets.get(viewportKey);
|
||||
|
||||
if (viewportBucket) {
|
||||
viewportBucket.count += p.count;
|
||||
viewportBucket.maxPageW = Math.max(viewportBucket.maxPageW, p.pageW);
|
||||
viewportBucket.maxPageH = Math.max(viewportBucket.maxPageH, p.pageH);
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
viewportBuckets.set(viewportKey, {
|
||||
width: p.viewportW,
|
||||
height: p.viewportH,
|
||||
count: p.count,
|
||||
maxPageW: p.pageW,
|
||||
maxPageH: p.pageH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let best: { width: number; height: number; count: number } | null = null;
|
||||
for (const bucket of buckets.values()) {
|
||||
if (!best || bucket.count > best.count) {
|
||||
best = bucket;
|
||||
let bestViewport:
|
||||
| {
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
maxPageW: number;
|
||||
maxPageH: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
for (const bucket of viewportBuckets.values()) {
|
||||
if (!bestViewport || bucket.count > bestViewport.count) {
|
||||
bestViewport = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { width: best.width, height: best.height } : null;
|
||||
}
|
||||
|
||||
function pickRepresentativePoint(
|
||||
points: HeatmapPoint[],
|
||||
viewport: { width: number; height: number } | null,
|
||||
): SnapshotPoint | null {
|
||||
if (!viewport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = points.find(
|
||||
point => point.viewportW === viewport.width && point.viewportH === viewport.height,
|
||||
);
|
||||
|
||||
return match ? { x: match.x, y: match.y } : null;
|
||||
}
|
||||
|
||||
function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null {
|
||||
if (!row) {
|
||||
if (!bestViewport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
replayId: row.replayId,
|
||||
timestamp: Number(row.timestamp),
|
||||
chunkIndex:
|
||||
row.chunkIndex === null || row.chunkIndex === undefined ? null : Number(row.chunkIndex),
|
||||
eventIndex:
|
||||
row.eventIndex === null || row.eventIndex === undefined ? null : Number(row.eventIndex),
|
||||
width: bestViewport.width,
|
||||
height: bestViewport.height,
|
||||
pageW: bestViewport.maxPageW,
|
||||
pageH: bestViewport.maxPageH,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface HeatmapEventRow {
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
pageX: number | null;
|
||||
pageY: number | null;
|
||||
pageW: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageH: number | null;
|
||||
@@ -26,12 +29,38 @@ export interface HeatmapEventRow {
|
||||
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
if (!rows?.length) return;
|
||||
|
||||
const normalizedRows = rows.map(r => ({
|
||||
...r,
|
||||
nodeId: toInt(r.nodeId),
|
||||
x: toInt(r.x),
|
||||
y: toInt(r.y),
|
||||
pageX: toInt(r.pageX),
|
||||
pageY: toInt(r.pageY),
|
||||
pageW: toInt(r.pageW),
|
||||
viewportW: toInt(r.viewportW),
|
||||
viewportH: toInt(r.viewportH),
|
||||
pageH: toInt(r.pageH),
|
||||
scrollPct: toScrollPct(r.scrollPct),
|
||||
}));
|
||||
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(rows),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(rows),
|
||||
[PRISMA]: () => relationalQuery(normalizedRows),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(normalizedRows),
|
||||
});
|
||||
}
|
||||
|
||||
function toInt(value: number | null) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
|
||||
function toScrollPct(value: number | null) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
return prisma.client.heatmapEvent.createMany({
|
||||
data: rows.map(r => ({
|
||||
@@ -44,6 +73,9 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
nodeId: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
pageX: r.pageX,
|
||||
pageY: r.pageY,
|
||||
pageW: r.pageW,
|
||||
viewportW: r.viewportW,
|
||||
viewportH: r.viewportH,
|
||||
pageH: r.pageH,
|
||||
@@ -70,6 +102,9 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
node_id: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
page_x: r.pageX,
|
||||
page_y: r.pageY,
|
||||
page_w: r.pageW,
|
||||
viewport_w: r.viewportW,
|
||||
viewport_h: r.viewportH,
|
||||
page_h: r.pageH,
|
||||
|
||||
@@ -49,8 +49,6 @@ export async function relationalQuery({
|
||||
for (const data of flattenedData) {
|
||||
const { sessionId, dataKey, ...props } = data;
|
||||
|
||||
// Try to update existing record using compound where clause
|
||||
// This is safer than using id from a previous query due to race conditions
|
||||
const updateResult = await client.sessionData.updateMany({
|
||||
where: {
|
||||
sessionId,
|
||||
|
||||
+319
-113
@@ -3,14 +3,15 @@ import { record } from 'rrweb';
|
||||
(window => {
|
||||
const { document } = window;
|
||||
const { currentScript } = document;
|
||||
|
||||
if (!currentScript) return;
|
||||
|
||||
const _data = 'data-';
|
||||
const attr = currentScript.getAttribute.bind(currentScript);
|
||||
const config = value => attr(`${_data}${value}`);
|
||||
|
||||
const website = config(`website-id`);
|
||||
const hostUrl = config(`host-url`);
|
||||
const website = config('website-id');
|
||||
const hostUrl = config('host-url');
|
||||
|
||||
if (!website) return;
|
||||
|
||||
@@ -18,41 +19,45 @@ import { record } from 'rrweb';
|
||||
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
|
||||
const hostBase = host.replace(/\/$/, '');
|
||||
const endpoint = `${hostBase}__COLLECT_REPLAY_ENDPOINT__`;
|
||||
const configEndpoint = `${hostBase}__RECORDER_CONFIG_ENDPOINT__`.replace(
|
||||
'{websiteId}',
|
||||
website,
|
||||
);
|
||||
const configEndpoint = `${hostBase}__RECORDER_CONFIG_ENDPOINT__`.replace('{websiteId}', website);
|
||||
|
||||
const FLUSH_EVENT_COUNT = 100;
|
||||
const FLUSH_INTERVAL = 10000;
|
||||
const REPLAY_FLUSH_EVENT_COUNT = 100;
|
||||
const REPLAY_FLUSH_INTERVAL = 10000;
|
||||
const HEATMAP_FLUSH_EVENT_COUNT = 20;
|
||||
const HEATMAP_FLUSH_INTERVAL = 5000;
|
||||
|
||||
let replayEnabled = false;
|
||||
let heatmapEnabled = false;
|
||||
let sampleRate = 0.15;
|
||||
let heatmapSampleRate = 0.15;
|
||||
let maskLevel = 'moderate';
|
||||
let maxDuration = 300000;
|
||||
let blockSelector = '';
|
||||
|
||||
let eventBuffer = [];
|
||||
let stopFn = null;
|
||||
let flushTimer = null;
|
||||
let startTime = null;
|
||||
let stopped = false;
|
||||
let recorderReady = false;
|
||||
let customEventBuffer = [];
|
||||
let replayBuffer = [];
|
||||
let heatmapBuffer = [];
|
||||
let replayStopFn = null;
|
||||
let replayFlushTimer = null;
|
||||
let heatmapFlushTimer = null;
|
||||
let replayStartTime = null;
|
||||
let replayStopped = false;
|
||||
let heatmapStarted = false;
|
||||
|
||||
const sendEvents = (events, useKeepalive = false) => {
|
||||
const session = window.umami?.getSession?.();
|
||||
if (!session?.cache) return;
|
||||
const getSessionCache = () => window.umami?.getSession?.()?.cache;
|
||||
|
||||
const sendPayload = (type, payload, useKeepalive = false) => {
|
||||
const cache = getSessionCache();
|
||||
|
||||
if (!cache) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
type: 'record',
|
||||
type,
|
||||
payload: {
|
||||
website,
|
||||
events,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
// keepalive has a 64KB body limit — only use it for small payloads on unload
|
||||
const keepalive = useKeepalive && body.length < 60000;
|
||||
|
||||
return fetch(endpoint, {
|
||||
@@ -61,67 +66,79 @@ import { record } from 'rrweb';
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-umami-cache': session.cache,
|
||||
'x-umami-cache': cache,
|
||||
},
|
||||
credentials: 'omit',
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const flush = (useKeepalive = false) => {
|
||||
if (!eventBuffer.length) return;
|
||||
const flushReplay = (useKeepalive = false) => {
|
||||
if (!replayBuffer.length) return;
|
||||
|
||||
const events = eventBuffer;
|
||||
eventBuffer = [];
|
||||
const events = replayBuffer;
|
||||
replayBuffer = [];
|
||||
|
||||
sendEvents(events, useKeepalive);
|
||||
sendPayload(
|
||||
'record',
|
||||
{
|
||||
events,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
useKeepalive,
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flush, FLUSH_INTERVAL);
|
||||
const flushHeatmap = (useKeepalive = false) => {
|
||||
if (!heatmapBuffer.length) return;
|
||||
|
||||
const events = heatmapBuffer;
|
||||
heatmapBuffer = [];
|
||||
|
||||
sendPayload(
|
||||
'heatmap',
|
||||
{
|
||||
events,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
useKeepalive,
|
||||
);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flush();
|
||||
if (stopFn) stopFn();
|
||||
const scheduleReplayFlush = () => {
|
||||
if (replayFlushTimer) clearTimeout(replayFlushTimer);
|
||||
replayFlushTimer = setTimeout(() => flushReplay(), REPLAY_FLUSH_INTERVAL);
|
||||
};
|
||||
|
||||
const flushCustomEvents = () => {
|
||||
if (!recorderReady || !customEventBuffer.length) return;
|
||||
|
||||
const events = customEventBuffer;
|
||||
customEventBuffer = [];
|
||||
|
||||
for (const event of events) {
|
||||
addCustomEvent(event.tag, event.payload);
|
||||
}
|
||||
const scheduleHeatmapFlush = () => {
|
||||
if (heatmapFlushTimer) clearTimeout(heatmapFlushTimer);
|
||||
heatmapFlushTimer = setTimeout(() => flushHeatmap(), HEATMAP_FLUSH_INTERVAL);
|
||||
};
|
||||
|
||||
const addCustomEvent = (tag, payload) => {
|
||||
if (stopped) return;
|
||||
const queueHeatmapEvent = event => {
|
||||
heatmapBuffer.push({
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (!recorderReady) {
|
||||
customEventBuffer.push({ tag, payload });
|
||||
if (heatmapBuffer.length >= HEATMAP_FLUSH_EVENT_COUNT) {
|
||||
flushHeatmap();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
record.addCustomEvent(tag, payload);
|
||||
} catch (e) {
|
||||
if (e?.message === 'please add custom event after start recording') {
|
||||
recorderReady = false;
|
||||
customEventBuffer.push({ tag, payload });
|
||||
setTimeout(() => {
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
scheduleHeatmapFlush();
|
||||
};
|
||||
|
||||
throw e;
|
||||
const stopReplay = () => {
|
||||
if (replayStopped) return;
|
||||
|
||||
replayStopped = true;
|
||||
|
||||
if (replayFlushTimer) clearTimeout(replayFlushTimer);
|
||||
flushReplay();
|
||||
|
||||
if (replayStopFn) {
|
||||
replayStopFn();
|
||||
replayStopFn = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,49 +149,142 @@ import { record } from 'rrweb';
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: '*',
|
||||
};
|
||||
default: // moderate
|
||||
default:
|
||||
return {
|
||||
maskAllInputs: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const waitForSession = (attempts = 0) => {
|
||||
if (attempts > 50) return;
|
||||
const shouldSample = value => {
|
||||
if (value >= 1) return true;
|
||||
if (value <= 0) return false;
|
||||
|
||||
const session = window.umami?.getSession?.();
|
||||
if (session?.cache) {
|
||||
beginRecording();
|
||||
} else {
|
||||
setTimeout(() => waitForSession(attempts + 1), 100);
|
||||
}
|
||||
return Math.random() <= value;
|
||||
};
|
||||
|
||||
const beginRecording = () => {
|
||||
startTime = Date.now();
|
||||
const measureElementWidth = element => {
|
||||
if (!element) return 0;
|
||||
|
||||
const maskConfig = getMaskConfig(maskLevel);
|
||||
const rect = element.getBoundingClientRect?.();
|
||||
|
||||
stopFn = record({
|
||||
return Math.max(
|
||||
element.scrollWidth || 0,
|
||||
element.offsetWidth || 0,
|
||||
element.clientWidth || 0,
|
||||
rect?.width || 0,
|
||||
);
|
||||
};
|
||||
|
||||
const measureElementHeight = element => {
|
||||
if (!element) return 0;
|
||||
|
||||
const rect = element.getBoundingClientRect?.();
|
||||
|
||||
return Math.max(
|
||||
element.scrollHeight || 0,
|
||||
element.offsetHeight || 0,
|
||||
element.clientHeight || 0,
|
||||
rect?.height || 0,
|
||||
);
|
||||
};
|
||||
|
||||
const measureDocumentBounds = doc => {
|
||||
const body = doc?.body;
|
||||
|
||||
if (!body) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
}
|
||||
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.max(0, Math.round(maxRight)),
|
||||
height: Math.max(0, Math.round(maxBottom)),
|
||||
};
|
||||
};
|
||||
|
||||
const createPageMetrics = () => {
|
||||
const computePageMetrics = ({ includeBounds = false } = {}) => {
|
||||
const scrollingElement = document.scrollingElement || document.documentElement;
|
||||
const firstChild = document.body?.firstElementChild;
|
||||
const bounds = includeBounds ? measureDocumentBounds(document) : null;
|
||||
const pageW = Math.max(
|
||||
bounds?.width || 0,
|
||||
measureElementWidth(scrollingElement),
|
||||
measureElementWidth(document.documentElement),
|
||||
measureElementWidth(document.body),
|
||||
measureElementWidth(firstChild),
|
||||
);
|
||||
const pageH = Math.max(
|
||||
bounds?.height || 0,
|
||||
measureElementHeight(scrollingElement),
|
||||
measureElementHeight(document.documentElement),
|
||||
measureElementHeight(document.body),
|
||||
measureElementHeight(firstChild),
|
||||
);
|
||||
const scrollLeft = scrollingElement?.scrollLeft || window.scrollX || 0;
|
||||
const scrollTop = scrollingElement?.scrollTop || window.scrollY || 0;
|
||||
|
||||
return {
|
||||
pageW,
|
||||
pageH,
|
||||
scrollLeft,
|
||||
scrollTop,
|
||||
};
|
||||
};
|
||||
|
||||
const computeScrollPct = () => {
|
||||
const { pageH, scrollTop } = computePageMetrics();
|
||||
const visible = scrollTop + window.innerHeight;
|
||||
|
||||
return {
|
||||
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
|
||||
pageH,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
computePageMetrics,
|
||||
computeScrollPct,
|
||||
};
|
||||
};
|
||||
|
||||
const beginReplayCapture = () => {
|
||||
replayStartTime = Date.now();
|
||||
|
||||
replayStopFn = record({
|
||||
emit(event) {
|
||||
if (stopped) return;
|
||||
if (replayStopped) return;
|
||||
|
||||
if (Date.now() - startTime > maxDuration) {
|
||||
stop();
|
||||
if (Date.now() - replayStartTime > maxDuration) {
|
||||
stopReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
eventBuffer.push(event);
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
replayBuffer.push(event);
|
||||
|
||||
if (eventBuffer.length >= FLUSH_EVENT_COUNT) {
|
||||
flush();
|
||||
if (replayBuffer.length >= REPLAY_FLUSH_EVENT_COUNT) {
|
||||
flushReplay();
|
||||
}
|
||||
|
||||
scheduleFlush();
|
||||
scheduleReplayFlush();
|
||||
},
|
||||
...maskConfig,
|
||||
...getMaskConfig(maskLevel),
|
||||
inlineStylesheet: true,
|
||||
slimDOMOptions: {
|
||||
script: true,
|
||||
@@ -191,54 +301,98 @@ import { record } from 'rrweb';
|
||||
checkoutEveryNms: 30000,
|
||||
...(blockSelector && { blockSelector }),
|
||||
});
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
};
|
||||
|
||||
const beginHeatmapCapture = () => {
|
||||
if (heatmapStarted) return;
|
||||
|
||||
heatmapStarted = true;
|
||||
|
||||
const { computePageMetrics, computeScrollPct } = createPageMetrics();
|
||||
let scrollUrl = location.href;
|
||||
let maxScrollPct = 0;
|
||||
let lastFlushedScrollPct = 0;
|
||||
let scrollTimer = null;
|
||||
|
||||
const computeScrollPct = () => {
|
||||
const pageH = document.documentElement.scrollHeight;
|
||||
const visible = window.scrollY + window.innerHeight;
|
||||
return {
|
||||
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
|
||||
pageH,
|
||||
};
|
||||
};
|
||||
|
||||
const flushScroll = () => {
|
||||
if (maxScrollPct <= 0) return;
|
||||
addCustomEvent('scroll-progress', {
|
||||
if (maxScrollPct <= 0 || maxScrollPct <= lastFlushedScrollPct) return;
|
||||
|
||||
const { pageW, pageH } = computePageMetrics({ includeBounds: true });
|
||||
|
||||
queueHeatmapEvent({
|
||||
type: 'scroll',
|
||||
url: scrollUrl,
|
||||
scrollPct: maxScrollPct,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
pageH: document.documentElement.scrollHeight,
|
||||
pageW,
|
||||
pageH,
|
||||
});
|
||||
|
||||
lastFlushedScrollPct = maxScrollPct;
|
||||
maxScrollPct = 0;
|
||||
};
|
||||
|
||||
const onClick = event => {
|
||||
if (!event.isTrusted || event.button !== 0) return;
|
||||
|
||||
const { pageW: rawPageW, pageH: rawPageH, scrollLeft, scrollTop } = computePageMetrics({
|
||||
includeBounds: true,
|
||||
});
|
||||
const pageX = Number.isFinite(event.pageX) ? event.pageX : event.clientX + scrollLeft;
|
||||
const pageY = Number.isFinite(event.pageY) ? event.pageY : event.clientY + scrollTop;
|
||||
const target = event.target;
|
||||
const targetRect =
|
||||
target && typeof target.getBoundingClientRect === 'function'
|
||||
? target.getBoundingClientRect()
|
||||
: null;
|
||||
const targetRight = targetRect ? targetRect.right + scrollLeft : 0;
|
||||
const targetBottom = targetRect ? targetRect.bottom + scrollTop : 0;
|
||||
const pageW = Math.max(rawPageW, Math.ceil(pageX), Math.ceil(targetRight));
|
||||
const pageH = Math.max(rawPageH, Math.ceil(pageY), Math.ceil(targetBottom));
|
||||
|
||||
queueHeatmapEvent({
|
||||
type: 'click',
|
||||
url: location.href,
|
||||
x: Math.round(event.clientX),
|
||||
y: Math.round(event.clientY),
|
||||
pageX: Math.round(pageX),
|
||||
pageY: Math.round(pageY),
|
||||
pageW,
|
||||
pageH,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (scrollTimer) return;
|
||||
|
||||
scrollTimer = setTimeout(() => {
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
|
||||
if (pct > maxScrollPct) {
|
||||
maxScrollPct = pct;
|
||||
}
|
||||
|
||||
flushScroll();
|
||||
scrollTimer = null;
|
||||
}, 200);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const onUrlChange = () => {
|
||||
if (location.href === scrollUrl) return;
|
||||
|
||||
flushScroll();
|
||||
scrollUrl = location.href;
|
||||
addCustomEvent('url-change', { url: scrollUrl });
|
||||
lastFlushedScrollPct = 0;
|
||||
};
|
||||
|
||||
const hookHistory = method => {
|
||||
const orig = history[method];
|
||||
const original = history[method];
|
||||
|
||||
history[method] = function (...args) {
|
||||
const result = orig.apply(this, args);
|
||||
const result = original.apply(this, args);
|
||||
onUrlChange();
|
||||
return result;
|
||||
};
|
||||
@@ -248,35 +402,84 @@ import { record } from 'rrweb';
|
||||
hookHistory('replaceState');
|
||||
window.addEventListener('popstate', onUrlChange);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
document.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
|
||||
// Capture initial scroll position
|
||||
{
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
|
||||
if (pct > maxScrollPct) {
|
||||
maxScrollPct = pct;
|
||||
}
|
||||
|
||||
flushScroll();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
flushHeatmap(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
flushHeatmap(true);
|
||||
});
|
||||
};
|
||||
|
||||
const waitForSession = (callback, attempts = 0) => {
|
||||
if (attempts > 50) return;
|
||||
|
||||
if (getSessionCache()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => waitForSession(callback, attempts + 1), 100);
|
||||
};
|
||||
|
||||
const startCaptures = () => {
|
||||
const shouldRecordReplay = replayEnabled && shouldSample(sampleRate);
|
||||
const shouldRecordHeatmap = heatmapEnabled && shouldSample(heatmapSampleRate);
|
||||
|
||||
if (shouldRecordHeatmap) {
|
||||
beginHeatmapCapture();
|
||||
}
|
||||
|
||||
if (shouldRecordReplay) {
|
||||
beginReplayCapture();
|
||||
}
|
||||
|
||||
if (!shouldRecordHeatmap && !shouldRecordReplay) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushReplay(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
flushReplay(true);
|
||||
});
|
||||
};
|
||||
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
const response = await fetch(configEndpoint, { credentials: 'omit' });
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.enabled) return;
|
||||
|
||||
replayEnabled = data.replayEnabled === true;
|
||||
heatmapEnabled = data.heatmapEnabled === true;
|
||||
|
||||
if (typeof data.sampleRate === 'number') sampleRate = data.sampleRate;
|
||||
if (typeof data.heatmapSampleRate === 'number') heatmapSampleRate = data.heatmapSampleRate;
|
||||
if (typeof data.maskLevel === 'string') maskLevel = data.maskLevel;
|
||||
if (typeof data.maxDuration === 'number') maxDuration = data.maxDuration;
|
||||
if (typeof data.blockSelector === 'string') blockSelector = data.blockSelector;
|
||||
@@ -284,14 +487,17 @@ import { record } from 'rrweb';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample rate check
|
||||
if (sampleRate < 1 && Math.random() > sampleRate) return;
|
||||
if (!replayEnabled && !heatmapEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
waitForSession();
|
||||
waitForSession(startCaptures);
|
||||
} else {
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (document.readyState === 'complete') waitForSession();
|
||||
if (document.readyState === 'complete') {
|
||||
waitForSession(startCaptures);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Test Convention
|
||||
|
||||
Use Vitest for unit and component tests. Cypress remains the end-to-end test runner.
|
||||
|
||||
- Place tests next to the code they cover as `*.test.ts` or `*.test.tsx`.
|
||||
- Import Vitest APIs explicitly: `import { describe, expect, test, vi } from 'vitest';`.
|
||||
- Use `test`, not `it`.
|
||||
- React component tests should import from `@/test/render`.
|
||||
- Prefer accessible Testing Library queries such as `getByRole`, `getByLabelText`, and `getByText`.
|
||||
- Use `getByTestId` only when there is no useful accessible query. The test id attribute is `data-test`.
|
||||
- Keep test doubles in the test file unless they are shared framework concerns, such as Next navigation.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
const testNavigation = vi.hoisted(() => ({
|
||||
pathname: '/',
|
||||
searchParams: new URLSearchParams(),
|
||||
router: {
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
push: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
export function setTestUrl(url: string) {
|
||||
const nextUrl = new URL(url, 'http://localhost');
|
||||
|
||||
testNavigation.pathname = nextUrl.pathname;
|
||||
testNavigation.searchParams = nextUrl.searchParams;
|
||||
|
||||
window.history.pushState({}, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
|
||||
}
|
||||
|
||||
export function getTestRouter() {
|
||||
return testNavigation.router;
|
||||
}
|
||||
|
||||
export function resetTestNavigation() {
|
||||
setTestUrl('/');
|
||||
|
||||
Object.values(testNavigation.router).forEach(mock => {
|
||||
mock.mockReset();
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: vi.fn(),
|
||||
redirect: vi.fn(),
|
||||
usePathname: () => testNavigation.pathname,
|
||||
useRouter: () => testNavigation.router,
|
||||
useSearchParams: () => testNavigation.searchParams,
|
||||
}));
|
||||
@@ -0,0 +1,83 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
type RenderOptions,
|
||||
screen,
|
||||
render as testingLibraryRender,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RouterProvider, ZenProvider } from '@umami/react-zen';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import enUS from '../../public/intl/messages/en-US.json';
|
||||
import { setTestUrl } from './navigation';
|
||||
|
||||
type TestRenderOptions = Omit<RenderOptions, 'wrapper'> & {
|
||||
locale?: string;
|
||||
messages?: Record<string, unknown>;
|
||||
queryClient?: QueryClient;
|
||||
route?: string;
|
||||
};
|
||||
|
||||
export function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function TestProviders({
|
||||
children,
|
||||
locale = 'en-US',
|
||||
messages = enUS,
|
||||
queryClient = createTestQueryClient(),
|
||||
}: {
|
||||
children: ReactNode;
|
||||
locale?: string;
|
||||
messages?: Record<string, unknown>;
|
||||
queryClient?: QueryClient;
|
||||
}) {
|
||||
return (
|
||||
<ZenProvider>
|
||||
<RouterProvider navigate={url => window.history.pushState({}, '', url)}>
|
||||
<NextIntlClientProvider locale={locale} messages={messages} onError={() => null}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</NextIntlClientProvider>
|
||||
</RouterProvider>
|
||||
</ZenProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function render(
|
||||
ui: ReactElement,
|
||||
{
|
||||
locale = 'en-US',
|
||||
messages = enUS,
|
||||
queryClient = createTestQueryClient(),
|
||||
route = '/',
|
||||
...options
|
||||
}: TestRenderOptions = {},
|
||||
) {
|
||||
setTestUrl(route);
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
user: userEvent.setup(),
|
||||
...testingLibraryRender(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<TestProviders locale={locale} messages={messages} queryClient={queryClient}>
|
||||
{children}
|
||||
</TestProviders>
|
||||
),
|
||||
...options,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export { screen, userEvent, waitFor, within };
|
||||
+53
-9
@@ -1,15 +1,59 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterAll, afterEach, beforeAll } from 'vitest';
|
||||
import { server } from './msw/server';
|
||||
import { cleanup, configure } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { resetTestNavigation } from './navigation';
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'error' });
|
||||
configure({ testIdAttribute: 'data-test' });
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '';
|
||||
readonly scrollMargin = '';
|
||||
readonly thresholds = [];
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
window.IntersectionObserver = IntersectionObserver;
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
cleanup();
|
||||
resetTestNavigation();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
+5
-12
@@ -2,21 +2,14 @@ import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/tests/e2e/**',
|
||||
'**/playwright-report/**',
|
||||
'**/test-results/**',
|
||||
],
|
||||
globals: true,
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx,js,jsx}'],
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user