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()