Skip to content
Closed
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 @@ -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)).
Expand Down
12 changes: 6 additions & 6 deletions sentry-android-replay/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<ID>ComplexCondition:ReplayIntegration.kt$ReplayIntegration$state.get().captureStrategy is SessionCaptureStrategy &amp;&amp; (lastKnownConnectionStatus == DISCONNECTED || scopes?.rateLimiter?.isActiveForCategory(All) == true || scopes?.rateLimiter?.isActiveForCategory(Replay) == true)</ID>
<ID>ComplexCondition:ScreenshotRecorder.kt$ScreenshotRecorder$root == null || root.width &lt;= 0 || root.height &lt;= 0 || !root.isShown</ID>
<ID>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)</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, ): ReplaySegment</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment</ID>
<ID>CyclomaticComplexMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?</ID>
<ID>CyclomaticComplexMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$override fun convert(breadcrumb: Breadcrumb): RRWebEvent?</ID>
<ID>CyclomaticComplexMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent</ID>
Expand Down Expand Up @@ -44,7 +44,7 @@
<ID>FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window</ID>
<ID>LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas</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: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>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment</ID>
<ID>LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?</ID>
<ID>LongMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$override fun convert(breadcrumb: Breadcrumb): RRWebEvent?</ID>
<ID>LongMethod:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent</ID>
Expand All @@ -57,9 +57,9 @@
<ID>LongMethod:ReplayIntegrationWithRecorderTest.kt$ReplayIntegrationWithRecorderTest$@Test fun `works with different recorder`()</ID>
<ID>LongMethod:SimpleVideoEncoder.kt$SimpleVideoEncoder$private fun drainCodec(endOfStream: Boolean)</ID>
<ID>LongMethod:ViewHierarchyNode.kt$ViewHierarchyNode.Companion$@JvmStatic fun fromView( view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryMaskingOptions, ): ViewHierarchyNode</ID>
<ID>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&lt;Breadcrumb>? = null, events: Deque&lt;RRWebEvent> = this.currentEvents, )</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, )</ID>
<ID>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&lt;Breadcrumb>?, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String> = emptyList(), segmentNames: List&lt;String> = emptyList(), )</ID>
<ID>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&lt;Breadcrumb>? = null, events: Deque&lt;RRWebEvent> = this.currentEvents, eventsStart: Date = currentSegmentTimestamp, eventsEnd: Date? = null, )</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, eventsStart: Date, eventsEnd: Date?, )</ID>
<ID>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&lt;Breadcrumb>?, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String> = emptyList(), segmentNames: List&lt;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, )</ID>
<ID>LongParameterList:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, )</ID>
<ID>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, )</ID>
<ID>LongParameterList:PixelCopyStrategy.kt$PixelCopyStrategy$( root: View, captures: Array&lt;SurfaceViewCapture?>, viewHierarchy: ViewHierarchyNode, windowX: Int, windowY: Int, resetUnstableCaptures: Boolean, )</ID>
Expand Down Expand Up @@ -102,7 +102,7 @@
<ID>MatchingDeclarationName:Persistable.kt$PersistableLinkedList : ConcurrentLinkedDeque</ID>
<ID>MatchingDeclarationName:ReplayLifecycle.kt$ReplayLifecycleState</ID>
<ID>MaxLineLength:ComposeMaskingOptionsTest.kt$ComposeMaskingOptionsTest$"Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic"</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, ): ReplaySegment</ID>
<ID>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&lt;Breadcrumb>, events: Deque&lt;RRWebEvent>, traceIds: List&lt;String>, segmentNames: List&lt;String>, eventsStart: Date, eventsEnd: Date?, ): ReplaySegment</ID>
<ID>NestedBlockDepth:DefaultReplayBreadcrumbConverter.kt$DefaultReplayBreadcrumbConverter$private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent</ID>
<ID>NestedBlockDepth:Nodes.kt$internal fun LayoutNode.findPainter(): Painter?</ID>
<ID>NestedBlockDepth:Nodes.kt$internal fun LayoutNode.findTextColor(): Color?</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand All @@ -164,6 +176,7 @@ constructor(
cache?.close()
replayDeadline = null
segmentTimestamp = null
eventsStartTimestamp = null
currentReplayId = SentryId.EMPTY_ID
}
)
Expand All @@ -183,6 +196,8 @@ constructor(
screenAtStart: String? = this.screenAtStart,
breadcrumbs: List<Breadcrumb>? = null,
events: Deque<RRWebEvent> = this.currentEvents,
eventsStart: Date = currentSegmentTimestamp,
eventsEnd: Date? = null,
): ReplaySegment {
val (traceIds, segmentNames) =
synchronized(replayContextLock) {
Expand All @@ -209,6 +224,8 @@ constructor(
events,
traceIds,
segmentNames,
eventsStart,
eventsEnd,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ internal interface CaptureStrategy {
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,
): ReplaySegment {
val generatedVideo =
cache?.createVideoOf(
Expand Down Expand Up @@ -138,6 +142,8 @@ internal interface CaptureStrategy {
events,
traceIds,
segmentNames,
eventsStart,
eventsEnd,
)
}

Expand All @@ -159,8 +165,11 @@ internal interface CaptureStrategy {
events: Deque<RRWebEvent>,
traceIds: List<String>,
segmentNames: List<String>,
eventsStart: Date,
eventsEnd: Date?,
): ReplaySegment {
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
val eventsEndTimestamp = eventsEnd ?: endTimestamp
val replay =
SentryReplayEvent().apply {
this.eventId = currentReplayId
Expand Down Expand Up @@ -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)

Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<RRWebBreadcrumbEvent>()
assertTrue(crumbs.isEmpty())
},
)
}

@Test
fun `replayId should be set and serialized first`() {
val strategy = fixture.getSut()
Expand Down
Loading
Loading