diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 55278c6356..51eb48f1b2 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,10 +245,6 @@ public final class io/sentry/android/core/SentryAndroid { public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V - public static fun pauseReplay ()V - public static fun resumeReplay ()V - public static fun startReplay ()V - public static fun stopReplay ()V } public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index b58051cee7..27c9d6e674 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -299,7 +299,10 @@ static void installDefaultIntegrations( options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { - options.addIntegration(new ReplayIntegration(context, CurrentDateProvider.getInstance())); + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + options.addIntegration(replay); + options.setReplayController(replay); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index ff281d2beb..81e77a75fb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -93,11 +93,12 @@ private void startSession() { addSessionBreadcrumb("start"); hub.startSession(); } - SentryAndroid.startReplay(); - } else if (!isFreshSession.getAndSet(false)) { + hub.getOptions().getReplayController().start(); + } else if (!isFreshSession.get()) { // only resume if it's not a fresh session, which has been started in SentryAndroid.init - SentryAndroid.resumeReplay(); + hub.getOptions().getReplayController().resume(); } + isFreshSession.set(false); this.lastUpdatedSession.set(currentTimeMillis); } @@ -108,7 +109,7 @@ public void onStop(final @NotNull LifecycleOwner owner) { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); - SentryAndroid.pauseReplay(); + hub.getOptions().getReplayController().pause(); scheduleEndSession(); AppState.getInstance().setInBackground(true); @@ -127,7 +128,7 @@ public void run() { addSessionBreadcrumb("end"); hub.endSession(); } - SentryAndroid.stopReplay(); + hub.getOptions().getReplayController().stop(); } }; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index d6e11d15f5..676bb2173a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -15,8 +15,6 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; -import io.sentry.android.replay.ReplayIntegration; -import io.sentry.android.replay.ReplayIntegrationKt; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -160,7 +158,7 @@ public static synchronized void init( hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); hub.startSession(); } - startReplay(); + hub.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -225,59 +223,4 @@ private static void deduplicateIntegrations( } } } - - public static synchronized void startReplay() { - if (!ensureReplayIntegration("starting")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).start(); - } - - public static synchronized void stopReplay() { - if (!ensureReplayIntegration("stopping")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).stop(); - } - - public static synchronized void resumeReplay() { - if (!ensureReplayIntegration("resuming")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).resume(); - } - - public static synchronized void pauseReplay() { - if (!ensureReplayIntegration("pausing")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).pause(); - } - - private static boolean ensureReplayIntegration(final @NotNull String actionName) { - final @NotNull IHub hub = Sentry.getCurrentHub(); - if (isReplayAvailable) { - final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); - if (replay != null) { - return true; - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't registered yet, not " + actionName + " the replay"); - } - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't found on classpath, not " + actionName + " the replay"); - } - return false; - } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 94b0490f17..9bded25087 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -491,6 +491,13 @@ class AndroidOptionsInitializerTest { assertNotNull(actual) } + @Test + fun `ReplayIntegration set as ReplayController if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + assertTrue(fixture.sentryOptions.replayController is ReplayIntegration) + } + @Test fun `ReplayIntegration won't be enabled, it throws class not found`() { fixture.initSutWithClassLoader(isReplayAvailable = false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 4b620813bf..388bfbe274 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -5,8 +5,10 @@ import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub import io.sentry.IScope +import io.sentry.ReplayController import io.sentry.ScopeCallback import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.Session import io.sentry.Session.State import io.sentry.transport.ICurrentDateProvider @@ -34,6 +36,8 @@ class LifecycleWatcherTest { val ownerMock = mock() val hub = mock() val dateProvider = mock() + val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -47,6 +51,8 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) + whenever(hub.options).thenReturn(options) return LifecycleWatcher( hub, @@ -70,6 +76,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -79,6 +86,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub, times(2)).startSession() + verify(fixture.replayController, times(2)).start() } @Test @@ -88,6 +96,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -96,6 +105,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) verify(fixture.hub, timeout(10000)).endSession() + verify(fixture.replayController, timeout(10000)).stop() } @Test @@ -110,6 +120,7 @@ class LifecycleWatcherTest { assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test @@ -241,6 +252,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub, never()).startSession() + verify(fixture.replayController, never()).start() } @Test @@ -267,6 +279,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -282,4 +295,50 @@ class LifecycleWatcherTest { watcher.onStop(fixture.ownerMock) assertTrue(AppState.getInstance().isInBackground!!) } + + @Test + fun `if the hub has already a fresh session running, doesn't resume replay`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getCurrentDateTime(), + DateUtils.getCurrentDateTime(), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release", + null + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController, never()).resume() + } + + @Test + fun `background-foreground replay`() { + whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) + val watcher = fixture.getSUT( + sessionIntervalMillis = 2L, + enableAppLifecycleBreadcrumbs = false + ) + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).start() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController).pause() + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).resume() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController, timeout(10000)).stop() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index b543ae318a..a4b213800f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -342,6 +342,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.sessionReplayOptions.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 4bdc382eae..0a064b803f 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -27,24 +27,20 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public fun close ()V public final fun createVideoOf (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun rotate (J)V } -public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { - public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; - public static final field VIDEO_BUFFER_DURATION J - public static final field VIDEO_SEGMENT_DURATION J +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V public final fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V - public final fun pause ()V + public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V - public final fun resume ()V - public final fun start ()V - public final fun stop ()V -} - -public final class io/sentry/android/replay/ReplayIntegration$Companion { + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun start ()V + public fun stop ()V } public final class io/sentry/android/replay/ReplayIntegrationKt { 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 dd09f80833..db1f691260 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 @@ -176,13 +176,7 @@ public class ReplayCache internal constructor( encoder = null } - frames.removeAll { - if (it.timestamp < (from + duration)) { - deleteFile(it.screenshot) - return@removeAll true - } - return@removeAll false - } + rotate(until = (from + duration)) return GeneratedVideo(videoFile, frameCount, videoDuration) } @@ -211,6 +205,21 @@ public class ReplayCache internal constructor( } } + /** + * Removes frames from the in-memory and disk cache from start to [until]. + * + * @param until value until whose the frames should be removed, represented as unix timestamp + */ + fun rotate(until: Long) { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + } + override fun close() { synchronized(encoderLock) { encoder?.release() 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 25c63f5179..a64353e09a 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 @@ -7,18 +7,26 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.Integration +import io.sentry.ReplayController import io.sentry.ReplayRecording +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.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File +import java.security.SecureRandom import java.util.Date import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -32,26 +40,26 @@ import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback { - - companion object { - const val VIDEO_SEGMENT_DURATION = 5_000L - const val VIDEO_BUFFER_DURATION = 30_000L - } +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null private var cache: ReplayCache? = null + private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here + private val isFullSession = AtomicBoolean(false) private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() private val currentSegment = AtomicInteger(0) - private val saver = + + // TODO: surround with try-catch on the calling site + private val saver by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( @@ -61,6 +69,13 @@ class ReplayIntegration( ) } + private fun sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < random.nextDouble()) // bad luck + } + return false + } + override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -69,22 +84,35 @@ class ReplayIntegration( return } - // TODO: check for replaysSessionSampleRate and replaysOnErrorSampleRate + if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, no sample rate specified") + return + } + + isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + if (!isFullSession.get() && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") + return + } this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) + + addIntegrationToSdkVersion(javaClass) + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } fun isRecording() = isRecording.get() - fun start() { + override fun start() { // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -98,7 +126,11 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) - hub?.configureScope { it.replayId = currentReplayId.get() } + if (isFullSession.get()) { + // only set replayId on the scope if it's a full session, otherwise all events will be + // tagged with the replay that might never be sent when we're recording in buffer mode + hub?.configureScope { it.replayId = currentReplayId.get() } + } cache = ReplayCache(options, currentReplayId.get(), recorderConfig) recorder?.startRecording() @@ -107,16 +139,75 @@ class ReplayIntegration( // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } - fun resume() { + override fun resume() { + if (!isEnabled.get()) { + return + } + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } - fun pause() { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + if (!isEnabled.get()) { + return + } + + if (isFullSession.get()) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + return + } + + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + return + } + + if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) + return + } + + val errorReplayDuration = options.experimental.sessionReplayOptions.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + saver.submit { + val videoDuration = + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + // since we're switching to session mode, even if the video is not sent for an error + // we still set the timestamp to now, because session is technically started "now" + segmentTimestamp.set(DateUtils.getDateTime(now)) + } + + hub?.configureScope { it.replayId = currentReplayId.get() } + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + isFullSession.set(true) + } + + override fun pause() { + if (!isEnabled.get()) { + return + } + val now = dateProvider.currentTimeMillis recorder?.pause() + if (!isFullSession.get()) { + return + } + val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time @@ -130,12 +221,8 @@ class ReplayIntegration( } } - fun stop() { + override fun stop() { if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -146,7 +233,10 @@ class ReplayIntegration( val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir saver.submit { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + // we don't flush the segment, but we still wanna clean up the folder for buffer mode + if (isFullSession.get()) { + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + } FileUtils.deleteRecursively(replayCacheDir) } @@ -167,14 +257,16 @@ class ReplayIntegration( cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis - if (now - segmentTimestamp.get().time >= VIDEO_SEGMENT_DURATION) { + if (isFullSession.get() && + (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) + ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val replayId = currentReplayId.get() val videoDuration = createAndCaptureSegment( - VIDEO_SEGMENT_DURATION, + options.experimental.sessionReplayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -184,6 +276,8 @@ class ReplayIntegration( // set next segment timestamp as close to the previous one as possible to avoid gaps segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } + } else if (!isFullSession.get()) { + cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) } } } @@ -192,7 +286,9 @@ class ReplayIntegration( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, - segmentId: Int + segmentId: Int, + replayType: ReplayType = SESSION, + hint: Hint? = null ): Long? { val generatedVideo = cache?.createVideoOf( duration, @@ -207,7 +303,9 @@ class ReplayIntegration( currentSegmentTimestamp, segmentId, frameCount, - videoDuration + videoDuration, + replayType, + hint ) return videoDuration } @@ -218,7 +316,9 @@ class ReplayIntegration( segmentTimestamp: Date, segmentId: Int, frameCount: Int, - duration: Long + duration: Long, + replayType: ReplayType, + hint: Hint? = null ) { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -228,6 +328,7 @@ class ReplayIntegration( if (segmentId == 0) { replayStartTimestamp = segmentTimestamp } + this.replayType = replayType videoFile = video } @@ -255,11 +356,14 @@ class ReplayIntegration( ) } - val hint = Hint().apply { replayRecording = recording } - hub?.captureReplay(replay, hint) + hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) } override fun close() { + if (!isEnabled.get()) { + return + } + stop() saver.gracefullyShutdown(options) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d639b765ff..3e65667941 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -45,6 +45,7 @@ public final class io/sentry/Baggage { public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Ljava/lang/String; public fun getSampleRate ()Ljava/lang/String; public fun getSampleRateDouble ()Ljava/lang/Double; public fun getSampled ()Ljava/lang/String; @@ -59,6 +60,7 @@ public final class io/sentry/Baggage { public fun setEnvironment (Ljava/lang/String;)V public fun setPublicKey (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayId (Ljava/lang/String;)V public fun setSampleRate (Ljava/lang/String;)V public fun setSampled (Ljava/lang/String;)V public fun setTraceId (Ljava/lang/String;)V @@ -66,7 +68,7 @@ public final class io/sentry/Baggage { public fun setUserId (Ljava/lang/String;)V public fun setUserSegment (Ljava/lang/String;)V public fun setValuesFromScope (Lio/sentry/IScope;Lio/sentry/SentryOptions;)V - public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V + public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V public fun toHeaderString (Ljava/lang/String;)Ljava/lang/String; public fun toTraceContext ()Lio/sentry/TraceContext; } @@ -76,6 +78,7 @@ public final class io/sentry/Baggage$DSCKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -1201,6 +1204,15 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun pause ()V + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun start ()V + public fun stop ()V +} + public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addAttachment (Lio/sentry/Attachment;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V @@ -1591,6 +1603,14 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public abstract interface class io/sentry/ReplayController { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public abstract fun start ()V + public abstract fun stop ()V +} + public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V public fun equals (Ljava/lang/Object;)Z @@ -2301,6 +2321,7 @@ public class io/sentry/SentryOptions { public fun getProxy ()Lio/sentry/SentryOptions$Proxy; public fun getReadTimeoutMillis ()I public fun getRelease ()Ljava/lang/String; + public fun getReplayController ()Lio/sentry/ReplayController; public fun getSampleRate ()Ljava/lang/Double; public fun getScopeObservers ()Ljava/util/List; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; @@ -2406,6 +2427,7 @@ public class io/sentry/SentryOptions { public fun setProxy (Lio/sentry/SentryOptions$Proxy;)V public fun setReadTimeoutMillis (I)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayController (Lio/sentry/ReplayController;)V public fun setSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSendClientReports (Z)V @@ -2549,6 +2571,8 @@ public final class io/sentry/SentryReplayOptions { public fun getFrameRate ()I public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun isSessionReplayEnabled ()Z + public fun isSessionReplayForErrorsEnabled ()Z public fun setErrorSampleRate (Ljava/lang/Double;)V public fun setSessionSampleRate (Ljava/lang/Double;)V } @@ -2899,6 +2923,7 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getSampleRate ()Ljava/lang/String; public fun getSampled ()Ljava/lang/String; public fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -2920,6 +2945,7 @@ public final class io/sentry/TraceContext$JsonKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 8e19fceaf8..53bd10248e 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -141,6 +141,7 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); + // TODO: add replay_id later baggage.freeze(); return baggage; } @@ -345,6 +346,16 @@ public void setSampled(final @Nullable String sampled) { set(DSCKeys.SAMPLED, sampled); } + @ApiStatus.Internal + public @Nullable String getReplayId() { + return get(DSCKeys.REPLAY_ID); + } + + @ApiStatus.Internal + public void setReplayId(final @Nullable String replayId) { + set(DSCKeys.REPLAY_ID, replayId); + } + @ApiStatus.Internal public void set(final @NotNull String key, final @Nullable String value) { if (mutable) { @@ -373,6 +384,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -384,6 +396,9 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); + if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); setSampled(StringUtils.toString(sampled(samplingDecision))); } @@ -393,10 +408,14 @@ public void setValuesFromScope( final @NotNull IScope scope, final @NotNull SentryOptions options) { final @NotNull PropagationContext propagationContext = scope.getPropagationContext(); final @Nullable User user = scope.getUser(); + final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); + if (!SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setUserSegment(user != null ? getSegment(user) : null); setTransaction(null); setSampleRate(null); @@ -468,6 +487,7 @@ private static boolean isHighQualityTransactionName( @Nullable public TraceContext toTraceContext() { final String traceIdString = getTraceId(); + final String replayIdString = getReplayId(); final String publicKey = getPublicKey(); if (traceIdString != null && publicKey != null) { @@ -481,7 +501,8 @@ public TraceContext toTraceContext() { getUserSegment(), getTransaction(), getSampleRate(), - getSampled()); + getSampled(), + replayIdString == null ? null : new SentryId(replayIdString)); traceContext.setUnknown(getUnknown()); return traceContext; } else { @@ -500,6 +521,7 @@ public static final class DSCKeys { public static final String TRANSACTION = "sentry-transaction"; public static final String SAMPLE_RATE = "sentry-sample_rate"; public static final String SAMPLED = "sentry-sampled"; + public static final String REPLAY_ID = "sentry-replay_id"; public static final List ALL = Arrays.asList( @@ -511,6 +533,7 @@ public static final class DSCKeys { USER_SEGMENT, TRANSACTION, SAMPLE_RATE, - SAMPLED); + SAMPLED, + REPLAY_ID); } } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 0d12bf844b..ebd1adabb2 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -4,7 +4,9 @@ /** * Experimental options for new features, these options are going to be promoted to SentryOptions - * before GA + * before GA. + * + *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java new file mode 100644 index 0000000000..d052fba8b4 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -0,0 +1,29 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +public final class NoOpReplayController implements ReplayController { + + private static final NoOpReplayController instance = new NoOpReplayController(); + + public static NoOpReplayController getInstance() { + return instance; + } + + private NoOpReplayController() {} + + @Override + public void start() {} + + @Override + public void stop() {} + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} +} diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java new file mode 100644 index 0000000000..a45a0ecda2 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -0,0 +1,17 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public interface ReplayController { + void start(); + + void stop(); + + void pause(); + + void resume(); + + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); +} diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 129d415a8a..e0a1177646 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -191,6 +191,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } + if (event != null) { + options.getReplayController().sendReplayForEvent(event, hint); + } + try { @Nullable TraceContext traceContext = null; if (HintUtils.hasType(hint, Backfillable.class)) { @@ -227,23 +231,42 @@ 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 + // 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 if (scope != null) { - final @Nullable ITransaction transaction = scope.getTransaction(); - if (transaction != null) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); - transaction.forceFinish(SpanStatus.ABORTED, false, hint); - } else { - transaction.forceFinish(SpanStatus.ABORTED, false, null); - } + finalizeTransaction(scope, hint); + finalizeReplay(scope, hint); + } + + return sentryId; + } + + private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); + transaction.forceFinish(SpanStatus.ABORTED, false, hint); + } else { + transaction.forceFinish(SpanStatus.ABORTED, false, null); } } } + } - return sentryId; + 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 diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 26e8a7c038..67d481f708 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -462,6 +462,8 @@ public class SentryOptions { private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); + private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); + /** * Adds an event processor * @@ -2281,6 +2283,15 @@ public ExperimentalOptions getExperimental() { return experimental; } + public @NotNull ReplayController getReplayController() { + return replayController; + } + + public void setReplayController(final @Nullable ReplayController replayController) { + this.replayController = + replayController != null ? replayController : NoOpReplayController.getInstance(); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index df98fc384f..d702d6256b 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -51,6 +51,10 @@ public Double getErrorSampleRate() { return errorSampleRate; } + public boolean isSessionReplayEnabled() { + return (getSessionSampleRate() != null && getSessionSampleRate() > 0); + } + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { throw new IllegalArgumentException( @@ -66,6 +70,10 @@ public Double getSessionSampleRate() { return sessionSampleRate; } + public boolean isSessionReplayForErrorsEnabled() { + return (getErrorSampleRate() != null && getErrorSampleRate() > 0); + } + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { if (!SampleRateUtils.isValidSampleRate(sessionSampleRate)) { throw new IllegalArgumentException( diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8c1536cbbf..320f79680b 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -582,12 +582,18 @@ private void updateBaggageValues() { synchronized (this) { if (baggage.isMutable()) { final AtomicReference userAtomicReference = new AtomicReference<>(); + final AtomicReference replayId = new AtomicReference<>(); hub.configureScope( scope -> { userAtomicReference.set(scope.getUser()); + replayId.set(scope.getReplayId()); }); baggage.setValuesFromTransaction( - this, userAtomicReference.get(), hub.getOptions(), this.getSamplingDecision()); + this, + userAtomicReference.get(), + replayId.get(), + hub.getOptions(), + this.getSamplingDecision()); baggage.freeze(); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index df799aaa07..da34382d51 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -21,12 +21,13 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { private final @Nullable String transaction; private final @Nullable String sampleRate; private final @Nullable String sampled; + private final @Nullable SentryId replayId; @SuppressWarnings("unused") private @Nullable Map unknown; TraceContext(@NotNull SentryId traceId, @NotNull String publicKey) { - this(traceId, publicKey, null, null, null, null, null, null, null); + this(traceId, publicKey, null, null, null, null, null, null, null, null); } TraceContext( @@ -38,7 +39,8 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userSegment, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { + @Nullable String sampled, + @Nullable SentryId replayId) { this.traceId = traceId; this.publicKey = publicKey; this.release = release; @@ -48,6 +50,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.transaction = transaction; this.sampleRate = sampleRate; this.sampled = sampled; + this.replayId = replayId; } @SuppressWarnings("UnusedMethod") @@ -96,6 +99,10 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { return sampled; } + public @Nullable SentryId getReplayId() { + return replayId; + } + /** * @deprecated only here to support parsing legacy JSON with non flattened user */ @@ -198,6 +205,7 @@ public static final class JsonKeys { public static final String TRANSACTION = "transaction"; public static final String SAMPLE_RATE = "sample_rate"; public static final String SAMPLED = "sampled"; + public static final String REPLAY_ID = "replay_id"; } @Override @@ -227,6 +235,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (sampled != null) { writer.name(TraceContext.JsonKeys.SAMPLED).value(sampled); } + if (replayId != null) { + writer.name(TraceContext.JsonKeys.REPLAY_ID).value(logger, replayId); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -253,6 +264,7 @@ public static final class Deserializer implements JsonDeserializer String transaction = null; String sampleRate = null; String sampled = null; + SentryId replayId = null; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -288,6 +300,9 @@ public static final class Deserializer implements JsonDeserializer case TraceContext.JsonKeys.SAMPLED: sampled = reader.nextStringOrNull(); break; + case TraceContext.JsonKeys.REPLAY_ID: + replayId = new SentryId.Deserializer().deserialize(reader, logger); + break; default: if (unknown == null) { unknown = new ConcurrentHashMap<>(); @@ -320,7 +335,8 @@ public static final class Deserializer implements JsonDeserializer userSegment, transaction, sampleRate, - sampled); + sampled, + replayId); traceContext.setUnknown(unknown); reader.endObject(); return traceContext; diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index eb1cfa0383..c24731e92a 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -527,15 +527,13 @@ class BaggageTest { @Test fun `unknown returns sentry- prefixed keys that are not known and passes them on to TraceContext`() { - val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=def", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) + val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=${SentryId()}", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) val unknown = baggage.unknown - assertEquals(2, unknown.size) - assertEquals("def", unknown["replay_id"]) + assertEquals(1, unknown.size) assertEquals("abc", unknown["anewkey"]) val traceContext = baggage.toTraceContext()!! - assertEquals(2, traceContext.unknown!!.size) - assertEquals("def", traceContext.unknown!!["replay_id"]) + assertEquals(1, traceContext.unknown!!.size) assertEquals("abc", traceContext.unknown!!["anewkey"]) } diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index a894fcfff3..7214b3643e 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -443,16 +443,16 @@ class JsonSerializerTest { @Test fun `serializes trace context`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } @Test fun `serializes trace context with user having null id and segment`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 0733e6ea45..eddacbf939 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,6 +1,7 @@ package io.sentry import io.sentry.Scope.IWithPropagationContext +import io.sentry.SentryLevel.WARNING import io.sentry.Session.State.Crashed import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason @@ -2272,6 +2273,41 @@ 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") @@ -2286,6 +2322,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId.EMPTY_ID) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2358,6 +2395,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId()) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2426,6 +2464,8 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + val replayId = SentryId() + whenever(scope.replayId).thenReturn(replayId) val scopePropagationContext = PropagationContext() doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) whenever(scope.propagationContext).thenReturn(scopePropagationContext) @@ -2438,6 +2478,7 @@ class SentryClientTest { check { assertNotNull(it.header.traceContext) assertEquals(scopePropagationContext.traceId, it.header.traceContext!!.traceId) + assertEquals(replayId, it.header.traceContext!!.replayId) }, any() ) @@ -2579,6 +2620,21 @@ class SentryClientTest { ) } + @Test + fun `calls sendReplayForEvent 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) + called = true + } + }) + val sut = fixture.getSut() + + sut.captureMessage("Test", WARNING) + assertTrue(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/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 37a3d09cca..a91f81f3cb 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.protocol.User import io.sentry.util.thread.IMainThreadChecker @@ -581,6 +582,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val trace = transaction.traceContext() assertNotNull(trace) { assertEquals(transaction.spanContext.traceId, it.traceId) @@ -590,6 +593,7 @@ class SentryTracerTest { assertEquals(transaction.name, it.transaction) // assertEquals("user-id", it.userId) assertEquals("pro", it.userSegment) + assertEquals(replayId, it.replayId) } } @@ -658,6 +662,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val header = transaction.toBaggageHeader(null) assertNotNull(header) { @@ -671,6 +677,7 @@ class SentryTracerTest { assertTrue(it.value.contains("sentry-transaction=name,")) // assertTrue(it.value.contains("sentry-user_id=userId12345,")) assertTrue(it.value.contains("sentry-user_segment=pro$".toRegex())) + assertTrue(it.value.contains("sentry-replay_id=$replayId")) } } diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index e79e5ebf8c..876ec12831 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -24,7 +24,8 @@ class TraceContextSerializationTest { "f7d8662b-5551-4ef8-b6a8-090f0561a530", "0252ec25-cd0a-4230-bd2f-936a4585637e", "0.00000021", - "true" + "true", + SentryId("3367f5196c494acaae85bbbd535379aa") ) } private val fixture = Fixture() @@ -62,6 +63,7 @@ class TraceContextSerializationTest { id = "user-id" others = mapOf("segment" to "pro") }, + SentryId(), SentryOptions().apply { dsn = dsnString environment = "prod" diff --git a/sentry/src/test/resources/json/sentry_envelope_header.json b/sentry/src/test/resources/json/sentry_envelope_header.json index 14c144f820..5f6b3b25e7 100644 --- a/sentry/src/test/resources/json/sentry_envelope_header.json +++ b/sentry/src/test/resources/json/sentry_envelope_header.json @@ -27,7 +27,8 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" }, "sent_at": "2020-02-07T14:16:00.000Z" } diff --git a/sentry/src/test/resources/json/trace_state.json b/sentry/src/test/resources/json/trace_state.json index 17a95fdc33..6ca0e48e61 100644 --- a/sentry/src/test/resources/json/trace_state.json +++ b/sentry/src/test/resources/json/trace_state.json @@ -7,5 +7,6 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" }