Replay model
One session has exactly one replay. The replay is an ordered list of chunks, keyed by the session’s session.id. There is no separate replay id, so the replay, the timeline and the traces of a session join on the same key.
OpenTelemetry has no signal for replay, so chunks are not OTLP. They are JSON, posted over HTTP with the same project credential to the collector’s replay receiver, and they never pass through Loki.
Three kinds of replay
Section titled “Three kinds of replay”| Kind | Recorded by | What a chunk holds | Player in Grafana |
|---|---|---|---|
rrweb |
@ouo11y/browser, or your own rrweb |
rrweb DOM events | rrweb player |
frames |
@ouo11y/react-native on iOS |
JPEG screenshots, masked on the device, and touches | frame player with touch marks |
video |
the Sentry React Native SDK, through the collector’s Sentry bridge | an mp4 segment and touches | video player |
A session’s replay has one kind: the kind of its first chunk. A chunk of another kind for the same session is refused.
The chunk pipeline (web)
Section titled “The chunk pipeline (web)”- 1BrowserRecord
rrweb.recordfor the life of the page. A full snapshot on every page load, and every 5 minutes.events in memory - 2BrowserCut a chunkEvery 5 s, or at 256 KB, whichever comes first, and on
pagehide.seqgoes up by one per chunk.gzip JSON
Basic credential - 3Collector :4319Check and stampCredential, CORS, size (8 MiB, rrweb 4 MiB), rate limit. Stamps
ouo.project.id. One log record per chunk.Bearer ingest token
X-Ouo-Project-Id - 4rum-apiStore
replays/<session_id>/000003.json.gzandmeta.json, on disk or in S3.Bearer read token - 5Grafana appPlayReads every chunk in
seqorder through Grafana's plugin proxy and plays them as one recording.
What each step answers when something is wrong:
| Step | Answer | Meaning |
|---|---|---|
| Collector | 202 |
queued for rum-api |
400 |
not a valid chunk, or bad gzip | |
401 |
no credential, or a wrong one | |
413 |
above the size limit | |
429 + Retry-After |
rate limited; the SDK retries | |
503 + Retry-After: 1 |
the collector is under memory pressure or its queue is full; the SDK retries | |
| rum-api (to the collector) | 409 |
the session already holds a replay of another kind |
403 |
the session’s replay belongs to another project |
The collector retries rum-api for up to 5 minutes, so a rum-api restart loses nothing.
A chunk
Section titled “A chunk”{ "session_id": "6f1c2b7e-…", "seq": 3, "started_at": 1793140000000, "ended_at": 1793140005000, "events": [ { "type": 3, "data": { "source": 2 }, "timestamp": 1793140001200 } ]}- Timestamps are epoch milliseconds, on the device’s clock. The timeline aligns them with the session’s telemetry.
seqis per session, from0to999999. It is saved with the session, so a page reload continues the sequence. Posting the sameseqagain replaces that chunk.- A replay holds one full snapshot per page load. The player concatenates the chunks; a missing chunk only means playback resumes at the next full snapshot.
frames chunks add "kind": "frames", "schema_version": 1, the viewport, and frames (base64 JPEG, longest side at most 1000 px, quality 0.5, about one per second while the screen changes) and touches. video chunks carry an mp4 segment instead.
Storage
Section titled “Storage”rum-api keeps the same layout on a local disk and in an S3-compatible bucket:
replays/ schema.json the newest chunk schema this store was opened with <session_id>/ meta.json kind, project_id, chunk count, time range, bytes 000000.json.gz chunk 0 000001.json.gz 000002.mp4 video chunks only: the decoded segment, next to its JSONMoving from disk to a bucket is a copy of that tree. On S3 several rum-api replicas can share one bucket: meta.json is written with conditional requests, so concurrent writers never drop a chunk. See rum-api configuration.
schema.json protects you from a downgrade. A rum-api that finds a store written by a newer chunk schema refuses to start, rather than half-read newer data. See Upgrading.
Privacy
Section titled “Privacy”Masking happens before the data leaves the device. Nothing downstream can unmask it.
- Web: every input is masked (
maskAllInputs, on by default). Add the classouo-maskto mask an element’s text,ouo-blockto replace an element with an empty box of its size. - iOS frames: text inputs, text views, labels, React Native text, images and web views are painted over in black on the device. A view with the accessibility identifier (React Native
testID)ouo-maskis masked too;ouo-unmaskopts a view and its children out. - Each
framesandvideochunk records whether it was masked, and the player shows it. - Replay is a separate switch from telemetry. It can be off (
replay.enabled: false) while traces and errors keep flowing, and it can be paused at run time.
To keep no replay at all for a project, whatever its apps send, set its server-side replay_rate and replay_on_error_rate to 0 in rum-api (below): chunks are dropped at the sink.
Sampling
Section titled “Sampling”The SDK decides once per session, and the decision is kept with the session:
| Option | Default | What happens |
|---|---|---|
replay.sampleRate |
1 |
this share of sessions records and uploads from the start |
replay.onErrorSampleRate |
0 |
this share of the other sessions keeps the last 30 to 60 seconds in memory, and starts uploading (buffer first) at the first error |
So with sampleRate: 0.1 and onErrorSampleRate: 1, one session in ten is recorded in full, and every session that hits an error has a replay of the minute before it.
rum-api can cap this per project on the server (projects.<id>.sampling), with the same deterministic rule as the SDKs, so it keeps a subset of what the SDKs sampled and error-triggered replays survive. Use it to bound storage for an app you do not control.
Retention and deletion
Section titled “Retention and deletion”DELETE /v1/sessions/{id}/replaywith the read token removes one replay.- The operator API, behind the admin token, deletes everything of a user or a project (replays, a Loki delete request, the Tempo traces), and writes each deletion to an append-only audit log.
A web chunk is small: rrweb records changes to the page, not pixels. iOS frames are larger, about 20 to 40 KB per frame at one frame per second while the screen changes, and close to nothing while it does not. On mobile, sampling and pause() matter more than on the web. Benchmarks has measured bytes per session.