> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Session replay model: recording lifecycle, two-tier storage, and playback

> How a session replay recording moves from capture to object storage to playback — the lifecycle state machine, the inline head plus durable chunk storage split, the failure and purge semantics, and the capture-time privacy boundary. Read this before you rely on the API or tune masking.

# Session replay model

Session replay is the **asynchronous twin of live [co-browse](/concepts/cobrowse-session-model)**. Co-browse mirrors a visitor's DOM to an agent over a scoped data channel while the page is open; session replay records the same rrweb event stream in the visitor's browser, uploads it in batches, and stores it against the conversation so an agent can play it back later — after the visitor has left, often on a closed thread. This page is the conceptual model behind that subsystem; the operator walkthrough lives in [the session replay guide](/guides/session-replay), and the request/response shapes are in the [Session Replay API reference](/api-reference/session-replay).

## Recording lifecycle

A recording is a stamped object on the conversation — `conversations.metadata.session_replay` — that moves through a small state machine:

```
recording ──(visitor stops / navigates away)──▶ ended
recording ──(abuse backstop trips)─────────────▶ capped
```

* **`recording`** — created when the widget's recorder starts (the visitor opt-in call on the widget capture endpoints). The widget buffers events in-browser and uploads them in batches of at most 500 events to the capture endpoint; each append either extends the inline head or spills to durable storage, as described below. The widget stamps the returned `recording_id` on every subsequent batch.
* **`ended`** — the terminal, healthy state. The widget finalizes the recording on page unload, navigation, or explicit stop, stamping `ended_at`. A finalized recording is immutable — the append path returns it unchanged.
* **`capped`** — the terminal, abnormal state. Only the abuse backstop (see below) can set it; normal sessions never reach it.

Both transitions are idempotent: re-finalizing an `ended` recording is a no-op, and starting a second recording reuses an existing non-ended one rather than duplicating it.

## Two-tier storage model

The design constraint is that a conversation row must never bloat with an unbounded event array, while a long session must not be silently truncated either. The model resolves this with two tiers of storage on one recording:

| Tier               | Bound                          | Where                                           |
| ------------------ | ------------------------------ | ----------------------------------------------- |
| **Inline head**    | First 5,000 events, up to 4 MB | On the conversation row (`events` array)        |
| **Durable chunks** | Everything past the head       | Ordered object-storage pointers (`blob_chunks`) |

Every incoming event lands in the inline head until either bound (5,000 events or 4 MB of serialized JSON) is hit. From that moment every further event becomes overflow, written as one chunk object per upload batch:

```
gs://{bucket}/session-replay/{schema}/{recording_id}/chunk-{seq}.json
```

Each pointer records a monotonic `seq` (its position in the append order), its `path`, and its `event_count` / `size_bytes`. Once anything has spilled, nothing returns to the head — append order stays contiguous-from-start in the head and contiguous-after-head in the chunks. This ordering property is what makes the reassembled rrweb stream replayable: rrweb's incremental mutations reference the full snapshot at the head, so dropping events from the middle would corrupt playback.

Total counters (`total_event_count`, `total_size_bytes`) track head plus chunks, and the recording carries `offloaded: true` once any chunk exists. The list endpoint summarizes on those totals so you can gauge per-recording footprint without fetching chunk pointers.

### Playback reassembly

For an inline-only recording, playback is the head — no storage round-trip. For an offloaded recording, the playback endpoint downloads every chunk concurrently and returns `head ++ chunks` in `seq` order as one event array. Chunk upload is idempotent on `(recording_id, seq)`: a widget retry of the same batch re-writes the same object path rather than duplicating events.

### Abuse backstop

Independent of the inline cap, a recording is hard-bounded at roughly **1 GiB of serialized events or 5 million events** across both tiers. Only this ceiling flips `status` to `capped` and sets `truncated: true`; the inline head cap never truncates — it only redirects where events live. A pathological recorder (for example a page script in a rapid DOM-mutation loop) is the only realistic way to trip it, so treat `capped` as a signal about the page, not a storage limit you need to raise.

## Failure model

Reassembly happens on the request path, so its failures are designed to degrade, not error:

* **Head-only fallback.** If a chunk download fails during playback (a transient object-storage read error), the response serves the inline head rather than failing — flagged by `offloaded: true` on the recording and `truncated: true` if the backstop also fired. Retry usually returns the full stream; persistent head-only responses on an offloaded recording are worth a support ticket.
* **Chunk upload failure.** If writing an overflow chunk fails, the capture endpoint asks the widget to retry that batch; idempotency on `(recording_id, seq)` makes the retry safe.
* **Corrupt recording payload.** Every read path runs the decoded JSON through a narrowing guard before it is used; a drifted or malformed stored shape fails loudly instead of being appended to or played from.

## Purge semantics

Purging a recording is ordered so the row pointer is dropped only after the durable data is gone:

1. **Durable chunks first** — best-effort deletion of every `blob_chunks` object; a missing object or transient storage error is logged and skipped so erasure still completes.
2. **Then the pointer** — the `session_replay` value is removed from the conversation metadata.
3. **Evidence** — an audit entry (`session_replay.deleted`) records the erasure.

Deleting the conversation also removes its attached recording, so conversation retention and recording retention move together. When an erasure request covers the recording but not the thread, purge the recording first.

## Privacy boundary

Masking is decided **in the visitor's browser, before any event is uploaded** — masked content never leaves the page, and the server stores exactly what the browser sent. The default posture is fail-closed: every input value is masked and the built-in PII heuristics apply until you explicitly relax them. The recorder treats event payloads as opaque bytes the server never interprets, and playback hands them back to the agent's player unchanged.

The tenant-owned controls map one-to-one onto the recorder's options:

| Field                 | Effect                                                                                                                        |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `mask_all_inputs`     | Mask every `<input>`, `<textarea>`, and `<select>` value.                                                                     |
| `mask_text_selectors` | Replace the rendered text of matching selectors with asterisks.                                                               |
| `block_selectors`     | Never capture matching subtrees at all — the hard cut for payment iframes and regulated content.                              |
| `mask_pii_default`    | Apply the recorder's built-in heuristic masking to elements flagged sensitive (`.rr-mask` class or `data-rr-mask` attribute). |

The policy is account-wide and takes effect from the next widget bootstrap — it never retroactively alters stored recordings. Choosing what to mask or block, and documenting that choice with your DPO, is the control that keeps replay aligned with your compliance posture. The same capture-time-redaction contract also governs live [co-browse](/concepts/cobrowse-session-model), so the two capture surfaces share one masking posture rather than diverging.

## Tenant isolation

Every recording is stamped inside one tenant's schema (the `{schema}` segment in the chunk object path is the same schema reference the query layer resolves), so recordings, chunk pointers, and chunk objects can never cross tenants — the isolation is structural, not a runtime filter. Visitor tokens resolve to exactly one tenant before any append is accepted.

## See also

* [Session replay guide](/guides/session-replay) — operator walkthrough: retention, playback in the inbox, purge, masking panel
* [Session Replay API reference](/api-reference/session-replay) — endpoint shapes and error codes
* [Co-browse session model](/concepts/cobrowse-session-model) — the live counterpart that shares this masking contract
* [Conversations](/concepts/conversations) — the entity a recording is stamped on
