Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265))
- Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340))
- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227))
- In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)).
- Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch.
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<ID>FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_dest: Bitmap</ID>
<ID>FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window</ID>
<ID>LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas</ID>
<ID>LargeClass:ReplayCacheTest.kt$ReplayCacheTest</ID>
<ID>LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest</ID>
<ID>LongMethod:CaptureStrategy.kt$CaptureStrategy.Companion$@Suppress("NewApi") private fun buildReplay( options: SentryOptions, video: File, currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, height: Int, width: Int, frameCount: Int, frameRate: Int, videoDuration: Long, replayType: ReplayType, screenAtStart: String?, breadcrumbs: List&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, ): ReplaySegment</ID>
<ID>LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
bitRate: Int,
videoFile: File = File(replayCacheDir, "$segmentId.mp4"),
): GeneratedVideo? {
if (videoFile.exists() && videoFile.length() > 0) {
videoFile.delete()
}
// Work on a snapshot of frames to avoid races with writers
val framesSnapshot =
framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() }
if (framesSnapshot.isEmpty()) {
// keep an existing video intact, e.g. one from a flush whose envelope is still queued
options.logger.log(DEBUG, "No captured frames, skipping generating a video segment")
return null
}
if (videoFile.exists() && videoFile.length() > 0) {
videoFile.delete()
}

encoder =
SimpleVideoEncoder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,23 +340,17 @@ public class ReplayIntegration(
return
}

var activeStrategy: CaptureStrategy = strategy
// Convert before capturing, so the callback knows which strategy continues the replay.
val activeStrategy = strategy.convert()
capture(strategy) { newTimestamp ->
enqueueOnMainThread {
val latest = state.get()
// The flush completes asynchronously; ignore it if this replay was stopped, restarted,
// or handed to another strategy in the meantime.
if (
latest.matches(expectedGeneration, expectedReplayId) &&
latest.captureStrategy === activeStrategy
) {
activeStrategy.currentSegment++
activeStrategy.segmentTimestamp = newTimestamp
activeStrategy.isFlushed = true
}
}
// Runs on the replay executor, which the converted strategy shares. Advancing the segment
// here lands before any segment the converted strategy creates later (e.g. on stop), so the
// flushed segment id is not reused. A stale strategy (stopped or restarted replay) is not
// in the state anymore, so updating it has no effect.
activeStrategy.currentSegment++
activeStrategy.segmentTimestamp = newTimestamp
activeStrategy.isFlushed = true
}
activeStrategy = strategy.convert()
val replayId: SentryId? = activeStrategy.currentReplayId
state.set(
current.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@ class ReplayCacheTest {
assertNull(video)
}

@Test
fun `when no frames are provided, does not delete existing video`() {
val replayCache = fixture.getSut(tmpDir)
val existingVideo =
File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(byteArrayOf(1, 2, 3)) }

val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000)

assertNull(video)
assertEquals(3, existingVideo.length())
}

@Test
fun `releases the muxer when the encoder fails to start`() {
ReplayShadowMediaCodec.throwOnStart = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,49 @@ class ReplayIntegrationTest {
assertThat(replay.replayId).isEqualTo(newReplayId)
}

@Test
fun `flush followed by immediate stop advances the converted strategy segment`() {
val replayId = SentryId()
var onSegmentSent: ((Date) -> Unit)? = null
val bufferStrategy = mock<BufferCaptureStrategy>()
whenever(bufferStrategy.currentReplayId).thenReturn(replayId)
doAnswer {
@Suppress("UNCHECKED_CAST")
onSegmentSent = it.arguments[0] as (Date) -> Unit
}
.whenever(bufferStrategy)
.flush(any())
val sessionStrategy = mock<SessionCaptureStrategy>()
whenever(sessionStrategy.currentReplayId).thenReturn(replayId)
whenever(sessionStrategy.currentSegment).thenReturn(0)
whenever(bufferStrategy.convert()).thenReturn(sessionStrategy)
val replay =
fixture.getSut(
context,
sessionSampleRate = 0.0,
onErrorSampleRate = 0.0,
replayCaptureStrategyProvider = { bufferStrategy },
mainLooperHandler = MainLooperHandler(),
)
replay.register(fixture.scopes, fixture.options)
replay.startBuffering()
shadowOf(Looper.getMainLooper()).idle()

replay.flush()
replay.stop()
shadowOf(Looper.getMainLooper()).idle()
verify(sessionStrategy).stop()

// the flushed segment completes on the replay executor after stop ran on the main thread,
// but before the stop segment of the converted strategy is created
val timestamp = Date()
onSegmentSent!!.invoke(timestamp)

verify(sessionStrategy).currentSegment = 1
verify(sessionStrategy).segmentTimestamp = timestamp
verify(sessionStrategy).isFlushed = true
}

@Test
fun `pause does nothing when not recording`() {
val captureStrategy = mock<CaptureStrategy>()
Expand Down
Loading