diff --git a/CHANGELOG.md b/CHANGELOG.md index 878238076f..71689dc8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### Features + +- Add manual Session Replay controls through `Sentry.replay()` ([#5978](https://github.com/getsentry/sentry-java/pull/5978)) + - Explicit `start()` and `startBuffering()` calls bypass the configured replay sample rates; sampling still controls automatic startup. + - `start()` starts a full-session replay and does nothing if one is already recording. + - `startBuffering()` keeps a rolling buffer that is sent on `flush()` or an error, then continues in session mode. + - `stop()` ends the current replay; the next `start()` creates a new replay session. + - `pause()` suspends recording until `resume()` and remains paused across background and foreground transitions and automatic replay restarts in the same process. + - `resume()` continues the same manually paused replay. + - `flush()` sends the current replay data, or starts a full-session replay when recording is stopped. + ### Fixes - Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888)) 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 de1c40c570..ca874e714e 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 @@ -76,14 +76,15 @@ private void startSession() { }); final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + final boolean startNewSession = + lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis; + if (startNewSession) { if (enableSessionTracking) { scopes.startSession(); } - scopes.getOptions().getReplayController().start(); } - scopes.getOptions().getReplayController().resume(); + scopes.getOptions().getReplayController().onAppForegrounded(startNewSession); this.lastUpdatedSession.set(currentTimeMillis); } @@ -94,7 +95,7 @@ public void onBackground() { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); - scopes.getOptions().getReplayController().pause(); + scopes.getOptions().getReplayController().onAppBackgrounded(); scheduleEndSession(); addAppBreadcrumb("background"); @@ -108,7 +109,7 @@ private void scheduleEndSession() { if (enableSessionTracking) { scopes.endSession(); } - scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getReplayController().onAppSessionEnded(); scopes.getOptions().getContinuousProfiler().close(false); }; 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 ab18a5827b..82916a248e 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 @@ -203,7 +203,7 @@ public static void init( scopes.startSession(); } } - scopes.getOptions().getReplayController().start(); + scopes.getOptions().getReplayController().onAppForegrounded(true); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); 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 ce518eabb0..5f14e029d0 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 @@ -77,7 +77,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) } @Test @@ -87,7 +87,7 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onForeground() verify(fixture.scopes, times(2)).startSession() - verify(fixture.replayController, times(2)).start() + verify(fixture.replayController, times(2)).onAppForegrounded(true) } @Test @@ -97,7 +97,8 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) + verify(fixture.replayController).onAppForegrounded(false) } @Test @@ -106,7 +107,7 @@ class LifecycleWatcherTest { watcher.onForeground() watcher.onBackground() verify(fixture.scopes, timeout(10000)).endSession() - verify(fixture.replayController, timeout(10000)).stop() + verify(fixture.replayController, timeout(10000)).onAppSessionEnded() verify(fixture.continuousProfiler, timeout(10000)).close(eq(false)) } @@ -123,7 +124,7 @@ class LifecycleWatcherTest { assertNull(watcher.endSessionFuture) verify(fixture.scopes, never()).endSession() - verify(fixture.replayController, never()).stop() + verify(fixture.replayController, never()).onAppSessionEnded() } @Test @@ -214,7 +215,7 @@ class LifecycleWatcherTest { watcher.onForeground() verify(fixture.scopes, never()).startSession() - verify(fixture.replayController, never()).start() + verify(fixture.replayController).onAppForegrounded(false) } @Test @@ -243,35 +244,7 @@ class LifecycleWatcherTest { watcher.onForeground() verify(fixture.scopes).startSession() - verify(fixture.replayController).start() - } - - @Test - fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { - val watcher = - fixture.getSUT( - enableAppLifecycleBreadcrumbs = false, - session = - Session( - State.Ok, - DateUtils.getCurrentDateTime(), - DateUtils.getCurrentDateTime(), - 0, - "abc", - "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", - true, - 0, - 10.0, - null, - null, - null, - "release", - null, - ), - ) - - watcher.onForeground() - verify(fixture.replayController).resume() + verify(fixture.replayController).onAppForegrounded(true) } @Test @@ -280,15 +253,15 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(sessionIntervalMillis = 500L, enableAppLifecycleBreadcrumbs = false) watcher.onForeground() - verify(fixture.replayController).start() + verify(fixture.replayController).onAppForegrounded(true) watcher.onBackground() - verify(fixture.replayController).pause() + verify(fixture.replayController).onAppBackgrounded() watcher.onForeground() - verify(fixture.replayController, times(2)).resume() + verify(fixture.replayController).onAppForegrounded(false) watcher.onBackground() - verify(fixture.replayController, timeout(10000)).stop() + verify(fixture.replayController, timeout(10000)).onAppSessionEnded() } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 0e4ce0461b..91c91778f0 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -62,11 +62,15 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun close ()V public fun disableDebugMaskingOverlay ()V public fun enableDebugMaskingOverlay ()V + public fun flush ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z + public fun onAppBackgrounded ()V + public fun onAppForegrounded (Z)V + public fun onAppSessionEnded ()V public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V @@ -81,6 +85,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V + public fun startBuffering ()V public fun stop ()V } 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 d13565624b..edb3f9c03a 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 @@ -52,6 +52,7 @@ import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Random import java.io.Closeable import java.io.File +import java.util.Date import java.util.LinkedList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -141,14 +142,6 @@ public class ReplayIntegration( return } - if ( - !options.sessionReplay.isSessionReplayEnabled && - !options.sessionReplay.isSessionReplayForErrorsEnabled - ) { - options.logger.log(INFO, "Session replay is disabled, no sample rate specified") - return - } - this.scopes = scopes recorder = recorderProvider?.invoke() @@ -167,10 +160,48 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal() } + enqueueOnMainThread { startInternal(isFullSession = true) } + } + + override fun startBuffering() { + enqueueOnMainThread { startInternal(isFullSession = false) } + } + + override fun onAppForegrounded(startNewSession: Boolean) { + enqueueOnMainThread { + if (!isEnabled.get()) { + return@enqueueOnMainThread + } + if (startNewSession) { + val wasManuallyPaused = isManualPause + stopInternal() + val isFullSession = sample(options.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { + options.logger.log( + INFO, + "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", + ) + } else { + startInternal(isFullSession) + } + isManualPause = wasManuallyPaused + if (wasManuallyPaused) { + pauseInternal() + } + } + resumeInternal() + } + } + + override fun onAppBackgrounded() { + enqueueOnMainThread { pauseInternal() } + } + + override fun onAppSessionEnded() { + enqueueOnMainThread { stopInternal(resetManualPause = false) } } - private fun startInternal() { + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return } @@ -184,15 +215,7 @@ public class ReplayIntegration( return } - val isFullSession = sample(options.sessionReplay.sessionSampleRate) - if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { - options.logger.log( - INFO, - "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", - ) - return - } - + isManualPause = false val strategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { @@ -282,16 +305,18 @@ public class ReplayIntegration( current.captureStrategy?.captureReplay(true) {} } else { enqueueOnMainThread { - captureReplayInternal(current.generation, current.replayId, false) + captureCurrentReplay(current.generation, current.replayId) { strategy, onSegmentSent -> + strategy.captureReplay(false, onSegmentSent) + } } } return current.replayId } - private fun captureReplayInternal( + private fun captureCurrentReplay( expectedGeneration: Long, expectedReplayId: SentryId, - isTerminating: Boolean, + capture: (CaptureStrategy, (Date) -> Unit) -> Unit, ) { val current = state.get() val strategy = current.captureStrategy @@ -303,30 +328,27 @@ public class ReplayIntegration( } options.logger.log( INFO, - "Replay was stopped or restarted before capture could run, not capturing for event", + "Replay was stopped or restarted before capture could run, not capturing replay", ) return } var activeStrategy: CaptureStrategy = strategy - strategy.captureReplay( - isTerminating, - onSegmentSent = { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } + capture(strategy) { newTimestamp -> + enqueueOnMainThread { + val latest = state.get() + // The flush completes asynchronously; ignore it if this replay was stopped, restarted, + // or handed to another strategy in the meantime. + if ( + latest.matches(expectedGeneration, expectedReplayId) && + latest.captureStrategy === activeStrategy + ) { + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true } - }, - ) + } + } activeStrategy = strategy.convert() val replayId: SentryId? = activeStrategy.currentReplayId state.set( @@ -339,6 +361,19 @@ public class ReplayIntegration( override fun getReplayId(): SentryId = state.get().replayId + override fun flush() { + enqueueOnMainThread { + val current = state.get() + if (!current.isRecording) { + startInternal(isFullSession = true) + } else { + captureCurrentReplay(current.generation, current.replayId) { strategy, onSegmentSent -> + strategy.flush(onSegmentSent) + } + } + } + } + override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter } @@ -393,7 +428,7 @@ public class ReplayIntegration( enqueueOnMainThread { stopInternal() } } - private fun stopInternal() { + private fun stopInternal(resetManualPause: Boolean = true) { val current = state.get() if (!isEnabled.get() || !current.lifecycleState.isAllowed(STOPPED)) { return @@ -404,6 +439,9 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() current.captureStrategy?.stop() + if (resetManualPause) { + isManualPause = false + } state.set( current.copy( lifecycleState = STOPPED, 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 deb51ecc00..17aefb3ebd 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 @@ -113,7 +113,9 @@ internal abstract class BaseCaptureStrategy( } override fun resume() { - segmentTimestamp = DateUtils.getCurrentDateTime() + val resumedAt = DateUtils.getCurrentDateTime() + // Keep the timeline update behind queued frames and pause/flush segment creation. + replayExecutor.submit(ReplayRunnable("$TAG.resume") { segmentTimestamp = resumedAt }) } override fun pause() = Unit 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 d20735e412..b8845f7484 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 @@ -123,6 +123,8 @@ internal class BufferCaptureStrategy( } } + override fun flush(onSegmentSent: (Date) -> Unit) = captureReplay(false, onSegmentSent) + override fun onScreenshotRecorded( bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit, 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 780cdd9248..fc8b8d1306 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 @@ -39,8 +39,15 @@ internal interface CaptureStrategy { fun resume() + /** + * Captures replay data for an event. Session mode may defer sending until its normal segment + * boundary because the replay is already being uploaded continuously. + */ fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) + /** Explicitly sends the current replay data to Sentry in either session or buffer mode. */ + fun flush(onSegmentSent: (Date) -> Unit) + fun onScreenshotRecorded( bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit, 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 df6e09b535..4fd3c910e2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -21,9 +21,10 @@ import java.util.concurrent.ScheduledExecutorService * `sessionSegmentDuration`, until the 1h `sessionDuration` deadline. Used when the session is * sampled by `sessionSampleRate`. * - * [captureReplay] is a no-op here — there is no buffer to flush, the segment covering the error is - * sent like any other. Because envelopes are in flight the whole time, `ReplayIntegration` pauses - * this strategy while offline or rate-limited so the envelope cache doesn't overflow. + * Event-triggered [captureReplay] is a no-op here — there is no buffer to flush, so the segment + * covering the error is sent like any other. An explicit [flush] still sends the current segment. + * Because envelopes are in flight the whole time, `ReplayIntegration` pauses this strategy while + * offline or rate-limited so the envelope cache doesn't overflow. * * See [BufferCaptureStrategy] for the on-error counterpart. */ @@ -91,6 +92,17 @@ internal class SessionCaptureStrategy( this.isTerminating.set(isTerminating) } + override fun flush(onSegmentSent: (Date) -> Unit) { + createCurrentSegment("flush") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + currentSegment++ + segmentTimestamp = segment.replay.timestamp + isFlushed = true + } + } + } + override fun onScreenshotRecorded( bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit, @@ -123,8 +135,10 @@ internal class SessionCaptureStrategy( return@ReplayRunnable } - val now = dateProvider.currentTimeMillis - if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { + if ( + frameTimestamp - currentSegmentTimestamp.time >= + options.sessionReplay.sessionSegmentDuration + ) { val segment = createSegmentInternal( options.sessionReplay.sessionSegmentDuration, @@ -144,7 +158,7 @@ internal class SessionCaptureStrategy( } } - if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { + if (frameTimestamp - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration) { options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } @@ -179,15 +193,16 @@ internal class SessionCaptureStrategy( return } - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp ?: return - val duration = now - currentSegmentTimestamp.time + val requestedAt = dateProvider.currentTimeMillis val replayId = currentReplayId replayExecutor.submit( ReplayRunnable("$TAG.$taskName") { + // Keep the request time so executor delays do not extend the segment, but read the mutable + // timeline here so a queued natural boundary cannot make it stale. + val currentSegmentTimestamp = segmentTimestamp ?: return@ReplayRunnable val segment = createSegmentInternal( - duration, + requestedAt - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, currentSegment, 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 8f2994fc64..932f26a2b6 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 @@ -188,12 +188,12 @@ class ReplayIntegrationTest { } @Test - fun `when no sample rate is set, does not register`() { + fun `when no sample rate is set, still registers`() { val replay = fixture.getSut(context, 0.0, 0.0) replay.register(fixture.scopes, fixture.options) - assertFalse(replay.isEnabled.get()) + assertTrue(replay.isEnabled.get()) } @Test @@ -225,6 +225,26 @@ class ReplayIntegrationTest { verify(captureStrategy, never()).start(any(), any(), anyOrNull()) } + @Test + fun `foreground before register does nothing`() { + val replay = fixture.getSut(context) + + replay.onAppForegrounded(true) + + assertThat(replay.isRecording).isFalse() + } + + @Test + fun `foreground queued before register starts replay after register`() { + val replay = fixture.getSut(context, mainLooperHandler = MainLooperHandler()) + + replay.onAppForegrounded(true) + replay.register(fixture.scopes, fixture.options) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(replay.isRecording).isTrue() + } + @Test fun `start sets isRecording to true`() { val captureStrategy = mock() @@ -269,7 +289,7 @@ class ReplayIntegrationTest { } @Test - fun `does not start replay when session is not sampled`() { + fun `automatic start does not start replay when session is not sampled`() { val captureStrategy = mock() val replay = fixture.getSut( @@ -280,14 +300,14 @@ class ReplayIntegrationTest { ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) verify(captureStrategy, never()) .start(eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull()) } @Test - fun `still starts replay when errorsSampleRate is set`() { + fun `automatic start still starts replay when errorsSampleRate is set`() { val captureStrategy = mock() val replay = fixture.getSut( @@ -297,12 +317,56 @@ class ReplayIntegrationTest { ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) verify(captureStrategy, times(1)) .start(eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull()) } + @Test + fun `manual start forces session mode without sample rates`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isTrue() + } + + @Test + fun `manual startBuffering forces buffer mode without sample rates`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isFalse() + } + @Test fun `calls recorder start`() { val recorder = mock() @@ -345,6 +409,106 @@ class ReplayIntegrationTest { verify(recorder).resume() } + @Test + fun `manual pause is not cleared when app returns to foreground`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pause() + replay.start() + replay.onAppForegrounded(false) + + verify(captureStrategy, never()).resume() + + replay.resume() + verify(captureStrategy).resume() + } + + @Test + fun `app foreground resumes an automatic background pause`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.onAppBackgrounded() + replay.onAppForegrounded(false) + + verify(captureStrategy).resume() + } + + @Test + fun `new app session replaces an automatically paused replay`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.onAppBackgrounded() + replay.onAppForegrounded(true) + + verify(firstStrategy).stop() + verify(firstStrategy, never()).resume() + verify(secondStrategy).start(any(), any(), anyOrNull()) + } + + @Test + fun `new app session replaces a manually paused replay and stays paused`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.pause() + replay.onAppForegrounded(true) + + verify(firstStrategy).pause() + verify(firstStrategy).stop() + verify(secondStrategy).start(any(), any(), anyOrNull()) + verify(secondStrategy).pause() + verify(secondStrategy, never()).resume() + + replay.resume() + verify(secondStrategy).resume() + } + + @Test + fun `new app session stays paused after the previous app session ends`() { + val firstStrategy = mock() + val secondStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy)) + val replay = + fixture.getSut( + context, + replayCaptureStrategyProvider = { strategies.removeFirst() }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.onAppForegrounded(true) + replay.pause() + replay.onAppSessionEnded() + replay.onAppForegrounded(true) + + verify(firstStrategy).stop() + verify(secondStrategy).start(any(), any(), anyOrNull()) + verify(secondStrategy).pause() + verify(secondStrategy, never()).resume() + } + @Test fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() @@ -393,6 +557,63 @@ class ReplayIntegrationTest { verify(captureStrategy).convert() } + @Test + fun `flush captures a manual buffer without error sampling`() { + val replayId = SentryId() + val captureStrategy = mock() + whenever(captureStrategy.currentReplayId).thenReturn(replayId) + whenever(captureStrategy.convert()).thenReturn(captureStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { captureStrategy }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + replay.flush() + + verify(captureStrategy).flush(any()) + verify(captureStrategy).convert() + } + + @Test + fun `flush captures the current session segment`() { + val captureStrategy = mock() + whenever(captureStrategy.convert()).thenReturn(captureStrategy) + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.flush() + + verify(captureStrategy).flush(any()) + } + + @Test + fun `flush starts a session when replay is stopped`() { + val captureStrategy = mock() + var isFullSession: Boolean? = null + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { + isFullSession = it + captureStrategy + }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.flush() + + assertThat(replay.isRecording).isTrue() + assertThat(isFullSession).isTrue() + } + @Test fun `captureReplay returns replay id and sets scope before queued capture`() { val replayId = SentryId() 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 b84b1b5334..0a8076f20f 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 @@ -184,7 +184,7 @@ class ReplaySmokeTest { val controller = buildActivity(ExampleActivity::class.java, null).setup() controller.create().start().resume() - replay.start() + replay.onAppForegrounded(true) // wait for windows to be registered in our listeners shadowOf(Looper.getMainLooper()).idle() 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 3e5198bea0..3d876b8f97 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 @@ -50,6 +50,7 @@ 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 import org.mockito.kotlin.verify @@ -251,6 +252,150 @@ class SessionCaptureStrategyTest { verify(fixture.scopes, never()).captureReplay(any(), any()) } + @Test + fun `flush creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.flush {} + + verify(fixture.scopes) + .captureReplay(argThat { event -> event is SentryReplayEvent && event.segmentId == 0 }, any()) + assertEquals(1, strategy.currentSegment) + assertTrue(strategy.isFlushed) + } + + @Test + fun `flush uses the timeline after queued segment boundaries`() { + val tasks = mutableListOf() + val segmentIds = mutableListOf() + val now = System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration * 2 + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + doAnswer { + segmentIds += (it.arguments[0] as SentryReplayEvent).segmentId + SentryId.EMPTY_ID + } + .whenever(fixture.scopes) + .captureReplay(any(), any()) + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + strategy.flush {} + tasks.forEach(Runnable::run) + + assertThat(segmentIds).containsExactly(0, 1).inOrder() + } + + @Test + fun `flush uses request time when executor is delayed`() { + val tasks = mutableListOf() + var now = System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val segmentStart = strategy.segmentTimestamp!!.time + val requestedAt = now + + strategy.flush {} + now += fixture.options.sessionReplay.sessionSegmentDuration + tasks.forEach(Runnable::run) + + verify(fixture.replayCache) + .createVideoOf( + eq(requestedAt - segmentStart), + eq(segmentStart), + eq(0), + any(), + any(), + any(), + any(), + any(), + ) + } + + @Test + fun `resume updates the timeline after a queued pause`() { + val tasks = mutableListOf() + val now = System.currentTimeMillis() + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val segmentStart = Date(now - fixture.options.sessionReplay.sessionSegmentDuration) + strategy.segmentTimestamp = segmentStart + + strategy.pause() + strategy.resume() + tasks.forEach(Runnable::run) + + verify(fixture.replayCache) + .createVideoOf( + eq(now - segmentStart.time), + eq(segmentStart.time), + eq(0), + any(), + any(), + any(), + any(), + any(), + ) + assertThat(strategy.segmentTimestamp).isNotEqualTo(segmentStart) + } + + @Test + fun `executor delay does not trigger a segment boundary`() { + val tasks = mutableListOf() + var now = System.currentTimeMillis() + val replayExecutor = + mock { + doAnswer { + tasks += it.arguments[0] as Runnable + null + } + .whenever(mock) + .submit(any()) + } + val strategy = fixture.getSut(dateProvider = { now }, replayExecutor = replayExecutor) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + now += fixture.options.sessionReplay.sessionSegmentDuration - 1 + + strategy.onScreenshotRecorded(mock()) {} + now += fixture.options.sessionReplay.sessionDuration + tasks.forEach(Runnable::run) + + verify(fixture.scopes, never()).captureReplay(any(), any()) + verify(fixture.options.replayController, never()).stop() + } + @Test fun `when process is crashing, onScreenshotRecorded does not create new segment`() { val now = @@ -297,22 +442,11 @@ class SessionCaptureStrategyTest { @Test fun `onScreenshotRecorded stops replay when replay duration exceeded`() { - val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2) - var count = 0 - val strategy = - fixture.getSut( - dateProvider = { - // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and - // frameTimestamp) - if (count++ == 2) { - now - } else { - System.currentTimeMillis() - } - } - ) + var now = System.currentTimeMillis() + val strategy = fixture.getSut(dateProvider = { now }) strategy.start() strategy.onConfigurationChanged(mock()) + now += fixture.options.sessionReplay.sessionDuration * 2 strategy.onScreenshotRecorded(mock()) {} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index b38f17ed64..eb18b96153 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -580,6 +580,48 @@ fun SessionReplayScreen() { horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + SentryTraced("start_replay") { + OutlinedButton(onClick = { Sentry.replay().start() }, modifier = Modifier) { + Text("Start Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("start_replay_buffering") { + OutlinedButton(onClick = { Sentry.replay().startBuffering() }, modifier = Modifier) { + Text("Start Replay Buffering", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("pause_replay") { + OutlinedButton(onClick = { Sentry.replay().pause() }, modifier = Modifier) { + Text("Pause Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("resume_replay") { + OutlinedButton(onClick = { Sentry.replay().resume() }, modifier = Modifier) { + Text("Resume Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("flush_replay") { + OutlinedButton(onClick = { Sentry.replay().flush() }, modifier = Modifier) { + Text("Flush Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + item { + SentryTraced("stop_replay") { + OutlinedButton(onClick = { Sentry.replay().stop() }, modifier = Modifier) { + Text("Stop Replay", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("enable_replay_debug") { OutlinedButton( diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fa876b3312..ff75e82d0a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -903,6 +903,12 @@ public abstract interface class io/sentry/IProfileConverter { public abstract interface class io/sentry/IReplayApi { public abstract fun disableDebugMaskingOverlay ()V public abstract fun enableDebugMaskingOverlay ()V + public abstract fun flush ()V + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun start ()V + public abstract fun startBuffering ()V + public abstract fun stop ()V } public abstract interface class io/sentry/IScope { @@ -1716,17 +1722,22 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun captureReplay (Ljava/lang/Boolean;)Lio/sentry/protocol/SentryId; public fun disableDebugMaskingOverlay ()V public fun enableDebugMaskingOverlay ()V + public fun flush ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z + public fun onAppBackgrounded ()V + public fun onAppForegrounded (Z)V + public fun onAppSessionEnded ()V public fun pause ()V public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V + public fun startBuffering ()V public fun stop ()V } @@ -2372,13 +2383,12 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isDebugMaskingOverlayEnabled ()Z public abstract fun isRecording ()Z - public abstract fun pause ()V + public abstract fun onAppBackgrounded ()V + public abstract fun onAppForegrounded (Z)V + public abstract fun onAppSessionEnded ()V public abstract fun registerSegmentName (Ljava/lang/String;)V public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V - public abstract fun resume ()V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V - public abstract fun start ()V - public abstract fun stop ()V } public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index f1dd003b52..e15bcdaa54 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -1,7 +1,44 @@ package io.sentry; +/** + * Controls Session Replay. Methods may be called from any thread and return before the requested + * operation completes. + */ public interface IReplayApi { + /** Starts a new replay session. Does nothing if a replay is already being recorded. */ + void start(); + + /** + * Starts replay buffering. The rolling buffer is sent when {@link #flush()} is called or an error + * is captured and selected by {@link SentryReplayOptions#getOnErrorSampleRate()}. After the + * buffer is sent, recording continues in session mode unless the process is terminating. + */ + void startBuffering(); + + /** + * Stops the current replay in either session or buffer mode. A subsequent {@link #start()} begins + * a new replay session. + */ + void stop(); + + /** + * Pauses replay recording in either session or buffer mode until {@link #resume()} is called. If + * the SDK automatically starts a new replay session in the same process, the new replay remains + * paused. This can be used to avoid recording sensitive screens, such as PIN entry. + */ + void pause(); + + /** Resumes a replay paused with {@link #pause()}. */ + void resume(); + + /** + * Immediately sends the current replay data to Sentry in either session or buffer mode. A + * buffering replay continues in session mode after the buffer is sent. If replay is not + * recording, starts a new replay session. + */ + void flush(); + /** * Draws a masking overlay on top of the screen to help visualize which parts of the screen are * masked by Session Replay. This is only useful for debugging purposes and should not be used in diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 3f1e88b822..f8ae171615 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -17,6 +17,9 @@ private NoOpReplayController() {} @Override public void start() {} + @Override + public void startBuffering() {} + @Override public void stop() {} @@ -26,6 +29,18 @@ public void pause() {} @Override public void resume() {} + @Override + public void flush() {} + + @Override + public void onAppForegrounded(boolean startNewReplay) {} + + @Override + public void onAppBackgrounded() {} + + @Override + public void onAppSessionEnded() {} + @Override public boolean isRecording() { return false; diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 630c0da3d5..a674b68f5e 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -7,19 +7,28 @@ @ApiStatus.Internal public interface ReplayController extends IReplayApi { - void start(); - - void stop(); + /** + * Handles app foregrounding. When a new app session begins, stops any previous replay and starts + * a newly sampled one while preserving an explicit user pause. + */ + void onAppForegrounded(boolean startNewSession); - void pause(); + /** + * Handles app backgrounding with a temporary lifecycle pause. Unlike {@link #pause()}, this pause + * is automatically resumed on foreground and does not override an explicit user pause. + */ + void onAppBackgrounded(); - void resume(); + /** Stops replay when the current app session ends without clearing an explicit user pause. */ + void onAppSessionEnded(); boolean isRecording(); /** - * Captures the buffered replay and returns its ID, or {@link SentryId#EMPTY_ID} if no replay was - * captured. + * Captures replay data for an event and returns its ID, or {@link SentryId#EMPTY_ID} if no replay + * was captured. In buffer mode, sends the buffered replay and continues in session mode. In + * session mode, the replay is already uploaded continuously, so this does not force an immediate + * segment upload; use {@link #flush()} for that. */ @NotNull SentryId captureReplay(@Nullable Boolean isTerminating); diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 98cda8e9d8..00f5f89db3 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1505,16 +1505,29 @@ class SentryTest { } @Test - fun `replay debug masking is forwarded to replay controller`() { + fun `replay API is forwarded to replay controller`() { val replayController = mock() initForTest { it.dsn = dsn it.setReplayController(replayController) } - Sentry.replay().enableDebugMaskingOverlay() - verify(replayController).enableDebugMaskingOverlay() + Sentry.replay().start() + Sentry.replay().startBuffering() + Sentry.replay().pause() + Sentry.replay().resume() + Sentry.replay().flush() + Sentry.replay().stop() + + verify(replayController).start() + verify(replayController).startBuffering() + verify(replayController).pause() + verify(replayController).resume() + verify(replayController).flush() + verify(replayController).stop() + Sentry.replay().enableDebugMaskingOverlay() Sentry.replay().disableDebugMaskingOverlay() + verify(replayController).enableDebugMaskingOverlay() verify(replayController).disableDebugMaskingOverlay() }