From 5d1abdbee8c619d40764f2383f718531d3d3edbe Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 09:53:47 +0200 Subject: [PATCH 01/13] test(replay): Reproduce missing video when replay stops with queued segments --- .../sentry-uitest-android/build.gradle.kts | 1 + .../sentry-uitest-android/proguard-rules.pro | 1 + .../android/ReplayFeedbackDialogTest.kt | 109 ++++++++++++++++++ .../android/mockservers/RelayAsserter.kt | 9 ++ .../android/mockservers/ReplayVideoPayload.kt | 30 +++++ .../capture/SessionCaptureStrategyTest.kt | 37 ++++++ 6 files changed, 187 insertions(+) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayFeedbackDialogTest.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/ReplayVideoPayload.kt diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 52c17199e4d..9c86829b134 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -124,6 +124,7 @@ dependencies { androidTestImplementation(libs.awaitility3.kotlin) androidTestImplementation(libs.kotlin.test.junit) androidTestImplementation(libs.leakcanary.instrumentation) + androidTestImplementation(libs.msgpack) androidTestImplementation(libs.okhttp.mockwebserver) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 5de2dac4bdb..b7988f3d881 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -36,6 +36,7 @@ -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** +-dontwarn sun.nio.ch.DirectBuffer -dontwarn org.opentest4j.AssertionFailedError -dontwarn org.mockito.internal.** -dontwarn org.jetbrains.annotations.** diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayFeedbackDialogTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayFeedbackDialogTest.kt new file mode 100644 index 00000000000..71ca8000734 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayFeedbackDialogTest.kt @@ -0,0 +1,109 @@ +package io.sentry.uitest.android + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Sentry +import io.sentry.android.core.R +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.uitest.android.mockservers.REPLAY_VIDEO_PART +import io.sentry.uitest.android.mockservers.replayVideoParts +import java.io.File +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import okhttp3.mockwebserver.MockResponse +import org.awaitility.kotlin.await +import org.junit.runner.RunWith + +/** + * Reproduces a customer flow that combines Session Replay with the user feedback form: opening the + * form flushes the replay, and discarding the form stops it. Stopping deletes the replay cache + * directory, while the segments that the flush produced can still be waiting in the transport + * queue. Their videos are read from disk only when the envelope is written to the socket, so a + * deleted directory turns a queued segment into a segment without a video, which relay discards as + * `invalid_replay_video`. + */ +@RunWith(AndroidJUnit4::class) +class ReplayFeedbackDialogTest : BaseUiTest() { + + @Test + fun discardedFeedbackKeepsVideoOfBufferedSegments() { + runDiscardedFeedbackFlow { it.sessionReplay.onErrorSampleRate = 1.0 } + } + + @Test + fun discardedFeedbackKeepsVideoOfSessionSegments() { + runDiscardedFeedbackFlow { it.sessionReplay.sessionSampleRate = 1.0 } + } + + private fun runDiscardedFeedbackFlow(configureReplay: (SentryAndroidOptions) -> Unit) { + initSentry { options -> + configureReplay(options) + // The transport is held open on purpose below; do not let it time out while it waits. + options.readTimeoutMillis = SECONDS.toMillis(TRANSPORT_HOLD_SECONDS + 10).toInt() + // What the customer does when the user discards the form. + options.feedbackOptions.onFormClose = Runnable { + Sentry.replay().stop() + Sentry.replay().startBuffering() + } + } + + val scenario = launchActivity() + scenario.moveToState(Lifecycle.State.RESUMED) + // A segment without frames produces no video at all, so let the recorder fill one first. + await.atMost(20, SECONDS).until { replayFrameCount() >= 2 } + + // Hold the transport on an unrelated envelope so that the replay segments are still queued when + // the form is discarded. On a real device the same window is opened by a slow upload. + relay.addResponse { MockResponse().setHeadersDelay(TRANSPORT_HOLD_SECONDS, SECONDS) } + Sentry.captureMessage("occupies the transport thread") + + // Opening the form flushes the replay (SentryUserFeedbackForm calls captureReplay). + scenario.onActivity { Sentry.feedback().show() } + onView(withId(R.id.sentry_dialog_user_feedback_layout)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + // Discarding the form runs onFormClose, which stops the replay. + onView(withId(R.id.sentry_dialog_user_feedback_btn_cancel)).inRoot(isDialog()).perform(click()) + + await.atMost(TRANSPORT_HOLD_SECONDS + 30, SECONDS).untilAsserted { + relay.assert { + val segments = peekEnvelopes { it.replayVideoParts() != null } + assertTrue(segments.isNotEmpty(), "No replay segment reached the server") + segments.forEach { envelope -> + val video = envelope.replayVideoParts()!![REPLAY_VIDEO_PART] + assertNotNull( + video, + "Segment ${envelope.header.eventId} was sent without its video, " + + "so relay discards it as invalid_replay_video", + ) + assertTrue(video.isNotEmpty(), "Segment ${envelope.header.eventId} has an empty video") + } + } + } + } + + /** Number of frame screenshots that the recorder has written to the replay cache. */ + private fun replayFrameCount(): Int { + val cacheDirPath = Sentry.getCurrentScopes().options.cacheDirPath ?: return 0 + return File(cacheDirPath) + .listFiles { file -> file.isDirectory && file.name.startsWith("replay_") } + .orEmpty() + .sumOf { replayDir -> + replayDir.listFiles { file -> file.name.endsWith(".jpg") }.orEmpty().size + } + } + + companion object { + private const val TRANSPORT_HOLD_SECONDS = 10L + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt index a449be66ae4..1d54d03d0c6 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt @@ -43,6 +43,15 @@ class RelayAsserter(private val unassertedEnvelopes: MutableList) return unassertedEnvelopes.removeAt(relayResponseIndex) } + /** + * Returns every envelope received so far that satisfies [filter], without consuming it. Use this + * while polling for envelopes that are still in flight: a consuming lookup would drop the + * envelopes that an earlier, failed attempt already found. + */ + fun peekEnvelopes( + filter: (envelope: SentryEnvelope) -> Boolean = { true } + ): List = originalUnassertedEnvelopes.mapNotNull { it.envelope }.filter(filter) + /** Asserts no other envelopes were sent. */ fun assertNoOtherEnvelopes() { if (unassertedEnvelopes.isNotEmpty()) { diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/ReplayVideoPayload.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/ReplayVideoPayload.kt new file mode 100644 index 00000000000..1df8bc32693 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/ReplayVideoPayload.kt @@ -0,0 +1,30 @@ +package io.sentry.uitest.android.mockservers + +import io.sentry.SentryEnvelope +import io.sentry.SentryItemType +import org.msgpack.core.MessagePack + +/** Key of the video part inside a `replay_video` envelope item. */ +const val REPLAY_VIDEO_PART = "replay_video" + +/** + * Decodes the `replay_video` envelope item into its msgpack parts (`replay_event`, + * `replay_recording` and [REPLAY_VIDEO_PART]), or returns null when the envelope holds no such + * item. + * + * Relay discards a segment as `invalid_replay_video` when [REPLAY_VIDEO_PART] is absent or empty. + * The SDK writes that part lazily, on the transport thread, so only the bytes that reach the server + * show whether a segment is usable. A `SentryReplayEvent` seen in `beforeSendReplay` does not. + */ +fun SentryEnvelope.replayVideoParts(): Map? { + val item = items.firstOrNull { it.header.type == SentryItemType.ReplayVideo } ?: return null + return unpackMsgpackMap(item.data) +} + +private fun unpackMsgpackMap(bytes: ByteArray): Map = + MessagePack.newDefaultUnpacker(bytes).use { unpacker -> + (0 until unpacker.unpackMapHeader()).associate { + val key = unpacker.unpackString() + key to unpacker.readPayload(unpacker.unpackBinaryHeader()) + } + } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 3d876b8f971..1111a365b76 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -217,6 +217,43 @@ class SessionCaptureStrategyTest { verify(fixture.replayCache).close() } + @Test + fun `stop keeps segment video readable for the transport`() { + val replayId = SentryId() + val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenReturn(GeneratedVideo(video, 5, Fixture.VIDEO_DURATION)) + + var capturedVideo: File? = null + doAnswer { + capturedVideo = (it.arguments[0] as SentryReplayEvent).videoFile + null + } + .whenever(fixture.scopes) + .captureReplay(any(), any()) + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.stop() + + // The envelope item reads the video lazily on the transport thread, long after stop() returns. + assertTrue(capturedVideo!!.exists(), "video was deleted before the transport could read it") + } + @Test fun `stop closes cache after queued replay work`() { val tasks = mutableListOf() From 8fbe2af721463533bd5bcf5aed5139f0c769cc80 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:20:01 +0200 Subject: [PATCH 02/13] feat(core): Notify hints when an envelope is discarded before sending --- sentry/api/sentry.api | 4 ++ .../src/main/java/io/sentry/SentryClient.java | 10 ++++ .../io/sentry/hints/DiscardNotification.java | 9 ++++ .../sentry/transport/AsyncHttpTransport.java | 2 + .../java/io/sentry/transport/RateLimiter.java | 2 + .../test/java/io/sentry/SentryClientTest.kt | 49 +++++++++++++++++++ .../transport/AsyncHttpTransportTest.kt | 16 ++++++ .../io/sentry/transport/RateLimiterTest.kt | 20 ++++++++ 8 files changed, 112 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/hints/DiscardNotification.java diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 53452c4c3b9..8872225da98 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5303,6 +5303,10 @@ public abstract class io/sentry/hints/BlockingFlushHint : io/sentry/hints/DiskFl public abstract interface class io/sentry/hints/Cached { } +public abstract interface class io/sentry/hints/DiscardNotification { + public abstract fun markDiscarded ()V +} + public abstract interface class io/sentry/hints/DiskFlushNotification { public abstract fun isFlushable (Lio/sentry/protocol/SentryId;)Z public abstract fun markFlushed ()V diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 4bba195feea..aa8782aacd7 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -6,6 +6,7 @@ import io.sentry.hints.ApplyScopeData; import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; +import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; @@ -320,6 +321,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { + markReplayDiscarded(hint); return SentryId.EMPTY_ID; } @@ -352,6 +354,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } if (event == null) { + markReplayDiscarded(hint); return SentryId.EMPTY_ID; } @@ -365,6 +368,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin transport.send(envelope, hint); } catch (IOException e) { options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); + markReplayDiscarded(hint); // if there was an error capturing the event, we return an emptyId sentryId = SentryId.EMPTY_ID; @@ -373,6 +377,12 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin return sentryId; } + private static void markReplayDiscarded(final @Nullable Hint hint) { + if (hint != null) { + HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); + } + } + private void addScopeAttachmentsToHint(@Nullable IScope scope, @NotNull Hint hint) { if (scope != null) { hint.addAttachments(scope.getAttachments()); diff --git a/sentry/src/main/java/io/sentry/hints/DiscardNotification.java b/sentry/src/main/java/io/sentry/hints/DiscardNotification.java new file mode 100644 index 00000000000..dff6ebfe912 --- /dev/null +++ b/sentry/src/main/java/io/sentry/hints/DiscardNotification.java @@ -0,0 +1,9 @@ +package io.sentry.hints; + +import org.jetbrains.annotations.ApiStatus; + +/** Marker interface for envelopes to notify when they are dropped without being sent or stored */ +@ApiStatus.Internal +public interface DiscardNotification { + void markDiscarded(); +} diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index c4d54c173ef..a71066cdea8 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -13,6 +13,7 @@ import io.sentry.cache.IEnvelopeCache; import io.sentry.clientreport.DiscardReason; import io.sentry.hints.Cached; +import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Enqueable; import io.sentry.hints.Retryable; @@ -110,6 +111,7 @@ public void send(final @NotNull SentryEnvelope envelope, final @NotNull Hint hin options .getClientReportRecorder() .recordLostEnvelope(DiscardReason.QUEUE_OVERFLOW, envelopeThatMayIncludeClientReport); + HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); } else { HintUtils.runIfHasType( hint, diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index 9b15cb6fefe..43ff8c471de 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -11,6 +11,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.clientreport.DiscardReason; +import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Retryable; import io.sentry.hints.SubmissionResult; @@ -185,6 +186,7 @@ private void markHintWhenSendingFailed(final @NotNull Hint hint, final boolean r diskFlushNotification.markFlushed(); config.getLogger().log(SentryLevel.DEBUG, "Disk flush envelope fired due to rate limit"); }); + HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); } /** diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 61181ee96a6..ea0cd53d345 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -11,6 +11,7 @@ import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData import io.sentry.hints.Backfillable import io.sentry.hints.Cached +import io.sentry.hints.DiscardNotification import io.sentry.hints.DiskFlushNotification import io.sentry.hints.TransactionEnd import io.sentry.logger.ILoggerBatchProcessor @@ -3835,6 +3836,54 @@ class SentryClientTest { verify(onDiscardMock, times(1)).execute(DiscardReason.BEFORE_SEND, DataCategory.Replay, 1) } + @Test + fun `when beforeSendReplay returns null, marks the replay as discarded`() { + fixture.sentryOptions.setBeforeSendReplay { _: SentryReplayEvent, _: Hint -> null } + val discardNotification = mock() + + fixture + .getSut() + .captureReplayEvent( + SentryReplayEvent(), + Scope(fixture.sentryOptions), + HintUtils.createWithTypeCheckHint(discardNotification), + ) + + verify(discardNotification).markDiscarded() + } + + @Test + fun `when an event processor drops the replay, marks the replay as discarded`() { + fixture.sentryOptions.addEventProcessor(DropEverythingEventProcessor()) + val discardNotification = mock() + + fixture + .getSut() + .captureReplayEvent( + createReplayEvent(), + createScope(), + HintUtils.createWithTypeCheckHint(discardNotification), + ) + + verify(discardNotification).markDiscarded() + } + + @Test + fun `when the replay is sent, does not mark it as discarded`() { + val discardNotification = mock() + + fixture + .getSut() + .captureReplayEvent( + createReplayEvent(), + createScope(), + HintUtils.createWithTypeCheckHint(discardNotification), + ) + + verify(fixture.transport).send(any(), anyOrNull()) + verify(discardNotification, never()).markDiscarded() + } + @Test fun `when beforeSendReplay returns new instance, new instance is sent`() { val expected = SentryReplayEvent().apply { tags = mapOf("test" to "test") } diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index 6f711cfedbf..62212f6d6a5 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -12,6 +12,7 @@ import io.sentry.SentryOptionsManipulator import io.sentry.Session import io.sentry.clientreport.NoOpClientReportRecorder import io.sentry.dsnString +import io.sentry.hints.DiscardNotification import io.sentry.hints.DiskFlushNotification import io.sentry.hints.Enqueable import io.sentry.protocol.SentryId @@ -20,6 +21,7 @@ import io.sentry.test.injectForField import io.sentry.util.HintUtils import java.io.IOException import java.util.Date +import java.util.concurrent.Future import java.util.concurrent.RejectedExecutionHandler import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -29,6 +31,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -310,6 +313,19 @@ class AsyncHttpTransportTest { verify(fixture.executor).submit(any()) } + @Test + fun `when the queue is full, DiscardNotification is marked as discarded`() { + val envelope = SentryEnvelope.from(fixture.sentryOptions.serializer, createSession(), null) + whenever(fixture.rateLimiter.filter(any(), anyOrNull())).thenAnswer { it.arguments[0] } + val cancelled = mock> { on { isCancelled } doReturn true } + doReturn(cancelled).whenever(fixture.executor).submit(any()) + val hint = mock() + + fixture.getSUT().send(envelope, HintUtils.createWithTypeCheckHint(hint)) + + verify(hint).markDiscarded() + } + @Test fun `When envelopes have retry after items, ignore them and send others`() { val sessionItem = diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 00ba9cd9872..0c2b062a549 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -32,6 +32,7 @@ import io.sentry.TransactionContext import io.sentry.UserFeedback import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.IClientReportRecorder +import io.sentry.hints.DiscardNotification import io.sentry.hints.DiskFlushNotification import io.sentry.protocol.Feedback import io.sentry.protocol.SentryId @@ -462,6 +463,25 @@ class RateLimiterTest { verify(hint).markFlushed() } + @Test + fun `on rate limit DiscardNotification is marked as discarded`() { + val rateLimiter = fixture.getSUT() + val sentryEvent = SentryEvent() + val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, sentryEvent) + val envelope = SentryEnvelope(SentryEnvelopeHeader(sentryEvent.eventId), arrayListOf(eventItem)) + + rateLimiter.updateRetryAfterLimits( + "50:transaction:key, 1:default;error;security:organization", + null, + 1, + ) + + val hint = mock() + rateLimiter.filter(envelope, HintUtils.createWithTypeCheckHint(hint)) + + verify(hint).markDiscarded() + } + @Test fun `drop replay items as lost`() { val rateLimiter = fixture.getSUT() From 3a0cfe0d688d59063f0b588f79a872d38eaed643 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:25:11 +0200 Subject: [PATCH 03/13] fix(replay): Delete the segment video when its envelope is discarded --- .../android/replay/ReplayIntegration.kt | 5 ++- .../android/replay/capture/CaptureStrategy.kt | 6 ++- .../replay/capture/ReplaySegmentHint.kt | 14 +++++++ .../android/replay/ReplayIntegrationTest.kt | 3 ++ .../replay/capture/ReplaySegmentHintTest.kt | 19 ++++++++++ .../capture/SessionCaptureStrategyTest.kt | 37 +++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/ReplaySegmentHint.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/capture/ReplaySegmentHintTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index edb3f9c03a7..d8e457bbf68 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -31,6 +31,7 @@ import io.sentry.android.replay.ReplayLifecycleState.STOPPED import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.capture.ReplaySegmentHint import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback @@ -701,7 +702,7 @@ public class ReplayIntegration( ) if (segment is ReplaySegment.Created) { - val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) + val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint(segment.replay.videoFile)) segment.capture(scopes, hint) } cleanupReplays( @@ -757,7 +758,7 @@ public class ReplayIntegration( isRecording && this.generation == generation && this.replayId == replayId } - private class PreviousReplayHint : Backfillable { + private class PreviousReplayHint(videoFile: File?) : ReplaySegmentHint(videoFile), Backfillable { override fun shouldEnrich(): Boolean = false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index fc8b8d13065..72f5d671ae8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -18,6 +18,7 @@ import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.util.HintUtils import java.io.File import java.util.Date import java.util.Deque @@ -273,7 +274,10 @@ internal interface CaptureStrategy { data class Created(val replay: SentryReplayEvent, val recording: ReplayRecording) : ReplaySegment() { - fun capture(scopes: IScopes?, hint: Hint = Hint()) { + fun capture( + scopes: IScopes?, + hint: Hint = HintUtils.createWithTypeCheckHint(ReplaySegmentHint(replay.videoFile)), + ) { scopes?.captureReplay(replay, hint.apply { replayRecording = recording }) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/ReplaySegmentHint.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/ReplaySegmentHint.kt new file mode 100644 index 00000000000..60d08118dab --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/ReplaySegmentHint.kt @@ -0,0 +1,14 @@ +package io.sentry.android.replay.capture + +import io.sentry.hints.DiscardNotification +import java.io.File + +/** + * Hint for a captured replay segment. The send path owns the segment video: it deletes the video + * after reading it, and this hint deletes it when the envelope is dropped before that. + */ +internal open class ReplaySegmentHint(private val videoFile: File?) : DiscardNotification { + override fun markDiscarded() { + videoFile?.delete() + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 932f26a2b6d..60b99fcc70d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -40,6 +40,7 @@ import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.tape.QueueFile +import io.sentry.hints.DiscardNotification import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -50,6 +51,7 @@ import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import io.sentry.transport.RateLimiter +import io.sentry.util.HintUtils import io.sentry.util.thread.IThreadChecker import java.io.ByteArrayOutputStream import java.io.File @@ -1049,6 +1051,7 @@ class ReplayIntegrationTest { assertEquals("0.mp4", it.videoFile?.name) }, check { + assertTrue(HintUtils.hasType(it, DiscardNotification::class.java)) val metaEvents = it.replayRecording?.payload?.filterIsInstance() assertEquals(912, metaEvents?.first()?.height) assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/ReplaySegmentHintTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/ReplaySegmentHintTest.kt new file mode 100644 index 00000000000..41bdee38f66 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/ReplaySegmentHintTest.kt @@ -0,0 +1,19 @@ +package io.sentry.android.replay.capture + +import kotlin.test.Test +import kotlin.test.assertFalse +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class ReplaySegmentHintTest { + @get:Rule val tmpDir = TemporaryFolder() + + @Test + fun `markDiscarded deletes the segment video`() { + val video = tmpDir.newFile("0.mp4") + + ReplaySegmentHint(video).markDiscarded() + + assertFalse(video.exists()) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 1111a365b76..48843f34aa1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -26,12 +26,14 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.maskAllImages +import io.sentry.hints.DiscardNotification import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.HintUtils import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService @@ -254,6 +256,41 @@ class SessionCaptureStrategyTest { assertTrue(capturedVideo!!.exists(), "video was deleted before the transport could read it") } + @Test + fun `captured segment deletes its video when the envelope is discarded`() { + val replayId = SentryId() + val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenReturn(GeneratedVideo(video, 5, Fixture.VIDEO_DURATION)) + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) + + // pause() captures a segment without deleting any files, unlike stop() before Task 5 + strategy.pause() + + verify(fixture.scopes) + .captureReplay( + any(), + check { hint -> + HintUtils.runIfHasType(hint, DiscardNotification::class.java) { it.markDiscarded() } + }, + ) + assertFalse(video.exists()) + } + @Test fun `stop closes cache after queued replay work`() { val tasks = mutableListOf() From 17b9095bb7ded072829e34a518910399ae548776 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:28:38 +0200 Subject: [PATCH 04/13] fix(replay): Delete frames and segment state when the replay cache closes --- .../io/sentry/android/replay/ReplayCache.kt | 25 +++++++++++++++-- .../sentry/android/replay/ReplayCacheTest.kt | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 541d6a3b439..8f8cca276ca 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -72,7 +72,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI * @param frameTimestamp the timestamp when the frame screenshot was taken */ internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long, screen: String? = null) { - if (replayCacheDir == null || bitmap.isRecycled) { + if (replayCacheDir == null || bitmap.isRecycled || isClosed.get()) { return } replayCacheDir?.mkdirs() @@ -101,7 +101,19 @@ public class ReplayCache(private val options: SentryOptions, private val replayI */ public fun addFrame(screenshot: File, frameTimestamp: Long, screen: String? = null) { val frame = ReplayFrame(screenshot, frameTimestamp, screen) - framesLock.acquire().use { frames += frame } + val added = + framesLock.acquire().use { + if (isClosed.get()) { + false + } else { + frames += frame + true + } + } + if (!added) { + // the replay has stopped, so nothing will encode or delete this frame + deleteFile(screenshot) + } } /** Returns the timestamp of the first frame if available in a thread-safe manner. */ @@ -271,12 +283,21 @@ public class ReplayCache(private val options: SentryOptions, private val replayI return screen } + /** + * Releases the encoder and deletes the frames and segment state of this cache. Segment videos are + * kept: a captured segment's video belongs to the send path. + */ override fun close() { try { encoder?.release() encoder = null } finally { isClosed.set(true) + framesLock.acquire().use { + frames.forEach { deleteFile(it.screenshot) } + frames.clear() + } + lock.acquire().use { replayCacheDir?.let { File(it, ONGOING_SEGMENT).delete() } } } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index b3f3307837b..94560b50af3 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -283,6 +283,34 @@ class ReplayCacheTest { assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) } + @Test + fun `close deletes frames and segment state but keeps videos`() { + val replayCache = fixture.getSut(tmpDir) + val frame = File(replayCache.replayCacheDir, "1.jpg").also { it.createNewFile() } + replayCache.addFrame(frame, 1) + replayCache.persistSegmentValues("key", "value") + val video = File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(ByteArray(1)) } + + replayCache.close() + + assertFalse(frame.exists()) + assertTrue(replayCache.frames.isEmpty()) + assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) + assertTrue(video.exists()) + } + + @Test + fun `addFrame after close deletes the screenshot`() { + val replayCache = fixture.getSut(tmpDir) + replayCache.close() + val frame = File(replayCache.replayCacheDir, "1.jpg").also { it.createNewFile() } + + replayCache.addFrame(frame, 1) + + assertFalse(frame.exists()) + assertTrue(replayCache.frames.isEmpty()) + } + @Test fun `when file does not exist upon persisting creates it`() { val replayId = SentryId() From fc616e385d295fb5665f8172cf25b0cfa62b549f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:32:05 +0200 Subject: [PATCH 05/13] fix(replay): Hand over the replay cache when converting to session mode --- .../android/replay/capture/BufferCaptureStrategy.kt | 11 +++++++++-- .../replay/capture/BufferCaptureStrategyTest.kt | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 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 b8845f74848..12689f231aa 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 @@ -171,9 +171,16 @@ internal class BufferCaptureStrategy( return this } // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of - // execution + // execution, and the cache so that its frames and files keep a single owner val captureStrategy = - SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor) + SessionCaptureStrategy( + options, + scopes, + dateProvider, + replayExecutor, + persistingExecutor, + replayCacheProvider = cache?.let { current -> { _ -> current } }, + ) captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, 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 0d12fd3c3ff..f385e9bd3fd 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 @@ -283,6 +283,17 @@ class BufferCaptureStrategyTest { assertEquals(ReplayType.BUFFER, converted.replayType) } + @Test + fun `convert hands over the replay cache to the session strategy`() { + val currentReplay = tmpDir.newFolder() + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start() + + val converted = strategy.convert() + + assertEquals(currentReplay, converted.replayCacheDir) + } + @Test fun `createCurrentSegment uses first frame timestamp when available`() { val now = System.currentTimeMillis() From dc04666d82e04439bcdf23e37632835359966753 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:35:51 +0200 Subject: [PATCH 06/13] fix(replay): Keep queued segment videos when replay stops --- .../replay/capture/BufferCaptureStrategy.kt | 6 +- .../replay/capture/SessionCaptureStrategy.kt | 3 - .../capture/BufferCaptureStrategyTest.kt | 62 ++++++++++++++++++- .../capture/SessionCaptureStrategyTest.kt | 1 - 4 files changed, 63 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 12689f231aa..68b3a9c444d 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 @@ -21,7 +21,6 @@ import io.sentry.android.replay.util.ReplayRunnable import io.sentry.clientreport.DiscardReason.RATELIMIT_BACKOFF import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider -import io.sentry.util.FileUtils import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService @@ -77,10 +76,11 @@ internal class BufferCaptureStrategy( } override fun stop() { - val replayCacheDir = cache?.replayCacheDir replayExecutor.submit( ReplayRunnable("$TAG.stop") { - FileUtils.deleteRecursively(replayCacheDir) + // Buffered segments were never captured, so nothing else will delete their videos. + bufferedSegments.forEach { deleteFile(it.replay.videoFile) } + bufferedSegments.clear() currentSegment = -1 } ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 4fd3c910e22..b84575a8293 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -12,7 +12,6 @@ import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider -import io.sentry.util.FileUtils import java.util.Date import java.util.concurrent.ScheduledExecutorService @@ -70,13 +69,11 @@ internal class SessionCaptureStrategy( } override fun stop() { - val replayCacheDir = cache?.replayCacheDir createCurrentSegment("stop") { segment -> if (segment is ReplaySegment.Created) { segment.capture(scopes) } currentSegment = -1 - FileUtils.deleteRecursively(replayCacheDir) } scopes?.configureScope { it.replayId = SentryId.EMPTY_ID } super.stop() 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 f385e9bd3fd..a91d53cc132 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 @@ -185,7 +185,7 @@ class BufferCaptureStrategyTest { } @Test - fun `stop clears replay cache dir`() { + fun `stop closes replay cache`() { val replayId = SentryId() val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } @@ -198,10 +198,68 @@ class BufferCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) assertEquals(-1, strategy.currentSegment) - assertFalse(currentReplay.exists()) verify(fixture.replayCache).close() } + @Test + fun `stop keeps videos of captured segments readable for the transport`() { + val replayId = SentryId() + val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenReturn(GeneratedVideo(video, 5, VIDEO_DURATION)) + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + strategy.captureReplay(false) {} + + strategy.stop() + + // The envelope item reads the video lazily on the transport thread, long after stop() returns. + assertTrue(video.exists(), "video was deleted before the transport could read it") + } + + @Test + fun `stop deletes videos of segments that were never captured`() { + val replayId = SentryId() + val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenReturn(GeneratedVideo(video, 5, VIDEO_DURATION)) + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() + + strategy.stop() + + verify(fixture.scopes, never()).captureReplay(any(), any()) + assertFalse(video.exists()) + } + @Test fun `onScreenshotRecorded adds screenshot to cache`() { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.errorReplayDuration * 5) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 48843f34aa1..1a56845184a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -215,7 +215,6 @@ class SessionCaptureStrategyTest { assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) assertEquals(-1, strategy.currentSegment) - assertFalse(currentReplay.exists()) verify(fixture.replayCache).close() } From 2865745cab568dbcf3c6adc54f2f2b848cbe9c90 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:38:24 +0200 Subject: [PATCH 07/13] test(replay): Create stop test folders in a temporary folder --- .../android/replay/capture/BufferCaptureStrategyTest.kt | 6 +++--- .../android/replay/capture/SessionCaptureStrategyTest.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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 a91d53cc132..87078fbfc7f 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 @@ -187,7 +187,7 @@ class BufferCaptureStrategyTest { @Test fun `stop closes replay cache`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val strategy = fixture.getSut(replayCacheDir = currentReplay) strategy.start(0, replayId) @@ -204,7 +204,7 @@ class BufferCaptureStrategyTest { @Test fun `stop keeps videos of captured segments readable for the transport`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } whenever( fixture.replayCache.createVideoOf( @@ -234,7 +234,7 @@ class BufferCaptureStrategyTest { @Test fun `stop deletes videos of segments that were never captured`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } whenever( fixture.replayCache.createVideoOf( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 1a56845184a..598fe0f6fb2 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -202,7 +202,7 @@ class SessionCaptureStrategyTest { @Test fun `stop creates and captures current segment and clears replayId from scope`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val strategy = fixture.getSut(replayCacheDir = currentReplay) strategy.start(0, replayId) @@ -221,7 +221,7 @@ class SessionCaptureStrategyTest { @Test fun `stop keeps segment video readable for the transport`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } whenever( fixture.replayCache.createVideoOf( @@ -258,7 +258,7 @@ class SessionCaptureStrategyTest { @Test fun `captured segment deletes its video when the envelope is discarded`() { val replayId = SentryId() - val currentReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + val currentReplay = tmpDir.newFolder("replay_$replayId") val video = File(currentReplay, "0.mp4").also { it.writeBytes(ByteArray(1024)) } whenever( fixture.replayCache.createVideoOf( From 1cf4bcaa16ce08bf6bfb30254b886e368f178280 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 11:54:16 +0200 Subject: [PATCH 08/13] fix(replay): Advance the segment id on the replay thread after a flush --- .../android/replay/ReplayIntegration.kt | 30 +++++--------- .../android/replay/ReplayIntegrationTest.kt | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d8e457bbf68..0776013fc9e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -334,30 +334,22 @@ public class ReplayIntegration( return } - var activeStrategy: CaptureStrategy = strategy - capture(strategy) { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } - } - } - activeStrategy = strategy.convert() - val replayId: SentryId? = activeStrategy.currentReplayId + // Convert before capturing, so the flush callback knows which strategy continues this replay. + val activeStrategy = strategy.convert() state.set( current.copy( - replayId = replayId ?: SentryId.EMPTY_ID, + replayId = activeStrategy.currentReplayId ?: SentryId.EMPTY_ID, captureStrategy = activeStrategy, ) ) + capture(strategy) { newTimestamp -> + // Runs on the replay thread right after the flush segment was captured, before any segment + // work queued behind it (pause, stop, frames), so no later segment reuses the flushed id. + // A restarted replay uses a new strategy instance, so this cannot touch it. + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true + } } override fun getReplayId(): SentryId = state.get().replayId diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 60b99fcc70d..a64eb922a2a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -748,6 +748,47 @@ class ReplayIntegrationTest { assertThat(replay.replayId).isEqualTo(newReplayId) } + @Test + fun `flush callback advances the converted strategy even after a queued stop`() { + val replayId = SentryId() + var onSegmentSent: ((Date) -> Unit)? = null + val bufferStrategy = mock() + val sessionStrategy = mock() + whenever(bufferStrategy.currentReplayId).thenReturn(replayId) + whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) + whenever(sessionStrategy.currentReplayId).thenReturn(replayId) + whenever(sessionStrategy.currentSegment).thenReturn(0) + doAnswer { + @Suppress("UNCHECKED_CAST") + onSegmentSent = it.arguments[1] as (Date) -> Unit + } + .whenever(bufferStrategy) + .captureReplay(any(), any()) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + replayCaptureStrategyProvider = { bufferStrategy }, + mainLooperHandler = MainLooperHandler(), + ) + replay.register(fixture.scopes, fixture.options) + replay.start() + shadowOf(Looper.getMainLooper()).idle() + replay.captureReplay(false) + shadowOf(Looper.getMainLooper()).idle() + replay.stop() + shadowOf(Looper.getMainLooper()).idle() + + // The flush finishes on the replay thread after stop() already ran on the main thread; the + // stop segment is queued behind it and must not reuse the flushed segment id. + val flushEnd = Date() + onSegmentSent!!.invoke(flushEnd) + + verify(sessionStrategy).currentSegment = 1 + verify(sessionStrategy).segmentTimestamp = flushEnd + verify(sessionStrategy).isFlushed = true + } + @Test fun `pause does nothing when not recording`() { val captureStrategy = mock() From c17a6997c400dab71e425f3cefccd2e9b0628def Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 12:07:38 +0200 Subject: [PATCH 09/13] fix(replay): Derive the next segment id from the flushed segment --- .../android/replay/ReplayIntegration.kt | 7 +++- .../android/replay/ReplayIntegrationTest.kt | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 0776013fc9e..ceb3703f400 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -344,9 +344,12 @@ public class ReplayIntegration( ) capture(strategy) { newTimestamp -> // Runs on the replay thread right after the flush segment was captured, before any segment - // work queued behind it (pause, stop, frames), so no later segment reuses the flushed id. + // work queued behind it (pause, stop, frames). `strategy.currentSegment` is exactly the id + // the flush segment used (the buffer flush does not increment its own counter), so deriving + // the next id from it, rather than incrementing activeStrategy's possibly-stale id, ensures + // no later segment reuses the flushed id even if a queued segment ran on `strategy` first. // A restarted replay uses a new strategy instance, so this cannot touch it. - activeStrategy.currentSegment++ + activeStrategy.currentSegment = strategy.currentSegment + 1 activeStrategy.segmentTimestamp = newTimestamp activeStrategy.isFlushed = true } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index a64eb922a2a..cbbd8010d67 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -757,7 +757,7 @@ class ReplayIntegrationTest { whenever(bufferStrategy.currentReplayId).thenReturn(replayId) whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) whenever(sessionStrategy.currentReplayId).thenReturn(replayId) - whenever(sessionStrategy.currentSegment).thenReturn(0) + whenever(bufferStrategy.currentSegment).thenReturn(0) doAnswer { @Suppress("UNCHECKED_CAST") onSegmentSent = it.arguments[1] as (Date) -> Unit @@ -789,6 +789,46 @@ class ReplayIntegrationTest { verify(sessionStrategy).isFlushed = true } + @Test + fun `flush callback derives the next segment id from the flushed segment`() { + val replayId = SentryId() + var onSegmentSent: ((Date) -> Unit)? = null + val bufferStrategy = mock() + val sessionStrategy = mock() + whenever(bufferStrategy.currentReplayId).thenReturn(replayId) + whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) + whenever(sessionStrategy.currentReplayId).thenReturn(replayId) + // A queued pause segment already ran on the replay thread by the time the callback fires, + // advancing the buffer's counter past the stale value convert() read on the main thread. + whenever(bufferStrategy.currentSegment).thenReturn(2) + whenever(sessionStrategy.currentSegment).thenReturn(0) + doAnswer { + @Suppress("UNCHECKED_CAST") + onSegmentSent = it.arguments[1] as (Date) -> Unit + } + .whenever(bufferStrategy) + .captureReplay(any(), any()) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + replayCaptureStrategyProvider = { bufferStrategy }, + mainLooperHandler = MainLooperHandler(), + ) + replay.register(fixture.scopes, fixture.options) + replay.start() + shadowOf(Looper.getMainLooper()).idle() + replay.captureReplay(false) + shadowOf(Looper.getMainLooper()).idle() + + val flushEnd = Date() + onSegmentSent!!.invoke(flushEnd) + + verify(sessionStrategy).currentSegment = 3 + verify(sessionStrategy).segmentTimestamp = flushEnd + verify(sessionStrategy).isFlushed = true + } + @Test fun `pause does nothing when not recording`() { val captureStrategy = mock() From 166275e0dfcba5586fb8c68a719164c4f51358c3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 25 Sep 2026 12:07:41 +0200 Subject: [PATCH 10/13] fix(core): Notify the discard hint when a disabled scope drops a replay --- sentry/src/main/java/io/sentry/Scopes.java | 4 ++++ .../java/io/sentry/hints/DiscardNotification.java | 2 +- sentry/src/test/java/io/sentry/ScopesTest.kt | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index d3926d56993..c694817723c 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.clientreport.DiscardReason; +import io.sentry.hints.DiscardNotification; import io.sentry.hints.SessionEndHint; import io.sentry.hints.SessionStartHint; import io.sentry.logger.ILoggerApi; @@ -1231,6 +1232,9 @@ public void reportFullyDisplayed() { .log( SentryLevel.WARNING, "Instance is disabled and this 'captureReplay' call is a no-op."); + if (hint != null) { + HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); + } } else { try { sentryId = getClient().captureReplayEvent(replay, getCombinedScopeView(), hint); diff --git a/sentry/src/main/java/io/sentry/hints/DiscardNotification.java b/sentry/src/main/java/io/sentry/hints/DiscardNotification.java index dff6ebfe912..3f030df1ce1 100644 --- a/sentry/src/main/java/io/sentry/hints/DiscardNotification.java +++ b/sentry/src/main/java/io/sentry/hints/DiscardNotification.java @@ -2,7 +2,7 @@ import org.jetbrains.annotations.ApiStatus; -/** Marker interface for envelopes to notify when they are dropped without being sent or stored */ +/** Notified when an envelope is dropped before it is sent or stored. */ @ApiStatus.Internal public interface DiscardNotification { void markDiscarded(); diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index d1cb38c6495..1c831d49984 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -5,6 +5,7 @@ import io.sentry.cache.EnvelopeCache import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent +import io.sentry.hints.DiscardNotification import io.sentry.hints.SessionEndHint import io.sentry.hints.SessionStartHint import io.sentry.logger.SentryLogParameters @@ -2311,6 +2312,17 @@ class ScopesTest { verify(mockClient, never()).captureReplayEvent(any(), any(), any()) } + @Test + fun `when captureReplay is called on disabled client, it notifies the discard hint`() { + val (sut, _) = getEnabledScopes() + sut.close() + + val discardNotification = mock() + val hint = HintUtils.createWithTypeCheckHint(discardNotification) + sut.captureReplay(SentryReplayEvent(), hint) + verify(discardNotification).markDiscarded() + } + @Test fun `when captureReplay is called with a valid argument, captureReplay on the client should be called`() { val (sut, mockClient) = getEnabledScopes() From bbe8efab9c6c3694d0dd6e01031e02aa05c4d86e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 28 Sep 2026 10:57:41 +0200 Subject: [PATCH 11/13] refactor(core): Move the discard notification helper to HintUtils --- sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/Scopes.java | 5 +---- sentry/src/main/java/io/sentry/SentryClient.java | 13 +++---------- .../io/sentry/transport/AsyncHttpTransport.java | 3 +-- .../main/java/io/sentry/transport/RateLimiter.java | 3 +-- sentry/src/main/java/io/sentry/util/HintUtils.java | 8 ++++++++ 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8872225da98..77f184b2df1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7955,6 +7955,7 @@ public final class io/sentry/util/HintUtils { public static fun hasType (Lio/sentry/Hint;Ljava/lang/Class;)Z public static fun isCaptureFailed (Lio/sentry/Hint;)Z public static fun isFromHybridSdk (Lio/sentry/Hint;)Z + public static fun markDiscarded (Lio/sentry/Hint;)V public static fun runIfDoesNotHaveType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryNullableConsumer;)V public static fun runIfHasType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryConsumer;)V public static fun runIfHasType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryConsumer;Lio/sentry/util/HintUtils$SentryHintFallback;)V diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index c694817723c..274aaf9d937 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -1,7 +1,6 @@ package io.sentry; import io.sentry.clientreport.DiscardReason; -import io.sentry.hints.DiscardNotification; import io.sentry.hints.SessionEndHint; import io.sentry.hints.SessionStartHint; import io.sentry.logger.ILoggerApi; @@ -1232,9 +1231,7 @@ public void reportFullyDisplayed() { .log( SentryLevel.WARNING, "Instance is disabled and this 'captureReplay' call is a no-op."); - if (hint != null) { - HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); - } + HintUtils.markDiscarded(hint); } else { try { sentryId = getClient().captureReplayEvent(replay, getCombinedScopeView(), hint); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index aa8782aacd7..c5201eb5cbd 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -6,7 +6,6 @@ import io.sentry.hints.ApplyScopeData; import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; -import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; @@ -321,7 +320,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - markReplayDiscarded(hint); + HintUtils.markDiscarded(hint); return SentryId.EMPTY_ID; } @@ -354,7 +353,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } if (event == null) { - markReplayDiscarded(hint); + HintUtils.markDiscarded(hint); return SentryId.EMPTY_ID; } @@ -368,7 +367,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin transport.send(envelope, hint); } catch (IOException e) { options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); - markReplayDiscarded(hint); + HintUtils.markDiscarded(hint); // if there was an error capturing the event, we return an emptyId sentryId = SentryId.EMPTY_ID; @@ -377,12 +376,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin return sentryId; } - private static void markReplayDiscarded(final @Nullable Hint hint) { - if (hint != null) { - HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); - } - } - private void addScopeAttachmentsToHint(@Nullable IScope scope, @NotNull Hint hint) { if (scope != null) { hint.addAttachments(scope.getAttachments()); diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index a71066cdea8..6c47a14c5c5 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -13,7 +13,6 @@ import io.sentry.cache.IEnvelopeCache; import io.sentry.clientreport.DiscardReason; import io.sentry.hints.Cached; -import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Enqueable; import io.sentry.hints.Retryable; @@ -111,7 +110,7 @@ public void send(final @NotNull SentryEnvelope envelope, final @NotNull Hint hin options .getClientReportRecorder() .recordLostEnvelope(DiscardReason.QUEUE_OVERFLOW, envelopeThatMayIncludeClientReport); - HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); + HintUtils.markDiscarded(hint); } else { HintUtils.runIfHasType( hint, diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index 43ff8c471de..612aa3e1d2a 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -11,7 +11,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.clientreport.DiscardReason; -import io.sentry.hints.DiscardNotification; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Retryable; import io.sentry.hints.SubmissionResult; @@ -186,7 +185,7 @@ private void markHintWhenSendingFailed(final @NotNull Hint hint, final boolean r diskFlushNotification.markFlushed(); config.getLogger().log(SentryLevel.DEBUG, "Disk flush envelope fired due to rate limit"); }); - HintUtils.runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); + HintUtils.markDiscarded(hint); } /** diff --git a/sentry/src/main/java/io/sentry/util/HintUtils.java b/sentry/src/main/java/io/sentry/util/HintUtils.java index 4ccbce3c33f..a79917c53ca 100644 --- a/sentry/src/main/java/io/sentry/util/HintUtils.java +++ b/sentry/src/main/java/io/sentry/util/HintUtils.java @@ -13,6 +13,7 @@ import io.sentry.hints.ApplyScopeData; import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; +import io.sentry.hints.DiscardNotification; import io.sentry.hints.EventDropReason; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -96,6 +97,13 @@ public static void runIfHasType( runIfHasType(hint, clazz, lambda, (value, clazz2) -> {}); } + /** Notifies the hint, if any, that its envelope was dropped before it was sent or stored. */ + public static void markDiscarded(final @Nullable Hint hint) { + if (hint != null) { + runIfHasType(hint, DiscardNotification.class, DiscardNotification::markDiscarded); + } + } + public static void runIfHasTypeLogIfNot( @NotNull Hint hint, @NotNull Class clazz, ILogger logger, SentryConsumer lambda) { runIfHasType( From f9f5ec5d72236e80511a91a62c78d3e1f7dbbbc8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 28 Sep 2026 11:01:02 +0200 Subject: [PATCH 12/13] docs: Add a changelog entry for the replay stop fixes --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec63c894fc8..a0d07b71ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - Deprecate `sendDefaultPii` in favor of `dataCollection` ahead of its removal in 9.0 ([#6158](https://github.com/getsentry/sentry-java/pull/6158)) - Make the tombstone merge time threshold configurable via `SentryAndroidOptions.setTombstoneMergeTimeThresholdMillis` and the `io.sentry.tombstone.merge-time-threshold-millis` manifest option ([#6154](https://github.com/getsentry/sentry-java/pull/6154)) +### Fixes + +- Keep the videos of already captured session replay segments when the replay stops, so the final segments are no longer missing ([#6171](https://github.com/getsentry/sentry-java/pull/6171)) +- Derive the next session replay segment id from the flushed segment, so segments are no longer sent with a duplicate id ([#6171](https://github.com/getsentry/sentry-java/pull/6171)) + ## 8.58.0 ### Features From 608e0e20353e6a7b7c3d034f3dfa76f44f863dee Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 28 Sep 2026 11:02:39 +0200 Subject: [PATCH 13/13] Update CHANGELOG Removed duplicate entry about deriving session replay segment id. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d07b71ed2..761bafba1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ ### Fixes - Keep the videos of already captured session replay segments when the replay stops, so the final segments are no longer missing ([#6171](https://github.com/getsentry/sentry-java/pull/6171)) -- Derive the next session replay segment id from the flushed segment, so segments are no longer sent with a duplicate id ([#6171](https://github.com/getsentry/sentry-java/pull/6171)) ## 8.58.0