From a05b31d9f1711f172189d29c8b821ae937e1b180 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 13:18:38 +0200 Subject: [PATCH 1/7] feat(replay): Add manual replay control API Expose start, buffering, pause, resume, stop, and flush operations through Sentry.replay(). Keep lifecycle pauses distinct from explicit user pauses. Foregrounding therefore does not resume sensitive-screen recording unexpectedly. Refs JAVA-325 Co-Authored-By: OpenAI Codex --- .../sentry/android/core/LifecycleWatcher.java | 11 +- .../io/sentry/android/core/SentryAndroid.java | 2 +- .../android/core/LifecycleWatcherTest.kt | 45 ++---- .../api/sentry-android-replay.api | 4 + .../android/replay/ReplayIntegration.kt | 59 +++++--- .../android/replay/ReplayIntegrationTest.kt | 130 +++++++++++++++++- .../sentry/android/replay/ReplaySmokeTest.kt | 2 +- sentry/api/sentry.api | 16 ++- .../src/main/java/io/sentry/IReplayApi.java | 32 +++++ .../java/io/sentry/NoOpReplayController.java | 12 ++ .../main/java/io/sentry/ReplayController.java | 16 ++- sentry/src/test/java/io/sentry/SentryTest.kt | 19 ++- 12 files changed, 267 insertions(+), 81 deletions(-) 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 de1c40c570c..107017ffd61 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"); 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 ab18a5827b9..82916a248e6 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 ce518eabb05..3ceeeef8c38 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 @@ -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,13 +253,13 @@ 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() diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 0e4ce0461b0..b16b83278ff 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -62,11 +62,14 @@ 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 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 +84,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 96f3ddf0926..c2a6d257694 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 @@ -141,14 +141,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 +159,35 @@ 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 (startNewSession) { + 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) + } + } + resumeInternal() + } } - private fun startInternal() { + override fun onAppBackgrounded() { + enqueueOnMainThread { pauseInternal() } + } + + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return } @@ -184,15 +201,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) { @@ -334,6 +343,17 @@ 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 { + captureReplayInternal(current.generation, current.replayId, false) + } + } + } + override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter } @@ -399,6 +419,7 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() current.captureStrategy?.stop() + isManualPause = false state.set( current.copy( lifecycleState = STOPPED, 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 f4983eff71d..80595b375e1 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 @@ -269,7 +269,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 +280,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 +297,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 +389,36 @@ 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 `captureReplay does nothing when not recording`() { val captureStrategy = mock() @@ -393,6 +467,50 @@ 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).captureReplay(eq(false), any()) + verify(captureStrategy).convert() + } + + @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 b84b1b53347..0a8076f20f6 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/api/sentry.api b/sentry/api/sentry.api index 2a5f14c4871..bc5627b2ac2 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,21 @@ 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 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 +2382,11 @@ 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 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 f1dd003b525..d31a24b0b21 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -1,7 +1,39 @@ 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. After the buffer is sent, recording continues in session mode unless the process + * is terminating. + */ + void startBuffering(); + + /** Stops the current replay. A subsequent {@link #start()} begins a new replay session. */ + void stop(); + + /** + * Pauses the current replay until {@link #resume()} is called. This can be used to avoid + * recording sensitive screens, such as PIN entry. + */ + void pause(); + + /** Resumes a replay paused with {@link #pause()}. */ + void resume(); + + /** + * Flushes replay data. 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 3f1e88b822b..8e010d197c2 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,15 @@ public void pause() {} @Override public void resume() {} + @Override + public void flush() {} + + @Override + public void onAppForegrounded(boolean startNewReplay) {} + + @Override + public void onAppBackgrounded() {} + @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 630c0da3d50..811208bfa99 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -7,13 +7,17 @@ @ApiStatus.Internal public interface ReplayController extends IReplayApi { - void start(); - - void stop(); - - void pause(); + /** + * Handles app foregrounding. When a new app session begins, starts a sampled replay unless one is + * already recording. An existing replay is never restarted or replaced. + */ + void onAppForegrounded(boolean startNewSession); - void resume(); + /** + * 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(); boolean isRecording(); diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 98cda8e9d82..00f5f89db39 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() } From 05bdcbc0d35f7009dba28f0d9674c71c264556f3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 13:38:07 +0200 Subject: [PATCH 2/7] changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4d2957973..d20b0d71118 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. + - `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 a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965)) From bd1914c689677c78aa03e5cd3543a796184944aa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 15:55:26 +0200 Subject: [PATCH 3/7] fix(replay): Ignore foreground before registration A foreground callback can run before ReplayIntegration registers and initializes its options. Ignore lifecycle callbacks until the integration is enabled to avoid crashing during SDK initialization. Refs JAVA-325 Co-Authored-By: Codex --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 3 +++ .../io/sentry/android/replay/ReplayIntegrationTest.kt | 9 +++++++++ 2 files changed, 12 insertions(+) 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 c2a6d257694..591134ac99a 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 @@ -168,6 +168,9 @@ public class ReplayIntegration( override fun onAppForegrounded(startNewSession: Boolean) { enqueueOnMainThread { + if (!isEnabled.get()) { + return@enqueueOnMainThread + } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { 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 80595b375e1..164856c47da 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 @@ -225,6 +225,15 @@ 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 `start sets isRecording to true`() { val captureStrategy = mock() From 80732082d22c023b5ee206da7ca1331d789bfae0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 19 Aug 2026 20:28:44 +0200 Subject: [PATCH 4/7] fix(replay): Bypass sampling for manual buffers Track whether a buffered replay was started automatically so only automatic buffers apply per-error sampling. Manually started buffers now capture on errors as documented. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 18 ++++++++----- .../android/replay/ReplayIntegrationTest.kt | 26 +++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 591134ac99a..002865460f4 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 @@ -159,11 +159,11 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal(isFullSession = true) } + enqueueOnMainThread { startInternal(isFullSession = true, shouldSampleOnError = false) } } override fun startBuffering() { - enqueueOnMainThread { startInternal(isFullSession = false) } + enqueueOnMainThread { startInternal(isFullSession = false, shouldSampleOnError = false) } } override fun onAppForegrounded(startNewSession: Boolean) { @@ -179,7 +179,7 @@ public class ReplayIntegration( "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", ) } else { - startInternal(isFullSession) + startInternal(isFullSession, shouldSampleOnError = !isFullSession) } } resumeInternal() @@ -190,7 +190,7 @@ public class ReplayIntegration( enqueueOnMainThread { pauseInternal() } } - private fun startInternal(isFullSession: Boolean) { + private fun startInternal(isFullSession: Boolean, shouldSampleOnError: Boolean) { if (!isEnabled.get()) { return } @@ -235,6 +235,7 @@ public class ReplayIntegration( lifecycleState = STARTED, replayId = replayId ?: SentryId.EMPTY_ID, captureStrategy = strategy, + shouldSampleOnError = shouldSampleOnError, ) ) @@ -279,7 +280,11 @@ public class ReplayIntegration( return SentryId.EMPTY_ID } - if (current.isBuffering && !sample(options.sessionReplay.onErrorSampleRate)) { + if ( + current.isBuffering && + current.shouldSampleOnError && + !sample(options.sessionReplay.onErrorSampleRate) + ) { options.logger.log( INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event", @@ -350,7 +355,7 @@ public class ReplayIntegration( enqueueOnMainThread { val current = state.get() if (!current.isRecording) { - startInternal(isFullSession = true) + startInternal(isFullSession = true, shouldSampleOnError = false) } else { captureReplayInternal(current.generation, current.replayId, false) } @@ -724,6 +729,7 @@ public class ReplayIntegration( val lifecycleState: ReplayLifecycleState = ReplayLifecycleState.INITIAL, val replayId: SentryId = SentryId.EMPTY_ID, val captureStrategy: CaptureStrategy? = null, + val shouldSampleOnError: Boolean = false, ) { val isBuffering: Boolean get() = captureStrategy is BufferCaptureStrategy 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 164856c47da..2c2a252e857 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 @@ -576,16 +576,38 @@ class ReplayIntegrationTest { fixture.getSut( context, sessionSampleRate = 0.0, - onErrorSampleRate = 0.0, + onErrorSampleRate = 1.0, replayCaptureStrategyProvider = { captureStrategy }, ) replay.register(fixture.scopes, fixture.options) - replay.start() + replay.onAppForegrounded(true) + fixture.options.sessionReplay.onErrorSampleRate = 0.0 assertThat(replay.captureReplay(false)).isEqualTo(SentryId.EMPTY_ID) verify(captureStrategy, never()).captureReplay(any(), any()) } + @Test + fun `manual buffer capture bypasses 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() + + assertThat(replay.captureReplay(false)).isEqualTo(replayId) + verify(captureStrategy).captureReplay(eq(false), any()) + verify(captureStrategy).convert() + } + @Test fun `capture queued after stop cannot resurrect replay`() { val replayId = SentryId() From 0582fec62ab15ed548ea7d7d46e078286767e07f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Aug 2026 11:04:37 +0200 Subject: [PATCH 5/7] revert: fix(replay): Bypass sampling for manual buffers This reverts commit 80732082d22c023b5ee206da7ca1331d789bfae0. Reason: Match Sentry JavaScript by applying onErrorSampleRate to all buffered replay captures. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 18 +++++-------- .../android/replay/ReplayIntegrationTest.kt | 26 ++----------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 002865460f4..591134ac99a 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 @@ -159,11 +159,11 @@ public class ReplayIntegration( override fun isRecording(): Boolean = state.get().isRecording override fun start() { - enqueueOnMainThread { startInternal(isFullSession = true, shouldSampleOnError = false) } + enqueueOnMainThread { startInternal(isFullSession = true) } } override fun startBuffering() { - enqueueOnMainThread { startInternal(isFullSession = false, shouldSampleOnError = false) } + enqueueOnMainThread { startInternal(isFullSession = false) } } override fun onAppForegrounded(startNewSession: Boolean) { @@ -179,7 +179,7 @@ public class ReplayIntegration( "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified", ) } else { - startInternal(isFullSession, shouldSampleOnError = !isFullSession) + startInternal(isFullSession) } } resumeInternal() @@ -190,7 +190,7 @@ public class ReplayIntegration( enqueueOnMainThread { pauseInternal() } } - private fun startInternal(isFullSession: Boolean, shouldSampleOnError: Boolean) { + private fun startInternal(isFullSession: Boolean) { if (!isEnabled.get()) { return } @@ -235,7 +235,6 @@ public class ReplayIntegration( lifecycleState = STARTED, replayId = replayId ?: SentryId.EMPTY_ID, captureStrategy = strategy, - shouldSampleOnError = shouldSampleOnError, ) ) @@ -280,11 +279,7 @@ public class ReplayIntegration( return SentryId.EMPTY_ID } - if ( - current.isBuffering && - current.shouldSampleOnError && - !sample(options.sessionReplay.onErrorSampleRate) - ) { + if (current.isBuffering && !sample(options.sessionReplay.onErrorSampleRate)) { options.logger.log( INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event", @@ -355,7 +350,7 @@ public class ReplayIntegration( enqueueOnMainThread { val current = state.get() if (!current.isRecording) { - startInternal(isFullSession = true, shouldSampleOnError = false) + startInternal(isFullSession = true) } else { captureReplayInternal(current.generation, current.replayId, false) } @@ -729,7 +724,6 @@ public class ReplayIntegration( val lifecycleState: ReplayLifecycleState = ReplayLifecycleState.INITIAL, val replayId: SentryId = SentryId.EMPTY_ID, val captureStrategy: CaptureStrategy? = null, - val shouldSampleOnError: Boolean = false, ) { val isBuffering: Boolean get() = captureStrategy is BufferCaptureStrategy 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 2c2a252e857..164856c47da 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 @@ -576,38 +576,16 @@ class ReplayIntegrationTest { fixture.getSut( context, sessionSampleRate = 0.0, - onErrorSampleRate = 1.0, + onErrorSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }, ) replay.register(fixture.scopes, fixture.options) - replay.onAppForegrounded(true) - fixture.options.sessionReplay.onErrorSampleRate = 0.0 + replay.start() assertThat(replay.captureReplay(false)).isEqualTo(SentryId.EMPTY_ID) verify(captureStrategy, never()).captureReplay(any(), any()) } - @Test - fun `manual buffer capture bypasses 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() - - assertThat(replay.captureReplay(false)).isEqualTo(replayId) - verify(captureStrategy).captureReplay(eq(false), any()) - verify(captureStrategy).convert() - } - @Test fun `capture queued after stop cannot resurrect replay`() { val replayId = SentryId() From df60eec73f16fbe7a31d21416159759ae585931f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Aug 2026 11:26:12 +0200 Subject: [PATCH 6/7] fix(replay): Ignore lifecycle callbacks before registration Drop foreground and background callbacks received before Replay is registered instead of leaving stale work on the main queue. Clarify the manual replay API documentation. Refs JAVA-325 Co-Authored-By: Codex --- .../io/sentry/android/replay/ReplayIntegration.kt | 9 ++++++--- .../sentry/android/replay/ReplayIntegrationTest.kt | 8 +++++--- sentry/src/main/java/io/sentry/IReplayApi.java | 13 ++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 591134ac99a..0beff402946 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 @@ -167,10 +167,10 @@ public class ReplayIntegration( } override fun onAppForegrounded(startNewSession: Boolean) { + if (!isEnabled.get()) { + return + } enqueueOnMainThread { - if (!isEnabled.get()) { - return@enqueueOnMainThread - } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { @@ -187,6 +187,9 @@ public class ReplayIntegration( } override fun onAppBackgrounded() { + if (!isEnabled.get()) { + return + } enqueueOnMainThread { pauseInternal() } } 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 164856c47da..958be61f757 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 @@ -226,12 +226,14 @@ class ReplayIntegrationTest { } @Test - fun `foreground before register does nothing`() { - val replay = fixture.getSut(context) + fun `lifecycle callbacks before register are not enqueued`() { + val mainLooperHandler = mock() + val replay = fixture.getSut(context, mainLooperHandler = mainLooperHandler) replay.onAppForegrounded(true) + replay.onAppBackgrounded() - assertThat(replay.isRecording).isFalse() + verify(mainLooperHandler, never()).post(any()) } @Test diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index d31a24b0b21..c2944d4b632 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -11,17 +11,20 @@ public interface IReplayApi { /** * Starts replay buffering. The rolling buffer is sent when {@link #flush()} is called or an error - * is captured. After the buffer is sent, recording continues in session mode unless the process - * is terminating. + * 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. A subsequent {@link #start()} begins a new replay session. */ + /** + * Stops the current replay in either session or buffer mode. A subsequent {@link #start()} begins + * a new replay session. + */ void stop(); /** - * Pauses the current replay until {@link #resume()} is called. This can be used to avoid - * recording sensitive screens, such as PIN entry. + * Pauses the current replay in either session or buffer mode until {@link #resume()} is called. + * This can be used to avoid recording sensitive screens, such as PIN entry. */ void pause(); From 20d793bacf0b6deea124973cbb6ef61dd93833df Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Aug 2026 12:10:40 +0200 Subject: [PATCH 7/7] fix(replay): Preserve queued foreground startup Check Replay registration when the foreground callback executes so AppState catch-up can start Replay after registration. Cover both callback orderings with tests. Refs JAVA-325 Co-Authored-By: Codex --- .../android/replay/ReplayIntegration.kt | 9 +++------ .../android/replay/ReplayIntegrationTest.kt | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 0beff402946..591134ac99a 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 @@ -167,10 +167,10 @@ public class ReplayIntegration( } override fun onAppForegrounded(startNewSession: Boolean) { - if (!isEnabled.get()) { - return - } enqueueOnMainThread { + if (!isEnabled.get()) { + return@enqueueOnMainThread + } if (startNewSession) { val isFullSession = sample(options.sessionReplay.sessionSampleRate) if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) { @@ -187,9 +187,6 @@ public class ReplayIntegration( } override fun onAppBackgrounded() { - if (!isEnabled.get()) { - return - } enqueueOnMainThread { pauseInternal() } } 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 958be61f757..30cc7ecb56a 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 @@ -226,14 +226,23 @@ class ReplayIntegrationTest { } @Test - fun `lifecycle callbacks before register are not enqueued`() { - val mainLooperHandler = mock() - val replay = fixture.getSut(context, mainLooperHandler = mainLooperHandler) + fun `foreground before register does nothing`() { + val replay = fixture.getSut(context) replay.onAppForegrounded(true) - replay.onAppBackgrounded() - verify(mainLooperHandler, never()).post(any()) + 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