fix(replay): Defer buffered replay upload until after error sampling - #6685
Conversation
In buffer (on-error) mode, the replay was flushed inside the beforeSend wrapper. Since @sentry/core 10.70.0 error sampleRate runs AFTER beforeSend, so a replay was uploaded even for errors later dropped by sampleRate, orphaning the replay and consuming quota (#6598). Split the work: link the event to the buffered replay id in beforeSend (read-only, no flush) and flush the native replay in an afterSendEvent hook, which only fires for events that survive sampling. To link before flushing, the current replay id must be readable while a buffer replay is still recording. On Android getCurrentReplayId now prefers ReplayController.getReplayId(); iOS relies on the matching sentry-cocoa change to SentrySDK.internal.replay.replayId. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
04a8df5 to
04f6aa8
Compare
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 04f6aa8. Configure here.
|
Blocking till getsentry/sentry-cocoa#8976 is released |
📲 Install BuildsAndroid
|
iOS (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 0a9e622+dirty | 3835.87 ms | 1221.07 ms | -2614.80 ms |
| 7d6fd3a+dirty | 1223.29 ms | 1229.57 ms | 6.28 ms |
| 5ca03f9+dirty | 3844.55 ms | 1218.36 ms | -2626.19 ms |
| 774257e+dirty | 3846.90 ms | 1215.02 ms | -2631.88 ms |
| 40c9884+dirty | 3837.72 ms | 1226.61 ms | -2611.11 ms |
| 4e0b819+dirty | 3839.05 ms | 1210.75 ms | -2628.30 ms |
| d038a14+dirty | 3845.71 ms | 1228.11 ms | -2617.59 ms |
| 57e0069+dirty | 3842.29 ms | 1212.12 ms | -2630.17 ms |
| 882f8ae+dirty | 3840.30 ms | 1224.41 ms | -2615.88 ms |
| 5125c43+dirty | 3846.45 ms | 1221.12 ms | -2625.32 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 0a9e622+dirty | 4.98 MiB | 6.51 MiB | 1.53 MiB |
| 7d6fd3a+dirty | 3.38 MiB | 4.77 MiB | 1.39 MiB |
| 5ca03f9+dirty | 4.98 MiB | 6.53 MiB | 1.55 MiB |
| 774257e+dirty | 5.15 MiB | 6.70 MiB | 1.54 MiB |
| 40c9884+dirty | 4.98 MiB | 6.51 MiB | 1.53 MiB |
| 4e0b819+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| d038a14+dirty | 5.15 MiB | 6.67 MiB | 1.51 MiB |
| 57e0069+dirty | 4.98 MiB | 6.50 MiB | 1.52 MiB |
| 882f8ae+dirty | 5.15 MiB | 6.70 MiB | 1.54 MiB |
| 5125c43+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
Previous results on branch: antonis/fix-6598-buffer-replay-orphaned
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f55facc+dirty | 3848.98 ms | 1221.90 ms | -2627.07 ms |
| 1fcb382+dirty | 3844.48 ms | 1227.18 ms | -2617.30 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f55facc+dirty | 5.15 MiB | 6.88 MiB | 1.73 MiB |
| 1fcb382+dirty | 5.15 MiB | 6.88 MiB | 1.73 MiB |
Android (legacy) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| a2585ce+dirty | 426.36 ms | 483.26 ms | 56.90 ms |
| 882f8ae+dirty | 399.98 ms | 427.06 ms | 27.08 ms |
| af33f3b+dirty | 434.90 ms | 506.14 ms | 71.24 ms |
| ecf47a2+dirty | 420.40 ms | 458.02 ms | 37.62 ms |
| acd838e+dirty | 422.63 ms | 462.39 ms | 39.76 ms |
| 7fd0012+dirty | 444.73 ms | 470.08 ms | 25.35 ms |
| 580fb5c+dirty | 436.34 ms | 471.63 ms | 35.28 ms |
| 5ca03f9+dirty | 423.30 ms | 467.00 ms | 43.70 ms |
| 5c1e987+dirty | 423.52 ms | 471.64 ms | 48.12 ms |
| 822d35b+dirty | 429.31 ms | 498.04 ms | 68.73 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| a2585ce+dirty | 49.74 MiB | 55.36 MiB | 5.61 MiB |
| 882f8ae+dirty | 48.30 MiB | 53.60 MiB | 5.29 MiB |
| af33f3b+dirty | 49.74 MiB | 55.09 MiB | 5.35 MiB |
| ecf47a2+dirty | 49.74 MiB | 54.82 MiB | 5.07 MiB |
| acd838e+dirty | 48.30 MiB | 53.60 MiB | 5.30 MiB |
| 7fd0012+dirty | 50.56 MiB | 56.46 MiB | 5.90 MiB |
| 580fb5c+dirty | 49.74 MiB | 54.79 MiB | 5.05 MiB |
| 5ca03f9+dirty | 49.74 MiB | 55.26 MiB | 5.52 MiB |
| 5c1e987+dirty | 43.75 MiB | 48.08 MiB | 4.33 MiB |
| 822d35b+dirty | 49.74 MiB | 54.84 MiB | 5.10 MiB |
Previous results on branch: antonis/fix-6598-buffer-replay-orphaned
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1fcb382+dirty | 446.20 ms | 489.64 ms | 43.44 ms |
| f55facc+dirty | 423.85 ms | 450.68 ms | 26.83 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1fcb382+dirty | 50.56 MiB | 56.46 MiB | 5.90 MiB |
| f55facc+dirty | 50.56 MiB | 56.46 MiB | 5.90 MiB |
iOS (new) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 64630e5+dirty | 3845.49 ms | 1215.19 ms | -2630.30 ms |
| b0d3373+dirty | 3842.49 ms | 1218.49 ms | -2624.00 ms |
| b04af96+dirty | 3830.54 ms | 1206.11 ms | -2624.44 ms |
| f9c1ed4+dirty | 3842.09 ms | 1220.70 ms | -2621.40 ms |
| 09a902f+dirty | 3847.65 ms | 1221.31 ms | -2626.34 ms |
| 44abcc2+dirty | 3841.42 ms | 1214.77 ms | -2626.65 ms |
| acd838e+dirty | 3835.94 ms | 1215.87 ms | -2620.07 ms |
| bfba737+dirty | 3834.18 ms | 1222.80 ms | -2611.38 ms |
| ce7b368+dirty | 3851.41 ms | 1222.37 ms | -2629.04 ms |
| 4e0ba9c+dirty | 3856.39 ms | 1234.44 ms | -2621.95 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 64630e5+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| b0d3373+dirty | 5.15 MiB | 6.68 MiB | 1.53 MiB |
| b04af96+dirty | 4.98 MiB | 6.54 MiB | 1.56 MiB |
| f9c1ed4+dirty | 4.98 MiB | 6.50 MiB | 1.53 MiB |
| 09a902f+dirty | 4.98 MiB | 6.46 MiB | 1.49 MiB |
| 44abcc2+dirty | 4.98 MiB | 6.55 MiB | 1.57 MiB |
| acd838e+dirty | 5.15 MiB | 6.70 MiB | 1.55 MiB |
| bfba737+dirty | 4.98 MiB | 6.51 MiB | 1.53 MiB |
| ce7b368+dirty | 4.98 MiB | 6.51 MiB | 1.53 MiB |
| 4e0ba9c+dirty | 5.15 MiB | 6.67 MiB | 1.51 MiB |
Previous results on branch: antonis/fix-6598-buffer-replay-orphaned
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f55facc+dirty | 3839.88 ms | 1227.33 ms | -2612.54 ms |
| 1fcb382+dirty | 3852.24 ms | 1220.86 ms | -2631.38 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| f55facc+dirty | 5.15 MiB | 6.88 MiB | 1.73 MiB |
| 1fcb382+dirty | 5.15 MiB | 6.88 MiB | 1.73 MiB |
Android (new) Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 37a2091+dirty | 429.71 ms | 477.00 ms | 47.29 ms |
| af33f3b+dirty | 417.68 ms | 448.04 ms | 30.37 ms |
| c2e182c+dirty | 468.50 ms | 545.44 ms | 76.94 ms |
| a50b33d+dirty | 353.21 ms | 398.48 ms | 45.27 ms |
| 5ca03f9+dirty | 412.83 ms | 459.40 ms | 46.57 ms |
| f3215d3+dirty | 396.53 ms | 436.66 ms | 40.13 ms |
| 7887847+dirty | 420.47 ms | 460.55 ms | 40.08 ms |
| 23598c3+dirty | 371.92 ms | 420.65 ms | 48.74 ms |
| bc8f61e+dirty | 419.31 ms | 453.39 ms | 34.08 ms |
| 09a902f+dirty | 423.02 ms | 472.18 ms | 49.16 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 37a2091+dirty | 48.30 MiB | 53.58 MiB | 5.28 MiB |
| af33f3b+dirty | 49.74 MiB | 55.09 MiB | 5.35 MiB |
| c2e182c+dirty | 49.74 MiB | 54.85 MiB | 5.11 MiB |
| a50b33d+dirty | 43.94 MiB | 48.94 MiB | 5.00 MiB |
| 5ca03f9+dirty | 49.74 MiB | 55.26 MiB | 5.52 MiB |
| f3215d3+dirty | 48.30 MiB | 53.49 MiB | 5.19 MiB |
| 7887847+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
| 23598c3+dirty | 43.94 MiB | 49.02 MiB | 5.08 MiB |
| bc8f61e+dirty | 49.74 MiB | 55.09 MiB | 5.35 MiB |
| 09a902f+dirty | 49.74 MiB | 54.81 MiB | 5.07 MiB |
Previous results on branch: antonis/fix-6598-buffer-replay-orphaned
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1fcb382+dirty | 426.39 ms | 462.00 ms | 35.61 ms |
| f55facc+dirty | 477.25 ms | 517.52 ms | 40.27 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1fcb382+dirty | 50.56 MiB | 56.46 MiB | 5.90 MiB |
| f55facc+dirty | 50.56 MiB | 56.46 MiB | 5.90 MiB |
…cache Address review findings on the buffered-replay sampling fix: - Android captureReplay now resolves with the scope's replayId (populated only when a replay was actually sent) instead of the controller's buffered id, so an on-error sampling miss resolves null, matching iOS. - getCurrentReplayId still prefers the controller id for linking in beforeSend. - Drop the bounded pending-flush set in favour of keying the flush decision on the event's own linked replay context, removing an eviction race. - Re-read the current recording id when the native flush uploads nothing, so the cache no longer exposes an id that was never uploaded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-replay-orphaned
alwx
left a comment
There was a problem hiding this comment.
One question here (except for review comments): what happens if If two errors survive sampling simultaneously (or near-simultaneously, with a super minor difference in time)?
I think it would mean both will reach afterSendEvent before either flush completes and then both will call captureReplay.
Maybe some sort of guard needs to be added here.
| // sent, so it stays empty while a buffer replay is recording (issue #6598). | ||
| final @NotNull SentryId controllerId = | ||
| Sentry.getCurrentScopes().getOptions().getReplayController().getReplayId(); | ||
| if (controllerId != SentryId.EMPTY_ID) { |
There was a problem hiding this comment.
Shouldn't it be something like that to avoid checking for object equality and check for value instead?
if (!SentryId.EMPTY_ID.equals(controllerId)) {
| // native `replaysOnErrorSampleRate` roll (which still happens at flush time in | ||
| // `captureReplay`). If that roll misses, the event carries a `replay_id` for a | ||
| // replay that is never uploaded. A fully-correct fix requires the native SDKs | ||
| // to decouple the on-error sampling decision from the buffer upload. |
There was a problem hiding this comment.
If that roll misses, the event carries a
replay_idfor a replay that is never uploaded.
Maybe I'm wrong but seems like the fix introduced in this PR still produces orphaned replay_id links when replaysOnErrorSampleRate misses which sounds a bit like a bug, not really a trade off.
IMO it's better to fix it, or at least create an issue instead of writing it down as a block comment in production code.
There was a problem hiding this comment.
Good point 👍 Created an issue to follow up on this #6696
Reference equality (`!=` / `==` against SentryId.EMPTY_ID) would misclassify a freshly-constructed empty SentryId as non-empty, handing JS an all-zeros replay id. Compare by value via SentryId.EMPTY_ID.equals(...) instead, which is also null-safe as the left operand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Good question 👍 I traced both native paths and should be safe. Both errors share the same buffered replayId, and the captureReplay bridge calls run on a single serial thread on each platform (iOS methodQueue = main queue; Android's Promise @ReactMethod = native-modules thread), so they execute in sequence.
Ready for another pass 🙇 |
📢 Type of change
📜 Description
In buffer / on-error Session Replay mode (
replaysOnErrorSampleRate), the buffered replay was flushed inside the mobile-replay integration'sbeforeSendwrapper. Since@sentry/core10.70.0 the errorsampleRateroll runs afterbeforeSend, so a replay was uploaded even for errors that were then dropped bysampleRate— orphaning the replay and burning replay quota.The work is split in two:
tagEventWithReplayId(in thebeforeSendwrapper) only links the event to the current buffered replay id — a read viaNATIVE.getCurrentReplayId(), no flush.flushReplayForSentEvent(in a newafterSendEventclient hook) performs the nativecaptureReplay()flush, and only fires for events that survive sampling and are actually sent.Reading the id before the flush requires the current replay id to be available while a buffer replay is still recording:
getCurrentReplayId()now prefersReplayController.getReplayId()(assigned at record start) and falls back to the scope. Self-contained — sentry-java already exposes it.SentrySDK.internal.replay.replayId(fix(replay): Return buffered replay id from internal hybrid API sentry-cocoa#8976). Untilsentry_cocoa_versioninRNSentry.podspecis bumped to a release containing that fix, on-error replays are not linked or uploaded on iOS. This is the blocker for taking this PR out of draft.💡 Motivation and Context
Fixes #6598.
💚 How did you test it?
mobilereplay.test.tsto assert the new split (no flush inbeforeSend; flush inafterSendEvent).mobilereplay.sampling.test.ts, which drives a real@sentry/coreclient and provessampleRate: 0→captureReplayis never called.RNSentryReplayIdTest.javacovering the controller-firstgetCurrentReplayId()lookup (4 cases).yarn build:sdk,yarn circularDepCheck, oxlint, and Java format all clean.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps