Skip to content

The browser: 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.

Press Play to follow one chunk from the page to the replay panel.

rrweb
replay.js
Collector :4319
rum-api
  1. 1 full snapshot · the first event
  2. 2 open a chunk · session_id, seq, 5 s timer
  3. 3 changes · clicks, DOM, scroll
  4. 4 5 s or 256 KB · flushReplay()
  5. 5 POST /v1/replay/chunks · x-ouo-session-id, x-ouo-seq
  6. 6 check · credential and headers
  7. 7 202 Accepted
  8. 8 store the chunk · by session, by seq
  9. 9 next chunk, seq + 1 · keepalive if the page hides
One session, one replay, many chunks. The collector checks each chunk's credential and headers; rum-api keeps them and serves them in seq order.

Create frontend/src/telemetry/replay.js:

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 buffer
let 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. seq numbers the chunks of a session and is kept in the session, so a reload continues the numbering; ouo11y plays the chunks in seq order.
  • Each chunk names its session twice: session_id in the body, and x-ouo-session-id / x-ouo-seq headers 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. maskAllInputs keeps what users type out of the replay. Add the class ouo-block to an element to record it as an empty box, ouo-mask to mask its text.
  • Leaving the page. keepalive lets 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.

Replace frontend/src/telemetry/index.js with its final version:

frontend/src/telemetry/index.js
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.

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.