(null);
+ const { data: replay, isLoading } = useReplayQuery(websiteId, snapshot.replayId, {
+ until: snapshot.replayTimeMs ?? undefined,
+ chunkIndex: snapshot.chunkIndex,
+ eventIndex: snapshot.eventIndex,
+ });
+
+ useEffect(() => {
+ if (isLoading) {
+ return;
+ }
+
+ if (!replay?.events?.length || !containerRef.current) {
+ onReady();
+ return;
+ }
+
+ let cancelled = false;
+
+ import('rrweb')
+ .then(mod => {
+ if (cancelled || !containerRef.current) {
+ return;
+ }
+
+ containerRef.current.innerHTML = '';
+
+ const replayer = new mod.Replayer(replay.events, {
+ root: containerRef.current,
+ loadTimeout: 0,
+ mouseTail: false,
+ pauseAnimation: true,
+ showWarning: false,
+ });
+
+ replayerRef.current = replayer;
+
+ const firstTimestamp = Number(replay.events[0]?.timestamp ?? 0);
+ const lastTimestamp = Number(replay.events[replay.events.length - 1]?.timestamp ?? 0);
+ const timeOffset = Math.max(0, lastTimestamp - firstTimestamp);
+
+ replayer.pause(timeOffset);
+ replayer.wrapper.style.width = '100%';
+ replayer.wrapper.style.height = '100%';
+ replayer.wrapper.style.pointerEvents = 'none';
+ replayer.wrapper.style.overflow = 'hidden';
+ replayer.iframe.style.display = 'block';
+ replayer.iframe.style.width = '100%';
+ replayer.iframe.style.height = '100%';
+ replayer.iframe.style.border = '0';
+
+ onReady();
+ })
+ .catch(() => onReady());
+
+ return () => {
+ cancelled = true;
+ replayerRef.current?.destroy?.();
+ replayerRef.current = null;
+ };
+ }, [isLoading, onReady, replay?.events, snapshot.id]);
+
+ return (
+
+ );
+}
+
function CanvasLoading() {
return (
diff --git a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
index 54e727e84..0dea8b2a2 100644
--- a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
+++ b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
@@ -20,6 +20,7 @@ const SNAPSHOT_ERROR_MAX_LENGTH = 500;
export type HeatmapSnapshotStatus = (typeof SNAPSHOT_STATUS)[keyof typeof SNAPSHOT_STATUS];
export interface HeatmapSnapshotImage {
+ kind: 'image';
id: string;
imageUrl: string | null;
status: HeatmapSnapshotStatus;
@@ -331,6 +332,7 @@ function getSnapshotImageUrl(websiteId: string, snapshotId: string) {
function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage {
return {
+ kind: 'image',
id: row.id,
imageUrl:
row.status === SNAPSHOT_STATUS.ready && row.hasImage
@@ -599,16 +601,12 @@ function getSnapshotErrorMessage(error: unknown) {
async function createSnapshotBrowser() {
const endpoint = process.env.PLAYWRIGHT_URL?.trim();
- if (endpoint) {
- const { chromium } = await import('playwright-core');
- return chromium.connect(endpoint);
+ if (!endpoint) {
+ return null;
}
- const { chromium } = await import('@playwright/test');
- return chromium.launch({
- channel: 'chromium',
- headless: true,
- });
+ const { chromium } = await import('playwright-core');
+ return chromium.connect(endpoint);
}
async function captureSnapshot(
@@ -618,6 +616,11 @@ async function captureSnapshot(
pageW?: number,
): Promise {
const browser = await createSnapshotBrowser();
+
+ if (!browser) {
+ throw new Error(SNAPSHOT_UNAVAILABLE_ERROR);
+ }
+
const initialViewportW = viewportW;
try {
@@ -698,6 +701,10 @@ export async function ensureHeatmapSnapshot({
return mapSnapshot(websiteId, existing);
}
+ if (!process.env.PLAYWRIGHT_URL?.trim()) {
+ return null;
+ }
+
const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null;
const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY;
diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts
index 785956bcd..00016ca78 100644
--- a/src/queries/sql/heatmap/getHeatmap.ts
+++ b/src/queries/sql/heatmap/getHeatmap.ts
@@ -9,6 +9,7 @@ import {
type HeatmapSnapshotImage,
shouldSkipSnapshot,
} from './ensureHeatmapSnapshot';
+import { getHeatmapReplayPreview } from './getHeatmapReplayPreview';
const FUNCTION_NAME = 'getHeatmap';
@@ -47,7 +48,20 @@ export interface HeatmapScrollBucket {
sessions: number;
}
-export type HeatmapSnapshot = HeatmapSnapshotImage;
+export interface HeatmapSnapshotReplay {
+ kind: 'replay';
+ id: string;
+ replayId: string;
+ chunkIndex: number;
+ eventIndex: number;
+ replayTimeMs: number | null;
+ pageW: number;
+ pageH: number;
+ viewportW: number;
+ viewportH: number;
+}
+
+export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotReplay;
export interface HeatmapResult {
mode: HeatmapMode;
@@ -194,7 +208,7 @@ async function relationalQuery(
viewportW: dim?.viewportW ?? null,
viewportH: dim?.viewportH ?? null,
};
- const snapshot = await ensureHeatmapSnapshot({
+ const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: scroll.viewportW,
@@ -258,7 +272,7 @@ async function relationalQuery(
);
const viewport = pickSnapshotViewport(rawPoints);
- const snapshot = await ensureHeatmapSnapshot({
+ const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
@@ -510,6 +524,52 @@ function emptyScroll(): HeatmapResult['scroll'] {
};
}
+async function resolveHeatmapSnapshot({
+ websiteId,
+ urlPath,
+ viewportW,
+ viewportH,
+ pageW,
+ pageH,
+}: {
+ websiteId: string;
+ urlPath: string;
+ viewportW: number | null;
+ viewportH: number | null;
+ pageW: number | null;
+ pageH: number | null;
+}): Promise {
+ if (process.env.PLAYWRIGHT_URL?.trim()) {
+ return ensureHeatmapSnapshot({
+ websiteId,
+ urlPath,
+ viewportW,
+ viewportH,
+ pageW,
+ pageH,
+ });
+ }
+
+ const replayPreview = await getHeatmapReplayPreview(websiteId, urlPath, viewportW, viewportH);
+
+ if (replayPreview && pageW && pageH) {
+ return {
+ kind: 'replay',
+ id: replayPreview.id,
+ replayId: replayPreview.replayId,
+ chunkIndex: replayPreview.chunkIndex,
+ eventIndex: replayPreview.eventIndex,
+ replayTimeMs: replayPreview.replayTimeMs,
+ pageW,
+ pageH,
+ viewportW: replayPreview.viewportW,
+ viewportH: replayPreview.viewportH,
+ };
+ }
+
+ return null;
+}
+
function pickSnapshotViewport(
points: HeatmapPoint[],
): { width: number; height: number; pageW: number; pageH: number } | null {
diff --git a/src/queries/sql/heatmap/getHeatmapReplayPreview.ts b/src/queries/sql/heatmap/getHeatmapReplayPreview.ts
new file mode 100644
index 000000000..9215b03a5
--- /dev/null
+++ b/src/queries/sql/heatmap/getHeatmapReplayPreview.ts
@@ -0,0 +1,70 @@
+import prisma from '@/lib/prisma';
+
+export interface HeatmapReplayPreview {
+ id: string;
+ replayId: string;
+ chunkIndex: number;
+ eventIndex: number;
+ replayTimeMs: number | null;
+ viewportW: number;
+ viewportH: number;
+}
+
+export async function getHeatmapReplayPreview(
+ websiteId: string,
+ urlPath: string,
+ viewportW: number | null,
+ viewportH: number | null,
+): Promise {
+ if (!websiteId || !urlPath || !viewportW || !viewportH) {
+ return null;
+ }
+
+ const rows: {
+ id: string;
+ replayId: string;
+ chunkIndex: number;
+ eventIndex: number;
+ replayTimeMs: bigint | number | null;
+ viewportW: number;
+ viewportH: number;
+ }[] = await prisma.rawQuery(
+ `
+ select
+ preview_id as id,
+ visit_id as "replayId",
+ replay_chunk_index as "chunkIndex",
+ replay_event_index as "eventIndex",
+ replay_time_ms as "replayTimeMs",
+ viewport_w as "viewportW",
+ viewport_h as "viewportH"
+ from heatmap_replay_preview
+ where website_id = {{websiteId::uuid}}
+ and url_path = {{urlPath}}
+ and viewport_w = {{viewportW}}
+ and viewport_h = {{viewportH}}
+ limit 1
+ `,
+ { websiteId, urlPath, viewportW, viewportH },
+ 'getHeatmapReplayPreview',
+ );
+
+ if (!rows.length) {
+ return null;
+ }
+
+ const row = rows[0];
+
+ return {
+ id: row.id,
+ replayId: row.replayId,
+ chunkIndex: Number(row.chunkIndex),
+ eventIndex: Number(row.eventIndex),
+ replayTimeMs:
+ row.replayTimeMs === null || row.replayTimeMs === undefined
+ ? null
+ : Number(row.replayTimeMs),
+ viewportW: Number(row.viewportW),
+ viewportH: Number(row.viewportH),
+ };
+}
diff --git a/src/queries/sql/replays/saveRecording.ts b/src/queries/sql/replays/saveRecording.ts
index 01f58ca05..cb7cc9555 100644
--- a/src/queries/sql/replays/saveRecording.ts
+++ b/src/queries/sql/replays/saveRecording.ts
@@ -5,6 +5,12 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import kafka from '@/lib/kafka';
import prisma from '@/lib/prisma';
+const RRWEB_TYPE_FULL_SNAPSHOT = 2;
+const RRWEB_TYPE_INCREMENTAL = 3;
+const RRWEB_TYPE_META = 4;
+const RRWEB_TYPE_CUSTOM = 5;
+const RRWEB_SOURCE_VIEWPORT_RESIZE = 4;
+
export interface SaveRecordingArgs {
websiteId: string;
sessionId: string;
@@ -17,10 +23,21 @@ export interface SaveRecordingArgs {
}
export async function saveRecording(args: SaveRecordingArgs) {
- return runQuery({
+ const result = await runQuery({
[PRISMA]: () => relationalQuery(args),
[CLICKHOUSE]: () => clickhouseQuery(args),
});
+
+ // rrweb-backed heatmap previews are intentionally relational-only.
+ if (!clickhouse.enabled) {
+ try {
+ await upsertHeatmapReplayPreviews(args);
+ } catch (error) {
+ console.error('Failed to save heatmap replay preview', error);
+ }
+ }
+
+ return result;
}
async function relationalQuery({
@@ -81,3 +98,222 @@ async function clickhouseQuery({
return insert('session_replay', [message]);
}
+
+interface HeatmapReplayPreviewRow {
+ websiteId: string;
+ sessionId: string;
+ visitId: string;
+ urlPath: string;
+ viewportW: number;
+ viewportH: number;
+ replayChunkIndex: number;
+ replayEventIndex: number;
+ replayTimeMs: number | null;
+}
+
+function safePathname(href: unknown): string | null {
+ if (typeof href !== 'string') {
+ return null;
+ }
+
+ try {
+ return new URL(href).pathname || '/';
+ } catch {
+ return href.startsWith('/') ? href.split(/[?#]/)[0] : null;
+ }
+}
+
+function toBigIntOrNull(value: number | null) {
+ return value === null ? null : BigInt(Math.trunc(value));
+}
+
+function extractHeatmapReplayPreviewRows({
+ websiteId,
+ sessionId,
+ visitId,
+ chunkIndex,
+ events,
+}: Pick) {
+ const latestByKey = new Map();
+ let urlPath: string | null = null;
+ let viewportW: number | null = null;
+ let viewportH: number | null = null;
+
+ for (const [eventIndex, event] of events.entries()) {
+ if (!event || typeof event !== 'object') {
+ continue;
+ }
+
+ const replayTimeMs =
+ typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
+ ? Math.trunc(event.timestamp)
+ : null;
+
+ if (event.type === RRWEB_TYPE_META && event.data) {
+ const nextPath = safePathname(event.data.href);
+
+ if (nextPath) {
+ urlPath = nextPath;
+ }
+
+ if (typeof event.data.width === 'number') {
+ viewportW = Math.trunc(event.data.width);
+ }
+
+ if (typeof event.data.height === 'number') {
+ viewportH = Math.trunc(event.data.height);
+ }
+
+ continue;
+ }
+
+ if (event.type === RRWEB_TYPE_CUSTOM && event.data?.tag === 'url-change') {
+ const nextPath = safePathname(event.data.payload?.url);
+
+ if (nextPath) {
+ urlPath = nextPath;
+ }
+
+ continue;
+ }
+
+ if (event.type === RRWEB_TYPE_INCREMENTAL && event.data?.source === RRWEB_SOURCE_VIEWPORT_RESIZE) {
+ if (typeof event.data.width === 'number') {
+ viewportW = Math.trunc(event.data.width);
+ }
+
+ if (typeof event.data.height === 'number') {
+ viewportH = Math.trunc(event.data.height);
+ }
+ }
+
+ if (
+ !urlPath ||
+ !viewportW ||
+ !viewportH ||
+ (event.type !== RRWEB_TYPE_FULL_SNAPSHOT &&
+ event.type !== RRWEB_TYPE_INCREMENTAL &&
+ event.type !== RRWEB_TYPE_CUSTOM)
+ ) {
+ continue;
+ }
+
+ const key = `${urlPath}:${viewportW}x${viewportH}`;
+
+ latestByKey.set(key, {
+ websiteId,
+ sessionId,
+ visitId,
+ urlPath,
+ viewportW,
+ viewportH,
+ replayChunkIndex: chunkIndex,
+ replayEventIndex: eventIndex,
+ replayTimeMs,
+ });
+ }
+
+ return Array.from(latestByKey.values());
+}
+
+function getSchema() {
+ const databaseUrl = process.env.DATABASE_URL;
+
+ if (!databaseUrl) {
+ return null;
+ }
+
+ try {
+ const connectionUrl = new URL(databaseUrl);
+
+ return connectionUrl.searchParams.get('schema');
+ } catch {
+ return null;
+ }
+}
+
+async function rawExecute(sql: string, data: Record = {}) {
+ const params: any[] = [];
+ const schema = getSchema();
+
+ if (schema) {
+ await prisma.client.$executeRawUnsafe(`SET search_path TO "${schema}";`);
+ }
+
+ const query = sql.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
+ const [, name, type] = args;
+
+ params.push(data[name]);
+
+ return `$${params.length}${type ?? ''}`;
+ });
+
+ return prisma.client.$executeRawUnsafe(query, ...params);
+}
+
+async function upsertHeatmapReplayPreviews({
+ websiteId,
+ sessionId,
+ visitId,
+ chunkIndex,
+ events,
+}: Pick) {
+ const previewRows = extractHeatmapReplayPreviewRows({
+ websiteId,
+ sessionId,
+ visitId,
+ chunkIndex,
+ events,
+ });
+
+ for (const row of previewRows) {
+ await rawExecute(
+ `
+ insert into heatmap_replay_preview (
+ preview_id,
+ website_id,
+ session_id,
+ visit_id,
+ url_path,
+ viewport_w,
+ viewport_h,
+ replay_chunk_index,
+ replay_event_index,
+ replay_time_ms
+ )
+ values (
+ {{id::uuid}},
+ {{websiteId::uuid}},
+ {{sessionId::uuid}},
+ {{visitId::uuid}},
+ {{urlPath}},
+ {{viewportW}},
+ {{viewportH}},
+ {{replayChunkIndex}},
+ {{replayEventIndex}},
+ {{replayTimeMs::bigint}}
+ )
+ on conflict (website_id, url_path, viewport_w, viewport_h)
+ do update set
+ session_id = excluded.session_id,
+ visit_id = excluded.visit_id,
+ replay_chunk_index = excluded.replay_chunk_index,
+ replay_event_index = excluded.replay_event_index,
+ replay_time_ms = excluded.replay_time_ms,
+ updated_at = now()
+ `,
+ {
+ id: uuid(),
+ websiteId: row.websiteId,
+ sessionId: row.sessionId,
+ visitId: row.visitId,
+ urlPath: row.urlPath,
+ viewportW: row.viewportW,
+ viewportH: row.viewportH,
+ replayChunkIndex: row.replayChunkIndex,
+ replayEventIndex: row.replayEventIndex,
+ replayTimeMs: toBigIntOrNull(row.replayTimeMs),
+ },
+ );
+ }
+}