Skip to content

fix(replay): Skip buffer-mode replay capture when rate-limited (DART-313) - #5813

Open
runningcode wants to merge 5 commits into
mainfrom
no/replay-skip-encode-when-rate-limited
Open

fix(replay): Skip buffer-mode replay capture when rate-limited (DART-313)#5813
runningcode wants to merge 5 commits into
mainfrom
no/replay-skip-encode-when-rate-limited

Conversation

@runningcode

@runningcode runningcode commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

📜 Description

In buffer (on-error) session replay mode, the recorder intentionally keeps running while the SDK is rate-limited so the rolling buffer stays warm. However, when an error triggered captureReplay, the strategy still encoded the current segment and every buffered segment and handed the resulting envelopes to the transport — which then discarded them because of the active rate limit. That wasted CPU, disk I/O, and MediaMuxer file descriptors on replays that could never be sent.

This adds a guard at the top of BufferCaptureStrategy.captureReplay: if the Replay (or All) data category is rate-limited, we skip encoding and capturing entirely. This mirrors the guard session mode already applies via onRateLimitChanged/checkCanRecord. The buffer keeps rolling cheaply in the background and captures normally once the rate limit expires.

💡 Motivation and Context

Follow-up to the session replay resource-leak work in #5583 and #5607. Those fixed the MediaMuxer FD leak during encoding; this closes the remaining item called out in getsentry/sentry-dart#3528 (getsentry/sentry-dart#3528): while rate-limited, the Android SDK kept creating and encoding segments that were only going to be discarded.

💚 How did you test it?

Added a unit test (captureReplay does nothing when rate-limited) asserting that, even with a buffered segment present, no segment is sent to the transport and no replayId is written to the scope while rate-limited.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

Optionally extend the same guard to the pause and onConfigurationChanged encode paths in buffer mode if that churn proves significant.

…313)

In buffer (on-error) mode the recorder keeps running while rate-limited so
the rolling buffer stays warm, but capturing on an error still encoded the
current and buffered segments and handed them to the transport, which then
dropped them. That wasted CPU, I/O, and MediaMuxer file descriptors on
envelopes that could never be sent.

Bail out of BufferCaptureStrategy.captureReplay when the Replay (or All)
category is rate-limited, mirroring the guard session mode already applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

DART-313

@sentry

sentry Bot commented Jul 22, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.50.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 310.41 ms 355.90 ms 45.49 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
05aa61d 326.06 ms 385.46 ms 59.40 ms
bb0ff41 315.84 ms 350.76 ms 34.92 ms
806307f 357.85 ms 424.64 ms 66.79 ms
d501a7e 307.33 ms 341.94 ms 34.61 ms
0ee65e9 321.06 ms 361.24 ms 40.18 ms
ed33deb 334.19 ms 362.30 ms 28.11 ms
9fbb112 401.87 ms 515.87 ms 114.00 ms
b8bd880 314.56 ms 336.50 ms 21.94 ms
5b1a06b 315.40 ms 353.33 ms 37.94 ms
6edfca2 316.43 ms 398.90 ms 82.46 ms

App size

Revision Plain With Sentry Diff
05aa61d 0 B 0 B 0 B
bb0ff41 0 B 0 B 0 B
806307f 1.58 MiB 2.10 MiB 533.42 KiB
d501a7e 0 B 0 B 0 B
0ee65e9 0 B 0 B 0 B
ed33deb 1.58 MiB 2.13 MiB 559.52 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
b8bd880 1.58 MiB 2.29 MiB 722.92 KiB
5b1a06b 0 B 0 B 0 B
6edfca2 1.58 MiB 2.13 MiB 559.07 KiB

Previous results on branch: no/replay-skip-encode-when-rate-limited

Startup times

Revision Plain With Sentry Diff
80c59fb 317.33 ms 342.69 ms 25.36 ms
a28aa98 321.85 ms 368.79 ms 46.94 ms

App size

Revision Plain With Sentry Diff
80c59fb 0 B 0 B 0 B
a28aa98 0 B 0 B 0 B

@runningcode
runningcode marked this pull request as ready for review July 24, 2026 11:05
}

override fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) {
if (isReplayRateLimited()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

h: I think we're calling this a bit early -- should likely do it after the if (isTerminating) check, otherwise we'd be dropping replays for crashed events (which are anyway not being sent in this process, but only on next launch, where the rate limit likely will have already been lifted).

Another side effect of not setting isTerminating would be that the following convert() call would convert to session mode and try to keep recording while the process is terminating. Probs, needs a test for this to not regress in the future too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point!

// the segment envelopes would be dropped by the transport anyway, so don't waste resources
// encoding videos that will only be discarded
options.logger.log(INFO, "Replay is rate-limited, not capturing for event")
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: another thing -- previously we'd record a client report with the reason ratelimit_backoff on the envelope being dropped, but now we're not doing it. I think after addressing the point above (moving this to after if (isTerminating), we could also record the ratelimit_backoff client report for a genuinely lost event (which would be also called after sampling) inside the if (isReplayRateLimited()) block.

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

pre-approving, but 2 things I'd want to have addressed

runningcode and others added 2 commits July 28, 2026 17:08
…imited (DART-313)

Skipping the encode when rate-limited meant the segments never reached the
transport, so RateLimiter.filter never recorded them as lost. Replay drops in
buffer mode silently vanished from client reports.

Record a RATELIMIT_BACKOFF lost event for the Replay category at the bail-out,
and move the rate-limit check below the sampling and isTerminating guards so we
only report replays that would genuinely have been sent — a replay dropped by
onErrorSampleRate is not a rate-limit loss, and a terminating one is deferred to
the next launch rather than lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// segment plus every buffered one, but a flush only ever loses a single replay from the
// user's perspective. Under-reporting here is preferable to making replay look like it
// dropped data it never held.
options.clientReportRecorder.recordLostEvent(RATELIMIT_BACKOFF, Replay)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@romtsn is this what you were thinking? Is it okay that we only report 1 lost event here even if there are more in the buffer? This is something my clanker said was a possibility.

@romtsn romtsn Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yeah, theoretically we could've used bufferedSegments.size + 1 here, but I agree it's better to under-report because for users it looks like a single replay lost, so let's keep it as-is

@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 91a65bb. Configure here.

Bailing out of BufferCaptureStrategy.captureReplay while rate-limited left
isTerminating unset, so ReplayIntegration's unconditional convert() still
swapped in a SessionCaptureStrategy. That discarded the rolling buffer, and
the next recorded frame then hit checkCanRecord(), which pauses session mode
when rate-limited and encodes a segment on the way out - exactly the work the
bail-out was meant to avoid. It also left recording paused for the rest of the
rate-limit window, contradicting onRateLimitChanged, which deliberately keeps
buffer mode running.

Stay in buffer mode while rate-limited so the buffer keeps rolling and the
next error after the limit expires can send a complete replay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

double-checked everything, it all makes sense, approving!

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.

2 participants