Session replay
Opt-in, masked-by-default recordings of real visits, played back in a sandbox and linked from sessions, issues, traces and rage clicks.
Session replay records what a visitor's browser showed and did, so you can watch the visit behind an error, a slow trace or a burst of rage clicks. Nothing is recorded unless you add the browser SDK's sessionReplay() integration, and every text and input value is masked unless you unmask it.
Turn it on
Add sessionReplay() to the browser SDK's integrations:
import { errors, initUnhandled, network, sessionReplay } from '@unhandled/browser'
const replay = sessionReplay({ autoStart: false, sampleRate: 0.2, unmaskSelector: '[data-unmask]' })
export const unhandled = initUnhandled({
dsn: import.meta.env.VITE_UNHANDLED_DSN,
environment: 'production',
integrations: [network(), errors(), replay],
})
onConsent(() => replay.start())| Option | Default | Meaning |
|---|---|---|
autoStart | true | false waits for replay.start(), for a consent banner. replay.stop() ends the recording and forgets the replay id. |
sampleRate | 1 | Share of replays recorded, from 0 to 1, decided once per replay. |
unmaskSelector | none | Text and input values inside matching elements are recorded as typed. Password inputs stay masked. |
blockSelector | none | More elements replaced by an empty placeholder, on top of the media defaults. |
maxDurationMs | 60 minutes | A replay longer than this continues as a new one. |
persistence | 'session' | 'memory' keeps the replay id out of sessionStorage, so every page load is its own replay. |
See the browser SDK for the rest of the SDK's options.
What is recorded
- The page, masked. Every text node becomes
*per character, and every input value is masked.img,picture,video,audio,canvas,iframe,objectandembedare replaced by empty placeholders, and fonts, canvas content and cross-origin iframes are not recorded.unmaskSelectorlets chosen parts of the page through;blockSelectorhides more. - Mouse, scroll and input events, so the player can show clicks, scrolling and typing (masked).
- URLs, sanitized. The page URL and every recorded
href,src,actionandsrcsetlose their query string and fragment, and token-like path segments readREDACTED, unless you setsanitizeUrls: false. YournormalizeUrlruns on them too. Unhandled strips the query and fragment from the page URL again when it stores a chunk. - Reported errors. Each exception the SDK reports during a recording is marked in the replay, and the exception carries the tag
replay.id.
Never recorded: network requests and their bodies, console output and cookies. With the default persistence: 'session', sessionStorage holds one key, unhandled.replay, with a random replay id, a chunk counter and timestamps, so a visit across several pages of one tab is one replay. A tab idle for 30 minutes starts a new one.
The recorder and its gzip code load only when a sampled replay starts. The integration adds about 3.2 KB (minified and gzipped) to your main bundle; a page that records then loads about 22.1 KB for the recorder and 3.3 KB for gzip.
Where to find it
Analytics > Replays (/o/:org/projects/:project/analytics/replays) lists the replays recorded in the selected range, newest first, 50 at a time: when each started, how long it ran, the page it started on, the identified user if your app called identify(), and its clicks, rage clicks and errors. Filter to replays with errors or with rage clicks, or to one user.
A replay's page plays it back with its errors and rage clicks marked on the timeline, and links to the visit it belongs to.
Replays are also linked from:
- Sessions. A visit with a replay recorded during it links to it from the list and from the visit's page.
- Issues. An occurrence reported while recording links to its replay.
- Traces. A trace started in a recording tab links to its replay.
- Clicks. The Clicks page counts the replays with rage clicks in the range and links to them.
A link appears only while the replay is still stored.
The player
The recorded page is played back inside a sandboxed frame that cannot run scripts. Before a recording reaches your browser, Unhandled also removes the recorded page's scripts, frames, embedded objects, <base> tags and refresh tags, every on… event handler, every javascript:, vbscript: and non-image data: URL, and every image, stylesheet or font URL on another host, so watching a replay never loads anything from outside Unhandled.
The player page also sends a stricter Content Security Policy than the rest of the app: images load only from Unhandled itself or from data: and blob: URLs. The playback frame inherits that policy, so an image URL the cleanup missed is blocked by your browser, and the recorded site never learns your IP address or browser.
Limits and retention
- A replay is split into chunks of up to 10 minutes and 2,000 chunks; a chunk is at most 1 MiB on the wire and 8 MiB unpacked.
- Each project stores at most 1 GiB of compressed replay data per UTC day; chunks past that are dropped and counted as rate limited. Self-hosted installs change it with
REPLAY_PROJECT_DAILY_MB. - If replay storage is unavailable, Unhandled retries each chunk briefly, then drops the chunks it could not store and counts them as unavailable, reason
replay_storage_unavailable. Errors, traces, logs and analytics sent alongside are stored as usual. - Replays are kept for your organization's analytics retention, and never longer than 30 days.
- Replay data counts toward your analytics usage by its stored, compressed size.
Erasure
- Deleting a user deletes every replay that names the user or ran on a device linked to them, whole.
- Deleting a project or an organization deletes its replays at once.
In your AI assistant
The MCP server's list_analytics_replays and get_analytics_replay tools return the same lists and replay summaries, with their chunks and linked visit. They never return the recording itself.