diff --git a/CHANGELOG.md b/CHANGELOG.md
index acca00fc3c..6058aab107 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Fixes
+- Session Replay: Keep touch events and breadcrumbs that happen right after a segment is cut (e.g. after an error captures a buffered replay) ([#6350](https://github.com/getsentry/sentry-java/pull/6350))
- 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))
- 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)).
diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml
index 2adcb41234..034b174f1b 100644
--- a/sentry-android-replay/detekt-baseline.xml
+++ b/sentry-android-replay/detekt-baseline.xml
@@ -8,7 +8,7 @@
ComplexCondition:ReplayIntegration.kt$ReplayIntegration$state.get().captureStrategy is SessionCaptureStrategy && (lastKnownConnectionStatus == DISCONNECTED || scopes?.rateLimiter?.isActiveForCategory(All) == true || scopes?.rateLimiter?.isActiveForCategory(Replay) == true)
ComplexCondition:ScreenshotRecorder.kt$ScreenshotRecorder$root == null || root.width <= 0 || root.height <= 0 || !root.isShown
ComplexCondition:SimpleVideoEncoder.kt$SimpleVideoEncoder$Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || Build.MANUFACTURER.contains("motorola", ignoreCase = true) || SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) .equals("spreadtrum", ignoreCase = true) || SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) .equals("unisoc", ignoreCase = true)
- CyclomaticComplexMethod: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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment
+ CyclomaticComplexMethod: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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment
CyclomaticComplexMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?
CyclomaticComplexMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$override fun convert(breadcrumb: Breadcrumb): RRWebEvent?
CyclomaticComplexMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent
@@ -44,7 +44,7 @@
FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window
LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas
LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest
- 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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment
+ 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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment
LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?
LongMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$override fun convert(breadcrumb: Breadcrumb): RRWebEvent?
LongMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent
@@ -57,9 +57,9 @@
LongMethod:ReplayIntegrationWithRecorderTest.kt$ReplayIntegrationWithRecorderTest$@Test fun `works with different recorder`()
LongMethod:SimpleVideoEncoder.kt$SimpleVideoEncoder$private fun drainCodec(endOfStream: Boolean)
LongMethod:ViewHierarchyNode.kt$ViewHierarchyNode.Companion$@JvmStatic fun fromView( view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryMaskingOptions, ): ViewHierarchyNode
- LongParameterList:BaseCaptureStrategy.kt$BaseCaptureStrategy$( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, frameRate: Int, bitRate: Int, replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, screenAtStart: String? = this.screenAtStart, breadcrumbs: List<Breadcrumb>? = null, events: Deque<RRWebEvent> = this.currentEvents, )
- LongParameterList:CaptureStrategy.kt$CaptureStrategy.Companion$( 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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, )
- LongParameterList:CaptureStrategy.kt$CaptureStrategy.Companion$( scopes: IScopes?, options: SentryOptions, duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, replayType: ReplayType, cache: ReplayCache?, frameRate: Int, bitRate: Int, screenAtStart: String?, breadcrumbs: List<Breadcrumb>?, events: Deque<RRWebEvent>, traceIds: List<String> = emptyList(), segmentNames: List<String> = emptyList(), )
+ LongParameterList:BaseCaptureStrategy.kt$BaseCaptureStrategy$( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, frameRate: Int, bitRate: Int, replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, screenAtStart: String? = this.screenAtStart, breadcrumbs: List<Breadcrumb>? = null, events: Deque<RRWebEvent> = this.currentEvents, eventsStart: Date = currentSegmentTimestamp, eventsEnd: Date? = null, )
+ LongParameterList:CaptureStrategy.kt$CaptureStrategy.Companion$( 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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, eventsStart: Date, eventsEnd: Date?, )
+ LongParameterList:CaptureStrategy.kt$CaptureStrategy.Companion$( scopes: IScopes?, options: SentryOptions, duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, replayType: ReplayType, cache: ReplayCache?, frameRate: Int, bitRate: Int, screenAtStart: String?, breadcrumbs: List<Breadcrumb>?, events: Deque<RRWebEvent>, traceIds: List<String> = emptyList(), segmentNames: List<String> = emptyList(), // The window of breadcrumbs and events can differ from the video's: the video duration is // rounded up to whole frames, so its end can be later than the time the segment was cut. eventsStart: Date = currentSegmentTimestamp, eventsEnd: Date? = null, )
LongParameterList:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, )
LongParameterList:PixelCopyStrategy.kt$( destCanvas: Canvas, destPaint: Paint, tmpSrc: Rect, tmpDst: RectF, sourceBitmap: Bitmap, sourceX: Int, sourceY: Int, windowX: Int, windowY: Int, scaleFactorX: Float, scaleFactorY: Float, )
LongParameterList:PixelCopyStrategy.kt$PixelCopyStrategy$( root: View, captures: Array<SurfaceViewCapture?>, viewHierarchy: ViewHierarchyNode, windowX: Int, windowY: Int, resetUnstableCaptures: Boolean, )
@@ -102,7 +102,7 @@
MatchingDeclarationName:Persistable.kt$PersistableLinkedList : ConcurrentLinkedDeque
MatchingDeclarationName:ReplayLifecycle.kt$ReplayLifecycleState
MaxLineLength:ComposeMaskingOptionsTest.kt$ComposeMaskingOptionsTest$"Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic"
- NestedBlockDepth: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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment
+ NestedBlockDepth: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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment
NestedBlockDepth:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent
NestedBlockDepth:Nodes.kt$internal fun LayoutNode.findPainter(): Painter?
NestedBlockDepth:Nodes.kt$internal fun LayoutNode.findTextColor(): Color?
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 b6b8981f8c..56ebcf0efd 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
@@ -91,6 +91,12 @@ constructor(
if (newValue == null) null else DateUtils.getTimestamp(newValue),
)
}
+ /**
+ * Where the breadcrumbs and events of the current segment start. Usually [segmentTimestamp], but
+ * after a segment is cut early (flush, configuration change, buffer conversion) the video ends at
+ * a time rounded up to whole frames, while events after the cut belong to the next segment.
+ */
+ @Volatile protected var eventsStartTimestamp: Date? = null
/**
* When the recording reaches its `sessionDuration` cap, or `null` while nothing is being
* recorded. Only [SessionCaptureStrategy] enforces a cap; buffer mode records indefinitely.
@@ -133,6 +139,7 @@ constructor(
this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER)
segmentTimestamp = DateUtils.getCurrentDateTime()
+ eventsStartTimestamp = segmentTimestamp
replayDeadline = Deadline.after(ticker, options.sessionReplay.sessionDuration, MILLISECONDS)
// the same values MainEventProcessor applies to current-run segments
recordingMetadata =
@@ -152,7 +159,12 @@ constructor(
override fun resume() {
val resumedAt = DateUtils.getCurrentDateTime()
// Keep the timeline update behind queued frames and pause/flush segment creation.
- replayExecutor.submit(ReplayRunnable("$TAG.resume") { segmentTimestamp = resumedAt })
+ replayExecutor.submit(
+ ReplayRunnable("$TAG.resume") {
+ segmentTimestamp = resumedAt
+ eventsStartTimestamp = resumedAt
+ }
+ )
}
override fun pause() = Unit
@@ -164,6 +176,7 @@ constructor(
cache?.close()
replayDeadline = null
segmentTimestamp = null
+ eventsStartTimestamp = null
currentReplayId = SentryId.EMPTY_ID
}
)
@@ -183,6 +196,8 @@ constructor(
screenAtStart: String? = this.screenAtStart,
breadcrumbs: List? = null,
events: Deque = this.currentEvents,
+ eventsStart: Date = currentSegmentTimestamp,
+ eventsEnd: Date? = null,
): ReplaySegment {
val (traceIds, segmentNames) =
synchronized(replayContextLock) {
@@ -209,6 +224,8 @@ constructor(
events,
traceIds,
segmentNames,
+ eventsStart,
+ eventsEnd,
)
}
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 071acc19ca..6e74ee554c 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
@@ -281,6 +281,9 @@ constructor(
currentConfig.recordingWidth,
currentConfig.frameRate,
currentConfig.bitRate,
+ // the video ends at a time rounded up to whole frames, events after the cut belong to
+ // the next segment
+ eventsEnd = DateUtils.getDateTime(now),
)
onSegmentCreated(segment)
}
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt
index fc8b8d1306..2a717f01e1 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt
@@ -98,6 +98,10 @@ internal interface CaptureStrategy {
events: Deque,
traceIds: List = emptyList(),
segmentNames: List = emptyList(),
+ // The window of breadcrumbs and events can differ from the video's: the video duration is
+ // rounded up to whole frames, so its end can be later than the time the segment was cut.
+ eventsStart: Date = currentSegmentTimestamp,
+ eventsEnd: Date? = null,
): ReplaySegment {
val generatedVideo =
cache?.createVideoOf(
@@ -138,6 +142,8 @@ internal interface CaptureStrategy {
events,
traceIds,
segmentNames,
+ eventsStart,
+ eventsEnd,
)
}
@@ -159,8 +165,11 @@ internal interface CaptureStrategy {
events: Deque,
traceIds: List,
segmentNames: List,
+ eventsStart: Date,
+ eventsEnd: Date?,
): ReplaySegment {
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
+ val eventsEndTimestamp = eventsEnd ?: endTimestamp
val replay =
SentryReplayEvent().apply {
this.eventId = currentReplayId
@@ -206,10 +215,10 @@ internal interface CaptureStrategy {
val isAfterNetworkReconnected =
previousCrumb?.isNetworkAvailable() == true &&
breadcrumb.isNetworkConnectivity() &&
- breadcrumb.timestamp.time + NETWORK_BREADCRUMB_START_OFFSET >= segmentTimestamp.time
+ breadcrumb.timestamp.time + NETWORK_BREADCRUMB_START_OFFSET >= eventsStart.time
if (
- (breadcrumb.timestamp.time >= segmentTimestamp.time || isAfterNetworkReconnected) &&
- breadcrumb.timestamp.time < endTimestamp.time
+ (breadcrumb.timestamp.time >= eventsStart.time || isAfterNetworkReconnected) &&
+ breadcrumb.timestamp.time < eventsEndTimestamp.time
) {
val rrwebEvent = options.replayController.breadcrumbConverter.convert(breadcrumb)
@@ -232,8 +241,8 @@ internal interface CaptureStrategy {
urls.addFirst(screenAtStart)
}
- rotateEvents(events, endTimestamp.time) { event ->
- if (event.timestamp >= segmentTimestamp.time) {
+ rotateEvents(events, eventsEndTimestamp.time) { event ->
+ if (event.timestamp >= eventsStart.time) {
recordingPayload += event
}
}
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 8e6ae9fc4a..289382ad67 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
@@ -1,6 +1,7 @@
package io.sentry.android.replay.capture
import android.graphics.Bitmap
+import io.sentry.DateUtils
import io.sentry.IScopes
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.INFO
@@ -154,12 +155,14 @@ constructor(
currentConfig.recordingWidth,
currentConfig.frameRate,
currentConfig.bitRate,
+ eventsStart = eventsStartTimestamp ?: currentSegmentTimestamp,
)
if (segment is ReplaySegment.Created) {
segment.capture(scopes)
currentSegment++
// set next segment timestamp as close to the previous one as possible to avoid gaps
segmentTimestamp = segment.replay.timestamp
+ eventsStartTimestamp = segment.replay.timestamp
}
}
@@ -215,7 +218,14 @@ constructor(
currentConfig.recordingWidth,
currentConfig.frameRate,
currentConfig.bitRate,
+ eventsStart = eventsStartTimestamp ?: currentSegmentTimestamp,
+ eventsEnd = DateUtils.getDateTime(requestedAt),
)
+ if (segment is ReplaySegment.Created) {
+ // the video ends at a time rounded up to whole frames, events after the request belong
+ // to the next segment
+ eventsStartTimestamp = DateUtils.getDateTime(requestedAt)
+ }
onSegmentCreated(segment)
}
)
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 6fdf3ab210..f1149b3d7e 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
@@ -2,7 +2,9 @@ package io.sentry.android.replay.capture
import android.graphics.Bitmap
import android.view.MotionEvent
+import io.sentry.Breadcrumb
import io.sentry.DataCategory
+import io.sentry.DateUtils
import io.sentry.IScopes
import io.sentry.Scope
import io.sentry.ScopeCallback
@@ -23,6 +25,7 @@ import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Compan
import io.sentry.clientreport.DiscardReason
import io.sentry.clientreport.DiscardedEvent
import io.sentry.protocol.SentryId
+import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.time.TestMonotonicTicker
import io.sentry.transport.CurrentDateProvider
import io.sentry.transport.ICurrentDateProvider
@@ -39,6 +42,7 @@ import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.check
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
@@ -447,6 +451,49 @@ class BufferCaptureStrategyTest {
verify(fixture.scopes).captureReplay(any(), any())
}
+ @Test
+ fun `captureReplay leaves breadcrumbs after the cut to the next segment`() {
+ val now = System.currentTimeMillis()
+ val strategy = fixture.getSut(dateProvider = { now })
+ strategy.start()
+ strategy.onConfigurationChanged(fixture.recorderConfig)
+ whenever(fixture.replayCache.firstFrameTimestamp()).thenReturn(now - 4000)
+ // rounded up to whole frames, the video ends 1s after the cut
+ whenever(
+ fixture.replayCache.createVideoOf(
+ anyLong(),
+ anyLong(),
+ anyInt(),
+ anyInt(),
+ anyInt(),
+ anyInt(),
+ anyInt(),
+ any(),
+ )
+ )
+ .thenReturn(GeneratedVideo(File("0.mp4"), 5, 5000))
+ // arrived before the queued segment was created; the session strategy that continues the
+ // replay sends it, so the buffered segment must not
+ fixture.scope.addBreadcrumb(
+ Breadcrumb(DateUtils.getDateTime(now + 300)).apply {
+ category = "navigation"
+ type = "navigation"
+ setData("to", "screen")
+ }
+ )
+
+ strategy.captureReplay(false) {}
+
+ verify(fixture.scopes)
+ .captureReplay(
+ any(),
+ check {
+ val crumbs = it.replayRecording!!.payload!!.filterIsInstance()
+ assertTrue(crumbs.isEmpty())
+ },
+ )
+ }
+
@Test
fun `replayId should be set and serialized first`() {
val strategy = fixture.getSut()
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 b0dbe3f82b..7bb389924a 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
@@ -391,6 +391,79 @@ class SessionCaptureStrategyTest {
)
}
+ @Test
+ fun `breadcrumbs after a flush go to the next segment when the video ends later`() {
+ val breadcrumbsPerSegment = recordBreadcrumbsPerSegment()
+ val strategy = fixture.getSut(dateProvider = { now })
+ strategy.start()
+ strategy.onConfigurationChanged(fixture.recorderConfig)
+ val start = strategy.segmentTimestamp!!.time
+
+ // the flushed video is rounded up to VIDEO_DURATION, so it ends 2.5s after the cut
+ now = start + 2500
+ strategy.flush {}
+ fixture.scope.addBreadcrumb(navigation(at = start + 2800))
+ now = start + 6000
+ strategy.stop()
+
+ assertThat(breadcrumbsPerSegment).containsExactly(0, emptyList(), 1, listOf(start + 2800))
+ }
+
+ @Test
+ fun `breadcrumbs after a flush request are sent once, with the next segment`() {
+ val breadcrumbsPerSegment = recordBreadcrumbsPerSegment()
+ val tasks = mutableListOf()
+ val replayExecutor =
+ mock {
+ doAnswer {
+ tasks += it.arguments[0] as Runnable
+ null
+ }
+ .whenever(mock)
+ .submit(any())
+ }
+ val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor)
+ strategy.start()
+ strategy.onConfigurationChanged(fixture.recorderConfig)
+ val start = strategy.segmentTimestamp!!.time
+
+ now = start + 2500
+ strategy.flush {}
+ // arrives before the queued flush segment is created
+ fixture.scope.addBreadcrumb(navigation(at = start + 2800))
+ now = start + 6000
+ strategy.stop()
+ tasks.forEach(Runnable::run)
+
+ assertThat(breadcrumbsPerSegment).containsExactly(0, emptyList(), 1, listOf(start + 2800))
+ }
+
+ private var now = 0L
+
+ private fun navigation(at: Long) =
+ Breadcrumb(DateUtils.getDateTime(at)).apply {
+ category = "navigation"
+ type = "navigation"
+ setData("to", "screen")
+ }
+
+ /** Collects the timestamps of the breadcrumb events of each captured segment. */
+ private fun recordBreadcrumbsPerSegment(): Map> {
+ val result = linkedMapOf>()
+ doAnswer {
+ val segmentId = (it.arguments[0] as SentryReplayEvent).segmentId
+ val hint = it.arguments[1] as io.sentry.Hint
+ result[segmentId] =
+ hint.replayRecording!!.payload!!.filterIsInstance().map { crumb ->
+ crumb.timestamp
+ }
+ SentryId.EMPTY_ID
+ }
+ .whenever(fixture.scopes)
+ .captureReplay(any(), any())
+ return result
+ }
+
@Test
fun `resume updates the timeline after a queued pause`() {
val tasks = mutableListOf()