From bb2d4458964686f07a6556cd12f412eb1487e468 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 12:06:43 +0200 Subject: [PATCH 1/6] fix(replay): Preserve segment ID after buffer-to-session conversion After a buffer-to-session conversion, the replay type stays BUFFER but the segment counter reflects the real sequence. If the app crashes and finalizePreviousReplay recovers the last segment, fromDisk() was normalizing the segment ID to 0 for all BUFFER replays, creating a duplicate segment 0 that overwrites the original. Add a persisted isFlushed flag set when the buffer is successfully flushed. fromDisk() now only normalizes to 0 when the buffer was never flushed (no segments were ever sent to the server). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../io/sentry/android/replay/ReplayCache.kt | 8 +++-- .../android/replay/ReplayIntegration.kt | 1 + .../replay/capture/BaseCaptureStrategy.kt | 3 ++ .../replay/capture/BufferCaptureStrategy.kt | 1 - .../android/replay/capture/CaptureStrategy.kt | 1 + .../sentry/android/replay/ReplayCacheTest.kt | 36 ++++++++++++++++++- 6 files changed, 46 insertions(+), 4 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 b3b9edae055..b54177bca9b 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 @@ -317,6 +317,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" internal const val SEGMENT_KEY_ID = "segment.id" + internal const val SEGMENT_KEY_FLUSHED = "replay.flushed" fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? = if (options.cacheDirPath.isNullOrEmpty()) { @@ -415,8 +416,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } cache.frames.sortBy { it.timestamp } - // TODO: this should be removed when we start sending buffered segments on next launch - val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val wasFlushed = lastSegment[SEGMENT_KEY_FLUSHED]?.toBooleanStrictOrNull() == true + // In buffer mode, if the buffer was never flushed (no error triggered captureReplay), + // no segments were ever sent, so we normalize to 0. After a flush + conversion to + // session mode, the persisted segmentId is the real sequence number. + val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = if (replayType == SESSION) { segmentTimestamp 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 612517438f6..bae0e411795 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 @@ -261,6 +261,7 @@ public class ReplayIntegration( onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 captureStrategy?.segmentTimestamp = newTimestamp + captureStrategy?.isFlushed = true }, ) captureStrategy = captureStrategy?.convert() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6bb58c5e2a2..a401ff17f34 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -13,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -89,6 +90,8 @@ internal abstract class BaseCaptureStrategy( get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + override var isFlushed: Boolean by + persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() private val traceIdsLock = Any() 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 0df8a642f63..f6c6f3997ae 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 @@ -105,7 +105,6 @@ internal class BufferCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(scopes) - // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already 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 6dc391a15ec..096a93741b2 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 @@ -29,6 +29,7 @@ internal interface CaptureStrategy { val replayCacheDir: File? var replayType: ReplayType var segmentTimestamp: Date? + var isFlushed: Boolean fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null) 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 257941a9114..8b64ca5caeb 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 @@ -9,6 +9,7 @@ import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID @@ -443,7 +444,7 @@ class ReplayCacheTest { } @Test - fun `sets segmentId to 0 for buffer mode`() { + fun `sets segmentId to 0 for buffer mode when not flushed`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } val replayId = SentryId() val replayCacheFolder = @@ -474,6 +475,39 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + @Test + fun `preserves segmentId for buffer mode when already flushed`() { + fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } + val replayId = SentryId() + val replayCacheFolder = + File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=5 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_FLUSHED=true + """ + .trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(5, lastSegment.id) + } + @Test fun `when screenshot is corrupted, deletes it immediately`() { ShadowBitmapFactory.setAllowInvalidImageData(false) From 819aada060a6a2206068f9a41804a5a6373fc18b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 12:07:31 +0200 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18713588b48..025d4bda7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixes +- Fix replay segment ID normalization after buffer-to-session conversion ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) ### Dependencies From 08237cff3a7a3fd658fa19ababbbee1fa7c7f4ae Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 12:50:20 +0200 Subject: [PATCH 3/6] Apply suggestion from @romtsn --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 025d4bda7e6..6528ac7c02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ ### Fixes -- Fix replay segment ID normalization after buffer-to-session conversion ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) +- Session Replay: Fix first recording segment missing for replays in `buffer` mode ([#5753](https://github.com/getsentry/sentry-java/pull/5753)) - Fix main thread identification for tombstone (native crash) events ([#5742](https://github.com/getsentry/sentry-java/pull/5742)) ### Dependencies From 49846bc63cd75da968907b5e8c0ecabfa9c6bba9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 15:32:22 +0200 Subject: [PATCH 4/6] Add comment explaining isFlushed flag purpose Co-Authored-By: Claude Opus 4.6 (1M context) --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index a401ff17f34..fbc0ccfd4bc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -90,6 +90,9 @@ internal abstract class BaseCaptureStrategy( get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + // Tracks whether the buffer was flushed (segments sent to server). Used by fromDisk() + // to decide whether to normalize the segment ID to 0 on crash recovery: if never flushed, + // no segments reached the server, so the recovered segment must be 0. override var isFlushed: Boolean by persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) From 33127b6367159ddb937594322155c55509555925 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 15:42:39 +0200 Subject: [PATCH 5/6] fix(replay): Use persisted timestamp for flushed buffer recovery After buffer-to-session conversion, use the persisted segmentTimestamp (which chains with previous segments) instead of the first frame timestamp, avoiding gaps in the recovered segment timeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b54177bca9b..4cfb3f84d27 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 @@ -422,7 +422,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI // session mode, the persisted segmentId is the real sequence number. val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = - if (replayType == SESSION) { + if (replayType == SESSION || wasFlushed) { segmentTimestamp } else { // in buffer mode we have to set the timestamp of the first frame as the actual start From 354056d3759b548fcee91fe62f0fc51597084830 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 10 Jul 2026 15:44:59 +0200 Subject: [PATCH 6/6] Revert timestamp normalization change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap from using first-frame timestamp vs persisted segmentTimestamp is at most ~1s (1/frameRate) — negligible for crash recovery. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4cfb3f84d27..b54177bca9b 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 @@ -422,7 +422,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI // session mode, the persisted segmentId is the real sequence number. val normalizedSegmentId = if (replayType == SESSION || wasFlushed) segmentId else 0 val normalizedTimestamp = - if (replayType == SESSION || wasFlushed) { + if (replayType == SESSION) { segmentTimestamp } else { // in buffer mode we have to set the timestamp of the first frame as the actual start