Switch heatmap snapshots to iframe previews
This commit is contained in:
@@ -84,10 +84,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapshotMessage {
|
|
||||||
margin-top: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollBand {
|
.scrollBand {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -172,9 +168,13 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapshotFrame {
|
.snapshotIframe {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: var(--surface-base);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
|
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 { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { useReplayQuery, useResultQuery } from '@/components/hooks';
|
import { useResultQuery } from '@/components/hooks';
|
||||||
import { getClientAuthToken } from '@/lib/client';
|
import { getClientAuthToken } from '@/lib/client';
|
||||||
import { formatLongNumber } from '@/lib/format';
|
import { formatLongNumber } from '@/lib/format';
|
||||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
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 CLICK_EDGE_PERCENT = 1.5;
|
||||||
const SCROLL_BUCKET_SIZE = 10;
|
const SCROLL_BUCKET_SIZE = 10;
|
||||||
const SNAPSHOT_UNAVAILABLE_ERROR = 'Page screenshot unavailable.';
|
|
||||||
|
|
||||||
interface ViewportBucket {
|
interface ViewportBucket {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -111,7 +110,6 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
|||||||
{urlPath ? (
|
{urlPath ? (
|
||||||
mode === 'scroll' ? (
|
mode === 'scroll' ? (
|
||||||
<ScrollHeatmapView
|
<ScrollHeatmapView
|
||||||
websiteId={websiteId}
|
|
||||||
urlPath={urlPath}
|
urlPath={urlPath}
|
||||||
scroll={scroll}
|
scroll={scroll}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
@@ -119,7 +117,6 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ClickHeatmapView
|
<ClickHeatmapView
|
||||||
websiteId={websiteId}
|
|
||||||
urlPath={urlPath}
|
urlPath={urlPath}
|
||||||
points={points}
|
points={points}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
@@ -234,13 +231,11 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClickHeatmapView({
|
function ClickHeatmapView({
|
||||||
websiteId,
|
|
||||||
urlPath,
|
urlPath,
|
||||||
points,
|
points,
|
||||||
snapshot,
|
snapshot,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
|
||||||
urlPath: string;
|
urlPath: string;
|
||||||
points: HeatmapPoint[];
|
points: HeatmapPoint[];
|
||||||
snapshot: HeatmapSnapshot | null;
|
snapshot: HeatmapSnapshot | null;
|
||||||
@@ -280,7 +275,7 @@ function ClickHeatmapView({
|
|||||||
const renderHeight = snapshot?.pageH ?? baseHeight;
|
const renderHeight = snapshot?.pageH ?? baseHeight;
|
||||||
const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX);
|
const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX);
|
||||||
const canvasWidth = hasMeasuredWidth
|
const canvasWidth = hasMeasuredWidth
|
||||||
? snapshot?.kind === 'replay'
|
? snapshot?.kind === 'iframe'
|
||||||
? `${renderWidth}px`
|
? `${renderWidth}px`
|
||||||
: `min(100%, ${renderWidth}px)`
|
: `min(100%, ${renderWidth}px)`
|
||||||
: '100%';
|
: '100%';
|
||||||
@@ -322,14 +317,8 @@ function ClickHeatmapView({
|
|||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
|
|
||||||
<Text color="muted" className={styles.snapshotMessage}>
|
|
||||||
{SNAPSHOT_UNAVAILABLE_ERROR}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
|
className={`${styles.canvasWrapper} ${snapshot?.kind === 'iframe' ? styles.canvasWrapperScrollable : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.canvas}
|
className={styles.canvas}
|
||||||
@@ -348,11 +337,7 @@ function ClickHeatmapView({
|
|||||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||||
{shouldRenderSnapshot && snapshot && (
|
{shouldRenderSnapshot && snapshot && (
|
||||||
<div hidden={!showPage}>
|
<div hidden={!showPage}>
|
||||||
<SnapshotPreview
|
<SnapshotPreview snapshot={snapshot} onReady={handleSnapshotReady} />
|
||||||
websiteId={websiteId}
|
|
||||||
snapshot={snapshot}
|
|
||||||
onReady={handleSnapshotReady}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -410,13 +395,11 @@ function ClickHeatmapView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScrollHeatmapView({
|
function ScrollHeatmapView({
|
||||||
websiteId,
|
|
||||||
urlPath,
|
urlPath,
|
||||||
scroll,
|
scroll,
|
||||||
snapshot,
|
snapshot,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
|
||||||
urlPath: string;
|
urlPath: string;
|
||||||
scroll: HeatmapResult['scroll'] | undefined;
|
scroll: HeatmapResult['scroll'] | undefined;
|
||||||
snapshot: HeatmapSnapshot | null;
|
snapshot: HeatmapSnapshot | null;
|
||||||
@@ -444,7 +427,7 @@ function ScrollHeatmapView({
|
|||||||
const renderHeight = snapshot?.pageH ?? baseHeight;
|
const renderHeight = snapshot?.pageH ?? baseHeight;
|
||||||
const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW);
|
const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW);
|
||||||
const canvasWidth = hasMeasuredWidth
|
const canvasWidth = hasMeasuredWidth
|
||||||
? snapshot?.kind === 'replay'
|
? snapshot?.kind === 'iframe'
|
||||||
? `${renderWidth}px`
|
? `${renderWidth}px`
|
||||||
: `min(100%, ${renderWidth}px)`
|
: `min(100%, ${renderWidth}px)`
|
||||||
: '100%';
|
: '100%';
|
||||||
@@ -501,14 +484,8 @@ function ScrollHeatmapView({
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
|
|
||||||
<Text color="muted" className={styles.snapshotMessage}>
|
|
||||||
{SNAPSHOT_UNAVAILABLE_ERROR}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
|
className={`${styles.canvasWrapper} ${snapshot?.kind === 'iframe' ? styles.canvasWrapperScrollable : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.canvas}
|
className={styles.canvas}
|
||||||
@@ -526,11 +503,7 @@ function ScrollHeatmapView({
|
|||||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||||
{shouldRenderSnapshot && snapshot && (
|
{shouldRenderSnapshot && snapshot && (
|
||||||
<div hidden={!showPage}>
|
<div hidden={!showPage}>
|
||||||
<SnapshotPreview
|
<SnapshotPreview snapshot={snapshot} onReady={handleSnapshotReady} />
|
||||||
websiteId={websiteId}
|
|
||||||
snapshot={snapshot}
|
|
||||||
onReady={handleSnapshotReady}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showOverlay && (
|
{showOverlay && (
|
||||||
@@ -578,28 +551,33 @@ function ScrollHeatmapView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SnapshotPreview({
|
function SnapshotPreview({
|
||||||
websiteId,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
onReady,
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
|
||||||
snapshot: HeatmapSnapshot;
|
snapshot: HeatmapSnapshot;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (snapshot.kind === 'replay') {
|
if (snapshot.kind === 'iframe') {
|
||||||
return <ReplaySnapshot websiteId={websiteId} snapshot={snapshot} onReady={onReady} />;
|
return <IframeSnapshot snapshot={snapshot} onReady={onReady} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SnapshotImage 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 [src, setSrc] = useState<string | null>(null);
|
||||||
const imageUrl = snapshot.kind === 'image' ? snapshot.imageUrl : null;
|
const imageUrl = snapshot.imageUrl;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (snapshot.kind !== 'image' || !imageUrl) {
|
if (!imageUrl) {
|
||||||
setSrc(null);
|
setSrc(null);
|
||||||
|
onReady();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +614,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [imageUrl, onReady, snapshot.id, snapshot.kind]);
|
}, [imageUrl, onReady, snapshot.id]);
|
||||||
|
|
||||||
const handleLoad = useCallback(() => onReady(), [onReady]);
|
const handleLoad = useCallback(() => onReady(), [onReady]);
|
||||||
|
|
||||||
@@ -653,91 +631,45 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReplaySnapshot({
|
function IframeSnapshot({
|
||||||
websiteId,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
onReady,
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
snapshot: Extract<HeatmapSnapshot, { kind: 'iframe' }>;
|
||||||
snapshot: Extract<HeatmapSnapshot, { kind: 'replay' }>;
|
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const [available, setAvailable] = useState(true);
|
||||||
const replayerRef = useRef<any>(null);
|
|
||||||
const { data: replay, isLoading } = useReplayQuery(websiteId, snapshot.replayId, {
|
|
||||||
until: snapshot.replayTimeMs ?? undefined,
|
|
||||||
chunkIndex: snapshot.chunkIndex,
|
|
||||||
eventIndex: snapshot.eventIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
setAvailable(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
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 (
|
return (
|
||||||
<div className={styles.snapshot}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,7 +371,8 @@ function getWebsiteOrigin(domain?: string | null) {
|
|||||||
return new URL(`${protocol}://${host}`);
|
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);
|
const origin = getWebsiteOrigin(domain);
|
||||||
|
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
@@ -379,6 +380,9 @@ function buildCaptureUrl(domain: string | null | undefined, urlPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new URL(urlPath || '/', origin).toString();
|
return new URL(urlPath || '/', origin).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSkipSnapshot(urlPath: string) {
|
export function shouldSkipSnapshot(urlPath: string) {
|
||||||
@@ -613,7 +617,6 @@ async function captureSnapshot(
|
|||||||
url: string,
|
url: string,
|
||||||
viewportW: number,
|
viewportW: number,
|
||||||
viewportH: number,
|
viewportH: number,
|
||||||
pageW?: number,
|
|
||||||
): Promise<CaptureResult> {
|
): Promise<CaptureResult> {
|
||||||
const browser = await createSnapshotBrowser();
|
const browser = await createSnapshotBrowser();
|
||||||
|
|
||||||
@@ -718,7 +721,7 @@ export async function ensureHeatmapSnapshot({
|
|||||||
|
|
||||||
const snapshotId = existing?.id ?? uuid();
|
const snapshotId = existing?.id ?? uuid();
|
||||||
const website = await getWebsite(websiteId);
|
const website = await getWebsite(websiteId);
|
||||||
const captureUrl = buildCaptureUrl(website?.domain, urlPath);
|
const captureUrl = buildHeatmapPageUrl(website?.domain, urlPath);
|
||||||
|
|
||||||
if (!captureUrl) {
|
if (!captureUrl) {
|
||||||
await upsertSnapshotRecord({
|
await upsertSnapshotRecord({
|
||||||
@@ -755,7 +758,7 @@ export async function ensureHeatmapSnapshot({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const capture = await captureSnapshot(captureUrl, viewportW, viewportH, pageW);
|
const capture = await captureSnapshot(captureUrl, viewportW, viewportH);
|
||||||
const objectKey = clickhouse.enabled
|
const objectKey = clickhouse.enabled
|
||||||
? getSnapshotObjectKey(websiteId, snapshotId, viewportW, viewportH)
|
? getSnapshotObjectKey(websiteId, snapshotId, viewportW, viewportH)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
|||||||
import { filtersObjectToArray } from '@/lib/params';
|
import { filtersObjectToArray } from '@/lib/params';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import type { QueryFilters } from '@/lib/types';
|
import type { QueryFilters } from '@/lib/types';
|
||||||
|
import { getWebsite } from '@/queries/prisma';
|
||||||
import {
|
import {
|
||||||
|
buildHeatmapPageUrl,
|
||||||
ensureHeatmapSnapshot,
|
ensureHeatmapSnapshot,
|
||||||
type HeatmapSnapshotImage,
|
type HeatmapSnapshotImage,
|
||||||
shouldSkipSnapshot,
|
shouldSkipSnapshot,
|
||||||
} from './ensureHeatmapSnapshot';
|
} from './ensureHeatmapSnapshot';
|
||||||
import { getHeatmapReplayPreview } from './getHeatmapReplayPreview';
|
|
||||||
|
|
||||||
const FUNCTION_NAME = 'getHeatmap';
|
const FUNCTION_NAME = 'getHeatmap';
|
||||||
|
|
||||||
@@ -48,20 +49,17 @@ export interface HeatmapScrollBucket {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeatmapSnapshotReplay {
|
export interface HeatmapSnapshotIframe {
|
||||||
kind: 'replay';
|
kind: 'iframe';
|
||||||
id: string;
|
id: string;
|
||||||
replayId: string;
|
url: string;
|
||||||
chunkIndex: number;
|
|
||||||
eventIndex: number;
|
|
||||||
replayTimeMs: number | null;
|
|
||||||
pageW: number;
|
pageW: number;
|
||||||
pageH: number;
|
pageH: number;
|
||||||
viewportW: number;
|
viewportW: number;
|
||||||
viewportH: number;
|
viewportH: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotReplay;
|
export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotIframe;
|
||||||
|
|
||||||
export interface HeatmapResult {
|
export interface HeatmapResult {
|
||||||
mode: HeatmapMode;
|
mode: HeatmapMode;
|
||||||
@@ -411,7 +409,7 @@ async function clickhouseQuery(
|
|||||||
viewportH:
|
viewportH:
|
||||||
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
|
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
|
||||||
};
|
};
|
||||||
const snapshot = await ensureHeatmapSnapshot({
|
const snapshot = await resolveHeatmapSnapshot({
|
||||||
websiteId,
|
websiteId,
|
||||||
urlPath,
|
urlPath,
|
||||||
viewportW: scroll.viewportW,
|
viewportW: scroll.viewportW,
|
||||||
@@ -501,7 +499,7 @@ async function clickhouseQuery(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const viewport = pickSnapshotViewport(points);
|
const viewport = pickSnapshotViewport(points);
|
||||||
const snapshot = await ensureHeatmapSnapshot({
|
const snapshot = await resolveHeatmapSnapshot({
|
||||||
websiteId,
|
websiteId,
|
||||||
urlPath,
|
urlPath,
|
||||||
viewportW: viewport?.width ?? null,
|
viewportW: viewport?.width ?? null,
|
||||||
@@ -539,8 +537,20 @@ async function resolveHeatmapSnapshot({
|
|||||||
pageW: number | null;
|
pageW: number | null;
|
||||||
pageH: number | null;
|
pageH: number | null;
|
||||||
}): Promise<HeatmapSnapshot | null> {
|
}): Promise<HeatmapSnapshot | null> {
|
||||||
if (process.env.PLAYWRIGHT_URL?.trim()) {
|
const imageSnapshot = await ensureHeatmapSnapshot({
|
||||||
return ensureHeatmapSnapshot({
|
websiteId,
|
||||||
|
urlPath,
|
||||||
|
viewportW,
|
||||||
|
viewportH,
|
||||||
|
pageW,
|
||||||
|
pageH,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageSnapshot?.status === 'ready' && imageSnapshot.imageUrl) {
|
||||||
|
return imageSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getIframeSnapshot({
|
||||||
websiteId,
|
websiteId,
|
||||||
urlPath,
|
urlPath,
|
||||||
viewportW,
|
viewportW,
|
||||||
@@ -550,26 +560,46 @@ async function resolveHeatmapSnapshot({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const replayPreview = await getHeatmapReplayPreview(websiteId, urlPath, viewportW, viewportH);
|
async function getIframeSnapshot({
|
||||||
|
websiteId,
|
||||||
if (replayPreview && pageW && pageH) {
|
urlPath,
|
||||||
return {
|
viewportW,
|
||||||
kind: 'replay',
|
viewportH,
|
||||||
id: replayPreview.id,
|
|
||||||
replayId: replayPreview.replayId,
|
|
||||||
chunkIndex: replayPreview.chunkIndex,
|
|
||||||
eventIndex: replayPreview.eventIndex,
|
|
||||||
replayTimeMs: replayPreview.replayTimeMs,
|
|
||||||
pageW,
|
pageW,
|
||||||
pageH,
|
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;
|
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(
|
function pickSnapshotViewport(
|
||||||
points: HeatmapPoint[],
|
points: HeatmapPoint[],
|
||||||
): { width: number; height: number; pageW: number; pageH: number } | null {
|
): { width: number; height: number; pageW: number; pageH: number } | null {
|
||||||
@@ -646,7 +676,7 @@ function getRelationalHeatmapPathFilterContext(filters: QueryFilters) {
|
|||||||
return { filterQuery: '', queryParams: {} };
|
return { filterQuery: '', queryParams: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const clauses = pathFilters.map(({ operator, value, paramName, name }) => {
|
const clauses = pathFilters.map(({ operator, paramName, name }) => {
|
||||||
const key = paramName ?? name;
|
const key = paramName ?? name;
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
@@ -706,7 +736,7 @@ function getClickhouseHeatmapPathFilterContext(filters: QueryFilters) {
|
|||||||
return { filterQuery: '', queryParams: {} };
|
return { filterQuery: '', queryParams: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const clauses = pathFilters.map(({ operator, value, paramName, name }) => {
|
const clauses = pathFilters.map(({ operator, paramName, name }) => {
|
||||||
const key = paramName ?? name;
|
const key = paramName ?? name;
|
||||||
|
|
||||||
switch (operator) {
|
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,6 @@ import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
|||||||
import kafka from '@/lib/kafka';
|
import kafka from '@/lib/kafka';
|
||||||
import prisma from '@/lib/prisma';
|
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 {
|
export interface SaveRecordingArgs {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -23,21 +17,10 @@ export interface SaveRecordingArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRecording(args: SaveRecordingArgs) {
|
export async function saveRecording(args: SaveRecordingArgs) {
|
||||||
const result = await runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(args),
|
[PRISMA]: () => relationalQuery(args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(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({
|
async function relationalQuery({
|
||||||
@@ -98,222 +81,3 @@ async function clickhouseQuery({
|
|||||||
|
|
||||||
return insert('session_replay', [message]);
|
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),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user