Session Replay
Use this skill when working on /sessions, replay ingest, replay storage, or
agent answers about browser recordings in the Analytics template.
Source Of Truth
- Replay ingest writes
session_recordingsandsession_replay_chunks. - The UI and agent must use
list-session-recordings,get-session-replay-summary, andget-session-replay-events. /sessions/:recordingIdis keyed bysession_recordings.id, notanalytics_events.session_id.- Do not add actions that synthesize "sessions" from
analytics_events. Events can be linked beside a recording throughsession_id, but they are not playable replay rows by themselves.
Storage And Access
- Never expose object-storage URLs or raw
session_replay_chunksrows to the browser or agent. - Playback bytes must go through scoped server helpers that check
session-recordingaccess before reading private blob refs. - SQL inline chunks are a local/dev fallback only; production should use private or encrypted blob storage.
- A local Analytics app pointed at a production database must also use the key
that encrypted those replay blobs. Set
ANALYTICS_SECRETS_ENCRYPTION_KEYin an untracked local env file; do not replace the workspace-wideBETTER_AUTH_SECRETjust to read production replay storage. - When sharing a replay with an external agent, use
create-session-replay-agent-link. It mints a two-houragent_accessURL scoped to the recording, embeds a small SSR discovery payload on/sessions/:recordingId, and advertises/api/session-replay/agent-context.jsonplus bounded/api/session-replay/agent-events.jsonand/api/session-replay/agent-diagnostics.jsonreads. - Do not make session recordings public just so an agent can inspect them. Tokenized agent links are the intended handoff path.
Console And Network Capture
- While recording, the core client (
session-replay.tsin@agent-native/core) patches console (log/info/warn/error/debug), windowerror/unhandledrejection,fetch, and XHR, and emits rrweb custom events taggedagent-native.consoleandagent-native.network. - Capture is on by default whenever session replay is enabled. Tune or disable
it with the
console/networkoptions on the session replay config; each accepts a boolean or an options object ({ maxEvents?: number });networkalso acceptscaptureErrorBodies(default true) andmaxErrorBodyLength(default 2048) to control the bounded 5xx response-body snippet. - Privacy bounds: request bodies and headers are never captured. Response
bodies are captured only as a bounded, redacted snippet for 5xx (server
error) responses, capped at
maxErrorBodyLengthchars; non-5xx and network-failure (status 0) responses never carry a body. URLs are scrubbed, messages are truncated, and recorder self-traffic (the replay ingest and tracking endpoints) is excluded. - Per-session budgets: 1000 console events and 2000 network events, with a truncation notice event once a budget is hit.
- On ingest,
deriveReplaySignalscomputes the realerrorCountfrom tagged console events plus the additivenetworkErrorCountcolumn onsession_recordings. Keep new columns additive.
Agent Diagnostics Surface
buildSessionReplayAgentContextincludes adiagnosticssection: up to 50 console entries and 50 network entries, errors/failures first, with totals and truncated flags. Agent-context instructions steer agents to diagnostics as the primary debugging signal.- The agent timeline includes
console-error/network-errormarkers; error markers are kept preferentially under the 200-marker cap. apis.diagnosticsadvertises the fuller bounded list:GET /api/session-replay/agent-diagnostics.json?id=<recordingId>&agent_access=<token>&kind=console|network|all&level=<level>&limit=<n>&offset=<n>&fromMs=<n>&toMs=<n>(limit defaults to 200, max 500). It uses the same recording-scopedagent_accesstoken as the other agent JSON APIs.offsetandfromMs/toMs(inclusive offsetMs window) enable full enumeration of a session's captured entries: page withoffset, or window withfromMs/toMsaround a timeline marker'soffsetMs. Providing any of these switches ordering to strictly chronological (no errors-first reshuffle) so pages are stable and disjoint.total/errorCount/warnCount/failedCountreflect the filtered (windowed/level/kind) population, not just the returned page, and each kind's response includeshasMorealongsidetruncatedso an agent can tell whether more entries remain. Route validation rejects negative/non-numericoffset/fromMs/toMsandfromMs > toMswith 400.
Dev Tools Panel
- The
/sessions/:recordingIdreplay player has a Dev Tools toggle that opens a panel with Console and Network tabs: filter chips, search, an error-count badge, and playback-time highlighting. - Rows expand inline under the selected line (Chrome-style). Expanding a row does not seek; use Jump to to move the playhead. Extend this panel instead of adding a separate debugging surface.
Playback Viewer
- Wait for all replay chunks (
isComplete) before constructing the rrwebReplayer. Progressive chunk publishes should only update the loading bar; rebuilding the player mid-load desyncs the scrubber and playhead. - Pass normal events to
Replayeruntouched. rrweb rebuilds them in a sandboxed iframe; pre-processing DOM, stylesheet, resource, or mutation payloads makes playback diverge from the captured page. In particular, never rewritehref,src,_cssText, CSSurl(), or Meta URLs toabout:blank; that exact remediation broke historical replay CSS in PR #2040. Handle request privacy at capture or the sandbox boundary instead of mutating stored rrweb events. Historical captures without inlined resources require live stylesheet/image/font requests for accurate rendering; the viewer accepts that fidelity tradeoff, uses rrweb's script-disabled sandbox plusreferrerpolicy="no-referrer", and must never add credentials or proxy those URLs through a privileged server. - Capture-time URL scrubbing must preserve load-bearing DOM resource attributes:
src,srcset,poster,data, andhrefonly on resource links such as stylesheets, preloads, and icons. Signed CDN query parameters are part of the resource identity; redacting them produces missing CSS, fonts, images, and oversized fallback icons. Keep scrubbing Meta/navigation URLs, anchor hrefs, and console/network diagnostics. Captured_cssTextand CSS@import/url()values must remain byte-identical. - rrweb rebuilds into an
about:srcdociframe, which inherits the Analytics document's CSP. Analytics currently sends no CSP header; if a future change adds restrictivestyle-src,font-src, orimg-srcdirectives, verify historical replays and resolve external imports/fonts at capture before blocking the recorded resource origins. Do not diagnose current font loss as CSP without checking the deployed response headers first. - Let rrweb own iframe sizing entirely via Meta / ViewportResize, and keep the
outer wrapper on the exact same raw dimensions for fit-to-stage scaling.
Player geometry and pointer coordinates are fully stock and untouched — do
not add width/aspect-ratio "recovery" heuristics or pointer-coordinate
projection. There is no such thing as a stored recording with corrupt
viewport geometry: a census of all production recordings found zero stored
widths >= 3,000px. The 2026-07 "ultra-wide replay" bugs (stages rendered
3,000–9,500px wide, frozen/teleporting cursors, giant icons) were caused
entirely by demo mode's fetch interceptor: its number redactor faked any
integer >= 1000 inside raw replay JSON at view time, corrupting Meta /
ViewportResize widths, pointer x/y coordinates, and numeric values inside
_cssTextand SVG attributes before rrweb ever saw the payload (heights below 1000 stayed real, which is why the symptom looked like a viewport problem rather than a redaction bug — two different sessions that both stored a 1,152px width read back as the same 4,491px, a deterministic salted-hash fingerprint of the redactor, not two coincidentally identical malformed recordings). This is fixed inpackages/core/src/demo/fetch-interceptor.ts: raw replay payload and manifest URLs are skipped from demo number redaction entirely, and must never be routed through it again. Do not reintroduce viewport clamping or pointer-coordinate projection in the player — they can now only corrupt genuine future recordings (for example, a real 3440x900 ultrawide browser window, or a short vertical window under 1,000px tall). - Keep rrweb's stock cursor stylesheet and its hotspot transform. During playback, hide the viewer's native pointer over Analytics' transparent click-to-pause overlay so it cannot masquerade as a frozen recorded cursor.
- Keep rrweb's recorded focus handling enabled. Focus and focus-visible state
affect menus, forms, and keyboard UX; disabling
triggerFocusmakes a valid snapshot diverge from the source page. insertStyleRulesmay suppress known toast/snackbar containers only. Never hide generic framework primitives such as[data-radix-popper-content-wrapper]: Radix dropdowns, selects, tooltips, and other real recorded product UI all share that wrapper.- Keep the realistic-fidelity purity/pass-through tests in
SessionDetailPage.spec.ts— raw event identity, raw viewport dimensions, and raw resize-state derivation (including the 3,189x885 tripwire against reintroducing a clamp) — as regression guards against reintroducing any viewport "recovery" or pointer-projection heuristic. Do not change their expectations merely to bless a new sanitizer or clamp; validate the affected replay in a browser first. An interim clamp for the exact 3,189x885 pair was also deleted once the view-time redaction root cause was proven; the earlier 3,000-3,999px band was rejected because it also catches real 3440px-wide displays. Neither the exact exception nor the band belongs in the player. - The event timeline soft-highlights the active marker, auto-scrolls it into view (pausing briefly after manual scroll), and supports search. It appears beside the player from ~880px content width upward.
- Dev Tools height is capped so the replay stage never collapses into a ribbon on short viewports; the scrubber playhead stays visually distinct from red error marker dots.
Debugging A User-Reported Bug
- Search the reporting user's email on
/sessionsto find their recordings. - Open the relevant session at
/sessions/:recordingIdand click Copy for agent to mint the two-hour tokenized link. - Paste the link to an agent. The agent fetches
/api/session-replay/agent-context.json, reads thediagnosticssection and timeline markers first, then drills intoapis.diagnostics(filtered bykind/level) andapis.eventsfor the fuller bounded lists as needed. - For human verification, open the Dev Tools panel in the replay player and jump-to-seek from the failing console or network row.
Capture Defaults
- Replay is on by default for signed-in hosted users when
VITE_AGENT_NATIVE_ANALYTICS_PUBLIC_KEYorconfigureTracking({ key })is present. The default sample rate is 100% of eligible sessions. - Replay remains off when no first-party analytics key is configured, and it is
not auto-enabled on localhost/local dev. Consumers can still enable replay
directly with
configureTracking({ key, endpoint, sessionReplay: { enabled: true } }). - Apps can opt out with
configureTracking({ sessionReplay: false }). - Agent-Native templates already call
configureTracking()in their roots; hosted template deployments only need the normal Agent-Native Analytics Vite/Netlify env vars on the recorded site. - Inputs are masked by default. Page text is visible unless marked with
.an-maskordata-an-mask. - Use
.an-block,.an-ignore,data-an-block, ordata-an-ignorefor sensitive zones that should not be captured. - A definitive upload
409abandons only the conflicted replay identity and immediately starts rrweb again under a fresh per-tab id, producing a new Meta + FullSnapshot for long-lived SPA tabs. Recovery is limited to one restart until an upload succeeds so a misconfigured endpoint cannot loop; Analytics tracks the content-freesession replay upload rejectedlifecycle event so conflicts and recovery success are measurable. - Do not label an old recording "corrupt" from pointer coordinates, unknown mutation node ids, or changing Meta geometry alone. Those shapes can be legitimate with scrolling, iframes/shadow DOM, navigation, and resize. A historical-artifact notice needs a durable capture/ingest marker or another low-false-positive invariant; do not guess from playback heuristics.