From a20440fe57f44283df8f01601d41ed63f0a2f3e1 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 11:00:46 +0200 Subject: [PATCH 1/5] fix(replay): Skip buffer-mode replay capture when rate-limited (DART-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) --- .../replay/capture/BufferCaptureStrategy.kt | 14 ++++++++++++++ .../replay/capture/BufferCaptureStrategyTest.kt | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index f6c6f3997ae..8d94ca01ad9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory.All +import io.sentry.DataCategory.Replay import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.SentryLevel.DEBUG @@ -76,6 +78,13 @@ internal class BufferCaptureStrategy( } override fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) { + if (isReplayRateLimited()) { + // 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 + } + val sampled = random.sample(options.sessionReplay.onErrorSampleRate) if (!sampled) { @@ -170,6 +179,11 @@ internal class BufferCaptureStrategy( rotateEvents(currentEvents, bufferLimit) } + private fun isReplayRateLimited(): Boolean = + scopes?.rateLimiter?.let { + it.isActiveForCategory(All) || it.isActiveForCategory(Replay) + } == true + private fun deleteFile(file: File?) { if (file == null) { return diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index b5048e856ff..1adcc7bc4dc 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Compan import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import io.sentry.util.Random import java.io.File import kotlin.test.Test @@ -336,6 +337,22 @@ class BufferCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) } + @Test + fun `captureReplay does nothing when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + // neither the current nor the buffered segment should be sent while rate-limited + verify(fixture.scopes, never()).captureReplay(any(), any()) + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + } + @Test fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false From 8a92042879c85d8c2e28a8cede887f571fdfab02 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 22 Jul 2026 11:01:31 +0200 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b74388723..77b364b75c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) - Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) From 3033fc0234cc733e60d5fcd9f103c5dcec10825d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 17:08:55 +0200 Subject: [PATCH 3/5] fix(replay): Record a lost replay event when buffer capture is rate-limited (DART-313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../replay/capture/BufferCaptureStrategy.kt | 20 +++++--- .../capture/BufferCaptureStrategyTest.kt | 49 ++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 8d94ca01ad9..73bfd699532 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -19,6 +19,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.ReplayRunnable import io.sentry.android.replay.util.sample +import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -78,13 +79,6 @@ internal class BufferCaptureStrategy( } override fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) { - if (isReplayRateLimited()) { - // 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 - } - val sampled = random.sample(options.sessionReplay.onErrorSampleRate) if (!sampled) { @@ -109,6 +103,18 @@ internal class BufferCaptureStrategy( return } + if (isReplayRateLimited()) { + // 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") + // one lost event per flush, not per segment: the transport would have counted the current + // 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) + return + } + createCurrentSegment("capture_replay") { segment -> bufferedSegments.capture() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 1adcc7bc4dc..fd6de12e3f8 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -2,9 +2,12 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.DataCategory import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeHeader import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.DefaultReplayBreadcrumbConverter @@ -17,6 +20,8 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider @@ -94,6 +99,16 @@ class BufferCaptureStrategyTest { bitRate = 20_000, ) + // client report counts are only readable by draining them onto an envelope + fun discardedEvents(): List = + options.clientReportRecorder + .attachReportToEnvelope(SentryEnvelope(SentryEnvelopeHeader(), emptyList())) + .items + .firstOrNull() + ?.getClientReport(options.serializer) + ?.discardedEvents + .orEmpty() + fun getSut( onErrorSampleRate: Double = 1.0, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), @@ -338,7 +353,7 @@ class BufferCaptureStrategyTest { } @Test - fun `captureReplay does nothing when rate-limited`() { + fun `captureReplay does not capture segments when rate-limited`() { val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) val strategy = fixture.getSut() @@ -350,7 +365,37 @@ class BufferCaptureStrategyTest { // neither the current nor the buffered segment should be sent while rate-limited verify(fixture.scopes, never()).captureReplay(any(), any()) - assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + // the replayId is still set on the scope so the error that flushed the buffer stays linked to + // the replay that gets recorded once the rate limit lifts + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay records a lost replay event when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) {} + + val discarded = fixture.discardedEvents() + assertEquals(1, discarded.size) + assertEquals(DiscardReason.RATELIMIT_BACKOFF.reason, discarded.first().reason) + assertEquals(DataCategory.Replay.category, discarded.first().category) + } + + @Test + fun `captureReplay does not record a lost replay event when not rate-limited`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertTrue(fixture.discardedEvents().none { it.category == DataCategory.Replay.category }) } @Test From 91a65bbcf63307693d08b1ee9c81b6fb8117ab3c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 17:10:55 +0200 Subject: [PATCH 4/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b364b75c3..83c557cd015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) + - These skipped replays are now reported as `ratelimit_backoff` discarded events in client reports, so they no longer disappear from drop statistics. One event is recorded per buffer flush rather than per segment. - Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) From 71759d704eaada13622e73780707290c610ea346 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 17:56:02 +0200 Subject: [PATCH 5/5] fix(replay): Keep buffer mode while rate-limited (DART-313) 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) --- CHANGELOG.md | 1 + .../android/replay/capture/BufferCaptureStrategy.kt | 7 +++++++ .../replay/capture/BufferCaptureStrategyTest.kt | 13 +++++++++++++ 3 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c557cd015..6fe0884a820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Skip encoding and capturing buffered session replay segments while rate-limited, so we don't waste resources on envelopes the transport will drop ([#5813](https://github.com/getsentry/sentry-java/pull/5813)) - These skipped replays are now reported as `ratelimit_backoff` discarded events in client reports, so they no longer disappear from drop statistics. One event is recorded per buffer flush rather than per segment. + - Buffer mode is also kept while rate-limited instead of switching to session mode, so the rolling buffer stays warm and the next error after the rate limit expires can send a complete replay. - Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 73bfd699532..1d7bd1c1126 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -166,6 +166,13 @@ internal class BufferCaptureStrategy( ) return this } + if (isReplayRateLimited()) { + // captureReplay skipped the flush, so there is nothing to continue in session mode. Staying + // in buffer mode keeps the rolling buffer warm, so the next error after the rate limit + // expires can send a complete replay starting at segment 0. + options.logger.log(DEBUG, "Not converting to session mode, because replay is rate-limited") + return this + } // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of // execution val captureStrategy = diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index fd6de12e3f8..fc1981a84b1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -255,6 +255,19 @@ class BufferCaptureStrategyTest { assertTrue(converted is BufferCaptureStrategy) } + @Test + fun `convert stays in buffer mode when rate-limited`() { + val rateLimiter = mock { on { isActiveForCategory(any()) }.thenReturn(true) } + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + val strategy = fixture.getSut() + strategy.start() + + strategy.captureReplay(false) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut()