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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

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

### Performance

- Defer starting Session Replay off the SDK initialization critical path ([#5965](https://github.com/getsentry/sentry-java/pull/5965))

### Dependencies

- Bump Native SDK from v0.16.2 to v0.16.3 ([#5962](https://github.com/getsentry/sentry-java/pull/5962))
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
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ class SentryAndroidTest {
@Config(sdk = [26])
fun `init starts session replay if app is in foreground`() {
initSentryWithForegroundImportance(true) { _ ->
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertTrue(Sentry.getCurrentHub().options.replayController.isRecording())
}
}
Expand All @@ -360,6 +361,7 @@ class SentryAndroidTest {
@Config(sdk = [26])
fun `init does not start session replay if the app is in background`() {
initSentryWithForegroundImportance(false) { _ ->
Shadows.shadowOf(Looper.getMainLooper()).idle()
assertFalse(Sentry.getCurrentHub().options.replayController.isRecording())
}
}
Expand Down
6 changes: 5 additions & 1 deletion sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,18 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun captureReplay (Ljava/lang/Boolean;)V
public fun captureReplay (Ljava/lang/Boolean;)Lio/sentry/protocol/SentryId;
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 @@ -22,7 +22,6 @@ import java.io.File
import java.io.StringReader
import java.util.Date
import java.util.LinkedList
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.atomic.AtomicBoolean

/**
Expand All @@ -41,7 +40,6 @@ import java.util.concurrent.atomic.AtomicBoolean
public class ReplayCache(private val options: SentryOptions, private val replayId: SentryId) :
Closeable {
private val isClosed = AtomicBoolean(false)
private val encoderLock = AutoClosableReentrantLock()
private val lock = AutoClosableReentrantLock()
private val framesLock = AutoClosableReentrantLock()
private var encoder: SimpleVideoEncoder? = null
Expand Down Expand Up @@ -152,28 +150,26 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
}

encoder =
encoderLock.acquire().use {
SimpleVideoEncoder(
options,
MuxerConfig(
file = videoFile,
recordingHeight = height,
recordingWidth = width,
frameRate = frameRate,
bitRate = bitRate,
),
)
.apply {
// the constructor already opened the MediaMuxer, so release it if start() fails,
// otherwise the encoder is never assigned and its resources leak (CloseGuard warning)
try {
start()
} catch (t: Throwable) {
release()
throw t
}
SimpleVideoEncoder(
options,
MuxerConfig(
file = videoFile,
recordingHeight = height,
recordingWidth = width,
frameRate = frameRate,
bitRate = bitRate,
),
)
.apply {
// the constructor already opened the MediaMuxer, so release it if start() fails,
// otherwise the encoder is never assigned and its resources leak (CloseGuard warning)
try {
start()
} catch (t: Throwable) {
release()
throw t
}
}
}

val step = 1000 / frameRate.toLong()
var frameCount = 0
Expand Down Expand Up @@ -209,20 +205,15 @@ public class ReplayCache(private val options: SentryOptions, private val replayI

if (frameCount == 0) {
options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment")
encoderLock.acquire().use {
encoder?.release()
encoder = null
}
encoder?.release()
encoder = null
deleteFile(videoFile)
return null
}

var videoDuration: Long
encoderLock.acquire().use {
encoder?.release()
videoDuration = encoder?.duration ?: 0
encoder = null
}
encoder?.release()
val videoDuration = encoder?.duration ?: 0
encoder = null

rotate(until = (from + duration))

Expand All @@ -235,7 +226,7 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
}
return try {
val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath)
encoderLock.acquire().use { encoder?.encode(bitmap) }
encoder?.encode(bitmap)
bitmap.recycle()
true
} catch (e: Throwable) {
Expand Down Expand Up @@ -281,27 +272,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
}

override fun close() {
// close() is called inline from the lifecycle path (ReplayIntegration.stop/close), which holds
// its own lock, so blocking here can freeze the main thread. If the encoder is wedged in a
// native MediaCodec call we'd never get the lock, so we give up instead: the already-dead codec
// is not released (leaking a native handle), which beats an ANR.
try {
val token = encoderLock.tryAcquire(ENCODER_RELEASE_TIMEOUT_MS, MILLISECONDS)
if (token == null) {
options.logger.log(
WARNING,
"Timed out waiting for the video encoder, skipping its release to not block the caller",
)
} else {
token.use {
encoder?.release()
encoder = null
}
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
encoder?.release()
encoder = null
} finally {
// has to happen on all paths, callers rely on it to stop persisting segment values
isClosed.set(true)
}
}
Expand Down Expand Up @@ -333,13 +307,6 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
}

internal companion object {
/**
* How long [close] waits for the video encoder to become available. Below Android's ~5s ANR
* budget, and above the encoder's own bail-out (see MAX_EOS_STALL_ITERATIONS), so an encoder
* that's merely slow is still awaited rather than abandoned.
*/
private const val ENCODER_RELEASE_TIMEOUT_MS = 2000L

internal const val ONGOING_SEGMENT = ".ongoing_segment"

internal const val SEGMENT_KEY_HEIGHT = "config.height"
Expand Down
Loading
Loading