diff --git a/prisma/migrations/21_add_heatmap_preview/migration.sql b/prisma/migrations/21_add_heatmap_preview/migration.sql new file mode 100644 index 000000000..6b7d162a3 --- /dev/null +++ b/prisma/migrations/21_add_heatmap_preview/migration.sql @@ -0,0 +1,38 @@ +-- AlterTable +ALTER TABLE "heatmap_snapshot" ALTER COLUMN "created_at" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "heatmap_replay_preview" ( + "preview_id" UUID NOT NULL, + "website_id" UUID NOT NULL, + "session_id" UUID NOT NULL, + "visit_id" UUID NOT NULL, + "url_path" VARCHAR(500) NOT NULL, + "viewport_w" INTEGER NOT NULL, + "viewport_h" INTEGER NOT NULL, + "replay_chunk_index" INTEGER NOT NULL, + "replay_event_index" INTEGER NOT NULL, + "replay_time_ms" BIGINT, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "heatmap_replay_preview_pkey" PRIMARY KEY ("preview_id") +); + +-- CreateIndex +CREATE INDEX "heatmap_replay_preview_website_id_idx" ON "heatmap_replay_preview"("website_id"); + +-- CreateIndex +CREATE INDEX "heatmap_replay_preview_visit_id_idx" ON "heatmap_replay_preview"("visit_id"); + +-- CreateIndex +CREATE INDEX "heatmap_replay_preview_website_id_url_path_idx" ON "heatmap_replay_preview"("website_id", "url_path"); + +-- CreateIndex +CREATE UNIQUE INDEX "heatmap_replay_preview_website_id_url_path_viewport_w_viewp_key" ON "heatmap_replay_preview"("website_id", "url_path", "viewport_w", "viewport_h"); + +-- CreateIndex +CREATE INDEX "session_replay_visit_id_idx" ON "session_replay"("visit_id"); + +-- RenameIndex +ALTER INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_eve" RENAME TO "heatmap_event_website_id_visit_id_replay_chunk_index_replay_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 16993f067..1ba608574 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model Website { sessionReplays SessionReplay[] sessionReplaysSaved SessionReplaySaved[] heatmapEvents HeatmapEvent[] + heatmapReplayPreviews HeatmapReplayPreview[] heatmapSnapshots HeatmapSnapshot[] @@index([userId]) @@ -434,6 +435,29 @@ model HeatmapEvent { @@map("heatmap_event") } +model HeatmapReplayPreview { + id String @id() @map("preview_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + sessionId String @map("session_id") @db.Uuid + visitId String @map("visit_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 + replayChunkIndex Int @map("replay_chunk_index") @db.Integer + replayEventIndex Int @map("replay_event_index") @db.Integer + replayTimeMs BigInt? @map("replay_time_ms") @db.BigInt + 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([visitId]) + @@index([websiteId, urlPath]) + @@map("heatmap_replay_preview") +} + model HeatmapSnapshot { id String @id() @map("snapshot_id") @db.Uuid websiteId String @map("website_id") @db.Uuid diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css index d02b815b1..4f19c45ca 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -120,6 +120,10 @@ overscroll-behavior: contain; } +.canvasWrapperScrollable { + overflow-x: auto; +} + .snapshotControlRow { margin-top: 8px; } @@ -168,6 +172,11 @@ -webkit-user-drag: none; } +.snapshotFrame { + width: 100%; + height: 100%; +} + .overlay { position: absolute; top: 0; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 0797c23fe..62cdc51d1 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -1,8 +1,8 @@ 'use client'; import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useResultQuery } from '@/components/hooks'; +import { useReplayQuery, useResultQuery } from '@/components/hooks'; import { getClientAuthToken } from '@/lib/client'; import { formatLongNumber } from '@/lib/format'; import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql'; @@ -111,6 +111,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H {urlPath ? ( mode === 'scroll' ? ( ) : ( setSnapshotReady(true), []); - const hasSnapshotImage = Boolean(snapshot?.imageUrl); + const hasSnapshot = Boolean(snapshot); useEffect(() => { - setSnapshotReady(!(showPage && hasSnapshotImage)); - }, [hasSnapshotImage, showPage, snapshot?.id]); + setSnapshotReady(!(showPage && hasSnapshot)); + }, [hasSnapshot, 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); @@ -275,10 +279,14 @@ function ClickHeatmapView({ const renderWidth = snapshot?.pageW ?? baseWidth; const renderHeight = snapshot?.pageH ?? baseHeight; const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX); - const canvasWidth = hasMeasuredWidth ? `min(100%, ${renderWidth}px)` : '100%'; + const canvasWidth = hasMeasuredWidth + ? snapshot?.kind === 'replay' + ? `${renderWidth}px` + : `min(100%, ${renderWidth}px)` + : '100%'; const overlayPageW = snapshot?.pageW ?? viewport?.pageW ?? baseWidth; const overlayPageH = snapshot?.pageH ?? viewport?.pageH ?? baseHeight; - const showSnapshot = renderWidth > 0 && showPage && hasSnapshotImage; + const showSnapshot = renderWidth > 0 && showPage && hasSnapshot; const showOverlay = !showSnapshot || snapshotReady; const totalClicks = visible.reduce((sum, point) => sum + point.count, 0); const showLoading = isLoading; @@ -313,18 +321,19 @@ function ClickHeatmapView({ )} - {showPage && snapshot?.status === 'failed' && ( + {showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && ( {SNAPSHOT_UNAVAILABLE_ERROR} )} -
+
@@ -336,8 +345,12 @@ function ClickHeatmapView({ <>
{showSnapshot && !snapshotReady && } - {showSnapshot && snapshot?.imageUrl && ( - + {showSnapshot && snapshot && ( + )}
{showOverlay && ( @@ -382,7 +395,7 @@ function ClickHeatmapView({
- {hasSnapshotImage && ( + {hasSnapshot && ( Show page @@ -394,11 +407,13 @@ function ClickHeatmapView({ } function ScrollHeatmapView({ + websiteId, urlPath, scroll, snapshot, isLoading, }: { + websiteId: string; urlPath: string; scroll: HeatmapResult['scroll'] | undefined; snapshot: HeatmapSnapshot | null; @@ -407,11 +422,11 @@ function ScrollHeatmapView({ const [showPage, setShowPage] = useState(true); const [snapshotReady, setSnapshotReady] = useState(false); const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); - const hasSnapshotImage = Boolean(snapshot?.imageUrl); + const hasSnapshot = Boolean(snapshot); useEffect(() => { - setSnapshotReady(!(showPage && hasSnapshotImage)); - }, [hasSnapshotImage, showPage, snapshot?.id]); + setSnapshotReady(!(showPage && hasSnapshot)); + }, [hasSnapshot, showPage, snapshot?.id]); const { buckets = [], totalSessions = 0, @@ -425,8 +440,12 @@ function ScrollHeatmapView({ const renderWidth = snapshot?.pageW ?? baseWidth; const renderHeight = snapshot?.pageH ?? baseHeight; const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW); - const canvasWidth = hasMeasuredWidth ? `min(100%, ${renderWidth}px)` : '100%'; - const showSnapshot = renderWidth > 0 && showPage && hasSnapshotImage; + const canvasWidth = hasMeasuredWidth + ? snapshot?.kind === 'replay' + ? `${renderWidth}px` + : `min(100%, ${renderWidth}px)` + : '100%'; + const showSnapshot = renderWidth > 0 && showPage && hasSnapshot; const showOverlay = !showSnapshot || snapshotReady; const hasScrollData = Boolean(scroll && totalSessions > 0 && pageW && pageH && viewportW); const showLoading = isLoading; @@ -478,18 +497,19 @@ function ScrollHeatmapView({ )} - {showPage && snapshot?.status === 'failed' && ( + {showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && ( {SNAPSHOT_UNAVAILABLE_ERROR} )} -
+
@@ -500,8 +520,12 @@ function ScrollHeatmapView({ ) : (
{showSnapshot && !snapshotReady && } - {showSnapshot && snapshot?.imageUrl && ( - + {showSnapshot && snapshot && ( + )} {showOverlay && (
@@ -536,7 +560,7 @@ function ScrollHeatmapView({
- {hasSnapshotImage && ( + {hasSnapshot && ( Show page @@ -547,11 +571,28 @@ function ScrollHeatmapView({ ); } +function SnapshotPreview({ + websiteId, + snapshot, + onReady, +}: { + websiteId: string; + snapshot: HeatmapSnapshot; + onReady: () => void; +}) { + if (snapshot.kind === 'replay') { + return ; + } + + return ; +} + function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) { const [src, setSrc] = useState(null); + const imageUrl = snapshot.kind === 'image' ? snapshot.imageUrl : null; useEffect(() => { - if (!snapshot.imageUrl) { + if (snapshot.kind !== 'image' || !imageUrl) { setSrc(null); return; } @@ -562,7 +603,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea setSrc(null); - fetch(snapshot.imageUrl, { + fetch(imageUrl, { signal: controller.signal, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), @@ -589,7 +630,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea URL.revokeObjectURL(objectUrl); } }; - }, [snapshot.id, snapshot.imageUrl]); + }, [imageUrl, onReady, snapshot.id, snapshot.kind]); const handleLoad = useCallback(() => onReady(), [onReady]); @@ -606,6 +647,95 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea ); } +function ReplaySnapshot({ + websiteId, + snapshot, + onReady, +}: { + websiteId: string; + snapshot: Extract; + onReady: () => void; +}) { + const containerRef = useRef(null); + const replayerRef = useRef(null); + const { data: replay, isLoading } = useReplayQuery(websiteId, snapshot.replayId, { + until: snapshot.replayTimeMs ?? undefined, + chunkIndex: snapshot.chunkIndex, + eventIndex: snapshot.eventIndex, + }); + + useEffect(() => { + if (isLoading) { + return; + } + + if (!replay?.events?.length || !containerRef.current) { + onReady(); + return; + } + + let cancelled = false; + + import('rrweb') + .then(mod => { + if (cancelled || !containerRef.current) { + return; + } + + containerRef.current.innerHTML = ''; + + const replayer = new mod.Replayer(replay.events, { + root: containerRef.current, + loadTimeout: 0, + mouseTail: false, + pauseAnimation: true, + showWarning: false, + }); + + replayerRef.current = replayer; + + const firstTimestamp = Number(replay.events[0]?.timestamp ?? 0); + const lastTimestamp = Number(replay.events[replay.events.length - 1]?.timestamp ?? 0); + const timeOffset = Math.max(0, lastTimestamp - firstTimestamp); + + replayer.pause(timeOffset); + replayer.wrapper.style.width = '100%'; + replayer.wrapper.style.height = '100%'; + replayer.wrapper.style.pointerEvents = 'none'; + replayer.wrapper.style.overflow = 'hidden'; + replayer.iframe.style.display = 'block'; + replayer.iframe.style.width = '100%'; + replayer.iframe.style.height = '100%'; + replayer.iframe.style.border = '0'; + const replayMouse = replayer.wrapper.querySelector('.replayer-mouse'); + const replayMouseTail = replayer.wrapper.querySelector( + '.replayer-mouse-tail', + ); + if (replayMouse) { + replayMouse.style.display = 'none'; + } + if (replayMouseTail) { + replayMouseTail.style.display = 'none'; + } + + onReady(); + }) + .catch(() => onReady()); + + return () => { + cancelled = true; + replayerRef.current?.destroy?.(); + replayerRef.current = null; + }; + }, [isLoading, onReady, replay?.events, snapshot.id]); + + return ( +
+
+
+ ); +} + function CanvasLoading() { return (
diff --git a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts index 54e727e84..0dea8b2a2 100644 --- a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts +++ b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts @@ -20,6 +20,7 @@ const SNAPSHOT_ERROR_MAX_LENGTH = 500; export type HeatmapSnapshotStatus = (typeof SNAPSHOT_STATUS)[keyof typeof SNAPSHOT_STATUS]; export interface HeatmapSnapshotImage { + kind: 'image'; id: string; imageUrl: string | null; status: HeatmapSnapshotStatus; @@ -331,6 +332,7 @@ function getSnapshotImageUrl(websiteId: string, snapshotId: string) { function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage { return { + kind: 'image', id: row.id, imageUrl: row.status === SNAPSHOT_STATUS.ready && row.hasImage @@ -599,16 +601,12 @@ function getSnapshotErrorMessage(error: unknown) { async function createSnapshotBrowser() { const endpoint = process.env.PLAYWRIGHT_URL?.trim(); - if (endpoint) { - const { chromium } = await import('playwright-core'); - return chromium.connect(endpoint); + if (!endpoint) { + return null; } - const { chromium } = await import('@playwright/test'); - return chromium.launch({ - channel: 'chromium', - headless: true, - }); + const { chromium } = await import('playwright-core'); + return chromium.connect(endpoint); } async function captureSnapshot( @@ -618,6 +616,11 @@ async function captureSnapshot( pageW?: number, ): Promise { const browser = await createSnapshotBrowser(); + + if (!browser) { + throw new Error(SNAPSHOT_UNAVAILABLE_ERROR); + } + const initialViewportW = viewportW; try { @@ -698,6 +701,10 @@ export async function ensureHeatmapSnapshot({ return mapSnapshot(websiteId, existing); } + if (!process.env.PLAYWRIGHT_URL?.trim()) { + return null; + } + const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null; const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY; diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts index 785956bcd..00016ca78 100644 --- a/src/queries/sql/heatmap/getHeatmap.ts +++ b/src/queries/sql/heatmap/getHeatmap.ts @@ -9,6 +9,7 @@ import { type HeatmapSnapshotImage, shouldSkipSnapshot, } from './ensureHeatmapSnapshot'; +import { getHeatmapReplayPreview } from './getHeatmapReplayPreview'; const FUNCTION_NAME = 'getHeatmap'; @@ -47,7 +48,20 @@ export interface HeatmapScrollBucket { sessions: number; } -export type HeatmapSnapshot = HeatmapSnapshotImage; +export interface HeatmapSnapshotReplay { + kind: 'replay'; + id: string; + replayId: string; + chunkIndex: number; + eventIndex: number; + replayTimeMs: number | null; + pageW: number; + pageH: number; + viewportW: number; + viewportH: number; +} + +export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotReplay; export interface HeatmapResult { mode: HeatmapMode; @@ -194,7 +208,7 @@ async function relationalQuery( viewportW: dim?.viewportW ?? null, viewportH: dim?.viewportH ?? null, }; - const snapshot = await ensureHeatmapSnapshot({ + const snapshot = await resolveHeatmapSnapshot({ websiteId, urlPath, viewportW: scroll.viewportW, @@ -258,7 +272,7 @@ async function relationalQuery( ); const viewport = pickSnapshotViewport(rawPoints); - const snapshot = await ensureHeatmapSnapshot({ + const snapshot = await resolveHeatmapSnapshot({ websiteId, urlPath, viewportW: viewport?.width ?? null, @@ -510,6 +524,52 @@ function emptyScroll(): HeatmapResult['scroll'] { }; } +async function resolveHeatmapSnapshot({ + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, +}: { + websiteId: string; + urlPath: string; + viewportW: number | null; + viewportH: number | null; + pageW: number | null; + pageH: number | null; +}): Promise { + if (process.env.PLAYWRIGHT_URL?.trim()) { + return ensureHeatmapSnapshot({ + websiteId, + urlPath, + viewportW, + viewportH, + pageW, + pageH, + }); + } + + const replayPreview = await getHeatmapReplayPreview(websiteId, urlPath, viewportW, viewportH); + + if (replayPreview && pageW && pageH) { + return { + kind: 'replay', + id: replayPreview.id, + replayId: replayPreview.replayId, + chunkIndex: replayPreview.chunkIndex, + eventIndex: replayPreview.eventIndex, + replayTimeMs: replayPreview.replayTimeMs, + pageW, + pageH, + viewportW: replayPreview.viewportW, + viewportH: replayPreview.viewportH, + }; + } + + return null; +} + function pickSnapshotViewport( points: HeatmapPoint[], ): { width: number; height: number; pageW: number; pageH: number } | null { diff --git a/src/queries/sql/heatmap/getHeatmapReplayPreview.ts b/src/queries/sql/heatmap/getHeatmapReplayPreview.ts new file mode 100644 index 000000000..9215b03a5 --- /dev/null +++ b/src/queries/sql/heatmap/getHeatmapReplayPreview.ts @@ -0,0 +1,70 @@ +import prisma from '@/lib/prisma'; + +export interface HeatmapReplayPreview { + id: string; + replayId: string; + chunkIndex: number; + eventIndex: number; + replayTimeMs: number | null; + viewportW: number; + viewportH: number; +} + +export async function getHeatmapReplayPreview( + websiteId: string, + urlPath: string, + viewportW: number | null, + viewportH: number | null, +): Promise { + if (!websiteId || !urlPath || !viewportW || !viewportH) { + return null; + } + + const rows: { + id: string; + replayId: string; + chunkIndex: number; + eventIndex: number; + replayTimeMs: bigint | number | null; + viewportW: number; + viewportH: number; + }[] = await prisma.rawQuery( + ` + select + preview_id as id, + visit_id as "replayId", + replay_chunk_index as "chunkIndex", + replay_event_index as "eventIndex", + replay_time_ms as "replayTimeMs", + viewport_w as "viewportW", + viewport_h as "viewportH" + from heatmap_replay_preview + where website_id = {{websiteId::uuid}} + and url_path = {{urlPath}} + and viewport_w = {{viewportW}} + and viewport_h = {{viewportH}} + limit 1 + `, + { websiteId, urlPath, viewportW, viewportH }, + 'getHeatmapReplayPreview', + ); + + if (!rows.length) { + return null; + } + + const row = rows[0]; + + return { + id: row.id, + replayId: row.replayId, + chunkIndex: Number(row.chunkIndex), + eventIndex: Number(row.eventIndex), + replayTimeMs: + row.replayTimeMs === null || row.replayTimeMs === undefined + ? null + : Number(row.replayTimeMs), + viewportW: Number(row.viewportW), + viewportH: Number(row.viewportH), + }; +} diff --git a/src/queries/sql/replays/saveRecording.ts b/src/queries/sql/replays/saveRecording.ts index 01f58ca05..cb7cc9555 100644 --- a/src/queries/sql/replays/saveRecording.ts +++ b/src/queries/sql/replays/saveRecording.ts @@ -5,6 +5,12 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import kafka from '@/lib/kafka'; import prisma from '@/lib/prisma'; +const RRWEB_TYPE_FULL_SNAPSHOT = 2; +const RRWEB_TYPE_INCREMENTAL = 3; +const RRWEB_TYPE_META = 4; +const RRWEB_TYPE_CUSTOM = 5; +const RRWEB_SOURCE_VIEWPORT_RESIZE = 4; + export interface SaveRecordingArgs { websiteId: string; sessionId: string; @@ -17,10 +23,21 @@ export interface SaveRecordingArgs { } export async function saveRecording(args: SaveRecordingArgs) { - return runQuery({ + const result = await runQuery({ [PRISMA]: () => relationalQuery(args), [CLICKHOUSE]: () => clickhouseQuery(args), }); + + // rrweb-backed heatmap previews are intentionally relational-only. + if (!clickhouse.enabled) { + try { + await upsertHeatmapReplayPreviews(args); + } catch (error) { + console.error('Failed to save heatmap replay preview', error); + } + } + + return result; } async function relationalQuery({ @@ -81,3 +98,222 @@ async function clickhouseQuery({ return insert('session_replay', [message]); } + +interface HeatmapReplayPreviewRow { + websiteId: string; + sessionId: string; + visitId: string; + urlPath: string; + viewportW: number; + viewportH: number; + replayChunkIndex: number; + replayEventIndex: number; + replayTimeMs: number | null; +} + +function safePathname(href: unknown): string | null { + if (typeof href !== 'string') { + return null; + } + + try { + return new URL(href).pathname || '/'; + } catch { + return href.startsWith('/') ? href.split(/[?#]/)[0] : null; + } +} + +function toBigIntOrNull(value: number | null) { + return value === null ? null : BigInt(Math.trunc(value)); +} + +function extractHeatmapReplayPreviewRows({ + websiteId, + sessionId, + visitId, + chunkIndex, + events, +}: Pick) { + const latestByKey = new Map(); + let urlPath: string | null = null; + let viewportW: number | null = null; + let viewportH: number | null = null; + + for (const [eventIndex, event] of events.entries()) { + if (!event || typeof event !== 'object') { + continue; + } + + const replayTimeMs = + typeof event.timestamp === 'number' && Number.isFinite(event.timestamp) + ? Math.trunc(event.timestamp) + : null; + + if (event.type === RRWEB_TYPE_META && event.data) { + const nextPath = safePathname(event.data.href); + + if (nextPath) { + urlPath = nextPath; + } + + if (typeof event.data.width === 'number') { + viewportW = Math.trunc(event.data.width); + } + + if (typeof event.data.height === 'number') { + viewportH = Math.trunc(event.data.height); + } + + continue; + } + + if (event.type === RRWEB_TYPE_CUSTOM && event.data?.tag === 'url-change') { + const nextPath = safePathname(event.data.payload?.url); + + if (nextPath) { + urlPath = nextPath; + } + + continue; + } + + if (event.type === RRWEB_TYPE_INCREMENTAL && event.data?.source === RRWEB_SOURCE_VIEWPORT_RESIZE) { + if (typeof event.data.width === 'number') { + viewportW = Math.trunc(event.data.width); + } + + if (typeof event.data.height === 'number') { + viewportH = Math.trunc(event.data.height); + } + } + + if ( + !urlPath || + !viewportW || + !viewportH || + (event.type !== RRWEB_TYPE_FULL_SNAPSHOT && + event.type !== RRWEB_TYPE_INCREMENTAL && + event.type !== RRWEB_TYPE_CUSTOM) + ) { + continue; + } + + const key = `${urlPath}:${viewportW}x${viewportH}`; + + latestByKey.set(key, { + websiteId, + sessionId, + visitId, + urlPath, + viewportW, + viewportH, + replayChunkIndex: chunkIndex, + replayEventIndex: eventIndex, + replayTimeMs, + }); + } + + return Array.from(latestByKey.values()); +} + +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 = {}) { + 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 upsertHeatmapReplayPreviews({ + websiteId, + sessionId, + visitId, + chunkIndex, + events, +}: Pick) { + const previewRows = extractHeatmapReplayPreviewRows({ + websiteId, + sessionId, + visitId, + chunkIndex, + events, + }); + + for (const row of previewRows) { + await rawExecute( + ` + insert into heatmap_replay_preview ( + preview_id, + website_id, + session_id, + visit_id, + url_path, + viewport_w, + viewport_h, + replay_chunk_index, + replay_event_index, + replay_time_ms + ) + values ( + {{id::uuid}}, + {{websiteId::uuid}}, + {{sessionId::uuid}}, + {{visitId::uuid}}, + {{urlPath}}, + {{viewportW}}, + {{viewportH}}, + {{replayChunkIndex}}, + {{replayEventIndex}}, + {{replayTimeMs::bigint}} + ) + on conflict (website_id, url_path, viewport_w, viewport_h) + do update set + session_id = excluded.session_id, + visit_id = excluded.visit_id, + replay_chunk_index = excluded.replay_chunk_index, + replay_event_index = excluded.replay_event_index, + replay_time_ms = excluded.replay_time_ms, + updated_at = now() + `, + { + id: uuid(), + websiteId: row.websiteId, + sessionId: row.sessionId, + visitId: row.visitId, + urlPath: row.urlPath, + viewportW: row.viewportW, + viewportH: row.viewportH, + replayChunkIndex: row.replayChunkIndex, + replayEventIndex: row.replayEventIndex, + replayTimeMs: toBigIntOrNull(row.replayTimeMs), + }, + ); + } +} diff --git a/src/recorder/index.js b/src/recorder/index.js index 80ceb018a..7caa8600c 100644 --- a/src/recorder/index.js +++ b/src/recorder/index.js @@ -1,4 +1,4 @@ -import { record } from 'rrweb'; +import { addCustomEvent, record } from 'rrweb'; (window => { const { document } = window; @@ -588,6 +588,10 @@ import { record } from 'rrweb'; flushScroll(); scrollUrl = location.href; lastFlushedScrollPct = 0; + + if (replayStopFn && !replayStopped) { + addCustomEvent('url-change', { url: scrollUrl }); + } }; const hookHistory = method => {