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 52d3927a5..cc7231051 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 @@ -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(); 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 bd21f24b7..db3294260 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 @@ -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; } }