From c9dbf684f6332a03f85f20995623787bf42b2770 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Sep 2025 15:06:45 +0200 Subject: [PATCH 1/5] fix(replay): Consider concurrent access to ReplayCache --- .../io/sentry/android/replay/ReplayCache.kt | 35 +++--- .../replay/capture/BufferCaptureStrategy.kt | 8 +- .../replay/capture/SessionCaptureStrategy.kt | 2 +- .../sentry/android/replay/ReplayCacheTest.kt | 104 ++++++++++++++++++ .../capture/BufferCaptureStrategyTest.kt | 68 ++++++++++++ 5 files changed, 198 insertions(+), 19 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 4c3e348153c..5cf0b0ce3e4 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 @@ -42,11 +42,11 @@ public class ReplayCache(private val options: SentryOptions, private val replayI private val isClosed = AtomicBoolean(false) private val encoderLock = AutoClosableReentrantLock() private val lock = AutoClosableReentrantLock() + private val framesLock = AutoClosableReentrantLock() private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { makeReplayCacheDir(options, replayId) } - // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() @@ -98,9 +98,13 @@ public class ReplayCache(private val options: SentryOptions, private val replayI */ public fun addFrame(screenshot: File, frameTimestamp: Long, screen: String? = null) { val frame = ReplayFrame(screenshot, frameTimestamp, screen) - frames += frame + framesLock.acquire().use { frames += frame } } + /** Returns the timestamp of the first frame if available in a thread-safe manner. */ + internal fun firstFrameTimestamp(): Long? = + framesLock.acquire().use { frames.firstOrNull()?.timestamp } + /** * Creates a video out of currently stored [frames] given the start time and duration using the * on-device codecs [android.media.MediaCodec]. The generated video will be stored in [videoFile] @@ -134,7 +138,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (videoFile.exists() && videoFile.length() > 0) { videoFile.delete() } - if (frames.isEmpty()) { + // Work on a snapshot of frames to avoid races with writers + val framesSnapshot = + framesLock.acquire().use { if (frames.isEmpty()) emptyList() else frames.toList() } + if (framesSnapshot.isEmpty()) { options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null } @@ -156,9 +163,9 @@ public class ReplayCache(private val options: SentryOptions, private val replayI val step = 1000 / frameRate.toLong() var frameCount = 0 - var lastFrame: ReplayFrame? = frames.first() + var lastFrame: ReplayFrame? = framesSnapshot.firstOrNull() for (timestamp in from until (from + (duration)) step step) { - val iter = frames.iterator() + val iter = framesSnapshot.iterator() while (iter.hasNext()) { val frame = iter.next() if (frame.timestamp in (timestamp..timestamp + step)) { @@ -180,7 +187,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI // if we failed to encode the frame, we delete the screenshot right away as the // likelihood of it being able to be encoded later is low deleteFile(lastFrame.screenshot) - frames.remove(lastFrame) + framesLock.acquire().use { frames.remove(lastFrame) } lastFrame = null } } @@ -240,14 +247,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI */ internal fun rotate(until: Long): String? { var screen: String? = null - frames.removeAll { - if (it.timestamp < until) { - deleteFile(it.screenshot) - return@removeAll true - } else if (screen == null) { - screen = it.screen + framesLock.acquire().use { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } else if (screen == null) { + screen = it.screen + } + return@removeAll false } - return@removeAll false } return screen } 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 bdb3be62237..68ce255f02f 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 @@ -217,12 +217,10 @@ internal class BufferCaptureStrategy( val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = - if (cache?.frames?.isNotEmpty() == true) { + cache?.firstFrameTimestamp()?.let { // in buffer mode we have to set the timestamp of the first frame as the actual start - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) - } + DateUtils.getDateTime(it) + } ?: DateUtils.getDateTime(now - errorReplayDuration) val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 6640cbf9faa..cc007d07067 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -98,7 +98,7 @@ internal class SessionCaptureStrategy( } if (currentConfig == null) { - options.logger.log(DEBUG, "Recorder config is not set, not recording frame") + options.logger.log(DEBUG, "Recorder config is not set, not capturing a segment") return@submitSafely } 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 452dd343fd1..257941a9114 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 @@ -22,6 +22,8 @@ import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -493,4 +495,106 @@ class ReplayCacheTest { assertTrue(replayCache.frames.isEmpty()) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) } + + @Test + fun `firstFrameTimestamp returns first timestamp when available`() { + val replayCache = fixture.getSut(tmpDir) + + assertNull(replayCache.firstFrameTimestamp()) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 42) + replayCache.addFrame(bitmap, 1001) + + assertEquals(42L, replayCache.firstFrameTimestamp()) + } + + @Test + fun `firstFrameTimestamp is safe under concurrent rotate and add`() { + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + repeat(10) { i -> replayCache.addFrame(bitmap, (i + 1).toLong()) } + + val start = CountDownLatch(1) + val done = CountDownLatch(2) + val error = AtomicReference() + + val tReader = Thread { + try { + start.await() + repeat(500) { + replayCache.firstFrameTimestamp() + Thread.yield() + } + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + val tWriter = Thread { + try { + start.await() + repeat(500) { i -> + if (i % 2 == 0) { + // delete all frames occasionally + replayCache.rotate(Long.MAX_VALUE) + } else { + // add a fresh frame + replayCache.addFrame(bitmap, System.currentTimeMillis()) + } + } + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + tReader.start() + tWriter.start() + start.countDown() + done.await() + + // No crash is success + assertNull(error.get()) + } + + @Test + fun `createVideoOf tolerates concurrent rotate without crashing`() { + ReplayShadowMediaCodec.framesToEncode = 3 + val replayCache = fixture.getSut(tmpDir) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + // prepare a few frames that might be deleted during encoding + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val start = CountDownLatch(1) + val done = CountDownLatch(1) + val error = AtomicReference() + + val tEncoder = Thread { + try { + start.await() + replayCache.createVideoOf(3000L, 0L, 0, 100, 200, 1, 20_000) + } catch (t: Throwable) { + error.set(t) + } finally { + done.countDown() + } + } + + tEncoder.start() + start.countDown() + // rotate while encoding to simulate concurrent mutation + replayCache.rotate(Long.MAX_VALUE) + done.await() + + // No crash is success + assertNull(error.get()) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index e0aa07a77c7..b3fb9058a95 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -254,6 +254,74 @@ class BufferCaptureStrategyTest { assertEquals(ReplayType.BUFFER, converted.replayType) } + @Test + fun `createCurrentSegment uses first frame timestamp when available`() { + val now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + // Stub first frame timestamp and capture the 'from' argument to createVideoOf + whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(1234L) + + var capturedFrom: Long = -1 + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenAnswer { invocation -> + capturedFrom = invocation.arguments[1] as Long + GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION) + } + + strategy.pause() + + assertEquals(1234L, capturedFrom) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `createCurrentSegment falls back to buffer start when no frames`() { + val now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + // No frames available + whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(null) + + var capturedFrom: Long = -1 + whenever( + fixture.replayCache.createVideoOf( + anyLong(), + anyLong(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + ) + .thenAnswer { invocation -> + capturedFrom = invocation.arguments[1] as Long + GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION) + } + + strategy.pause() + + assertEquals(now - fixture.options.sessionReplay.errorReplayDuration, capturedFrom) + assertEquals(1, strategy.currentSegment) + } + @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(onErrorSampleRate = 0.0) From fd146ffb7e28fe3bf25e623c742ab80b9c95a087 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 10 Sep 2025 14:51:18 +0200 Subject: [PATCH 2/5] Move SentryLogs out of experimental (#4710) * removed all @Experimental annotations from SentryLogs code --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/ExternalOptions.java | 2 -- sentry/src/main/java/io/sentry/HubAdapter.java | 1 - sentry/src/main/java/io/sentry/HubScopesWrapper.java | 1 - sentry/src/main/java/io/sentry/IScopes.java | 1 - sentry/src/main/java/io/sentry/ISentryClient.java | 1 - sentry/src/main/java/io/sentry/NoOpHub.java | 1 - sentry/src/main/java/io/sentry/NoOpScopes.java | 1 - sentry/src/main/java/io/sentry/NoOpSentryClient.java | 1 - sentry/src/main/java/io/sentry/ScopesAdapter.java | 1 - sentry/src/main/java/io/sentry/Sentry.java | 1 - sentry/src/main/java/io/sentry/SentryOptions.java | 8 ++------ sentry/src/main/java/io/sentry/logger/ILoggerApi.java | 2 -- sentry/src/main/java/io/sentry/logger/LoggerApi.java | 2 -- sentry/src/main/java/io/sentry/logger/NoOpLoggerApi.java | 2 -- .../java/io/sentry/logger/NoOpLoggerBatchProcessor.java | 2 -- 16 files changed, 3 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6050ae401dc..16b1bb53246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Move SentryLogs out of experimental ([#4710](https://github.com/getsentry/sentry-java/pull/4710)) - Add support for w3c traceparent header ([#4671](https://github.com/getsentry/sentry-java/pull/4671)) - This feature is disabled by default. If enabled, outgoing requests will include the w3c `traceparent` header. - See https://develop.sentry.dev/sdk/telemetry/traces/distributed-tracing/#w3c-trace-context-header for more details. diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 3e40d05543d..4382477d5a0 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -522,12 +522,10 @@ public void setCaptureOpenTelemetryEvents(final @Nullable Boolean captureOpenTel return captureOpenTelemetryEvents; } - @ApiStatus.Experimental public void setEnableLogs(final @Nullable Boolean enableLogs) { this.enableLogs = enableLogs; } - @ApiStatus.Experimental public @Nullable Boolean isEnableLogs() { return enableLogs; } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index c6af24870f2..f9065dd64c1 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -384,7 +384,6 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().getRateLimiter(); } - @ApiStatus.Experimental @Override public @NotNull ILoggerApi logger() { return Sentry.getCurrentScopes().logger(); diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index 7de59538c52..f6e2b394093 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -370,7 +370,6 @@ public void reportFullyDisplayed() { return scopes.captureReplay(replay, hint); } - @ApiStatus.Experimental @Override public @NotNull ILoggerApi logger() { return scopes.logger(); diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index 87081a1852a..7633bf2d579 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -742,7 +742,6 @@ default boolean isNoOp() { @NotNull SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint); - @ApiStatus.Experimental @NotNull ILoggerApi logger(); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index b01988a72a8..6efcca6309c 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -304,7 +304,6 @@ SentryId captureProfileChunk( @ApiStatus.Experimental SentryId captureCheckIn(@NotNull CheckIn checkIn, @Nullable IScope scope, @Nullable Hint hint); - @ApiStatus.Experimental void captureLog(@NotNull SentryLogEvent logEvent, @Nullable IScope scope); @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 1c5806fdf95..67a6ded5e5a 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -327,7 +327,6 @@ public boolean isNoOp() { return true; } - @ApiStatus.Experimental @Override public @NotNull ILoggerApi logger() { return NoOpLoggerApi.getInstance(); diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 34adb15769b..4e039a7b508 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -324,7 +324,6 @@ public boolean isNoOp() { return SentryId.EMPTY_ID; } - @ApiStatus.Experimental @Override public @NotNull ILoggerApi logger() { return NoOpLoggerApi.getInstance(); diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 97eed16b9ce..904228b8a0b 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -84,7 +84,6 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint return SentryId.EMPTY_ID; } - @ApiStatus.Experimental @Override public void captureLog(@NotNull SentryLogEvent logEvent, @Nullable IScope scope) { // do nothing diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index 6c3816afd91..86d316b967f 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -381,7 +381,6 @@ public void reportFullyDisplayed() { return Sentry.getCurrentScopes().captureReplay(replay, hint); } - @ApiStatus.Experimental @Override public @NotNull ILoggerApi logger() { return Sentry.getCurrentScopes().logger(); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index d5a9d5efe6b..3c04a6aaa96 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1291,7 +1291,6 @@ public interface OptionsConfiguration { return getCurrentScopes().captureCheckIn(checkIn); } - @ApiStatus.Experimental @NotNull public static ILoggerApi logger() { return getCurrentScopes().logger(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index dac7c7c488b..781e287253b 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3474,20 +3474,19 @@ public void setDefaultRecoveryThreshold(@Nullable Long defaultRecoveryThreshold) public static final class Logs { /** Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. */ - @ApiStatus.Experimental private boolean enable = false; + private boolean enable = false; /** * This function is called with an SDK specific log event object and can return a modified event * object or nothing to skip reporting the log item */ - @ApiStatus.Experimental private @Nullable BeforeSendLogCallback beforeSend; + private @Nullable BeforeSendLogCallback beforeSend; /** * Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. * * @return true if Sentry Logs should be enabled */ - @ApiStatus.Experimental public boolean isEnabled() { return enable; } @@ -3497,7 +3496,6 @@ public boolean isEnabled() { * * @param enableLogs true if Sentry Logs should be enabled */ - @ApiStatus.Experimental public void setEnabled(boolean enableLogs) { this.enable = enableLogs; } @@ -3507,7 +3505,6 @@ public void setEnabled(boolean enableLogs) { * * @return the beforeSendLog callback or null if not set */ - @ApiStatus.Experimental public @Nullable BeforeSendLogCallback getBeforeSend() { return beforeSend; } @@ -3517,7 +3514,6 @@ public void setEnabled(boolean enableLogs) { * * @param beforeSendLog the beforeSendLog callback */ - @ApiStatus.Experimental public void setBeforeSend(@Nullable BeforeSendLogCallback beforeSendLog) { this.beforeSend = beforeSendLog; } diff --git a/sentry/src/main/java/io/sentry/logger/ILoggerApi.java b/sentry/src/main/java/io/sentry/logger/ILoggerApi.java index bd892ea68f5..b29ea5d3ebd 100644 --- a/sentry/src/main/java/io/sentry/logger/ILoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/ILoggerApi.java @@ -2,11 +2,9 @@ import io.sentry.SentryDate; import io.sentry.SentryLogLevel; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@ApiStatus.Experimental public interface ILoggerApi { void trace(final @Nullable String message, @Nullable Object... args); diff --git a/sentry/src/main/java/io/sentry/logger/LoggerApi.java b/sentry/src/main/java/io/sentry/logger/LoggerApi.java index 19c25c0f56d..84abfa71b2e 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerApi.java @@ -21,11 +21,9 @@ import io.sentry.util.Platform; import io.sentry.util.TracingUtils; import java.util.HashMap; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@ApiStatus.Experimental public final class LoggerApi implements ILoggerApi { private final @NotNull Scopes scopes; diff --git a/sentry/src/main/java/io/sentry/logger/NoOpLoggerApi.java b/sentry/src/main/java/io/sentry/logger/NoOpLoggerApi.java index 16ea708f466..50edf72f3a4 100644 --- a/sentry/src/main/java/io/sentry/logger/NoOpLoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/NoOpLoggerApi.java @@ -2,11 +2,9 @@ import io.sentry.SentryDate; import io.sentry.SentryLogLevel; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@ApiStatus.Experimental public final class NoOpLoggerApi implements ILoggerApi { private static final NoOpLoggerApi instance = new NoOpLoggerApi(); diff --git a/sentry/src/main/java/io/sentry/logger/NoOpLoggerBatchProcessor.java b/sentry/src/main/java/io/sentry/logger/NoOpLoggerBatchProcessor.java index 372b9f6b67d..1cf45cdbd61 100644 --- a/sentry/src/main/java/io/sentry/logger/NoOpLoggerBatchProcessor.java +++ b/sentry/src/main/java/io/sentry/logger/NoOpLoggerBatchProcessor.java @@ -1,10 +1,8 @@ package io.sentry.logger; import io.sentry.SentryLogEvent; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -@ApiStatus.Experimental public final class NoOpLoggerBatchProcessor implements ILoggerBatchProcessor { private static final NoOpLoggerBatchProcessor instance = new NoOpLoggerBatchProcessor(); From c3900011d9ae2147e71f42c561a553b2580e09db Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Sep 2025 15:11:33 +0200 Subject: [PATCH 3/5] Changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b1bb53246..e2df9097b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ - Remove internal API status from get/setDistinctId ([#4708](https://github.com/getsentry/sentry-java/pull/4708)) +### Fixes + +- Fix `NoSuchElementException` in `BufferCaptureStrategy` ([#4717](https://github.com/getsentry/sentry-java/pull/4717)) + ## 8.21.1 ### Fixes From b5f5ac8b9ec1cc13380023ab56e768de3c0182d8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Sep 2025 22:37:55 +0200 Subject: [PATCH 4/5] fix(replay): Continue recording in session mode after buffer is triggered --- .../replay/capture/BaseCaptureStrategy.kt | 2 +- .../replay/capture/BufferCaptureStrategy.kt | 1 + .../android/replay/ReplayIntegrationTest.kt | 58 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) 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) From 896d1f927401899de5f3e482edad2706609498ec Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Sep 2025 22:41:45 +0200 Subject: [PATCH 5/5] Changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2df9097b56..27eb6d0e366 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)) ## 8.21.1