Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -214,7 +215,7 @@ class LifecycleWatcherTest {

watcher.onForeground()
verify(fixture.scopes, never()).startSession()
verify(fixture.replayController, never()).start()
verify(fixture.replayController).onAppForegrounded(false)
}

@Test
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -167,10 +159,38 @@ public class ReplayIntegration(
override fun isRecording(): Boolean = state.get().isRecording

override fun start() {
enqueueOnMainThread { startInternal() }
enqueueOnMainThread { startInternal(isFullSession = true, shouldSampleOnError = false) }
}

override fun startBuffering() {
enqueueOnMainThread { startInternal(isFullSession = false, shouldSampleOnError = false) }
}

override fun onAppForegrounded(startNewSession: Boolean) {
enqueueOnMainThread {
if (!isEnabled.get()) {
return@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, shouldSampleOnError = !isFullSession)
}
}
resumeInternal()
}
}

private fun startInternal() {
override fun onAppBackgrounded() {
enqueueOnMainThread { pauseInternal() }
}

private fun startInternal(isFullSession: Boolean, shouldSampleOnError: Boolean) {
if (!isEnabled.get()) {
return
}
Expand All @@ -184,15 +204,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) {
Expand Down Expand Up @@ -223,6 +235,7 @@ public class ReplayIntegration(
lifecycleState = STARTED,
replayId = replayId ?: SentryId.EMPTY_ID,
captureStrategy = strategy,
shouldSampleOnError = shouldSampleOnError,
)
)

Expand Down Expand Up @@ -267,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",
Expand Down Expand Up @@ -334,6 +351,17 @@ public class ReplayIntegration(

override fun getReplayId(): SentryId = state.get().replayId

override fun flush() {
Comment thread
cursor[bot] marked this conversation as resolved.
enqueueOnMainThread {
val current = state.get()
if (!current.isRecording) {
startInternal(isFullSession = true, shouldSampleOnError = false)
} else {
captureReplayInternal(current.generation, current.replayId, false)
}
}
}

override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) {
replayBreadcrumbConverter = converter
}
Expand Down Expand Up @@ -399,6 +427,7 @@ public class ReplayIntegration(
recorder?.stop()
gestureRecorder?.stop()
current.captureStrategy?.stop()
isManualPause = false
state.set(
current.copy(
lifecycleState = STOPPED,
Expand Down Expand Up @@ -700,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
Expand Down
Loading
Loading