Replay player
The replay player lives at /replays/{sessionId} in the dashboard. It takes the rrweb event stream the ingest pipeline wrote to Postgres and reconstructs the user's browser session pixel-for-pixel, inside a hardened sandbox.
For the capture side (browser → events) see SDK reference.
Architecture overview
The page nests four layers, outer-to-inner:
- Dashboard chrome — Next.js + React 19 + Tailwind v4. Renders the page's standard layout (header above the viewport, side panel beside the viewport, control bar below).
- Viewport host element — a regular
<div>withpointer-events: none, so the user can never click into the replayed content. They watch, they don't interact. - Shadow root (
mode: "open", mounted on the host) — owns the Replayer's CSS in isolation. The dashboard's Tailwind can't bleed into the replayed content, and the replayed page's styles can't bleed back out. - rrweb iframe — created by the Replayer inside the shadow root. The recorded page's reconstructed DOM lives here; any scripts, links, or external requests in the recording are confined to this iframe and can't reach the dashboard's chrome.
Layers 3 + 4 are the security boundary: Shadow DOM contains styles, the iframe contains scripts.
How the replay actually works
rrweb events come in three families:
| Type | What it carries |
|---|---|
2 Full snapshot | Complete DOM tree at session start / post URL change |
3 Incremental snapshot | DOM mutation, mouse position, scroll, input event |
4 Meta | Page metadata (URL, viewport dimensions) |
The Replayer:
- Loads the meta event to set up the iframe with the recorded viewport dimensions
- Applies the full snapshot to seed the iframe's DOM
- Replays incremental events in timestamp order — each one updates the iframe DOM (or moves the mouse cursor, or scrolls)
- Honours playback speed — events play at their original cadence
times the speed multiplier (
0.5×/1×/2×)
Scrubbing is "skip ahead by N ms": the player fast-forwards through events without rendering intermediate states, then resumes normal playback at the target timestamp.
Event stream + read-time history
The session-detail endpoint (GET /api/sessions/{id}) returns
metadata + a marker list — typed timeline anchors. Events themselves
load via a sibling endpoint:
User opens session
↓
GET /api/sessions/{id} → metadata + markers (typed anchors)
↓
GET /api/sessions/{id}/events → { batches: [{ data: "<base64-gzip>", … }] }
↓
Browser decompresses each blob (DecompressionStream),
concatenates, sorts by timestamp
↓
Two consumers receive the same flat event stream:
• rrweb Replayer plays the entire session as one continuous stream
• History builder (pure module, src/lib/history/) folds the stream
into a chronological feed of timeline annotationsuseSessionEventsQuery is staleTime: Infinity — recordings are
immutable once captured. The EventBatch rows are sorted by
firstTimestamp ASC server-side; out-of-order arrival from the SDK
is sorted out at read time.
The history feed is derived state. Changing criteria (e.g. adjusting the idle-gap threshold) rebuilds the feed instantly with no extra fetch.
dozor:url markers carry a fresh FullSnapshot
The SDK pairs every dozor:url custom event with an immediate
record.takeFullSnapshot() — the next event in the stream after the
marker is a fresh type: 2 (FullSnapshot) for the new pathname.
Because the player consumes one continuous stream, the new
FullSnapshot simply lands inline and the rrweb Replayer applies it
as a clean DOM reset for the new pathname. The history feed treats
the marker as the boundary between two navigation sections so the
user can jump straight to either page.
Read endpoints
Both endpoints are session-cookie-authenticated (VIEWER+) and
respond with Cache-Control: no-store so polling-driven reloads
always reach the server.
GET /api/sessions/{sessionId}/events
{
batches: Array<{
id: string;
firstTimestamp: number; // Unix ms
lastTimestamp: number; // Unix ms
eventCount: number;
data: string; // base64-encoded gzip of `eventWithTime[]`
}>;
nextCursor: string | null; // forward-compat — currently always null
}The browser decodes each data field with
atob(...) → Uint8Array → DecompressionStream("gzip") → JSON.parse.
nextCursor is in the schema for the eventual day a single session
hits a row count we want to page; today every session fits in one
response.
GET /api/sessions/{sessionId}/markers
{
markers: Array<{
timestamp: number; // Unix ms
kind: "url" | "identity"; // tag suffix (after "dozor:")
data: unknown; // shape per kind:
// url → { url, pathname }
// identity → { userId, traits? }
}>;
}Optional ?kind=url / ?kind=identity filters server-side. Anything
else returns 400 — silent empty-result rebounded into a typed
rejection.
The wire contract for POST /api/ingest (the SDK-facing side) lives
in SDK → Wire format.
History feed
[▶] /checkout 0:00–1:18 ← active
[⇄] /checkout/payment 1:18–3:04
[⏸] Idle 3:04–3:42
[👤] Identified: user-42 3:51Each row shows a kind icon + label + duration. Items break down into two flavours:
- Sections —
initandnavigation. They chain head-to-tail and cover the entire session timeline. Exactly one section is the active item at any moment of playback. - Markers —
idleandidentify. Markers nest inside sections; during playback a marker's range takes priority over the surrounding section in the active-item lookup, so playing through an idle gap visibly highlights the gap.
Clicking a row calls seek(item.startedAt - sessionStart) on the
Zustand store. The replayer state stays as it was — playing rows
keep playing from the new offset, paused rows pause at it. There is
no remount: the rrweb Replayer plays one continuous stream for the
whole session.
Auto-scaling viewport
The recorded session was captured at the user's actual viewport (e.g. 1440×900). The dashboard player slot is a fixed aspect ratio (16:9) that adapts to the user's screen.
The viewport component runs two ResizeObserver instances:
- One on the rrweb iframe (replayed-content size)
- One on the container (dashboard slot size)
Each resize recomputes:
scale = Math.min(containerW / replayW, containerH / replayH)Then applies transform: scale(<scale>) with centering — pure CSS
transform, no DOM rewrite. The replayed page sees its native
viewport dimensions; the dashboard renders it scaled to fit.
Both observers are properly disconnected in the effect cleanup so no zombie listeners stay active across re-renders.
Side panel — History + Console tabs
A 320 px column to the right of the viewport. Two tabs share the column; switching tabs is local state in the Zustand store, no remount. Both tabs own a toolbar slot (empty today, a future home for filters / sort) above their feed list.
History tab
Renders the history feed described above. The currently-playing
section is highlighted with a left accent border and a tinted
background; the list auto-scrolls (block: "nearest" smooth) to
keep it in view. The active id is recomputed in the same RAF tick
that drives currentTime updates and is set in the store only
when it actually changes — the 60 fps currentTime refresh
doesn't re-render the list.
Console tab
Events of type === 6 are rrweb's console events (when
recordConsole: true was set in the SDK). The Console tab reads
them out of the same stream and renders:
- The log level (
info/warn/error/debug/log) - The arguments serialised
- The timestamp relative to the session start
Read-only feed — entries auto-scroll while you're at the bottom and pause auto-scroll when you scroll up to inspect an earlier line.
Control bar
The bar below the viewport carries:
- Play / Pause — toggle playback
- −5s / +5s — seek backward / forward
- Speed — popover with
0.5×/1×/2× - Skip idle (toggle) — fast-forward through inactive periods during playback
- Compress idle (toggle, default on) — rewrites the rrweb event stream upstream so each idle
gap longer than 30 s is shrunk to exactly 5 s. The Replayer's
getMetaData().totalTimeand the seek bar reflect the compressed timeline; history items keeprealDurationMsset from the original gap, so labels show real durations even though seek-bar position is compressed. Seelib/history/compress.tsfor the transform +mapToCompressedtranslator. - Seek bar — scrubbable progress across the (possibly compressed) timeline
There are no keyboard shortcuts. The history feed in the side panel is the primary timeline-navigation surface.
Privacy contract preserved
What the player cannot show:
- Anything masked at SDK source — that text is asterisks in the database, the player has no original to recover
- Anything
data-dozor-block'd — the recorded DOM has a placeholder, the player renders the placeholder - Inputs masked by
privacyMaskInputs: true— same story
The player is a faithful replay of the captured event stream. If masking happened at capture, masked stays masked. See SDK → Privacy & masking for what's captured / not captured.
Where the code lives
All paths are relative to the repo root. Player files live under
src/app/[locale]/(dashboard)/replays/[sessionId]/.
| Module | Responsibility |
|---|---|
replays/[sessionId]/page.tsx | Server Component prefetch of session detail |
components/player/index.tsx | Composition root — mounts viewport + side panel + control bar |
components/player/viewport/index.tsx | Shadow DOM host + rrweb Replayer instantiation + ResizeObservers |
components/player/viewport/replayer-css.ts | The CSS string injected into the shadow root (rrweb requirements) |
components/player/store.ts | Zustand store — events, historyItems, activeTab, ... |
components/player/control-bar/ | Play/pause/scrub/speed controls |
components/player/side-panel/ | Tab-switched side panel — History + Console feeds |
api-client/sessions/queries.ts | useSessionEventsQuery + useSessionMarkersQuery |
lib/history/ | Pure history builder — buildHistory(events, criteria) |
See also
- Dashboard → Replays → Player — the user-facing description.
- Ingest pipeline — how the events the player consumes got into the database.
- SDK → Privacy & masking — what's recoverable in replay vs what's permanently redacted.