mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge branch 'dev' into (fix)--delete-dialog-on-click-behavior
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
+160
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user