Merge branch 'heatmaps' into dev

# Conflicts:
#	db/clickhouse/schema.sql
This commit is contained in:
Mike Cao committed 2026-05-07 14:49:21 -07:00
commit 9b18511af0
23 files changed
+1702 -10

No files matched your search

@@ -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';