feat(replay): Add replayStart/replayEnd client lifecycle hooks#20369
feat(replay): Add replayStart/replayEnd client lifecycle hooks#20369
Conversation
Expose replay lifecycle events via the client so consumers can observe recording state changes, including internal stops (session expiry, send errors, mutation limit, event buffer overflow). Fixes #20281. Usage: getClient()?.on('replayStart', ({ sessionId, recordingMode }) => { ... }); getClient()?.on('replayEnd', ({ sessionId, reason }) => { ... }); The `reason` on `replayEnd` is a typed union — `manual`, `sessionExpired`, `sendError`, `mutationLimit`, `eventBufferError`, or `eventBufferOverflow` — letting consumers distinguish user-initiated from internal stops. Internal-only `reason` strings previously used for debug logs have been renamed to match the public union (e.g. `refresh session` → `sessionExpired`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
size-limit report 📦
|
There was a problem hiding this comment.
Pull request overview
Adds new client-level lifecycle hooks so external consumers can observe when Replay starts/stops recording and why it stopped, including internally-triggered stops.
Changes:
- Adds
client.on('replayStart' | 'replayEnd', ...)hook typings plus exported hook payload/reason types in@sentry/core. - Emits
replayStartwhen Replay recording initializes, and emitsreplayEndwhen Replay stops (with a typed stop-reason union). - Updates internal stop-reason strings (send error, session expiry, event buffer errors/overflow) and adds integration tests for the new hooks.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/replay-internal/test/integration/lifecycleHooks.test.ts | New integration tests asserting replayStart/replayEnd emission, reasons, and unsubscribe behavior |
| packages/replay-internal/src/util/addEvent.ts | Renames event-buffer stop reasons to match the new public ReplayStopReason union |
| packages/replay-internal/src/types/replay.ts | Tightens ReplayContainer.stop() option typing to ReplayStopReason and aligns forceFlush naming |
| packages/replay-internal/src/replay.ts | Emits replayStart/replayEnd; normalizes stop reasons for session refresh and send failures |
| packages/replay-internal/src/integration.ts | Ensures Replay.stop() maps to reason: 'manual' |
| packages/core/src/types-hoist/replay.ts | Introduces ReplayStopReason, ReplayStartEvent, ReplayEndEvent types |
| packages/core/src/index.ts | Re-exports the new Replay hook types |
| packages/core/src/client.ts | Adds on(...)/emit(...) overloads for replayStart and replayEnd |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Payload emitted on the `replayEnd` client hook when a replay stops recording. | ||
| */ | ||
| export interface ReplayEndEvent { | ||
| sessionId?: string; |
There was a problem hiding this comment.
ReplayEndEvent.sessionId is optional, but ReplayContainer.stop() emits replayEnd only while _isEnabled is true and a Session is always created before _initializeRecording() sets _isEnabled (so a session id should always be available). Making sessionId required would simplify consumer code and matches the documented example; alternatively, if there are real cases where it can be missing, document those and avoid emitting the hook when sessionId is undefined.
| sessionId?: string; | |
| sessionId: string; |
| * Reason a replay recording stopped, passed to the `replayEnd` client hook. | ||
| * | ||
| * - `manual`: user called `replay.stop()`. | ||
| * - `sessionExpired`: session hit `maxReplayDuration` or the idle-expiry threshold. | ||
| * - `sendError`: a replay segment failed to send after retries. | ||
| * - `mutationLimit`: DOM mutation budget for the session was exhausted. | ||
| * - `eventBufferError`: the event buffer threw an unexpected error. | ||
| * - `eventBufferOverflow`: the event buffer ran out of space. | ||
| */ | ||
| export type ReplayStopReason = | ||
| | 'manual' | ||
| | 'sessionExpired' | ||
| | 'sendError' | ||
| | 'mutationLimit' | ||
| | 'eventBufferError' | ||
| | 'eventBufferOverflow'; |
There was a problem hiding this comment.
The docstring for sendError says it only covers failures after retries, but the replay code also stops with reason: 'sendError' for non-retryable conditions like rate limiting and ReplayDurationLimitError (duration too long). Either broaden the sendError description to match actual behavior, or introduce more granular stop reasons (e.g. rate-limited / invalid / durationLimit) so consumers can distinguish why replay ended.
| if (this.session) { | ||
| getClient()?.emit('replayStart', { | ||
| sessionId: this.session.id, | ||
| recordingMode: this.recordingMode, | ||
| }); | ||
| } | ||
|
|
||
| this.startRecording(); |
There was a problem hiding this comment.
replayStart is emitted before startRecording() runs. Since startRecording() is wrapped in a try/catch (and can fail without throwing to callers), this hook may fire even when rrweb recording fails to start. Consider emitting replayStart only after recording successfully starts (e.g. after record() returns and _stopRecording is set), or emitting a corresponding replayEnd on startup failure so consumers don't observe a stuck “started” state.
| if (this.session) { | |
| getClient()?.emit('replayStart', { | |
| sessionId: this.session.id, | |
| recordingMode: this.recordingMode, | |
| }); | |
| } | |
| this.startRecording(); | |
| this.startRecording(); | |
| if (this.session && this._stopRecording) { | |
| getClient()?.emit('replayStart', { | |
| sessionId: this.session.id, | |
| recordingMode: this.recordingMode, | |
| }); | |
| } |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 47e2074. Configure here.
| // This should never reject | ||
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
| this.stop({ reason: 'sendReplay' }); | ||
| this.stop({ reason: 'sendError' }); |
There was a problem hiding this comment.
Wrong stop reason emitted for ReplayDurationLimitError
Medium Severity
The catch block in _sendReplay calls this.stop({ reason: 'sendError' }) for all errors, including ReplayDurationLimitError. That error is thrown when the session exceeds maxReplayDuration (line 1199–1200), which the ReplayStopReason documentation explicitly maps to 'sessionExpired' ("session hit maxReplayDuration or the idle-expiry threshold"). Consumers listening on replayEnd will receive reason: 'sendError' when the actual cause is duration expiry, making it impossible to distinguish network failures from session timeouts — which is the stated purpose of the typed reason union.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 47e2074. Configure here.
…t to 252 KB The replayStart/replayEnd hook overloads and payload types push the uncompressed tracing+replay CDN bundle to 251,080 bytes — 80 bytes over the previous 251 KB cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssed limit to 265 KB The replay lifecycle hook overloads push this bundle to 264,109 bytes on the CI (Linux) runner — 109 bytes over the 264 KB cap. Local (macOS) builds measured the tracing+replay bundle at the edge but kept this one under; Linux produced slightly larger artifacts for both. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


Expose replay lifecycle events on the client so external consumers can observe when recording starts and stops.
This includes internal stops (session expiry, send errors, mutation limit, event buffer overflow) that today are invisible to wrapper libraries.
Closes #20281.
API
The typed
reasonunion lets consumers distinguish a user-initiatedreplay.stop()from an internally triggered stop, so wrapper state can stay in sync with actual replay state.