mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
fix(session-replay): cap keepalive flush events at the uncompressed batch target
Keepalive flushes (pagehide/visibilitychange/stop) skip async gzip to dispatch before page tear-down, so they're bound by the server's ~1MB raw body limit. Allowing 8MB single events there made the first oversized batch 413 and drop the buffered events behind it; gate the single-event drop threshold on keepalive so those events are dropped cleanly instead. Also align MAX_SINGLE_EVENT_BYTES to the server's binary-mebibyte MAX_DECOMPRESSED_BYTES, and hoist the duplicated getRequestInit/gunzipToText test helpers into shared scope.
This commit is contained in:
parent
32978756f7
commit
d63486bfd8
@ -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();
|
||||
@ -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<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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user