}
{shouldRenderSnapshot && snapshot && (
-
+
)}
{showOverlay && (
@@ -578,28 +551,33 @@ function ScrollHeatmapView({
}
function SnapshotPreview({
- websiteId,
snapshot,
onReady,
}: {
- websiteId: string;
snapshot: HeatmapSnapshot;
onReady: () => void;
}) {
- if (snapshot.kind === 'replay') {
- return
;
+ if (snapshot.kind === 'iframe') {
+ return
;
}
return
;
}
-function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) {
+function SnapshotImage({
+ snapshot,
+ onReady,
+}: {
+ snapshot: Extract
;
+ onReady: () => void;
+}) {
const [src, setSrc] = useState(null);
- const imageUrl = snapshot.kind === 'image' ? snapshot.imageUrl : null;
+ const imageUrl = snapshot.imageUrl;
useEffect(() => {
- if (snapshot.kind !== 'image' || !imageUrl) {
+ if (!imageUrl) {
setSrc(null);
+ onReady();
return;
}
@@ -636,7 +614,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
URL.revokeObjectURL(objectUrl);
}
};
- }, [imageUrl, onReady, snapshot.id, snapshot.kind]);
+ }, [imageUrl, onReady, snapshot.id]);
const handleLoad = useCallback(() => onReady(), [onReady]);
@@ -653,91 +631,45 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
);
}
-function ReplaySnapshot({
- websiteId,
+function IframeSnapshot({
snapshot,
onReady,
}: {
- websiteId: string;
- snapshot: Extract;
+ snapshot: Extract;
onReady: () => void;
}) {
- const containerRef = useRef(null);
- const replayerRef = useRef(null);
- const { data: replay, isLoading } = useReplayQuery(websiteId, snapshot.replayId, {
- until: snapshot.replayTimeMs ?? undefined,
- chunkIndex: snapshot.chunkIndex,
- eventIndex: snapshot.eventIndex,
- });
+ const [available, setAvailable] = useState(true);
useEffect(() => {
- if (isLoading) {
- return;
- }
+ setAvailable(true);
- if (!replay?.events?.length || !containerRef.current) {
- onReady();
- return;
- }
+ const readyTimer = window.setTimeout(() => onReady(), 1500);
- let cancelled = false;
+ return () => window.clearTimeout(readyTimer);
+ }, [onReady, snapshot.id]);
- import('rrweb')
- .then(mod => {
- if (cancelled || !containerRef.current) {
- return;
- }
+ const handleLoad = useCallback(() => onReady(), [onReady]);
+ const handleError = useCallback(() => {
+ setAvailable(false);
+ onReady();
+ }, [onReady]);
- 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';
- const replayMouse = replayer.wrapper.querySelector('.replayer-mouse');
- const replayMouseTail = replayer.wrapper.querySelector(
- '.replayer-mouse-tail',
- );
- if (replayMouse) {
- replayMouse.style.display = 'none';
- }
- if (replayMouseTail) {
- replayMouseTail.style.display = 'none';
- }
-
- onReady();
- })
- .catch(() => onReady());
-
- return () => {
- cancelled = true;
- replayerRef.current?.destroy?.();
- replayerRef.current = null;
- };
- }, [isLoading, onReady, replay?.events, snapshot.id]);
+ if (!available) {
+ return null;
+ }
return (
);
}
diff --git a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
index 0dea8b2a2..92e43281c 100644
--- a/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
+++ b/src/queries/sql/heatmap/ensureHeatmapSnapshot.ts
@@ -371,14 +371,18 @@ function getWebsiteOrigin(domain?: string | null) {
return new URL(`${protocol}://${host}`);
}
-function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
- const origin = getWebsiteOrigin(domain);
+export function buildHeatmapPageUrl(domain: string | null | undefined, urlPath: string) {
+ try {
+ const origin = getWebsiteOrigin(domain);
- if (!origin) {
+ if (!origin) {
+ return null;
+ }
+
+ return new URL(urlPath || '/', origin).toString();
+ } catch {
return null;
}
-
- return new URL(urlPath || '/', origin).toString();
}
export function shouldSkipSnapshot(urlPath: string) {
@@ -613,7 +617,6 @@ async function captureSnapshot(
url: string,
viewportW: number,
viewportH: number,
- pageW?: number,
): Promise {
const browser = await createSnapshotBrowser();
@@ -718,7 +721,7 @@ export async function ensureHeatmapSnapshot({
const snapshotId = existing?.id ?? uuid();
const website = await getWebsite(websiteId);
- const captureUrl = buildCaptureUrl(website?.domain, urlPath);
+ const captureUrl = buildHeatmapPageUrl(website?.domain, urlPath);
if (!captureUrl) {
await upsertSnapshotRecord({
@@ -755,7 +758,7 @@ export async function ensureHeatmapSnapshot({
});
try {
- const capture = await captureSnapshot(captureUrl, viewportW, viewportH, pageW);
+ const capture = await captureSnapshot(captureUrl, viewportW, viewportH);
const objectKey = clickhouse.enabled
? getSnapshotObjectKey(websiteId, snapshotId, viewportW, viewportH)
: null;
diff --git a/src/queries/sql/heatmap/getHeatmap.ts b/src/queries/sql/heatmap/getHeatmap.ts
index 00016ca78..0a61255ec 100644
--- a/src/queries/sql/heatmap/getHeatmap.ts
+++ b/src/queries/sql/heatmap/getHeatmap.ts
@@ -4,12 +4,13 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import { filtersObjectToArray } from '@/lib/params';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
+import { getWebsite } from '@/queries/prisma';
import {
+ buildHeatmapPageUrl,
ensureHeatmapSnapshot,
type HeatmapSnapshotImage,
shouldSkipSnapshot,
} from './ensureHeatmapSnapshot';
-import { getHeatmapReplayPreview } from './getHeatmapReplayPreview';
const FUNCTION_NAME = 'getHeatmap';
@@ -48,20 +49,17 @@ export interface HeatmapScrollBucket {
sessions: number;
}
-export interface HeatmapSnapshotReplay {
- kind: 'replay';
+export interface HeatmapSnapshotIframe {
+ kind: 'iframe';
id: string;
- replayId: string;
- chunkIndex: number;
- eventIndex: number;
- replayTimeMs: number | null;
+ url: string;
pageW: number;
pageH: number;
viewportW: number;
viewportH: number;
}
-export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotReplay;
+export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotIframe;
export interface HeatmapResult {
mode: HeatmapMode;
@@ -411,7 +409,7 @@ async function clickhouseQuery(
viewportH:
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
};
- const snapshot = await ensureHeatmapSnapshot({
+ const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: scroll.viewportW,
@@ -501,7 +499,7 @@ async function clickhouseQuery(
}));
const viewport = pickSnapshotViewport(points);
- const snapshot = await ensureHeatmapSnapshot({
+ const snapshot = await resolveHeatmapSnapshot({
websiteId,
urlPath,
viewportW: viewport?.width ?? null,
@@ -539,35 +537,67 @@ async function resolveHeatmapSnapshot({
pageW: number | null;
pageH: number | null;
}): Promise {
- if (process.env.PLAYWRIGHT_URL?.trim()) {
- return ensureHeatmapSnapshot({
- websiteId,
- urlPath,
- viewportW,
- viewportH,
- pageW,
- pageH,
- });
+ const imageSnapshot = await ensureHeatmapSnapshot({
+ websiteId,
+ urlPath,
+ viewportW,
+ viewportH,
+ pageW,
+ pageH,
+ });
+
+ if (imageSnapshot?.status === 'ready' && imageSnapshot.imageUrl) {
+ return imageSnapshot;
}
- const replayPreview = await getHeatmapReplayPreview(websiteId, urlPath, viewportW, viewportH);
+ return getIframeSnapshot({
+ websiteId,
+ urlPath,
+ viewportW,
+ viewportH,
+ pageW,
+ pageH,
+ });
+}
- 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,
- };
+async function getIframeSnapshot({
+ websiteId,
+ urlPath,
+ viewportW,
+ viewportH,
+ pageW,
+ pageH,
+}: {
+ websiteId: string;
+ urlPath: string;
+ viewportW: number | null;
+ viewportH: number | null;
+ pageW: number | null;
+ pageH: number | null;
+}): Promise {
+ if (!urlPath || !viewportW || !pageW || !pageH || shouldSkipSnapshot(urlPath)) {
+ return null;
}
- return null;
+ const website = await getWebsite(websiteId);
+ const url = buildHeatmapPageUrl(website?.domain, urlPath);
+
+ if (!url) {
+ return null;
+ }
+
+ const fallbackViewportH = Math.min(Math.max(pageH, 640), 1080);
+ const height = viewportH || fallbackViewportH;
+
+ return {
+ kind: 'iframe',
+ id: `iframe:${websiteId}:${urlPath}:${viewportW}x${height}`,
+ url,
+ pageW,
+ pageH,
+ viewportW,
+ viewportH: height,
+ };
}
function pickSnapshotViewport(
@@ -646,7 +676,7 @@ function getRelationalHeatmapPathFilterContext(filters: QueryFilters) {
return { filterQuery: '', queryParams: {} };
}
- const clauses = pathFilters.map(({ operator, value, paramName, name }) => {
+ const clauses = pathFilters.map(({ operator, paramName, name }) => {
const key = paramName ?? name;
switch (operator) {
@@ -706,7 +736,7 @@ function getClickhouseHeatmapPathFilterContext(filters: QueryFilters) {
return { filterQuery: '', queryParams: {} };
}
- const clauses = pathFilters.map(({ operator, value, paramName, name }) => {
+ const clauses = pathFilters.map(({ operator, paramName, name }) => {
const key = paramName ?? name;
switch (operator) {
diff --git a/src/queries/sql/heatmap/getHeatmapReplayPreview.ts b/src/queries/sql/heatmap/getHeatmapReplayPreview.ts
deleted file mode 100644
index 9215b03a5..000000000
--- a/src/queries/sql/heatmap/getHeatmapReplayPreview.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-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 cb7cc9555..01f58ca05 100644
--- a/src/queries/sql/replays/saveRecording.ts
+++ b/src/queries/sql/replays/saveRecording.ts
@@ -5,12 +5,6 @@ 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;
@@ -23,21 +17,10 @@ export interface SaveRecordingArgs {
}
export async function saveRecording(args: SaveRecordingArgs) {
- const result = await runQuery({
+ return 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({
@@ -98,222 +81,3 @@ 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),
- },
- );
- }
-}