Merge branch 'dev' into pnpm-11

This commit is contained in:
Mike Cao
2026-05-22 20:16:48 -04:00
committed by GitHub
80 changed files with 3683 additions and 3604 deletions
+1 -2
View File
@@ -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
View File
@@ -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;
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,4 @@
/// <reference types="node" />
import { defineConfig, devices } from '@playwright/test';
const port = process.env.PORT ?? '3000';
+1094 -2186
View File
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
View File
@@ -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")
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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í",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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 ?? '',
+25 -4
View File
@@ -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) {
+1 -8
View File
@@ -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;
}
});
+2 -2
View File
@@ -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} />}
</>
);
+2 -7
View File
@@ -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
+15
View File
@@ -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();
});
+1 -1
View File
@@ -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 || [];
+1 -1
View File
@@ -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',
-7
View File
@@ -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(
-57
View File
@@ -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');
});
-246
View File
@@ -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
View File
@@ -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>(
+3
View File
@@ -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 {
+100
View File
@@ -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()),
};
}
-5
View File
@@ -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;
@@ -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
View File
@@ -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>[]) {
+54
View File
@@ -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;
}
-3
View File
@@ -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 } }] };
+57 -46
View File
@@ -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),
};
}
+153 -298
View File
@@ -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,
};
}
+37 -2
View File
@@ -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
View File
@@ -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);
}
});
}
};
+11
View File
@@ -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.
+43
View File
@@ -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,
}));
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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'],
},
});