From 71f75ee5e1842b4c797812f711e569add036cc7c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 17:24:43 +0200 Subject: [PATCH 1/2] fix(replay): Keep events between a segment cut and its rounded video end A segment's video duration is rounded up to whole frames, so its end can be up to one frame interval after the time the segment was cut (flush, configuration change, buffer conversion). The next segment took breadcrumbs and touch events only from that rounded end, so events in between were dropped, e.g. touches right after an error converted a buffered replay. Give segments an event window separate from the video window: a cut segment takes events up to the cut time, and the next segment takes them from there. Video timestamps do not change, so playback stays aligned; the player places events by their own timestamps. Co-Authored-By: Claude Opus 5.5 --- sentry-android-replay/detekt-baseline.xml | 12 +-- .../replay/capture/BaseCaptureStrategy.kt | 19 ++++- .../replay/capture/BufferCaptureStrategy.kt | 3 + .../android/replay/capture/CaptureStrategy.kt | 19 +++-- .../replay/capture/SessionCaptureStrategy.kt | 10 +++ .../capture/BufferCaptureStrategyTest.kt | 47 ++++++++++++ .../capture/SessionCaptureStrategyTest.kt | 73 +++++++++++++++++++ 7 files changed, 171 insertions(+), 12 deletions(-) diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 2adcb412344..034b174f1b9 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 b6b8981f8ce..56ebcf0efdd 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 071acc19cae..6e74ee554c0 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 fc8b8d13065..2a717f01e1e 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 8e6ae9fc4a4..289382ad67c 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 6fdf3ab210d..f1149b3d7e3 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 b0dbe3f82bb..7bb389924ad 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() From 192d7a7f4cf98273098bc4e9839177e87ede26fe Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 17:25:20 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c3..6058aab107b 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)).