The browser: replay
6c. Record the replay
Section titled “6c. Record the replay”rrweb records the page as a stream of events: one full snapshot of the DOM, then only the changes (a click, a new node, a scroll). You cut that stream into chunks and post each one to the collector. This is the replay row of the contract.
Each chunk is one JSON body:
{ "session_id": "<the session's id>", "seq": 0, "started_at": 1761600000000, "ended_at": 1761600004890, "events": [ /* rrweb events, in order */ ]}started_at and ended_at are the timestamps, in milliseconds, of the chunk’s first and last rrweb event.
How a chunk travels
Section titled “How a chunk travels”Press Play to follow one chunk from the page to the replay panel.
- 1 full snapshot · the first event
- 2 open a chunk · session_id, seq, 5 s timer
- 3 changes · clicks, DOM, scroll
- 4 5 s or 256 KB · flushReplay()
- 5 POST /v1/replay/chunks · x-ouo-session-id, x-ouo-seq
- 6 check · credential and headers
- 7 202 Accepted
- 8 store the chunk · by session, by seq
- 9 next chunk, seq + 1 · keepalive if the page hides
The code
Section titled “The code”Create frontend/src/telemetry/replay.js:
frontend/src/telemetry/replay.js
// Session replay: rrweb records the page, and the events go to ouo in chunks// (docs/adr/0003-replay-model.md): { session_id, seq, started_at, ended_at, events },// POST /v1/replay/chunks on the collector's replay port, every 5 s or at 256 KB.import { record } from "rrweb";import { AUTHORIZATION, REPLAY_ENDPOINT } from "./config.js";import { getSession, nextReplaySeq, onSessionChange } from "./session.js";
const FLUSH_INTERVAL_MS = 5000;const MAX_CHUNK_BYTES = 256 * 1024;const KEEPALIVE_MAX_BYTES = 60 * 1024; // browsers cap keepalive request bodies at 64 KiB in total
let buffer = [];let bufferBytes = 0;let chunk = null; // { session_id, seq, started_at } of the events in the bufferlet timer = null;
function push(event) { if (buffer.length === 0) { // The chunk belongs to the session that was current when its first event happened. chunk = { session_id: getSession().id, seq: nextReplaySeq(), started_at: event.timestamp }; timer = setTimeout(flushReplay, FLUSH_INTERVAL_MS); } buffer.push(event); bufferBytes += JSON.stringify(event).length; if (bufferBytes >= MAX_CHUNK_BYTES) flushReplay();}
// Sends the buffered events as one chunk. pageHidden: the page may be gone after this.export function flushReplay(pageHidden = false) { if (buffer.length === 0) return; clearTimeout(timer); const body = JSON.stringify({ ...chunk, ended_at: buffer[buffer.length - 1].timestamp, events: buffer }); const headers = { "content-type": "application/json", Authorization: AUTHORIZATION, "x-ouo-session-id": chunk.session_id, "x-ouo-seq": String(chunk.seq), }; buffer = []; bufferBytes = 0; fetch(`${REPLAY_ENDPOINT}/v1/replay/chunks`, { method: "POST", headers, body, // keepalive lets the request outlive the page, but only for small bodies. keepalive: pageHidden && body.length < KEEPALIVE_MAX_BYTES, }).catch((err) => console.warn("[replay] chunk upload failed", err));}
export function startReplay() { record({ emit: push, maskAllInputs: true, // what users type never leaves the browser blockClass: "ouo-block", // elements with this class are recorded as an empty box maskTextClass: "ouo-mask", // text inside elements with this class is masked checkoutEveryNms: 5 * 60 * 1000, // a full snapshot every 5 minutes keeps seeking fast }); // A new session needs its own full snapshot: send the old session's events first. onSessionChange((type, session, previous) => { if (type !== "start" || !previous) return; flushReplay(document.visibilityState === "hidden"); record.takeFullSnapshot(); });}- One session, one replay, many chunks. rrweb emits a stream of events; they are cut into chunks every 5 seconds or at 256 KB.
seqnumbers the chunks of a session and is kept in the session, so a reload continues the numbering; ouo11y plays the chunks inseqorder. - Each chunk names its session twice:
session_idin the body, andx-ouo-session-id/x-ouo-seqheaders that the collector checks before it stores the chunk. - A new session needs a full snapshot. rrweb’s later events only describe changes, so when the session is replaced the old chunk is sent and
record.takeFullSnapshot()starts the new session’s replay from a complete page. - Privacy.
maskAllInputskeeps what users type out of the replay. Add the classouo-blockto an element to record it as an empty box,ouo-maskto mask its text. - Leaving the page.
keepalivelets the last request outlive the page, but browsers allow only 64 KiB of keepalive bodies at a time, so a bigger last chunk is sent without it.
Start the replay
Section titled “Start the replay”Replace frontend/src/telemetry/index.js with its final version:
frontend/src/telemetry/index.js
// RUM without the ouo SDK: OpenTelemetry + rrweb + web-vitals. Import this first, before React.import { captureErrors, trackNavigation, trackWebVitals } from "./events.js";import { flushTelemetry } from "./otel.js";import { flushReplay, startReplay } from "./replay.js";import { getSession, touch } from "./session.js";
getSession(); // resume the session, or start one (session.start)captureErrors();trackNavigation();trackWebVitals();startReplay();
// User activity keeps the session alive.for (const type of ["click", "keydown", "scroll", "touchstart"]) { addEventListener(type, touch, { capture: true, passive: true });}
// The tab is hidden: it may be closed next, so send everything now.addEventListener("visibilitychange", () => { if (document.visibilityState !== "hidden") return; flushReplay(true); flushTelemetry();});
console.log("[telemetry] session", getSession().id);
export { API } from "./config.js";export { addEvent } from "./events.js";Compared with the second version, it starts the replay and, when the page is hidden, sends the last replay chunk before the spans and logs.
What you should see
Section titled “What you should see”Reload http://localhost:5173 with the DevTools Network tab open and click around. Every few seconds a POST /v1/replay/chunks on port 4319 answers 202. Click one: its request headers have x-ouo-session-id, the id the Console printed, and x-ouo-seq, which goes up by one for each chunk.
Next: the custom event.