WIP heatmap full-page coordinate and replay rendering work

This commit is contained in:
Francis Cao
2026-05-19 09:05:11 -07:00
parent a7a0d73dd7
commit 1f3f805a30
13 changed files with 911 additions and 209 deletions
@@ -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),
+3
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),
@@ -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,
+3
View File
@@ -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
@@ -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) {
@@ -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<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);
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<string, ViewportBucket>();
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 <CanvasLoading />;
@@ -261,9 +291,14 @@ function HeatmapView({
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 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,30 +322,45 @@ function HeatmapView({
className={styles.canvas}
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
>
<div className={styles.canvasClip}>
<div className={styles.snapshotClip}>
{showSnapshot && !snapshotReady && <CanvasLoading />}
{showSnapshot && (
<ReplaySnapshot
websiteId={websiteId}
snapshot={snapshot}
width={viewport.width}
height={viewport.height}
width={baseWidth}
height={baseHeight}
scale={scale}
allowWidthExpansion={viewport.pageW > viewport.width}
onReady={handleSnapshotReady}
/>
)}
</div>
{showOverlay && (
<div className={styles.overlay}>
{visible.map((p, i) => {
const intensity = Math.min(1, p.count / maxCount);
const size = 24 + intensity * 36;
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 (
<div
key={`${p.x}-${p.y}-${i}`}
key={`${p.pageX}-${p.pageY}-${i}`}
className={styles.dot}
style={{
left: p.x * scale - size / 2,
top: p.y * scale - size / 2,
left: centerX - size / 2,
top: centerY - size / 2,
width: size,
height: size,
opacity: 0.25 + intensity * 0.55,
@@ -323,7 +373,6 @@ function HeatmapView({
)}
</div>
</div>
</div>
{snapshot && (
<Row justifyContent="center" className={styles.snapshotControlRow}>
<Switch isSelected={showPage} onChange={setShowPage}>
@@ -356,20 +405,22 @@ function ScrollHeatmapView({
useEffect(() => {
setSnapshotReady(!(showPage && snapshot));
}, [containerWidth, showPage, snapshot]);
}, [showPage, snapshot]);
if (isLoading) {
return <CanvasLoading />;
}
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
if (!scroll || scroll.totalSessions === 0 || !scroll.pageW || !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 { 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({
<ReplaySnapshot
websiteId={websiteId}
snapshot={snapshot}
width={viewportW}
width={pageW}
height={pageH}
scale={scale}
allowWidthExpansion={pageW > 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<HTMLDivElement>(null);
const replayerRef = useRef<ReplayInstance | null>(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<typeof setTimeout> | null = null;
let readyTimer: ReturnType<typeof setTimeout> | 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 (
<div
ref={containerRef}
className={styles.snapshot}
style={{ width, height, transform: `scale(${scale})` }}
style={{
width: contentWidth,
height,
transform: `scale(${snapshotScaleX}, ${scale})`,
}}
>
<div
ref={containerRef}
style={{
width: contentWidth,
height,
}}
/>
</div>
);
}
@@ -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 } = {}) {
@@ -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<ReturnType<typeof getReplayChunks>>,
{ 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<ReturnType<typeof getReplayChunks>>,
{
@@ -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;
+31 -24
View File
@@ -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;
+247 -99
View File
@@ -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<string, any>;
}
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<HeatmapSnapshot | null> {
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<HeatmapSnapshot | null> {
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<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
@@ -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<SnapshotRow[]>(
`
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<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) {
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 {
@@ -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,
+37 -6
View File
@@ -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<ReplayChunk[]> {
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<ReplayChunk[]> {
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,
);
@@ -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,
+116 -4
View File
@@ -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;