From c9dbf684f6332a03f85f20995623787bf42b2770 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 11 Sep 2025 15:06:45 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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 63b3ed99df6272d3e5fd9f676c25d495b47dc404 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Sep 2025 16:58:24 +0200 Subject: [PATCH 4/4] Remove failed frame from snapshot list too --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5cf0b0ce3e4..72d8b7f29fb 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 @@ -140,7 +140,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI } // Work on a snapshot of frames to avoid races with writers val framesSnapshot = - framesLock.acquire().use { if (frames.isEmpty()) emptyList() else frames.toList() } + framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() } if (framesSnapshot.isEmpty()) { options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null @@ -188,6 +188,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI // likelihood of it being able to be encoded later is low deleteFile(lastFrame.screenshot) framesLock.acquire().use { frames.remove(lastFrame) } + framesSnapshot.remove(lastFrame) lastFrame = null } }