diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9f5493972..3a51e15797 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)).
diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml
index 54c6754e2d..2adcb41234 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/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt
index d20881fd64..21d8f88c7f 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,19 @@ 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"
+ 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
@@ -475,6 +489,24 @@ 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 },
+ metadata = recordingMetadata(lastSegment),
+ )
+ }
+
+ private fun recordingMetadata(lastSegment: Map): RecordingMetadata? {
+ if (RECORDING_METADATA_KEYS.none { it in lastSegment }) {
+ // 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),
)
}
}
@@ -489,6 +521,19 @@ internal data class LastSegmentData(
val replayType: ReplayType,
val screenAtStart: String?,
val events: List,
+ /** 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?,
)
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 edb3f9c03a..4b9d1eb89c 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,6 +40,10 @@ 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.PersistingScopeObserver.BREADCRUMBS_FILENAME
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
import io.sentry.hints.Backfillable
@@ -653,10 +658,6 @@ 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
-
options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") {
val persistingScopeObserver = options.findPersistingScopeObserver()
val previousReplayIdString =
@@ -701,6 +702,7 @@ public class ReplayIntegration(
)
if (segment is ReplaySegment.Created) {
+ segment.replay.applyRecordingMetadata(lastSegment.metadata, options)
val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint())
segment.capture(scopes, hint)
}
@@ -781,3 +783,30 @@ 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. 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.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)
+ environment = PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java)
+ dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java)
+ 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 17aefb3ebd..3c8af759cb 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,15 +11,21 @@ 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
+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
@@ -80,6 +86,14 @@ internal abstract class BaseCaptureStrategy(
)
}
protected val replayStartTimestamp = AtomicLong()
+ private var recordingMetadata: RecordingMetadata? by
+ persistableAtomicNullable(propertyName = "") { _, _, newValue ->
+ 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)
override var currentReplayId: SentryId by
@@ -110,6 +124,19 @@ internal abstract class BaseCaptureStrategy(
segmentTimestamp = DateUtils.getCurrentDateTime()
replayStartTimestamp.set(dateProvider.currentTimeMillis)
+ // 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/PreviousReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/PreviousReplayIntegrationTest.kt
new file mode 100644
index 0000000000..5999ac9826
--- /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)
+ }
+}
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 b3f3307837..5607ebd87c 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,12 +11,17 @@ 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
+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 +509,76 @@ class ReplayCacheTest {
assertEquals(0, lastSegment.id)
}
+ @Test
+ fun `fromDisk reads the recording metadata 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_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
+ """
+ .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)!!
+
+ 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
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 932f26a2b6..14d500cb61 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,11 +24,15 @@ 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
+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
@@ -38,8 +42,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 +1085,123 @@ class ReplayIntegrationTest {
)
}
+ @Test
+ fun `register backfills previous replay recorded by an older sdk from persisted options`() {
+ val oldReplayId = SentryId()
+ writePreviousReplay(oldReplayId)
+ PersistingOptionsObserver(fixture.options).apply {
+ setRelease("io.sentry.sample@1.0.0+1")
+ setEnvironment("previous-env")
+ setDist("1")
+ }
+ // 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.dist = "2"
+
+ 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")
+ },
+ any(),
+ )
+ }
+
+ @Test
+ fun `register backfills previous replay with the metadata stored with the replay`() {
+ val oldReplayId = SentryId()
+ writePreviousReplay(
+ oldReplayId,
+ 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",
+ ),
+ )
+ // 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)
+ 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("recording-env")
+ assertThat(it.dist).isNull()
+ assertThat(it.sdk?.name).isEqualTo("sentry.javascript.react-native")
+ assertThat(it.sdk?.version).isEqualTo("7.0.0")
+ },
+ any(),
+ )
+ }
+
+ @Test
+ fun `register falls back to current replay sdk when the previous replay did not store it`() {
+ val oldReplayId = SentryId()
+ writePreviousReplay(oldReplayId)
+ fixture.options.sessionReplay.sdkVersion = SdkVersion("sentry.javascript.react-native", "7.1.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.1.0")
+ assertThat(it.release).isNull()
+ assertThat(it.environment).isNull()
+ },
+ any(),
+ )
+ }
+
+ private fun writePreviousReplay(
+ replayId: SentryId,
+ extraSegmentValues: Map = emptyMap(),
+ ) {
+ 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\"")
+ 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",
+ )
+ extraSegmentValues.forEach { (key, value) -> segmentValues += "$key=$value" }
+ File(oldReplay, ONGOING_SEGMENT).writeText(segmentValues.joinToString("\n"))
+ }
+
@Test
fun `register cleans up old replays`() {
val replayId = SentryId()
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 e3f4be41b0..d2e75507d3 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,16 +16,22 @@ 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
+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 +160,33 @@ class SessionCaptureStrategyTest {
assertEquals(0, strategy.currentSegment)
}
+ @Test
+ 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()