Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "heatmap_snapshot" ALTER COLUMN "created_at" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_replay_preview" (
|
||||
"preview_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"url_path" VARCHAR(500) NOT NULL,
|
||||
"viewport_w" INTEGER NOT NULL,
|
||||
"viewport_h" INTEGER NOT NULL,
|
||||
"replay_chunk_index" INTEGER NOT NULL,
|
||||
"replay_event_index" INTEGER NOT NULL,
|
||||
"replay_time_ms" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "heatmap_replay_preview_pkey" PRIMARY KEY ("preview_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "heatmap_replay_preview_website_id_idx" ON "heatmap_replay_preview"("website_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "heatmap_replay_preview_visit_id_idx" ON "heatmap_replay_preview"("visit_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "heatmap_replay_preview_website_id_url_path_idx" ON "heatmap_replay_preview"("website_id", "url_path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "heatmap_replay_preview_website_id_url_path_viewport_w_viewp_key" ON "heatmap_replay_preview"("website_id", "url_path", "viewport_w", "viewport_h");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_replay_visit_id_idx" ON "session_replay"("visit_id");
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "heatmap_event_website_id_visit_id_replay_chunk_index_replay_eve" RENAME TO "heatmap_event_website_id_visit_id_replay_chunk_index_replay_idx";
|
||||
@@ -89,6 +89,7 @@ model Website {
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
heatmapEvents HeatmapEvent[]
|
||||
heatmapReplayPreviews HeatmapReplayPreview[]
|
||||
heatmapSnapshots HeatmapSnapshot[]
|
||||
|
||||
@@index([userId])
|
||||
@@ -434,6 +435,29 @@ model HeatmapEvent {
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
|
||||
model HeatmapReplayPreview {
|
||||
id String @id() @map("preview_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
viewportW Int @map("viewport_w") @db.Integer
|
||||
viewportH Int @map("viewport_h") @db.Integer
|
||||
replayChunkIndex Int @map("replay_chunk_index") @db.Integer
|
||||
replayEventIndex Int @map("replay_event_index") @db.Integer
|
||||
replayTimeMs BigInt? @map("replay_time_ms") @db.BigInt
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@unique([websiteId, urlPath, viewportW, viewportH])
|
||||
@@index([websiteId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, urlPath])
|
||||
@@map("heatmap_replay_preview")
|
||||
}
|
||||
|
||||
model HeatmapSnapshot {
|
||||
id String @id() @map("snapshot_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
|
||||
@@ -120,6 +120,10 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.canvasWrapperScrollable {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.snapshotControlRow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -168,6 +172,11 @@
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.snapshotFrame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useResultQuery } from '@/components/hooks';
|
||||
import { useReplayQuery, useResultQuery } from '@/components/hooks';
|
||||
import { getClientAuthToken } from '@/lib/client';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
||||
@@ -111,6 +111,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
{urlPath ? (
|
||||
mode === 'scroll' ? (
|
||||
<ScrollHeatmapView
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
scroll={scroll}
|
||||
snapshot={snapshot}
|
||||
@@ -118,6 +119,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
/>
|
||||
) : (
|
||||
<ClickHeatmapView
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
points={points}
|
||||
snapshot={snapshot}
|
||||
@@ -232,11 +234,13 @@ function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
||||
}
|
||||
|
||||
function ClickHeatmapView({
|
||||
websiteId,
|
||||
urlPath,
|
||||
points,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
points: HeatmapPoint[];
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
@@ -262,11 +266,11 @@ function ClickHeatmapView({
|
||||
);
|
||||
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
const hasSnapshotImage = Boolean(snapshot?.imageUrl);
|
||||
const hasSnapshot = Boolean(snapshot);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && hasSnapshotImage));
|
||||
}, [hasSnapshotImage, showPage, snapshot?.id]);
|
||||
setSnapshotReady(!(showPage && hasSnapshot));
|
||||
}, [hasSnapshot, showPage, snapshot?.id]);
|
||||
const overlayGutter = Math.max(48, Math.round((viewport?.width ?? 1920) * 0.04));
|
||||
const maxPointX = visible.reduce((max, point) => Math.max(max, point.pageX), 0);
|
||||
const maxPointY = visible.reduce((max, point) => Math.max(max, point.pageY), 0);
|
||||
@@ -275,10 +279,14 @@ function ClickHeatmapView({
|
||||
const renderWidth = snapshot?.pageW ?? baseWidth;
|
||||
const renderHeight = snapshot?.pageH ?? baseHeight;
|
||||
const hasMeasuredWidth = Boolean(snapshot?.pageW || viewport?.pageW || maxPointX);
|
||||
const canvasWidth = hasMeasuredWidth ? `min(100%, ${renderWidth}px)` : '100%';
|
||||
const canvasWidth = hasMeasuredWidth
|
||||
? snapshot?.kind === 'replay'
|
||||
? `${renderWidth}px`
|
||||
: `min(100%, ${renderWidth}px)`
|
||||
: '100%';
|
||||
const overlayPageW = snapshot?.pageW ?? viewport?.pageW ?? baseWidth;
|
||||
const overlayPageH = snapshot?.pageH ?? viewport?.pageH ?? baseHeight;
|
||||
const showSnapshot = renderWidth > 0 && showPage && hasSnapshotImage;
|
||||
const showSnapshot = renderWidth > 0 && showPage && hasSnapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
const totalClicks = visible.reduce((sum, point) => sum + point.count, 0);
|
||||
const showLoading = isLoading;
|
||||
@@ -313,18 +321,19 @@ function ClickHeatmapView({
|
||||
)}
|
||||
</Column>
|
||||
|
||||
{showPage && snapshot?.status === 'failed' && (
|
||||
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
|
||||
<Text color="muted" className={styles.snapshotMessage}>
|
||||
{SNAPSHOT_UNAVAILABLE_ERROR}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
|
||||
>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{
|
||||
width: canvasWidth,
|
||||
maxWidth: '100%',
|
||||
aspectRatio: `${Math.max(1, renderWidth)} / ${Math.max(1, renderHeight)}`,
|
||||
}}
|
||||
>
|
||||
@@ -336,8 +345,12 @@ function ClickHeatmapView({
|
||||
<>
|
||||
<div className={styles.snapshotClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && snapshot?.imageUrl && (
|
||||
<SnapshotImage snapshot={snapshot} onReady={handleSnapshotReady} />
|
||||
{showSnapshot && snapshot && (
|
||||
<SnapshotPreview
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showOverlay && (
|
||||
@@ -382,7 +395,7 @@ function ClickHeatmapView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSnapshotImage && (
|
||||
{hasSnapshot && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
@@ -394,11 +407,13 @@ function ClickHeatmapView({
|
||||
}
|
||||
|
||||
function ScrollHeatmapView({
|
||||
websiteId,
|
||||
urlPath,
|
||||
scroll,
|
||||
snapshot,
|
||||
isLoading,
|
||||
}: {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
scroll: HeatmapResult['scroll'] | undefined;
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
@@ -407,11 +422,11 @@ function ScrollHeatmapView({
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
const hasSnapshotImage = Boolean(snapshot?.imageUrl);
|
||||
const hasSnapshot = Boolean(snapshot);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && hasSnapshotImage));
|
||||
}, [hasSnapshotImage, showPage, snapshot?.id]);
|
||||
setSnapshotReady(!(showPage && hasSnapshot));
|
||||
}, [hasSnapshot, showPage, snapshot?.id]);
|
||||
const {
|
||||
buckets = [],
|
||||
totalSessions = 0,
|
||||
@@ -425,8 +440,12 @@ function ScrollHeatmapView({
|
||||
const renderWidth = snapshot?.pageW ?? baseWidth;
|
||||
const renderHeight = snapshot?.pageH ?? baseHeight;
|
||||
const hasMeasuredWidth = Boolean(snapshot?.pageW || pageW);
|
||||
const canvasWidth = hasMeasuredWidth ? `min(100%, ${renderWidth}px)` : '100%';
|
||||
const showSnapshot = renderWidth > 0 && showPage && hasSnapshotImage;
|
||||
const canvasWidth = hasMeasuredWidth
|
||||
? snapshot?.kind === 'replay'
|
||||
? `${renderWidth}px`
|
||||
: `min(100%, ${renderWidth}px)`
|
||||
: '100%';
|
||||
const showSnapshot = renderWidth > 0 && showPage && hasSnapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
const hasScrollData = Boolean(scroll && totalSessions > 0 && pageW && pageH && viewportW);
|
||||
const showLoading = isLoading;
|
||||
@@ -478,18 +497,19 @@ function ScrollHeatmapView({
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{showPage && snapshot?.status === 'failed' && (
|
||||
{showPage && snapshot?.kind === 'image' && snapshot.status === 'failed' && (
|
||||
<Text color="muted" className={styles.snapshotMessage}>
|
||||
{SNAPSHOT_UNAVAILABLE_ERROR}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={`${styles.canvasWrapper} ${snapshot?.kind === 'replay' ? styles.canvasWrapperScrollable : ''}`}
|
||||
>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{
|
||||
width: canvasWidth,
|
||||
maxWidth: '100%',
|
||||
aspectRatio: `${Math.max(1, renderWidth)} / ${Math.max(1, renderHeight)}`,
|
||||
}}
|
||||
>
|
||||
@@ -500,8 +520,12 @@ function ScrollHeatmapView({
|
||||
) : (
|
||||
<div className={styles.canvasClip}>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && snapshot?.imageUrl && (
|
||||
<SnapshotImage snapshot={snapshot} onReady={handleSnapshotReady} />
|
||||
{showSnapshot && snapshot && (
|
||||
<SnapshotPreview
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
@@ -536,7 +560,7 @@ function ScrollHeatmapView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSnapshotImage && (
|
||||
{hasSnapshot && (
|
||||
<Row justifyContent="center" className={styles.snapshotControlRow}>
|
||||
<Switch isSelected={showPage} onChange={setShowPage}>
|
||||
Show page
|
||||
@@ -547,11 +571,28 @@ 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} />;
|
||||
}
|
||||
|
||||
return <SnapshotImage snapshot={snapshot} onReady={onReady} />;
|
||||
}
|
||||
|
||||
function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const imageUrl = snapshot.kind === 'image' ? snapshot.imageUrl : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot.imageUrl) {
|
||||
if (snapshot.kind !== 'image' || !imageUrl) {
|
||||
setSrc(null);
|
||||
return;
|
||||
}
|
||||
@@ -562,7 +603,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
|
||||
|
||||
setSrc(null);
|
||||
|
||||
fetch(snapshot.imageUrl, {
|
||||
fetch(imageUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
@@ -589,7 +630,7 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [snapshot.id, snapshot.imageUrl]);
|
||||
}, [imageUrl, onReady, snapshot.id, snapshot.kind]);
|
||||
|
||||
const handleLoad = useCallback(() => onReady(), [onReady]);
|
||||
|
||||
@@ -606,6 +647,95 @@ function SnapshotImage({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onRea
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaySnapshot({
|
||||
websiteId,
|
||||
snapshot,
|
||||
onReady,
|
||||
}: {
|
||||
websiteId: string;
|
||||
snapshot: Extract<HeatmapSnapshot, { kind: 'replay' }>;
|
||||
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,
|
||||
});
|
||||
|
||||
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';
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasLoading() {
|
||||
return (
|
||||
<div className={styles.canvasLoading}>
|
||||
|
||||
@@ -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<CaptureResult> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<HeatmapSnapshot | null> {
|
||||
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 {
|
||||
|
||||
@@ -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<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,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<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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { record } from 'rrweb';
|
||||
import { addCustomEvent, record } from 'rrweb';
|
||||
|
||||
(window => {
|
||||
const { document } = window;
|
||||
@@ -588,6 +588,10 @@ import { record } from 'rrweb';
|
||||
flushScroll();
|
||||
scrollUrl = location.href;
|
||||
lastFlushedScrollPct = 0;
|
||||
|
||||
if (replayStopFn && !replayStopped) {
|
||||
addCustomEvent('url-change', { url: scrollUrl });
|
||||
}
|
||||
};
|
||||
|
||||
const hookHistory = method => {
|
||||
|
||||
Reference in New Issue
Block a user