Merge branch 'dev' into (fix)--delete-dialog-on-click-behavior

This commit is contained in:
Vedanta-Gawande
2026-06-30 16:13:41 -04:00
committed by GitHub
6 changed files with 423 additions and 37 deletions
@@ -11,7 +11,7 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumbe
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { gzip as gzipCb } from "node:zlib";
import { gzip as gzipCb, gunzipSync } from "node:zlib";
const gzip = promisify(gzipCb);
@@ -19,6 +19,35 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0
const MAX_BODY_BYTES = 1_000_000;
const MAX_EVENTS = 5_000;
// Zip-bomb guard; keep in sync with the client's MAX_SINGLE_EVENT_BYTES.
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024;
// Gunzips application/octet-stream bodies (the client gzips large batches);
// plain application/json bodies pass through untouched.
function maybeDecodeBinaryBody(value: unknown): unknown {
let bytes: Uint8Array | undefined;
if (value instanceof ArrayBuffer) {
bytes = new Uint8Array(value);
} else if (value instanceof Uint8Array) {
bytes = value;
}
if (!bytes) return value;
if (bytes.byteLength > MAX_BODY_BYTES) {
throw new StatusError(StatusError.PayloadTooLarge, `Encoded session replay body too large (max ${MAX_BODY_BYTES} bytes)`);
}
let decompressed: Buffer;
try {
decompressed = gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES });
} catch {
throw new StatusError(StatusError.BadRequest, "Invalid encoded session replay body");
}
try {
return JSON.parse(decompressed.toString("utf-8"));
} catch {
throw new StatusError(StatusError.BadRequest, "Invalid encoded session replay body");
}
}
function extractEventTimesMs(events: unknown[], fallbackMs: number) {
let minTs = Infinity;
@@ -60,7 +89,7 @@ export const POST = createSmartRouteHandler({
started_at_ms: yupNumber().defined().integer().min(0),
sent_at_ms: yupNumber().defined().integer().min(0),
events: yupArray(yupMixed().defined()).defined(),
}).defined(),
}).defined().transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { gzipSync } from "node:zlib";
import { PLAN_LIMITS } from "@hexclave/shared/dist/plans";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { it } from "../../../../helpers";
@@ -162,6 +163,112 @@ it("stores session replay batch metadata and dedupes by (session_replay_id, batc
expect(second.body?.session_replay_id).toBe(recordingId);
});
it("accepts a gzipped binary body (compressed large-payload encoding)", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const now = Date.now();
const payload = {
browser_session_id: randomUUID(),
session_replay_segment_id: randomUUID(),
batch_id: randomUUID(),
started_at_ms: now,
sent_at_ms: now + 500,
// Large full snapshot: exceeds the 1MB raw wire limit but gzips under it.
events: [{ timestamp: now + 100, type: 2, data: { html: "x".repeat(2_000_000) } }],
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload), "utf-8"));
// Sanity: the raw payload exceeds the wire limit, the compressed one doesn't.
expect(compressed.byteLength).toBeLessThan(1_000_000);
const res = await niceBackendFetch("/api/v1/session-replays/batch", {
method: "POST",
accessType: "client",
rawBody: compressed,
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"batch_id": "<stripped UUID>",
"deduped": false,
"s3_key": "session-replays/<stripped UUID>/main/<stripped UUID>/<stripped UUID>.json.gz",
"session_replay_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects a binary body that isn't valid gzip", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
const res = await niceBackendFetch("/api/v1/session-replays/batch", {
method: "POST",
accessType: "client",
rawBody: new Uint8Array([0, 1, 2, 3, 4, 5]),
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Invalid encoded session replay body",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects a binary body larger than the compressed size cap", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
// Random bytes don't compress, so the byteLength check fires before gunzip.
// 1.1 MB > the 1 MB MAX_BODY_BYTES cap.
const oversized = new Uint8Array(randomBytes(Math.floor(1.1 * 1024 * 1024)));
const res = await niceBackendFetch("/api/v1/session-replays/batch", {
method: "POST",
accessType: "client",
rawBody: oversized,
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 413,
"body": "Encoded session replay body too large (max 1000000 bytes)",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects a gzipped body that decompresses past the server size cap", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
await Auth.Otp.signIn();
// 9 MB of zeros gzips to ~9 KB but decompresses past the 8 MB server cap.
const bomb = gzipSync(Buffer.alloc(9 * 1024 * 1024));
const res = await niceBackendFetch("/api/v1/session-replays/batch", {
method: "POST",
accessType: "client",
rawBody: bomb,
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Invalid encoded session replay body",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("rejects empty events", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
@@ -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();
@@ -763,3 +763,68 @@ describe("sendAnalyticsEventBatch encoding", () => {
expect(init.body).toBe(payload);
});
});
describe("sendSessionReplayBatch encoding", () => {
function captureFetch() {
const fetchMock = vi.fn(async () => createJsonResponse({ session_replay_id: "r", batch_id: "b", s3_key: "k", deduped: false }));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
it("gzips body and sends application/octet-stream when keepalive is false", async () => {
const fetchMock = captureFetch();
const iface = createClientInterface();
// A large, highly-compressible payload — the case the wire-limit drop used to lose.
const payload = JSON.stringify({ batch_id: "abc", events: [{ type: 2, data: "x".repeat(1_000_000) }] });
await iface.sendSessionReplayBatch(payload, null, { keepalive: false });
const init = getRequestInit(fetchMock);
expect(new Headers(init.headers).get("Content-Type")).toBe("application/octet-stream");
expect(init.body).toBeInstanceOf(Uint8Array);
// The compressed body is far smaller than the 1MB+ raw payload.
expect((init.body as Uint8Array).byteLength).toBeLessThan(100_000);
await expect(gunzipToText(init.body)).resolves.toBe(payload);
});
it("falls back to plain JSON when keepalive is true (avoids racing pagehide tear-down)", async () => {
const fetchMock = captureFetch();
const iface = createClientInterface();
const payload = JSON.stringify({ batch_id: "abc", events: [] });
await iface.sendSessionReplayBatch(payload, null, { keepalive: true });
const init = getRequestInit(fetchMock);
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
expect(init.body).toBe(payload);
});
it("falls back to plain JSON when CompressionStream is unavailable", async () => {
vi.stubGlobal("CompressionStream", undefined);
const fetchMock = captureFetch();
const iface = createClientInterface();
const payload = JSON.stringify({ batch_id: "abc", events: [] });
await iface.sendSessionReplayBatch(payload, null, { keepalive: false });
const init = getRequestInit(fetchMock);
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
expect(init.body).toBe(payload);
});
it("falls back to plain JSON when CompressionStream throws at runtime", async () => {
class ThrowingCompressionStream {
constructor() { throw new Error("compression unsupported"); }
}
vi.stubGlobal("CompressionStream", ThrowingCompressionStream);
const fetchMock = captureFetch();
const iface = createClientInterface();
const payload = JSON.stringify({ batch_id: "abc", events: [] });
await iface.sendSessionReplayBatch(payload, null, { keepalive: false });
const init = getRequestInit(fetchMock);
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
expect(init.body).toBe(payload);
});
});
@@ -130,13 +130,13 @@ function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefine
};
}
async function encodeAnalyticsBody(
async function encodeGzipJsonBody(
jsonBody: string,
options: { keepalive: boolean },
): Promise<{ body: BodyInit, contentType: string }> {
// pagehide/visibilitychange flushes use keepalive: true. The browser must
// dispatch the fetch before tearing the page down — awaiting async gzip
// first lets the request slip past tear-down and never start.
// Used by analytics-event and session-replay batch uploads; the server
// gunzips application/octet-stream bodies before schema validation.
// keepalive flushes must dispatch before page tear-down, so skip async gzip.
if (options.keepalive) {
return { body: jsonBody, contentType: "application/json" };
}
@@ -150,8 +150,7 @@ async function encodeAnalyticsBody(
const buffer = await new Response(stream).arrayBuffer();
return { body: new Uint8Array(buffer), contentType: "application/octet-stream" };
} catch {
// Partial/broken CompressionStream support: fall back to plain JSON so
// EventTracker._flush() doesn't drop the batch via Result.error.
// Broken CompressionStream (e.g. Safari < 16.4): fall back to plain JSON.
return { body: jsonBody, contentType: "application/json" };
}
}
@@ -551,12 +550,13 @@ export class HexclaveClientInterface {
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const encoded = await encodeGzipJsonBody(body, { keepalive: options.keepalive });
const response = await this.sendClientRequest(
"/session-replays/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
headers: { "Content-Type": encoded.contentType },
body: encoded.body,
keepalive: options.keepalive,
},
session,
@@ -576,7 +576,7 @@ export class HexclaveClientInterface {
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const encoded = await encodeAnalyticsBody(body, { keepalive: options.keepalive });
const encoded = await encodeGzipJsonBody(body, { keepalive: options.keepalive });
const response = await this.sendClientRequest(
"/analytics/events/batch",
{
@@ -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";
@@ -199,7 +200,7 @@ describe("SessionRecorder flush", () => {
(recorder as any)._tick();
await vi.advanceTimersByTimeAsync(0);
// Should still send the event (the server may reject it, but we don't drop it client-side)
// Sent (not dropped): the transport gzips it under the wire limit.
expect(sentBodies).toHaveLength(1);
const batch = JSON.parse(sentBodies[0]);
expect(batch.events).toHaveLength(1);
@@ -210,6 +211,164 @@ describe("SessionRecorder flush", () => {
}
});
it("drops a single event that exceeds the server's decompressed budget", 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 {
// >8MB (MAX_SINGLE_EVENT_BYTES) is dropped; the next event still sends.
const hugeEvent = { type: 2, timestamp: Date.now(), data: "z".repeat(9_000_000) };
const smallEvent = { type: 3, timestamp: Date.now(), data: "ok" };
const sizeOf = (e: unknown) => JSON.stringify(e).length;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._events = [hugeEvent, smallEvent];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._eventSizes = [sizeOf(hugeEvent), sizeOf(smallEvent)];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(recorder as any)._approxBytes = sizeOf(hugeEvent) + sizeOf(smallEvent);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
(recorder as any)._tick();
await vi.advanceTimersByTimeAsync(0);
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("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("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();
@@ -106,9 +106,16 @@ const IDLE_TTL_MS = 3 * 60 * 1000;
const FLUSH_INTERVAL_MS = 5_000;
const MAX_EVENTS_PER_BATCH = 200;
const MAX_APPROX_BYTES_PER_BATCH = 512_000;
// The server rejects payloads > 1MB. Stay well under to account for JSON
// envelope overhead (browser_session_id, timestamps, wrapper keys, etc.).
const MAX_FLUSH_PAYLOAD_BYTES = 900_000;
// Uncompressed per-batch target; the transport gzips before sending.
const MAX_BATCH_UNCOMPRESSED_BYTES = 900_000;
// 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();
@@ -281,20 +288,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) {
// Build a batch that fits under the server's payload limit.
// When _flushInProgress blocked earlier flushes, events can accumulate
// well past MAX_APPROX_BYTES_PER_BATCH; sending them all at once would
// exceed the server's 1MB body limit (413).
// A single event over the limit can't be sent (rrweb events aren't splittable); drop it and move on.
// 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_FLUSH_PAYLOAD_BYTES) {
if (firstSize > maxSingleEventBytes) {
captureWarning(
"SessionRecorder.flush",
new Error(`Dropping oversized session replay event (${firstSize} bytes > ${MAX_FLUSH_PAYLOAD_BYTES} byte limit); it cannot be sent without a 413.`),
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;
@@ -304,7 +316,7 @@ export class SessionRecorder {
let batchEnd = offset;
for (let i = offset; i < allEvents.length; i++) {
const nextSize = allSizes[i] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
if (batchBytes + nextSize > MAX_FLUSH_PAYLOAD_BYTES && batchEnd > offset) break;
if (batchBytes + nextSize > MAX_BATCH_UNCOMPRESSED_BYTES && batchEnd > offset) break;
batchBytes += nextSize;
batchEnd = i + 1;
}
@@ -342,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;
}
}