chore(session-replay): trim verbose comments added in this PR

This commit is contained in:
Bilal Godil 2026-06-26 15:13:46 -07:00
parent c2dbe0abf5
commit 32978756f7
5 changed files with 15 additions and 41 deletions

View File

@ -19,16 +19,11 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0
const MAX_BODY_BYTES = 1_000_000;
const MAX_EVENTS = 5_000;
// Upper bound on a gunzipped body. Bounds memory against a zip bomb and keeps
// the decompressed payload in line with the client's MAX_SINGLE_EVENT_BYTES.
// Zip-bomb guard; keep in sync with the client's MAX_SINGLE_EVENT_BYTES.
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024;
// The client gzips large batches and sends them as application/octet-stream
// (rrweb full snapshots compress ~8x, keeping them under MAX_BODY_BYTES on the
// wire; it also evades keyword-matching adblockers). We gunzip + JSON.parse
// here so the rest of the schema validates the decoded object normally. Plain
// application/json bodies (keepalive flushes, or browsers without
// CompressionStream) arrive already-parsed and pass through untouched.
// Gunzips application/octet-stream bodies (the client gzips large batches);
// plain application/json bodies pass through untouched.
function maybeDecodeBinaryBody(value: unknown): unknown {
let bytes: Uint8Array | undefined;
if (value instanceof ArrayBuffer) {

View File

@ -175,8 +175,7 @@ it("accepts a gzipped binary body (compressed large-payload encoding)", async ({
batch_id: randomUUID(),
started_at_ms: now,
sent_at_ms: now + 500,
// A large rrweb-like full snapshot — the case that exceeds the 1MB raw wire
// limit but compresses comfortably under it.
// Large full snapshot: exceeds the 1MB raw wire limit but gzips under it.
events: [{ timestamp: now + 100, type: 2, data: { html: "x".repeat(2_000_000) } }],
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf-8"));

View File

@ -134,15 +134,9 @@ async function encodeGzipJsonBody(
jsonBody: string,
options: { keepalive: boolean },
): Promise<{ body: BodyInit, contentType: string }> {
// Shared by the analytics-event and session-replay batch uploads. gzipping
// both shrinks large payloads (rrweb full snapshots compress ~8x, keeping
// them under the server's 1MB body limit) and evades keyword-matching
// adblockers (e.g. filters on "$click"). The server detects the
// application/octet-stream content type and gunzips before schema validation.
//
// pagehide/visibilitychange flushes use keepalive: true. The browser must
// dispatch the fetch before tearing the page down — awaiting async gzip
// first lets the request slip past tear-down and never start.
// Used by analytics-event and session-replay batch uploads; the server
// gunzips application/octet-stream bodies before schema validation.
// keepalive flushes must dispatch before page tear-down, so skip async gzip.
if (options.keepalive) {
return { body: jsonBody, contentType: "application/json" };
}
@ -156,8 +150,7 @@ async function encodeGzipJsonBody(
const buffer = await new Response(stream).arrayBuffer();
return { body: new Uint8Array(buffer), contentType: "application/octet-stream" };
} catch {
// Partial/broken CompressionStream support (e.g. Safari < 16.4): fall back
// to plain JSON so the flush doesn't drop the batch via Result.error.
// Broken CompressionStream (e.g. Safari < 16.4): fall back to plain JSON.
return { body: jsonBody, contentType: "application/json" };
}
}

View File

@ -199,8 +199,7 @@ describe("SessionRecorder flush", () => {
(recorder as any)._tick();
await vi.advanceTimersByTimeAsync(0);
// Should still send the event (the transport gzips it under the wire
// limit, so it is no longer dropped client-side).
// Sent (not dropped): the transport gzips it under the wire limit.
expect(sentBodies).toHaveLength(1);
const batch = JSON.parse(sentBodies[0]);
expect(batch.events).toHaveLength(1);
@ -234,9 +233,7 @@ describe("SessionRecorder flush", () => {
);
try {
// A single event larger than MAX_SINGLE_EVENT_BYTES (8MB). Even gzipped it
// can't fit the server's decompressed budget, so it is dropped. A normal
// event queued after it should still be sent.
// >8MB (MAX_SINGLE_EVENT_BYTES) is dropped; the next event still sends.
const hugeEvent = { type: 2, timestamp: Date.now(), data: "z".repeat(9_000_000) };
const smallEvent = { type: 3, timestamp: Date.now(), data: "ok" };
const sizeOf = (e: unknown) => JSON.stringify(e).length;

View File

@ -106,16 +106,10 @@ const IDLE_TTL_MS = 3 * 60 * 1000;
const FLUSH_INTERVAL_MS = 5_000;
const MAX_EVENTS_PER_BATCH = 200;
const MAX_APPROX_BYTES_PER_BATCH = 512_000;
// Target *uncompressed* size of a single batch. The transport gzips the body
// before sending (rrweb compresses ~8x), so this comfortably clears the
// server's 1MB compressed-body limit while keeping batches small enough that
// the uncompressed keepalive fallback path stays under the limit too.
// Uncompressed per-batch target; the transport gzips before sending.
const MAX_BATCH_UNCOMPRESSED_BYTES = 900_000;
// A single event whose *uncompressed* size exceeds the server's decompressed
// budget can never be accepted (the server caps gunzip output at this size), so
// it's dropped rather than sent into a guaranteed rejection. Keep in sync with
// MAX_DECOMPRESSED_BYTES in apps/backend session-replays/batch route. Anything
// smaller is sent as its own batch and relies on gzip to fit the wire limit.
// Single events above the server's decompressed budget can never be accepted,
// so drop them. Keep in sync with MAX_DECOMPRESSED_BYTES in the backend route.
const MAX_SINGLE_EVENT_BYTES = 8_000_000;
// Reused across the emit hot path to avoid per-event allocation.
@ -293,12 +287,8 @@ export class SessionRecorder {
try {
let offset = 0;
while (offset < allEvents.length) {
// Build a batch under the per-batch target. The transport gzips the
// body, so an oversized single event (e.g. a large rrweb full snapshot)
// is sent alone and compression brings it under the server's wire
// limit. Only an event that exceeds even the server's decompressed
// budget is unsendable; drop it and move on (rrweb events aren't
// splittable).
// An oversized single event is sent alone and gzipped under the wire
// limit; only one past the server's decompressed budget is unsendable.
const firstSize = allSizes[offset] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
if (firstSize > MAX_SINGLE_EVENT_BYTES) {
captureWarning(