Skip to content

fix(replay): Prevent rrweb recording from dying when the emit callback throws#21409

Merged
Lms24 merged 3 commits into
developfrom
fix/replay-recording-resilience
Jul 9, 2026
Merged

fix(replay): Prevent rrweb recording from dying when the emit callback throws#21409
Lms24 merged 3 commits into
developfrom
fix/replay-recording-resilience

Conversation

@billyvg

@billyvg billyvg commented Jun 9, 2026

Copy link
Copy Markdown
Member

rrweb session-replay recording could stop entirely while the rest of the Replay integration kept working — breadcrumbs, network, and console events continued and segments kept flushing, so it looked like "recording just froze." This hardens the emit callback so a throw inside it can no longer escape back into rrweb and tear recording down.

Root cause

Every rrweb event type flows through the single getHandleRecordingEmit callback, which had no try/catch. Because Sentry's rrweb errorHandler returns undefined, rrweb re-throws any error that escapes the callback (callbackWrapper), which can tear down recording or leave the mutation buffer permanently locked.

Changes

  • handleRecordingEmit.ts: wrap the emit callback body in try/catchhandleException, so an exception can never escape back into rrweb.

Includes a real-rrweb integration reproduction of the upstream lock hazard plus unit coverage of the emit guard.

Follow-up

  • A separate fix in the @sentry-internal/rrweb fork (wrap takeFullSnapshot's lock/unlock in try/finally, and unlock on the !node early-return) will close the "stuck locked" hazard at the source.
  • The flush-time watchdog that restarts a recorder which died while the integration is still enabled (_ensureRecordingIsRunning + bounded retry) was split out of this PR to keep the scope focused and will follow separately.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.59 kB - -
@sentry/browser - with treeshaking flags 26.03 kB - -
@sentry/browser (incl. Tracing) 46.34 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 48.13 kB - -
@sentry/browser (incl. Tracing, Profiling) 51.12 kB - -
@sentry/browser (incl. Tracing, Replay) 85.62 kB +0.01% +3 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 75.26 kB +0.02% +8 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 90.32 kB +0.01% +6 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 102.97 kB +0.01% +2 B 🔺
@sentry/browser (incl. Feedback) 44.76 kB - -
@sentry/browser (incl. sendFeedback) 32.38 kB - -
@sentry/browser (incl. FeedbackAsync) 37.51 kB - -
@sentry/browser (incl. Metrics) 28.67 kB - -
@sentry/browser (incl. Logs) 28.91 kB - -
@sentry/browser (incl. Metrics & Logs) 29.59 kB - -
@sentry/react 29.38 kB - -
@sentry/react (incl. Tracing) 48.61 kB - -
@sentry/vue 33.03 kB - -
@sentry/vue (incl. Tracing) 48.24 kB - -
@sentry/svelte 27.61 kB - -
CDN Bundle 30 kB - -
CDN Bundle (incl. Tracing) 48.32 kB - -
CDN Bundle (incl. Logs, Metrics) 31.57 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 49.64 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 70.81 kB +0.01% +5 B 🔺
CDN Bundle (incl. Tracing, Replay) 85.84 kB +0.01% +2 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 87.14 kB +0.01% +3 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 91.64 kB +0.01% +4 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.92 kB +0.01% +2 B 🔺
CDN Bundle - uncompressed 89.35 kB - -
CDN Bundle (incl. Tracing) - uncompressed 146.1 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 94.05 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 150.07 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.78 kB +0.02% +35 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 265.3 kB +0.02% +35 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 269.26 kB +0.02% +35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 279 kB +0.02% +35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 282.95 kB +0.02% +35 B 🔺
@sentry/nextjs (client) 51.16 kB - -
@sentry/sveltekit (client) 46.75 kB - -
@sentry/core/server 78.42 kB - -
@sentry/core/browser 64.74 kB - -
@sentry/node-core 62.72 kB +0.01% +1 B 🔺
@sentry/node 125.33 kB - -
@sentry/node (incl. diagnostics channel injection) 134.97 kB - -
@sentry/node/import (ESM hook with diagnostics-channel injection) 69.95 kB - -
@sentry/node/light 50.72 kB -0.01% -1 B 🔽
@sentry/node - without tracing 74.05 kB +0.01% +1 B 🔺
@sentry/aws-serverless 85.5 kB -0.01% -1 B 🔽
@sentry/cloudflare (withSentry) - minified 181.71 kB - -
@sentry/cloudflare (withSentry) 449.16 kB - -

View base workflow run

@billyvg billyvg self-assigned this Jun 11, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This pull request has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you apply the label PR: no-auto-close I will leave it alone ... forever!

rrweb recording could die while the rest of the Replay integration kept
working (breadcrumbs/network/console continued and segments kept flushing),
so it looked like recording just froze.

Every rrweb event flows through the `getHandleRecordingEmit` callback, which
had no try/catch. Because our rrweb `errorHandler` returns `undefined`, rrweb
re-throws anything that escapes the callback and can tear down recording or
leave the mutation buffer permanently locked. Separately, the buffer->session
conversion tears down rrweb and restarts it; if that restart failed (rrweb's
`record()` can throw or silently return `undefined`), `_stopRecording` was left
unset with no recovery while the integration stayed enabled.

- Wrap the emit callback so an exception can never escape back into rrweb.
- Detect a missing recorder after `startRecording()` and surface it.
- Add a bounded flush-time watchdog that restarts a recorder which died while
  the integration is still enabled and unpaused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@billyvg billyvg changed the title fix(replay): Recover when rrweb recording silently stops fix(replay): Prevent rrweb recording from dying when the emit callback throws Jul 8, 2026
@billyvg
billyvg force-pushed the fix/replay-recording-resilience branch from ba7e40f to 2379448 Compare July 8, 2026 21:59
@billyvg
billyvg marked this pull request as ready for review July 8, 2026 22:13
@billyvg
billyvg requested a review from a team as a code owner July 8, 2026 22:13
@billyvg
billyvg requested review from Lms24, logaretm and mydea and removed request for a team July 8, 2026 22:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 1280dd6. Configure here.

});
} catch (error) {
replay.handleException(error);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

First-event flag after failed emit

Medium Severity

The emit callback sets hadFirstEvent to true before mirror sync, click handling, and addUpdate run. When the new try/catch swallows a throw from those steps, recording continues but the failed event was never buffered while hadFirstEvent stays true. Later events may no longer be treated as the first checkout unless rrweb passes _isCheckout, skipping checkout-only work like buffer resets, settings meta, and session-mode flush.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1280dd6. Configure here.

// `_isCheckout` is only set when the checkout is due to `checkoutEveryNms`
// We also want to treat the first event as a checkout, so we handle this specifically here
const isCheckout = _isCheckout || !hadFirstEvent;
hadFirstEvent = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The hadFirstEvent flag is set prematurely. If the first event fails to process, subsequent events won't be treated as the initial checkout, potentially missing the initial snapshot.
Severity: LOW

Suggested Fix

Move the line this.hadFirstEvent = true; to the end of the try block, after this.replay.addUpdate(). This ensures the flag is only set after the first event has been successfully processed and added to the replay buffer, preventing a race condition where a failed first event corrupts the state.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/replay-internal/src/util/handleRecordingEmit.ts#L41

Potential issue: In `handleRecordingEmit`, the `hadFirstEvent` flag is set to `true` at
the beginning of the `try` block. If an error occurs later in the block, such as in
`syncMirrorAttributesFromMutationEvent` or `replay.addUpdate()`, the event processing
fails, but the flag remains `true`. Consequently, the next event will not be treated as
the first event (a "checkout"), which can lead to the initial recording segment being
flushed without a snapshot. While the system has mitigations, such as periodic checkouts
and logic to handle segments without checkouts, this race condition can still result in
a recording segment missing its initial state for a period of time, particularly in
buffer mode.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

technically valid, but i want to keep the scope of this pr low.

@Lms24
Lms24 merged commit 9f254fa into develop Jul 9, 2026
212 checks passed
@Lms24
Lms24 deleted the fix/replay-recording-resilience branch July 9, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants