From b2b15fa65fd543f1af8cb7fdf69cdadf6dc2635c Mon Sep 17 00:00:00 2001 From: ColtenOuO Date: Thu, 17 Sep 2026 10:40:55 +0000 Subject: [PATCH] Keep replay posts alive past the page closing The interview's last replay events are flushed as the candidate reaches the report, which is when a tab is most likely to close, and a plain fetch is cancelled with it. Every post now asks for keepalive unless its body is over the 64 KiB budget, which would fail the request and drop the batch instead. --- tests/browser/replay-feed.test.js | 29 +++++++++++++++++++++++++++++ web/replay-feed.js | 11 ++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/browser/replay-feed.test.js b/tests/browser/replay-feed.test.js index edec3d5..781eed4 100644 --- a/tests/browser/replay-feed.test.js +++ b/tests/browser/replay-feed.test.js @@ -77,3 +77,32 @@ test("replay-feed waits out Retry-After", async () => { delete globalThis.fetch; } }); + +test("replay-feed keeps a batch alive past the page unless it is over the keepalive budget", async () => { + const feed = await import("../../web/replay-feed.js?keepalive"); + const posts = []; + globalThis.fetch = async (_url, init) => { + posts.push({ events: JSON.parse(init.body).events.length, keepalive: init.keepalive }); + return { status: 204, headers: { get: () => null } }; + }; + try { + feed.initReplay({ + state: { interviewId: "i1" }, + nodes: {}, + recordingEnabled: true, + consentVersion: "v1", + replayVersion: 1, + }); + feed.recordReplay("lifecycle", { state: "ended", reason: "time_up" }); + await feed.flushReplay(); + feed.recordReplay("code", { text: "é".repeat(40_000) }); + await feed.flushReplay(); + assert.deepEqual(posts, [ + { events: 1, keepalive: true }, + { events: 1, keepalive: false }, + ]); + } finally { + feed.closeReplay(); + delete globalThis.fetch; + } +}); diff --git a/web/replay-feed.js b/web/replay-feed.js index 3a5c724..98a1530 100644 --- a/web/replay-feed.js +++ b/web/replay-feed.js @@ -25,6 +25,7 @@ const REPLAY_FLUSH_MS = 1000; const REPLAY_MAX_BATCH = 32; const REPLAY_RETRY_MS = 60_000; const REPLAY_RETRY_MAX_MS = 120_000; +const REPLAY_KEEPALIVE_MAX_BYTES = 64 * 1024; /// How often the clock and the problem heading are restated. /// @@ -121,11 +122,19 @@ async function sendQueuedBatch() { return; } const batch = replayQueue.splice(0, REPLAY_MAX_BATCH); + const body = JSON.stringify({ events: batch }); try { + // Kept alive so a batch already on its way survives the tab closing. The + // interview's last events are flushed as the candidate reaches the report, + // which is the moment a candidate is most likely to leave, and a plain + // fetch is cancelled with the page. Every batch rather than only the last, + // because the end may be the one inside a Retry-After window and so go + // out on the timer rather than from the call that asked for it. const response = await fetch(`/api/interviews/${encodeURIComponent(state.interviewId)}/events`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ events: batch }), + body, + keepalive: new TextEncoder().encode(body).length <= REPLAY_KEEPALIVE_MAX_BYTES, }); // 404 is an interview whose consent has been withdrawn, and quota is an // interview that has recorded all it may. Both mean the server will refuse