Merge branch 'heatmaps' into dev

# Conflicts:
#	db/clickhouse/schema.sql
This commit is contained in:
Mike Cao
2026-05-07 14:49:21 -07:00
23 changed files with 1702 additions and 10 deletions
@@ -0,0 +1,22 @@
-- Create heatmap_event
CREATE TABLE umami.heatmap_event
(
heatmap_event_id UUID,
website_id UUID,
session_id UUID,
visit_id UUID,
url_path String,
event_type UInt8,
node_id Nullable(Int32),
x Nullable(Int32),
y Nullable(Int32),
viewport_w Nullable(Int32),
viewport_h Nullable(Int32),
page_h Nullable(Int32),
scroll_pct Nullable(UInt8),
created_at DateTime('UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (website_id, url_path, event_type, created_at)
SETTINGS index_granularity = 8192;
+23
View File
@@ -380,3 +380,26 @@ AS SELECT
groupArrayState(data_type) AS property_types
FROM umami.session_data
GROUP BY website_id, session_id, distinct_id;
-- Create heatmap_event
CREATE TABLE umami.heatmap_event
(
heatmap_event_id UUID,
website_id UUID,
session_id UUID,
visit_id UUID,
url_path String,
event_type UInt8,
node_id Nullable(Int32),
x Nullable(Int32),
y Nullable(Int32),
viewport_w Nullable(Int32),
viewport_h Nullable(Int32),
page_h Nullable(Int32),
scroll_pct Nullable(UInt8),
created_at DateTime('UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (website_id, url_path, event_type, created_at)
SETTINGS index_granularity = 8192;
+1
View File
@@ -28,6 +28,7 @@ const contentSecurityPolicy = `
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' https:;
frame-src 'self' http: https:;
frame-ancestors 'self' ${frameAncestors};
`;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "heatmap_event" (
"heatmap_event_id" UUID NOT NULL,
"website_id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"visit_id" UUID NOT NULL,
"url_path" VARCHAR(500) NOT NULL,
"event_type" INTEGER NOT NULL,
"node_id" INTEGER,
"x" INTEGER,
"y" INTEGER,
"viewport_w" INTEGER,
"viewport_h" INTEGER,
"scroll_pct" INTEGER,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "heatmap_event_pkey" PRIMARY KEY ("heatmap_event_id")
);
-- CreateIndex
CREATE INDEX "heatmap_event_website_id_idx" ON "heatmap_event"("website_id");
CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "heatmap_event" ADD COLUMN "page_h" INTEGER;
+26
View File
@@ -88,6 +88,7 @@ model Website {
sessionData SessionData[]
sessionReplays SessionReplay[]
sessionReplaysSaved SessionReplaySaved[]
heatmapEvents HeatmapEvent[]
@@index([userId])
@@index([teamId])
@@ -398,4 +399,29 @@ model SessionReplaySaved {
@@index([visitId])
@@index([websiteId, createdAt])
@@map("session_replay_saved")
}
model HeatmapEvent {
id String @id() @map("heatmap_event_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid
visitId String @map("visit_id") @db.Uuid
urlPath String @map("url_path") @db.VarChar(500)
eventType Int @map("event_type") @db.Integer
nodeId Int? @map("node_id") @db.Integer
x Int? @db.Integer
y Int? @db.Integer
viewportW Int? @map("viewport_w") @db.Integer
viewportH Int? @map("viewport_h") @db.Integer
pageH Int? @map("page_h") @db.Integer
scrollPct Int? @map("scroll_pct") @db.Integer
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@index([visitId])
@@index([websiteId, createdAt])
@@index([websiteId, urlPath, eventType, createdAt])
@@map("heatmap_event")
}
+3
View File
@@ -140,6 +140,9 @@
"greater-than-equals": "Greater than or equals",
"grouped": "Grouped",
"growth": "Growth",
"heatmap": "Heatmap",
"heatmap-description": "Visualize where visitors click and how far they scroll on your pages.",
"heatmaps": "Heatmaps",
"hostname": "Hostname",
"hour": "Hour",
"includes": "Includes",
@@ -0,0 +1,138 @@
.pageList {
overflow-y: auto;
padding-right: 8px;
max-height: 100%;
}
.pageButton {
width: 100%;
text-align: left;
border: 0;
background: transparent;
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
font: inherit;
color: inherit;
}
.pageButton:hover {
background: var(--interactive-bg-hover);
}
.pageButtonSelected,
.pageButtonSelected:hover {
background: var(--surface-inverted);
color: var(--surface-base);
}
.toggleButton {
border: 1px solid var(--border-base);
background: var(--surface-base);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font: inherit;
color: inherit;
}
.toggleButton:hover {
background: var(--interactive-bg-hover);
}
.toggleButtonSelected,
.toggleButtonSelected:hover {
background: var(--surface-inverted);
color: var(--surface-base);
border-color: var(--surface-inverted);
}
.scrollBand {
position: absolute;
left: 0;
right: 0;
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 2px 8px;
}
.scrollBandLabel {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.7);
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
}
.canvasWrapper {
width: 100%;
min-width: 0;
}
.canvas {
position: relative;
overflow: hidden;
border: 1px solid var(--border-base);
border-radius: 8px;
background: var(--surface-sunken);
max-width: 100%;
}
.snapshot {
position: absolute;
top: 0;
left: 0;
z-index: 1;
transform-origin: top left;
pointer-events: none;
overflow: hidden;
}
.snapshot :global(.replayer-wrapper) {
border: 0;
overflow: hidden;
}
.snapshot :global(iframe) {
border: 0;
pointer-events: none;
}
.snapshot :global(.replayer-mouse),
.snapshot :global(.replayer-mouse-tail) {
display: none !important;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
pointer-events: none;
}
.canvasLoading {
position: absolute;
inset: 0;
z-index: 2;
background: var(--surface-sunken);
}
.dot {
position: absolute;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 60, 0, 0.9) 0%,
rgba(255, 200, 0, 0.6) 45%,
rgba(255, 200, 0, 0) 80%
);
pointer-events: auto;
mix-blend-mode: multiply;
}
@@ -0,0 +1,516 @@
'use client';
import { Column, Grid, Heading, Loading, Row, Text } from '@umami/react-zen';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useResultQuery } from '@/components/hooks';
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
import { formatLongNumber } from '@/lib/format';
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
import styles from './Heatmap.module.css';
import 'rrweb/dist/replay/rrweb-replay.css';
const MAX_RENDER_WIDTH = 1024;
function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
const w = entries[0]?.contentRect.width ?? 0;
setWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
interface ReplayData {
events: any[];
}
interface ViewportBucket {
width: number;
height: number;
count: number;
}
interface HeatmapProps {
websiteId: string;
urlPath: string;
onUrlPathChange: (urlPath: string) => void;
mode: HeatmapMode;
onModeChange: (mode: HeatmapMode) => void;
}
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChange }: HeatmapProps) {
const { data, error, isLoading } = useResultQuery<HeatmapResult>('heatmap', {
websiteId,
urlPath: urlPath || undefined,
mode,
});
const pages = data?.pages ?? [];
const points = data?.points ?? [];
const scroll = data?.scroll;
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="900px">
<Grid columns="320px 1fr" gap minHeight="900px">
<PageList pages={pages} selected={urlPath} onSelect={onUrlPathChange} mode={mode} />
<Column gap>
<ModeToggle mode={mode} onChange={onModeChange} />
{urlPath ? (
mode === 'scroll' ? (
<ScrollHeatmapView
websiteId={websiteId}
scroll={scroll}
snapshot={data?.snapshot ?? null}
/>
) : (
<HeatmapView
websiteId={websiteId}
points={points}
snapshot={data?.snapshot ?? null}
/>
)
) : (
<EmptyState />
)}
</Column>
</Grid>
</LoadingPanel>
);
}
function ModeToggle({
mode,
onChange,
}: {
mode: HeatmapMode;
onChange: (mode: HeatmapMode) => void;
}) {
return (
<Row gap="2">
<button
type="button"
onClick={() => onChange('click')}
className={`${styles.toggleButton} ${mode === 'click' ? styles.toggleButtonSelected : ''}`}
>
Clicks
</button>
<button
type="button"
onClick={() => onChange('scroll')}
className={`${styles.toggleButton} ${mode === 'scroll' ? styles.toggleButtonSelected : ''}`}
>
Scroll
</button>
</Row>
);
}
function PageList({
pages,
selected,
onSelect,
mode,
}: {
pages: HeatmapResult['pages'];
selected: string;
onSelect: (urlPath: string) => void;
mode: HeatmapMode;
}) {
return (
<Column className={styles.pageList} gap="2">
<Heading size="lg">Pages</Heading>
{pages.length === 0 && <Text color="muted">No data yet</Text>}
{pages.map(p => (
<button
key={p.urlPath}
type="button"
onClick={() => onSelect(p.urlPath)}
className={`${styles.pageButton} ${selected === p.urlPath ? styles.pageButtonSelected : ''}`}
>
<Row alignItems="center" justifyContent="space-between" gap="2">
<Text truncate>{p.urlPath}</Text>
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
</Row>
</button>
))}
</Column>
);
}
function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
if (!points.length) return null;
const buckets = new Map<string, ViewportBucket>();
for (const p of points) {
const key = `${p.viewportW}x${p.viewportH}`;
const existing = buckets.get(key);
if (existing) {
existing.count += p.count;
} else {
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
}
}
let best: ViewportBucket | null = null;
for (const b of buckets.values()) {
if (!best || b.count > best.count) best = b;
}
return best;
}
function HeatmapView({
websiteId,
points,
snapshot,
}: {
websiteId: string;
points: HeatmapPoint[];
snapshot: HeatmapSnapshot | null;
}) {
const [showPage, setShowPage] = useState(true);
const [snapshotReady, setSnapshotReady] = useState(false);
const viewport = useMemo(() => pickViewport(points), [points]);
const visible = useMemo(() => {
if (!viewport) return [];
return points.filter(p => p.viewportW === viewport.width && p.viewportH === viewport.height);
}, [points, viewport]);
const maxCount = useMemo(
() => visible.reduce((m, p) => (p.count > m ? p.count : m), 1),
[visible],
);
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
useEffect(() => {
setSnapshotReady(!(showPage && snapshot));
}, [containerWidth, showPage, snapshot]);
if (!viewport || visible.length === 0) {
return <EmptyState />;
}
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
const scale = renderWidth ? renderWidth / viewport.width : 0;
const renderHeight = Math.round(viewport.height * scale);
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
const showOverlay = !showSnapshot || snapshotReady;
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">
{visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '}
clicks · viewport {viewport.width}×{viewport.height}
</Text>
{snapshot && (
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowPage(v => !v)}
>
{showPage ? 'Hide page' : 'Show page'}
</button>
)}
</Row>
<div ref={containerRef} className={styles.canvasWrapper}>
<div
className={styles.canvas}
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
>
{showSnapshot && !snapshotReady && <CanvasLoading />}
{showSnapshot && (
<ReplaySnapshot
websiteId={websiteId}
snapshot={snapshot}
width={viewport.width}
height={viewport.height}
scale={scale}
onReady={handleSnapshotReady}
/>
)}
{showOverlay && (
<div className={styles.overlay}>
{visible.map((p, i) => {
const intensity = Math.min(1, p.count / maxCount);
const size = 24 + intensity * 36;
return (
<div
key={`${p.x}-${p.y}-${i}`}
className={styles.dot}
style={{
left: p.x * scale - size / 2,
top: p.y * scale - size / 2,
width: size,
height: size,
opacity: 0.25 + intensity * 0.55,
}}
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
/>
);
})}
</div>
)}
</div>
</div>
</Column>
);
}
function ScrollHeatmapView({
websiteId,
scroll,
snapshot,
}: {
websiteId: string;
scroll: HeatmapResult['scroll'] | undefined;
snapshot: HeatmapSnapshot | null;
}) {
const [showPage, setShowPage] = useState(true);
const [snapshotReady, setSnapshotReady] = useState(false);
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
useEffect(() => {
setSnapshotReady(!(showPage && snapshot));
}, [containerWidth, showPage, snapshot]);
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
return <EmptyState message="No scroll data for this page yet." />;
}
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
const scale = renderWidth ? renderWidth / viewportW : 0;
const renderHeight = Math.round(pageH * scale);
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
const showOverlay = !showSnapshot || snapshotReady;
// Cumulative reach: % of sessions that scrolled at least to depth D.
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
let remaining = totalSessions;
const cumulative = sortedBuckets.map(b => {
const reached = remaining;
remaining -= b.sessions;
return { depth: b.depth, reached, ratio: reached / totalSessions };
});
// Build page-spanning bands. Each band covers a vertical slice of the page.
// Intensity = fraction of sessions reaching the band's TOP edge (everyone who
// got that far saw at least the start of the slice).
type Band = { fromPct: number; toPct: number; reached: number; ratio: number };
const bands: Band[] = [];
const firstDepth = cumulative[0]?.depth ?? 100;
if (firstDepth > 0) {
bands.push({ fromPct: 0, toPct: firstDepth, reached: totalSessions, ratio: 1 });
}
for (let i = 0; i < cumulative.length; i++) {
const c = cumulative[i];
const toPct = cumulative[i + 1]?.depth ?? 100;
if (c.depth < toPct) {
bands.push({ fromPct: c.depth, toPct, reached: c.reached, ratio: c.ratio });
}
}
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">
{formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH}
{viewportH ? ` · viewport ${viewportH}` : ''}
</Text>
{snapshot && (
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowPage(v => !v)}
>
{showPage ? 'Hide page' : 'Show page'}
</button>
)}
</Row>
<div ref={containerRef} className={styles.canvasWrapper}>
<div
className={styles.canvas}
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
>
{showSnapshot && !snapshotReady && <CanvasLoading />}
{showSnapshot && (
<ReplaySnapshot
websiteId={websiteId}
snapshot={snapshot}
width={viewportW}
height={pageH}
scale={scale}
onReady={handleSnapshotReady}
/>
)}
{showOverlay && (
<div className={styles.overlay}>
{bands.map(b => {
const top = Math.round((b.fromPct / 100) * renderHeight);
const bottom = Math.round((b.toPct / 100) * renderHeight);
const height = Math.max(0, bottom - top);
const intensity = b.ratio;
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
return (
<div
key={b.fromPct}
className={styles.scrollBand}
style={{
top,
height,
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
}}
title={`${b.fromPct}${b.toPct}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
>
<span className={styles.scrollBandLabel}>
{b.fromPct}% · {Math.round(intensity * 100)}%
</span>
</div>
);
})}
</div>
)}
</div>
</div>
</Column>
);
}
function ReplaySnapshot({
websiteId,
snapshot,
width,
height,
scale,
onReady,
}: {
websiteId: string;
snapshot: HeatmapSnapshot;
width: number;
height: number;
scale: number;
onReady: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const replayerRef = useRef<any>(null);
const { data } = useReplayQuery(websiteId, snapshot.replayId) as { data?: ReplayData };
useEffect(() => {
const container = containerRef.current;
const events = data?.events;
if (!container || !events?.length) return;
let cancelled = false;
import('rrweb').then(({ Replayer }) => {
if (cancelled || !containerRef.current) return;
container.innerHTML = '';
const replayer = new Replayer(events, {
root: container,
showWarning: false,
mouseTail: false,
triggerFocus: false,
pauseAnimation: true,
useVirtualDom: false,
});
replayerRef.current = replayer;
let ready = false;
const freeze = () => {
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
replayer.pause(offset);
replayer.disableInteract();
resizeReplayFrame(replayer, width, height);
};
const handleReady = () => {
if (ready || cancelled) return;
ready = true;
requestAnimationFrame(() => {
if (cancelled) return;
freeze();
requestAnimationFrame(() => {
if (cancelled) return;
freeze();
onReady();
});
});
};
replayer.on('fullsnapshot-rebuilded', handleReady);
});
return () => {
cancelled = true;
if (replayerRef.current) {
replayerRef.current.destroy();
replayerRef.current = null;
}
if (container) {
container.innerHTML = '';
}
};
}, [data?.events, height, onReady, snapshot.timestamp, width]);
useEffect(() => {
if (replayerRef.current) {
resizeReplayFrame(replayerRef.current, width, height);
}
}, [height, width]);
return (
<div
ref={containerRef}
className={styles.snapshot}
style={{ width, height, transform: `scale(${scale})` }}
/>
);
}
function CanvasLoading() {
return (
<div className={styles.canvasLoading}>
<Loading icon="dots" placement="center" />
</div>
);
}
function resizeReplayFrame(replayer: any, width: number, height: number) {
const { iframe, wrapper } = replayer;
if (wrapper) {
wrapper.style.width = `${width}px`;
wrapper.style.height = `${height}px`;
}
if (iframe) {
iframe.setAttribute('width', String(width));
iframe.setAttribute('height', String(height));
iframe.style.width = `${width}px`;
iframe.style.height = `${height}px`;
}
}
function EmptyState({ message }: { message?: string } = {}) {
return (
<Column alignItems="center" justifyContent="center" height="100%" gap>
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
</Column>
);
}
@@ -0,0 +1,27 @@
'use client';
import { Column } from '@umami/react-zen';
import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import type { HeatmapMode } from '@/queries/sql';
import { Heatmap } from './Heatmap';
export function HeatmapsPage({ websiteId }: { websiteId: string }) {
const [urlPath, setUrlPath] = useState<string>('');
const [mode, setMode] = useState<HeatmapMode>('click');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Panel minHeight="900px" allowFullscreen>
<Heatmap
websiteId={websiteId}
urlPath={urlPath}
onUrlPathChange={setUrlPath}
mode={mode}
onModeChange={setMode}
/>
</Panel>
</Column>
);
}
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { HeatmapsPage } from './HeatmapsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <HeatmapsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Heatmaps',
};
+18
View File
@@ -9,6 +9,8 @@ import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
import { saveRecording } from '@/queries/sql';
import { extractHeatmapEvents } from '@/queries/sql/heatmap/extractHeatmapEvents';
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
interface Cache {
sessionId: string;
@@ -113,6 +115,22 @@ export async function POST(request: Request) {
endedAt,
});
try {
const heatmapRows = extractHeatmapEvents(events).map(e => ({
websiteId,
sessionId,
visitId,
...e,
}));
if (heatmapRows.length) {
await saveHeatmapEvents(heatmapRows);
}
} catch (e) {
// eslint-disable-next-line no-console
console.log('heatmap extraction failed', serializeError(e));
}
return json({ ok: true });
} catch (e) {
const error = serializeError(e);
+25
View File
@@ -0,0 +1,25 @@
import { parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getHeatmap, type HeatmapParameters } from '@/queries/sql';
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema);
if (error) {
return error();
}
const { websiteId } = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const parameters = (await setWebsiteDate(websiteId, body.parameters)) as HeatmapParameters;
const data = await getHeatmap(websiteId, parameters);
return json(data);
}
+3 -1
View File
@@ -10,11 +10,13 @@ export function TypeIcon({
value: string;
children?: ReactNode;
}) {
const iconValue = type === 'browser' && value === 'browser' ? 'unknown' : value;
return (
<Row gap="3" alignItems="center">
<img
src={`${process.env.basePath || ''}/images/${type}/${
value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
iconValue?.replaceAll(' ', '-').toLowerCase() || 'unknown'
}.png`}
onError={e => {
e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
@@ -3,6 +3,7 @@ import {
ChartPie,
Clock,
Eye,
Flame,
Sheet,
Tag,
User,
@@ -107,6 +108,12 @@ export function useWebsiteNavItems(websiteId: string) {
icon: <Video />,
path: renderPath('/replays'),
},
{
id: 'heatmaps',
label: t(labels.heatmaps),
icon: <Flame />,
path: renderPath('/heatmaps'),
},
],
},
{
+3
View File
@@ -284,6 +284,9 @@ export const labels: Record<string, string> = {
journey: 'label.journey',
journeys: 'label.journeys',
journeyDescription: 'label.journey-description',
heatmap: 'label.heatmap',
heatmaps: 'label.heatmaps',
heatmapDescription: 'label.heatmap-description',
compareDates: 'label.compare-dates',
compare: 'label.compare',
current: 'label.current',
+6
View File
@@ -119,6 +119,11 @@ export const EVENT_TYPE = {
performance: 5,
} as const;
export const HEATMAP_EVENT_TYPE = {
click: 1,
scroll: 2,
} as const;
export const ENTITY_TYPE = {
website: 1,
link: 2,
@@ -271,6 +276,7 @@ export const BROWSERS = {
aol: 'AOL',
bb10: 'BlackBerry 10',
beaker: 'Beaker',
browser: 'Unknown',
chrome: 'Chrome',
'chromium-webview': 'Chrome (webview)',
crios: 'Chrome (iOS)',
+18 -7
View File
@@ -131,6 +131,7 @@ export const reportTypeParam = z.enum([
'breakdown',
'funnel',
'goal',
'heatmap',
'journey',
'performance',
'retention',
@@ -159,13 +160,12 @@ export const operatorParam = z.enum([
export const goalReportSchema = z.object({
type: z.literal('goal'),
parameters: z
.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
type: z.string(),
value: z.string(),
}),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
type: z.string(),
value: z.string(),
}),
});
export const funnelReportSchema = z.object({
@@ -268,6 +268,16 @@ export const breakdownReportSchema = z.object({
}),
});
export const heatmapReportSchema = z.object({
type: z.literal('heatmap'),
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
urlPath: z.string().max(500).optional(),
mode: z.enum(['click', 'scroll']).optional(),
}),
});
export const reportBaseSchema = z.object({
websiteId: z.uuid(),
type: reportTypeParam,
@@ -286,6 +296,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
revenueReportSchema,
attributionReportSchema,
breakdownReportSchema,
heatmapReportSchema,
]);
export const reportSchema = reportBaseSchema;
@@ -0,0 +1,112 @@
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;
viewportW: number | null;
viewportH: number | null;
pageH: number | null;
scrollPct: number | null;
urlPath: string;
createdAt: Date;
}
function safePathname(href: unknown): string | null {
if (typeof href !== 'string') return null;
try {
return new URL(href).pathname || '/';
} catch {
return href.startsWith('/') ? href.split(/[?#]/)[0] : null;
}
}
export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
if (!Array.isArray(events) || events.length === 0) return [];
let urlPath: string | null = null;
let viewportW: number | null = null;
let viewportH: number | null = null;
const out: ExtractedHeatmapEvent[] = [];
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue;
if (ev.type === RRWEB_TYPE_META && ev.data) {
const path = safePathname(ev.data.href);
if (path) urlPath = path;
if (typeof ev.data.width === 'number') viewportW = ev.data.width;
if (typeof ev.data.height === 'number') viewportH = ev.data.height;
continue;
}
if (ev.type === RRWEB_TYPE_CUSTOM && ev.data) {
if (ev.data.tag === 'url-change') {
const path = safePathname(ev.data.payload?.url);
if (path) urlPath = path;
continue;
}
if (ev.data.tag === 'scroll-progress' && ev.data.payload) {
const p = ev.data.payload;
const path = safePathname(p.url) ?? urlPath;
if (path === null) continue;
out.push({
eventType: HEATMAP_EVENT_TYPE.scroll,
nodeId: null,
x: null,
y: 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,
scrollPct:
typeof p.scrollPct === 'number'
? Math.max(0, Math.min(100, Math.round(p.scrollPct)))
: null,
urlPath: path,
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
});
}
continue;
}
if (ev.type !== RRWEB_TYPE_INCREMENTAL || !ev.data) continue;
const d = ev.data;
if (d.source === RRWEB_SOURCE_VIEWPORT_RESIZE) {
if (typeof d.width === 'number') viewportW = d.width;
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(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
});
}
}
return out;
}
+523
View File
@@ -0,0 +1,523 @@
import clickhouse from '@/lib/clickhouse';
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
const FUNCTION_NAME = 'getHeatmap';
const POINT_LIMIT = 5000;
const PAGE_LIMIT = 100;
const SCROLL_BUCKET_SIZE = 5;
export type HeatmapMode = 'click' | 'scroll';
export interface HeatmapParameters {
startDate: Date;
endDate: Date;
urlPath?: string;
mode?: HeatmapMode;
}
export interface HeatmapPage {
urlPath: string;
count: number;
sessions: number;
}
export interface HeatmapPoint {
nodeId: number | null;
x: number;
y: number;
viewportW: number;
viewportH: number;
count: number;
}
export interface HeatmapScrollBucket {
depth: number;
sessions: number;
}
export interface HeatmapSnapshot {
replayId: string;
timestamp: number;
}
export interface HeatmapResult {
mode: HeatmapMode;
pages: HeatmapPage[];
points: HeatmapPoint[];
snapshot: HeatmapSnapshot | null;
scroll: {
buckets: HeatmapScrollBucket[];
totalSessions: number;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
};
}
export async function getHeatmap(
websiteId: string,
parameters: HeatmapParameters,
): Promise<HeatmapResult> {
return runQuery({
[PRISMA]: () => relationalQuery(websiteId, parameters),
[CLICKHOUSE]: () => clickhouseQuery(websiteId, parameters),
});
}
const emptyScroll = (): HeatmapResult['scroll'] => ({
buckets: [],
totalSessions: 0,
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 }>();
for (const p of points) {
const key = `${p.viewportW}x${p.viewportH}`;
const existing = buckets.get(key);
if (existing) {
existing.count += p.count;
} else {
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
}
}
let best: { width: number; height: number; count: number } | null = null;
for (const bucket of buckets.values()) {
if (!best || bucket.count > best.count) {
best = bucket;
}
}
return best ? { width: best.width, height: best.height } : null;
}
async function relationalQuery(
websiteId: string,
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = prisma;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const pages: HeatmapPage[] = await rawQuery(
`
select
url_path as "urlPath",
count(*)::int as count,
count(distinct visit_id)::int as sessions
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and created_at between {{startDate}} and {{endDate}}
group by url_path
order by count desc
limit ${PAGE_LIMIT}
`,
{ websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
if (!urlPath) {
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows: { depth: number | string; sessions: number | string }[] = await rawQuery(
`
select
(floor(max_pct / ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE})::int as depth,
count(*)::int as sessions
from (
select visit_id, max(scroll_pct) as max_pct
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and url_path = {{urlPath}}
and created_at between {{startDate}} and {{endDate}}
and scroll_pct is not null
group by visit_id
) per_session
group by depth
order by depth
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows: {
totalSessions: number | string;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[] = await rawQuery(
`
select
count(distinct visit_id)::int as "totalSessions",
(mode() within group (order by page_h))::int as "pageH",
(mode() within group (order by viewport_w))::int as "viewportW",
(mode() within group (order by viewport_h))::int as "viewportH"
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and url_path = {{urlPath}}
and created_at between {{startDate}} and {{endDate}}
and scroll_pct is not null
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
const scroll = {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageH: dim?.pageH ?? null,
viewportW: dim?.viewportW ?? null,
viewportH: dim?.viewportH ?? null,
};
const snapshot = await getRelationalSnapshot(rawQuery, {
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW: scroll.viewportW,
viewportH: scroll.viewportH,
});
return {
mode,
pages,
points: [],
snapshot,
scroll,
};
}
const rawPoints: HeatmapPoint[] = await rawQuery(
`
select
node_id as "nodeId",
x,
y,
viewport_w as "viewportW",
viewport_h as "viewportH",
count(*)::int as count
from heatmap_event
where website_id = {{websiteId::uuid}}
and event_type = {{eventType}}
and url_path = {{urlPath}}
and created_at between {{startDate}} and {{endDate}}
and x is not null
and y is not null
and viewport_w is not null
and viewport_h is not null
group by node_id, x, y, viewport_w, viewport_h
order by count desc
limit ${POINT_LIMIT}
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const viewport = pickSnapshotViewport(rawPoints);
const snapshot = await getRelationalSnapshot(rawQuery, {
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
});
return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() };
}
async function getRelationalSnapshot(
rawQuery: typeof prisma.rawQuery,
{
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW,
viewportH,
}: {
websiteId: string;
eventType: number;
urlPath: string;
startDate: Date;
endDate: Date;
viewportW: number | null;
viewportH: number | null;
},
): Promise<HeatmapSnapshot | null> {
const viewportFilter =
viewportW && viewportH
? `
and h.viewport_w = {{viewportW}}
and h.viewport_h = {{viewportH}}
`
: '';
const rows: { replayId: string; timestamp: number | string }[] = await rawQuery(
`
select
h.visit_id as "replayId",
(extract(epoch from h.created_at) * 1000)::bigint as "timestamp"
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
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}
order by h.created_at asc
limit 1
`,
{ websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH },
FUNCTION_NAME,
);
const row = rows[0];
return row ? { replayId: row.replayId, timestamp: Number(row.timestamp) } : null;
}
async function clickhouseQuery(
websiteId: string,
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
): Promise<HeatmapResult> {
const { rawQuery } = clickhouse;
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
const pageRows = await rawQuery<
{ urlPath: string; count: string | number; sessions: string | number }[]
>(
`
select
url_path as urlPath,
count() as count,
uniq(visit_id) as sessions
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
group by url_path
order by count desc
limit ${PAGE_LIMIT}
`,
{ websiteId, eventType, startDate, endDate },
FUNCTION_NAME,
);
const pages: HeatmapPage[] = pageRows.map(p => ({
urlPath: p.urlPath,
count: Number(p.count),
sessions: Number(p.sessions),
}));
if (!urlPath) {
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
}
if (mode === 'scroll') {
const bucketRows = await rawQuery<{ depth: number | string; sessions: number | string }[]>(
`
select
intDiv(max_pct, ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE} as depth,
count() as sessions
from (
select visit_id, max(scroll_pct) as max_pct
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and url_path = {urlPath:String}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and scroll_pct is not null
group by visit_id
)
group by depth
order by depth
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dimRows = await rawQuery<
{
totalSessions: number | string;
pageH: number | null;
viewportW: number | null;
viewportH: number | null;
}[]
>(
`
select
uniq(visit_id) as totalSessions,
toInt32OrNull(toString(arrayElement(topK(1)(page_h), 1))) as pageH,
toInt32OrNull(toString(arrayElement(topK(1)(viewport_w), 1))) as viewportW,
toInt32OrNull(toString(arrayElement(topK(1)(viewport_h), 1))) as viewportH
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and url_path = {urlPath:String}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and scroll_pct is not null
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const dim = dimRows[0];
const scroll = {
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
totalSessions: Number(dim?.totalSessions ?? 0),
pageH: dim?.pageH === null || dim?.pageH === undefined ? null : Number(dim.pageH),
viewportW:
dim?.viewportW === null || dim?.viewportW === undefined ? null : Number(dim.viewportW),
viewportH:
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
};
const snapshot = await getClickhouseSnapshot(rawQuery, {
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW: scroll.viewportW,
viewportH: scroll.viewportH,
});
return {
mode,
pages,
points: [],
snapshot,
scroll,
};
}
const pointRows = await rawQuery<
{
nodeId: number | null;
x: number;
y: number;
viewportW: number;
viewportH: number;
count: string | number;
}[]
>(
`
select
node_id as nodeId,
x,
y,
viewport_w as viewportW,
viewport_h as viewportH,
count() as count
from heatmap_event
where website_id = {websiteId:UUID}
and event_type = {eventType:UInt8}
and url_path = {urlPath:String}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and x is not null
and y is not null
and viewport_w is not null
and viewport_h is not null
group by node_id, x, y, viewport_w, viewport_h
order by count desc
limit ${POINT_LIMIT}
`,
{ websiteId, eventType, urlPath, startDate, endDate },
FUNCTION_NAME,
);
const points: HeatmapPoint[] = pointRows.map(p => ({
nodeId: p.nodeId === null || p.nodeId === undefined ? null : Number(p.nodeId),
x: Number(p.x),
y: Number(p.y),
viewportW: Number(p.viewportW),
viewportH: Number(p.viewportH),
count: Number(p.count),
}));
const viewport = pickSnapshotViewport(points);
const snapshot = await getClickhouseSnapshot(rawQuery, {
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW: viewport?.width ?? null,
viewportH: viewport?.height ?? null,
});
return { mode, pages, points, snapshot, scroll: emptyScroll() };
}
async function getClickhouseSnapshot(
rawQuery: typeof clickhouse.rawQuery,
{
websiteId,
eventType,
urlPath,
startDate,
endDate,
viewportW,
viewportH,
}: {
websiteId: string;
eventType: number;
urlPath: string;
startDate: Date;
endDate: Date;
viewportW: number | null;
viewportH: number | null;
},
): Promise<HeatmapSnapshot | null> {
const viewportFilter =
viewportW && viewportH
? `
and h.viewport_w = {viewportW:UInt32}
and h.viewport_h = {viewportH:UInt32}
`
: '';
const rows = await rawQuery<{ replayId: string; timestamp: string | number }[]>(
`
select
toString(h.visit_id) as replayId,
toUnixTimestamp(h.created_at) * 1000 as timestamp
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
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}
order by h.created_at asc
limit 1
`,
{ websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH },
FUNCTION_NAME,
);
const row = rows[0];
return row ? { replayId: row.replayId, timestamp: Number(row.timestamp) } : null;
}
@@ -0,0 +1,79 @@
import clickhouse from '@/lib/clickhouse';
import { uuid } from '@/lib/crypto';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import kafka from '@/lib/kafka';
import prisma from '@/lib/prisma';
export interface HeatmapEventRow {
websiteId: string;
sessionId: string;
visitId: string;
urlPath: string;
eventType: number;
nodeId: number | null;
x: number | null;
y: number | null;
viewportW: number | null;
viewportH: number | null;
pageH: number | null;
scrollPct: number | null;
createdAt: Date;
}
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
if (!rows?.length) return;
return runQuery({
[PRISMA]: () => relationalQuery(rows),
[CLICKHOUSE]: () => clickhouseQuery(rows),
});
}
async function relationalQuery(rows: HeatmapEventRow[]) {
return prisma.client.heatmapEvent.createMany({
data: rows.map(r => ({
id: uuid(),
websiteId: r.websiteId,
sessionId: r.sessionId,
visitId: r.visitId,
urlPath: r.urlPath,
eventType: r.eventType,
nodeId: r.nodeId,
x: r.x,
y: r.y,
viewportW: r.viewportW,
viewportH: r.viewportH,
pageH: r.pageH,
scrollPct: r.scrollPct,
createdAt: r.createdAt,
})),
});
}
async function clickhouseQuery(rows: HeatmapEventRow[]) {
const { insert, getUTCString } = clickhouse;
const { sendMessage } = kafka;
const messages = rows.map(r => ({
heatmap_event_id: uuid(),
website_id: r.websiteId,
session_id: r.sessionId,
visit_id: r.visitId,
url_path: r.urlPath,
event_type: r.eventType,
node_id: r.nodeId,
x: r.x,
y: r.y,
viewport_w: r.viewportW,
viewport_h: r.viewportH,
page_h: r.pageH,
scroll_pct: r.scrollPct,
created_at: getUTCString(r.createdAt),
}));
if (kafka.enabled) {
return sendMessage('heatmap_event', messages);
}
return insert('heatmap_event', messages);
}
+3
View File
@@ -26,6 +26,9 @@ export * from './getValues';
export * from './getWebsiteDateRange';
export * from './getWebsiteStats';
export * from './getWeeklyTraffic';
export * from './heatmap/extractHeatmapEvents';
export * from './heatmap/getHeatmap';
export * from './heatmap/saveHeatmapEvents';
export * from './pageviews/getPageviewExpandedMetrics';
export * from './pageviews/getPageviewMetrics';
export * from './pageviews/getPageviewStats';
+111 -2
View File
@@ -33,6 +33,8 @@ import { record } from 'rrweb';
let flushTimer = null;
let startTime = null;
let stopped = false;
let recorderReady = false;
let customEventBuffer = [];
const sendEvents = (events, useKeepalive = false) => {
const session = window.umami?.getSession?.();
@@ -84,6 +86,42 @@ import { record } from 'rrweb';
if (stopFn) stopFn();
};
const flushCustomEvents = () => {
if (!recorderReady || !customEventBuffer.length) return;
const events = customEventBuffer;
customEventBuffer = [];
for (const event of events) {
addCustomEvent(event.tag, event.payload);
}
};
const addCustomEvent = (tag, payload) => {
if (stopped) return;
if (!recorderReady) {
customEventBuffer.push({ tag, payload });
return;
}
try {
record.addCustomEvent(tag, payload);
} catch (e) {
if (e?.message === 'please add custom event after start recording') {
recorderReady = false;
customEventBuffer.push({ tag, payload });
setTimeout(() => {
recorderReady = true;
flushCustomEvents();
}, 0);
return;
}
throw e;
}
};
const getMaskConfig = level => {
switch (level) {
case 'strict':
@@ -124,6 +162,8 @@ import { record } from 'rrweb';
}
eventBuffer.push(event);
recorderReady = true;
flushCustomEvents();
if (eventBuffer.length >= FLUSH_EVENT_COUNT) {
flush();
@@ -148,12 +188,81 @@ import { record } from 'rrweb';
checkoutEveryNms: 30000,
...(blockSelector && { blockSelector }),
});
recorderReady = true;
flushCustomEvents();
let scrollUrl = location.href;
let maxScrollPct = 0;
let scrollTimer = null;
const computeScrollPct = () => {
const pageH = document.documentElement.scrollHeight;
const visible = window.scrollY + window.innerHeight;
return {
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
pageH,
};
};
const flushScroll = () => {
if (maxScrollPct <= 0) return;
addCustomEvent('scroll-progress', {
url: scrollUrl,
scrollPct: maxScrollPct,
viewportW: window.innerWidth,
viewportH: window.innerHeight,
pageH: document.documentElement.scrollHeight,
});
maxScrollPct = 0;
};
const onScroll = () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
const { pct } = computeScrollPct();
if (pct > maxScrollPct) maxScrollPct = pct;
scrollTimer = null;
}, 200);
};
const onUrlChange = () => {
if (location.href === scrollUrl) return;
flushScroll();
scrollUrl = location.href;
addCustomEvent('url-change', { url: scrollUrl });
};
const hookHistory = method => {
const orig = history[method];
history[method] = function (...args) {
const result = orig.apply(this, args);
onUrlChange();
return result;
};
};
hookHistory('pushState');
hookHistory('replaceState');
window.addEventListener('popstate', onUrlChange);
window.addEventListener('scroll', onScroll, { passive: true });
// Capture initial scroll position
{
const { pct } = computeScrollPct();
if (pct > maxScrollPct) maxScrollPct = pct;
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush(true);
if (document.visibilityState === 'hidden') {
flushScroll();
flush(true);
}
});
window.addEventListener('beforeunload', () => flush(true));
window.addEventListener('beforeunload', () => {
flushScroll();
flush(true);
});
};
if (document.readyState === 'complete') {