diff --git a/packages/shared/src/interface/client-interface.test.ts b/packages/shared/src/interface/client-interface.test.ts index dd6806b7f..ed341e60b 100644 --- a/packages/shared/src/interface/client-interface.test.ts +++ b/packages/shared/src/interface/client-interface.test.ts @@ -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 { + 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 { - 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 { - 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(); 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 52ebfcb2a..52d3927a5 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 @@ -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(); 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 f53dc175c..bd21f24b7 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 @@ -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;