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()