Dozor

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:

  1. 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).
  2. Viewport host element — a regular <div> with pointer-events: none, so the user can never click into the replayed content. They watch, they don't interact.
  3. 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.
  4. 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:

TypeWhat it carries
2 Full snapshotComplete DOM tree at session start / post URL change
3 Incremental snapshotDOM mutation, mouse position, scroll, input event
4 MetaPage metadata (URL, viewport dimensions)

The Replayer:

  1. Loads the meta event to set up the iframe with the recorded viewport dimensions
  2. Applies the full snapshot to seed the iframe's DOM
  3. Replays incremental events in timestamp order — each one updates the iframe DOM (or moves the mouse cursor, or scrolls)
  4. Honours playback speed — events play at their original cadence times the speed multiplier (0.5× / / )

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 annotations

useSessionEventsQuery 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:51

Each row shows a kind icon + label + duration. Items break down into two flavours:

  • Sectionsinit and navigation. They chain head-to-tail and cover the entire session timeline. Exactly one section is the active item at any moment of playback.
  • Markersidle and identify. 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× / /
  • 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().totalTime and the seek bar reflect the compressed timeline; history items keep realDurationMs set from the original gap, so labels show real durations even though seek-bar position is compressed. See lib/history/compress.ts for the transform + mapToCompressed translator.
  • 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]/.

ModuleResponsibility
replays/[sessionId]/page.tsxServer Component prefetch of session detail
components/player/index.tsxComposition root — mounts viewport + side panel + control bar
components/player/viewport/index.tsxShadow DOM host + rrweb Replayer instantiation + ResizeObservers
components/player/viewport/replayer-css.tsThe CSS string injected into the shadow root (rrweb requirements)
components/player/store.tsZustand 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.tsuseSessionEventsQuery + useSessionMarkersQuery
lib/history/Pure history builder — buildHistory(events, criteria)

See also

On this page