Repository navigation
feat(flutter): Add experimental Session Replay for Flutter Web - #4059
lucas-zimerman wants to merge 55 commits into
Conversation
Enable an internal Flutter Web option for JS Replay canvas recording and attach the active replay ID to Dart events sent through the web transport. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror Dart scope mutations into the browser JS SDK on web so native JavaScript events receive Flutter scope data. Emit replay-compatible breadcrumbs for Flutter cases that the JS replay bundle filters from its generic breadcrumb handler. Co-Authored-By: GPT-5.5 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Capture masked frames with the Flutter screenshot recorder and draw them onto a shadow canvas that the JS SDK's replay canvas integration snapshots manually. Flutter's own canvas is blocked by selector, since it lives in flt-glass-pane's shadow DOM. - Register ReplayIntegration on web; the JS SDK handles error mode - Map replay quality to a web capture scale (0.5x/0.7x/1.0x) - Drop the JS SDK's flutter-view click breadcrumbs in replays - Enable web replay from the replay sample rates instead of the internal enableWebCanvasRecording flag - Add a replay playground screen to the example app Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. Features
Fixes
DependenciesDeps
Internal Changes
🤖 This preview updates automatically when you update the PR. |
🚨 Detected changes in high risk code 🚨High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4059 +/- ##
==========================================
+ Coverage 88.81% 93.08% +4.26%
==========================================
Files 348 118 -230
Lines 12631 4713 -7918
==========================================
- Hits 11218 4387 -6831
+ Misses 1413 326 -1087
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
iOS Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5367726 | 1211.00 ms | 1216.73 ms | 5.73 ms |
| c492ba7 | 1248.33 ms | 1258.42 ms | 10.09 ms |
| 7b21e8b | 1256.79 ms | 1267.12 ms | 10.33 ms |
| 827bf09 | 1261.86 ms | 1276.41 ms | 14.55 ms |
| 0bea8d9 | 1234.39 ms | 1239.59 ms | 5.20 ms |
| 819c1e7 | 1250.59 ms | 1249.08 ms | -1.51 ms |
| e04b24b | 1230.22 ms | 1233.90 ms | 3.67 ms |
| 3801d52 | 1267.76 ms | 1266.10 ms | -1.65 ms |
| af26bcd | 1243.98 ms | 1243.49 ms | -0.49 ms |
| 98188da | 1252.31 ms | 1251.41 ms | -0.90 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 5367726 | 5.73 MiB | 6.17 MiB | 455.87 KiB |
| c492ba7 | 5.91 MiB | 6.35 MiB | 449.92 KiB |
| 7b21e8b | 5.53 MiB | 6.00 MiB | 479.96 KiB |
| 827bf09 | 7.86 MiB | 9.44 MiB | 1.58 MiB |
| 0bea8d9 | 5.73 MiB | 6.18 MiB | 463.52 KiB |
| 819c1e7 | 5.53 MiB | 6.00 MiB | 479.96 KiB |
| e04b24b | 5.53 MiB | 6.00 MiB | 480.00 KiB |
| 3801d52 | 5.73 MiB | 6.17 MiB | 455.54 KiB |
| af26bcd | 5.83 MiB | 6.28 MiB | 460.44 KiB |
| 98188da | 5.83 MiB | 6.28 MiB | 459.62 KiB |
Previous results on branch: lz/replay/web-mask
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| e749de6 | 1253.47 ms | 1263.08 ms | 9.61 ms |
| c5b01dc | 1247.21 ms | 1251.60 ms | 4.40 ms |
| fd971e7 | 1255.60 ms | 1261.22 ms | 5.62 ms |
| f85c15f | 1254.51 ms | 1248.76 ms | -5.76 ms |
| 1dd802b | 1256.45 ms | 1266.90 ms | 10.45 ms |
| c26554e | 1241.11 ms | 1250.79 ms | 9.68 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| e749de6 | 5.91 MiB | 6.35 MiB | 450.57 KiB |
| c5b01dc | 5.91 MiB | 6.35 MiB | 450.66 KiB |
| fd971e7 | 5.91 MiB | 6.35 MiB | 450.71 KiB |
| f85c15f | 5.91 MiB | 6.35 MiB | 450.71 KiB |
| 1dd802b | 5.91 MiB | 6.35 MiB | 450.72 KiB |
| c26554e | 5.91 MiB | 6.35 MiB | 450.57 KiB |
|
Will target main once #4056 is merged. |
The replay playground has its own PR (#4056). This PR now targets that branch, so the example still has the playground for testing without duplicating it here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Script loader: track scripts as they load and remove them if a later one fails, so close() cleans up and a retry doesn't inject duplicates - SentryWeb.close(): await the replay recorder's stop before closing the JS SDK, so an in-flight capture can't snapshot into it - setReplayConfig: mark the recorder started only after start() succeeds, so a failed start is retried on the next config Adds a createReplayCanvasBridge seam to SentryWeb for testing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # packages/flutter/lib/src/replay/integration.dart # packages/flutter/lib/src/screen_capture/recorder.dart # packages/flutter/lib/src/sentry_flutter.dart # packages/flutter/lib/src/web/script_loader/sentry_script_loader.dart # packages/flutter/lib/src/web/sentry_js_sdk_version.dart # packages/flutter/lib/src/web/sentry_web.dart # packages/flutter/lib/src/web/web_sentry_js_binding.dart # packages/flutter/test/sentry_flutter_test.dart # packages/flutter/test/web/sentry_web_test.dart # packages/flutter/test/web/web_sentry_js_binding_test.dart
…lz/replay/web-mask
A window drag or rotation changes the replay config every frame, and each change restarted the capture scheduler, which captures on the next frame. The shadow canvas was resized at frame rate while rrweb throttled the matching snapshots, so recordings got canvas sizes out of sync with their content. While running with an unchanged frame rate, store the new config without restarting: capture() reads it on every run, so the next scheduled capture already uses the new size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sentry's replay player clones a canvas on its first recorded draw and keeps drawing into that clone, so later width/height changes never reach it. After a resize, smaller frames only covered the clone's top-left corner, leaving the previous frame around them (duplicated widgets) and squishing content to one side. Once the shadow canvas has been snapshotted, a size change now swaps in a new element, which the player clones fresh at the new size. Its first snapshot waits a task, until rrweb has registered the node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| c58ce03 | 469.42 ms | 472.40 ms | 2.98 ms |
| 9c9cb8e | 360.30 ms | 373.57 ms | 13.27 ms |
| e3f3ea1 | 420.16 ms | 436.54 ms | 16.39 ms |
| e200a70 | 433.06 ms | 437.00 ms | 3.94 ms |
| d8f332d | 365.31 ms | 349.98 ms | -15.33 ms |
| 9993fa9 | 389.44 ms | 390.85 ms | 1.41 ms |
| 5fc86f6 | 347.73 ms | 334.13 ms | -13.59 ms |
| 25ed82c | 363.31 ms | 352.83 ms | -10.48 ms |
| 6b69699 | 456.06 ms | 557.44 ms | 101.38 ms |
| 5a95d04 | 378.92 ms | 364.33 ms | -14.59 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| c58ce03 | 14.31 MiB | 15.49 MiB | 1.19 MiB |
| 9c9cb8e | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| e3f3ea1 | 13.93 MiB | 15.18 MiB | 1.25 MiB |
| e200a70 | 13.93 MiB | 15.18 MiB | 1.25 MiB |
| d8f332d | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| 9993fa9 | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| 5fc86f6 | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| 25ed82c | 14.55 MiB | 15.87 MiB | 1.31 MiB |
| 6b69699 | 6.54 MiB | 7.70 MiB | 1.17 MiB |
| 5a95d04 | 13.93 MiB | 15.06 MiB | 1.13 MiB |
Previous results on branch: lz/replay/web-mask
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f85c15f | 373.65 ms | 392.72 ms | 19.07 ms |
| c26554e | 374.06 ms | 376.54 ms | 2.48 ms |
| 1dd802b | 378.25 ms | 376.87 ms | -1.38 ms |
| fd971e7 | 378.36 ms | 391.00 ms | 12.64 ms |
| e749de6 | 370.47 ms | 369.67 ms | -0.80 ms |
| c5b01dc | 388.46 ms | 424.62 ms | 36.16 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f85c15f | 14.77 MiB | 16.08 MiB | 1.31 MiB |
| c26554e | 14.77 MiB | 16.08 MiB | 1.31 MiB |
| 1dd802b | 14.77 MiB | 16.08 MiB | 1.31 MiB |
| fd971e7 | 14.77 MiB | 16.08 MiB | 1.31 MiB |
| e749de6 | 14.77 MiB | 16.08 MiB | 1.31 MiB |
| c5b01dc | 14.77 MiB | 16.02 MiB | 1.25 MiB |
SentryFlutter.replay's start, startBuffering, stop and flush now drive the JS SDK's replay on web instead of logging "not supported", and start or stop the Flutter recorder with it. A stopped replay stays stopped across config changes (e.g. a window resize) until started again. pause/resume remain no-ops: the JS SDK can't pause a replay. The real-binding tests clear the JS SDK's stored replay session first; a session left by another test on the same origin resumed in `session` mode, so stop() hung uploading to the fake DSN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tap breadcrumbs carry the widget's label and the labels in its touch path when sendDefaultPii is on. The replay copy kept them, so text the replay video masks showed up in the replay timeline. Strip them from the replay breadcrumb only; the event breadcrumb is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assert the production script lists in release mode, with and without replay; always picking the debug lists used to pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dart events reach the JS SDK through client.sendEnvelope(), which skips the afterSendEvent hook that client.sendEvent() runs. Replay switches a buffering replay to a session from that hook, so with onErrorSampleRate a Dart error never uploaded the buffered replay; only JS errors did. Pass the events and transactions in each envelope to the binding, which emits afterSendEvent for them once the envelope is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Catch errors from the deferred snapshot of a replaced replay canvas; it runs outside the recorder's error handling. - close() closes the JS SDK even if stopping the recorder fails. - stopReplay() during an in-flight recorder start now stops the recorder once the start completes, instead of leaving it capturing. Tests: wrap the canvas bridge tests in one subject group, match JS integration names exactly, and close the SDK after replay-enabled mock tests so their schedulers stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The cadence test measures from a rendered frame to the next capture instead of waiting a fixed 50ms, so a slow capture can't hide a scheduler restart. - Pin the replay block selector as a literal, not the production constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If stopping the replay recorder threw, stopReplay() skipped stopping the JS replay and left the recorder marked as started, so a later startReplay() never restarted it. Always stop the JS replay, and mark the recorder stopped before stopping it. Also folds the replay controls test group into its parent to keep test nesting within three levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Will link to captured replays tomorrow. |
Replay start, stop, flush, config changes and close now run one at a time in call order, replacing the flags that tracked their interleavings. Document the JS SDK behavior the web replay relies on, and model buffer mode in the event processor test fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buenaflor
left a comment
There was a problem hiding this comment.
Pls check and resolve the AI reviews first
- Always stop the web replay recorder on close, so a failed start doesn't leave the shadow canvas in the DOM. - Add ReplayTelemetryIntegration on web and check replay support when it runs: the replay sample rates are set after the default integrations are created. - Read back the captured image before yielding to the event loop. - Pin a supported renderer in the replay script loading tests. Co-Authored-By: Lucas Zimerman Fraulob <lzfstudio+sentry@hotmail.com>
Nothing puts the JS replay's ID on the Dart scope, so ReplayTelemetryIntegration marked every web replay as buffering. Bindings can now report whether the replay is buffering; web asks the JS Replay for its recording mode, as the JS SDK does for sentry._internal.replay_is_buffering. Native bindings return null and keep inferring it from the scope.
Replace the microtask spin loops waiting for bridgeStart with a Completer on the fake bridge, bounded by a timeout, so a start that never runs fails the test instead of hanging it.
A rejected client.sendEnvelope() drops the envelope, so log it at error level with its stack trace instead of debug. Also tighten the web replay tests: pin the shadow canvas id as a literal and check that the block selector spares it while matching other canvases, and keep the real-binding replay tests in buffer mode so they never upload to the test DSN.
Enable replay in the attachment-only envelope test, so event collection is on and the empty-events check means something. Assert that the manual canvas integration has enableManualSnapshot set.
Flutter Web reports inactive when a visible page loses focus. The resumed-only capture guard, meant for native GPU surface teardown, froze the replay canvas then. Let the web recorder also capture while inactive.
Rename web replay tests to plain verb phrases, split the scope payload test per method, pin the JS SDK name and replay-canvas URLs as literals, and clarify the isReplayBuffering null contract. Co-Authored-By: Lucas Zimerman Fraulob <lzfstudio+sentry@hotmail.com>
buenaflor
left a comment
There was a problem hiding this comment.
1st review pass, so far looks good!
- Clear the shadow canvas before drawing each frame so transparent pixels don't keep the previous frame - Pick the replay bundle from the binding's supportsReplay - Drop the unused name-based replayCanvas integration - Add a regression test feeding a real JS SDK flutter-view click through the replay click filter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop the no-replay script test, already covered by the default supportsReplay stub - Name the click filter tests with verb phrases Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buenaflor
left a comment
There was a problem hiding this comment.
as discussed let's add an opt-in flag and mark it experimental
feel free to add some ideas on the naming of the flag
maybe smth like "enableWebReplay"?
|
enableWebReplay goes straight to the point, can't think of a better name :D |
Add the experimental `options.replay.enableWebReplay` flag, off by default. Web Session Replay now only starts when it is set alongside a replay sample rate; otherwise init logs how to enable it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 13bfebd. Configure here.
| if (breadcrumb.message != null) 'message': breadcrumb.message, | ||
| if (breadcrumb.category != null) 'category': breadcrumb.category, | ||
| if (breadcrumb.data?.isNotEmpty ?? false) | ||
| 'data': normalizeMap(breadcrumb.data), |
There was a problem hiding this comment.
Replay path labels survive normalization
Medium Severity
normalize stringifies each tap-path node because production _getTouchPath returns List<Map<String, String?>>, and Map is invariant, so those nodes are not Map<String, dynamic>. _withoutLabels then never sees maps and cannot drop label. Replay click breadcrumbs keep widget text that this path was written to exclude.
Additional Locations (1)
Triggered by project rule: PR Review Guidelines for Cursor Bot (Root)
Reviewed by Cursor Bugbot for commit 13bfebd. Configure here.


📜 Description
Adds experimental Session Replay for Flutter Web, with the same widget-level masking as mobile. It's opt-in through
options.replay.enableWebReplay(@experimental, off by default):The JS SDK's canvas recording can't mask anything: it screenshots the whole
<canvas>, and blocking is all-or-nothing per element. So instead:ScreenshotRecordercaptures a masked frame (same masking pipeline as mobile).SentryWebReplayRecorderdraws it onto a hidden "shadow" canvas (#sentry-flutter-replay-canvas) placed behind the Flutter view.replayCanvasIntegrationruns withenableManualSnapshot: trueand snapshots only that shadow canvas, when Dart says a frame is ready.block: ['canvas:not(#sentry-flutter-replay-canvas)']. It has to be a selector because the canvas lives inflt-glass-pane's shadow DOM, where tagging elements doesn't work.Other changes:
ReplayIntegrationis registered on web and drives the recorder throughsetReplayConfig. On web, its send-time hook leaves error events to the JS SDK, which sends buffered error replays itself. For feedback,captureReplay()flushes the active JS replay, as the JS SDK's own feedback integration does, because the JS SDK only sends a buffered replay for errors and transactions.WebReplayEventProcessoradds the JS replay's ID to Dart events (replayIdtag and trace context). Dart events reach the JS SDK as raw envelopes, which skip the JS event processors that would add it.NativeScopeObserveris now registered on web, so the Dart user, tags, extras, contexts and breadcrumbs are mirrored into the JS SDK. JS-captured errors now carry the Dart scope too. The JS calls are guarded, so a JS failure can't surface inSentry.addBreadcrumborconfigureScope. The JS SDK also getshttpContextIntegration(URL, user agent and referrer on JS events).canvasReplayerPlugin) draws each recorded canvas into a copy made on its first draw and shows that copy's backing buffer as an image, so the buffer size is what viewers see.ScreenshotRecorder.yieldToEventLoop()is a new hook that is a no-op everywhere except web. There it yields between capture phases so a capture doesn't block the browser's main thread.SentryFlutter.replaycontrols work on web:start,startBuffering,stopandflushdrive the JS SDK's replay and the Flutter recorder together. A stopped replay stays stopped across config changes such as a resize.pause/resumeare no-ops on web, because the JS SDK can't pause a replay.onErrorSampleRate) work for Dart errors. Dart events reach the JS SDK throughclient.sendEnvelope(), which skips theafterSendEventhook that Replay uses to turn a buffering replay into a session, so only JS errors used to trigger it. The binding now emitsafterSendEventfor the events in each envelope once it's sent.body > flutter-viewclick breadcrumbs are dropped from replays. They can't say what was tapped; the Dartflutter.ui.clickbreadcrumb does.Builds on @buenaflor's web canvas replay work (first two commits).
Opt-in:
enableWebReplayWeb replay only starts when
options.replay.enableWebReplayistrueandreplay.sessionSampleRateorreplay.onErrorSampleRateis above 0. The flag is marked@experimental: its API and behavior may change in a minor release. Recording also needs the CanvasKit or Skwasm renderer; with any other renderer the replay bundle isn't loaded and nothing records.Apps that already set a replay sample rate for mobile are unaffected on web until they set the flag: no replay JS bundle is loaded and nothing records. In that case
initlogs a debug message saying how to enable it. Native builds are unaffected: all web replay code sits behinddart.library.js_interopconditional exports.💡 Motivation and Context
Closes #2897
💚 How did you test it?
Sample Replay: https://sentry-sdks.sentry.io/explore/replays/0596cac52a994bdcae6eb2f2d6be33f8/?playlistEnd=2026-10-20T22%3A15%3A00&playlistStart=2026-10-13T22%3A15%3A00&project=5428562&query=&referrer=replayList
SentryWebandWebSdkIntegration.flutter test --platform chrome test/web/(148 passing). Full VM suite passing.enableWebReplayon and off against the built example app in headless Chromium: with the flag on, the replay records insessionmode and uploadsreplay_event/replay_recording; with it off (same sample rates), there's no replay ID, no shadow canvas and no replay envelopes.flutter build web), by intercepting the replay envelopes with Playwright and decoding them:rr_width/rr_height, norr_dataURL).stop/start/flushagainst the real JS SDK in Chrome.onErrorSampleRateset and a Dart error, with Sentry requests answered locally. Before the fix the replay stayed inbufferwith no upload; after it, it switches tosessionand uploadsreplay_event/replay_recording(3/3 runs).📝 Checklist
sendDefaultPiiis enabled🔮 Next steps
flt-semantics-host/flt-text-editing-hostshow up in the recording. They rely only on the JS SDK's default text/input masking and aren't explicitly blocked; this needs a decision.sampling.canvas(1/2/4 fps), even for manual snapshots. It needs a deliberate setting.HtmlElementView). Cross-origin iframes can't be captured at all; that's a browser limit.z-index: -1). An app with a transparent background or a custom host element could show it. Hiding it needs a browser check that rrweb still records it.Changelog Entry
options.replay.enableWebReplay = truealong withreplay.sessionSampleRateorreplay.onErrorSampleRate. Requires the CanvasKit or Skwasm renderer.SentryFlutter.replaystart/stop/flush also work on web, and the Dart scope (user, tags, extras, contexts, breadcrumbs) now syncs to the JS SDK on web (#2897)