Switch heatmap snapshots to iframe previews

This commit is contained in:
Mike Cao
2026-06-20 22:51:45 -07:00
parent 0862518ec7
commit a0587f72e5
6 changed files with 129 additions and 470 deletions
@@ -84,10 +84,6 @@
white-space: nowrap;
}
.snapshotMessage {
margin-top: -4px;
}
.scrollBand {
position: absolute;
left: 0;
@@ -172,9 +168,13 @@
-webkit-user-drag: none;
}
.snapshotFrame {
.snapshotIframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: var(--surface-base);
pointer-events: none;
}
.overlay {
@@ -1,8 +1,8 @@
'use client';
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useReplayQuery, useResultQuery } from '@/components/hooks';
import { useResultQuery } from '@/components/hooks';
import { getClientAuthToken } from '@/lib/client';
import { formatLongNumber } from '@/lib/format';
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
@@ -10,7 +10,6 @@ import styles from './Heatmap.module.css';
const CLICK_EDGE_PERCENT = 1.5;
const SCROLL_BUCKET_SIZE = 10;
const SNAPSHOT_UNAVAILABLE_ERROR = 'Page screenshot unavailable.';
interface ViewportBucket {
width: number;
@@ -111,7 +110,6 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
{urlPath ? (
mode === 'scroll' ? (
<ScrollHeatmapView
websiteId={websiteId}
urlPath={urlPath}
scroll={scroll}
snapshot={snapshot}
@@ -119,7 +117,6 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
/>
) : (
<ClickHeatmapView
websiteId={websiteId}
urlPath={urlPath}
points={points}
snapshot={snapshot}
@@ -234,13 +231,11 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
}
function ClickHeatmapView({
websiteId,
urlPath,
points,
snapshot,
isLoading,
}: {
websiteId: string;
urlPath: string;
points: HeatmapPoint[];
snapshot: HeatmapSnapshot | null;
@@ -280,7 +275,7 @@ function ClickHeatmapView({
const renderHeight = snapshot?.pageH ?? baseHeight;
const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX);
const canvasWidth = hasMeasuredWidth
? snapshot?.kind === 'replay'
? snapshot?.kind === 'iframe'
? `${renderWidth}px`
: `min(100%, ${renderWidth}px)`
: '100%';
@@ -322,14 +317,8 @@ function ClickHeatmapView({
)}
</Column>
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
<Text color="muted" className={styles.snapshotMessage}>
{SNAPSHOT_UNAVAILABLE_ERROR}
</Text>
)}
<div
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
className={`${styles.canvasWrapper} ${snapshot?.kind === 'iframe' ? styles.canvasWrapperScrollable : ''}`}
>
<div
className={styles.canvas}
@@ -348,11 +337,7 @@ function ClickHeatmapView({
{showSnapshot && !snapshotReady && <CanvasLoading />}
{shouldRenderSnapshot && snapshot && (
<div hidden={!showPage}>
<SnapshotPreview
websiteId={websiteId}
snapshot={snapshot}
onReady={handleSnapshotReady}
/>
<SnapshotPreview snapshot={snapshot} onReady={handleSnapshotReady} />
</div>
)}
</div>
@@ -410,13 +395,11 @@ function ClickHeatmapView({
}
function ScrollHeatmapView({
websiteId,
urlPath,
scroll,
snapshot,
isLoading,
}: {
websiteId: string;
urlPath: string;
scroll: HeatmapResult['scroll'] | undefined;
snapshot: HeatmapSnapshot | null;
@@ -444,7 +427,7 @@ function ScrollHeatmapView({
const renderHeight = snapshot?.pageH ?? baseHeight;
const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW);
const canvasWidth = hasMeasuredWidth
? snapshot?.kind === 'replay'
? snapshot?.kind === 'iframe'
? `${renderWidth}px`
: `min(100%, ${renderWidth}px)`
: '100%';
@@ -501,14 +484,8 @@ function ScrollHeatmapView({
</Row>
)}
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
<Text color="muted" className={styles.snapshotMessage}>
{SNAPSHOT_UNAVAILABLE_ERROR}
</Text>
)}
<div
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
className={`${styles.canvasWrapper} ${snapshot?.kind === 'iframe' ? styles.canvasWrapperScrollable : ''}`}
>
<div
className={styles.canvas}
@@ -526,11 +503,7 @@ function ScrollHeatmapView({
{showSnapshot && !snapshotReady && <CanvasLoading />}
{shouldRenderSnapshot && snapshot && (
<div hidden={!showPage}>
<SnapshotPreview
websiteId={websiteId}
snapshot={snapshot}
onReady={handleSnapshotReady}
/>
<SnapshotPreview snapshot={snapshot} onReady={handleSnapshotReady} />
</div>
)}
{showOverlay && (
@@ -578,28 +551,33 @@ function ScrollHeatmapView({
}
function SnapshotPreview({
websiteId,
snapshot,
onReady,
}: {
websiteId: string;
snapshot: HeatmapSnapshot;
onReady: () => void;
}) {
if (snapshot.kind === 'replay') {
return <ReplaySnapshot websiteId={websiteId} snapshot={snapshot} onReady={onReady} />;
if (snapshot.kind === 'iframe') {
return <IframeSnapshot snapshot={snapshot} onReady={onReady} />;
}
return <SnapshotImage snapshot={snapshot} onReady={onReady} />;
}
function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) {
function SnapshotImage({
snapshot,
onReady,
}: {
snapshot: Extract<HeatmapSnapshot, { kind: 'image' }>;
onReady: () => void;
}) {
const [src, setSrc] = useState<string | null>(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<HeatmapSnapshot, { kind: 'replay' }>;
snapshot: Extract<HeatmapSnapshot, { kind: 'iframe' }>;
onReady: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const replayerRef = useRef<any>(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) {
const readyTimer = window.setTimeout(() => onReady(), 1500);
return () => window.clearTimeout(readyTimer);
}, [onReady, snapshot.id]);
const handleLoad = useCallback(() => onReady(), [onReady]);
const handleError = useCallback(() => {
setAvailable(false);
onReady();
return;
}, [onReady]);
if (!available) {
return null;
}
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';
const replayMouse = replayer.wrapper.querySelector<HTMLElement>('.replayer-mouse');
const replayMouseTail = replayer.wrapper.querySelector<HTMLCanvasElement>(
'.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]);
return (
<div className={styles.snapshot}>
<div ref={containerRef} className={styles.snapshotFrame} />
<iframe
className={styles.snapshotIframe}
src={snapshot.url}
title={snapshot.url}
tabIndex={-1}
loading="lazy"
referrerPolicy="no-referrer"
onLoad={handleLoad}
onError={handleError}
/>
</div>
);
}
@@ -371,7 +371,8 @@ function getWebsiteOrigin(domain?: string | null) {
return new URL(`${protocol}://${host}`);
}
function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
export function buildHeatmapPageUrl(domain: string | null | undefined, urlPath: string) {
try {
const origin = getWebsiteOrigin(domain);
if (!origin) {
@@ -379,6 +380,9 @@ function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
}
return new URL(urlPath || '/', origin).toString();
} catch {
return null;
}
}
export function shouldSkipSnapshot(urlPath: string) {
@@ -613,7 +617,6 @@ async function captureSnapshot(
url: string,
viewportW: number,
viewportH: number,
pageW?: number,
): Promise<CaptureResult> {
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;
+57 -27
View File
@@ -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,8 +537,20 @@ async function resolveHeatmapSnapshot({
pageW: number | null;
pageH: number | null;
}): Promise<HeatmapSnapshot | null> {
if (process.env.PLAYWRIGHT_URL?.trim()) {
return ensureHeatmapSnapshot({
const imageSnapshot = await ensureHeatmapSnapshot({
websiteId,
urlPath,
viewportW,
viewportH,
pageW,
pageH,
});
if (imageSnapshot?.status === 'ready' && imageSnapshot.imageUrl) {
return imageSnapshot;
}
return getIframeSnapshot({
websiteId,
urlPath,
viewportW,
@@ -550,26 +560,46 @@ async function resolveHeatmapSnapshot({
});
}
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,
async function getIframeSnapshot({
websiteId,
urlPath,
viewportW,
viewportH,
pageW,
pageH,
viewportW: replayPreview.viewportW,
viewportH: replayPreview.viewportH,
};
}: {
websiteId: string;
urlPath: string;
viewportW: number | null;
viewportH: number | null;
pageW: number | null;
pageH: number | null;
}): Promise<HeatmapSnapshotIframe | null> {
if (!urlPath || !viewportW || !pageW || !pageH || shouldSkipSnapshot(urlPath)) {
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(
points: HeatmapPoint[],
): { width: number; height: number; pageW: number; pageH: number } | null {
@@ -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) {
@@ -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<HeatmapReplayPreview | null> {
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),
};
}
+1 -237
View File
@@ -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<SaveRecordingArgs, 'websiteId' | 'sessionId' | 'visitId' | 'chunkIndex' | 'events'>) {
const latestByKey = new Map<string, HeatmapReplayPreviewRow>();
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<string, any> = {}) {
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<SaveRecordingArgs, 'websiteId' | 'sessionId' | 'visitId' | 'chunkIndex' | 'events'>) {
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),
},
);
}
}