diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cdbc9bf6f..5db2be07e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,8 @@ ### Fixes -- Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717)) +- Session Replay: Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717)) +- Session Replay: Fix continue recording in Session mode after Buffer is triggered ([#4719](https://github.com/getsentry/sentry-java/pull/4719)) ### Dependencies 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 ae8f849f9f8..7e76f92aa7e 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 @@ -60,7 +60,7 @@ internal abstract class BaseCaptureStrategy( protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected var recorderConfig: ScreenshotRecorderConfig? by + internal var recorderConfig: ScreenshotRecorderConfig? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> if (newValue == null) { // recorderConfig is only nullable on init, but never after diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 68ce255f02f..706a958f3f8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -146,6 +146,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, replayId = currentReplayId, 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 2ef2f09890e..7b17c77a3b9 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 @@ -27,10 +27,12 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDI 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.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException @@ -43,6 +45,7 @@ import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import io.sentry.transport.RateLimiter +import io.sentry.util.Random import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest @@ -63,13 +66,14 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) +@Config(sdk = [26], shadows = [ReplayShadowMediaCodec::class]) class ReplayIntegrationTest { @get:Rule val tmpDir = TemporaryFolder() @@ -726,6 +730,58 @@ class ReplayIntegrationTest { verify(recorder).resume() } + @Test + fun `continues recording after converting to session strategy without extra config change`() { + // Force buffer mode at start, but enable onError sample so captureReplay triggers + val recorder = mock() + val replay = + fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { isFullSession -> + // Always start with buffer strategy regardless of sampling + BufferCaptureStrategy( + fixture.options, + fixture.scopes, + // make time jump so session strategy will immediately cut a segment on next frame + ICurrentDateProvider { + System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration + }, + Random(), + // run tasks synchronously in tests + mock { + doAnswer { (it.arguments[0] as Runnable).run() } + .whenever(mock) + .submit(any()) + }, + ) { _ -> + fixture.replayCache + } + }, + ) + + fixture.options.sessionReplay.sessionSampleRate = 0.0 // ensure buffer mode initially + fixture.options.sessionReplay.onErrorSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val config = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + replay.onConfigurationChanged(config) + + // Trigger convert() via captureReplay + replay.captureReplay(false) + + // Now, without invoking another config change, record a frame + // Reset interactions to assert only post-convert capture + reset(fixture.scopes) + replay.onScreenshotRecorded(mock()) + + // Should capture a session segment after conversion without additional config changes + verify(fixture.scopes).captureReplay(any(), any()) + } + @Test fun `closed replay cannot be started`() { val replay = fixture.getSut(context)