mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
d63486bfd8
commit
855cdb052e
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user