Switch heatmap snapshots to iframe previews
This commit is contained in:
@@ -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<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;
|
||||
|
||||
@@ -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<HeatmapSnapshot | null> {
|
||||
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<HeatmapSnapshotIframe | null> {
|
||||
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) {
|
||||
|
||||
@@ -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 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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user