Skip to content

feat(replay): Add replayStart/replayEnd client lifecycle hooks#20369

Open
logaretm wants to merge 3 commits intodevelopfrom
awad/js-2160-add-lifecycle-event-hooks-onstart-onstop
Open

feat(replay): Add replayStart/replayEnd client lifecycle hooks#20369
logaretm wants to merge 3 commits intodevelopfrom
awad/js-2160-add-lifecycle-event-hooks-onstart-onstop

Conversation

@logaretm
Copy link
Copy Markdown
Member

@logaretm logaretm commented Apr 16, 2026

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

getClient()?.on('replayStart', ({ sessionId, recordingMode }) => {
  // recordingMode: 'session' | 'buffer'
});

getClient()?.on('replayEnd', ({ sessionId, reason }) => {
  // reason: 'manual' | 'sessionExpired' | 'sendError' | 'mutationLimit'
  //       | 'eventBufferError' | 'eventBufferOverflow'
});

The typed reason union lets consumers distinguish a user-initiated replay.stop() from an internally triggered stop, so wrapper state can stay in sync with actual replay state.

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>
@linear-code
Copy link
Copy Markdown

linear-code bot commented Apr 16, 2026

Comment thread packages/replay-internal/src/replay.ts Dismissed
Comment thread packages/replay-internal/src/replay.ts Dismissed
@logaretm logaretm requested review from billyvg, chargome and mydea April 16, 2026 18:23
@logaretm logaretm marked this pull request as ready for review April 16, 2026 18:27
@logaretm logaretm requested a review from a team as a code owner April 16, 2026 18:27
Copilot AI review requested due to automatic review settings April 16, 2026 18:27
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

size-limit report 📦

Path Size % Change Change
@sentry/browser 25.78 kB - -
@sentry/browser - with treeshaking flags 24.27 kB - -
@sentry/browser (incl. Tracing) 43.65 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 45.36 kB - -
@sentry/browser (incl. Tracing, Profiling) 48.58 kB - -
@sentry/browser (incl. Tracing, Replay) 82.84 kB +0.07% +52 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 72.36 kB +0.1% +66 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 87.54 kB +0.06% +52 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 99.79 kB +0.06% +57 B 🔺
@sentry/browser (incl. Feedback) 42.59 kB - -
@sentry/browser (incl. sendFeedback) 30.45 kB - -
@sentry/browser (incl. FeedbackAsync) 35.45 kB - -
@sentry/browser (incl. Metrics) 27.07 kB - -
@sentry/browser (incl. Logs) 27.2 kB - -
@sentry/browser (incl. Metrics & Logs) 27.89 kB - -
@sentry/react 27.53 kB - -
@sentry/react (incl. Tracing) 45.92 kB - -
@sentry/vue 30.61 kB - -
@sentry/vue (incl. Tracing) 45.49 kB - -
@sentry/svelte 25.8 kB - -
CDN Bundle 28.46 kB - -
CDN Bundle (incl. Tracing) 44.73 kB - -
CDN Bundle (incl. Logs, Metrics) 29.83 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 45.81 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 68.8 kB +0.1% +63 B 🔺
CDN Bundle (incl. Tracing, Replay) 81.74 kB +0.08% +58 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 82.82 kB +0.07% +56 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 87.25 kB +0.07% +54 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 88.33 kB +0.07% +55 B 🔺
CDN Bundle - uncompressed 83.12 kB - -
CDN Bundle (incl. Tracing) - uncompressed 133.75 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 87.27 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 137.17 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 210.84 kB +0.1% +210 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 251.2 kB +0.09% +210 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 254.59 kB +0.09% +210 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 264.11 kB +0.08% +210 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 267.5 kB +0.08% +210 B 🔺
@sentry/nextjs (client) 48.44 kB - -
@sentry/sveltekit (client) 44.09 kB - -
@sentry/node-core 57.94 kB +0.02% +7 B 🔺
@sentry/node 174.78 kB +0.01% +7 B 🔺
@sentry/node - without tracing 97.89 kB +0.03% +21 B 🔺
@sentry/aws-serverless 115.12 kB +0.01% +8 B 🔺

View base workflow run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 replayStart when Replay recording initializes, and emits replayEnd when 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;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
sessionId?: string;
sessionId: string;

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +45
* 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';
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +871 to 878
if (this.session) {
getClient()?.emit('replayStart', {
sessionId: this.session.id,
recordingMode: this.recordingMode,
});
}

this.startRecording();
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
});
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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' });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47e2074. Configure here.

logaretm and others added 2 commits April 16, 2026 14:55
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add lifecycle event hooks (on('start') / on('stop')) to notify when replay state changes

3 participants