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 29c38ef6c2..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,6 +4,7 @@ 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; @@ -53,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; @@ -80,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); @@ -156,14 +170,68 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje 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) { - final @Nullable String persistedReplayId = - PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @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); } 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 2c381c6285..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,13 +15,14 @@ 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 @@ -77,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 { @@ -89,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" @@ -120,7 +124,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) - persistScope(REPLAY_FILENAME, SentryId("64cf554cc8d74c6eafa3e08b7c984f6d")) + persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID)) } if (populateOptionsCache) { @@ -129,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) @@ -295,8 +302,6 @@ class AnrV2EventProcessorTest { // contexts assertEquals(1024, processed.contexts.response!!.bodySize) assertEquals("Google Chrome", processed.contexts.browser!!.name) - // replay_id - assertEquals("64cf554cc8d74c6eafa3e08b7c984f6d", processed.contexts[Contexts.REPLAY_ID].toString()) } @Test @@ -549,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 86800491b4..2a81b45b82 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -52,6 +52,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ 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; @@ -65,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 d7dda8bbd7..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 @@ -10,6 +10,7 @@ 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 @@ -21,7 +22,6 @@ import java.io.StringReader import java.util.Date import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.ceil /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -95,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 } @@ -152,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, @@ -381,17 +384,17 @@ public class ReplayCache internal constructor( } cache.frames.sortBy { it.timestamp } - - fun roundToNearestFrame(duration: Long, frameDuration: Int): Long { - val frames = duration.toDouble() / frameDuration.toDouble() - return ceil(frames).toLong() * frameDuration + // 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) } - // we need to round to the nearest frame to include breadcrumbs/events happened after the frame was captured - val duration = roundToNearestFrame( - duration = (cache.frames.last().timestamp - segmentTimestamp.time), - frameDuration = 1000 / frameRate - ) - 1 // we need to subtract 1ms to avoid capturing the next frame which doesn't exist + // 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) @@ -406,8 +409,8 @@ public class ReplayCache internal constructor( return LastSegmentData( recorderConfig = recorderConfig, cache = cache, - timestamp = segmentTimestamp, - id = segmentId, + timestamp = normalizedTimestamp, + id = normalizedSegmentId, duration = duration, replayType = replayType, screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], 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 df461a9369..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() @@ -140,7 +150,7 @@ public class ReplayIntegration( captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider = replayCacheProvider) } captureStrategy?.start(recorderConfig) @@ -156,30 +166,17 @@ 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)) { - options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + options.logger.log(DEBUG, "Replay id is not set, not capturing for event") return } - captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 }) captureStrategy = captureStrategy?.convert() @@ -259,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 8aa2b41c85..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 @@ -3,11 +3,8 @@ 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 @@ -23,30 +20,27 @@ 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.cache.PersistingScopeObserver -import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME -import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME 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.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.properties.ReadWriteProperty @@ -72,6 +66,7 @@ internal abstract class BaseCaptureStrategy( Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) } + protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> if (newValue == null) { @@ -99,7 +94,6 @@ internal abstract class BaseCaptureStrategy( persistingExecutor, cacheProvider = { cache } ) - private val currentEventsLock = Any() private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -108,27 +102,14 @@ internal abstract class BaseCaptureStrategy( executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - override fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - if (cleanupOldReplays) { - replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { - val unfinishedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: "" - // clean up old replays - options.cacheDirPath?.let { cacheDir -> - File(cacheDir).listFiles { dir, name -> - if (name.startsWith("replay_") && - !name.contains(replayId.toString()) && - !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) - ) { - FileUtils.deleteRecursively(File(dir, name)) - } - false - } - } - } - } - + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { 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 @@ -136,8 +117,6 @@ internal abstract class BaseCaptureStrategy( segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) - - finalizePreviousReplay() } override fun resume() { @@ -154,7 +133,7 @@ internal abstract class BaseCaptureStrategy( currentReplayId = SentryId.EMPTY_ID } - protected fun createSegment( + protected fun createSegmentInternal( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, @@ -167,133 +146,23 @@ internal abstract class BaseCaptureStrategy( screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: LinkedList = this.currentEvents - ): ReplaySegment { - val generatedVideo = cache?.createVideoOf( + ): ReplaySegment = + createSegment( + hub, + options, 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( - video, - replayId, currentSegmentTimestamp, + replayId, segmentId, height, width, - frameCount, - frameRate, - videoDuration, replayType, + cache, + frameRate, screenAtStart, - replayBreadcrumbs, + breadcrumbs, events ) - } - - private fun buildReplay( - video: File, - currentReplayId: SentryId, - segmentTimestamp: Date, - segmentId: Int, - height: Int, - width: Int, - frameCount: Int, - frameRate: Int, - duration: Long, - replayType: ReplayType, - screenAtStart: String?, - breadcrumbs: List, - events: LinkedList - ): 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() - this.frameRate = frameRate - this.height = height - this.width = width - // TODO: support non-fullscreen windows later - left = 0 - 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 - payload = recordingPayload.sortedBy { it.timestamp } - } - - replay.urls = urls - return ReplaySegment.Created( - videoDuration = duration, - replay = replay, - recording = recording - ) - } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { this.recorderConfig = recorderConfig @@ -312,56 +181,6 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } - protected 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() - } - } - } - - private fun finalizePreviousReplay() { - // TODO: run it on options.executorService and 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 - - replayExecutor.submitSafely(options, "$TAG.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: return@submitSafely - val previousReplayId = SentryId(previousReplayIdString) - if (previousReplayId == SentryId.EMPTY_ID) { - return@submitSafely - } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List - - val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: return@submitSafely - val segment = createSegment( - 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) { - segment.capture(hub) - } - FileUtils.deleteRecursively(lastSegment.cache.replayCacheDir) - } - } - private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -380,28 +199,6 @@ 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 fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List? { val event = this return when (event.actionMasked) { @@ -526,20 +323,24 @@ internal abstract class BaseCaptureStrategy( initialValue: T? = null, propertyName: String, crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> - if (options.mainThreadChecker.isMainThread) { - persistingExecutor.submit { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } - } else { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } + 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 { - onChange(propertyName, initialValue, initialValue) + runInBackground { onChange(propertyName, initialValue, initialValue) } } override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() @@ -547,7 +348,7 @@ internal abstract class BaseCaptureStrategy( override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { val oldValue = this.value.getAndSet(value) if (oldValue != value) { - onChange(propertyName, oldValue, value) + runInBackground { onChange(propertyName, oldValue, value) } } } } @@ -556,13 +357,7 @@ internal abstract class BaseCaptureStrategy( initialValue: T? = null, propertyName: String, crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> - if (options.mainThreadChecker.isMainThread) { - persistingExecutor.submit { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } - } else { - cache?.persistSegmentValues(propertyName, newValue.toString()) - } + cache?.persistSegmentValues(propertyName, newValue.toString()) } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, 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 264242ac13..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,33 +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, private val random: SecureRandom, + executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, 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( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId, - cleanupOldReplays: Boolean + replayId: SentryId ) { - super.start(recorderConfig, segmentId, replayId, cleanupOldReplays) + 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) @@ -62,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") { @@ -70,16 +88,14 @@ 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 } @@ -89,45 +105,23 @@ internal class BufferCaptureStrategy( 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 - val replayId = currentReplayId - 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() } } @@ -143,58 +137,12 @@ 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-- - 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 - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId - 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 @@ -205,9 +153,13 @@ internal class BufferCaptureStrategy( } 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, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, cleanupOldReplays = false) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId) return captureStrategy } @@ -228,7 +180,78 @@ internal class BufferCaptureStrategy( 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 949bc86777..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,18 +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.Date +import java.util.LinkedList internal interface CaptureStrategy { var currentSegment: Int var currentReplayId: SentryId val replayCacheDir: File? - fun start(recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int = 0, + replayId: SentryId = SentryId() + ) fun stop() @@ -21,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) @@ -34,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 2d9e308551..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 @@ -31,10 +31,9 @@ internal class SessionCaptureStrategy( override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId, - cleanupOldReplays: Boolean + replayId: SentryId ) { - super.start(recorderConfig, segmentId, replayId, cleanupOldReplays) + 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 { @@ -66,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) { @@ -99,10 +90,15 @@ internal class SessionCaptureStrategy( 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, currentReplayId, @@ -153,7 +149,7 @@ internal class SessionCaptureStrategy( 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/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 2b2fc18adf..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 @@ -234,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( @@ -247,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) @@ -408,7 +419,7 @@ class ReplayCacheTest { assertEquals(0, lastSegment.id) assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) assertEquals(ReplayType.SESSION, lastSegment.replayType) - assertEquals(2999, lastSegment.duration) + assertEquals(3543, lastSegment.duration) // duration + 1 frame duration assertTrue { val firstEvent = lastSegment.events.first() as RRWebInteractionEvent firstEvent.timestamp == 1720693523997 && @@ -458,4 +469,60 @@ class ReplayCacheTest { 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 93239f81eb..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,6 +51,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -40,9 +65,26 @@ class ReplayIntegrationTest { 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, @@ -61,7 +103,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = null, + replayCacheProvider = { _, _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider ) } @@ -118,7 +160,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any(), any()) + verify(captureStrategy, never()).start(any(), any(), any()) } @Test @@ -141,7 +183,11 @@ class ReplayIntegrationTest { replay.start() replay.start() - verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -152,7 +198,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, never()).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, never()).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -163,7 +213,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, times(1)).start(any(), eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -203,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 }) @@ -212,27 +266,13 @@ 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(SentryId.EMPTY_ID) } @@ -244,13 +284,13 @@ 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(SentryId()) } @@ -265,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() } @@ -376,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 index 6dd1b99250..ac593f6c27 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub import io.sentry.Scope import io.sentry.ScopeCallback @@ -13,25 +12,19 @@ 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.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_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.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig -import io.sentry.cache.PersistingScopeObserver 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 @@ -123,7 +116,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) assertEquals(replayId, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -135,7 +128,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -162,124 +155,10 @@ class SessionCaptureStrategyTest { assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) } - @Test - fun `start cleans up old replays`() { - val replayId = SentryId() - - fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val currentReplay = - File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } - val evenOlderReplay = - File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } - val scopeCache = File( - fixture.options.cacheDirPath, - PersistingScopeObserver.SCOPE_CACHE - ).also { it.mkdirs() } - - val strategy = fixture.getSut() - - strategy.start(fixture.recorderConfig, 0, replayId, true) - - // deletes older replay folders, but keeps current and previous replay + everything else - assertTrue(currentReplay.exists()) - assertTrue(scopeCache.exists()) - assertFalse(evenOlderReplay.exists()) - } - - @Test - fun `start finalizes previous replay`() { - val replayId = SentryId() - val oldReplayId = SentryId() - - fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val currentReplay = - File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } - val oldReplay = - File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } - 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 strategy = fixture.getSut(replayCacheDir = oldReplay) - strategy.start(fixture.recorderConfig, 0, replayId, true) - - assertTrue(currentReplay.exists()) - assertFalse(oldReplay.exists()) - 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 `pause creates and captures current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId(), false) + strategy.start(fixture.recorderConfig, 0, SentryId()) strategy.pause() @@ -299,7 +178,7 @@ class SessionCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId, false) + strategy.start(fixture.recorderConfig, 0, replayId) strategy.stop() @@ -317,26 +196,26 @@ class SessionCaptureStrategyTest { } @Test - fun `sendReplayForEvent captures last segment for crashed event`() { + fun `captureReplay does nothing for non-crashed event`() { val strategy = fixture.getSut() strategy.start(fixture.recorderConfig) - strategy.sendReplayForEvent(true, "event-id", Hint()) {} + strategy.captureReplay(false) {} - verify(fixture.hub).captureReplay( - argThat { event -> - event is SentryReplayEvent && event.segmentId == 0 - }, - any() - ) + verify(fixture.hub, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent does nothing for non-crashed event`() { - val strategy = fixture.getSut() + 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.sendReplayForEvent(false, "event-id", Hint()) {} + strategy.captureReplay(true) {} + strategy.onScreenshotRecorded(mock()) {} verify(fixture.hub, never()).captureReplay(any(), any()) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 708083ea3c..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 } @@ -1260,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 @@ -1676,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 @@ -2120,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; @@ -3332,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 @@ -3341,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 } @@ -3372,6 +3373,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse 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 { 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/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/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/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 988cc14d7d..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) { @@ -318,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); @@ -628,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 73cac15aa4..7c186cf99d 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -150,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/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index c32bb7b9e9..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() @@ -2362,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") @@ -2752,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() ) } }