Skip to content
Merged
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 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))
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 All @@ -108,7 +109,7 @@ private void scheduleEndSession() {
if (enableSessionTracking) {
scopes.endSession();
}
scopes.getOptions().getReplayController().stop();
scopes.getOptions().getReplayController().onAppSessionEnded();
scopes.getOptions().getContinuousProfiler().close(false);
};

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

Expand All @@ -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
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,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()
}
}
5 changes: 5 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,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
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Comment thread
cursor[bot] marked this conversation as resolved.
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()
}
}
Comment thread
sentry[bot] marked this conversation as resolved.
resumeInternal()
Comment thread
romtsn marked this conversation as resolved.
}
}

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

override fun onAppSessionEnded() {
enqueueOnMainThread { stopInternal(resetManualPause = false) }
}

private fun startInternal() {
private fun startInternal(isFullSession: Boolean) {
if (!isEnabled.get()) {
return
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -339,6 +361,19 @@ 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)
Comment thread
romtsn marked this conversation as resolved.
} else {
captureCurrentReplay(current.generation, current.replayId) { strategy, onSegmentSent ->
strategy.flush(onSegmentSent)
}
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

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