heatmap migration/code clean-up
This commit is contained in:
1 parent
3888ec6f4f
commit
8a0bdc2727
13 files changed
+49
-1326
No files matched your search
@@ -1,877 +0,0 @@
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { getApiUrl } from '@/lib/api-url';
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getHeatmapSnapshot, putHeatmapSnapshot } from '@/lib/heatmap-r2';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
|
||||
const SNAPSHOT_STATUS = {
|
||||
pending: 'pending',
|
||||
ready: 'ready',
|
||||
failed: 'failed',
|
||||
} as const;
|
||||
|
||||
const SNAPSHOT_RETRY_DELAY_MS = 15 * 60 * 1000;
|
||||
const SNAPSHOT_PENDING_WINDOW_MS = 30 * 1000;
|
||||
const SNAPSHOT_DEVICE_SCALE_FACTOR = 1;
|
||||
const SNAPSHOT_UNAVAILABLE_ERROR = 'Page screenshot unavailable.';
|
||||
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;
|
||||
mimeType: string | null;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SnapshotRecord {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
objectKey: string | null;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
hasImage: boolean;
|
||||
updatedAt: Date | string | null;
|
||||
}
|
||||
|
||||
interface EnsureHeatmapSnapshotOptions {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
imageData: Buffer;
|
||||
mimeType: string;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
}
|
||||
|
||||
const CLICKHOUSE_SNAPSHOT_STATUS = {
|
||||
pending: 0,
|
||||
ready: 1,
|
||||
failed: 2,
|
||||
} as const;
|
||||
|
||||
async function measurePage(page: any) {
|
||||
return page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
const body = document.body;
|
||||
const root = document.scrollingElement || doc || body;
|
||||
const rootClientWidth = root?.clientWidth || 0;
|
||||
const docClientWidth = doc?.clientWidth || 0;
|
||||
const bodyClientWidth = body?.clientWidth || 0;
|
||||
const visibleWidth = Math.max(
|
||||
window.innerWidth,
|
||||
rootClientWidth,
|
||||
docClientWidth,
|
||||
bodyClientWidth,
|
||||
);
|
||||
const rootScrollWidth = root?.scrollWidth || 0;
|
||||
const docScrollWidth = doc?.scrollWidth || 0;
|
||||
const bodyScrollWidth = body?.scrollWidth || 0;
|
||||
const horizontalOverflow = Math.max(
|
||||
rootScrollWidth - rootClientWidth,
|
||||
docScrollWidth - docClientWidth,
|
||||
bodyScrollWidth - bodyClientWidth,
|
||||
0,
|
||||
);
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
if (body) {
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
||||
let node = walker.currentNode as Element | null;
|
||||
|
||||
while (node) {
|
||||
const rect = node.getBoundingClientRect?.();
|
||||
|
||||
if (rect && (rect.width > 0 || rect.height > 0)) {
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
}
|
||||
|
||||
node = walker.nextNode() as Element | null;
|
||||
}
|
||||
}
|
||||
|
||||
const pageW =
|
||||
horizontalOverflow > 24
|
||||
? Math.max(visibleWidth, rootScrollWidth, docScrollWidth, bodyScrollWidth)
|
||||
: visibleWidth;
|
||||
const pageH = Math.max(
|
||||
window.innerHeight,
|
||||
root?.scrollHeight || 0,
|
||||
doc?.scrollHeight || 0,
|
||||
body?.scrollHeight || 0,
|
||||
Math.ceil(maxBottom),
|
||||
);
|
||||
|
||||
return {
|
||||
pageW: Math.ceil(pageW),
|
||||
pageH: Math.ceil(pageH),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function warmLazyContent(page: any) {
|
||||
await page.evaluate(async () => {
|
||||
const root = document.scrollingElement || document.documentElement;
|
||||
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(0, root.scrollHeight - window.innerHeight);
|
||||
|
||||
if (maxScrollTop <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = [
|
||||
Math.min(window.innerHeight, maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.25), maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.5), maxScrollTop),
|
||||
Math.min(Math.round(maxScrollTop * 0.75), maxScrollTop),
|
||||
maxScrollTop,
|
||||
].filter((value, index, values) => value > 0 && values.indexOf(value) === index);
|
||||
|
||||
for (const top of targets) {
|
||||
window.scrollTo(0, top);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 350));
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 250));
|
||||
});
|
||||
}
|
||||
|
||||
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 findSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
if (clickhouse.enabled) {
|
||||
return findClickhouseSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
}
|
||||
|
||||
return findRelationalSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
}
|
||||
|
||||
async function findRelationalSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
snapshot_id as id,
|
||||
website_id as "websiteId",
|
||||
url_path as "urlPath",
|
||||
viewport_w as "viewportW",
|
||||
viewport_h as "viewportH",
|
||||
page_w as "pageW",
|
||||
page_h as "pageH",
|
||||
status,
|
||||
mime_type as "mimeType",
|
||||
null as "objectKey",
|
||||
image_size as "imageSize",
|
||||
error,
|
||||
image_data is not null as "hasImage",
|
||||
updated_at as "updatedAt"
|
||||
from heatmap_snapshot
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and url_path = {{urlPath}}
|
||||
and viewport_w = {{viewportW}}
|
||||
and viewport_h = {{viewportH}}
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, urlPath, viewportW, viewportH },
|
||||
'findHeatmapSnapshot',
|
||||
);
|
||||
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function findClickhouseSnapshot(
|
||||
websiteId: string,
|
||||
urlPath: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<SnapshotRecord | null> {
|
||||
const rows = await clickhouse.rawQuery<
|
||||
{
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: number;
|
||||
mimeType: string | null;
|
||||
objectKey: string;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
snapshot_id as id,
|
||||
website_id as websiteId,
|
||||
url_path as urlPath,
|
||||
viewport_w as viewportW,
|
||||
viewport_h as viewportH,
|
||||
page_w as pageW,
|
||||
page_h as pageH,
|
||||
status,
|
||||
mime_type as mimeType,
|
||||
object_key as objectKey,
|
||||
image_size as imageSize,
|
||||
error,
|
||||
created_at as createdAt
|
||||
from heatmap_snapshot
|
||||
where website_id = {websiteId:UUID}
|
||||
and url_path = {urlPath:String}
|
||||
and viewport_w = {viewportW:UInt32}
|
||||
and viewport_h = {viewportH:UInt32}
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, urlPath, viewportW, viewportH },
|
||||
'findHeatmapSnapshot',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = Object.entries(CLICKHOUSE_SNAPSHOT_STATUS).find(
|
||||
([, value]) => value === row.status,
|
||||
)?.[0];
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
status: SNAPSHOT_STATUS[status as keyof typeof SNAPSHOT_STATUS],
|
||||
mimeType: row.mimeType || null,
|
||||
objectKey: row.objectKey || null,
|
||||
hasImage: row.status === CLICKHOUSE_SNAPSHOT_STATUS.ready && Boolean(row.objectKey),
|
||||
updatedAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshotImageUrl(websiteId: string, snapshotId: string) {
|
||||
return getApiUrl(`/websites/${websiteId}/heatmaps/snapshots/${snapshotId}`, {
|
||||
apiUrl: process.env.API_URL,
|
||||
basePath: process.env.BASE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
function mapSnapshot(websiteId: string, row: SnapshotRecord): HeatmapSnapshotImage {
|
||||
return {
|
||||
kind: 'image',
|
||||
id: row.id,
|
||||
imageUrl:
|
||||
row.status === SNAPSHOT_STATUS.ready && row.hasImage
|
||||
? getSnapshotImageUrl(websiteId, row.id)
|
||||
: null,
|
||||
status: row.status,
|
||||
mimeType: row.mimeType,
|
||||
pageW: Number(row.pageW),
|
||||
pageH: Number(row.pageH),
|
||||
viewportW: Number(row.viewportW),
|
||||
viewportH: Number(row.viewportH),
|
||||
error: row.error,
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstDomain(domain?: string | null) {
|
||||
return domain?.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getWebsiteOrigin(domain?: string | null) {
|
||||
const host = getFirstDomain(domain);
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.startsWith('http://') || host.startsWith('https://')) {
|
||||
return new URL(host);
|
||||
}
|
||||
|
||||
const protocol =
|
||||
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]')
|
||||
? 'http'
|
||||
: 'https';
|
||||
|
||||
return new URL(`${protocol}://${host}`);
|
||||
}
|
||||
|
||||
export function buildHeatmapPageUrl(domain: string | null | undefined, urlPath: string) {
|
||||
try {
|
||||
const origin = getWebsiteOrigin(domain);
|
||||
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(urlPath || '/', origin).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSkipSnapshot(urlPath: string) {
|
||||
// Internal Umami app routes cannot be rendered from the tracked website domain.
|
||||
return urlPath.startsWith('/teams/');
|
||||
}
|
||||
|
||||
async function upsertSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
objectKey,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageData: Buffer | null;
|
||||
objectKey?: string | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (clickhouse.enabled) {
|
||||
return insertClickhouseSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
objectKey: objectKey ?? null,
|
||||
imageSize: imageData?.byteLength ?? null,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertRelationalSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRelationalSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
imageData: Buffer | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
return rawExecute(
|
||||
`
|
||||
insert into heatmap_snapshot (
|
||||
snapshot_id,
|
||||
website_id,
|
||||
url_path,
|
||||
viewport_w,
|
||||
viewport_h,
|
||||
page_w,
|
||||
page_h,
|
||||
status,
|
||||
mime_type,
|
||||
image_data,
|
||||
image_size,
|
||||
error,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
{{id::uuid}},
|
||||
{{websiteId::uuid}},
|
||||
{{urlPath}},
|
||||
{{viewportW}},
|
||||
{{viewportH}},
|
||||
{{pageW}},
|
||||
{{pageH}},
|
||||
{{status}},
|
||||
{{mimeType}},
|
||||
{{imageData}},
|
||||
{{imageSize}},
|
||||
{{error}},
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (website_id, url_path, viewport_w, viewport_h) do update
|
||||
set
|
||||
page_w = excluded.page_w,
|
||||
page_h = excluded.page_h,
|
||||
status = excluded.status,
|
||||
mime_type = excluded.mime_type,
|
||||
image_data = excluded.image_data,
|
||||
image_size = excluded.image_size,
|
||||
error = excluded.error,
|
||||
updated_at = now()
|
||||
`,
|
||||
{
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
imageData,
|
||||
imageSize: imageData?.byteLength ?? null,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function insertClickhouseSnapshotRecord({
|
||||
id,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status,
|
||||
mimeType,
|
||||
objectKey,
|
||||
imageSize,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
pageW: number;
|
||||
pageH: number;
|
||||
status: HeatmapSnapshotStatus;
|
||||
mimeType: string | null;
|
||||
objectKey: string | null;
|
||||
imageSize: number | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
return clickhouse.insert('heatmap_snapshot', [
|
||||
{
|
||||
snapshot_id: id,
|
||||
website_id: websiteId,
|
||||
url_path: urlPath,
|
||||
viewport_w: viewportW,
|
||||
viewport_h: viewportH,
|
||||
page_w: pageW,
|
||||
page_h: pageH,
|
||||
status: CLICKHOUSE_SNAPSHOT_STATUS[status],
|
||||
mime_type: mimeType || '',
|
||||
object_key: objectKey || '',
|
||||
image_size: imageSize,
|
||||
error,
|
||||
created_at: clickhouse.getUTCString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function getSnapshotObjectKey(
|
||||
websiteId: string,
|
||||
snapshotId: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
) {
|
||||
return `${websiteId}/${viewportW}x${viewportH}/${snapshotId}.png`;
|
||||
}
|
||||
|
||||
function getSnapshotErrorMessage(error: unknown) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? [error.name, error.message].filter(Boolean).join(': ')
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: SNAPSHOT_UNAVAILABLE_ERROR;
|
||||
|
||||
return message.slice(0, SNAPSHOT_ERROR_MAX_LENGTH) || SNAPSHOT_UNAVAILABLE_ERROR;
|
||||
}
|
||||
|
||||
async function createSnapshotBrowser() {
|
||||
const endpoint = process.env.PLAYWRIGHT_URL?.trim();
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { chromium } = await import('playwright-core');
|
||||
return chromium.connect(endpoint);
|
||||
}
|
||||
|
||||
async function captureSnapshot(
|
||||
url: string,
|
||||
viewportW: number,
|
||||
viewportH: number,
|
||||
): Promise<CaptureResult> {
|
||||
const browser = await createSnapshotBrowser();
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(SNAPSHOT_UNAVAILABLE_ERROR);
|
||||
}
|
||||
|
||||
const initialViewportW = viewportW;
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: initialViewportW, height: viewportH },
|
||||
screen: { width: initialViewportW, height: viewportH },
|
||||
deviceScaleFactor: SNAPSHOT_DEVICE_SCALE_FACTOR,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(500);
|
||||
await warmLazyContent(page);
|
||||
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
let dimensions = await measurePage(page);
|
||||
let currentWidth = initialViewportW;
|
||||
let captureWidth =
|
||||
dimensions.pageW > initialViewportW + 24
|
||||
? Math.max(initialViewportW, dimensions.pageW)
|
||||
: initialViewportW;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (captureWidth <= currentWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = captureWidth;
|
||||
|
||||
await page.setViewportSize({
|
||||
width: currentWidth,
|
||||
height: viewportH,
|
||||
});
|
||||
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
dimensions = await measurePage(page);
|
||||
captureWidth = Math.max(currentWidth, dimensions.pageW);
|
||||
}
|
||||
|
||||
const imageData = Buffer.from(await page.screenshot({ fullPage: true, type: 'png' }));
|
||||
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
imageData,
|
||||
mimeType: 'image/png',
|
||||
pageW: dimensions.pageW,
|
||||
pageH: dimensions.pageH,
|
||||
};
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
}: EnsureHeatmapSnapshotOptions): Promise<HeatmapSnapshotImage | null> {
|
||||
if (!urlPath || !viewportW || !viewportH || !pageW || !pageH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldSkipSnapshot(urlPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!process.env.PLAYWRIGHT_URL?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
if (existing?.status === SNAPSHOT_STATUS.ready && existing.hasImage) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const updatedAt = existing?.updatedAt ? new Date(existing.updatedAt) : null;
|
||||
const ageMs = updatedAt ? Date.now() - updatedAt.getTime() : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (existing?.status === SNAPSHOT_STATUS.pending && ageMs < SNAPSHOT_PENDING_WINDOW_MS) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
if (existing?.status === SNAPSHOT_STATUS.failed && ageMs < SNAPSHOT_RETRY_DELAY_MS) {
|
||||
return mapSnapshot(websiteId, existing);
|
||||
}
|
||||
|
||||
const snapshotId = existing?.id ?? uuid();
|
||||
const website = await getWebsite(websiteId);
|
||||
const captureUrl = buildHeatmapPageUrl(website?.domain, urlPath);
|
||||
|
||||
if (!captureUrl) {
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: SNAPSHOT_UNAVAILABLE_ERROR,
|
||||
});
|
||||
|
||||
const failed = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return failed ? mapSnapshot(websiteId, failed) : null;
|
||||
}
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.pending,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const capture = await captureSnapshot(captureUrl, viewportW, viewportH);
|
||||
const objectKey = clickhouse.enabled
|
||||
? getSnapshotObjectKey(websiteId, snapshotId, viewportW, viewportH)
|
||||
: null;
|
||||
|
||||
if (objectKey) {
|
||||
await putHeatmapSnapshot(objectKey, capture.imageData, capture.mimeType);
|
||||
}
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW: capture.pageW,
|
||||
pageH: capture.pageH,
|
||||
status: SNAPSHOT_STATUS.ready,
|
||||
mimeType: capture.mimeType,
|
||||
imageData: clickhouse.enabled ? null : capture.imageData,
|
||||
objectKey,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Heatmap snapshot capture failed', {
|
||||
captureUrl,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
error: serializeError(error),
|
||||
});
|
||||
|
||||
await upsertSnapshotRecord({
|
||||
id: snapshotId,
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
status: SNAPSHOT_STATUS.failed,
|
||||
mimeType: null,
|
||||
imageData: null,
|
||||
error: getSnapshotErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = await findSnapshot(websiteId, urlPath, viewportW, viewportH);
|
||||
|
||||
return snapshot ? mapSnapshot(websiteId, snapshot) : null;
|
||||
}
|
||||
|
||||
export async function getHeatmapSnapshotImage(
|
||||
websiteId: string,
|
||||
snapshotId: string,
|
||||
): Promise<{ mimeType: string; imageData: Buffer } | null> {
|
||||
if (clickhouse.enabled) {
|
||||
const rows = await clickhouse.rawQuery<{ mimeType: string; objectKey: string }[]>(
|
||||
`
|
||||
select
|
||||
mime_type as mimeType,
|
||||
object_key as objectKey
|
||||
from heatmap_snapshot
|
||||
where snapshot_id = {snapshotId:UUID}
|
||||
and website_id = {websiteId:UUID}
|
||||
and status = {status:UInt8}
|
||||
and object_key != ''
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
{
|
||||
websiteId,
|
||||
snapshotId,
|
||||
status: CLICKHOUSE_SNAPSHOT_STATUS.ready,
|
||||
},
|
||||
'getHeatmapSnapshotImage',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row?.objectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getHeatmapSnapshot(row.objectKey);
|
||||
}
|
||||
|
||||
const rows = await prisma.rawQuery(
|
||||
`
|
||||
select
|
||||
mime_type as "mimeType",
|
||||
image_data as "imageData"
|
||||
from heatmap_snapshot
|
||||
where snapshot_id = {{snapshotId::uuid}}
|
||||
and website_id = {{websiteId::uuid}}
|
||||
and status = 'ready'
|
||||
and image_data is not null
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, snapshotId },
|
||||
'getHeatmapSnapshotImage',
|
||||
);
|
||||
|
||||
const row = rows?.[0];
|
||||
|
||||
if (!row?.imageData || !row?.mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: row.mimeType,
|
||||
imageData: Buffer.from(row.imageData),
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ const RRWEB_MOUSE_CLICK = 2;
|
||||
|
||||
export interface ExtractedHeatmapEvent {
|
||||
eventType: number;
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
viewportW: number | null;
|
||||
@@ -18,9 +17,6 @@ export interface ExtractedHeatmapEvent {
|
||||
scrollPct: number | null;
|
||||
urlPath: string;
|
||||
createdAt: Date;
|
||||
replayChunkIndex: number | null;
|
||||
replayEventIndex: number | null;
|
||||
replayTimeMs: number | null;
|
||||
}
|
||||
|
||||
interface ExtractHeatmapEventOptions {
|
||||
@@ -36,10 +32,7 @@ function safePathname(href: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHeatmapEvents(
|
||||
events: any[],
|
||||
{ chunkIndex }: ExtractHeatmapEventOptions = {},
|
||||
): ExtractedHeatmapEvent[] {
|
||||
export function extractHeatmapEvents(events: any[], _options: ExtractHeatmapEventOptions = {}) {
|
||||
if (!Array.isArray(events) || events.length === 0) return [];
|
||||
|
||||
let urlPath: string | null = null;
|
||||
@@ -47,7 +40,7 @@ export function extractHeatmapEvents(
|
||||
let viewportH: number | null = null;
|
||||
const out: ExtractedHeatmapEvent[] = [];
|
||||
|
||||
for (const [eventIndex, ev] of events.entries()) {
|
||||
for (const ev of events) {
|
||||
if (!ev || typeof ev !== 'object') continue;
|
||||
const replayTimeMs =
|
||||
typeof ev.timestamp === 'number' && Number.isFinite(ev.timestamp) ? ev.timestamp : null;
|
||||
@@ -73,7 +66,6 @@ export function extractHeatmapEvents(
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.scroll,
|
||||
nodeId: null,
|
||||
x: null,
|
||||
y: null,
|
||||
viewportW: typeof p.viewportW === 'number' ? p.viewportW : viewportW,
|
||||
@@ -85,9 +77,6 @@ export function extractHeatmapEvents(
|
||||
: null,
|
||||
urlPath: path,
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -110,7 +99,6 @@ export function extractHeatmapEvents(
|
||||
) {
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.click,
|
||||
nodeId: typeof d.id === 'number' ? d.id : null,
|
||||
x: typeof d.x === 'number' ? Math.round(d.x) : null,
|
||||
y: typeof d.y === 'number' ? Math.round(d.y) : null,
|
||||
viewportW,
|
||||
@@ -119,9 +107,6 @@ export function extractHeatmapEvents(
|
||||
scrollPct: null,
|
||||
urlPath,
|
||||
createdAt: new Date(replayTimeMs ?? Date.now()),
|
||||
replayChunkIndex: chunkIndex ?? null,
|
||||
replayEventIndex: eventIndex,
|
||||
replayTimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ 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';
|
||||
|
||||
const FUNCTION_NAME = 'getHeatmap';
|
||||
|
||||
@@ -32,7 +26,6 @@ export interface HeatmapPage {
|
||||
}
|
||||
|
||||
export interface HeatmapPoint {
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
@@ -63,7 +56,7 @@ export interface HeatmapSnapshotIframe {
|
||||
viewportH: number;
|
||||
}
|
||||
|
||||
export type HeatmapSnapshot = HeatmapSnapshotImage | HeatmapSnapshotIframe;
|
||||
export type HeatmapSnapshot = HeatmapSnapshotIframe;
|
||||
|
||||
export interface HeatmapResult {
|
||||
mode: HeatmapMode;
|
||||
@@ -234,7 +227,6 @@ async function relationalQuery(
|
||||
const rawPoints: HeatmapPoint[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.node_id as "nodeId",
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as "pageX",
|
||||
@@ -260,7 +252,6 @@ async function relationalQuery(
|
||||
and h.viewport_w is not null
|
||||
and h.viewport_h is not null
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
@@ -441,7 +432,6 @@ async function clickhouseQuery(
|
||||
|
||||
const pointRows = await rawQuery<
|
||||
{
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
pageX: number;
|
||||
@@ -455,7 +445,6 @@ async function clickhouseQuery(
|
||||
>(
|
||||
`
|
||||
select
|
||||
h.node_id as nodeId,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x as pageX,
|
||||
@@ -481,7 +470,6 @@ async function clickhouseQuery(
|
||||
and h.viewport_w is not null
|
||||
and h.viewport_h is not null
|
||||
group by
|
||||
h.node_id,
|
||||
h.x,
|
||||
h.y,
|
||||
h.page_x,
|
||||
@@ -498,7 +486,6 @@ async function clickhouseQuery(
|
||||
);
|
||||
|
||||
const points: HeatmapPoint[] = pointRows.map(p => ({
|
||||
nodeId: p.nodeId === null || p.nodeId === undefined ? null : Number(p.nodeId),
|
||||
x: Number(p.x),
|
||||
y: Number(p.y),
|
||||
pageX: Number(p.pageX),
|
||||
@@ -549,19 +536,6 @@ async function resolveHeatmapSnapshot({
|
||||
pageW: number | null;
|
||||
pageH: number | null;
|
||||
}): Promise<HeatmapSnapshot | null> {
|
||||
const imageSnapshot = await ensureHeatmapSnapshot({
|
||||
websiteId,
|
||||
urlPath,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageW,
|
||||
pageH,
|
||||
});
|
||||
|
||||
if (imageSnapshot?.status === 'ready' && imageSnapshot.imageUrl) {
|
||||
return imageSnapshot;
|
||||
}
|
||||
|
||||
return getIframeSnapshot({
|
||||
websiteId,
|
||||
urlPath,
|
||||
@@ -612,6 +586,48 @@ async function getIframeSnapshot({
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstDomain(domain?: string | null) {
|
||||
return domain?.split(',')[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getWebsiteOrigin(domain?: string | null) {
|
||||
const host = getFirstDomain(domain);
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.startsWith('http://') || host.startsWith('https://')) {
|
||||
return new URL(host);
|
||||
}
|
||||
|
||||
const protocol =
|
||||
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]')
|
||||
? 'http'
|
||||
: 'https';
|
||||
|
||||
return new URL(`${protocol}://${host}`);
|
||||
}
|
||||
|
||||
function buildHeatmapPageUrl(domain: string | null | undefined, urlPath: string) {
|
||||
try {
|
||||
const origin = getWebsiteOrigin(domain);
|
||||
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(urlPath || '/', origin).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipSnapshot(urlPath: string) {
|
||||
// Internal Umami app routes cannot be rendered from the tracked website domain.
|
||||
return urlPath.startsWith('/teams/');
|
||||
}
|
||||
|
||||
function pickSnapshotViewport(
|
||||
points: HeatmapPoint[],
|
||||
): { width: number; height: number; pageW: number; pageH: number } | null {
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface HeatmapEventRow {
|
||||
visitId: string;
|
||||
urlPath: string;
|
||||
eventType: number;
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
pageX: number | null;
|
||||
@@ -21,9 +20,6 @@ export interface HeatmapEventRow {
|
||||
pageH: number | null;
|
||||
scrollPct: number | null;
|
||||
createdAt: Date;
|
||||
replayChunkIndex: number | null;
|
||||
replayEventIndex: number | null;
|
||||
replayTimeMs: number | null;
|
||||
}
|
||||
|
||||
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
@@ -31,7 +27,6 @@ export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
|
||||
const normalizedRows = rows.map(r => ({
|
||||
...r,
|
||||
nodeId: toInt(r.nodeId),
|
||||
x: toInt(r.x),
|
||||
y: toInt(r.y),
|
||||
pageX: toInt(r.pageX),
|
||||
@@ -70,7 +65,6 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
visitId: r.visitId,
|
||||
urlPath: r.urlPath,
|
||||
eventType: r.eventType,
|
||||
nodeId: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
pageX: r.pageX,
|
||||
@@ -81,9 +75,6 @@ async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
pageH: r.pageH,
|
||||
scrollPct: r.scrollPct,
|
||||
createdAt: r.createdAt,
|
||||
replayChunkIndex: r.replayChunkIndex,
|
||||
replayEventIndex: r.replayEventIndex,
|
||||
replayTimeMs: r.replayTimeMs,
|
||||
})) as any,
|
||||
});
|
||||
}
|
||||
@@ -99,7 +90,6 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
visit_id: r.visitId,
|
||||
url_path: r.urlPath,
|
||||
event_type: r.eventType,
|
||||
node_id: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
page_x: r.pageX,
|
||||
@@ -110,9 +100,6 @@ async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
page_h: r.pageH,
|
||||
scroll_pct: r.scrollPct,
|
||||
created_at: getUTCString(r.createdAt),
|
||||
replay_chunk_index: r.replayChunkIndex,
|
||||
replay_event_index: r.replayEventIndex,
|
||||
replay_time_ms: r.replayTimeMs,
|
||||
}));
|
||||
|
||||
if (kafka.enabled) {
|
||||
|
||||
Reference in new issue
Block a user