Reject oversized replay payloads and chunk recorder flushes
This commit is contained in:
@@ -8,7 +8,7 @@ import { parseToken } from '@/lib/jwt';
|
|||||||
import { fetchAccount, fetchTeam } from '@/lib/load';
|
import { fetchAccount, fetchTeam } from '@/lib/load';
|
||||||
import { getRecorderConfig } from '@/lib/recorder';
|
import { getRecorderConfig } from '@/lib/recorder';
|
||||||
import { parseRequest } from '@/lib/request';
|
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 { getWebsite } from '@/queries/prisma';
|
||||||
import { saveRecording } from '@/queries/sql';
|
import { saveRecording } from '@/queries/sql';
|
||||||
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
||||||
@@ -18,6 +18,8 @@ interface Cache {
|
|||||||
visitId: string;
|
visitId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_RECORD_REQUEST_BYTES = 1_000_000;
|
||||||
|
|
||||||
const schema = z.discriminatedUnion('type', [
|
const schema = z.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('record'),
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
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 });
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -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>) {
|
export function notFound(error?: Record<string, any>) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: { message: 'Not found', code: 'not-found', status: 404, ...error } },
|
{ error: { message: 'Not found', code: 'not-found', status: 404, ...error } },
|
||||||
|
|||||||
+79
-16
@@ -23,6 +23,7 @@ import { record } from 'rrweb';
|
|||||||
|
|
||||||
const REPLAY_FLUSH_EVENT_COUNT = 100;
|
const REPLAY_FLUSH_EVENT_COUNT = 100;
|
||||||
const REPLAY_FLUSH_INTERVAL = 2000;
|
const REPLAY_FLUSH_INTERVAL = 2000;
|
||||||
|
const REPLAY_MAX_PAYLOAD_SIZE = 900000;
|
||||||
const HEATMAP_FLUSH_EVENT_COUNT = 20;
|
const HEATMAP_FLUSH_EVENT_COUNT = 20;
|
||||||
const HEATMAP_FLUSH_INTERVAL = 5000;
|
const HEATMAP_FLUSH_INTERVAL = 5000;
|
||||||
|
|
||||||
@@ -45,12 +46,8 @@ import { record } from 'rrweb';
|
|||||||
|
|
||||||
const getSessionCache = () => window.umami?.getSession?.()?.cache;
|
const getSessionCache = () => window.umami?.getSession?.()?.cache;
|
||||||
|
|
||||||
const sendPayload = (type, payload, useKeepalive = false) => {
|
const getPayloadBody = (type, payload) =>
|
||||||
const cache = getSessionCache();
|
JSON.stringify({
|
||||||
|
|
||||||
if (!cache) return;
|
|
||||||
|
|
||||||
const body = JSON.stringify({
|
|
||||||
type,
|
type,
|
||||||
payload: {
|
payload: {
|
||||||
website,
|
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, {
|
return fetch(endpoint, {
|
||||||
keepalive,
|
keepalive,
|
||||||
@@ -72,20 +90,55 @@ import { record } from 'rrweb';
|
|||||||
}).catch(() => {});
|
}).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) => {
|
const flushReplay = (useKeepalive = false) => {
|
||||||
if (!replayBuffer.length) return;
|
if (!replayBuffer.length) return;
|
||||||
|
|
||||||
const events = replayBuffer;
|
const events = replayBuffer;
|
||||||
replayBuffer = [];
|
replayBuffer = [];
|
||||||
|
|
||||||
sendPayload(
|
sendReplayEvents(events, Math.floor(Date.now() / 1000), useKeepalive);
|
||||||
'record',
|
|
||||||
{
|
|
||||||
events,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
},
|
|
||||||
useKeepalive,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const flushHeatmap = (useKeepalive = false) => {
|
const flushHeatmap = (useKeepalive = false) => {
|
||||||
@@ -273,9 +326,19 @@ import { record } from 'rrweb';
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
replayBuffer.length &&
|
||||||
|
isReplayPayloadTooLarge([...replayBuffer, event], Math.floor(Date.now() / 1000))
|
||||||
|
) {
|
||||||
|
flushReplay();
|
||||||
|
}
|
||||||
|
|
||||||
replayBuffer.push(event);
|
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();
|
flushReplay();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user