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) }
}

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

override fun onAppForegrounded(startNewSession: Boolean) {
enqueueOnMainThread {
if (!isEnabled.get()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make more sense to check this condition before enqueing?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make more sense to check this condition before enqueing?

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)
}
}
resumeInternal()
}
}

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

private fun startInternal(isFullSession: 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 @@ -334,6 +346,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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does flush call startInternal if we're not recording?

} else {
captureReplayInternal(current.generation, current.replayId, false)
}
}
}

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