diff --git a/db/clickhouse/migrations/12_add_heatmap.sql b/db/clickhouse/migrations/12_add_heatmap.sql index a9253899b..ba5f93cb7 100644 --- a/db/clickhouse/migrations/12_add_heatmap.sql +++ b/db/clickhouse/migrations/12_add_heatmap.sql @@ -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), diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 0f7f84156..a075977f1 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -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), diff --git a/prisma/migrations/20_add_heatmap/migration.sql b/prisma/migrations/20_add_heatmap/migration.sql index 6c73964af..99506cbc3 100644 --- a/prisma/migrations/20_add_heatmap/migration.sql +++ b/prisma/migrations/20_add_heatmap/migration.sql @@ -9,6 +9,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, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd6d851cf..45ea6f169 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -411,6 +411,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 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 2213fdfc7..9db53fdfe 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -1,7 +1,7 @@ .layoutGrid { height: 100%; min-height: 0; - overflow: hidden; + overflow: visible; } .pageList { @@ -102,6 +102,10 @@ .canvasWrapper { width: 100%; min-width: 0; + max-width: 100%; + max-height: 75vh; + overflow: auto; + overscroll-behavior: contain; } .snapshotControlRow { @@ -114,7 +118,7 @@ border: 1px solid var(--border-base); border-radius: 8px; background: var(--surface-sunken); - max-width: 100%; + flex: 0 0 auto; } .canvasClip { @@ -125,6 +129,14 @@ background: inherit; } +.snapshotClip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: inherit; + background: inherit; +} + .snapshot { position: absolute; top: 0; @@ -137,7 +149,7 @@ .snapshot :global(.replayer-wrapper) { border: 0; - overflow: hidden; + overflow: visible; } .snapshot :global(iframe) { diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 6365122fa..f8f3061ee 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -9,22 +9,29 @@ import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from ' import styles from './Heatmap.module.css'; import 'rrweb/dist/replay/rrweb-replay.css'; -const MAX_RENDER_WIDTH = 1024; +const MAX_FIXED_WIDTH_OVERRUN = 160; +const CLICK_EDGE_PADDING = 4; function useElementWidth() { const ref = useRef(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); + const nextWidth = entries[0]?.contentRect.width ?? 0; + setWidth(nextWidth); }); + ro.observe(el); + return () => ro.disconnect(); }, []); + return [ref, width] as const; } @@ -44,6 +51,8 @@ interface ReplayInstance { interface ViewportBucket { width: number; height: number; + pageW: number; + pageH: number; count: number; } @@ -201,21 +210,42 @@ function PageList({ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { if (!points.length) return null; - const buckets = new Map(); + const viewportBuckets = new Map< + string, + ViewportBucket & { maxPageW: number; maxPageH: number } + >(); for (const p of points) { - const key = `${p.viewportW}x${p.viewportH}`; - const existing = buckets.get(key); + const viewportKey = `${p.viewportW}x${p.viewportH}`; + const existing = viewportBuckets.get(viewportKey); if (existing) { existing.count += p.count; + existing.maxPageW = Math.max(existing.maxPageW, p.pageW); + existing.maxPageH = Math.max(existing.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, + pageW: p.pageW, + pageH: p.pageH, + count: p.count, + maxPageW: p.pageW, + maxPageH: p.pageH, + }); } } - let best: ViewportBucket | null = null; - for (const b of buckets.values()) { + let best: (ViewportBucket & { maxPageW: number; maxPageH: number }) | null = null; + for (const b of viewportBuckets.values()) { if (!best || b.count > best.count) best = b; } - return best; + if (!best) return null; + + return { + width: best.width, + height: best.height, + pageW: best.maxPageW, + pageH: best.maxPageH, + count: best.count, + }; } function HeatmapView({ @@ -251,7 +281,7 @@ function HeatmapView({ useEffect(() => { setSnapshotReady(!(showPage && snapshot)); - }, [containerWidth, showPage, snapshot]); + }, [showPage, snapshot]); if (isLoading) { return ; @@ -261,9 +291,14 @@ function HeatmapView({ return ; } - 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 overlayGutter = Math.max(48, Math.round(viewport.width * 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(viewport.pageW, maxPointX + overlayGutter); + const baseHeight = Math.max(viewport.pageH, maxPointY + overlayGutter); + const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth; + const scale = renderWidth / baseWidth; + const renderHeight = Math.round(baseHeight * scale); const showSnapshot = renderWidth > 0 && showPage && !!snapshot; const showOverlay = !showSnapshot || snapshotReady; @@ -287,41 +322,55 @@ function HeatmapView({ className={styles.canvas} style={{ width: renderWidth || '100%', height: renderHeight || 0 }} > -
+
{showSnapshot && !snapshotReady && } {showSnapshot && ( viewport.width} onReady={handleSnapshotReady} /> )} - {showOverlay && ( -
- {visible.map((p, i) => { - const intensity = Math.min(1, p.count / maxCount); - const size = 24 + intensity * 36; - return ( -
- ); - })} -
- )}
+ {showOverlay && ( +
+ {visible.map((p, i) => { + const intensity = Math.min(1, p.count / maxCount); + const desiredSize = 24 + intensity * 36; + const pointWidth = Math.max(baseWidth, p.pageW || 0, p.pageX); + const pointHeight = Math.max(baseHeight, p.pageH || 0, p.pageY); + const rawCenterX = (p.pageX / Math.max(1, pointWidth)) * renderWidth; + const rawCenterY = (p.pageY / Math.max(1, pointHeight)) * renderHeight; + const size = desiredSize; + const centerX = Math.max( + size / 2 + CLICK_EDGE_PADDING, + Math.min(renderWidth - size / 2 - CLICK_EDGE_PADDING, rawCenterX), + ); + const centerY = Math.max( + size / 2 + CLICK_EDGE_PADDING, + Math.min(renderHeight - size / 2 - CLICK_EDGE_PADDING, rawCenterY), + ); + return ( +
+ ); + })} +
+ )}
{snapshot && ( @@ -356,20 +405,22 @@ function ScrollHeatmapView({ useEffect(() => { setSnapshotReady(!(showPage && snapshot)); - }, [containerWidth, showPage, snapshot]); + }, [showPage, snapshot]); if (isLoading) { return ; } - if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) { + if (!scroll || scroll.totalSessions === 0 || !scroll.pageW || !scroll.pageH || !scroll.viewportW) { return ; } - 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 { buckets, totalSessions, pageW, pageH, viewportW, viewportH } = scroll; + const baseWidth = pageW; + const baseHeight = pageH; + const renderWidth = containerWidth > 0 ? Math.min(baseWidth, containerWidth) : baseWidth; + const scale = renderWidth / baseWidth; + const renderHeight = Math.round(baseHeight * scale); const showSnapshot = renderWidth > 0 && showPage && !!snapshot; const showOverlay = !showSnapshot || snapshotReady; @@ -382,9 +433,6 @@ function ScrollHeatmapView({ 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; @@ -421,9 +469,10 @@ function ScrollHeatmapView({ viewportW} onReady={handleSnapshotReady} /> )} @@ -474,6 +523,7 @@ function ReplaySnapshot({ width, height, scale, + allowWidthExpansion = true, onReady, }: { websiteId: string; @@ -481,22 +531,39 @@ function ReplaySnapshot({ width: number; height: number; scale: number; + allowWidthExpansion?: boolean; onReady: () => void; }) { const containerRef = useRef(null); const replayerRef = useRef(null); + const contentWidthRef = useRef(width); + const [contentWidth, setContentWidth] = useState(width); const { data } = useReplayQuery(websiteId, snapshot.replayId, { until: snapshot.timestamp, chunkIndex: snapshot.chunkIndex, eventIndex: snapshot.eventIndex, }) as { data?: ReplayData }; + useEffect(() => { + contentWidthRef.current = width; + setContentWidth(width); + }, [ + snapshot.chunkIndex, + snapshot.eventIndex, + snapshot.replayId, + snapshot.timestamp, + width, + ]); + useEffect(() => { const container = containerRef.current; const events = data?.events; if (!container || !events?.length) return; let cancelled = false; + let finalizeTimer: ReturnType | null = null; + let readyTimer: ReturnType | null = null; + let widthObserver: ResizeObserver | null = null; import('rrweb').then(({ Replayer }) => { if (cancelled || !containerRef.current) return; @@ -516,7 +583,70 @@ function ReplaySnapshot({ replayerRef.current = replayer; let rebuilt = false; let waitingForStyles = false; - let settled = false; + let finalizeToken = 0; + let observingWidth = false; + + const getBoundedWidth = (nextWidth: number) => { + const maxWidth = allowWidthExpansion ? Number.POSITIVE_INFINITY : width + MAX_FIXED_WIDTH_OVERRUN; + return Math.min(Math.max(width, nextWidth), maxWidth); + }; + + const commitWidth = (nextWidth: number) => { + const stableWidth = Math.max(contentWidthRef.current, getBoundedWidth(nextWidth), width); + + if (stableWidth === contentWidthRef.current) { + return; + } + + contentWidthRef.current = stableWidth; + resizeReplayFrame(replayer, stableWidth, height); + setContentWidth(stableWidth); + }; + + const scheduleReady = (delay = 250) => { + if (cancelled) { + return; + } + + if (readyTimer) { + clearTimeout(readyTimer); + } + + readyTimer = setTimeout(() => { + readyTimer = null; + if (!cancelled) { + onReady(); + } + }, delay); + }; + + const startWidthObserver = () => { + if (observingWidth || cancelled) { + return; + } + + const doc = replayer.iframe?.contentDocument; + const html = doc?.documentElement; + const body = doc?.body; + + if (!html && !body) { + return; + } + + observingWidth = true; + widthObserver = new ResizeObserver(() => { + commitWidth(measureReplayWidth(replayer, width)); + scheduleReady(); + }); + + if (html) { + widthObserver.observe(html); + } + + if (body) { + widthObserver.observe(body); + } + }; const freeze = () => { const offset = Math.max(0, snapshot.timestamp - events[0].timestamp); @@ -526,28 +656,62 @@ function ReplaySnapshot({ resizeReplayFrame(replayer, width, height); }; - const finalize = async () => { - if (settled || waitingForStyles || !rebuilt || cancelled) { + const finalize = async (token: number) => { + if (waitingForStyles || !rebuilt || cancelled) { return; } - settled = true; - await waitForReplayLayout(replayer); - if (cancelled) { + if (cancelled || token !== finalizeToken) { return; } freeze(); await waitForAnimationFrames(2); - if (cancelled) { + if (cancelled || token !== finalizeToken) { return; } freeze(); - onReady(); + await waitForAnimationFrames(2); + + if (cancelled || token !== finalizeToken) { + return; + } + + commitWidth( + await measureStableReplayWidth(replayer, width, { + maxWidth: allowWidthExpansion ? undefined : width + MAX_FIXED_WIDTH_OVERRUN, + samples: 12, + }), + ); + + if (cancelled || token !== finalizeToken) { + return; + } + + startWidthObserver(); + scheduleReady(); + }; + + const scheduleFinalize = (delay = 250) => { + if (cancelled) { + return; + } + + finalizeToken += 1; + const token = finalizeToken; + + if (finalizeTimer) { + clearTimeout(finalizeTimer); + } + + finalizeTimer = setTimeout(() => { + finalizeTimer = null; + void finalize(token); + }, delay); }; replayer.on('load-stylesheet-start', () => { @@ -556,26 +720,35 @@ function ReplaySnapshot({ replayer.on('load-stylesheet-end', () => { waitingForStyles = false; - void finalize(); + scheduleFinalize(); }); replayer.on('fullsnapshot-rebuilded', () => { rebuilt = true; - void finalize(); + scheduleFinalize(); }); replayer.on('resize', () => { - resizeReplayFrame(replayer, width, height); + scheduleFinalize(); }); setTimeout(() => { waitingForStyles = false; - void finalize(); + scheduleFinalize(0); }, 3500); }); return () => { cancelled = true; + if (finalizeTimer) { + clearTimeout(finalizeTimer); + } + if (readyTimer) { + clearTimeout(readyTimer); + } + if (widthObserver) { + widthObserver.disconnect(); + } if (replayerRef.current) { replayerRef.current.destroy(); replayerRef.current = null; @@ -584,20 +757,34 @@ function ReplaySnapshot({ container.innerHTML = ''; } }; - }, [data?.events, height, onReady, snapshot.timestamp, width]); + }, [allowWidthExpansion, data?.events, height, onReady, snapshot.timestamp, width]); useEffect(() => { if (replayerRef.current) { - resizeReplayFrame(replayerRef.current, width, height); + resizeReplayFrame(replayerRef.current, contentWidth, height); } - }, [height, width]); + }, [contentWidth, height]); + + const fitScaleX = contentWidth > width ? width / Math.max(1, contentWidth) : 1; + const snapshotScaleX = scale * fitScaleX; return (
+ style={{ + width: contentWidth, + height, + transform: `scale(${snapshotScaleX}, ${scale})`, + }} + > +
+
); } @@ -641,17 +828,36 @@ async function waitForReplayLayout(replayer: ReplayInstance) { await waitForAnimationFrames(3); } -function syncReplayDocumentViewport(replayer: ReplayInstance, width: number, height: number) { +async function measureStableReplayWidth( + replayer: ReplayInstance, + fallbackWidth: number, + { maxWidth, samples = 6 }: { maxWidth?: number; samples?: number } = {}, +) { + let stableWidth = fallbackWidth; + + for (let i = 0; i < samples; i++) { + await waitForAnimationFrames(1); + stableWidth = Math.max(stableWidth, measureReplayWidth(replayer, fallbackWidth)); + } + + return maxWidth ? Math.min(stableWidth, maxWidth) : stableWidth; +} + +function syncReplayDocumentViewport(replayer: ReplayInstance) { const doc = replayer.iframe?.contentDocument; const html = doc?.documentElement; const body = doc?.body; if (html) { html.style.margin = '0'; + html.style.overflowX = 'visible'; + html.style.overflowY = 'hidden'; } if (body) { body.style.margin = '0'; + body.style.overflowX = 'visible'; + body.style.overflowY = 'hidden'; } } @@ -667,7 +873,7 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb wrapper.style.maxHeight = `${height}px`; wrapper.style.margin = '0'; wrapper.style.padding = '0'; - wrapper.style.overflow = 'hidden'; + wrapper.style.overflow = 'visible'; } if (iframe) { @@ -683,7 +889,66 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb iframe.style.display = 'block'; } - syncReplayDocumentViewport(replayer, width, height); + syncReplayDocumentViewport(replayer); +} + +function measureReplayWidth(replayer: ReplayInstance, fallbackWidth: number) { + const doc = replayer.iframe?.contentDocument; + const win = replayer.iframe?.contentWindow; + const root = doc?.documentElement as HTMLElement | undefined; + const body = doc?.body as HTMLElement | undefined; + const scrollingElement = doc?.scrollingElement as HTMLElement | undefined; + const firstChild = body?.firstElementChild as HTMLElement | null; + const wrapperWidth = replayer.wrapper?.scrollWidth || replayer.wrapper?.offsetWidth || 0; + const iframeWidth = replayer.iframe?.scrollWidth || replayer.iframe?.offsetWidth || 0; + + return Math.round( + Math.max( + fallbackWidth, + measureDocumentWidth(doc), + wrapperWidth, + iframeWidth, + win?.innerWidth || 0, + measureElementWidth(root), + measureElementWidth(body), + measureElementWidth(scrollingElement), + measureElementWidth(firstChild || undefined), + ), + ); +} + +function measureDocumentWidth(doc?: Document | null) { + const body = doc?.body; + + if (!body) { + return 0; + } + + let maxRight = 0; + const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + let node = walker.currentNode as Element | null; + + while (node) { + const rect = node.getBoundingClientRect?.(); + + if (rect && rect.width > 0) { + maxRight = Math.max(maxRight, rect.right); + } + + node = walker.nextNode() as Element | null; + } + + return Math.max(0, Math.round(maxRight)); +} + +function measureElementWidth(element?: HTMLElement | null) { + if (!element) { + return 0; + } + + const rect = element.getBoundingClientRect(); + + return Math.max(element.scrollWidth, element.offsetWidth, element.clientWidth, rect.width); } function EmptyState({ message }: { message?: string } = {}) { diff --git a/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts index b3b749228..f65780e34 100644 --- a/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts +++ b/src/app/api/websites/[websiteId]/replays/[replayId]/route.ts @@ -3,6 +3,11 @@ import { json, unauthorized } from '@/lib/response'; import { canViewWebsite } from '@/permissions'; import { getReplayChunks } from '@/queries/sql'; +const RRWEB_TYPE_FULL_SNAPSHOT = 2; +const RRWEB_TYPE_META = 4; +const SNAPSHOT_WINDOW_CHUNK_LIMIT = 6; +const SNAPSHOT_WINDOW_MAX_CHUNKS = 96; + function getEventTimestamp(event: any): number | null { const timestamp = Number(event?.timestamp); @@ -19,6 +24,102 @@ function parseOptionalInteger(value: string | null): number | undefined { return Number.isInteger(parsed) ? parsed : undefined; } +function trimChunksToCheckpoint( + chunks: Awaited>, + { endChunkIndex, endEventIndex }: { endChunkIndex?: number; endEventIndex?: number }, +) { + let lastMetaChunkIndex: number | null = null; + let checkpointStartChunkIndex: number | null = null; + let hasMeta = false; + + for (const chunk of chunks) { + if (endChunkIndex !== undefined && chunk.chunkIndex > endChunkIndex) { + break; + } + + for (let chunkEventIndex = 0; chunkEventIndex < chunk.events.length; chunkEventIndex++) { + if ( + chunk.chunkIndex === endChunkIndex && + endEventIndex !== undefined && + chunkEventIndex > endEventIndex + ) { + break; + } + + const event = chunk.events[chunkEventIndex]; + + if (event?.type === RRWEB_TYPE_META) { + lastMetaChunkIndex = chunk.chunkIndex; + hasMeta = true; + } + + if (event?.type === RRWEB_TYPE_FULL_SNAPSHOT) { + checkpointStartChunkIndex = lastMetaChunkIndex ?? chunk.chunkIndex; + } + } + } + + if (checkpointStartChunkIndex === null) { + return { chunks, foundCheckpoint: false, hasMeta }; + } + + return { + chunks: chunks.filter(chunk => chunk.chunkIndex >= checkpointStartChunkIndex), + foundCheckpoint: true, + hasMeta, + }; +} + +async function getReplaySnapshotChunks( + websiteId: string, + replayId: string, + { endAt, endChunkIndex, endEventIndex }: { endAt?: Date; endChunkIndex: number; endEventIndex?: number }, +) { + let limit = SNAPSHOT_WINDOW_CHUNK_LIMIT; + + while (limit <= SNAPSHOT_WINDOW_MAX_CHUNKS) { + const descChunks = await getReplayChunks(websiteId, replayId, { + endAt, + endChunkIndex, + limit, + order: 'desc', + }); + + const chunks = [...descChunks].reverse(); + const { chunks: trimmedChunks, foundCheckpoint, hasMeta } = trimChunksToCheckpoint(chunks, { + endChunkIndex, + endEventIndex, + }); + + if (foundCheckpoint || descChunks.length < limit) { + if (hasMeta || trimmedChunks.length === 0) { + return trimmedChunks; + } + + const initialChunks = await getReplayChunks(websiteId, replayId, { + limit: 1, + order: 'asc', + }); + + if (!initialChunks.length) { + return trimmedChunks; + } + + const initialChunk = initialChunks[0]; + + if (trimmedChunks.some(chunk => chunk.chunkIndex === initialChunk.chunkIndex)) { + return trimmedChunks; + } + + return [initialChunk, ...trimmedChunks]; + } + + limit *= 2; + } + + return getReplayChunks(websiteId, replayId, { endAt, endChunkIndex }); +} + function mergeReplayEvents( chunks: Awaited>, { @@ -90,7 +191,14 @@ export async function GET( return unauthorized(); } - const chunks = await getReplayChunks(websiteId, replayId, { endAt, endChunkIndex }); + const chunks = + endChunkIndex !== undefined + ? await getReplaySnapshotChunks(websiteId, replayId, { + endAt, + endChunkIndex, + endEventIndex, + }) + : await getReplayChunks(websiteId, replayId, { endAt, endChunkIndex }); const allEvents = mergeReplayEvents(chunks, { until, endChunkIndex, endEventIndex }); const sessionId = chunks.length > 0 ? chunks[0].sessionId : null; const startedAt = chunks.length > 0 ? chunks[0].startedAt : null; diff --git a/src/queries/sql/heatmap/extractHeatmapEvents.ts b/src/queries/sql/heatmap/extractHeatmapEvents.ts index cb8d65c79..461ba82e8 100644 --- a/src/queries/sql/heatmap/extractHeatmapEvents.ts +++ b/src/queries/sql/heatmap/extractHeatmapEvents.ts @@ -3,15 +3,16 @@ import { HEATMAP_EVENT_TYPE } from '@/lib/constants'; const RRWEB_TYPE_INCREMENTAL = 3; const RRWEB_TYPE_META = 4; const RRWEB_TYPE_CUSTOM = 5; -const RRWEB_SOURCE_MOUSE_INTERACTION = 2; const RRWEB_SOURCE_VIEWPORT_RESIZE = 4; -const RRWEB_MOUSE_CLICK = 2; export interface ExtractedHeatmapEvent { eventType: number; 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; @@ -76,6 +77,9 @@ export function extractHeatmapEvents( nodeId: null, x: null, y: null, + pageX: null, + pageY: null, + pageW: typeof p.pageW === 'number' ? p.pageW : null, viewportW: typeof p.viewportW === 'number' ? p.viewportW : viewportW, viewportH: typeof p.viewportH === 'number' ? p.viewportH : viewportH, pageH: typeof p.pageH === 'number' ? p.pageH : null, @@ -90,6 +94,31 @@ export function extractHeatmapEvents( replayTimeMs, }); } + + if (ev.data.tag === 'heatmap-click' && ev.data.payload) { + const p = ev.data.payload; + const path = safePathname(p.url) ?? urlPath; + if (path === null) continue; + out.push({ + eventType: HEATMAP_EVENT_TYPE.click, + nodeId: null, + x: typeof p.x === 'number' ? Math.round(p.x) : null, + y: typeof p.y === 'number' ? Math.round(p.y) : null, + pageX: typeof p.pageX === 'number' ? Math.round(p.pageX) : null, + pageY: typeof p.pageY === 'number' ? Math.round(p.pageY) : null, + pageW: typeof p.pageW === 'number' ? Math.round(p.pageW) : null, + viewportW: typeof p.viewportW === 'number' ? Math.round(p.viewportW) : viewportW, + viewportH: typeof p.viewportH === 'number' ? Math.round(p.viewportH) : viewportH, + pageH: typeof p.pageH === 'number' ? Math.round(p.pageH) : null, + scrollPct: null, + urlPath: path, + createdAt: new Date(replayTimeMs ?? Date.now()), + replayChunkIndex: chunkIndex ?? null, + replayEventIndex: eventIndex, + replayTimeMs, + }); + } + continue; } @@ -102,28 +131,6 @@ export function extractHeatmapEvents( if (typeof d.height === 'number') viewportH = d.height; continue; } - - if ( - d.source === RRWEB_SOURCE_MOUSE_INTERACTION && - d.type === RRWEB_MOUSE_CLICK && - urlPath !== null - ) { - out.push({ - eventType: HEATMAP_EVENT_TYPE.click, - nodeId: typeof d.id === 'number' ? d.id : null, - x: typeof d.x === 'number' ? Math.round(d.x) : null, - y: typeof d.y === 'number' ? Math.round(d.y) : null, - viewportW, - viewportH, - pageH: null, - scrollPct: null, - urlPath, - createdAt: new Date(replayTimeMs ?? Date.now()), - replayChunkIndex: chunkIndex ?? null, - replayEventIndex: eventIndex, - replayTimeMs, - }); - } } return out; diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts index 9169f861a..cb79d64d9 100644 --- a/src/queries/sql/heatmap/getHeatmap.ts +++ b/src/queries/sql/heatmap/getHeatmap.ts @@ -27,6 +27,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; @@ -52,6 +56,7 @@ export interface HeatmapResult { scroll: { buckets: HeatmapScrollBucket[]; totalSessions: number; + pageW: number | null; pageH: number | null; viewportW: number | null; viewportH: number | null; @@ -80,11 +85,6 @@ interface HeatmapFilterContext { queryParams: Record; } -interface SnapshotPoint { - x: number; - y: number; -} - async function relationalQuery( websiteId: string, parameters: HeatmapParameters, @@ -98,6 +98,10 @@ async function relationalQuery( ? ` 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 ` @@ -153,6 +157,7 @@ async function relationalQuery( const dimRows: { totalSessions: number | string; + pageW: number | null; pageH: number | null; viewportW: number | null; viewportH: number | null; @@ -160,6 +165,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,6 +185,7 @@ 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, @@ -189,9 +196,10 @@ async function relationalQuery( urlPath, startDate, endDate, + pageW: scroll.pageW, + pageH: scroll.pageH, viewportW: scroll.viewportW, viewportH: scroll.viewportH, - point: null, filterContext, }); @@ -210,6 +218,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 +233,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,16 +257,16 @@ async function relationalQuery( ); const viewport = pickSnapshotViewport(rawPoints); - const point = pickRepresentativePoint(rawPoints, viewport); const snapshot = await getRelationalSnapshot(rawQuery, { websiteId, eventType, urlPath, startDate, endDate, + pageW: null, + pageH: null, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, - point, filterContext, }); @@ -256,9 +281,10 @@ async function getRelationalSnapshot( urlPath, startDate, endDate, + pageW, + pageH, viewportW, viewportH, - point, filterContext, }: { websiteId: string; @@ -266,12 +292,20 @@ async function getRelationalSnapshot( urlPath: string; startDate: Date; endDate: Date; + pageW: number | null; + pageH: number | null; viewportW: number | null; viewportH: number | null; - point: SnapshotPoint | null; filterContext: HeatmapFilterContext; }, ): Promise { + const pageFilter = + pageW && pageH + ? ` + and h.page_w = {{pageW}} + and h.page_h = {{pageH}} + ` + : ''; const viewportFilter = viewportW && viewportH ? ` @@ -279,39 +313,15 @@ async function getRelationalSnapshot( 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 @@ -322,10 +332,58 @@ async function getRelationalSnapshot( and h.event_type = {{eventType}} and h.url_path = {{urlPath}} and h.created_at between {{startDate}} and {{endDate}} + and h.replay_chunk_index is not null + and h.replay_event_index is not null + and h.replay_time_ms is not null + ${pageFilter} + ${viewportFilter} + order by + h.replay_chunk_index asc nulls last, + h.replay_event_index asc nulls last, + h.replay_time_ms asc, + h.created_at asc + limit 1 + `, + { + ...filterContext.queryParams, + websiteId, + eventType, + urlPath, + startDate, + endDate, + pageW, + pageH, + viewportW, + viewportH, + }, + FUNCTION_NAME, + ); + + if (rows.length > 0) { + return mapSnapshot(rows[0]); + } + + const fallbackRows: SnapshotRow[] = await rawQuery( + ` + 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 ( + 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}} + ${pageFilter} ${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 @@ -338,15 +396,15 @@ async function getRelationalSnapshot( urlPath, startDate, endDate, + pageW, + pageH, viewportW, viewportH, - pointX: point?.x, - pointY: point?.y, }, FUNCTION_NAME, ); - return mapSnapshot(rows[0]); + return mapSnapshot(fallbackRows[0]); } async function clickhouseQuery( @@ -362,6 +420,10 @@ async function clickhouseQuery( ? ` 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 ` @@ -426,6 +488,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 +497,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,6 +517,7 @@ 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), @@ -465,9 +530,10 @@ async function clickhouseQuery( urlPath, startDate, endDate, + pageW: scroll.pageW, + pageH: scroll.pageH, viewportW: scroll.viewportW, viewportH: scroll.viewportH, - point: null, filterContext, }); @@ -485,6 +551,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 +565,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 +580,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,22 +607,26 @@ 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, { websiteId, eventType, urlPath, startDate, endDate, + pageW: null, + pageH: null, viewportW: viewport?.width ?? null, viewportH: viewport?.height ?? null, - point, filterContext, }); @@ -550,9 +641,10 @@ async function getClickhouseSnapshot( urlPath, startDate, endDate, + pageW, + pageH, viewportW, viewportH, - point, filterContext, }: { websiteId: string; @@ -560,12 +652,20 @@ async function getClickhouseSnapshot( urlPath: string; startDate: Date; endDate: Date; + pageW: number | null; + pageH: number | null; viewportW: number | null; viewportH: number | null; - point: SnapshotPoint | null; filterContext: HeatmapFilterContext; }, ): Promise { + const pageFilter = + pageW && pageH + ? ` + and h.page_w = {pageW:UInt32} + and h.page_h = {pageH:UInt32} + ` + : ''; const viewportFilter = viewportW && viewportH ? ` @@ -573,39 +673,15 @@ async function getClickhouseSnapshot( and h.viewport_h = {viewportH:UInt32} ` : ''; - const pointFilter = point - ? ` - and h.x = {pointX:UInt32} - and h.y = {pointY:UInt32} - ` - : ''; const rows = 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: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 @@ -616,10 +692,58 @@ async function getClickhouseSnapshot( and h.event_type = {eventType:UInt8} and h.url_path = {urlPath:String} and h.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and h.replay_chunk_index is not null + and h.replay_event_index is not null + and h.replay_time_ms is not null + ${pageFilter} + ${viewportFilter} + order by + h.replay_chunk_index asc, + h.replay_event_index asc, + h.replay_time_ms asc, + h.created_at asc + limit 1 + `, + { + ...filterContext.queryParams, + websiteId, + eventType, + urlPath, + startDate, + endDate, + pageW, + pageH, + viewportW, + viewportH, + }, + FUNCTION_NAME, + ); + + if (rows.length > 0) { + return mapSnapshot(rows[0]); + } + + const fallbackRows = await rawQuery( + ` + 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 ( + 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} + ${pageFilter} ${viewportFilter} - ${pointFilter} order by - isNull(h.replay_chunk_index) asc, h.replay_chunk_index asc, h.replay_event_index asc, h.created_at asc @@ -632,63 +756,87 @@ async function getClickhouseSnapshot( urlPath, startDate, endDate, + pageW, + pageH, viewportW, viewportH, - pointX: point?.x, - pointY: point?.y, }, FUNCTION_NAME, ); - return mapSnapshot(rows[0]); + return mapSnapshot(fallbackRows[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(); +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) { + if (!bestViewport) { return null; } - const match = points.find( - point => point.viewportW === viewport.width && point.viewportH === viewport.height, - ); - - return match ? { x: match.x, y: match.y } : null; + return { + width: bestViewport.width, + height: bestViewport.height, + pageW: bestViewport.maxPageW, + pageH: bestViewport.maxPageH, + }; } function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null { diff --git a/src/queries/sql/heatmap/saveHeatmapEvents.ts b/src/queries/sql/heatmap/saveHeatmapEvents.ts index 547884ce9..0229e418c 100644 --- a/src/queries/sql/heatmap/saveHeatmapEvents.ts +++ b/src/queries/sql/heatmap/saveHeatmapEvents.ts @@ -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; @@ -44,6 +47,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 +76,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, diff --git a/src/queries/sql/replays/getReplayChunks.ts b/src/queries/sql/replays/getReplayChunks.ts index 182c0703a..a53fb5b63 100644 --- a/src/queries/sql/replays/getReplayChunks.ts +++ b/src/queries/sql/replays/getReplayChunks.ts @@ -18,6 +18,9 @@ export interface ReplayChunk { interface GetReplayChunksOptions { endAt?: Date; endChunkIndex?: number; + startChunkIndex?: number; + limit?: number; + order?: 'asc' | 'desc'; } export async function getReplayChunks( @@ -34,7 +37,7 @@ export async function getReplayChunks( async function relationalQuery( websiteId: string, visitId: string, - { endAt, endChunkIndex }: GetReplayChunksOptions, + { endAt, endChunkIndex, startChunkIndex, limit, order = 'asc' }: GetReplayChunksOptions, ): Promise { const { rawQuery } = prisma; const endAtFilter = endAt @@ -48,6 +51,18 @@ async function relationalQuery( and chunk_index <= {{endChunkIndex}} ` : ''; + const startChunkFilter = + startChunkIndex !== undefined + ? ` + and chunk_index >= {{startChunkIndex}} + ` + : ''; + const limitClause = + limit !== undefined + ? ` + limit ${limit} + ` + : ''; const chunks: { sessionId: string; @@ -71,10 +86,12 @@ async function relationalQuery( where website_id = {{websiteId::uuid}} and visit_id = {{visitId::uuid}} ${endAtFilter} + ${startChunkFilter} ${endChunkFilter} - order by chunk_index asc + order by chunk_index ${order} + ${limitClause} `, - { websiteId, visitId, endAt, endChunkIndex }, + { websiteId, visitId, endAt, endChunkIndex, startChunkIndex }, FUNCTION_NAME, ); @@ -87,7 +104,7 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, visitId: string, - { endAt, endChunkIndex }: GetReplayChunksOptions, + { endAt, endChunkIndex, startChunkIndex, limit, order = 'asc' }: GetReplayChunksOptions, ): Promise { const { rawQuery } = clickhouse; const endAtFilter = endAt @@ -101,6 +118,18 @@ async function clickhouseQuery( and chunk_index <= {endChunkIndex:UInt32} ` : ''; + const startChunkFilter = + startChunkIndex !== undefined + ? ` + and chunk_index >= {startChunkIndex:UInt32} + ` + : ''; + const limitClause = + limit !== undefined + ? ` + limit ${limit} + ` + : ''; const results = await rawQuery< { @@ -126,10 +155,12 @@ async function clickhouseQuery( prewhere website_id = {websiteId:UUID} and visit_id = {visitId:UUID} ${endAtFilter} + ${startChunkFilter} ${endChunkFilter} - order by chunk_index asc + order by chunk_index ${order} + ${limitClause} `, - { websiteId, visitId, endAt, endChunkIndex }, + { websiteId, visitId, endAt, endChunkIndex, startChunkIndex }, FUNCTION_NAME, ); diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts index cce6cd28c..b1a63ed3e 100644 --- a/src/queries/sql/sessions/saveSessionData.ts +++ b/src/queries/sql/sessions/saveSessionData.ts @@ -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, diff --git a/src/recorder/index.js b/src/recorder/index.js index dee818b71..b740fc546 100644 --- a/src/recorder/index.js +++ b/src/recorder/index.js @@ -139,6 +139,54 @@ import { record } from 'rrweb'; } }; + const measureElementWidth = element => { + if (!element) return 0; + const rect = element.getBoundingClientRect?.(); + 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 waitForSession = (attempts = 0) => { if (attempts > 50) return; @@ -198,9 +246,39 @@ import { record } from 'rrweb'; let maxScrollPct = 0; let scrollTimer = null; + 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 = document.documentElement.scrollHeight; - const visible = window.scrollY + window.innerHeight; + 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, @@ -209,16 +287,50 @@ import { record } from 'rrweb'; const flushScroll = () => { if (maxScrollPct <= 0) return; + const { pageW, pageH } = computePageMetrics({ includeBounds: true }); + addCustomEvent('scroll-progress', { url: scrollUrl, scrollPct: maxScrollPct, viewportW: window.innerWidth, viewportH: window.innerHeight, - pageH: document.documentElement.scrollHeight, + pageW, + pageH, }); 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)); + + addCustomEvent('heatmap-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(() => { @@ -248,8 +360,8 @@ 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;