From 32978756f7832d19d90a8d922c21efa5cd9bc224 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 26 Jun 2026 15:13:46 -0700 Subject: [PATCH] chore(session-replay): trim verbose comments added in this PR --- .../latest/session-replays/batch/route.tsx | 11 +++------- .../endpoints/api/v1/session-replays.test.ts | 3 +-- .../shared/src/interface/client-interface.ts | 15 ++++---------- .../implementations/session-replay.test.ts | 7 ++----- .../apps/implementations/session-replay.ts | 20 +++++-------------- 5 files changed, 15 insertions(+), 41 deletions(-) diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 2e2a08157..2c5955384 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -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) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index ba14ae549..abaccc8ad 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -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")); diff --git a/packages/shared/src/interface/client-interface.ts b/packages/shared/src/interface/client-interface.ts index 3320ecaf0..bcde0191c 100644 --- a/packages/shared/src/interface/client-interface.ts +++ b/packages/shared/src/interface/client-interface.ts @@ -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" }; } } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts index f634f4393..52ebfcb2a 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts @@ -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; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 4b63c6043..f53dc175c 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -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(