fix(session-replay): reserve envelope margin on single-event cap, log 413 distinctly

Aligning MAX_SINGLE_EVENT_BYTES exactly to the server's 8 MiB decompressed
cap left no room for the JSON envelope: a single event in the ~250-byte
window just under 8 MiB passed the client check but made the server's
gunzip exceed maxOutputLength (400). Reserve a 1 KiB envelope margin so a
single event that clears the cap can't render a batch over the server's cap.

Also log a distinct warning on a 413: the client-side size caps are meant
to prevent it, so a 413 signals a poorly-compressible event (embedded
image/canvas) that gzipped past the wire limit. The warning now names the
413 and the number of buffered events dropped (the flush stops on any
non-2xx, abandoning the rest of the captured buffer).
This commit is contained in:
Bilal Godil 2026-06-30 11:58:00 -07:00
parent d63486bfd8
commit 855cdb052e
2 changed files with 80 additions and 5 deletions

View File

@ -2,6 +2,7 @@
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { describe, expect, it, vi } from "vitest";
import * as errors from "@hexclave/shared/dist/utils/errors";
import { Result } from "@hexclave/shared/dist/utils/results";
import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from "./session-replay";
@ -312,6 +313,62 @@ describe("SessionRecorder flush", () => {
}
});
it("logs a distinct 413 warning and drops the buffered events when the server rejects a batch as too large", async () => {
vi.useFakeTimers();
const captureWarningSpy = vi.spyOn(errors, "captureWarning").mockImplementation(() => {});
const storageKey = `hexclave:session-replay:v1:test-project`;
localStorage.setItem(storageKey, JSON.stringify({
session_id: "test-session",
created_at_ms: Date.now(),
last_activity_ms: Date.now(),
}));
let calls = 0;
const recorder = new SessionRecorder(
{
projectId: "test-project",
sendBatch: async () => {
calls += 1;
// A poorly-compressible event can clear the client-side caps yet still
// exceed the server's body limit after gzip → 413.
return Result.ok(new Response("payload too large", { status: 413 }));
},
},
{},
);
try {
const eventA = { type: 3, timestamp: Date.now(), data: "a" };
const eventB = { type: 3, timestamp: Date.now(), data: "b" };
const sizeOf = (e: unknown) => new TextEncoder().encode(JSON.stringify(e)).byteLength;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._events = [eventA, eventB];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._eventSizes = [sizeOf(eventA), sizeOf(eventB)];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._approxBytes = sizeOf(eventA) + sizeOf(eventB);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
await (recorder as any)._flush({ keepalive: false });
// The 413 stops the loop; no retry of the rejected batch.
expect(calls).toBe(1);
expect(captureWarningSpy).toHaveBeenCalledTimes(1);
const warned = captureWarningSpy.mock.calls[0]?.[1];
expect(warned).toBeInstanceOf(Error);
expect((warned as Error).message).toContain("413");
// Both buffered events are reported as dropped.
expect((warned as Error).message).toContain("2 buffered event");
} finally {
recorder.stop();
localStorage.removeItem(storageKey);
captureWarningSpy.mockRestore();
vi.useRealTimers();
}
});
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();

View File

@ -108,10 +108,14 @@ const MAX_EVENTS_PER_BATCH = 200;
const MAX_APPROX_BYTES_PER_BATCH = 512_000;
// Uncompressed per-batch target; the transport gzips before sending.
const MAX_BATCH_UNCOMPRESSED_BYTES = 900_000;
// 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
// (binary mebibytes, matching the server's gunzip maxOutputLength).
const MAX_SINGLE_EVENT_BYTES = 8 * 1024 * 1024;
// The server gunzips the whole batch (envelope + events) and rejects anything
// whose decompressed size exceeds MAX_DECOMPRESSED_BYTES (8 MiB) in the backend
// route. Since a single oversized event is sent alone, reserve headroom for the
// JSON envelope (session/batch ids, timestamps, wrapper keys) so an event that
// passes this check can't render a batch that trips the server's cap by a few
// hundred bytes. Keep the server's MAX_DECOMPRESSED_BYTES >= this + the margin.
const BATCH_ENVELOPE_OVERHEAD_BYTES = 1024;
const MAX_SINGLE_EVENT_BYTES = 8 * 1024 * 1024 - BATCH_ENVELOPE_OVERHEAD_BYTES;
// Reused across the emit hot path to avoid per-event allocation.
const textEncoder = new TextEncoder();
@ -350,7 +354,21 @@ export class SessionRecorder {
}
if (!res.data.ok) {
captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
// On any non-2xx we stop the loop, so this batch and every event still
// buffered behind it are dropped (not retried). Count them for the log.
const droppedCount = batchEvents.length + (allEvents.length - offset);
if (res.data.status === 413) {
// The payload exceeded the server's body limit despite the client-side
// size caps — most likely a single poorly-compressible event (e.g. an
// embedded image/canvas) that gzipped above the wire limit. Distinct
// from other failures because the caps are supposed to prevent it.
captureWarning(
"SessionRecorder.flush",
new Error(`Session replay batch rejected with 413 (payload too large) despite client-side size caps; dropping ${droppedCount} buffered event(s). A poorly-compressible event likely exceeded the server's body limit after gzip.`),
);
return;
}
captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed (dropping ${droppedCount} buffered event(s)): ${res.data.status} ${await res.data.text()}`));
return;
}
}