Reject oversized replay payloads and chunk recorder flushes

This commit is contained in:
Mike Cao
2026-06-10 23:18:50 -07:00
parent 1118f4b28e
commit 475d9548de
3 changed files with 121 additions and 17 deletions
+33 -1
View File
@@ -8,7 +8,7 @@ import { parseToken } from '@/lib/jwt';
import { fetchAccount, fetchTeam } from '@/lib/load';
import { getRecorderConfig } from '@/lib/recorder';
import { parseRequest } from '@/lib/request';
import { badRequest, forbidden, json, serverError } from '@/lib/response';
import { badRequest, forbidden, json, payloadTooLarge, serverError } from '@/lib/response';
import { getWebsite } from '@/queries/prisma';
import { saveRecording } from '@/queries/sql';
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
@@ -18,6 +18,8 @@ interface Cache {
visitId: string;
}
const MAX_RECORD_REQUEST_BYTES = 1_000_000;
const schema = z.discriminatedUnion('type', [
z.object({
type: z.literal('record'),
@@ -73,8 +75,38 @@ function getUrlPath(url: string) {
}
}
async function getRequestBodySize(request: Request): Promise<number | null> {
const contentLength = request.headers.get('content-length');
if (contentLength) {
const size = Number(contentLength);
if (Number.isFinite(size)) {
return size;
}
}
try {
const text = await request.clone().text();
return new TextEncoder().encode(text).length;
} catch {
return null;
}
}
export async function POST(request: Request) {
try {
const requestBodySize = await getRequestBodySize(request);
if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) {
return payloadTooLarge({
reason: 'payload_too_large',
maxBytes: MAX_RECORD_REQUEST_BYTES,
size: requestBodySize,
});
}
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
+9
View File
@@ -36,6 +36,15 @@ export function forbidden(error?: Record<string, any>) {
);
}
export function payloadTooLarge(error?: Record<string, any>) {
return Response.json(
{
error: { message: 'Payload too large', code: 'payload-too-large', status: 413, ...error },
},
{ status: 413 },
);
}
export function notFound(error?: Record<string, any>) {
return Response.json(
{ error: { message: 'Not found', code: 'not-found', status: 404, ...error } },
+79 -16
View File
@@ -23,6 +23,7 @@ import { record } from 'rrweb';
const REPLAY_FLUSH_EVENT_COUNT = 100;
const REPLAY_FLUSH_INTERVAL = 2000;
const REPLAY_MAX_PAYLOAD_SIZE = 900000;
const HEATMAP_FLUSH_EVENT_COUNT = 20;
const HEATMAP_FLUSH_INTERVAL = 5000;
@@ -45,12 +46,8 @@ import { record } from 'rrweb';
const getSessionCache = () => window.umami?.getSession?.()?.cache;
const sendPayload = (type, payload, useKeepalive = false) => {
const cache = getSessionCache();
if (!cache) return;
const body = JSON.stringify({
const getPayloadBody = (type, payload) =>
JSON.stringify({
type,
payload: {
website,
@@ -58,7 +55,28 @@ import { record } from 'rrweb';
},
});
const keepalive = useKeepalive && body.length < 60000;
const getPayloadSize = body => {
try {
return new Blob([body]).size;
} catch {
return body.length;
}
};
const getReplayPayloadSize = (events, timestamp) =>
getPayloadSize(getPayloadBody('record', { events, timestamp }));
const isReplayPayloadTooLarge = (events, timestamp) =>
getReplayPayloadSize(events, timestamp) > REPLAY_MAX_PAYLOAD_SIZE;
const sendPayload = (type, payload, useKeepalive = false) => {
const cache = getSessionCache();
if (!cache) return;
const body = getPayloadBody(type, payload);
const keepalive = useKeepalive && getPayloadSize(body) < 60000;
return fetch(endpoint, {
keepalive,
@@ -72,20 +90,55 @@ import { record } from 'rrweb';
}).catch(() => {});
};
const sendReplayEvents = (events, timestamp, useKeepalive = false) => {
let chunk = [];
let chunkOffset = 0;
events.forEach(event => {
const candidate = [...chunk, event];
if (isReplayPayloadTooLarge(candidate, timestamp + chunkOffset)) {
if (chunk.length) {
sendPayload(
'record',
{
events: chunk,
timestamp: timestamp + chunkOffset,
},
useKeepalive,
);
chunk = [];
chunkOffset += 1;
}
if (isReplayPayloadTooLarge([event], timestamp + chunkOffset)) {
chunkOffset += 1;
return;
}
}
chunk.push(event);
});
if (chunk.length) {
sendPayload(
'record',
{
events: chunk,
timestamp: timestamp + chunkOffset,
},
useKeepalive,
);
}
};
const flushReplay = (useKeepalive = false) => {
if (!replayBuffer.length) return;
const events = replayBuffer;
replayBuffer = [];
sendPayload(
'record',
{
events,
timestamp: Math.floor(Date.now() / 1000),
},
useKeepalive,
);
sendReplayEvents(events, Math.floor(Date.now() / 1000), useKeepalive);
};
const flushHeatmap = (useKeepalive = false) => {
@@ -273,9 +326,19 @@ import { record } from 'rrweb';
return;
}
if (
replayBuffer.length &&
isReplayPayloadTooLarge([...replayBuffer, event], Math.floor(Date.now() / 1000))
) {
flushReplay();
}
replayBuffer.push(event);
if (replayBuffer.length >= REPLAY_FLUSH_EVENT_COUNT) {
if (
replayBuffer.length >= REPLAY_FLUSH_EVENT_COUNT ||
isReplayPayloadTooLarge(replayBuffer, Math.floor(Date.now() / 1000))
) {
flushReplay();
}
},