Extracts click events from rrweb session-replay buffers at write time into a new heatmap_event table (Prisma + ClickHouse). New /heatmaps report shows a page picker plus an overlay of click density on the dominant viewport, with an optional iframe of the live page behind.
144 lines
4.0 KiB
TypeScript
144 lines
4.0 KiB
TypeScript
import { isbot } from 'isbot';
|
|
import { serializeError } from 'serialize-error';
|
|
import { z } from 'zod';
|
|
import { secret } from '@/lib/crypto';
|
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
|
import { parseToken } from '@/lib/jwt';
|
|
import { fetchAccount, fetchTeam } from '@/lib/load';
|
|
import { parseRequest } from '@/lib/request';
|
|
import { badRequest, forbidden, json, serverError } from '@/lib/response';
|
|
import { getWebsite } from '@/queries/prisma';
|
|
import { saveRecording } from '@/queries/sql';
|
|
import { extractHeatmapEvents } from '@/queries/sql/heatmap/extractHeatmapEvents';
|
|
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
|
|
|
interface Cache {
|
|
sessionId: string;
|
|
visitId: string;
|
|
}
|
|
|
|
const schema = z.object({
|
|
type: z.literal('record'),
|
|
payload: z.object({
|
|
website: z.uuid(),
|
|
events: z.array(z.any()).max(200),
|
|
timestamp: z.coerce.number().int().optional(),
|
|
}),
|
|
});
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
|
|
|
if (error) {
|
|
return error();
|
|
}
|
|
|
|
const { website: websiteId, events, timestamp } = body.payload;
|
|
|
|
if (!events?.length) {
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// Parse cache token to get session info
|
|
const cacheHeader = request.headers.get('x-umami-cache');
|
|
|
|
if (!cacheHeader) {
|
|
return badRequest({ message: 'Missing session token.' });
|
|
}
|
|
|
|
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
|
|
|
|
if (!cache?.sessionId || !cache?.visitId) {
|
|
return badRequest({ message: 'Invalid session token.' });
|
|
}
|
|
|
|
const { sessionId, visitId } = cache;
|
|
|
|
// Query directly to avoid stale Redis cache for recordingEnabled
|
|
const website = await getWebsite(websiteId);
|
|
|
|
if (!website) {
|
|
return badRequest({ message: 'Website not found.' });
|
|
}
|
|
|
|
if (!website.replayEnabled) {
|
|
return json({ ok: false, reason: 'replay_disabled' });
|
|
}
|
|
|
|
if (process.env.CLOUD_MODE) {
|
|
const account = website.teamId
|
|
? await fetchTeam(website.teamId)
|
|
: website.userId
|
|
? await fetchAccount(website.userId)
|
|
: null;
|
|
|
|
if (!account?.isBusiness && !account?.isNoBilling) {
|
|
return forbidden({ message: 'Business subscription required.' });
|
|
}
|
|
}
|
|
|
|
// Client info for bot/IP checks
|
|
const { ip, userAgent } = await getClientInfo(request, {});
|
|
|
|
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
|
|
return json({ beep: 'boop' });
|
|
}
|
|
|
|
if (hasBlockedIp(ip)) {
|
|
return forbidden();
|
|
}
|
|
|
|
// Compute timestamps from events
|
|
const eventTimestamps = events
|
|
.map((e: any) => Number(e?.timestamp))
|
|
.filter((t: number) => Number.isFinite(t) && t > 0);
|
|
|
|
const fallbackMs = (timestamp || Math.floor(Date.now() / 1000)) * 1000;
|
|
const minTimestamp = eventTimestamps.length ? Math.min(...eventTimestamps) : fallbackMs;
|
|
const maxTimestamp = eventTimestamps.length ? Math.max(...eventTimestamps) : fallbackMs;
|
|
|
|
const startedAt = new Date(minTimestamp);
|
|
const endedAt = new Date(maxTimestamp);
|
|
|
|
// Use timestamp-based chunk index for ordering
|
|
const chunkIndex = timestamp || Math.floor(Date.now() / 1000);
|
|
|
|
await saveRecording({
|
|
websiteId,
|
|
sessionId,
|
|
visitId,
|
|
chunkIndex,
|
|
events,
|
|
eventCount: events.length,
|
|
startedAt,
|
|
endedAt,
|
|
});
|
|
|
|
try {
|
|
const heatmapRows = extractHeatmapEvents(events).map(e => ({
|
|
websiteId,
|
|
sessionId,
|
|
visitId,
|
|
...e,
|
|
}));
|
|
|
|
if (heatmapRows.length) {
|
|
await saveHeatmapEvents(heatmapRows);
|
|
}
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.log('heatmap extraction failed', serializeError(e));
|
|
}
|
|
|
|
return json({ ok: true });
|
|
} catch (e) {
|
|
const error = serializeError(e);
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log(error);
|
|
|
|
return serverError({ errorObject: error });
|
|
}
|
|
}
|