From 9d3f4869825924a57ab2c7a6ba9c65844fcb2fb4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:08:31 +0000 Subject: [PATCH 1/7] fix(replay): Backfill release, environment and SDK for replays from the previous run Co-Authored-By: Roman Zavarnitsyn Co-Authored-By: George Gritsouk --- .../android/replay/ReplayIntegration.kt | 39 ++++++++- .../android/replay/ReplayIntegrationTest.kt | 82 +++++++++++++++++++ 2 files changed, 117 insertions(+), 4 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 edb3f9c03a7..410a7927050 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 @@ -22,6 +22,7 @@ import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent import io.sentry.TypeCheckHint import io.sentry.android.replay.ReplayLifecycleState.CLOSED import io.sentry.android.replay.ReplayLifecycleState.PAUSED @@ -39,9 +40,15 @@ import io.sentry.android.replay.util.ReplayExecutorService import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingOptionsObserver +import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME +import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME +import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable +import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.transport.RateLimiter @@ -653,10 +660,7 @@ public class ReplayIntegration( } private fun finalizePreviousReplay() { - // TODO: read persisted options/scope values form the - // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in - // MainEventProcessor - + // TODO: also backfill persisted scope values (e.g. user, tags, contexts) from the previous run options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { val persistingScopeObserver = options.findPersistingScopeObserver() val previousReplayIdString = @@ -701,6 +705,7 @@ public class ReplayIntegration( ) if (segment is ReplaySegment.Created) { + segment.replay.applyPreviousRunOptions() val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) segment.capture(scopes, hint) } @@ -710,6 +715,32 @@ public class ReplayIntegration( } } + /** + * [PreviousReplayHint] is [Backfillable], so MainEventProcessor does not apply release, + * environment, dist or SDK from the current run. Restore them from the options persisted by the + * previous run instead. This runs on the SDK executor before Sentry.init persists the current + * options, so the persisted values still describe the run that recorded this replay. + */ + private fun SentryReplayEvent.applyPreviousRunOptions() { + if (release == null) { + release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java) + } + if (environment == null) { + environment = + PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java) + } + if (dist == null) { + dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java) + } + if (sdk == null) { + // the SDK name does not change between runs, so fall back to the current one rather than + // sending a replay without any SDK info + sdk = + PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion::class.java) + ?: options.sdkVersion + } + } + override fun onWindowSizeChanged(width: Int, height: Int) { if (!isEnabled.get() || !state.get().isRecording) { return 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..2f341d9f18a 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 @@ -38,8 +38,10 @@ import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Compa import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.tape.QueueFile +import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -1079,6 +1081,86 @@ class ReplayIntegrationTest { ) } + @Test + fun `register backfills previous replay with options persisted by the previous run`() { + val oldReplayId = SentryId() + writePreviousReplay(oldReplayId) + PersistingOptionsObserver(fixture.options).apply { + setRelease("io.sentry.sample@1.0.0+1") + setEnvironment("previous-env") + setDist("1") + setSdkVersion(SdkVersion("sentry.java.android", "1.0.0")) + } + // current run options differ and must not be used for the previous replay + fixture.options.release = "io.sentry.sample@2.0.0+2" + fixture.options.environment = "current-env" + fixture.options.sdkVersion = SdkVersion("sentry.java.android", "2.0.0") + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureReplay( + check { + assertThat(it.replayId).isEqualTo(oldReplayId) + assertThat(it.release).isEqualTo("io.sentry.sample@1.0.0+1") + assertThat(it.environment).isEqualTo("previous-env") + assertThat(it.dist).isEqualTo("1") + assertThat(it.sdk?.name).isEqualTo("sentry.java.android") + assertThat(it.sdk?.version).isEqualTo("1.0.0") + }, + any(), + ) + } + + @Test + fun `register falls back to current sdk version when previous run did not persist it`() { + val oldReplayId = SentryId() + writePreviousReplay(oldReplayId) + fixture.options.sdkVersion = SdkVersion("sentry.java.android", "2.0.0") + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureReplay( + check { + assertThat(it.replayId).isEqualTo(oldReplayId) + assertThat(it.sdk?.name).isEqualTo("sentry.java.android") + assertThat(it.sdk?.version).isEqualTo("2.0.0") + assertThat(it.release).isNull() + assertThat(it.environment).isNull() + }, + any(), + ) + } + + private fun writePreviousReplay(replayId: SentryId) { + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) + val oldReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + File(oldReplay, "1720693523997.jpg").outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val scopeCache = + File(fixture.options.cacheDirPath, PersistingScopeObserver.SCOPE_CACHE).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).writeText("\"$replayId\"") + File(oldReplay, ONGOING_SEGMENT) + .writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """ + .trimIndent() + ) + } + @Test fun `register cleans up old replays`() { val replayId = SentryId() From 5cca6997e181e727b0fdb48061ad71bb5157d4c2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:08:59 +0000 Subject: [PATCH 2/7] changelog Co-Authored-By: Roman Zavarnitsyn Co-Authored-By: George Gritsouk --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f54939727..3a51e157972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Session Replay: Backfill release, environment and SDK info for replays sent on the next launch after the app was killed or crashed ([#6253](https://github.com/getsentry/sentry-java/pull/6253)) - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Drop telemetry when a `beforeSend*` callback throws and report it using `DiscardReason.CALLBACK_ERROR`. - Drop transactions when `tracesSampler` throws instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)). From f53c2e0f8bef4cc8a53198d443dc90bafcf0162d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:16:47 +0000 Subject: [PATCH 3/7] fix(replay): Drop TODO flagged by detekt and stale baseline entries --- sentry-android-replay/detekt-baseline.xml | 2 -- .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 1 - 2 files changed, 3 deletions(-) diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 54c6754e2d8..2adcb412344 100644 --- a/sentry-android-replay/detekt-baseline.xml +++ b/sentry-android-replay/detekt-baseline.xml @@ -33,8 +33,6 @@ ForbiddenComment:Persistable.kt$PersistableLinkedList$// TODO: enable this back after we are able to serialize individual touches to disk to not overload ForbiddenComment:PixelCopyStrategy.kt$PixelCopyStrategy$// TODO: disableAllMasking here and dont traverse? ForbiddenComment:ReplayCache.kt$ReplayCache$// TODO: it's awful, choose a better serialization format - ForbiddenComment:ReplayIntegration.kt$ReplayIntegration$// TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in - ForbiddenComment:ReplayIntegration.kt$ReplayIntegration$// TODO: read persisted options/scope values form the ForbiddenComment:ReplaySmokeTest.kt$ReplaySmokeTest$// TODO: figure out why there's more than 10 ForbiddenComment:SimpleVideoEncoder.kt$SimpleVideoEncoder$// TODO: because the screenshot config has to match the video config ForbiddenComment:SimpleVideoEncoder.kt$SimpleVideoEncoder$// TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from() 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 410a7927050..a4e05193f3e 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 @@ -660,7 +660,6 @@ public class ReplayIntegration( } private fun finalizePreviousReplay() { - // TODO: also backfill persisted scope values (e.g. user, tags, contexts) from the previous run options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { val persistingScopeObserver = options.findPersistingScopeObserver() val previousReplayIdString = From 51b6b04e43b16c9ffad1ce4c5dce3b07f5b47ca1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:58:13 +0000 Subject: [PATCH 4/7] fix(replay): Use hybrid SDK replay version when backfilling previous-run replays --- .../android/replay/ReplayIntegration.kt | 59 ++++++++++--------- .../android/replay/ReplayIntegrationTest.kt | 22 +++++++ 2 files changed, 54 insertions(+), 27 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 a4e05193f3e..8a2d71e5826 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 @@ -704,7 +704,7 @@ public class ReplayIntegration( ) if (segment is ReplaySegment.Created) { - segment.replay.applyPreviousRunOptions() + segment.replay.applyPreviousRunOptions(options) val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) segment.capture(scopes, hint) } @@ -714,32 +714,6 @@ public class ReplayIntegration( } } - /** - * [PreviousReplayHint] is [Backfillable], so MainEventProcessor does not apply release, - * environment, dist or SDK from the current run. Restore them from the options persisted by the - * previous run instead. This runs on the SDK executor before Sentry.init persists the current - * options, so the persisted values still describe the run that recorded this replay. - */ - private fun SentryReplayEvent.applyPreviousRunOptions() { - if (release == null) { - release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java) - } - if (environment == null) { - environment = - PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java) - } - if (dist == null) { - dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java) - } - if (sdk == null) { - // the SDK name does not change between runs, so fall back to the current one rather than - // sending a replay without any SDK info - sdk = - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion::class.java) - ?: options.sdkVersion - } - } - override fun onWindowSizeChanged(width: Int, height: Int) { if (!isEnabled.get() || !state.get().isRecording) { return @@ -811,3 +785,34 @@ public class ReplayIntegration( } } } + +/** + * Replays from the previous run are sent with a [Backfillable] hint, so MainEventProcessor does not + * apply release, environment, dist or SDK from the current run. Restore them from the options + * persisted by the previous run instead. This runs on the SDK executor before Sentry.init persists + * the current options, so the persisted values still describe the run that recorded this replay. + */ +private fun SentryReplayEvent.applyPreviousRunOptions(options: SentryOptions) { + if (release == null) { + release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java) + } + if (environment == null) { + environment = PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java) + } + if (dist == null) { + dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java) + } + if (sdk == null) { + val replaySdkVersion = options.sessionReplay.sdkVersion + sdk = + if (replaySdkVersion != null && replaySdkVersion != options.sdkVersion) { + // Hybrid SDKs override the replay SDK version, which is what MainEventProcessor uses for + // current-run segments. It isn't persisted, but the SDK name doesn't change between runs. + replaySdkVersion + } else { + // fall back to the current SDK rather than sending a replay without any SDK info + PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion::class.java) + ?: options.sdkVersion + } + } +} 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 2f341d9f18a..ac62b26e471 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 @@ -1135,6 +1135,28 @@ class ReplayIntegrationTest { ) } + @Test + fun `register backfills previous replay with the replay sdk version set by hybrid sdks`() { + val oldReplayId = SentryId() + writePreviousReplay(oldReplayId) + PersistingOptionsObserver(fixture.options) + .setSdkVersion(SdkVersion("sentry.java.android.react-native", "1.0.0")) + fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.0.0") + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureReplay( + check { + assertThat(it.replayId).isEqualTo(oldReplayId) + assertThat(it.sdk?.name).isEqualTo("sentry.javascript.react-native") + assertThat(it.sdk?.version).isEqualTo("7.0.0") + }, + any(), + ) + } + private fun writePreviousReplay(replayId: SentryId) { fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) From 67f1f6a1aee3c04b578226e3dedd8164de4ecd3e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:12:20 +0000 Subject: [PATCH 5/7] fix(replay): Store the recording SDK with the replay for previous-run backfill Co-Authored-By: roman Co-Authored-By: George Gritsouk --- .../io/sentry/android/replay/ReplayCache.kt | 10 +++ .../android/replay/ReplayIntegration.kt | 24 +++---- .../replay/capture/BaseCaptureStrategy.kt | 11 ++++ .../sentry/android/replay/ReplayCacheTest.kt | 35 ++++++++++ .../android/replay/ReplayIntegrationTest.kt | 66 +++++++++++-------- .../capture/SessionCaptureStrategyTest.kt | 14 ++++ 6 files changed, 115 insertions(+), 45 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 d20881fd64e..8cd57ac3e28 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 @@ -13,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent import io.sentry.util.AutoClosableReentrantLock @@ -321,6 +322,8 @@ public class ReplayCache(private val options: SentryOptions, private val replayI 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" + internal const val SEGMENT_KEY_SDK_NAME = "replay.sdk-name" + internal const val SEGMENT_KEY_SDK_VERSION = "replay.sdk-version" /** * Deletes [replayCacheDir], but keeps the segment videos it holds. A captured segment's video @@ -475,8 +478,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI replayType = replayType, screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], events = events.sortedBy { it.timestamp }, + sdkVersion = + sdkVersion(lastSegment[SEGMENT_KEY_SDK_NAME], lastSegment[SEGMENT_KEY_SDK_VERSION]), ) } + + private fun sdkVersion(name: String?, version: String?): SdkVersion? = + if (name.isNullOrEmpty() || version.isNullOrEmpty()) null else SdkVersion(name, version) } } @@ -489,6 +497,8 @@ internal data class LastSegmentData( val replayType: ReplayType, val screenAtStart: String?, val events: List, + /** The SDK that recorded the replay, null for replays recorded by older SDK versions. */ + val sdkVersion: SdkVersion?, ) internal data class ReplayFrame( 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 8a2d71e5826..4105df917af 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 @@ -44,11 +44,9 @@ import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME -import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable -import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.transport.RateLimiter @@ -704,6 +702,7 @@ public class ReplayIntegration( ) if (segment is ReplaySegment.Created) { + segment.replay.sdk = lastSegment.sdkVersion segment.replay.applyPreviousRunOptions(options) val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) segment.capture(scopes, hint) @@ -788,9 +787,10 @@ public class ReplayIntegration( /** * Replays from the previous run are sent with a [Backfillable] hint, so MainEventProcessor does not - * apply release, environment, dist or SDK from the current run. Restore them from the options - * persisted by the previous run instead. This runs on the SDK executor before Sentry.init persists - * the current options, so the persisted values still describe the run that recorded this replay. + * apply release, environment, dist or SDK from the current run. Restore release, environment and + * dist from the options persisted by the previous run instead. This runs on the SDK executor before + * Sentry.init persists the current options, so the persisted values still describe the run that + * recorded this replay. The SDK is stored with the replay itself, see [LastSegmentData.sdkVersion]. */ private fun SentryReplayEvent.applyPreviousRunOptions(options: SentryOptions) { if (release == null) { @@ -803,16 +803,8 @@ private fun SentryReplayEvent.applyPreviousRunOptions(options: SentryOptions) { dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java) } if (sdk == null) { - val replaySdkVersion = options.sessionReplay.sdkVersion - sdk = - if (replaySdkVersion != null && replaySdkVersion != options.sdkVersion) { - // Hybrid SDKs override the replay SDK version, which is what MainEventProcessor uses for - // current-run segments. It isn't persisted, but the SDK name doesn't change between runs. - replaySdkVersion - } else { - // fall back to the current SDK rather than sending a replay without any SDK info - PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion::class.java) - ?: options.sdkVersion - } + // replays recorded by older SDK versions don't store the SDK, fall back to the one that + // MainEventProcessor applies to current-run segments rather than sending none + sdk = options.sessionReplay.sdkVersion ?: options.sdkVersion } } 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 17aefb3ebd7..27f8b26b0fe 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 @@ -20,6 +20,8 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_VERSION import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig @@ -27,6 +29,7 @@ import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter import io.sentry.android.replay.util.ReplayRunnable +import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent import io.sentry.transport.ICurrentDateProvider @@ -80,6 +83,12 @@ internal abstract class BaseCaptureStrategy( ) } protected val replayStartTimestamp = AtomicLong() + // persisted so a replay sent on the next launch reports the SDK that recorded it + private var recordingSdkVersion: SdkVersion? by + persistableAtomicNullable(propertyName = "") { _, _, newValue -> + cache?.persistSegmentValues(SEGMENT_KEY_SDK_NAME, newValue?.name) + cache?.persistSegmentValues(SEGMENT_KEY_SDK_VERSION, newValue?.version) + } protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) override var currentReplayId: SentryId by @@ -110,6 +119,8 @@ internal abstract class BaseCaptureStrategy( segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) + // same SDK that MainEventProcessor applies to current-run segments + recordingSdkVersion = options.sessionReplay.sdkVersion ?: options.sdkVersion } override fun resume() { 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..89cd55a44a8 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 @@ -17,6 +17,8 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_VERSION import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.util.ReplayShadowMediaCodec @@ -504,6 +506,39 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + @Test + fun `fromDisk reads the sdk version stored with the replay`() { + 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=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_SDK_NAME=sentry.java.android + $SEGMENT_KEY_SDK_VERSION=8.0.0 + """ + .trimIndent() + ) + } + File(replayCacheFolder, "1720693523997.jpg").outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals("sentry.java.android", lastSegment.sdkVersion?.name) + assertEquals("8.0.0", lastSegment.sdkVersion?.version) + } + @Test fun `preserves segmentId for buffer mode when already flushed`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } 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 ac62b26e471..7475005919b 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 @@ -29,6 +29,8 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_VERSION import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.capture.BufferCaptureStrategy @@ -1089,12 +1091,11 @@ class ReplayIntegrationTest { setRelease("io.sentry.sample@1.0.0+1") setEnvironment("previous-env") setDist("1") - setSdkVersion(SdkVersion("sentry.java.android", "1.0.0")) } // current run options differ and must not be used for the previous replay fixture.options.release = "io.sentry.sample@2.0.0+2" fixture.options.environment = "current-env" - fixture.options.sdkVersion = SdkVersion("sentry.java.android", "2.0.0") + fixture.options.dist = "2" val replay = fixture.getSut(context) replay.register(fixture.scopes, fixture.options) @@ -1106,18 +1107,21 @@ class ReplayIntegrationTest { assertThat(it.release).isEqualTo("io.sentry.sample@1.0.0+1") assertThat(it.environment).isEqualTo("previous-env") assertThat(it.dist).isEqualTo("1") - assertThat(it.sdk?.name).isEqualTo("sentry.java.android") - assertThat(it.sdk?.version).isEqualTo("1.0.0") }, any(), ) } @Test - fun `register falls back to current sdk version when previous run did not persist it`() { + fun `register backfills previous replay with the sdk stored with the replay`() { val oldReplayId = SentryId() - writePreviousReplay(oldReplayId) - fixture.options.sdkVersion = SdkVersion("sentry.java.android", "2.0.0") + writePreviousReplay( + oldReplayId, + sdkName = "sentry.javascript.react-native", + sdkVersion = "7.0.0", + ) + // the current run has a newer SDK, which must not be used for the previous replay + fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.1.0") val replay = fixture.getSut(context) replay.register(fixture.scopes, fixture.options) @@ -1126,22 +1130,18 @@ class ReplayIntegrationTest { .captureReplay( check { assertThat(it.replayId).isEqualTo(oldReplayId) - assertThat(it.sdk?.name).isEqualTo("sentry.java.android") - assertThat(it.sdk?.version).isEqualTo("2.0.0") - assertThat(it.release).isNull() - assertThat(it.environment).isNull() + assertThat(it.sdk?.name).isEqualTo("sentry.javascript.react-native") + assertThat(it.sdk?.version).isEqualTo("7.0.0") }, any(), ) } @Test - fun `register backfills previous replay with the replay sdk version set by hybrid sdks`() { + fun `register falls back to current replay sdk when the previous replay did not store it`() { val oldReplayId = SentryId() writePreviousReplay(oldReplayId) - PersistingOptionsObserver(fixture.options) - .setSdkVersion(SdkVersion("sentry.java.android.react-native", "1.0.0")) - fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.0.0") + fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.1.0") val replay = fixture.getSut(context) replay.register(fixture.scopes, fixture.options) @@ -1151,13 +1151,19 @@ class ReplayIntegrationTest { check { assertThat(it.replayId).isEqualTo(oldReplayId) assertThat(it.sdk?.name).isEqualTo("sentry.javascript.react-native") - assertThat(it.sdk?.version).isEqualTo("7.0.0") + assertThat(it.sdk?.version).isEqualTo("7.1.0") + assertThat(it.release).isNull() + assertThat(it.environment).isNull() }, any(), ) } - private fun writePreviousReplay(replayId: SentryId) { + private fun writePreviousReplay( + replayId: SentryId, + sdkName: String? = null, + sdkVersion: String? = null, + ) { fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) val oldReplay = File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } @@ -1168,19 +1174,21 @@ class ReplayIntegrationTest { val scopeCache = File(fixture.options.cacheDirPath, PersistingScopeObserver.SCOPE_CACHE).also { it.mkdirs() } File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).writeText("\"$replayId\"") - File(oldReplay, ONGOING_SEGMENT) - .writeText( - """ - $SEGMENT_KEY_HEIGHT=912 - $SEGMENT_KEY_WIDTH=416 - $SEGMENT_KEY_FRAME_RATE=1 - $SEGMENT_KEY_BIT_RATE=75000 - $SEGMENT_KEY_ID=0 - $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z - $SEGMENT_KEY_REPLAY_TYPE=BUFFER - """ - .trimIndent() + val segmentValues = + mutableListOf( + "$SEGMENT_KEY_HEIGHT=912", + "$SEGMENT_KEY_WIDTH=416", + "$SEGMENT_KEY_FRAME_RATE=1", + "$SEGMENT_KEY_BIT_RATE=75000", + "$SEGMENT_KEY_ID=0", + "$SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z", + "$SEGMENT_KEY_REPLAY_TYPE=BUFFER", ) + if (sdkName != null && sdkVersion != null) { + segmentValues += "$SEGMENT_KEY_SDK_NAME=$sdkName" + segmentValues += "$SEGMENT_KEY_SDK_VERSION=$sdkVersion" + } + File(oldReplay, ONGOING_SEGMENT).writeText(segmentValues.joinToString("\n")) } @Test 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 e3f4be41b04..dd30b5ecb58 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 @@ -21,11 +21,14 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_VERSION import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP 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.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent @@ -154,6 +157,17 @@ class SessionCaptureStrategyTest { assertEquals(0, strategy.currentSegment) } + @Test + fun `start persists the replay sdk version`() { + fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.0.0") + val strategy = fixture.getSut() + + strategy.start(0, SentryId()) + + assertEquals("sentry.javascript.react-native", fixture.persistedSegment[SEGMENT_KEY_SDK_NAME]) + assertEquals("7.0.0", fixture.persistedSegment[SEGMENT_KEY_SDK_VERSION]) + } + @Test fun `start persists segment values`() { val strategy = fixture.getSut() From c1b121d7e4c8837a669c89f984325d3a326b5303 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:24:29 +0000 Subject: [PATCH 6/7] fix(replay): Store release, environment and dist with the replay for previous-run backfill Co-Authored-By: roman --- .../io/sentry/android/replay/ReplayCache.kt | 45 ++++++++++++++++-- .../android/replay/ReplayIntegration.kt | 34 +++++++------- .../replay/capture/BaseCaptureStrategy.kt | 30 +++++++++--- .../sentry/android/replay/ReplayCacheTest.kt | 46 +++++++++++++++++-- .../android/replay/ReplayIntegrationTest.kt | 33 ++++++++----- .../capture/SessionCaptureStrategyTest.kt | 21 ++++++++- 6 files changed, 166 insertions(+), 43 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 8cd57ac3e28..5a699bed526 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 @@ -324,6 +324,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI internal const val SEGMENT_KEY_FLUSHED = "replay.flushed" internal const val SEGMENT_KEY_SDK_NAME = "replay.sdk-name" internal const val SEGMENT_KEY_SDK_VERSION = "replay.sdk-version" + internal const val SEGMENT_KEY_RELEASE = "replay.release" + internal const val SEGMENT_KEY_ENVIRONMENT = "replay.environment" + internal const val SEGMENT_KEY_DIST = "replay.dist" + private val RECORDING_METADATA_KEYS = + listOf( + SEGMENT_KEY_RELEASE, + SEGMENT_KEY_ENVIRONMENT, + SEGMENT_KEY_DIST, + SEGMENT_KEY_SDK_NAME, + SEGMENT_KEY_SDK_VERSION, + ) /** * Deletes [replayCacheDir], but keeps the segment videos it holds. A captured segment's video @@ -478,13 +489,26 @@ public class ReplayCache(private val options: SentryOptions, private val replayI replayType = replayType, screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], events = events.sortedBy { it.timestamp }, - sdkVersion = - sdkVersion(lastSegment[SEGMENT_KEY_SDK_NAME], lastSegment[SEGMENT_KEY_SDK_VERSION]), + metadata = recordingMetadata(lastSegment), ) } - private fun sdkVersion(name: String?, version: String?): SdkVersion? = - if (name.isNullOrEmpty() || version.isNullOrEmpty()) null else SdkVersion(name, version) + private fun recordingMetadata(lastSegment: Map): RecordingMetadata? { + if (RECORDING_METADATA_KEYS.none { lastSegment.containsKey(it) }) { + // recorded by an older SDK version + return null + } + val sdkName = lastSegment[SEGMENT_KEY_SDK_NAME] + val sdkVersion = lastSegment[SEGMENT_KEY_SDK_VERSION] + return RecordingMetadata( + release = lastSegment[SEGMENT_KEY_RELEASE], + environment = lastSegment[SEGMENT_KEY_ENVIRONMENT], + dist = lastSegment[SEGMENT_KEY_DIST], + sdkVersion = + if (sdkName.isNullOrEmpty() || sdkVersion.isNullOrEmpty()) null + else SdkVersion(sdkName, sdkVersion), + ) + } } } @@ -497,7 +521,18 @@ internal data class LastSegmentData( val replayType: ReplayType, val screenAtStart: String?, val events: List, - /** The SDK that recorded the replay, null for replays recorded by older SDK versions. */ + /** Null for replays recorded by SDK versions that didn't persist it. */ + val metadata: RecordingMetadata?, +) + +/** + * Event metadata of the run that recorded a replay, persisted when recording starts so a replay + * sent on the next launch describes the run that recorded it. + */ +internal data class RecordingMetadata( + val release: String?, + val environment: String?, + val dist: String?, val sdkVersion: SdkVersion?, ) 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 4105df917af..4b9d1eb89c4 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 @@ -702,8 +702,7 @@ public class ReplayIntegration( ) if (segment is ReplaySegment.Created) { - segment.replay.sdk = lastSegment.sdkVersion - segment.replay.applyPreviousRunOptions(options) + segment.replay.applyRecordingMetadata(lastSegment.metadata, options) val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) segment.capture(scopes, hint) } @@ -787,24 +786,27 @@ public class ReplayIntegration( /** * Replays from the previous run are sent with a [Backfillable] hint, so MainEventProcessor does not - * apply release, environment, dist or SDK from the current run. Restore release, environment and - * dist from the options persisted by the previous run instead. This runs on the SDK executor before - * Sentry.init persists the current options, so the persisted values still describe the run that - * recorded this replay. The SDK is stored with the replay itself, see [LastSegmentData.sdkVersion]. + * apply release, environment, dist or SDK from the current run. Use the values persisted with the + * replay when it started recording instead. + * + * Replays recorded by older SDK versions don't have them stored. For those, read release, + * environment and dist from the options persisted by the previous run: this runs on the SDK + * executor before Sentry.init persists the current options, so they still describe that run. The + * SDK falls back to the one MainEventProcessor applies to current-run segments rather than none. */ -private fun SentryReplayEvent.applyPreviousRunOptions(options: SentryOptions) { - if (release == null) { +private fun SentryReplayEvent.applyRecordingMetadata( + metadata: RecordingMetadata?, + options: SentryOptions, +) { + if (metadata != null) { + release = metadata.release + environment = metadata.environment + dist = metadata.dist + sdk = metadata.sdkVersion ?: options.sessionReplay.sdkVersion ?: options.sdkVersion + } else { release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java) - } - if (environment == null) { environment = PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java) - } - if (dist == null) { dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java) - } - if (sdk == null) { - // replays recorded by older SDK versions don't store the SDK, fall back to the one that - // MainEventProcessor applies to current-run segments rather than sending none sdk = options.sessionReplay.sdkVersion ?: options.sdkVersion } } 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 27f8b26b0fe..3c8af759cb0 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 @@ -11,12 +11,16 @@ import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.RecordingMetadata 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_DIST +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ENVIRONMENT 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 +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_RELEASE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE @@ -29,7 +33,6 @@ import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter import io.sentry.android.replay.util.ReplayRunnable -import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent import io.sentry.transport.ICurrentDateProvider @@ -83,11 +86,13 @@ internal abstract class BaseCaptureStrategy( ) } protected val replayStartTimestamp = AtomicLong() - // persisted so a replay sent on the next launch reports the SDK that recorded it - private var recordingSdkVersion: SdkVersion? by + private var recordingMetadata: RecordingMetadata? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> - cache?.persistSegmentValues(SEGMENT_KEY_SDK_NAME, newValue?.name) - cache?.persistSegmentValues(SEGMENT_KEY_SDK_VERSION, newValue?.version) + persistMetadataValue(SEGMENT_KEY_RELEASE, newValue?.release) + persistMetadataValue(SEGMENT_KEY_ENVIRONMENT, newValue?.environment) + persistMetadataValue(SEGMENT_KEY_DIST, newValue?.dist) + persistMetadataValue(SEGMENT_KEY_SDK_NAME, newValue?.sdkVersion?.name) + persistMetadataValue(SEGMENT_KEY_SDK_VERSION, newValue?.sdkVersion?.version) } protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) @@ -119,8 +124,19 @@ internal abstract class BaseCaptureStrategy( segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) - // same SDK that MainEventProcessor applies to current-run segments - recordingSdkVersion = options.sessionReplay.sdkVersion ?: options.sdkVersion + // the same values MainEventProcessor applies to current-run segments + recordingMetadata = + RecordingMetadata( + release = options.release, + environment = options.environment, + dist = options.dist, + sdkVersion = options.sessionReplay.sdkVersion ?: options.sdkVersion, + ) + } + + private fun persistMetadataValue(key: String, value: String?) { + // the segment file stores one key=value per line, so values with line breaks can't be stored + cache?.persistSegmentValues(key, value?.takeIf { '\n' !in it && '\r' !in it }) } override fun resume() { 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 89cd55a44a8..5607ebd87c6 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 @@ -11,10 +11,13 @@ 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_DIST +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ENVIRONMENT 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 +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_RELEASE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME @@ -507,7 +510,7 @@ class ReplayCacheTest { } @Test - fun `fromDisk reads the sdk version stored with the replay`() { + fun `fromDisk reads the recording metadata stored with the replay`() { fixture.options.run { cacheDirPath = tmpDir.newFolder()?.absolutePath } val replayId = SentryId() val replayCacheFolder = @@ -522,6 +525,9 @@ class ReplayCacheTest { $SEGMENT_KEY_ID=0 $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z $SEGMENT_KEY_REPLAY_TYPE=BUFFER + $SEGMENT_KEY_RELEASE=io.sentry.sample@1.0.0+1 + $SEGMENT_KEY_ENVIRONMENT=recording-env + $SEGMENT_KEY_DIST=1 $SEGMENT_KEY_SDK_NAME=sentry.java.android $SEGMENT_KEY_SDK_VERSION=8.0.0 """ @@ -535,8 +541,42 @@ class ReplayCacheTest { val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! - assertEquals("sentry.java.android", lastSegment.sdkVersion?.name) - assertEquals("8.0.0", lastSegment.sdkVersion?.version) + val metadata = lastSegment.metadata!! + assertEquals("io.sentry.sample@1.0.0+1", metadata.release) + assertEquals("recording-env", metadata.environment) + assertEquals("1", metadata.dist) + assertEquals("sentry.java.android", metadata.sdkVersion?.name) + assertEquals("8.0.0", metadata.sdkVersion?.version) + } + + @Test + fun `fromDisk returns no recording metadata for replays recorded by older sdks`() { + 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=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """ + .trimIndent() + ) + } + File(replayCacheFolder, "1720693523997.jpg").outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertNull(lastSegment.metadata) } @Test 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 7475005919b..14d500cb615 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 @@ -24,9 +24,11 @@ import io.sentry.SentryReplayOptions import io.sentry.TypeCheckHint 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_ENVIRONMENT 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 +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_RELEASE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME @@ -1084,7 +1086,7 @@ class ReplayIntegrationTest { } @Test - fun `register backfills previous replay with options persisted by the previous run`() { + fun `register backfills previous replay recorded by an older sdk from persisted options`() { val oldReplayId = SentryId() writePreviousReplay(oldReplayId) PersistingOptionsObserver(fixture.options).apply { @@ -1113,14 +1115,24 @@ class ReplayIntegrationTest { } @Test - fun `register backfills previous replay with the sdk stored with the replay`() { + fun `register backfills previous replay with the metadata stored with the replay`() { val oldReplayId = SentryId() writePreviousReplay( oldReplayId, - sdkName = "sentry.javascript.react-native", - sdkVersion = "7.0.0", + mapOf( + SEGMENT_KEY_RELEASE to "io.sentry.sample@1.0.0+1", + SEGMENT_KEY_ENVIRONMENT to "recording-env", + SEGMENT_KEY_SDK_NAME to "sentry.javascript.react-native", + SEGMENT_KEY_SDK_VERSION to "7.0.0", + ), ) - // the current run has a newer SDK, which must not be used for the previous replay + // neither the persisted options nor the current ones may be mixed in + PersistingOptionsObserver(fixture.options).apply { + setRelease("io.sentry.sample@0.9.0+0") + setEnvironment("persisted-env") + setDist("0") + } + fixture.options.release = "io.sentry.sample@2.0.0+2" fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.1.0") val replay = fixture.getSut(context) @@ -1130,6 +1142,9 @@ class ReplayIntegrationTest { .captureReplay( check { assertThat(it.replayId).isEqualTo(oldReplayId) + assertThat(it.release).isEqualTo("io.sentry.sample@1.0.0+1") + assertThat(it.environment).isEqualTo("recording-env") + assertThat(it.dist).isNull() assertThat(it.sdk?.name).isEqualTo("sentry.javascript.react-native") assertThat(it.sdk?.version).isEqualTo("7.0.0") }, @@ -1161,8 +1176,7 @@ class ReplayIntegrationTest { private fun writePreviousReplay( replayId: SentryId, - sdkName: String? = null, - sdkVersion: String? = null, + extraSegmentValues: Map = emptyMap(), ) { fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) @@ -1184,10 +1198,7 @@ class ReplayIntegrationTest { "$SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z", "$SEGMENT_KEY_REPLAY_TYPE=BUFFER", ) - if (sdkName != null && sdkVersion != null) { - segmentValues += "$SEGMENT_KEY_SDK_NAME=$sdkName" - segmentValues += "$SEGMENT_KEY_SDK_VERSION=$sdkVersion" - } + extraSegmentValues.forEach { (key, value) -> segmentValues += "$key=$value" } File(oldReplay, ONGOING_SEGMENT).writeText(segmentValues.joinToString("\n")) } 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 dd30b5ecb58..d2e75507d3e 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 @@ -16,9 +16,12 @@ import io.sentry.android.replay.DefaultReplayBreadcrumbConverter import io.sentry.android.replay.GeneratedVideo 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_DIST +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ENVIRONMENT 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 +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_RELEASE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME @@ -158,16 +161,32 @@ class SessionCaptureStrategyTest { } @Test - fun `start persists the replay sdk version`() { + fun `start persists the recording metadata`() { + fixture.options.release = "io.sentry.sample@1.0.0+1" + fixture.options.environment = "recording-env" + fixture.options.dist = "1" fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.0.0") val strategy = fixture.getSut() strategy.start(0, SentryId()) + assertEquals("io.sentry.sample@1.0.0+1", fixture.persistedSegment[SEGMENT_KEY_RELEASE]) + assertEquals("recording-env", fixture.persistedSegment[SEGMENT_KEY_ENVIRONMENT]) + assertEquals("1", fixture.persistedSegment[SEGMENT_KEY_DIST]) assertEquals("sentry.javascript.react-native", fixture.persistedSegment[SEGMENT_KEY_SDK_NAME]) assertEquals("7.0.0", fixture.persistedSegment[SEGMENT_KEY_SDK_VERSION]) } + @Test + fun `start does not persist recording metadata with line breaks`() { + fixture.options.environment = "multi\nline" + val strategy = fixture.getSut() + + strategy.start(0, SentryId()) + + assertNull(fixture.persistedSegment[SEGMENT_KEY_ENVIRONMENT]) + } + @Test fun `start persists segment values`() { val strategy = fixture.getSut() From 9a3cfc9f23a03b022d32f700d87f9b33b8643b03 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:54:30 +0000 Subject: [PATCH 7/7] test(replay): Guard the init order the previous-replay backfill relies on --- .../io/sentry/android/replay/ReplayCache.kt | 2 +- .../replay/PreviousReplayIntegrationTest.kt | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/PreviousReplayIntegrationTest.kt 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 5a699bed526..21d8f88c7f8 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 @@ -494,7 +494,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } private fun recordingMetadata(lastSegment: Map): RecordingMetadata? { - if (RECORDING_METADATA_KEYS.none { lastSegment.containsKey(it) }) { + if (RECORDING_METADATA_KEYS.none { it in lastSegment }) { // recorded by an older SDK version return null } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/PreviousReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/PreviousReplayIntegrationTest.kt new file mode 100644 index 00000000000..5999ac9826b --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/PreviousReplayIntegrationTest.kt @@ -0,0 +1,112 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryReplayEvent +import io.sentry.android.core.performance.AppStartMetrics +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_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME +import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE +import io.sentry.protocol.SentryId +import java.io.File +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.awaitility.kotlin.await +import org.awaitility.kotlin.untilNotNull +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30], shadows = [ReplayShadowMediaCodec::class]) +class PreviousReplayIntegrationTest { + @get:Rule val tmpDir = TemporaryFolder() + + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 1 + Sentry.close() + AppStartMetrics.getInstance().clear() + context = ApplicationProvider.getApplicationContext() + } + + // Replays recorded by older SDK versions don't store their metadata, so the previous replay is + // backfilled from the options cache. That only works while ReplayIntegration reads the cache + // before Sentry.init overwrites it with the current options. + @Test + fun `previous replay without stored metadata is backfilled from the previous run options`() { + val cacheDir = tmpDir.newFolder().absolutePath + val replayId = SentryId() + val capturedReplay = AtomicReference() + + initForTest(context) { + it.dsn = "https://key@sentry.io/123" + it.cacheDirPath = cacheDir + it.release = "io.sentry.sample@2.0.0+2" + it.environment = "current-env" + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { + capturedReplay.set(event) + return event + } + } + ) + + // have to do it after the cacheDir is set to options, because it adds a dsn hash after + val optionsCache = File(it.cacheDirPath!!, OPTIONS_CACHE).also { dir -> dir.mkdirs() } + File(optionsCache, RELEASE_FILENAME).writeText("\"io.sentry.sample@1.0.0+1\"") + File(optionsCache, ENVIRONMENT_FILENAME).writeText("\"previous-env\"") + + val scopeCache = File(it.cacheDirPath!!, SCOPE_CACHE).also { dir -> dir.mkdirs() } + File(scopeCache, REPLAY_FILENAME).writeText("\"$replayId\"") + + val replayFolder = File(it.cacheDirPath!!, "replay_$replayId").also { dir -> dir.mkdirs() } + File(replayFolder, ONGOING_SEGMENT) + .writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """ + .trimIndent() + ) + File(replayFolder, "1720693523997.jpg").outputStream().use { os -> + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, os) + os.flush() + } + } + + val replay = await.untilNotNull { capturedReplay.get() } + assertEquals(replayId, replay.replayId) + assertEquals("io.sentry.sample@1.0.0+1", replay.release) + assertEquals("previous-env", replay.environment) + } +}