From a2889fc0443a4c8550a22948a951dcd06c911771 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 23 Jun 2026 12:04:46 -0700 Subject: [PATCH 1/2] fix: chunk SessionRecorder flush to stay under server 1MB body limit (#1647) --- .../implementations/session-replay.test.ts | 127 +++++++++++++++++- .../apps/implementations/session-replay.ts | 90 +++++++++---- 2 files changed, 185 insertions(+), 32 deletions(-) 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 663fa5b11..2ca190db5 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 @@ -70,8 +70,11 @@ describe("SessionRecorder flush", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { + const event1 = { type: 2, timestamp: Date.now(), data: {} }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }]; + (recorder as any)._events = [event1]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(event1).length]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call (recorder as any)._tick(); @@ -82,8 +85,11 @@ describe("SessionRecorder flush", () => { // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the // recorder — subsequent flushes continue attempting delivery. + const event2 = { type: 3, timestamp: Date.now(), data: {} }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }]; + (recorder as any)._events = [event2]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(event2).length]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call (recorder as any)._tick(); await vi.advanceTimersByTimeAsync(0); @@ -97,6 +103,113 @@ describe("SessionRecorder flush", () => { } }); + it("splits large batches into multiple requests to stay under server 1MB limit", 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 { + // Create events that together exceed 900KB (the per-batch cap). + // Each event is ~500KB, so two events (~1MB) must be split into two batches. + const largeData = "x".repeat(500_000); + const event1 = { type: 2, timestamp: Date.now(), data: largeData }; + const event2 = { type: 3, timestamp: Date.now(), data: largeData }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [event1, event2]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(event1).length, JSON.stringify(event2).length]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._approxBytes = JSON.stringify(event1).length + JSON.stringify(event2).length; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (recorder as any)._tick(); + await vi.advanceTimersByTimeAsync(0); + + // Should have sent two separate batches + expect(sentBodies).toHaveLength(2); + + // Each batch should contain exactly one event + const batch1 = JSON.parse(sentBodies[0]); + const batch2 = JSON.parse(sentBodies[1]); + expect(batch1.events).toHaveLength(1); + expect(batch2.events).toHaveLength(1); + + // They should have different batch IDs + expect(batch1.batch_id).not.toBe(batch2.batch_id); + } finally { + recorder.stop(); + localStorage.removeItem(storageKey); + vi.useRealTimers(); + } + }); + + it("sends a single oversized event alone without dropping it", 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 { + // A single event larger than 900KB — should still be sent (not dropped) + const hugeData = "y".repeat(1_000_000); + const hugeEvent = { type: 2, timestamp: Date.now(), data: hugeData }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [hugeEvent]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(hugeEvent).length]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._approxBytes = JSON.stringify(hugeEvent).length; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (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) + expect(sentBodies).toHaveLength(1); + const batch = JSON.parse(sentBodies[0]); + expect(batch.events).toHaveLength(1); + } finally { + recorder.stop(); + localStorage.removeItem(storageKey); + vi.useRealTimers(); + } + }); + it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => { vi.useFakeTimers(); @@ -122,8 +235,11 @@ describe("SessionRecorder flush", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { + const event1 = { type: 2, timestamp: Date.now(), data: {} }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }]; + (recorder as any)._events = [event1]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(event1).length]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call (recorder as any)._tick(); @@ -132,8 +248,11 @@ describe("SessionRecorder flush", () => { expect(sentBodies).toHaveLength(1); expect(warnSpy).not.toHaveBeenCalled(); + const event2 = { type: 3, timestamp: Date.now(), data: {} }; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }]; + (recorder as any)._events = [event2]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [JSON.stringify(event2).length]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call (recorder as any)._tick(); await vi.advanceTimersByTimeAsync(0); 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 713d0da28..80bad1ff1 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 @@ -1,6 +1,6 @@ import { KnownErrors } from "@hexclave/shared/dist/known-errors"; import { isBrowserLike } from "@hexclave/shared/dist/utils/env"; -import { captureWarning } from "@hexclave/shared/dist/utils/errors"; +import { captureWarning, throwErr } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { Result } from "@hexclave/shared/dist/utils/results"; @@ -106,6 +106,9 @@ 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; export type StoredSession = { session_id: string, @@ -189,6 +192,7 @@ export class SessionRecorder { private _detachListeners: (() => void) | null = null; private _flushTimer: ReturnType | null = null; private _events: unknown[] = []; + private _eventSizes: number[] = []; private _approxBytes = 0; private _lastPersistActivity = 0; private _recording = false; @@ -239,6 +243,7 @@ export class SessionRecorder { clearBuffer() { this._events = []; + this._eventSizes = []; this._approxBytes = 0; } @@ -264,42 +269,68 @@ export class SessionRecorder { const nowMs = Date.now(); const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs }); - const batchId = generateUuid(); - const payload = { - browser_session_id: stored.session_id, - session_replay_segment_id: this._sessionReplaySegmentId, - batch_id: batchId, - started_at_ms: stored.created_at_ms, - sent_at_ms: nowMs, - events: this._events, - }; - + // Capture all buffered events upfront (before any await) so that + // stop() / _stopCurrentRecording() clearing this._events cannot race + // with the async send loop below and silently discard overflow batches. + const allEvents = this._events; + const allSizes = this._eventSizes; this._events = []; + this._eventSizes = []; this._approxBytes = 0; this._flushInProgress = true; try { - const res = await this._deps.sendBatch( - JSON.stringify(payload), - { keepalive: options.keepalive }, - ); + 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). + let batchBytes = 0; + 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; + batchBytes += nextSize; + batchEnd = i + 1; + } - if (res.status === "error") { - if (isAnalyticsNotEnabledError(res.error)) { - this._disable(); + const batchEvents = allEvents.slice(offset, batchEnd); + offset = batchEnd; + + const batchId = generateUuid(); + const payload = { + browser_session_id: stored.session_id, + session_replay_segment_id: this._sessionReplaySegmentId, + batch_id: batchId, + started_at_ms: stored.created_at_ms, + sent_at_ms: nowMs, + events: batchEvents, + }; + + const res = await this._deps.sendBatch( + JSON.stringify(payload), + { keepalive: options.keepalive }, + ); + + if (res.status === "error") { + if (isAnalyticsNotEnabledError(res.error)) { + this._disable(); + return; + } + // Ad blockers commonly block analytics endpoints, causing network + // errors. These are expected and should not pollute the console. + if (isAdBlockerNetworkError(res.error)) { + return; + } + captureWarning("SessionRecorder.flush", res.error); return; } - // Ad blockers commonly block analytics endpoints, causing network - // errors. These are expected and should not pollute the console. - if (isAdBlockerNetworkError(res.error)) { + + if (!res.data.ok) { + captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`)); return; } - captureWarning("SessionRecorder.flush", res.error); - return; - } - - if (!res.data.ok) { - captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`)); } } finally { this._flushInProgress = false; @@ -353,8 +384,10 @@ export class SessionRecorder { } } + const eventSize = JSON.stringify(event).length; this._events.push(event); - this._approxBytes += JSON.stringify(event).length; + this._eventSizes.push(eventSize); + this._approxBytes += eventSize; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { runAsynchronously(() => this._flush({ keepalive: false })); } @@ -387,6 +420,7 @@ export class SessionRecorder { this._stopRecording = null; } this._events = []; + this._eventSizes = []; this._approxBytes = 0; this._recording = false; } From 7b0f4309752c8cd9cfb4cd9975b2596cae0b38b2 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 23 Jun 2026 12:16:14 -0700 Subject: [PATCH 2/2] fix: suppress React 19 script warning in SsrScript (#1648) --- .../components/elements/ssr-layout-effect.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/template/src/components/elements/ssr-layout-effect.tsx b/packages/template/src/components/elements/ssr-layout-effect.tsx index 90a7cf57a..a37713c16 100644 --- a/packages/template/src/components/elements/ssr-layout-effect.tsx +++ b/packages/template/src/components/elements/ssr-layout-effect.tsx @@ -1,6 +1,10 @@ "use client"; import { useLayoutEffect } from "react"; +function escapeHtmlAttr(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + export function SsrScript(props: { script: string, nonce?: string }) { useLayoutEffect(() => { // TODO fix workaround: React has a bug where it doesn't run the script on the first CSR render if SSR has been skipped due to suspense @@ -9,11 +13,19 @@ export function SsrScript(props: { script: string, nonce?: string }) { (0, eval)(props.script); }, []); + // Embed the `, + }} /> ); }