From 07b758bad7d55989f7b72648983bc9c9e27b0096 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 17 Dec 2024 11:33:09 +0100 Subject: [PATCH 01/27] Get rid of the lock on touch events --- .../replay/capture/BaseCaptureStrategy.kt | 20 ++++++++----------- .../replay/capture/BufferCaptureStrategy.kt | 2 ++ .../android/replay/capture/CaptureStrategy.kt | 18 ++++++++--------- .../sentry/android/replay/util/Persistable.kt | 9 ++++++++- 4 files changed, 27 insertions(+), 22 deletions(-) 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 4b37eafc78..6bd4f6eef0 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.annotation.TargetApi import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils @@ -21,7 +22,6 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment -import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter import io.sentry.android.replay.util.PersistableLinkedList @@ -32,7 +32,8 @@ import io.sentry.rrweb.RRWebEvent import io.sentry.transport.ICurrentDateProvider import java.io.File import java.util.Date -import java.util.LinkedList +import java.util.Deque +import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -42,6 +43,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +@TargetApi(26) internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, @@ -81,12 +83,8 @@ internal abstract class BaseCaptureStrategy( override val replayCacheDir: File? get() = cache?.replayCacheDir override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) - protected val currentEvents: LinkedList = PersistableLinkedList( - propertyName = SEGMENT_KEY_REPLAY_RECORDING, - options, - persistingExecutor, - cacheProvider = { cache } - ) + + protected val currentEvents: Deque = ConcurrentLinkedDeque() protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -135,7 +133,7 @@ internal abstract class BaseCaptureStrategy( frameRate: Int = recorderConfig.frameRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, - events: LinkedList = this.currentEvents + events: Deque = this.currentEvents ): ReplaySegment = createSegment( hub, @@ -161,9 +159,7 @@ internal abstract class BaseCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { val rrwebEvents = gestureConverter.convert(event, recorderConfig) if (rrwebEvents != null) { - synchronized(currentEventsLock) { - currentEvents += rrwebEvents - } + currentEvents += rrwebEvents } } 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 2dbb2a1746..0d8ab2e643 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.annotation.TargetApi import android.graphics.Bitmap import android.view.MotionEvent import io.sentry.DateUtils @@ -23,6 +24,7 @@ import java.io.File import java.util.Date import java.util.concurrent.ScheduledExecutorService +@TargetApi(26) internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, 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 53f5c66683..b2a049e79a 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 @@ -19,6 +19,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import java.io.File import java.util.Date +import java.util.Deque import java.util.LinkedList internal interface CaptureStrategy { @@ -57,7 +58,6 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L - internal val currentEventsLock = Any() fun createSegment( hub: IHub?, @@ -73,7 +73,7 @@ internal interface CaptureStrategy { frameRate: Int, screenAtStart: String?, breadcrumbs: List?, - events: LinkedList + events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( duration, @@ -127,7 +127,7 @@ internal interface CaptureStrategy { replayType: ReplayType, screenAtStart: String?, breadcrumbs: List, - events: LinkedList + events: Deque ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = SentryReplayEvent().apply { @@ -207,16 +207,16 @@ internal interface CaptureStrategy { } internal fun rotateEvents( - events: LinkedList, + events: Deque, until: Long, callback: ((RRWebEvent) -> Unit)? = null ) { - synchronized(currentEventsLock) { - var event = events.peek() - while (event != null && event.timestamp < until) { + val iter = events.iterator() + while (iter.hasNext()) { + val event = iter.next() + if (event.timestamp < until) { callback?.invoke(event) - events.remove() - event = events.peek() + iter.remove() } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt index 553bae8dee..0e224c5356 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -1,6 +1,9 @@ // ktlint-disable filename package io.sentry.android.replay.util +import android.annotation.TargetApi +import android.os.Build.VERSION_CODES +import androidx.annotation.RequiresApi import io.sentry.ReplayRecording import io.sentry.SentryOptions import io.sentry.android.replay.ReplayCache @@ -8,14 +11,18 @@ import io.sentry.rrweb.RRWebEvent import java.io.BufferedWriter import java.io.StringWriter import java.util.LinkedList +import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.ScheduledExecutorService +// TODO: enable this back after we are able to serialize individual touches to disk to not overload cpu +@Suppress("unused") +@TargetApi(26) internal class PersistableLinkedList( private val propertyName: String, private val options: SentryOptions, private val persistingExecutor: ScheduledExecutorService, private val cacheProvider: () -> ReplayCache? -) : LinkedList() { +) : ConcurrentLinkedDeque() { // only overriding methods that we use, to observe the collection override fun addAll(elements: Collection): Boolean { val result = super.addAll(elements) From e3df5393ccb3c14431b5c333943e78bc31dc4861 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 17 Dec 2024 11:35:30 +0100 Subject: [PATCH 02/27] pre-allocate some things for gesture converter --- .../sentry/android/replay/gestures/ReplayGestureConverter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt index 59d6b30bce..fa215960c4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -56,7 +56,7 @@ class ReplayGestureConverter( val totalOffset = now - touchMoveBaseline return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { - val moveEvents = mutableListOf() + val moveEvents = ArrayList(currentPositions.size) for ((pointerId, positions) in currentPositions) { if (positions.isNotEmpty()) { moveEvents += RRWebInteractionMoveEvent().apply { @@ -88,7 +88,7 @@ class ReplayGestureConverter( } // new finger down - add a new pointer for tracking movement - currentPositions[pId] = ArrayList() + currentPositions[pId] = ArrayList(10) listOf( RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis From fe152785d76f73ff9825c32494176d50755dccd8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Dec 2024 11:57:11 +0100 Subject: [PATCH 03/27] have one less thread switch for re]play --- .../android/replay/ReplayIntegration.kt | 26 ++++++++++++++----- .../android/replay/ScreenshotRecorder.kt | 10 +++---- .../sentry/android/replay/WindowRecorder.kt | 6 +++-- .../replay/capture/BaseCaptureStrategy.kt | 22 +--------------- .../replay/capture/BufferCaptureStrategy.kt | 4 +-- .../android/replay/capture/CaptureStrategy.kt | 2 -- .../replay/capture/SessionCaptureStrategy.kt | 2 +- .../sentry/android/replay/util/Executors.kt | 7 ++++- .../sentry/android/replay/util/Persistable.kt | 3 --- .../android/replay/ReplayIntegrationTest.kt | 1 - .../ReplayIntegrationWithRecorderTest.kt | 6 ++--- 11 files changed, 40 insertions(+), 49 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 66bd17b743..5b7e3ecae6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -29,6 +29,7 @@ import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.appContext +import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.cache.PersistingScopeObserver @@ -46,8 +47,9 @@ import io.sentry.util.Random import java.io.Closeable import java.io.File import java.util.LinkedList +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean -import kotlin.LazyThreadSafetyMode.NONE public class ReplayIntegration( private val context: Context, @@ -93,7 +95,10 @@ public class ReplayIntegration( private var recorder: Recorder? = null private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } - internal val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } + internal val rootViewsSpy by lazy { RootViewsSpy.install() } + private val replayExecutor by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) @@ -123,7 +128,7 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) @@ -166,9 +171,9 @@ public class ReplayIntegration( recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) + SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider = replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayExecutor, replayCacheProvider) } captureStrategy?.start(recorderConfig) @@ -229,7 +234,6 @@ public class ReplayIntegration( gestureRecorder?.stop() captureStrategy?.stop() isRecording.set(false) - captureStrategy?.close() captureStrategy = null } @@ -264,6 +268,7 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() + replayExecutor.gracefullyShutdown(options) } override fun onConfigurationChanged(newConfig: Configuration) { @@ -405,4 +410,13 @@ public class ReplayIntegration( private class PreviousReplayHint : Backfillable { override fun shouldEnrich(): Boolean = false } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 8ca9fed7fe..6f588fe779 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -25,7 +25,6 @@ import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.addOnDrawListenerSafe import io.sentry.android.replay.util.getVisibleRects -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.removeOnDrawListenerSafe import io.sentry.android.replay.util.submitSafely import io.sentry.android.replay.util.traverse @@ -34,7 +33,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarc import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File import java.lang.ref.WeakReference -import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE @@ -44,13 +43,11 @@ import kotlin.math.roundToInt internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, val options: SentryOptions, - val mainLooperHandler: MainLooperHandler, + private val mainLooperHandler: MainLooperHandler, + private val recorder: ScheduledExecutorService, private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { - private val recorder by lazy { - Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) - } private var rootView: WeakReference? = null private val maskingPaint by lazy(NONE) { Paint() } private val singlePixelBitmap: Bitmap by lazy(NONE) { @@ -231,7 +228,6 @@ internal class ScreenshotRecorder( rootView?.clear() lastScreenshot?.recycle() isCapturing.set(false) - recorder.gracefullyShutdown(options) } private fun Bitmap.dominantColorForRect(rect: Rect): Int { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 35b5690a8d..4237b2c9c8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -8,6 +8,7 @@ import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.lang.ref.WeakReference import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS @@ -17,7 +18,8 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, - private val mainLooperHandler: MainLooperHandler + private val mainLooperHandler: MainLooperHandler, + private val replayExecutor: ScheduledExecutorService ) : Recorder, OnRootViewsChangedListener { internal companion object { @@ -57,7 +59,7 @@ internal class WindowRecorder( return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback) + recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", 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 6bd4f6eef0..df0b7575a3 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 @@ -15,7 +15,6 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID -import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP @@ -24,8 +23,6 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.PersistableLinkedList -import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent @@ -48,7 +45,7 @@ internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - executor: ScheduledExecutorService? = null, + protected val replayExecutor: ScheduledExecutorService, private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : CaptureStrategy { @@ -86,10 +83,6 @@ internal abstract class BaseCaptureStrategy( protected val currentEvents: Deque = ConcurrentLinkedDeque() - protected val replayExecutor: ScheduledExecutorService by lazy { - executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) - } - override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, @@ -163,19 +156,6 @@ internal abstract class BaseCaptureStrategy( } } - override fun close() { - replayExecutor.gracefullyShutdown(options) - } - - private class ReplayExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayIntegration-" + cnt++) - ret.setDaemon(true) - return ret - } - } - private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { 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 0d8ab2e643..0418283dda 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 @@ -30,9 +30,9 @@ internal class BufferCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, private val random: Random, - executor: ScheduledExecutorService? = null, + executor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider = replayCacheProvider) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() 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 b2a049e79a..f4e6215710 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 @@ -54,8 +54,6 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy - fun close() - companion object { private const val BREADCRUMB_START_OFFSET = 100L 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 08aec7e9f7..1a6dbc8c89 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 @@ -20,7 +20,7 @@ internal class SessionCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - executor: ScheduledExecutorService? = null, + executor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt index 453ff49df2..fefd3b7875 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -50,6 +50,11 @@ internal fun ExecutorService.submitSafely( taskName: String, task: Runnable ): Future<*>? { + if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) { + // we're already on the worker thread, no need to submit + task.run() + return null + } return try { submit { try { @@ -73,7 +78,7 @@ internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( task: Runnable ): ScheduledFuture<*>? { return try { - scheduleAtFixedRate({ + scheduleWithFixedDelay({ try { task.run() } catch (e: Throwable) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt index 0e224c5356..2ae3bad1b6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -2,15 +2,12 @@ package io.sentry.android.replay.util import android.annotation.TargetApi -import android.os.Build.VERSION_CODES -import androidx.annotation.RequiresApi import io.sentry.ReplayRecording import io.sentry.SentryOptions import io.sentry.android.replay.ReplayCache import io.sentry.rrweb.RRWebEvent import java.io.BufferedWriter import java.io.StringWriter -import java.util.LinkedList import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.ScheduledExecutorService diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index ff396d04c1..95380deaa7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -411,7 +411,6 @@ class ReplayIntegrationTest { verify(recorder).stop() verify(recorder).close() verify(captureStrategy).stop() - verify(captureStrategy).close() assertFalse(replay.isRecording()) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index 6bab0f6549..ea28ce7e54 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -136,9 +136,6 @@ class ReplayIntegrationWithRecorderTest { replay.stop() assertEquals(STOPPED, recorder.state) - replay.close() - assertEquals(CLOSED, recorder.state) - // start again and capture some frames replay.start() @@ -176,6 +173,9 @@ class ReplayIntegrationWithRecorderTest { assertEquals(0, videoEvents?.first()?.segmentId) } ) + + replay.close() + assertEquals(CLOSED, recorder.state) } enum class LifecycleState { From c65f9b644d8d3644e99576f0cfa023fc89c4773c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Dec 2024 13:23:29 +0100 Subject: [PATCH 04/27] update --- .../src/main/java/io/sentry/android/replay/WindowRecorder.kt | 2 ++ .../src/main/java/io/sentry/android/replay/util/Executors.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 4237b2c9c8..8f4b0526fc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -60,6 +60,8 @@ internal class WindowRecorder( } recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + // TODO: change this to use MainThreadHandler and just post on the main thread with delay + // to avoid thread context switch every time capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt index fefd3b7875..3c07ad7eaa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -78,7 +78,7 @@ internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( task: Runnable ): ScheduledFuture<*>? { return try { - scheduleWithFixedDelay({ + scheduleAtFixedRate({ try { task.run() } catch (e: Throwable) { From bc2e9d6ddcd0d9a898213299d5c2eb71cd925c23 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Dec 2024 13:36:47 +0100 Subject: [PATCH 05/27] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1a18877f..3dffe87b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Change TTFD timeout to 25 seconds ([#3984](https://github.com/getsentry/sentry-java/pull/3984)) - Session Replay: Fix memory leak when masking Compose screens ([#3985](https://github.com/getsentry/sentry-java/pull/3985)) +- Session Replay: Fix potential ANRs in `GestureRecorder` ([#4001](https://github.com/getsentry/sentry-java/pull/4001)) ## 7.19.0 From fb832b238e3c9c9fb39560dde6efae8791aee9ce Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 19 Dec 2024 17:38:09 +0100 Subject: [PATCH 06/27] Add option to disable orientation change tracking for session replay --- .../android/replay/ReplayIntegration.kt | 22 +++++++++++++------ sentry/api/sentry.api | 2 ++ .../java/io/sentry/SentryReplayOptions.java | 16 ++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 5b7e3ecae6..700e661e86 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -134,10 +134,16 @@ public class ReplayIntegration( options.connectionStatusProvider.addConnectionStatusObserver(this) hub.rateLimiter?.addRateLimitObserver(this) - try { - context.registerComponentCallbacks(this) - } catch (e: Throwable) { - options.logger.log(INFO, "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", e) + if (options.experimental.sessionReplay.isTrackOrientationChange) { + try { + context.registerComponentCallbacks(this) + } catch (e: Throwable) { + options.logger.log( + INFO, + "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", + e + ) + } } addIntegrationToSdkVersion("Replay") @@ -260,9 +266,11 @@ public class ReplayIntegration( options.connectionStatusProvider.removeConnectionStatusObserver(this) hub?.rateLimiter?.removeRateLimitObserver(this) - try { - context.unregisterComponentCallbacks(this) - } catch (ignored: Throwable) { + if (options.experimental.sessionReplay.isTrackOrientationChange) { + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } } stop() recorder?.close() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fc2f4ba51e..ef922d4f59 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2741,12 +2741,14 @@ public final class io/sentry/SentryReplayOptions { public fun getUnmaskViewContainerClass ()Ljava/lang/String; public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z + public fun isTrackOrientationChange ()Z public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V public fun setMaskViewContainerClass (Ljava/lang/String;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V public fun setSessionSampleRate (Ljava/lang/Double;)V + public fun setTrackOrientationChange (Z)V public fun setUnmaskViewContainerClass (Ljava/lang/String;)V } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index fd492213ac..f9e82c8600 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -108,6 +108,12 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; + /** + * Whether to track orientation changes in session replay. Used in Flutter as it has its own + * callbacks to determine the orientation change. + */ + private boolean trackOrientationChange = true; + public SentryReplayOptions(final boolean empty) { if (!empty) { setMaskAllText(true); @@ -266,4 +272,14 @@ public void setUnmaskViewContainerClass(@NotNull String containerClass) { public @Nullable String getUnmaskViewContainerClass() { return unmaskViewContainerClass; } + + @ApiStatus.Internal + public boolean isTrackOrientationChange() { + return trackOrientationChange; + } + + @ApiStatus.Internal + public void setTrackOrientationChange(final boolean trackOrientationChange) { + this.trackOrientationChange = trackOrientationChange; + } } From 55dbeec461242e4aac83b14cd4e21cb8d8ceebba Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 20 Dec 2024 13:34:52 +0100 Subject: [PATCH 07/27] Make RecorderConfig lazier --- .../api/sentry-android-replay.api | 10 +++++----- .../java/io/sentry/android/replay/ReplayCache.kt | 16 ++++++++-------- .../sentry/android/replay/ReplayIntegration.kt | 11 +++++------ .../replay/capture/BaseCaptureStrategy.kt | 6 ++++-- .../replay/capture/BufferCaptureStrategy.kt | 2 +- .../android/replay/capture/CaptureStrategy.kt | 5 ++++- .../replay/capture/SessionCaptureStrategy.kt | 2 +- 7 files changed, 28 insertions(+), 24 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 7e2db5248f..33043e69b6 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -43,12 +43,12 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; - public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V public static synthetic fun addFrame$default (Lio/sentry/android/replay/ReplayCache;Ljava/io/File;JLjava/lang/String;ILjava/lang/Object;)V public fun close ()V - public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; - public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun createVideoOf (JJIIIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V public final fun rotate (J)Ljava/lang/String; } @@ -60,8 +60,8 @@ public final class io/sentry/android/replay/ReplayCache$Companion { public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V - public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 3db92ea5d8..a757c4b455 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -38,8 +38,7 @@ import java.util.concurrent.atomic.AtomicBoolean */ public class ReplayCache( private val options: SentryOptions, - private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig + private val replayId: SentryId ) : Closeable { private val isClosed = AtomicBoolean(false) @@ -133,6 +132,8 @@ public class ReplayCache( segmentId: Int, height: Int, width: Int, + frameRate: Int, + bitRate: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { if (videoFile.exists() && videoFile.length() > 0) { @@ -146,7 +147,6 @@ public class ReplayCache( return null } - // TODO: reuse instance of encoder and just change file path to create a different muxer encoder = synchronized(encoderLock) { SimpleVideoEncoder( options, @@ -154,13 +154,13 @@ public class ReplayCache( file = videoFile, recordingHeight = height, recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate + frameRate = frameRate, + bitRate = bitRate ) ).also { it.start() } } - val step = 1000 / recorderConfig.frameRate.toLong() + val step = 1000 / frameRate.toLong() var frameCount = 0 var lastFrame: ReplayFrame = frames.first() for (timestamp in from until (from + (duration)) step step) { @@ -306,7 +306,7 @@ public class ReplayCache( } } - internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null): LastSegmentData? { val replayCacheDir = makeReplayCacheDir(options, replayId) val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) if (!lastSegmentFile.exists()) { @@ -360,7 +360,7 @@ public class ReplayCache( scaleFactorY = 1.0f ) - val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + val cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) cache.replayCacheDir?.listFiles { dir, name -> if (name.endsWith(".jpg")) { val file = File(dir, name) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 700e661e86..4148fbb26c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -56,7 +56,7 @@ public class ReplayIntegration( private val dateProvider: ICurrentDateProvider, private val recorderProvider: (() -> Recorder)? = null, private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, - private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, @@ -80,7 +80,7 @@ public class ReplayIntegration( dateProvider: ICurrentDateProvider, recorderProvider: (() -> Recorder)?, recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null @@ -110,8 +110,6 @@ public class ReplayIntegration( private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null - private lateinit var recorderConfig: ScreenshotRecorderConfig - override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -175,7 +173,7 @@ public class ReplayIntegration( return } - recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider) } else { @@ -287,7 +285,7 @@ public class ReplayIntegration( recorder?.stop() // refresh config based on new device configuration - recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) @@ -400,6 +398,7 @@ public class ReplayIntegration( height = lastSegment.recorderConfig.recordingHeight, width = lastSegment.recorderConfig.recordingWidth, frameRate = lastSegment.recorderConfig.frameRate, + bitRate = lastSegment.recorderConfig.bitRate, cache = lastSegment.cache, replayType = lastSegment.replayType, screenAtStart = lastSegment.screenAtStart, 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 df0b7575a3..fbc80565b1 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 @@ -46,7 +46,7 @@ internal abstract class BaseCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, protected val replayExecutor: ScheduledExecutorService, - private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : CaptureStrategy { internal companion object { @@ -89,7 +89,7 @@ internal abstract class BaseCaptureStrategy( replayId: SentryId, replayType: ReplayType? ) { - cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) this.currentReplayId = replayId this.currentSegment = segmentId @@ -124,6 +124,7 @@ internal abstract class BaseCaptureStrategy( replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, frameRate: Int = recorderConfig.frameRate, + bitRate: Int = recorderConfig.bitRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents @@ -140,6 +141,7 @@ internal abstract class BaseCaptureStrategy( replayType, cache, frameRate, + bitRate, screenAtStart, breadcrumbs, events 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 0418283dda..8ef346f8a3 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 @@ -31,7 +31,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, private val random: Random, executor: ScheduledExecutorService, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider = replayCacheProvider) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered 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 f4e6215710..2f7a5bef14 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 @@ -69,6 +69,7 @@ internal interface CaptureStrategy { replayType: ReplayType, cache: ReplayCache?, frameRate: Int, + bitRate: Int, screenAtStart: String?, breadcrumbs: List?, events: Deque @@ -78,7 +79,9 @@ internal interface CaptureStrategy { currentSegmentTimestamp.time, segmentId, height, - width + width, + frameRate, + bitRate ) ?: return ReplaySegment.Failed val (video, frameCount, videoDuration) = generatedVideo 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 1a6dbc8c89..a8c8f1387c 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 @@ -21,7 +21,7 @@ internal class SessionCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, executor: ScheduledExecutorService, - replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider) { internal companion object { From 01b5a88fec2c54586b4f2691d0330670ac20d179 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 20 Dec 2024 13:42:34 +0100 Subject: [PATCH 08/27] Fix tests --- .../sentry/android/replay/ReplayCacheTest.kt | 72 +++++++------------ .../android/replay/ReplayIntegrationTest.kt | 4 +- .../capture/BufferCaptureStrategyTest.kt | 4 +- .../capture/SessionCaptureStrategyTest.kt | 4 +- 4 files changed, 33 insertions(+), 51 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 91a17f5192..c7529ac1f6 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -47,14 +47,12 @@ class ReplayCacheTest { val options = SentryOptions() fun getSut( dir: TemporaryFolder?, - replayId: SentryId = SentryId(), - frameRate: Int + replayId: SentryId = SentryId() ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig) + return ReplayCache(options, replayId) } } @@ -70,8 +68,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( null, - replayId, - frameRate = 1 + replayId ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -85,8 +82,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -101,11 +97,10 @@ class ReplayCacheTest { @Test fun `when no frames are provided, returns nothing`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) - val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertNull(video) } @@ -114,8 +109,7 @@ class ReplayCacheTest { fun `deletes frames after creating a video`() { ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -123,7 +117,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000) assertEquals(3, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -136,14 +130,13 @@ class ReplayCacheTest { @Test fun `repeats last known frame for the segment duration`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -153,15 +146,14 @@ class ReplayCacheTest { @Test fun `repeats last known frame for the segment duration for each timespan`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 3001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -171,20 +163,19 @@ class ReplayCacheTest { @Test fun `repeats last known frame for each segment`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 5001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) - val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200) + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200, 1, 20_000) assertEquals(5, segment1!!.frameCount) assertEquals(5000, segment1.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -196,8 +187,7 @@ class ReplayCacheTest { ReplayShadowMediaCodec.framesToEncode = 6 val replayCache = fixture.getSut( - tmpDir, - frameRate = 2 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -205,7 +195,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 1501) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 2, 20_000) assertEquals(6, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -215,8 +205,7 @@ class ReplayCacheTest { @Test fun `does not add frame when bitmap is recycled`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } @@ -228,8 +217,7 @@ class ReplayCacheTest { @Test fun `addFrame with File path works`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val flutterCacheDir = @@ -240,7 +228,7 @@ class ReplayCacheTest { val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } replayCache.addFrame(screenshot, frameTimestamp = 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000, videoFile = video) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) @@ -251,8 +239,7 @@ class ReplayCacheTest { @Test fun `rotates frames`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -269,8 +256,7 @@ class ReplayCacheTest { @Test fun `rotate returns first screen in buffer`() { val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -288,8 +274,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.close() @@ -303,8 +288,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.persistSegmentValues("key1", "value1") @@ -320,8 +304,7 @@ class ReplayCacheTest { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, - replayId, - frameRate = 1 + replayId ) replayCache.persistSegmentValues("key1", "value1") @@ -467,8 +450,7 @@ class ReplayCacheTest { ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( - tmpDir, - frameRate = 1 + tmpDir ) val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { @@ -480,7 +462,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, oldVideoFile) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000, oldVideoFile) assertEquals(3, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 95380deaa7..f375136149 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -96,7 +96,7 @@ class ReplayIntegrationTest { val replayCache = mock { on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } @@ -127,7 +127,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = { _, _ -> replayCache }, + replayCacheProvider = { _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider, gestureRecorderProvider = gestureRecorderProvider ) 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 625306cb8e..1fdb41386a 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 @@ -70,7 +70,7 @@ class BufferCaptureStrategyTest { on { persistSegmentValues(any(), anyOrNull()) }.then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) } - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } val recorderConfig = ScreenshotRecorderConfig( @@ -104,7 +104,7 @@ class BufferCaptureStrategyTest { null }.whenever(it).submit(any()) } - ) { _, _ -> replayCache } + ) { _ -> replayCache } } fun mockedMotionEvent(action: Int): MotionEvent = mock { 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 12eb10c3f4..50adeae3a1 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 @@ -76,7 +76,7 @@ class SessionCaptureStrategyTest { on { persistSegmentValues(any(), anyOrNull()) }.then { persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) } - on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any()) } .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) } val recorderConfig = ScreenshotRecorderConfig( @@ -105,7 +105,7 @@ class SessionCaptureStrategyTest { null }.whenever(it).submit(any()) } - ) { _, _ -> replayCache } + ) { _ -> replayCache } } } From e96370789a0dcd78599b6001a83739a25b889c26 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 20 Dec 2024 14:02:54 +0100 Subject: [PATCH 09/27] Changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dffe87b4c..8649137bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Session Replay: Fix memory leak when masking Compose screens ([#3985](https://github.com/getsentry/sentry-java/pull/3985)) - Session Replay: Fix potential ANRs in `GestureRecorder` ([#4001](https://github.com/getsentry/sentry-java/pull/4001)) +### Internal + +- Session Replay: Flutter improvements ([#4007](https://github.com/getsentry/sentry-java/pull/4007)) + ## 7.19.0 ### Fixes From c48094f4e415d46f293033cf267fe8d46f5653f5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 14:43:51 +0100 Subject: [PATCH 10/27] Allow overriding SdkVersion for replay events only --- sentry/api/sentry.api | 8 +++--- .../java/io/sentry/ExperimentalOptions.java | 6 +++-- .../java/io/sentry/MainEventProcessor.java | 7 +++++ .../src/main/java/io/sentry/SentryClient.java | 3 ++- .../main/java/io/sentry/SentryOptions.java | 12 +++++++-- .../java/io/sentry/SentryReplayOptions.java | 27 ++++++++++++++++--- .../java/io/sentry/MainEventProcessorTest.kt | 14 +++++++++- .../java/io/sentry/SentryReplayOptionsTest.kt | 6 ++--- 8 files changed, 68 insertions(+), 15 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ef922d4f59..92a8e90976 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -315,7 +315,7 @@ public abstract interface class io/sentry/EventProcessor { } public final class io/sentry/ExperimentalOptions { - public fun (Z)V + public fun (ZLio/sentry/protocol/SdkVersion;)V public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } @@ -2724,8 +2724,8 @@ public final class io/sentry/SentryReplayOptions { public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String; public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; - public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun (Z)V + public fun (Ljava/lang/Double;Ljava/lang/Double;Lio/sentry/protocol/SdkVersion;)V + public fun (ZLio/sentry/protocol/SdkVersion;)V public fun addMaskViewClass (Ljava/lang/String;)V public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J @@ -2734,6 +2734,7 @@ public final class io/sentry/SentryReplayOptions { public fun getMaskViewContainerClass ()Ljava/lang/String; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J @@ -2747,6 +2748,7 @@ public final class io/sentry/SentryReplayOptions { public fun setMaskViewContainerClass (Ljava/lang/String;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V + public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSessionSampleRate (Ljava/lang/Double;)V public fun setTrackOrientationChange (Z)V public fun setUnmaskViewContainerClass (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 4a0e7de78d..f1bf9a8bc7 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -1,6 +1,8 @@ package io.sentry; +import io.sentry.protocol.SdkVersion; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Experimental options for new features, these options are going to be promoted to SentryOptions @@ -11,8 +13,8 @@ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplay; - public ExperimentalOptions(final boolean empty) { - this.sessionReplay = new SentryReplayOptions(empty); + public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { + this.sessionReplay = new SentryReplayOptions(empty, sdkVersion); } @NotNull diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index d6445e3a56..30f95b8b8f 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -4,6 +4,7 @@ import io.sentry.hints.Cached; import io.sentry.protocol.DebugImage; import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; @@ -159,6 +160,12 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { if (shouldApplyScopeData(event, hint)) { processNonCachedEvent(event); + final @Nullable SdkVersion replaySdkVersion = + options.getExperimental().getSessionReplay().getSdkVersion(); + if (replaySdkVersion != null) { + // we override the SdkVersion only for replay events as those may come from Hybrid SDKs + event.setSdk(replaySdkVersion); + } } return event; } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 31a5d6f780..fdbfaf4622 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -641,7 +641,8 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { final SentryId sentryId = event.getEventId(); final SentryEnvelopeHeader envelopeHeader = - new SentryEnvelopeHeader(sentryId, options.getSdkVersion(), traceContext); + new SentryEnvelopeHeader( + sentryId, options.getExperimental().getSessionReplay().getSdkVersion(), traceContext); return new SentryEnvelope(envelopeHeader, envelopeItems); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 22738b4ba1..23f195ba8e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1421,6 +1421,13 @@ public void setSslSocketFactory(final @Nullable SSLSocketFactory sslSocketFactor */ @ApiStatus.Internal public void setSdkVersion(final @Nullable SdkVersion sdkVersion) { + final @Nullable SdkVersion replaySdkVersion = experimental.getSessionReplay().getSdkVersion(); + if (this.sdkVersion != null + && replaySdkVersion != null + && this.sdkVersion.equals(replaySdkVersion)) { + // if sdkVersion = sessionReplay.sdkVersion we override it, as it means no one else set it + experimental.getSessionReplay().setSdkVersion(sdkVersion); + } this.sdkVersion = sdkVersion; } @@ -2626,7 +2633,8 @@ public SentryOptions() { * @param empty if options should be empty. */ private SentryOptions(final boolean empty) { - experimental = new ExperimentalOptions(empty); + final @NotNull SdkVersion sdkVersion = createSdkVersion(); + experimental = new ExperimentalOptions(empty, sdkVersion); if (!empty) { // SentryExecutorService should be initialized before any // SendCachedEventFireAndForgetIntegration @@ -2647,7 +2655,7 @@ private SentryOptions(final boolean empty) { } setSentryClientName(BuildConfig.SENTRY_JAVA_SDK_NAME + "/" + BuildConfig.VERSION_NAME); - setSdkVersion(createSdkVersion()); + setSdkVersion(sdkVersion); addPackageInfo(); } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index f9e82c8600..73eb7a33e9 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -114,7 +115,13 @@ public enum SentryReplayQuality { */ private boolean trackOrientationChange = true; - public SentryReplayOptions(final boolean empty) { + /** + * SdkVersion object that contains the Sentry Client Name and its version. This object is only + * applied to {@link SentryReplayEvent}s. + */ + private @Nullable SdkVersion sdkVersion; + + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { setMaskAllText(true); setMaskAllImages(true); @@ -123,14 +130,18 @@ public SentryReplayOptions(final boolean empty) { maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); maskViewClasses.add(EXOPLAYER_CLASS_NAME); maskViewClasses.add(EXOPLAYER_STYLED_CLASS_NAME); + this.sdkVersion = sdkVersion; } } public SentryReplayOptions( - final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { - this(false); + final @Nullable Double sessionSampleRate, + final @Nullable Double onErrorSampleRate, + final @Nullable SdkVersion sdkVersion) { + this(false, sdkVersion); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; + this.sdkVersion = sdkVersion; } @Nullable @@ -282,4 +293,14 @@ public boolean isTrackOrientationChange() { public void setTrackOrientationChange(final boolean trackOrientationChange) { this.trackOrientationChange = trackOrientationChange; } + + @ApiStatus.Internal + public @Nullable SdkVersion getSdkVersion() { + return sdkVersion; + } + + @ApiStatus.Internal + public void setSdkVersion(final @Nullable SdkVersion sdkVersion) { + this.sdkVersion = sdkVersion; + } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 00214e92c5..e82b184c27 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -27,7 +27,7 @@ import kotlin.test.assertTrue class MainEventProcessorTest { class Fixture { - private val sentryOptions: SentryOptions = SentryOptions().apply { + val sentryOptions: SentryOptions = SentryOptions().apply { dsn = dsnString release = "release" dist = "dist" @@ -619,6 +619,18 @@ class MainEventProcessorTest { assertEquals("value1", replayEvent.tags!!["tag1"]) } + @Test + fun `uses SdkVersion from replay options for replay events`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + + fixture.sentryOptions.experimental.sessionReplay.sdkVersion = SdkVersion("dart", "3.2.1") + var replayEvent = SentryReplayEvent() + replayEvent = sut.process(replayEvent, Hint()) + + assertEquals("3.2.1", replayEvent.sdk!!.version) + assertEquals("dart", replayEvent.sdk!!.name) + } + private fun generateCrashedEvent(crashedThread: Thread = Thread.currentThread()) = SentryEvent().apply { val mockThrowable = mock() diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 794a3dac09..48d9d71ac4 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -7,7 +7,7 @@ class SentryReplayOptionsTest { @Test fun `uses medium quality as default`() { - val replayOptions = SentryReplayOptions(true) + val replayOptions = SentryReplayOptions(true, null) assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) assertEquals(75_000, replayOptions.quality.bitRate) @@ -16,7 +16,7 @@ class SentryReplayOptionsTest { @Test fun `low quality`() { - val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + val replayOptions = SentryReplayOptions(true, null).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } assertEquals(50_000, replayOptions.quality.bitRate) assertEquals(0.8f, replayOptions.quality.sizeScale) @@ -24,7 +24,7 @@ class SentryReplayOptionsTest { @Test fun `high quality`() { - val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + val replayOptions = SentryReplayOptions(true, null).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } assertEquals(100_000, replayOptions.quality.bitRate) assertEquals(1.0f, replayOptions.quality.sizeScale) From 94586a7dedd49fb38fa81f670ff5805cf72e3297 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 17:51:19 +0100 Subject: [PATCH 11/27] Send replay recording options --- .../android/replay/capture/CaptureStrategy.kt | 5 + .../capture/SessionCaptureStrategyTest.kt | 39 +++ sentry/api/sentry.api | 29 +++ .../java/io/sentry/SentryReplayOptions.java | 5 + .../io/sentry/rrweb/RRWebOptionsEvent.java | 232 ++++++++++++++++++ .../java/io/sentry/util/CollectionUtils.java | 24 ++ 6 files changed, 334 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java 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 2f7a5bef14..660a366ecd 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 @@ -16,6 +16,7 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.rrweb.RRWebVideoEvent import java.io.File import java.util.Date @@ -195,6 +196,10 @@ internal interface CaptureStrategy { } } + if (segmentId == 0) { + recordingPayload += RRWebOptionsEvent(options) + } + val recording = ReplayRecording().apply { this.segmentId = segmentId this.payload = recordingPayload.sortedBy { it.timestamp } 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 50adeae3a1..1331ff7e6d 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 @@ -9,6 +9,8 @@ import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions.SentryReplayQuality.HIGH +import io.sentry.android.replay.BuildConfig import io.sentry.android.replay.DefaultReplayBreadcrumbConverter import io.sentry.android.replay.GeneratedVideo import io.sentry.android.replay.ReplayCache @@ -22,9 +24,11 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.maskAllImages import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule @@ -367,4 +371,39 @@ class SessionCaptureStrategyTest { "the current replay cache folder is not being deleted." ) } + + @Test + fun `records replay options event for segment 0`() { + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.experimental.sessionReplay.maskAllImages = false + fixture.options.experimental.sessionReplay.quality = HIGH + fixture.options.experimental.sessionReplay.addMaskViewClass("my.custom.View") + + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.filterIsInstance()!! + assertEquals("sentry.java", optionsEvent[0].optionsPayload["nativeSdkName"]) + assertEquals(BuildConfig.VERSION_NAME, optionsEvent[0].optionsPayload["nativeSdkVersion"]) + + assertEquals(null, optionsEvent[0].optionsPayload["errorSampleRate"]) + assertEquals(1.0, optionsEvent[0].optionsPayload["sessionSampleRate"]) + assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) + assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) + assertEquals("high", optionsEvent[0].optionsPayload["quality"]) + assertEquals("android.widget.TextView,android.webkit.WebView,android.widget.VideoView,androidx.media3.ui.PlayerView,com.google.android.exoplayer2.ui.PlayerView,com.google.android.exoplayer2.ui.StyledPlayerView,my.custom.View", optionsEvent[0].optionsPayload["maskedViewClasses"]) + assertEquals("android.widget.ImageView", optionsEvent[0].optionsPayload["unmaskedViewClasses"]) + } + ) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 92a8e90976..d5b7589f16 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2760,6 +2760,7 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality; public final field bitRate I public final field sizeScale F + public fun serializedName ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static fun values ()[Lio/sentry/SentryReplayOptions$SentryReplayQuality; } @@ -5415,6 +5416,33 @@ public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebOptionsEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/SentryOptions;)V + public fun getDataUnknown ()Ljava/util/Map; + public fun getOptionsPayload ()Ljava/util/Map; + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setOptionsPayload (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebOptionsEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public fun ()V +} + public final class io/sentry/rrweb/RRWebSpanEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field EVENT_TAG Ljava/lang/String; public fun ()V @@ -5637,6 +5665,7 @@ public final class io/sentry/util/CollectionUtils { public static fun contains ([Ljava/lang/Object;Ljava/lang/Object;)Z public static fun filterListEntries (Ljava/util/List;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/List; public static fun filterMapEntries (Ljava/util/Map;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/Map; + public static fun joinToString (Ljava/util/Set;Ljava/lang/String;)Ljava/lang/String; public static fun map (Ljava/util/List;Lio/sentry/util/CollectionUtils$Mapper;)Ljava/util/List; public static fun newArrayList (Ljava/util/List;)Ljava/util/List; public static fun newConcurrentHashMap (Ljava/util/Map;)Ljava/util/Map; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 73eb7a33e9..396c733815 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; +import java.util.Locale; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import org.jetbrains.annotations.ApiStatus; @@ -42,6 +43,10 @@ public enum SentryReplayQuality { this.sizeScale = sizeScale; this.bitRate = bitRate; } + + public @NotNull String serializedName() { + return name().toLowerCase(Locale.ROOT); + } } /** diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java new file mode 100644 index 0000000000..23a24cdd50 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -0,0 +1,232 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.SentryOptions; +import io.sentry.SentryReplayOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.util.CollectionUtils; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebOptionsEvent extends RRWebEvent implements JsonSerializable, JsonUnknown { + public static final String EVENT_TAG = "options"; + + private @NotNull String tag; + // keeping this untyped so hybrids can easily set what they want + private @NotNull Map optionsPayload = new HashMap<>(); + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebOptionsEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + public RRWebOptionsEvent(final @NotNull SentryOptions options) { + this(); + final SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion != null) { + optionsPayload.put("nativeSdkName", sdkVersion.getName()); + optionsPayload.put("nativeSdkVersion", sdkVersion.getVersion()); + } + final @NotNull SentryReplayOptions replayOptions = options.getExperimental().getSessionReplay(); + optionsPayload.put("errorSampleRate", replayOptions.getOnErrorSampleRate()); + optionsPayload.put("sessionSampleRate", replayOptions.getSessionSampleRate()); + optionsPayload.put( + "maskAllImages", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)); + optionsPayload.put( + "maskAllText", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)); + optionsPayload.put("quality", replayOptions.getQuality().serializedName()); + optionsPayload.put( + "maskedViewClasses", CollectionUtils.joinToString(replayOptions.getMaskViewClasses(), ",")); + optionsPayload.put( + "unmaskedViewClasses", + CollectionUtils.joinToString(replayOptions.getUnmaskViewClasses(), ",")); + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public @NotNull Map getOptionsPayload() { + return optionsPayload; + } + + public void setOptionsPayload(final @NotNull Map optionsPayload) { + this.optionsPayload = optionsPayload; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (optionsPayload != null) { + for (final String key : optionsPayload.keySet()) { + final Object value = optionsPayload.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebOptionsEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebOptionsEvent event = new RRWebOptionsEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map optionsPayload = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + if (optionsPayload == null) { + optionsPayload = new HashMap<>(); + } + reader.nextUnknown(logger, optionsPayload, nextName); + } + if (optionsPayload != null) { + event.setOptionsPayload(optionsPayload); + } + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index f3a0e1d9d7..cb88575e9b 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -161,6 +162,29 @@ public static boolean contains(final @NotNull T[] array, final @NotNull T el return false; } + /** + * Joins a set of items into a string using the specified separator. + * + * @param items the items + * @param separator the separator + * @return a string with the items joined by the separator + */ + public static String joinToString( + final @NotNull Set items, final @NotNull String separator) { + final StringBuilder result = new StringBuilder(); + + int i = 0; + for (T item : items) { + result.append(item); + if (i < items.size() - 1) { + result.append(separator); + } + i++; + } + + return result.toString(); + } + /** * A simplified copy of Java 8 Predicate. * From 870fdd8f41c463ab799ebfae0359f05370f777e7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 17:56:16 +0100 Subject: [PATCH 12/27] Changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1b0807c3..4336a3aa82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937)) +### Internal + +- Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) + ## 7.19.1 ### Fixes From cba3c7215b530d83d2203457149099ae63592a48 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 18:16:13 +0100 Subject: [PATCH 13/27] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4336a3aa82..893779a9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Internal - Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) +- Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015)) ## 7.19.1 From 4bf8acd9c48bc3a8848f1b516a5daeb77375954d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 22:24:19 +0100 Subject: [PATCH 14/27] Add a comment --- sentry/src/main/java/io/sentry/SentryClient.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index fdbfaf4622..a94d6b5ec3 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -640,6 +640,8 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { envelopeItems.add(replayItem); final SentryId sentryId = event.getEventId(); + // SdkVersion from ReplayOptions defaults to SdkVersion from SentryOptions and can be + // overwritten by the hybrid SDKs final SentryEnvelopeHeader envelopeHeader = new SentryEnvelopeHeader( sentryId, options.getExperimental().getSessionReplay().getSdkVersion(), traceContext); From 35313ba90a3c039bc663d8c2edfcd583b07cfeaa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 22:39:17 +0100 Subject: [PATCH 15/27] Add a test --- .../capture/SessionCaptureStrategyTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 1331ff7e6d..103ea9755a 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 @@ -49,6 +49,7 @@ import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SessionCaptureStrategyTest { @@ -406,4 +407,32 @@ class SessionCaptureStrategyTest { } ) } + + @Test + fun `does not record replay options event for segment above 0`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.find { it is RRWebOptionsEvent } + assertNull(optionsEvent) + } + ) + } } From fa37ca32de7fd9ec8bea358e846c4c0f955690c8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 30 Dec 2024 23:16:50 +0100 Subject: [PATCH 16/27] Change joinToString to serializable list for options --- .../capture/SessionCaptureStrategyTest.kt | 19 +++++++++++++-- sentry/api/sentry.api | 1 - .../io/sentry/rrweb/RRWebOptionsEvent.java | 8 ++----- .../java/io/sentry/util/CollectionUtils.java | 24 ------------------- 4 files changed, 19 insertions(+), 33 deletions(-) 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 103ea9755a..1a817609d0 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 @@ -47,6 +47,7 @@ import org.mockito.kotlin.whenever import java.io.File import java.util.Date import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -402,8 +403,22 @@ class SessionCaptureStrategyTest { assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) assertEquals("high", optionsEvent[0].optionsPayload["quality"]) - assertEquals("android.widget.TextView,android.webkit.WebView,android.widget.VideoView,androidx.media3.ui.PlayerView,com.google.android.exoplayer2.ui.PlayerView,com.google.android.exoplayer2.ui.StyledPlayerView,my.custom.View", optionsEvent[0].optionsPayload["maskedViewClasses"]) - assertEquals("android.widget.ImageView", optionsEvent[0].optionsPayload["unmaskedViewClasses"]) + assertContentEquals( + listOf( + "android.widget.TextView", + "android.webkit.WebView", + "android.widget.VideoView", + "androidx.media3.ui.PlayerView", + "com.google.android.exoplayer2.ui.PlayerView", + "com.google.android.exoplayer2.ui.StyledPlayerView", + "my.custom.View" + ), + optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*> + ) + assertContentEquals( + listOf("android.widget.ImageView"), + optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*> + ) } ) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d5b7589f16..26f77fe3ac 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5665,7 +5665,6 @@ public final class io/sentry/util/CollectionUtils { public static fun contains ([Ljava/lang/Object;Ljava/lang/Object;)Z public static fun filterListEntries (Ljava/util/List;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/List; public static fun filterMapEntries (Ljava/util/Map;Lio/sentry/util/CollectionUtils$Predicate;)Ljava/util/Map; - public static fun joinToString (Ljava/util/Set;Ljava/lang/String;)Ljava/lang/String; public static fun map (Ljava/util/List;Lio/sentry/util/CollectionUtils$Mapper;)Ljava/util/List; public static fun newArrayList (Ljava/util/List;)Ljava/util/List; public static fun newConcurrentHashMap (Ljava/util/Map;)Ljava/util/Map; diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index 23a24cdd50..d7ad2b1b48 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -9,7 +9,6 @@ import io.sentry.SentryOptions; import io.sentry.SentryReplayOptions; import io.sentry.protocol.SdkVersion; -import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.HashMap; @@ -51,11 +50,8 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { "maskAllText", replayOptions.getMaskViewClasses().contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)); optionsPayload.put("quality", replayOptions.getQuality().serializedName()); - optionsPayload.put( - "maskedViewClasses", CollectionUtils.joinToString(replayOptions.getMaskViewClasses(), ",")); - optionsPayload.put( - "unmaskedViewClasses", - CollectionUtils.joinToString(replayOptions.getUnmaskViewClasses(), ",")); + optionsPayload.put("maskedViewClasses", replayOptions.getMaskViewClasses()); + optionsPayload.put("unmaskedViewClasses", replayOptions.getUnmaskViewClasses()); } @NotNull diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index cb88575e9b..f3a0e1d9d7 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -5,7 +5,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -162,29 +161,6 @@ public static boolean contains(final @NotNull T[] array, final @NotNull T el return false; } - /** - * Joins a set of items into a string using the specified separator. - * - * @param items the items - * @param separator the separator - * @return a string with the items joined by the separator - */ - public static String joinToString( - final @NotNull Set items, final @NotNull String separator) { - final StringBuilder result = new StringBuilder(); - - int i = 0; - for (T item : items) { - result.append(item); - if (i < items.size() - 1) { - result.append(separator); - } - i++; - } - - return result.toString(); - } - /** * A simplified copy of Java 8 Predicate. * From a5f1ba50488743f17216106bd2319d7737b1ba06 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 10:33:23 +0100 Subject: [PATCH 17/27] Reduce heap allocations in screenshot recorder --- .../io/sentry/android/replay/ReplayCache.kt | 16 +++++-- .../android/replay/ScreenshotRecorder.kt | 46 ++++++++----------- .../java/io/sentry/SentryReplayOptions.java | 20 +++++--- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index a757c4b455..c8940ab5cf 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -2,6 +2,7 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.CompressFormat.PNG import android.graphics.BitmapFactory import io.sentry.DateUtils import io.sentry.ReplayRecording @@ -85,7 +86,7 @@ public class ReplayCache( it.createNewFile() } screenshot.outputStream().use { - bitmap.compress(JPEG, 80, it) + bitmap.compress(JPEG, options.experimental.sessionReplay.quality.screenshotQuality, it) it.flush() } @@ -162,7 +163,7 @@ public class ReplayCache( val step = 1000 / frameRate.toLong() var frameCount = 0 - var lastFrame: ReplayFrame = frames.first() + var lastFrame: ReplayFrame? = frames.first() for (timestamp in from until (from + (duration)) step step) { val iter = frames.iterator() while (iter.hasNext()) { @@ -182,6 +183,12 @@ public class ReplayCache( // to respect the video duration if (encode(lastFrame)) { frameCount++ + } else if (lastFrame != null) { + // if we failed to encode the frame, we delete the screenshot right away as the + // likelihood of it being able to be encoded later is low + deleteFile(lastFrame.screenshot) + frames.remove(lastFrame) + lastFrame = null } } @@ -206,7 +213,10 @@ public class ReplayCache( return GeneratedVideo(videoFile, frameCount, videoDuration) } - private fun encode(frame: ReplayFrame): Boolean { + private fun encode(frame: ReplayFrame?): Boolean { + if (frame == null) { + return false + } return try { val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) synchronized(encoderLock) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 6f588fe779..086c40f41d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap -import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix @@ -54,9 +53,14 @@ internal class ScreenshotRecorder( Bitmap.createBitmap( 1, 1, - Bitmap.Config.ARGB_8888 + Bitmap.Config.RGB_565 ) } + private val screenshot = Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + Bitmap.Config.RGB_565 + ) private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } private val prescaledMatrix by lazy(NONE) { Matrix().apply { @@ -65,7 +69,7 @@ internal class ScreenshotRecorder( } private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) - private var lastScreenshot: Bitmap? = null + private val lastCaptureSuccessful = AtomicBoolean(false) fun capture() { if (!isCapturing.get()) { @@ -73,14 +77,10 @@ internal class ScreenshotRecorder( return } - if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) { + if (!contentChanged.get() && lastCaptureSuccessful.get()) { options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") - lastScreenshot?.let { - screenshotRecorderCallback?.onScreenshotRecorded( - it.copy(ARGB_8888, false) - ) - } + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) return } @@ -96,38 +96,33 @@ internal class ScreenshotRecorder( return } - val bitmap = Bitmap.createBitmap( - config.recordingWidth, - config.recordingHeight, - Bitmap.Config.ARGB_8888 - ) - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible mainLooperHandler.post { try { contentChanged.set(false) PixelCopy.request( window, - bitmap, + screenshot, { copyResult: Int -> if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - bitmap.recycle() + lastCaptureSuccessful.set(false) return@request } // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - bitmap.recycle() + lastCaptureSuccessful.set(false) return@request } + // TODO: disableAllMasking here and dont traverse? val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.mask") { - val canvas = Canvas(bitmap) + val canvas = Canvas(screenshot) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> if (node.shouldMask && (node.width > 0 && node.height > 0)) { @@ -141,7 +136,7 @@ internal class ScreenshotRecorder( val (visibleRects, color) = when (node) { is ImageViewHierarchyNode -> { listOf(node.visibleRect) to - bitmap.dominantColorForRect(node.visibleRect) + screenshot.dominantColorForRect(node.visibleRect) } is TextViewHierarchyNode -> { @@ -168,20 +163,17 @@ internal class ScreenshotRecorder( return@traverse true } - val screenshot = bitmap.copy(ARGB_8888, false) screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - lastScreenshot?.recycle() - lastScreenshot = screenshot + // TODO: set it to false when failed to capture + lastCaptureSuccessful.set(true) contentChanged.set(false) - - bitmap.recycle() } }, mainLooperHandler.handler ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) - bitmap.recycle() + lastCaptureSuccessful.set(false) } } } @@ -226,7 +218,7 @@ internal class ScreenshotRecorder( fun close() { unbind(rootView?.get()) rootView?.clear() - lastScreenshot?.recycle() + screenshot.recycle() isCapturing.set(false) } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 396c733815..f424566c5e 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -21,14 +21,14 @@ public final class SentryReplayOptions { "com.google.android.exoplayer2.ui.StyledPlayerView"; public enum SentryReplayQuality { - /** Video Scale: 80% Bit Rate: 50.000 */ - LOW(0.8f, 50_000), + /** Video Scale: 80% Bit Rate: 50.000 JPEG Compression: 10 */ + LOW(0.8f, 50_000, 10), - /** Video Scale: 100% Bit Rate: 75.000 */ - MEDIUM(1.0f, 75_000), + /** Video Scale: 100% Bit Rate: 75.000 JPEG Compression: 30 */ + MEDIUM(1.0f, 75_000, 30), - /** Video Scale: 100% Bit Rate: 100.000 */ - HIGH(1.0f, 100_000); + /** Video Scale: 100% Bit Rate: 100.000 JPEG Compression: 50 */ + HIGH(1.0f, 100_000, 50); /** The scale related to the window size (in dp) at which the replay will be created. */ public final float sizeScale; @@ -39,9 +39,15 @@ public enum SentryReplayQuality { */ public final int bitRate; - SentryReplayQuality(final float sizeScale, final int bitRate) { + /** + * Defines the compression quality with which the screenshots are stored to disk. + */ + public final int screenshotQuality; + + SentryReplayQuality(final float sizeScale, final int bitRate, final int screenshotQuality) { this.sizeScale = sizeScale; this.bitRate = bitRate; + this.screenshotQuality = screenshotQuality; } public @NotNull String serializedName() { From c709c504fff848d1f2ea37032106b0e4012d0c05 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 10:56:44 +0100 Subject: [PATCH 18/27] Formatting --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 1 - sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/SentryReplayOptions.java | 4 +--- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index c8940ab5cf..8031455c68 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -2,7 +2,6 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG -import android.graphics.Bitmap.CompressFormat.PNG import android.graphics.BitmapFactory import io.sentry.DateUtils import io.sentry.ReplayRecording diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 26f77fe3ac..fa303ba61a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2759,6 +2759,7 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality; public final field bitRate I + public final field screenshotQuality I public final field sizeScale F public fun serializedName ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index f424566c5e..36afd60879 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -39,9 +39,7 @@ public enum SentryReplayQuality { */ public final int bitRate; - /** - * Defines the compression quality with which the screenshots are stored to disk. - */ + /** Defines the compression quality with which the screenshots are stored to disk. */ public final int screenshotQuality; SentryReplayQuality(final float sizeScale, final int bitRate, final int screenshotQuality) { From cd1438408924276f223028e411f86e593c074ed3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 14:21:32 +0100 Subject: [PATCH 19/27] Add test --- .../sentry/android/replay/ReplayCacheTest.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index c7529ac1f6..b2c8836d40 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -25,6 +25,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBitmapFactory import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -61,6 +62,7 @@ class ReplayCacheTest { @BeforeTest fun `set up`() { ReplayShadowMediaCodec.framesToEncode = 5 + ShadowBitmapFactory.setAllowInvalidImageData(true) } @Test @@ -500,4 +502,28 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) } + + @Test + fun `when screenshot is corrupted, deletes it immediately`() { + ShadowBitmapFactory.setAllowInvalidImageData(false) + ReplayShadowMediaCodec.framesToEncode = 1 + val replayCache = fixture.getSut( + tmpDir + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + // corrupt the image + File(replayCache.replayCacheDir, "1.jpg").outputStream().use { + it.write(Int.MIN_VALUE) + it.flush() + } + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000) + assertNull(segment0) + + assertTrue(replayCache.frames.isEmpty()) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) + } } From 179088d92f8ab784c706bbde4e7289f92ccc776c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 14:33:39 +0100 Subject: [PATCH 20/27] Fix --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 - sentry/src/main/java/io/sentry/SentryReplayOptions.java | 4 ---- 2 files changed, 5 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 086c40f41d..ba3cfc7115 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -164,7 +164,6 @@ internal class ScreenshotRecorder( } screenshotRecorderCallback?.onScreenshotRecorded(screenshot) - // TODO: set it to false when failed to capture lastCaptureSuccessful.set(true) contentChanged.set(false) } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 6aeaa8e6b9..36afd60879 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -51,10 +51,6 @@ public enum SentryReplayQuality { public @NotNull String serializedName() { return name().toLowerCase(Locale.ROOT); } - - public @NotNull String serializedName() { - return name().toLowerCase(Locale.ROOT); - } } /** From 036e373ad6d1a731082431421308acc2d1d387e4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 14:48:24 +0100 Subject: [PATCH 21/27] Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 893779a9a3..c71f589a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixes - Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937)) +- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) +- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016)) ### Internal From d812f11dcb86df31a02739f9395f4cf8456b573a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 16:02:47 +0100 Subject: [PATCH 22/27] GA Session replay --- .../android/core/ManifestMetadataReader.java | 10 +++---- .../core/ManifestMetadataReaderTest.kt | 20 +++++++------- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../io/sentry/uitest/android/ReplayTest.kt | 2 +- .../io/sentry/android/replay/ReplayCache.kt | 2 +- .../android/replay/ReplayIntegration.kt | 16 ++++++------ .../replay/capture/BufferCaptureStrategy.kt | 8 +++--- .../replay/capture/SessionCaptureStrategy.kt | 6 ++--- .../viewhierarchy/ComposeViewHierarchyNode.kt | 4 +-- .../replay/viewhierarchy/ViewHierarchyNode.kt | 8 +++--- .../replay/AnrWithReplayIntegrationTest.kt | 2 +- .../android/replay/ReplayIntegrationTest.kt | 4 +-- .../ReplayIntegrationWithRecorderTest.kt | 4 +-- .../sentry/android/replay/ReplaySmokeTest.kt | 6 ++--- .../capture/BufferCaptureStrategyTest.kt | 8 +++--- .../capture/SessionCaptureStrategyTest.kt | 22 ++++++++-------- .../ComposeMaskingOptionsTest.kt | 14 +++++----- .../ContainerMaskingOptionsTest.kt | 18 ++++++------- .../viewhierarchy/MaskingOptionsTest.kt | 26 +++++++++---------- .../java/io/sentry/ExperimentalOptions.java | 11 -------- .../java/io/sentry/MainEventProcessor.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 2 +- .../main/java/io/sentry/SentryOptions.java | 16 ++++++++++-- .../io/sentry/rrweb/RRWebOptionsEvent.java | 2 +- .../java/io/sentry/MainEventProcessorTest.kt | 2 +- sentry/src/test/java/io/sentry/SentryTest.kt | 2 +- 27 files changed, 110 insertions(+), 111 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index eb60c5d9c4..2d2df5700a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -399,28 +399,26 @@ static void applyMetadata( options.setEnableMetrics( readBool(metadata, logger, ENABLE_METRICS, options.isEnableMetrics())); - if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { + if (options.getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); + options.getSessionReplay().setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getSessionReplay().getOnErrorSampleRate() == null) { + if (options.getSessionReplay().getOnErrorSampleRate() == null) { final Double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (onErrorSampleRate != -1) { - options.getExperimental().getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); + options.getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); } } options - .getExperimental() .getSessionReplay() .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); options - .getExperimental() .getSessionReplay() .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index ee4b4ae39a..d60f47bd2c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1459,14 +1459,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } @Test fun `applyMetadata does not override replays onErrorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() + fixture.options.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1474,7 +1474,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } @Test @@ -1486,7 +1486,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertNull(fixture.options.sessionReplay.onErrorSampleRate) } @Test @@ -1499,8 +1499,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1512,8 +1512,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1562,7 +1562,7 @@ class ManifestMetadataReaderTest { assertEquals(expectedSampleRate.toDouble(), fixture.options.sampleRate) assertEquals(expectedSampleRate.toDouble(), fixture.options.tracesSampleRate) assertEquals(expectedSampleRate.toDouble(), fixture.options.profilesSampleRate) - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.sessionSampleRate) - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.sessionSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 17c11475c9..ec2b3db4ce 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -371,7 +371,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplay.onErrorSampleRate = 1.0 + options.sessionReplay.onErrorSampleRate = 1.0 optionsConfig(options) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index ffea8f2e04..96fb906609 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -65,7 +65,7 @@ class ReplayTest : BaseUiTest() { activityScenario.moveToState(Lifecycle.State.RESUMED) initSentry { - it.experimental.sessionReplay.sessionSampleRate = 1.0 + it.sessionReplay.sessionSampleRate = 1.0 it.beforeSendReplay = SentryOptions.BeforeSendReplayCallback { event, _ -> diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 8031455c68..88638e7e16 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -85,7 +85,7 @@ public class ReplayCache( it.createNewFile() } screenshot.outputStream().use { - bitmap.compress(JPEG, options.experimental.sessionReplay.quality.screenshotQuality, it) + bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it) it.flush() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 4148fbb26c..c0b77abc2a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -118,8 +118,8 @@ public class ReplayIntegration( return } - if (!options.experimental.sessionReplay.isSessionReplayEnabled && - !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled + if (!options.sessionReplay.isSessionReplayEnabled && + !options.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return @@ -132,7 +132,7 @@ public class ReplayIntegration( options.connectionStatusProvider.addConnectionStatusObserver(this) hub.rateLimiter?.addRateLimitObserver(this) - if (options.experimental.sessionReplay.isTrackOrientationChange) { + if (options.sessionReplay.isTrackOrientationChange) { try { context.registerComponentCallbacks(this) } catch (e: Throwable) { @@ -167,13 +167,13 @@ public class ReplayIntegration( return } - val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate) - if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) { + val isFullSession = random.sample(options.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { options.logger.log(INFO, "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified") return } - val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider) } else { @@ -264,7 +264,7 @@ public class ReplayIntegration( options.connectionStatusProvider.removeConnectionStatusObserver(this) hub?.rateLimiter?.removeRateLimitObserver(this) - if (options.experimental.sessionReplay.isTrackOrientationChange) { + if (options.sessionReplay.isTrackOrientationChange) { try { context.unregisterComponentCallbacks(this) } catch (ignored: Throwable) { @@ -285,7 +285,7 @@ public class ReplayIntegration( recorder?.stop() // refresh config based on new device configuration - val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) 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 8ef346f8a3..996e31afbd 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 @@ -65,7 +65,7 @@ internal class BufferCaptureStrategy( isTerminating: Boolean, onSegmentSent: (Date) -> Unit ) { - val sampled = random.sample(options.experimental.sessionReplay.onErrorSampleRate) + val sampled = random.sample(options.sessionReplay.onErrorSampleRate) if (!sampled) { options.logger.log(INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event") @@ -107,7 +107,7 @@ internal class BufferCaptureStrategy( cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis - val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration + val bufferLimit = now - options.sessionReplay.errorReplayDuration screenAtStart = cache?.rotate(bufferLimit) bufferedSegments.rotate(bufferLimit) } @@ -137,7 +137,7 @@ internal class BufferCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) - val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + val bufferLimit = dateProvider.currentTimeMillis - options.sessionReplay.errorReplayDuration rotateEvents(currentEvents, bufferLimit) } @@ -189,7 +189,7 @@ internal class BufferCaptureStrategy( } private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { - val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start 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 a8c8f1387c..03ca0cdf55 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 @@ -92,10 +92,10 @@ internal class SessionCaptureStrategy( } val now = dateProvider.currentTimeMillis - if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { + if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { val segment = createSegmentInternal( - options.experimental.sessionReplay.sessionSegmentDuration, + options.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, currentReplayId, currentSegment, @@ -110,7 +110,7 @@ internal class SessionCaptureStrategy( } } - if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 652e8cd040..a4d82159da 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -56,11 +56,11 @@ internal object ComposeViewHierarchyNode { } val className = getProxyClassName(isImage) - if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { + if (options.sessionReplay.unmaskViewClasses.contains(className)) { return false } - return options.experimental.sessionReplay.maskViewClasses.contains(className) + return options.sessionReplay.maskViewClasses.contains(className) } private var _rootCoordinates: WeakReference? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 03bda7cfc6..329717d62c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -269,22 +269,22 @@ sealed class ViewHierarchyNode( return false } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { + if (this.javaClass.isAssignableFrom(options.sessionReplay.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) + return this.javaClass.isAssignableFrom(options.sessionReplay.maskViewClasses) } private fun ViewParent.isUnmaskContainer(options: SentryOptions): Boolean { val unmaskContainer = - options.experimental.sessionReplay.unmaskViewContainerClass ?: return false + options.sessionReplay.unmaskViewContainerClass ?: return false return this.javaClass.name == unmaskContainer } private fun View.isMaskContainer(options: SentryOptions): Boolean { val maskContainer = - options.experimental.sessionReplay.maskViewContainerClass ?: return false + options.sessionReplay.maskViewContainerClass ?: return false return this.javaClass.name == maskContainer } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index a050bd885f..510f87715f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -151,7 +151,7 @@ class AnrWithReplayIntegrationTest { it.cacheDirPath = cacheDir it.isDebug = true it.setLogger(SystemOutLogger()) - it.experimental.sessionReplay.onErrorSampleRate = 1.0 + it.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index f375136149..4183a780b8 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -113,8 +113,8 @@ class ReplayIntegrationTest { dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { options.run { - experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate - experimental.sessionReplay.sessionSampleRate = sessionSampleRate + sessionReplay.onErrorSampleRate = onErrorSampleRate + sessionReplay.sessionSampleRate = sessionSampleRate connectionStatusProvider = mock { on { connectionStatus }.thenReturn(if (isOffline) DISCONNECTED else CONNECTED) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index ea28ce7e54..ae817a1759 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -87,10 +87,10 @@ class ReplayIntegrationWithRecorderTest { // fake current time to trigger segment creation, CurrentDateProvider.getInstance() should // be used in prod val dateProvider = ICurrentDateProvider { - System.currentTimeMillis() + fixture.options.experimental.sessionReplay.sessionSegmentDuration + System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration } - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 1b4fa39411..9bd8e2038d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -108,7 +108,7 @@ class ReplaySmokeTest { captured.set(true) } - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration = fixture.getSut(context) @@ -155,7 +155,7 @@ class ReplaySmokeTest { captured.set(true) } - fixture.options.experimental.sessionReplay.onErrorSampleRate = 1.0 + fixture.options.sessionReplay.onErrorSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration = fixture.getSut(context) @@ -204,7 +204,7 @@ class ReplaySmokeTest { @Test fun `works when double inited`() { - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath // first init + close 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 1fdb41386a..29c777e171 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 @@ -91,7 +91,7 @@ class BufferCaptureStrategyTest { whenever(replayCache.replayCacheDir).thenReturn(it) } options.run { - experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate + sessionReplay.onErrorSampleRate = onErrorSampleRate } return BufferCaptureStrategy( options, @@ -181,7 +181,7 @@ class BufferCaptureStrategyTest { @Test fun `onScreenshotRecorded adds screenshot to cache`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.errorReplayDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -195,7 +195,7 @@ class BufferCaptureStrategyTest { @Test fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.errorReplayDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -204,7 +204,7 @@ class BufferCaptureStrategyTest { strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) } - verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + verify(fixture.replayCache).rotate(eq(now - fixture.options.sessionReplay.errorReplayDuration)) } @Test 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 1a817609d0..dfe4137fb0 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 @@ -214,7 +214,7 @@ class SessionCaptureStrategyTest { @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -229,7 +229,7 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut( dateProvider = { now } ) @@ -260,7 +260,7 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded stops replay when replay duration exceeded`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2) var count = 0 val strategy = fixture.getSut( dateProvider = { @@ -315,7 +315,7 @@ class SessionCaptureStrategyTest { @Test fun `fills replay urls from navigation breadcrumbs`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) @@ -341,7 +341,7 @@ class SessionCaptureStrategyTest { fixture.scope.screen = "MainActivity" val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) @@ -376,13 +376,13 @@ class SessionCaptureStrategyTest { @Test fun `records replay options event for segment 0`() { - fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 - fixture.options.experimental.sessionReplay.maskAllImages = false - fixture.options.experimental.sessionReplay.quality = HIGH - fixture.options.experimental.sessionReplay.addMaskViewClass("my.custom.View") + fixture.options.sessionReplay.sessionSampleRate = 1.0 + fixture.options.sessionReplay.maskAllImages = false + fixture.options.sessionReplay.quality = HIGH + fixture.options.sessionReplay.addMaskViewClass("my.custom.View") val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) @@ -426,7 +426,7 @@ class SessionCaptureStrategyTest { @Test fun `does not record replay options event for segment above 0`() { val now = - System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) strategy.start(fixture.recorderConfig) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index e5330fa827..8fa3106058 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -57,7 +57,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) @@ -72,7 +72,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) @@ -85,7 +85,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true + sessionReplay.maskAllImages = true } val imageNodes = activity.get().collectNodesOfType(options) @@ -98,7 +98,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = false + sessionReplay.maskAllImages = false } val imageNodes = activity.get().collectNodesOfType(options) @@ -112,7 +112,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) @@ -132,7 +132,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) @@ -152,7 +152,7 @@ class ComposeMaskingOptionsTest { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt index ff9a125d95..fab8d81ac7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ContainerMaskingOptionsTest.kt @@ -38,8 +38,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.maskAllText = true + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textViewInUnmask!!, null, 0, options) @@ -51,8 +51,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.maskAllImages = true + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageViewInUnmask!!, null, 0, options) @@ -64,7 +64,7 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) + sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) } val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.maskWithChildren!!, null, 0, options) @@ -77,8 +77,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.addMaskViewClass(CustomView::class.java.name) - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.addMaskViewClass(CustomView::class.java.name) + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val maskContainer = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithChildren!!, null, 0, options) @@ -95,8 +95,8 @@ class ContainerMaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) - experimental.sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) + sessionReplay.setMaskViewContainerClass(CustomMask::class.java.name) + sessionReplay.setUnmaskViewContainerClass(CustomUnmask::class.java.name) } val unmaskNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.unmaskWithMaskChild!!, null, 0, options) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt index 4a40e0a915..6620392bde 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -42,7 +42,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -60,7 +60,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -78,7 +78,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = true + sessionReplay.maskAllImages = true } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) @@ -92,7 +92,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllImages = false + sessionReplay.maskAllImages = false } val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) @@ -106,7 +106,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.tag = "sentry-mask" @@ -120,7 +120,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.tag = "sentry-unmask" @@ -134,7 +134,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = false + sessionReplay.maskAllText = false } MaskingOptionsActivity.textView!!.sentryReplayMask() @@ -148,7 +148,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.sentryReplayUnmask() @@ -162,7 +162,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true + sessionReplay.maskAllText = true } MaskingOptionsActivity.textView!!.visibility = View.GONE @@ -176,7 +176,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) } val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) @@ -189,8 +189,8 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.maskAllText = true // all TextView subclasses - experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + sessionReplay.maskAllText = true // all TextView subclasses + sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) } val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) @@ -205,7 +205,7 @@ class MaskingOptionsTest { buildActivity(MaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) } val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index f1bf9a8bc7..bf74e236be 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -11,18 +11,7 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplay; public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { - this.sessionReplay = new SentryReplayOptions(empty, sdkVersion); - } - - @NotNull - public SentryReplayOptions getSessionReplay() { - return sessionReplay; - } - - public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { - this.sessionReplay = sessionReplayOptions; } } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 30f95b8b8f..52c8a46186 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -161,7 +161,7 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { if (shouldApplyScopeData(event, hint)) { processNonCachedEvent(event); final @Nullable SdkVersion replaySdkVersion = - options.getExperimental().getSessionReplay().getSdkVersion(); + options.getSessionReplay().getSdkVersion(); if (replaySdkVersion != null) { // we override the SdkVersion only for replay events as those may come from Hybrid SDKs event.setSdk(replaySdkVersion); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f075876325..4d363dc261 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -373,7 +373,7 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); observer.setReplayErrorSampleRate( - options.getExperimental().getSessionReplay().getOnErrorSampleRate()); + options.getSessionReplay().getOnErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a94d6b5ec3..8dde6f2f14 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -644,7 +644,7 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { // overwritten by the hybrid SDKs final SentryEnvelopeHeader envelopeHeader = new SentryEnvelopeHeader( - sentryId, options.getExperimental().getSessionReplay().getSdkVersion(), traceContext); + sentryId, options.getSessionReplay().getSdkVersion(), traceContext); return new SentryEnvelope(envelopeHeader, envelopeItems); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 23f195ba8e..8e528ba508 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -501,6 +501,8 @@ public class SentryOptions { */ @ApiStatus.Experimental private boolean enableScreenTracking = true; + private @NotNull SentryReplayOptions sessionReplay; + /** * Adds an event processor * @@ -1421,12 +1423,12 @@ public void setSslSocketFactory(final @Nullable SSLSocketFactory sslSocketFactor */ @ApiStatus.Internal public void setSdkVersion(final @Nullable SdkVersion sdkVersion) { - final @Nullable SdkVersion replaySdkVersion = experimental.getSessionReplay().getSdkVersion(); + final @Nullable SdkVersion replaySdkVersion = getSessionReplay().getSdkVersion(); if (this.sdkVersion != null && replaySdkVersion != null && this.sdkVersion.equals(replaySdkVersion)) { // if sdkVersion = sessionReplay.sdkVersion we override it, as it means no one else set it - experimental.getSessionReplay().setSdkVersion(sdkVersion); + getSessionReplay().setSdkVersion(sdkVersion); } this.sdkVersion = sdkVersion; } @@ -2484,6 +2486,15 @@ public void setEnableScreenTracking(final boolean enableScreenTracking) { this.enableScreenTracking = enableScreenTracking; } + @NotNull + public SentryReplayOptions getSessionReplay() { + return sessionReplay; + } + + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; + } + /** * Load the lazy fields. Useful to load in the background, so that results are already cached. DO * NOT CALL THIS METHOD ON THE MAIN THREAD. @@ -2635,6 +2646,7 @@ public SentryOptions() { private SentryOptions(final boolean empty) { final @NotNull SdkVersion sdkVersion = createSdkVersion(); experimental = new ExperimentalOptions(empty, sdkVersion); + sessionReplay = new SentryReplayOptions(empty, sdkVersion); if (!empty) { // SentryExecutorService should be initialized before any // SendCachedEventFireAndForgetIntegration diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index d7ad2b1b48..f9a96074c1 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -40,7 +40,7 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { optionsPayload.put("nativeSdkName", sdkVersion.getName()); optionsPayload.put("nativeSdkVersion", sdkVersion.getVersion()); } - final @NotNull SentryReplayOptions replayOptions = options.getExperimental().getSessionReplay(); + final @NotNull SentryReplayOptions replayOptions = options.getSessionReplay(); optionsPayload.put("errorSampleRate", replayOptions.getOnErrorSampleRate()); optionsPayload.put("sessionSampleRate", replayOptions.getSessionSampleRate()); optionsPayload.put( diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index e82b184c27..8881b6d386 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -623,7 +623,7 @@ class MainEventProcessorTest { fun `uses SdkVersion from replay options for replay events`() { val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) - fixture.sentryOptions.experimental.sessionReplay.sdkVersion = SdkVersion("dart", "3.2.1") + fixture.sentryOptions.sessionReplay.sdkVersion = SdkVersion("dart", "3.2.1") var replayEvent = SentryReplayEvent() replayEvent = sut.process(replayEvent, Hint()) diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index ae34ad870b..697450f0e5 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -737,7 +737,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") - it.experimental.sessionReplay.onErrorSampleRate = 0.5 + it.sessionReplay.onErrorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) From 6649e9b5869989be100f2101a3ce9893fa7c2157 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 2 Jan 2025 15:13:42 +0000 Subject: [PATCH 23/27] Format code --- sentry/src/main/java/io/sentry/ExperimentalOptions.java | 4 +--- sentry/src/main/java/io/sentry/MainEventProcessor.java | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index bf74e236be..80d59d4f01 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -1,7 +1,6 @@ package io.sentry; import io.sentry.protocol.SdkVersion; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** @@ -12,6 +11,5 @@ */ public final class ExperimentalOptions { - public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { - } + public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {} } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 52c8a46186..45be9212ba 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -160,8 +160,7 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { if (shouldApplyScopeData(event, hint)) { processNonCachedEvent(event); - final @Nullable SdkVersion replaySdkVersion = - options.getSessionReplay().getSdkVersion(); + final @Nullable SdkVersion replaySdkVersion = options.getSessionReplay().getSdkVersion(); if (replaySdkVersion != null) { // we override the SdkVersion only for replay events as those may come from Hybrid SDKs event.setSdk(replaySdkVersion); From c468058546d7a232bbff5717b708aa4277696d73 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 16:36:11 +0100 Subject: [PATCH 24/27] Changelog --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c71f589a2b..e1de763306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### Features + +- Session Replay GA ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + +To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` options. + + ```kotlin + import io.sentry.SentryReplayOptions + import io.sentry.android.core.SentryAndroid + + SentryAndroid.init(context) { options -> + + options.sessionReplay.sessionSampleRate = 1.0 + options.sessionReplay.errorSampleRate = 1.0 + + // To change default redaction behavior (defaults to true) + options.sessionReplay.redactAllImages = true + options.sessionReplay.redactAllText = true + + // To change quality of the recording (defaults to MEDIUM) + options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) + } + ``` + ### Fixes - Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937)) @@ -13,6 +37,10 @@ - Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) - Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015)) +### Breaking changes + +- Session Replay options were moved from under `experimental` to the main `options` object ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) + ## 7.19.1 ### Fixes From c6257b8cbd0c0c1d3420b16416e717b2e1a3e718 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 16:42:45 +0100 Subject: [PATCH 25/27] Api dump --- sentry/api/sentry.api | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fa303ba61a..c38d11e945 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -316,8 +316,6 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun (ZLio/sentry/protocol/SdkVersion;)V - public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; - public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2434,6 +2432,7 @@ public class io/sentry/SentryOptions { public fun getSerializer ()Lio/sentry/ISerializer; public fun getServerName ()Ljava/lang/String; public fun getSessionFlushTimeoutMillis ()J + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; public fun getSessionTrackingIntervalMillis ()J public fun getShutdownTimeout ()J public fun getShutdownTimeoutMillis ()J @@ -2560,6 +2559,7 @@ public class io/sentry/SentryOptions { public fun setSerializer (Lio/sentry/ISerializer;)V public fun setServerName (Ljava/lang/String;)V public fun setSessionFlushTimeoutMillis (J)V + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V public fun setSessionTrackingIntervalMillis (J)V public fun setShutdownTimeout (J)V public fun setShutdownTimeoutMillis (J)V From 6805cedc62ab86d5ef156a31cbbfda60cc6a2efb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 18:08:56 +0100 Subject: [PATCH 26/27] Update CHANGELOG.md Co-authored-by: Stefano --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1de763306..90b0ecd9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.err SentryAndroid.init(context) { options -> options.sessionReplay.sessionSampleRate = 1.0 - options.sessionReplay.errorSampleRate = 1.0 + options.sessionReplay.onErrorSampleRate = 1.0 // To change default redaction behavior (defaults to true) options.sessionReplay.redactAllImages = true From 665d47ee6f8b603c89d4d3c3895efd7429a76c52 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jan 2025 18:09:02 +0100 Subject: [PATCH 27/27] Update CHANGELOG.md Co-authored-by: Stefano --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b0ecd9ec..e3fe23b8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Session Replay GA ([#4017](https://github.com/getsentry/sentry-java/pull/4017)) -To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` options. +To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.onErrorSampleRate` options. ```kotlin import io.sentry.SentryReplayOptions