fix(session-replay): cap keepalive flush events at the uncompressed batch target

Keepalive flushes (pagehide/visibilitychange/stop) skip async gzip to
dispatch before page tear-down, so they're bound by the server's ~1MB raw
body limit. Allowing 8MB single events there made the first oversized batch
413 and drop the buffered events behind it; gate the single-event drop
threshold on keepalive so those events are dropped cleanly instead.

Also align MAX_SINGLE_EVENT_BYTES to the server's binary-mebibyte
MAX_DECOMPRESSED_BYTES, and hoist the duplicated getRequestInit/gunzipToText
test helpers into shared scope.
This commit is contained in:
Bilal Godil 2026-06-30 11:47:55 -07:00
parent 32978756f7
commit d63486bfd8
3 changed files with 79 additions and 30 deletions

View File

@ -689,6 +689,18 @@ describe("_withFallback", () => {
});
});
function getRequestInit(fetchMock: { mock: { calls: unknown[][] } }): RequestInit {
const init = fetchMock.mock.calls[0]?.[1];
if (init == null || typeof init !== "object") throw new Error("expected RequestInit");
return init as RequestInit;
}
async function gunzipToText(body: unknown): Promise<string> {
if (!(body instanceof Uint8Array)) throw new Error("expected Uint8Array body");
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
describe("sendAnalyticsEventBatch encoding", () => {
function captureFetch() {
const fetchMock = vi.fn(async () => createJsonResponse({ inserted: 0 }));
@ -696,18 +708,6 @@ describe("sendAnalyticsEventBatch encoding", () => {
return fetchMock;
}
function getRequestInit(fetchMock: { mock: { calls: unknown[][] } }): RequestInit {
const init = fetchMock.mock.calls[0]?.[1];
if (init == null || typeof init !== "object") throw new Error("expected RequestInit");
return init as RequestInit;
}
async function gunzipToText(body: unknown): Promise<string> {
if (!(body instanceof Uint8Array)) throw new Error("expected Uint8Array body");
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
it("gzips body and sends application/octet-stream when keepalive is false", async () => {
const fetchMock = captureFetch();
const iface = createClientInterface();
@ -771,18 +771,6 @@ describe("sendSessionReplayBatch encoding", () => {
return fetchMock;
}
function getRequestInit(fetchMock: { mock: { calls: unknown[][] } }): RequestInit {
const init = fetchMock.mock.calls[0]?.[1];
if (init == null || typeof init !== "object") throw new Error("expected RequestInit");
return init as RequestInit;
}
async function gunzipToText(body: unknown): Promise<string> {
if (!(body instanceof Uint8Array)) throw new Error("expected Uint8Array body");
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
it("gzips body and sends application/octet-stream when keepalive is false", async () => {
const fetchMock = captureFetch();
const iface = createClientInterface();

View File

@ -260,6 +260,58 @@ describe("SessionRecorder flush", () => {
}
});
it("on a keepalive flush, drops an event over the uncompressed batch target (it can't be gzipped before page tear-down)", async () => {
vi.useFakeTimers();
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(),
}));
const sentBodies: string[] = [];
const recorder = new SessionRecorder(
{
projectId: "test-project",
sendBatch: async (body) => {
sentBodies.push(body);
return Result.ok(new Response("ok", { status: 200 }));
},
},
{},
);
try {
// ~2MB is under the 8MB gzipped ceiling but over the 900KB uncompressed
// batch target. Keepalive flushes skip gzip, so this event would 413 the
// server's ~1MB raw body limit; it must be dropped, but the next event
// (small enough to send raw) still goes out.
const midEvent = { type: 2, timestamp: Date.now(), data: "z".repeat(2_000_000) };
const smallEvent = { type: 3, timestamp: Date.now(), data: "ok" };
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 = [midEvent, smallEvent];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._eventSizes = [sizeOf(midEvent), sizeOf(smallEvent)];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._approxBytes = sizeOf(midEvent) + sizeOf(smallEvent);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
await (recorder as any)._flush({ keepalive: true });
expect(sentBodies).toHaveLength(1);
const batch = JSON.parse(sentBodies[0]);
expect(batch.events).toHaveLength(1);
expect(batch.events[0].type).toBe(3);
} finally {
recorder.stop();
localStorage.removeItem(storageKey);
vi.useRealTimers();
}
});
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();

View File

@ -109,8 +109,9 @@ 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.
const MAX_SINGLE_EVENT_BYTES = 8_000_000;
// 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;
// Reused across the emit hot path to avoid per-event allocation.
const textEncoder = new TextEncoder();
@ -283,17 +284,25 @@ export class SessionRecorder {
this._eventSizes = [];
this._approxBytes = 0;
// Non-keepalive flushes gzip before sending, so a single event up to the
// server's decompressed budget can be sent alone. Keepalive flushes
// (pagehide/visibilitychange/stop) skip async gzip to dispatch before page
// tear-down, so they're bound by the server's raw ~1MB body limit; cap their
// single-event size at the uncompressed batch target so an oversized event
// is dropped rather than 413-ing the flush and losing the events behind it.
const maxSingleEventBytes = options.keepalive ? MAX_BATCH_UNCOMPRESSED_BYTES : MAX_SINGLE_EVENT_BYTES;
this._flushInProgress = true;
try {
let offset = 0;
while (offset < allEvents.length) {
// An oversized single event is sent alone and gzipped under the wire
// limit; only one past the server's decompressed budget is unsendable.
// A single event over the limit can't be sent (rrweb events aren't
// splittable); drop it and move on to the rest of the buffer.
const firstSize = allSizes[offset] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
if (firstSize > MAX_SINGLE_EVENT_BYTES) {
if (firstSize > maxSingleEventBytes) {
captureWarning(
"SessionRecorder.flush",
new Error(`Dropping oversized session replay event (${firstSize} bytes > ${MAX_SINGLE_EVENT_BYTES} byte limit); it exceeds the server's decompressed budget and cannot be sent.`),
new Error(`Dropping oversized session replay event (${firstSize} bytes > ${maxSingleEventBytes} byte limit); it cannot be sent without exceeding the server's body limit.`),
);
offset += 1;
continue;