diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e24537f31..7037e72370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Features + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Capture remaining replay segment for ANRs on next app launch + - Capture remaining replay segment for unhandled crashes on next app launch + +### Fixes + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Fix stopping replay in `session` mode at 1 hour deadline + - Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment + - Use propagation context when no active transaction for ANRs + ### Dependencies - Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 45f997542b..b1751d5cc8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -4,16 +4,19 @@ import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; +import static io.sentry.protocol.Contexts.REPLAY_ID; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -51,6 +54,8 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; +import java.io.File; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -78,13 +83,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable SecureRandom random; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { + this(context, options, buildInfoProvider, null); + } + + AnrV2EventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable SecureRandom random) { this.context = context; this.options = options; this.buildInfoProvider = buildInfoProvider; + this.random = random; final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -151,6 +167,72 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setFingerprints(event, hint); setLevel(event); setTrace(event); + setReplayId(event); + } + + private boolean sampleReplay(final @NotNull SentryEvent event) { + final @Nullable String replayErrorSampleRate = + PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + + if (replayErrorSampleRate == null) { + return false; + } + + try { + // we have to sample here with the old sample rate, because it may change between app launches + final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (replayErrorSampleRateDouble < random.nextDouble()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Not capturing replay for ANR %s due to not being sampled.", + event.getEventId()); + return false; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); + return false; + } + + return true; + } + + private void setReplayId(final @NotNull SentryEvent event) { + @Nullable + String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + final @NotNull File replayFolder = + new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + if (!replayFolder.exists()) { + if (!sampleReplay(event)) { + return; + } + // if the replay folder does not exist (e.g. running in buffer mode), we need to find the + // latest replay folder that was modified before the ANR event. + persistedReplayId = null; + long lastModified = Long.MIN_VALUE; + final File[] dirs = new File(options.getCacheDirPath()).listFiles(); + if (dirs != null) { + for (File dir : dirs) { + if (dir.isDirectory() && dir.getName().startsWith("replay_")) { + if (dir.lastModified() > lastModified + && dir.lastModified() <= event.getTimestamp().getTime()) { + lastModified = dir.lastModified(); + persistedReplayId = dir.getName().substring("replay_".length()); + } + } + } + } + } + + if (persistedReplayId == null) { + return; + } + + // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay + PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); + event.getContexts().put(REPLAY_ID, persistedReplayId); } private void setTrace(final @NotNull SentryEvent event) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index b581856fe0..80ae946711 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -15,18 +15,20 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext -import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME @@ -44,6 +46,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace import io.sentry.protocol.SentryThread @@ -75,7 +78,9 @@ class AnrV2EventProcessorTest { val tmpDir = TemporaryFolder() class Fixture { - + companion object { + const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d" + } val buildInfo = mock() lateinit var context: Context val options = SentryAndroidOptions().apply { @@ -87,7 +92,8 @@ class AnrV2EventProcessorTest { dir: TemporaryFolder, currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, - populateOptionsCache: Boolean = false + populateOptionsCache: Boolean = false, + replayErrorSampleRate: Double? = null ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" @@ -118,6 +124,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) + persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID)) } if (populateOptionsCache) { @@ -126,7 +133,10 @@ class AnrV2EventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + replayErrorSampleRate?.let { + persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) + } } return AnrV2EventProcessor(context, options, buildInfo) @@ -544,6 +554,65 @@ class AnrV2EventProcessorTest { assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `sets replayId when replay folder exists`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString()) + } + + @Test + fun `does not set replayId when replay folder does not exist and no sample rate persisted`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `does not set replayId when replay folder does not exist and not sampled`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `set replayId of the last modified folder`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + replayFolder1.setLastModified(1000) + replayFolder2.setLastModified(500) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + } + private fun processEvent( hint: Hint, populateScopeCache: Boolean = false, diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index e8b85a0ae9..2a81b45b82 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,18 +34,25 @@ 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 Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;J)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 persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V public final fun rotate (J)V } +public final class io/sentry/android/replay/ReplayCache$Companion { + public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; +} + public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { 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 captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; @@ -59,8 +66,6 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V - public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun stop ()V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 504c4adf21..c95b72088a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -131,14 +131,23 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { val breadcrumb = this + val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] + val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" description = breadcrumb.data["url"] as String - startTimestamp = - (breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0 - endTimestamp = - (breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0 + // can be double if it was serialized to disk + startTimestamp = if (httpStartTimestamp is Double) { + httpStartTimestamp / 1000.0 + } else { + (httpStartTimestamp as Long) / 1000.0 + } + endTimestamp = if (httpEndTimestamp is Double) { + httpEndTimestamp / 1000.0 + } else { + (httpEndTimestamp as Long) / 1000.0 + } val breadcrumbData = mutableMapOf() for ((key, value) in breadcrumb.data) { 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 f49abfaa84..549db2566b 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 @@ -3,15 +3,25 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.BitmapFactory +import io.sentry.DateUtils +import io.sentry.ReplayRecording import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebEvent +import io.sentry.util.FileUtils import java.io.Closeable import java.io.File +import java.io.StringReader +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -50,24 +60,30 @@ public class ReplayCache internal constructor( ).also { it.start() } }) + private val isClosed = AtomicBoolean(false) private val encoderLock = Any() private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { - if (options.cacheDirPath.isNullOrEmpty()) { - options.logger.log( - WARNING, - "SentryOptions.cacheDirPath is not set, session replay is no-op" - ) - null - } else { - File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } - } + makeReplayCacheDir(options, replayId) } // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() + private val ongoingSegment = LinkedHashMap() + private val ongoingSegmentFile: File? by lazy { + if (replayCacheDir == null) { + return@lazy null + } + + val file = File(replayCacheDir, ONGOING_SEGMENT) + if (!file.exists()) { + file.createNewFile() + } + file + } + /** * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored @@ -79,7 +95,7 @@ public class ReplayCache internal constructor( * @param frameTimestamp the timestamp when the frame screenshot was taken */ internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { - if (replayCacheDir == null) { + if (replayCacheDir == null || bitmap.isRecycled) { return } @@ -136,6 +152,9 @@ public class ReplayCache internal constructor( width: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } if (frames.isEmpty()) { options.logger.log( DEBUG, @@ -237,9 +256,181 @@ public class ReplayCache internal constructor( encoder?.release() encoder = null } + isClosed.set(true) + } + + // TODO: it's awful, choose a better serialization format + @Synchronized + fun persistSegmentValues(key: String, value: String?) { + if (isClosed.get()) { + return + } + if (ongoingSegment.isEmpty()) { + ongoingSegmentFile?.useLines { lines -> + lines.associateTo(ongoingSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + } + if (value == null) { + ongoingSegment.remove(key) + } else { + ongoingSegment[key] = value + } + ongoingSegmentFile?.writeText(ongoingSegment.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + } + + companion object { + internal const val ONGOING_SEGMENT = ".ongoing_segment" + + internal const val SEGMENT_KEY_HEIGHT = "config.height" + internal const val SEGMENT_KEY_WIDTH = "config.width" + internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate" + internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate" + internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp" + internal const val SEGMENT_KEY_REPLAY_ID = "replay.id" + internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type" + internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" + internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" + internal const val SEGMENT_KEY_ID = "segment.id" + + fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? { + return if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { + val replayCacheDir = makeReplayCacheDir(options, replayId) + val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) + if (!lastSegmentFile.exists()) { + options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val lastSegment = LinkedHashMap() + lastSegmentFile.useLines { lines -> + lines.associateTo(lastSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + + val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull() + val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull() + val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull() + val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull() + val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull() + val segmentTimestamp = try { + DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty()) + } catch (e: Throwable) { + null + } + val replayType = try { + ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty()) + } catch (e: Throwable) { + null + } + if (height == null || width == null || frameRate == null || bitRate == null || + (segmentId == null || segmentId == -1) || segmentTimestamp == null || replayType == null + ) { + options.logger.log( + DEBUG, + "Incorrect segment values found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val recorderConfig = ScreenshotRecorderConfig( + recordingHeight = height, + recordingWidth = width, + frameRate = frameRate, + bitRate = bitRate, + // these are not used for already captured frames, so we just hardcode them + scaleFactorX = 1.0f, + scaleFactorY = 1.0f + ) + + val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + cache.replayCacheDir?.listFiles { dir, name -> + if (name.endsWith(".jpg")) { + val file = File(dir, name) + val timestamp = file.nameWithoutExtension.toLongOrNull() + if (timestamp != null) { + cache.addFrame(file, timestamp) + } + } + false + } + + if (cache.frames.isEmpty()) { + options.logger.log( + DEBUG, + "No frames found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + cache.frames.sortBy { it.timestamp } + // TODO: this should be removed when we start sending buffered segments on next launch + val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val normalizedTimestamp = if (replayType == SESSION) { + segmentTimestamp + } else { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache.frames.first().timestamp) + } + + // add one frame to include breadcrumbs/events happened after the frame was captured + val duration = cache.frames.last().timestamp - normalizedTimestamp.time + (1000 / frameRate) + + val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { + val reader = StringReader(it) + val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) + if (recording?.payload != null) { + LinkedList(recording.payload!!) + } else { + null + } + } ?: emptyList() + + return LastSegmentData( + recorderConfig = recorderConfig, + cache = cache, + timestamp = normalizedTimestamp, + id = normalizedSegmentId, + duration = duration, + replayType = replayType, + screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], + events = events.sortedBy { it.timestamp } + ) + } } } +internal data class LastSegmentData( + val recorderConfig: ScreenshotRecorderConfig, + val cache: ReplayCache, + val timestamp: Date, + val id: Int, + val duration: Long, + val replayType: ReplayType, + val screenAtStart: String?, + val events: List +) + internal data class ReplayFrame( val screenshot: File, val timestamp: Long 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 d207cf0331..e99aec2c90 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 @@ -6,30 +6,38 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent -import io.sentry.Hint +import io.sentry.Breadcrumb import io.sentry.IHub import io.sentry.Integration import io.sentry.NoOpReplayBreadcrumbConverter import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController import io.sentry.ScopeObserverAdapter -import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME +import io.sentry.hints.Backfillable import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import io.sentry.util.HintUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File import java.security.SecureRandom +import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean public class ReplayIntegration( @@ -112,6 +120,8 @@ public class ReplayIntegration( addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) + + finalizePreviousReplay() } override fun isRecording() = isRecording.get() @@ -138,12 +148,12 @@ public class ReplayIntegration( recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) + SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider = replayCacheProvider) } - captureStrategy?.start() + captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) } @@ -156,34 +166,23 @@ public class ReplayIntegration( recorder?.resume() } - override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - if (!isEnabled.get() || !isRecording.get()) { - return - } - - if (!(event.isErrored || event.isCrashed)) { - options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) - return - } - - sendReplay(event.isCrashed, event.eventId.toString(), hint) - } - - override fun sendReplay(isCrashed: Boolean?, eventId: String?, hint: Hint?) { + override fun captureReplay(isTerminating: Boolean?) { if (!isEnabled.get() || !isRecording.get()) { return } - if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { - options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event") return } - captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { + captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 + }) captureStrategy = captureStrategy?.convert() } - override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: SentryId.EMPTY_ID override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter @@ -257,4 +256,67 @@ public class ReplayIntegration( override fun onTouchEvent(event: MotionEvent) { captureStrategy?.onTouchEvent(event) } + + private fun cleanupReplays(unfinishedReplayId: String = "") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles()?.forEach { file -> + val name = file.name + if (name.startsWith("replay_") && + !name.contains(replayId.toString()) && + !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) + ) { + FileUtils.deleteRecursively(file) + } + } + } + } + + private fun finalizePreviousReplay() { + // TODO: read persisted options/scope values form the + // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor + + options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { + val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + cleanupReplays() + return@submitSafely + } + val previousReplayId = SentryId(previousReplayIdString) + if (previousReplayId == SentryId.EMPTY_ID) { + cleanupReplays() + return@submitSafely + } + val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: run { + cleanupReplays() + return@submitSafely + } + val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + val segment = CaptureStrategy.createSegment( + hub = hub, + options = options, + duration = lastSegment.duration, + currentSegmentTimestamp = lastSegment.timestamp, + replayId = previousReplayId, + segmentId = lastSegment.id, + height = lastSegment.recorderConfig.recordingHeight, + width = lastSegment.recorderConfig.recordingWidth, + frameRate = lastSegment.recorderConfig.frameRate, + cache = lastSegment.cache, + replayType = lastSegment.replayType, + screenAtStart = lastSegment.screenAtStart, + breadcrumbs = breadcrumbs, + events = LinkedList(lastSegment.events) + ) + + if (segment is ReplaySegment.Created) { + val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) + segment.capture(hub, hint) + } + cleanupReplays(unfinishedReplayId = previousReplayIdString) // will be cleaned up after the envelope is assembled + } + } + + private class PreviousReplayHint : Backfillable { + override fun shouldEnrich(): Boolean = false + } } 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 79a75f816c..62bba00cd0 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,45 +1,55 @@ package io.sentry.android.replay.capture import android.view.MotionEvent +import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub -import io.sentry.ReplayRecording import io.sentry.SentryOptions -import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +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 +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.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.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebIncrementalSnapshotEvent import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionEvent.InteractionType import io.sentry.rrweb.RRWebInteractionMoveEvent import io.sentry.rrweb.RRWebInteractionMoveEvent.Position -import io.sentry.rrweb.RRWebMetaEvent -import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider -import io.sentry.util.FileUtils import java.io.File import java.util.Date import java.util.LinkedList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory -import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - protected var recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null, private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : CaptureStrategy { @@ -52,16 +62,38 @@ internal abstract class BaseCaptureStrategy( private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } + private val persistingExecutor: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + } + + protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected val segmentTimestamp = AtomicReference() + protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + if (newValue == null) { + // recorderConfig is only nullable on init, but never after + return@persistableAtomic + } + cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_FRAME_RATE, newValue.frameRate.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_BIT_RATE, newValue.bitRate.toString()) + } + protected var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> + cache?.persistSegmentValues(SEGMENT_KEY_TIMESTAMP, if (newValue == null) null else DateUtils.getTimestamp(newValue)) + } protected val replayStartTimestamp = AtomicLong() - protected val screenAtStart = AtomicReference() - override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) - override val currentSegment = AtomicInteger(0) + protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) + override var currentReplayId: SentryId by persistableAtomic(initialValue = SentryId.EMPTY_ID, propertyName = SEGMENT_KEY_REPLAY_ID) + override var currentSegment: Int by persistableAtomic(initialValue = -1, propertyName = SEGMENT_KEY_ID) override val replayCacheDir: File? get() = cache?.replayCacheDir - protected val currentEvents = LinkedList() - private val currentEventsLock = Any() + private var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + protected val currentEvents: LinkedList = PersistableLinkedList( + propertyName = SEGMENT_KEY_REPLAY_RECORDING, + options, + persistingExecutor, + cacheProvider = { cache } + ) private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -70,170 +102,67 @@ internal abstract class BaseCaptureStrategy( executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - currentSegment.set(segmentId) - currentReplayId.set(replayId) - - if (cleanupOldReplays) { - replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { - // clean up old replays - options.cacheDirPath?.let { cacheDir -> - File(cacheDir).listFiles { dir, name -> - // TODO: also exclude persisted replay_id from scope when implementing ANRs - if (name.startsWith("replay_") && !name.contains( - currentReplayId.get().toString() - ) - ) { - FileUtils.deleteRecursively(File(dir, name)) - } - false - } - } - } - } + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) - cache = - replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + // TODO: this should be persisted even after conversion + replayType = if (this is SessionCaptureStrategy) SESSION else BUFFER + this.recorderConfig = recorderConfig + currentSegment = segmentId + currentReplayId = replayId - // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) - // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } override fun resume() { - // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = DateUtils.getCurrentDateTime() } override fun pause() = Unit override fun stop() { cache?.close() - currentSegment.set(0) + currentSegment = -1 replayStartTimestamp.set(0) - segmentTimestamp.set(null) - currentReplayId.set(SentryId.EMPTY_ID) + segmentTimestamp = null + currentReplayId = SentryId.EMPTY_ID } - protected fun createSegment( + protected fun createSegmentInternal( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, - replayType: ReplayType = SESSION - ): ReplaySegment { - val generatedVideo = cache?.createVideoOf( + replayType: ReplayType = SESSION, + cache: ReplayCache? = this.cache, + frameRate: Int = recorderConfig.frameRate, + screenAtStart: String? = this.screenAtStart, + breadcrumbs: List? = null, + events: LinkedList = this.currentEvents + ): ReplaySegment = + createSegment( + hub, + options, duration, - currentSegmentTimestamp.time, - segmentId, - height, - width - ) ?: return ReplaySegment.Failed - - val (video, frameCount, videoDuration) = generatedVideo - return buildReplay( - video, - replayId, currentSegmentTimestamp, + replayId, segmentId, height, width, - frameCount, - videoDuration, - replayType - ) - } - - private fun buildReplay( - video: File, - currentReplayId: SentryId, - segmentTimestamp: Date, - segmentId: Int, - height: Int, - width: Int, - frameCount: Int, - duration: Long, - replayType: ReplayType - ): ReplaySegment { - val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) - val replay = SentryReplayEvent().apply { - eventId = currentReplayId - replayId = currentReplayId - this.segmentId = segmentId - this.timestamp = endTimestamp - replayStartTimestamp = segmentTimestamp - this.replayType = replayType - videoFile = video - } - - val recordingPayload = mutableListOf() - recordingPayload += RRWebMetaEvent().apply { - this.timestamp = segmentTimestamp.time - this.height = height - this.width = width - } - recordingPayload += RRWebVideoEvent().apply { - this.timestamp = segmentTimestamp.time - this.segmentId = segmentId - this.durationMs = duration - this.frameCount = frameCount - size = video.length() - frameRate = recorderConfig.frameRate - this.height = height - this.width = width - // TODO: support non-fullscreen windows later - left = 0 - top = 0 - } - - val urls = LinkedList() - hub?.configureScope { scope -> - scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.time >= segmentTimestamp.time && - breadcrumb.timestamp.time < endTimestamp.time - ) { - val rrwebEvent = options - .replayController - .breadcrumbConverter - .convert(breadcrumb) - - if (rrwebEvent != null) { - recordingPayload += rrwebEvent - - // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { - urls.add(rrwebEvent.data!!["to"] as String) - } - } - } - } - } - - if (screenAtStart.get() != null && urls.firstOrNull() != screenAtStart.get()) { - urls.addFirst(screenAtStart.get()) - } - - rotateCurrentEvents(endTimestamp.time) { event -> - if (event.timestamp >= segmentTimestamp.time) { - recordingPayload += event - } - } - - val recording = ReplayRecording().apply { - this.segmentId = segmentId - payload = recordingPayload.sortedBy { it.timestamp } - } - - replay.urls = urls - return ReplaySegment.Created( - videoDuration = duration, - replay = replay, - recording = recording + replayType, + cache, + frameRate, + screenAtStart, + breadcrumbs, + events ) - } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { this.recorderConfig = recorderConfig @@ -252,20 +181,6 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } - protected fun rotateCurrentEvents( - until: Long, - callback: ((RRWebEvent) -> Unit)? = null - ) { - synchronized(currentEventsLock) { - var event = currentEvents.peek() - while (event != null && event.timestamp < until) { - callback?.invoke(event) - currentEvents.remove() - event = currentEvents.peek() - } - } - } - private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -275,25 +190,12 @@ internal abstract class BaseCaptureStrategy( } } - protected sealed class ReplaySegment { - object Failed : ReplaySegment() - data class Created( - val videoDuration: Long, - val replay: SentryReplayEvent, - val recording: ReplayRecording - ) : ReplaySegment() { - fun capture(hub: IHub?, hint: Hint = Hint()) { - hub?.captureReplay(replay, hint.apply { replayRecording = recording }) - } - - fun setSegmentId(segmentId: Int) { - replay.segmentId = segmentId - recording.payload?.forEach { - when (it) { - is RRWebVideoEvent -> it.segmentId = segmentId - } - } - } + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret } } @@ -416,4 +318,52 @@ internal abstract class BaseCaptureStrategy( else -> null } } + + private inline fun persistableAtomicNullable( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + object : ReadWriteProperty { + private val value = AtomicReference(initialValue) + + private fun runInBackground(task: () -> Unit) { + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submitSafely(options, "$TAG.runInBackground") { + task() + } + } else { + task() + } + } + + init { + runInBackground { onChange(propertyName, initialValue, initialValue) } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + val oldValue = this.value.getAndSet(value) + if (oldValue != value) { + runInBackground { onChange(propertyName, oldValue, value) } + } + } + } + + private inline fun persistableAtomic( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty + + private inline fun persistableAtomic( + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit + ): ReadWriteProperty = + persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } 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 96d54735d5..a49c7bf789 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 @@ -3,14 +3,16 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub +import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId @@ -18,29 +20,38 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.security.SecureRandom +import java.util.concurrent.ScheduledExecutorService internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - recorderConfig: ScreenshotRecorderConfig, private val random: SecureRandom, + executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { + // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() + + // TODO: rework this bs, it doesn't work with sending replay on restart private val bufferedScreensLock = Any() private val bufferedScreens = mutableListOf>() internal companion object { private const val TAG = "BufferCaptureStrategy" + private const val ENVELOPE_PROCESSING_DELAY: Long = 100L } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + super.start(recorderConfig, segmentId, replayId) hub?.configureScope { - val screen = it.screen + val screen = it.screen?.substringAfterLast('.') if (screen != null) { synchronized(bufferedScreensLock) { bufferedScreens.add(screen to dateProvider.currentTimeMillis) @@ -58,6 +69,17 @@ internal class BufferCaptureStrategy( } } + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment++ + } + } + super.pause() + } + override fun stop() { val replayCacheDir = cache?.replayCacheDir replayExecutor.submitSafely(options, "$TAG.stop") { @@ -66,64 +88,40 @@ internal class BufferCaptureStrategy( super.stop() } - override fun sendReplayForEvent( - isCrashed: Boolean, - eventId: String?, - hint: Hint?, + override fun captureReplay( + isTerminating: Boolean, onSegmentSent: () -> Unit ) { val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) if (!sampled) { - options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", eventId) + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event") return } // write replayId to scope right away, so it gets picked up by the event that caused buffer // to flush hub?.configureScope { - it.replayId = currentReplayId.get() + it.replayId = currentReplayId } - val errorReplayDuration = options.experimental.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 - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) + if (isTerminating) { + this.isTerminating.set(true) + // avoid capturing replay, because the video will be malformed + options.logger.log(DEBUG, "Not capturing replay for crashed event, will be captured on next launch") + return } - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - findAndSetStartScreen(currentSegmentTimestamp.time) + createCurrentSegment("capture_replay") { segment -> + bufferedSegments.capture() - replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { - var bufferedSegment = bufferedSegments.removeFirstOrNull() - while (bufferedSegment != null) { - // capture without hint, so the buffered segments don't trigger flush notification - bufferedSegment.capture(hub) - bufferedSegment = bufferedSegments.removeFirstOrNull() - Thread.sleep(100L) - } - val segment = - createSegment( - now - currentSegmentTimestamp.time, - currentSegmentTimestamp, - replayId, - segmentId, - height, - width, - BUFFER - ) if (segment is ReplaySegment.Created) { - segment.capture(hub, hint ?: Hint()) + segment.capture(hub) // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already + // TODO: also pass new segmentTimestamp to the new strategy onSegmentSent() } } @@ -139,78 +137,36 @@ internal class BufferCaptureStrategy( val now = dateProvider.currentTimeMillis val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration cache?.rotate(bufferLimit) - - var removed = false - bufferedSegments.removeAll { - // it can be that the buffered segment is half-way older than the buffer limit, but - // we only drop it if its end timestamp is older - if (it.replay.timestamp.time < bufferLimit) { - currentSegment.decrementAndGet() - deleteFile(it.replay.videoFile) - removed = true - return@removeAll true - } - return@removeAll false - } - if (removed) { - // shift segmentIds after rotating buffered segments - bufferedSegments.forEachIndexed { index, segment -> - segment.setSegmentId(index) - } - } - } - } - - private fun deleteFile(file: File?) { - if (file == null) { - return - } - try { - if (!file.delete()) { - options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) - } - } catch (e: Throwable) { - options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + bufferedSegments.rotate(bufferLimit) } } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val errorReplayDuration = options.experimental.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 - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) - } - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val height = this.recorderConfig.recordingHeight - val width = this.recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { - val segment = - createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + createCurrentSegment("configuration_changed") { segment -> if (segment is ReplaySegment.Created) { bufferedSegments += segment - currentSegment.getAndIncrement() + currentSegment++ } } super.onConfigurationChanged(recorderConfig) } override fun convert(): CaptureStrategy { + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not converting to session mode, because the process is about to terminate") + return this + } // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayExecutor) - captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId) return captureStrategy } override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration - rotateCurrentEvents(bufferLimit) + rotateEvents(currentEvents, bufferLimit) } private fun findAndSetStartScreen(segmentStart: Long) { @@ -221,10 +177,81 @@ internal class BufferCaptureStrategy( // if no screen is found before the segment start, this likely means the buffer is from the // app start, and the start screen will be taken from the navigation crumbs if (startScreen != null) { - screenAtStart.set(startScreen) + screenAtStart = startScreen } // can clear as we switch to session mode and don't care anymore about buffering - bufferedSegments.clear() + bufferedScreens.clear() + } + } + + private fun deleteFile(file: File?) { + if (file == null) { + return + } + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + } + } + + private fun MutableList.capture() { + var bufferedSegment = removeFirstOrNull() + while (bufferedSegment != null) { + bufferedSegment.capture(hub) + bufferedSegment = removeFirstOrNull() + // a short delay between processing envelopes to avoid bursting our server and hitting + // another rate limit https://develop.sentry.dev/sdk/features/#additional-capabilities + // InterruptedException will be handled by the outer try-catch + Thread.sleep(ENVELOPE_PROCESSING_DELAY) + } + } + + private fun MutableList.rotate(bufferLimit: Long) { + // TODO: can be a single while-loop + var removed = false + removeAll { + // it can be that the buffered segment is half-way older than the buffer limit, but + // we only drop it if its end timestamp is older + if (it.replay.timestamp.time < bufferLimit) { + currentSegment-- + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val errorReplayDuration = options.experimental.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 + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + + findAndSetStartScreen(currentSegmentTimestamp.time) + + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + onSegmentCreated(segment) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 3233556615..c3be520b84 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 @@ -2,20 +2,35 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference +import java.util.Date +import java.util.LinkedList internal interface CaptureStrategy { - val currentSegment: AtomicInteger - val currentReplayId: AtomicReference + var currentSegment: Int + var currentReplayId: SentryId val replayCacheDir: File? - fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int = 0, + replayId: SentryId = SentryId() + ) fun stop() @@ -23,7 +38,7 @@ internal interface CaptureStrategy { fun resume() - fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) + fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) fun onScreenshotRecorded(bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit) @@ -36,4 +51,190 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy fun close() + + companion object { + internal val currentEventsLock = Any() + + fun createSegment( + hub: IHub?, + options: SentryOptions, + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType, + cache: ReplayCache?, + frameRate: Int, + screenAtStart: String?, + breadcrumbs: List?, + events: LinkedList + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + + val replayBreadcrumbs: List = if (breadcrumbs == null) { + var crumbs = emptyList() + hub?.configureScope { scope -> + crumbs = ArrayList(scope.breadcrumbs) + } + crumbs + } else { + breadcrumbs + } + + return buildReplay( + options, + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + frameRate, + videoDuration, + replayType, + screenAtStart, + replayBreadcrumbs, + events + ) + } + + private fun buildReplay( + options: SentryOptions, + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + frameRate: Int, + videoDuration: Long, + replayType: ReplayType, + screenAtStart: String?, + breadcrumbs: List, + events: LinkedList + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) + val replay = SentryReplayEvent().apply { + this.eventId = currentReplayId + this.replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = endTimestamp + this.replayStartTimestamp = segmentTimestamp + this.replayType = replayType + this.videoFile = video + } + + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.durationMs = videoDuration + this.frameCount = frameCount + this.size = video.length() + this.frameRate = frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + this.left = 0 + this.top = 0 + } + + val urls = LinkedList() + breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time + ) { + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) + + if (rrwebEvent != null) { + recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } + } + } + } + + if (screenAtStart != null && urls.firstOrNull() != screenAtStart) { + urls.addFirst(screenAtStart) + } + + rotateEvents(events, endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + this.payload = recordingPayload.sortedBy { it.timestamp } + } + + replay.urls = urls + return ReplaySegment.Created( + videoDuration = videoDuration, + replay = replay, + recording = recording + ) + } + + internal fun rotateEvents( + events: LinkedList, + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + synchronized(currentEventsLock) { + var event = events.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + events.remove() + event = events.peek() + } + } + } + } + + sealed class ReplaySegment { + object Failed : ReplaySegment() + data class Created( + val videoDuration: Long, + val replay: SentryReplayEvent, + val recording: ReplayRecording + ) : ReplaySegment() { + fun capture(hub: IHub?, hint: Hint = Hint()) { + hub?.captureReplay(replay, hint.apply { replayRecording = recording }) + } + + fun setSegmentId(segmentId: Int) { + replay.segmentId = segmentId + recording.payload?.forEach { + when (it) { + is RRWebVideoEvent -> it.segmentId = segmentId + } + } + } + } + } } 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 02687201b8..d0fd2ce1e1 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 @@ -2,7 +2,6 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IHub import io.sentry.SentryLevel.DEBUG @@ -10,6 +9,7 @@ import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider @@ -20,22 +20,25 @@ internal class SessionCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider) { internal companion object { private const val TAG = "SessionCaptureStrategy" } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + super.start(recorderConfig, segmentId, replayId) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { - it.replayId = currentReplayId.get() - screenAtStart.set(it.screen) + it.replayId = currentReplayId + screenAtStart = it.screen } } @@ -44,7 +47,7 @@ internal class SessionCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ } } super.pause() @@ -62,17 +65,9 @@ internal class SessionCaptureStrategy( super.stop() } - override fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) { - if (!isCrashed) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", eventId) - } else { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, capturing last segment for crashed event %s", eventId) - createCurrentSegment("send_replay_for_event") { segment -> - if (segment is ReplaySegment.Created) { - segment.capture(hub, hint ?: Hint()) - } - } - } + override fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event") + this.isTerminating.set(isTerminating) } override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { @@ -89,43 +84,52 @@ internal class SessionCaptureStrategy( replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.store(frameTimestamp) - val now = dateProvider.currentTimeMillis - if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() + val currentSegmentTimestamp = segmentTimestamp + currentSegmentTimestamp ?: run { + options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") + return@submitSafely + } + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not capturing segment, because the app is terminating, will be captured on next launch") + return@submitSafely + } + + val now = dateProvider.currentTimeMillis + if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { val segment = - createSegment( + createSegmentInternal( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, - replayId, - segmentId, + currentReplayId, + currentSegment, height, width ) if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } - } else if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { - stop() + } + + if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } } } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val currentSegmentTimestamp = segmentTimestamp.get() + val currentSegmentTimestamp = segmentTimestamp ?: return createCurrentSegment("onConfigurationChanged") { segment -> if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } } @@ -137,15 +141,15 @@ internal class SessionCaptureStrategy( private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - (currentSegmentTimestamp?.time ?: 0) - val replayId = currentReplayId.get() + val currentSegmentTimestamp = segmentTimestamp ?: return + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) onSegmentCreated(segment) } } 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 093416f9bb..453ff49df2 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import io.sentry.ISentryExecutorService import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import java.util.concurrent.ExecutorService @@ -25,6 +26,25 @@ internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { } } +internal fun ISentryExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + internal fun ExecutorService.submitSafely( options: SentryOptions, taskName: String, 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 new file mode 100644 index 0000000000..553bae8dee --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -0,0 +1,53 @@ +// ktlint-disable filename +package io.sentry.android.replay.util + +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.ScheduledExecutorService + +internal class PersistableLinkedList( + private val propertyName: String, + private val options: SentryOptions, + private val persistingExecutor: ScheduledExecutorService, + private val cacheProvider: () -> ReplayCache? +) : LinkedList() { + // only overriding methods that we use, to observe the collection + override fun addAll(elements: Collection): Boolean { + val result = super.addAll(elements) + persistRecording() + return result + } + + override fun add(element: RRWebEvent): Boolean { + val result = super.add(element) + persistRecording() + return result + } + + override fun remove(): RRWebEvent { + val result = super.remove() + persistRecording() + return result + } + + private fun persistRecording() { + val cache = cacheProvider() ?: return + val recording = ReplayRecording().apply { payload = ArrayList(this@PersistableLinkedList) } + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } else { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 54a3bc1f89..fd770131d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -120,7 +120,7 @@ internal class SimpleVideoEncoder( ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size format } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 0dfb3d39c8..a659f7f596 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -61,6 +61,28 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(400, rrwebEvent.data!!["requestBodySize"]) } + @Test + fun `convert RRWebSpanEvent works with floating timestamps`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["url"] = "http://example.com" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234.0 + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234.0 + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + } + @Test fun `returns null if not eligible for RRWebSpanEvent`() { val converter = fixture.getSut() 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 fe0b50c9c8..0dae78e723 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 @@ -5,10 +5,24 @@ import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 import android.media.MediaCodec import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +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_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +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.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -18,6 +32,7 @@ import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -219,6 +234,20 @@ class ReplayCacheTest { assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } + @Test + fun `does not add frame when bitmap is recycled`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + @Test fun `addFrame with File path works`() { val replayCache = fixture.getSut( @@ -232,10 +261,7 @@ class ReplayCacheTest { val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } val video = File(flutterCacheDir, "flutter_0.mp4") - screenshot.outputStream().use { - Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) - it.flush() - } + 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) @@ -264,4 +290,239 @@ class ReplayCacheTest { assertEquals(1, replayCache.frames.size) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.name == "1.jpg" || it.name == "1001.jpg" }) } + + @Test + fun `does not persist segment if already closed`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.close() + + replayCache.persistSegmentValues("key", "value") + assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) + } + + @Test + fun `stores segment key value pairs`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key1=value1", segmentValues[0]) + assertEquals("key2=value2", segmentValues[1]) + } + + @Test + fun `removes segment key value pair, if the value is null`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + replayCache.persistSegmentValues("key1", null) + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals(1, segmentValues.size) + assertEquals("key2=value2", segmentValues[0]) + } + + @Test + fun `if no ongoing_segment file exists, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId") + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `if one of the required segment values is not present, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + """.trimIndent() + ) + // omitting replay type, which is required, for the test + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `returns last segment data when all values are present`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(912, lastSegment.recorderConfig.recordingHeight) + assertEquals(416, lastSegment.recorderConfig.recordingWidth) + assertEquals(1, lastSegment.recorderConfig.frameRate) + assertEquals(75000, lastSegment.recorderConfig.bitRate) + assertEquals(0, lastSegment.id) + assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) + assertEquals(ReplayType.SESSION, lastSegment.replayType) + assertEquals(3543, lastSegment.duration) // duration + 1 frame duration + assertTrue { + val firstEvent = lastSegment.events.first() as RRWebInteractionEvent + firstEvent.timestamp == 1720693523997 && + firstEvent.interactionType == TouchStart && + firstEvent.x.toDouble() == 314.2979431152344 && + firstEvent.y.toDouble() == 625.44140625 + } + assertTrue { + val lastEvent = lastSegment.events.last() as RRWebInteractionEvent + lastEvent.timestamp == 1720693524774 && + lastEvent.interactionType == TouchEnd && + lastEvent.x.toDouble() == 322.00390625 && + lastEvent.y.toDouble() == 424.4384765625 + } + } + + @Test + fun `fills in cache with frames from disk`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(1, lastSegment.cache.frames.size) + assertEquals(1, lastSegment.cache.frames.first().timestamp) + assertEquals("1.jpg", lastSegment.cache.frames.first().screenshot.name) + } + + @Test + fun `when videoFile exists and is not empty, deletes it before writing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 3 + ) + + val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { + it.createNewFile() + it.writeBytes(byteArrayOf(1, 2, 3)) + } + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, oldVideoFile) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `sets segmentId to 0 for buffer mode`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=2 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(0, lastSegment.id) + } } 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 cb236b6318..a98e344277 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 @@ -1,25 +1,49 @@ package io.sentry.android.replay import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions -import io.sentry.android.replay.ReplayCacheTest.Fixture +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +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_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +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.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -27,7 +51,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import java.util.concurrent.atomic.AtomicReference +import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -37,14 +61,30 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [26]) class ReplayIntegrationTest { - // write tests for ReplayIntegration with mocked context and other android things @get:Rule val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + executorService = mock { + doAnswer { + (it.arguments[0] as Runnable).run() + }.whenever(mock).submit(any()) + } + } val hub = mock() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + fun getSut( context: Context, sessionSampleRate: Double = 1.0, @@ -63,7 +103,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = null, + replayCacheProvider = { _, _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider ) } @@ -120,7 +160,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start() + verify(captureStrategy, never()).start(any(), any(), any()) } @Test @@ -143,7 +183,11 @@ class ReplayIntegrationTest { replay.start() replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -154,7 +198,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, never()).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, never()).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -165,7 +213,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -205,7 +257,7 @@ class ReplayIntegrationTest { } @Test - fun `sendReplayForEvent does nothing when not recording`() { + fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -214,29 +266,15 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent does nothing for non errored events`() { - val captureStrategy = mock() - val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) - - replay.register(fixture.hub, fixture.options) - replay.start() - - val event = SentryEvent() - replay.sendReplayForEvent(event, Hint()) - - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) - } - - @Test - fun `sendReplayForEvent does nothing when currentReplayId is not set`() { + fun `captureReplay does nothing when currentReplayId is not set`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId.EMPTY_ID)) + whenever(mock.currentReplayId).thenReturn(SentryId.EMPTY_ID) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -246,15 +284,15 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent calls and converts strategy`() { + fun `captureReplay calls and converts strategy`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId())) + whenever(mock.currentReplayId).thenReturn(SentryId()) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -267,9 +305,9 @@ class ReplayIntegrationTest { } event.eventId = id val hint = Hint() - replay.sendReplayForEvent(event, hint) + replay.captureReplay(event.isCrashed) - verify(captureStrategy).sendReplayForEvent(eq(false), eq(id.toString()), eq(hint), any()) + verify(captureStrategy).captureReplay(eq(false), any()) verify(captureStrategy).convert() } @@ -378,4 +416,117 @@ class ReplayIntegrationTest { verify(recorder, times(2)).start(eq(recorderConfig)) assertTrue(configChanged) } + + @Test + fun `register finalizes previous replay`() { + val oldReplayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val oldReplay = + File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } + val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { + it.createNewFile() + it.writeText("\"$oldReplayId\"") + } + val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + fixture.options.serializer.serialize( + listOf( + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + } + ), + breadcrumbsFile.writer() + ) + File(oldReplay, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=1 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + + assertTrue(oldReplay.exists()) // should not be deleted until the video is packed into envelope + verify(fixture.hub).captureReplay( + check { + assertEquals(oldReplayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(1, videoEvents?.first()?.segmentId) + + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + + val interactionEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals( + InteractionType.TouchStart, + interactionEvents?.first()?.interactionType + ) + assertEquals(314.29794f, interactionEvents?.first()?.x) + assertEquals(625.4414f, interactionEvents?.first()?.y) + + assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) + assertEquals(322.0039f, interactionEvents?.last()?.x) + assertEquals(424.43848f, interactionEvents?.last()?.y) + } + ) + } + + @Test + fun `register cleans up old replays`() { + val replayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val evenOlderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + + val captureStrategy = mock { + on { currentReplayId }.thenReturn(replayId) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.hub, fixture.options) + + assertTrue(scopeCache.exists()) + assertFalse(evenOlderReplay.exists()) + } } 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 f7e4da2304..8e3bef2c2f 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 @@ -22,6 +22,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.thread.NoOpMainThreadChecker import org.awaitility.kotlin.await import org.junit.Rule import org.junit.Test @@ -49,7 +50,9 @@ class ReplayIntegrationWithRecorderTest { val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + mainThreadChecker = NoOpMainThreadChecker.getInstance() + } val hub = mock() var encoder: SimpleVideoEncoder? = null 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 22f35b157b..53ef7c009e 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 @@ -13,17 +13,13 @@ import android.widget.LinearLayout.LayoutParams import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.Hint import io.sentry.IHub import io.sentry.Scope import io.sentry.ScopeCallback -import io.sentry.SentryEvent import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder -import io.sentry.protocol.Mechanism -import io.sentry.protocol.SentryException import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -221,14 +217,7 @@ class ReplaySmokeTest { } catch (e: ConditionTimeoutException) { } - val crash = SentryEvent().apply { - exceptions = listOf( - SentryException().apply { - mechanism = Mechanism().apply { isHandled = false } - } - ) - } - replay.sendReplayForEvent(crash, Hint()) + replay.captureReplay(isTerminating = false) await.timeout(Duration.ofSeconds(5)).untilTrue(captured) 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 new file mode 100644 index 0000000000..5e5130aae8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -0,0 +1,270 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +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_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BufferCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + errorSampleRate: Double = 1.0, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): BufferCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + options.run { + experimental.sessionReplay.errorSampleRate = errorSampleRate + } + return BufferCaptureStrategy( + options, + hub, + dateProvider, + SecureRandom(), + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + + fun mockedMotionEvent(action: Int): MotionEvent = mock { + on { actionMasked }.thenReturn(action) + on { getPointerId(anyInt()) }.thenReturn(0) + on { findPointerIndex(anyInt()) }.thenReturn(0) + on { getX(anyInt()) }.thenReturn(1f) + on { getY(anyInt()) }.thenReturn(1f) + } + } + + private val fixture = Fixture() + + @Test + fun `start does not set replayId on scope for buffered session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.BUFFER.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates but does not capture current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop clears replay cache dir`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.hub, never()).captureReplay(any(), any()) + + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `onScreenshotRecorded adds screenshot to cache`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + } + + @Test + fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `convert does nothing when process is terminating`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + + @Test + fun `convert converts to session strategy and sets replayId to scope`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertTrue(converted is SessionCaptureStrategy) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay does not replayId to scope when not sampled`() { + val strategy = fixture.getSut(errorSampleRate = 0.0) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + } + + @Test + fun `captureReplay sets replayId to scope and captures buffered segments`() { + var called = false + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) { + called = true + } + + // buffered + current = 2 + verify(fixture.hub, times(2)).captureReplay(any(), any()) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + assertTrue(called) + } +} 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 new file mode 100644 index 0000000000..ac593f6c27 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -0,0 +1,355 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +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_TYPE +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.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): SessionCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + return SessionCaptureStrategy( + options, + hub, + dateProvider, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + } + + private val fixture = Fixture() + + @Test + fun `start sets replayId on scope for full session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(replayId, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.SESSION.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertEquals( + fixture.recorderConfig.recordingWidth.toString(), + fixture.persistedSegment[SEGMENT_KEY_WIDTH] + ) + assertEquals( + fixture.recorderConfig.recordingHeight.toString(), + fixture.persistedSegment[SEGMENT_KEY_HEIGHT] + ) + assertEquals( + fixture.recorderConfig.frameRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_FRAME_RATE] + ) + assertEquals( + fixture.recorderConfig.bitRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_BIT_RATE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop creates and captures current segment and clears replayId from scope`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `captureReplay does nothing for non-crashed event`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + verify(fixture.hub, never()).captureReplay(any(), any()) + } + + @Test + fun `when process is crashing, onScreenshotRecorded does not create new segment`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub, never()).captureReplay(any(), any()) + } + + @Test + fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `onScreenshotRecorded stops replay when replay duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + var count = 0 + val strategy = fixture.getSut( + dateProvider = { + // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and frameTimestamp) + if (count++ == 2) { + now + } else { + System.currentTimeMillis() + } + } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.options.replayController).stop() + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + // should still capture with the old values + assertEquals(1920, metaEvents?.first()?.height) + assertEquals(1080, metaEvents?.first()?.width) + } + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + assertEquals("1080", fixture.persistedSegment[SEGMENT_KEY_HEIGHT]) + assertEquals("1920", fixture.persistedSegment[SEGMENT_KEY_WIDTH]) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `fills replay urls from navigation breadcrumbs`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertEquals("to", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + + @Test + fun `sets screen from scope as replay url`() { + fixture.scope.screen = "MainActivity" + + 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( + check { + assertEquals("MainActivity", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertTrue(breadcrumbEvents?.isEmpty() == true) + } + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index af9ffe9fc4..598b2c7b6b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -658,6 +658,7 @@ public abstract interface class io/sentry/IOptionsObserver { public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setProguardUuid (Ljava/lang/String;)V public abstract fun setRelease (Ljava/lang/String;)V + public abstract fun setReplayErrorSampleRate (Ljava/lang/Double;)V public abstract fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public abstract fun setTags (Ljava/util/Map;)V } @@ -743,10 +744,11 @@ public abstract interface class io/sentry/IScopeObserver { public abstract fun setExtras (Ljava/util/Map;)V public abstract fun setFingerprint (Ljava/util/Collection;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setTags (Ljava/util/Map;)V - public abstract fun setTrace (Lio/sentry/SpanContext;)V + public abstract fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public abstract fun setTransaction (Ljava/lang/String;)V public abstract fun setUser (Lio/sentry/protocol/User;)V } @@ -1259,14 +1261,13 @@ public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBre } public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public fun captureReplay (Ljava/lang/Boolean;)V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isRecording ()Z public fun pause ()V public fun resume ()V - public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun stop ()V @@ -1666,6 +1667,7 @@ public final class io/sentry/PropagationContext { public fun setSampled (Ljava/lang/Boolean;)V public fun setSpanId (Lio/sentry/SpanId;)V public fun setTraceId (Lio/sentry/protocol/SentryId;)V + public fun toSpanContext ()Lio/sentry/SpanContext; public fun traceContext ()Lio/sentry/TraceContext; } @@ -1674,13 +1676,12 @@ public abstract interface class io/sentry/ReplayBreadcrumbConverter { } public abstract interface class io/sentry/ReplayController { + public abstract fun captureReplay (Ljava/lang/Boolean;)V public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V - public abstract fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public abstract fun start ()V public abstract fun stop ()V @@ -1804,10 +1805,11 @@ public abstract class io/sentry/ScopeObserverAdapter : io/sentry/IScopeObserver public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V } @@ -2117,7 +2119,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; - public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;)Lio/sentry/SentryEnvelopeItem; + public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;Z)Lio/sentry/SentryEnvelopeItem; public static fun fromSession (Lio/sentry/ISerializer;Lio/sentry/Session;)Lio/sentry/SentryEnvelopeItem; public static fun fromUserFeedback (Lio/sentry/ISerializer;Lio/sentry/UserFeedback;)Lio/sentry/SentryEnvelopeItem; public fun getClientReport (Lio/sentry/ISerializer;)Lio/sentry/clientreport/ClientReport; @@ -3329,6 +3331,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public static final field OPTIONS_CACHE Ljava/lang/String; public static final field PROGUARD_UUID_FILENAME Ljava/lang/String; public static final field RELEASE_FILENAME Ljava/lang/String; + public static final field REPLAY_ERROR_SAMPLE_RATE_FILENAME Ljava/lang/String; public static final field SDK_VERSION_FILENAME Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V @@ -3338,6 +3341,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public fun setEnvironment (Ljava/lang/String;)V public fun setProguardUuid (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayErrorSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setTags (Ljava/util/Map;)V } @@ -3348,6 +3352,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field EXTRAS_FILENAME Ljava/lang/String; public static final field FINGERPRINT_FILENAME Ljava/lang/String; public static final field LEVEL_FILENAME Ljava/lang/String; + public static final field REPLAY_FILENAME Ljava/lang/String; public static final field REQUEST_FILENAME Ljava/lang/String; public static final field SCOPE_CACHE Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; @@ -3362,11 +3367,13 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V + public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -3974,6 +3981,7 @@ public final class io/sentry/protocol/Browser$JsonKeys { } public final class io/sentry/protocol/Contexts : java/util/concurrent/ConcurrentHashMap, io/sentry/JsonSerializable { + public static final field REPLAY_ID Ljava/lang/String; public fun ()V public fun (Lio/sentry/protocol/Contexts;)V public fun getApp ()Lio/sentry/protocol/App; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index de7cf95a20..d736358ee1 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.protocol.Contexts.REPLAY_ID; + import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; @@ -141,7 +143,12 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); - // TODO: add replay_id later + final @Nullable Object replayId = event.getContexts().get(REPLAY_ID); + if (replayId != null && !replayId.toString().equals(SentryId.EMPTY_ID.toString())) { + baggage.setReplayId(replayId.toString()); + // relay will set it from the DSC, we don't need to send it + event.getContexts().remove(REPLAY_ID); + } baggage.freeze(); return baggage; } diff --git a/sentry/src/main/java/io/sentry/IOptionsObserver.java b/sentry/src/main/java/io/sentry/IOptionsObserver.java index 54cacc666a..5a2ddcc9b5 100644 --- a/sentry/src/main/java/io/sentry/IOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/IOptionsObserver.java @@ -22,4 +22,6 @@ public interface IOptionsObserver { void setDist(@Nullable String dist); void setTags(@NotNull Map tags); + + void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate); } diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index 4a103668d2..a43ccf6b69 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -41,5 +42,7 @@ public interface IScopeObserver { void setTransaction(@Nullable String transaction); - void setTrace(@Nullable SpanContext spanContext); + void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope); + + void setReplayId(@NotNull SentryId replayId); } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index d365f650ea..e868038db2 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -32,11 +32,7 @@ public boolean isRecording() { } @Override - public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} - - @Override - public void sendReplay( - @Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint) {} + public void captureReplay(@Nullable Boolean isTerminating) {} @Override public @NotNull SentryId getReplayId() { diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index 9a29e8c161..b0debc2a9d 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -139,4 +139,10 @@ public void setSampled(final @Nullable Boolean sampled) { return null; } + + public @NotNull SpanContext toSpanContext() { + final SpanContext spanContext = new SpanContext(traceId, spanId, "default", null, null); + spanContext.setOrigin("auto"); + return spanContext; + } } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index caaa847423..01c0f9da12 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -17,9 +17,7 @@ public interface ReplayController { boolean isRecording(); - void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); - - void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + void captureReplay(@Nullable Boolean isTerminating); @NotNull SentryId getReplayId(); diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index ca1c676dbd..a83eddd380 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -81,7 +81,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger // {"segment_id":0}\n{json-serialized-rrweb-protocol} writer.setLenient(true); - writer.jsonValue("\n"); + if (segmentId != null) { + writer.jsonValue("\n"); + } if (payload != null) { writer.value(logger, payload); } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index be24c34dfb..f071cb8c5e 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -243,10 +243,10 @@ public void setTransaction(final @Nullable ITransaction transaction) { for (final IScopeObserver observer : options.getScopeObservers()) { if (transaction != null) { observer.setTransaction(transaction.getName()); - observer.setTrace(transaction.getSpanContext()); + observer.setTrace(transaction.getSpanContext(), this); } else { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } } @@ -326,7 +326,9 @@ public void setScreen(final @Nullable String screen) { public void setReplayId(final @NotNull SentryId replayId) { this.replayId = replayId; - // TODO: set to contexts and notify observers to persist this as well + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setReplayId(replayId); + } } /** @@ -486,7 +488,7 @@ public void clearTransaction() { for (final IScopeObserver observer : options.getScopeObservers()) { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } @@ -940,6 +942,11 @@ public void clearSession() { @Override public void setPropagationContext(final @NotNull PropagationContext propagationContext) { this.propagationContext = propagationContext; + + final @NotNull SpanContext spanContext = propagationContext.toSpanContext(); + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTrace(spanContext, this); + } } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java index 38d0cdf7a1..f0ec6448e0 100644 --- a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -52,5 +53,8 @@ public void setContexts(@NotNull Contexts contexts) {} public void setTransaction(@Nullable String transaction) {} @Override - public void setTrace(@Nullable SpanContext spanContext) {} + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) {} + + @Override + public void setReplayId(@NotNull SentryId replayId) {} } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f157256ce5..13cf9ab389 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -356,6 +356,8 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setDist(options.getDist()); observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); + observer.setReplayErrorSampleRate( + options.getExperimental().getSessionReplay().getErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 8d27793e1a..6868894340 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -199,13 +199,16 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } - if (event != null) { - options.getReplayController().sendReplayForEvent(event, hint); + final boolean isBackfillable = HintUtils.hasType(hint, Backfillable.class); + // if event is backfillable we don't wanna trigger capture replay, because it's an event from + // the past + if (event != null && !isBackfillable && (event.isErrored() || event.isCrashed())) { + options.getReplayController().captureReplay(event.isCrashed()); } try { @Nullable TraceContext traceContext = null; - if (HintUtils.hasType(hint, Backfillable.class)) { + if (isBackfillable) { // for backfillable hint we synthesize Baggage from event values if (event != null) { final Baggage baggage = Baggage.fromEvent(event, options); @@ -239,12 +242,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } // if we encountered a crash/abnormal exit finish tracing in order to persist and send - // any running transaction / profiling data. We also finish session replay, and it has priority - // over transactions as it takes longer to finalize replay than transactions, therefore - // the replay_id will be the trigger for flushing and unblocking the thread in case of a crash + // any running transaction / profiling data. if (scope != null) { finalizeTransaction(scope, hint); - finalizeReplay(scope, hint); } return sentryId; @@ -265,18 +265,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } } - private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { - final @Nullable SentryId replayId = scope.getReplayId(); - if (!SentryId.EMPTY_ID.equals(replayId)) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); - } - } - } - } - @Override public @NotNull SentryId captureReplayEvent( @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { @@ -305,6 +293,7 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } try { + // TODO: check if event is Backfillable and backfill traceContext from the event values @Nullable TraceContext traceContext = null; if (scope != null) { final @Nullable ITransaction transaction = scope.getTransaction(); @@ -317,7 +306,9 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } } - final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); + final boolean cleanupReplayFolder = HintUtils.hasType(hint, Backfillable.class); + final SentryEnvelope envelope = + buildEnvelope(event, hint.getReplayRecording(), traceContext, cleanupReplayFolder); hint.clear(); transport.send(envelope, hint); @@ -627,12 +618,17 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { private @NotNull SentryEnvelope buildEnvelope( final @NotNull SentryReplayEvent event, final @Nullable ReplayRecording replayRecording, - final @Nullable TraceContext traceContext) { + final @Nullable TraceContext traceContext, + final boolean cleanupReplayFolder) { final List envelopeItems = new ArrayList<>(); final SentryEnvelopeItem replayItem = SentryEnvelopeItem.fromReplay( - options.getSerializer(), options.getLogger(), event, replayRecording); + options.getSerializer(), + options.getLogger(), + event, + replayRecording, + cleanupReplayFolder); envelopeItems.add(replayItem); final SentryId sentryId = event.getEventId(); diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 856976b589..7862c8d664 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -8,6 +8,7 @@ import io.sentry.exception.SentryEnvelopeException; import io.sentry.metrics.EncodedMetrics; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; import io.sentry.vendor.Base64; @@ -372,7 +373,8 @@ public static SentryEnvelopeItem fromReplay( final @NotNull ISerializer serializer, final @NotNull ILogger logger, final @NotNull SentryReplayEvent replayEvent, - final @Nullable ReplayRecording replayRecording) { + final @Nullable ReplayRecording replayRecording, + final boolean cleanupReplayFolder) { final File replayVideo = replayEvent.getVideoFile(); @@ -415,7 +417,11 @@ public static SentryEnvelopeItem fromReplay( return null; } finally { if (replayVideo != null) { - replayVideo.delete(); + if (cleanupReplayFolder) { + FileUtils.deleteRecursively(replayVideo.getParentFile()); + } else { + replayVideo.delete(); + } } } }); diff --git a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java index bb1bb71572..49ec2da904 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java @@ -16,6 +16,7 @@ public final class PersistingOptionsObserver implements IOptionsObserver { public static final String ENVIRONMENT_FILENAME = "environment.json"; public static final String DIST_FILENAME = "dist.json"; public static final String TAGS_FILENAME = "tags.json"; + public static final String REPLAY_ERROR_SAMPLE_RATE_FILENAME = "replay-error-sample-rate.json"; private final @NotNull SentryOptions options; @@ -73,6 +74,15 @@ public void setTags(@NotNull Map tags) { store(tags, TAGS_FILENAME); } + @Override + public void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate) { + if (replayErrorSampleRate == null) { + delete(REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } else { + store(replayErrorSampleRate.toString(), REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } + } + private void store(final @NotNull T entity, final @NotNull String fileName) { CacheUtils.store(options, entity, OPTIONS_CACHE, fileName); } diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 0c4a110733..7c186cf99d 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -3,6 +3,7 @@ import static io.sentry.SentryLevel.ERROR; import io.sentry.Breadcrumb; +import io.sentry.IScope; import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; @@ -10,6 +11,7 @@ import io.sentry.SpanContext; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -29,6 +31,7 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String FINGERPRINT_FILENAME = "fingerprint.json"; public static final String TRANSACTION_FILENAME = "transaction.json"; public static final String TRACE_FILENAME = "trace.json"; + public static final String REPLAY_FILENAME = "replay.json"; private final @NotNull SentryOptions options; @@ -105,11 +108,13 @@ public void setTransaction(@Nullable String transaction) { } @Override - public void setTrace(@Nullable SpanContext spanContext) { + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) { serializeToDisk( () -> { if (spanContext == null) { - delete(TRACE_FILENAME); + // we always need a trace_id to properly link with traces/replays, so we fallback to + // propagation context values and create a fake SpanContext + store(scope.getPropagationContext().toSpanContext(), TRACE_FILENAME); } else { store(spanContext, TRACE_FILENAME); } @@ -121,6 +126,11 @@ public void setContexts(@NotNull Contexts contexts) { serializeToDisk(() -> store(contexts, CONTEXTS_FILENAME)); } + @Override + public void setReplayId(@NotNull SentryId replayId) { + serializeToDisk(() -> store(replayId, REPLAY_FILENAME)); + } + @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { try { @@ -140,13 +150,20 @@ private void serializeToDisk(final @NotNull Runnable task) { } private void store(final @NotNull T entity, final @NotNull String fileName) { - CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + store(options, entity, fileName); } private void delete(final @NotNull String fileName) { CacheUtils.delete(options, SCOPE_CACHE, fileName); } + public static void store( + final @NotNull SentryOptions options, + final @NotNull T entity, + final @NotNull String fileName) { + CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + } + public static @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 28d2e8d2a4..ba4cbe51cb 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -19,6 +19,7 @@ public final class Contexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; + public static final String REPLAY_ID = "replay_id"; /** Response lock, Ops should be atomic */ private final @NotNull Object responseLock = new Object(); diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 906c897c62..07b9176de7 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -2,6 +2,7 @@ package io.sentry import io.sentry.SentryLevel.WARNING import io.sentry.protocol.Request +import io.sentry.protocol.SentryId import io.sentry.protocol.User import io.sentry.test.callMethod import org.junit.Assert.assertArrayEquals @@ -738,7 +739,7 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) } @Test @@ -751,7 +752,7 @@ class ScopeTest { scope.transaction = null verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -767,11 +768,11 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) scope.clearTransaction() verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -819,6 +820,21 @@ class ScopeTest { ) } + @Test + fun `Scope set propagation context sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + scope.propagationContext = PropagationContext(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), SpanId(), null, null, null) + verify(observer).setTrace( + argThat { traceId.toString() == "64cf554cc8d74c6eafa3e08b7c984f6d" }, + eq(scope) + ) + } + @Test fun `Scope getTransaction returns the transaction if there is no active span`() { val scope = Scope(SentryOptions()) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 9b883d5ef2..f87a148bf1 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -28,6 +28,8 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import io.sentry.util.HintUtils import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -67,6 +69,9 @@ import kotlin.test.assertTrue class SentryClientTest { + @get:Rule + val tmpDir = TemporaryFolder() + class Fixture { var transport = mock() var factory = mock() @@ -852,6 +857,7 @@ class SentryClientTest { val event = SentryEvent().apply { environment = "release" release = "io.sentry.samples@22.1.1" + contexts[Contexts.REPLAY_ID] = "64cf554cc8d74c6eafa3e08b7c984f6d" contexts.trace = SpanContext(traceId, SpanId(), "ui.load", null, null) transaction = "MainActivity" } @@ -866,6 +872,7 @@ class SentryClientTest { assertEquals("io.sentry.samples@22.1.1", it.header.traceContext!!.release) assertEquals(traceId, it.header.traceContext!!.traceId) assertEquals("MainActivity", it.header.traceContext!!.transaction) + assertEquals(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), it.header.traceContext!!.replayId) }, anyOrNull() ) @@ -2360,41 +2367,6 @@ class SentryClientTest { @Test fun `when event has DiskFlushNotification, TransactionEnds set transaction id as flushable`() { val sut = fixture.getSut() - val replayId = SentryId() - val scope = mock { - whenever(it.replayId).thenReturn(replayId) - whenever(it.breadcrumbs).thenReturn(LinkedList()) - whenever(it.extras).thenReturn(emptyMap()) - whenever(it.contexts).thenReturn(Contexts()) - } - val scopePropagationContext = PropagationContext() - whenever(scope.propagationContext).thenReturn(scopePropagationContext) - doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) - - var capturedEventId: SentryId? = null - val transactionEnd = object : TransactionEnd, DiskFlushNotification { - override fun markFlushed() {} - override fun isFlushable(eventId: SentryId?): Boolean = true - override fun setFlushable(eventId: SentryId) { - capturedEventId = eventId - } - } - val transactionEndHint = HintUtils.createWithTypeCheckHint(transactionEnd) - - sut.captureEvent(SentryEvent(), scope, transactionEndHint) - - assertEquals(replayId, capturedEventId) - verify(fixture.transport).send( - check { - assertEquals(1, it.items.count()) - }, - any() - ) - } - - @Test - fun `when event has DiskFlushNotification, TransactionEnds set replay id as flushable`() { - val sut = fixture.getSut() // build up a running transaction val spanContext = SpanContext("op.load") @@ -2750,20 +2722,84 @@ class SentryClientTest { } @Test - fun `calls sendReplayForEvent on replay controller for error events`() { + fun `calls captureReplay on replay controller for error events`() { var called = false fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { - override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - assertEquals("Test", event.message?.formatted) + override fun captureReplay(isTerminating: Boolean?) { called = true } }) val sut = fixture.getSut() - sut.captureMessage("Test", WARNING) + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) assertTrue(called) } + @Test + fun `calls captureReplay on replay controller for crash events and sets isTerminating`() { + var terminated: Boolean? = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + terminated = isTerminating + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + } + ) + assertTrue(terminated == true) + } + + @Test + fun `cleans up replay folder for Backfillable replay events`() { + val dir = File(tmpDir.newFolder().absolutePath) + val sut = fixture.getSut() + val replayEvent = createReplayEvent().apply { + videoFile = File(dir, "hello.txt").apply { writeText("hello") } + } + + sut.captureReplayEvent(replayEvent, createScope(), HintUtils.createWithTypeCheckHint(BackfillableHint())) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + item.data + assertFalse(dir.exists()) + }, + any() + ) + } + + @Test + fun `does not captureReplay for backfillable events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + }, + HintUtils.createWithTypeCheckHint(BackfillableHint()) + ) + assertFalse(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index efc5e5cadf..760d1270e5 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -8,6 +8,8 @@ import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField import io.sentry.vendor.Base64 import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -31,6 +33,9 @@ import kotlin.test.assertNull class SentryEnvelopeItemTest { + @get:Rule + val tmpDir = TemporaryFolder() + private class Fixture { val options = SentryOptions() val serializer = JsonSerializer(options) @@ -469,7 +474,7 @@ class SentryEnvelopeItemTest { } val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording, false) assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) @@ -486,7 +491,7 @@ class SentryEnvelopeItemTest { } val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) replayItem.data assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> assertEquals(1, mapSize) @@ -503,10 +508,28 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + + @Test + fun `fromReplay cleans up video folder if cleanupReplayFolder is set`() { + val dir = File(tmpDir.newFolder().absolutePath) + val file = File(dir, fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, true) assert(file.exists()) replayItem.data assertFalse(file.exists()) + assertFalse(dir.exists()) } private fun createSession(): Session { diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 0f4966b44a..5ef764bc5d 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -737,6 +737,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") + it.experimental.sessionReplay.errorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) @@ -745,6 +746,7 @@ class SentryTest { assertEquals("uuid", optionsObserver.proguardUuid) assertEquals(mapOf("one" to "two"), optionsObserver.tags) assertEquals(SdkVersion("sentry.java.android", "6.13.0"), optionsObserver.sdkVersion) + assertEquals(0.5, optionsObserver.replayErrorSampleRate) } @Test @@ -1164,6 +1166,8 @@ class SentryTest { private set var tags: Map = mapOf() private set + var replayErrorSampleRate: Double? = null + private set override fun setRelease(release: String?) { this.release = release @@ -1188,6 +1192,10 @@ class SentryTest { override fun setTags(tags: MutableMap) { this.tags = tags } + + override fun setReplayErrorSampleRate(replayErrorSampleRate: Double?) { + this.replayErrorSampleRate = replayErrorSampleRate + } } private class CustomMainThreadChecker : IMainThreadChecker { diff --git a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt index ded3908f14..3c325bd640 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt @@ -5,6 +5,7 @@ import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME import io.sentry.protocol.SdkVersion @@ -28,13 +29,18 @@ class DeleteOptionsValue(private val delete: PersistingOptionsObserver.() -> Uni } } +class ReadOptionsValue(private val read: (options: SentryOptions) -> T) { + operator fun invoke(options: SentryOptions) = read(options) +} + @RunWith(Parameterized::class) class PersistingOptionsObserverTest( private val entity: T, private val store: StoreOptionsValue, private val filename: String, private val delete: DeleteOptionsValue, - private val deletedEntity: T? + private val deletedEntity: T?, + private val read: ReadOptionsValue? ) { @get:Rule @@ -60,7 +66,7 @@ class PersistingOptionsObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut) - val persisted = read() + val persisted = read?.invoke(fixture.options) ?: read() assertEquals(entity, persisted) delete(sut) @@ -81,6 +87,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setRelease(it) }, RELEASE_FILENAME, DeleteOptionsValue { setRelease(null) }, + null, null ) @@ -89,6 +96,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setProguardUuid(it) }, PROGUARD_UUID_FILENAME, DeleteOptionsValue { setProguardUuid(null) }, + null, null ) @@ -97,6 +105,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setSdkVersion(it) }, SDK_VERSION_FILENAME, DeleteOptionsValue { setSdkVersion(null) }, + null, null ) @@ -105,6 +114,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setDist(it) }, DIST_FILENAME, DeleteOptionsValue { setDist(null) }, + null, null ) @@ -113,6 +123,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setEnvironment(it) }, ENVIRONMENT_FILENAME, DeleteOptionsValue { setEnvironment(null) }, + null, null ) @@ -124,7 +135,23 @@ class PersistingOptionsObserverTest( StoreOptionsValue> { setTags(it) }, TAGS_FILENAME, DeleteOptionsValue { setTags(emptyMap()) }, - emptyMap() + emptyMap(), + null + ) + + private fun replaysErrorSampleRate(): Array = arrayOf( + 0.5, + StoreOptionsValue { setReplayErrorSampleRate(it) }, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + DeleteOptionsValue { setReplayErrorSampleRate(null) }, + null, + ReadOptionsValue { + PersistingOptionsObserver.read( + it, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String::class.java + )!!.toDouble() + } ) @JvmStatic @@ -136,7 +163,8 @@ class PersistingOptionsObserverTest( dist(), environment(), sdkVersion(), - tags() + tags(), + replaysErrorSampleRate() ) } } diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index d31b7088cf..e1927438e5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -3,6 +3,7 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.JsonDeserializer +import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SpanContext @@ -12,6 +13,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME @@ -35,15 +37,21 @@ import org.junit.runners.Parameterized import kotlin.test.Test import kotlin.test.assertEquals -class StoreScopeValue(private val store: PersistingScopeObserver.(T) -> Unit) { - operator fun invoke(value: T, observer: PersistingScopeObserver) { - observer.store(value) +class StoreScopeValue(private val store: PersistingScopeObserver.(T, Scope) -> Unit) { + operator fun invoke(value: T, observer: PersistingScopeObserver, scope: Scope) { + observer.store(value, scope) } } -class DeleteScopeValue(private val delete: PersistingScopeObserver.() -> Unit) { - operator fun invoke(observer: PersistingScopeObserver) { - observer.delete() +class DeleteScopeValue(private val delete: PersistingScopeObserver.(Scope) -> Unit) { + operator fun invoke(observer: PersistingScopeObserver, scope: Scope) { + observer.delete(scope) + } +} + +class DeletedEntityProvider(private val provider: (Scope) -> T?) { + operator fun invoke(scope: Scope): T? { + return provider(scope) } } @@ -53,7 +61,7 @@ class PersistingScopeObserverTest( private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: T?, + private val deletedEntity: DeletedEntityProvider, private val elementDeserializer: JsonDeserializer? ) { @@ -63,6 +71,7 @@ class PersistingScopeObserverTest( class Fixture { val options = SentryOptions() + val scope = Scope(options) fun getSut(cacheDir: TemporaryFolder): PersistingScopeObserver { options.run { @@ -78,14 +87,14 @@ class PersistingScopeObserverTest( @Test fun `store and delete scope value`() { val sut = fixture.getSut(tmpDir) - store(entity, sut) + store(entity, sut, fixture.scope) val persisted = read() assertEquals(entity, persisted) - delete(sut) + delete(sut, fixture.scope) val persistedAfterDeletion = read() - assertEquals(deletedEntity, persistedAfterDeletion) + assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } private fun read(): T? = PersistingScopeObserver.read( @@ -103,10 +112,10 @@ class PersistingScopeObserverTest( id = "c4d61c1b-c144-431e-868f-37a46be5e5f2" ipAddress = "192.168.0.1" }, - StoreScopeValue { setUser(it) }, + StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - null, + DeletedEntityProvider { null }, null ) @@ -115,10 +124,10 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { setBreadcrumbs(it) }, + StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, Breadcrumb.Deserializer() ) @@ -127,10 +136,10 @@ class PersistingScopeObserverTest( "one" to "two", "tag" to "none" ), - StoreScopeValue> { setTags(it) }, + StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -140,10 +149,10 @@ class PersistingScopeObserverTest( "two" to 2, "three" to 3.2 ), - StoreScopeValue> { setExtras(it) }, + StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -156,46 +165,46 @@ class PersistingScopeObserverTest( fragment = "fragment" bodySize = 1000 }, - StoreScopeValue { setRequest(it) }, + StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - null, + DeletedEntityProvider { null }, null ) private fun fingerprint(): Array = arrayOf( listOf("finger", "print"), - StoreScopeValue> { setFingerprint(it) }, + StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, null ) private fun level(): Array = arrayOf( SentryLevel.WARNING, - StoreScopeValue { setLevel(it) }, + StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - null, + DeletedEntityProvider { null }, null ) private fun transaction(): Array = arrayOf( "MainActivity", - StoreScopeValue { setTransaction(it) }, + StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - null, + DeletedEntityProvider { null }, null ) private fun trace(): Array = arrayOf( SpanContext(SentryId(), SpanId(), "ui.load", null, null), - StoreScopeValue { setTrace(it) }, + StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, - DeleteScopeValue { setTrace(null) }, - null, + DeleteScopeValue { scope -> setTrace(null, scope) }, + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, null ) @@ -257,10 +266,19 @@ class PersistingScopeObserverTest( } ) }, - StoreScopeValue { setContexts(it) }, + StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - Contexts(), + DeletedEntityProvider { Contexts() }, + null + ) + + private fun replayId(): Array = arrayOf( + "64cf554cc8d74c6eafa3e08b7c984f6d", + StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, + REPLAY_FILENAME, + DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, + DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, null ) @@ -277,7 +295,8 @@ class PersistingScopeObserverTest( level(), transaction(), trace(), - contexts() + contexts(), + replayId() ) } }