Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cherry-pick: Session Replay: Fix various crashes and issues (#4135) #4145

Merged
merged 2 commits into from
Feb 7, 2025
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

### Fixes

- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135))
- Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file
- Fix `IllegalStateException` when registering `onDrawListener`
- Fix SIGABRT native crashes on Motorola devices when encoding a video
- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029))
- This allows Composables that use this modifier to be skippable
- This allows Composables that use this modifier to be skippable

## 7.21.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import io.sentry.transport.ICurrentDateProvider;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -19,7 +18,6 @@
final class LifecycleWatcher implements DefaultLifecycleObserver {

private final AtomicLong lastUpdatedSession = new AtomicLong(0L);
private final AtomicBoolean isFreshSession = new AtomicBoolean(false);

private final long sessionIntervalMillis;

Expand Down Expand Up @@ -80,7 +78,6 @@ private void startSession() {
final @Nullable Session currentSession = scope.getSession();
if (currentSession != null && currentSession.getStarted() != null) {
lastUpdatedSession.set(currentSession.getStarted().getTime());
isFreshSession.set(true);
}
}
});
Expand All @@ -92,11 +89,8 @@ private void startSession() {
hub.startSession();
}
hub.getOptions().getReplayController().start();
} else if (!isFreshSession.get()) {
// only resume if it's not a fresh session, which has been started in SentryAndroid.init
hub.getOptions().getReplayController().resume();
}
isFreshSession.set(false);
hub.getOptions().getReplayController().resume();
this.lastUpdatedSession.set(currentTimeMillis);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ class LifecycleWatcherTest {
}

@Test
fun `if the hub has already a fresh session running, doesn't resume replay`() {
fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() {
val watcher = fixture.getSUT(
enableAppLifecycleBreadcrumbs = false,
session = Session(
Expand All @@ -276,7 +276,7 @@ class LifecycleWatcherTest {
)

watcher.onStart(fixture.ownerMock)
verify(fixture.replayController, never()).resume()
verify(fixture.replayController).resume()
}

@Test
Expand All @@ -293,7 +293,7 @@ class LifecycleWatcherTest {
verify(fixture.replayController).pause()

watcher.onStart(fixture.ownerMock)
verify(fixture.replayController).resume()
verify(fixture.replayController, times(2)).resume()

watcher.onStop(fixture.ownerMock)
verify(fixture.replayController, timeout(10000)).stop()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class ReplayCache(
internal val frames = mutableListOf<ReplayFrame>()

private val ongoingSegment = LinkedHashMap<String, String>()
private val ongoingSegmentFile: File? by lazy {
internal val ongoingSegmentFile: File? by lazy {
if (replayCacheDir == null) {
return@lazy null
}
Expand Down Expand Up @@ -273,6 +273,9 @@ public class ReplayCache(
if (isClosed.get()) {
return
}
if (ongoingSegmentFile?.exists() != true) {
ongoingSegmentFile?.createNewFile()
}
if (ongoingSegment.isEmpty()) {
ongoingSegmentFile?.useLines { lines ->
lines.associateTo(ongoingSegment) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.android.replay.ReplayState.CLOSED
import io.sentry.android.replay.ReplayState.PAUSED
import io.sentry.android.replay.ReplayState.RESUMED
import io.sentry.android.replay.ReplayState.STARTED
import io.sentry.android.replay.ReplayState.STOPPED
import io.sentry.android.replay.capture.BufferCaptureStrategy
import io.sentry.android.replay.capture.CaptureStrategy
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
Expand Down Expand Up @@ -100,15 +105,15 @@ public class ReplayIntegration(
Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
}

// TODO: probably not everything has to be thread-safe here
internal val isEnabled = AtomicBoolean(false)
private val isRecording = AtomicBoolean(false)
internal val isManualPause = AtomicBoolean(false)
private var captureStrategy: CaptureStrategy? = null
public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir
private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance()
private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null
private var mainLooperHandler: MainLooperHandler = MainLooperHandler()
private var gestureRecorderProvider: (() -> GestureRecorder)? = null
private val lifecycle = ReplayLifecycle()

override fun register(hub: IHub, options: SentryOptions) {
this.options = options
Expand Down Expand Up @@ -151,15 +156,15 @@ public class ReplayIntegration(
finalizePreviousReplay()
}

override fun isRecording() = isRecording.get()
override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED

@Synchronized
override fun start() {
// TODO: add lifecycle state instead and manage it in start/pause/resume/stop
if (!isEnabled.get()) {
return
}

if (isRecording.getAndSet(true)) {
if (!lifecycle.isAllowed(STARTED)) {
options.logger.log(
DEBUG,
"Session replay is already being recorded, not starting a new one"
Expand All @@ -183,19 +188,35 @@ public class ReplayIntegration(
captureStrategy?.start(recorderConfig)
recorder?.start(recorderConfig)
registerRootViewListeners()
lifecycle.currentState = STARTED
}

override fun resume() {
if (!isEnabled.get() || !isRecording.get()) {
isManualPause.set(false)
resumeInternal()
}

@Synchronized
private fun resumeInternal() {
if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) {
return
}

if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED ||
hub?.rateLimiter?.isActiveForCategory(All) == true ||
hub?.rateLimiter?.isActiveForCategory(Replay) == true
) {
return
}

captureStrategy?.resume()
recorder?.resume()
lifecycle.currentState = RESUMED
}

@Synchronized
override fun captureReplay(isTerminating: Boolean?) {
if (!isEnabled.get() || !isRecording.get()) {
if (!isEnabled.get() || !isRecording()) {
return
}

Expand All @@ -220,25 +241,33 @@ public class ReplayIntegration(
override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter

override fun pause() {
if (!isEnabled.get() || !isRecording.get()) {
isManualPause.set(true)
pauseInternal()
}

@Synchronized
private fun pauseInternal() {
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {
return
}

recorder?.pause()
captureStrategy?.pause()
lifecycle.currentState = PAUSED
}

@Synchronized
override fun stop() {
if (!isEnabled.get() || !isRecording.get()) {
if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) {
return
}

unregisterRootViewListeners()
recorder?.stop()
gestureRecorder?.stop()
captureStrategy?.stop()
isRecording.set(false)
captureStrategy = null
lifecycle.currentState = STOPPED
}

override fun onScreenshotRecorded(bitmap: Bitmap) {
Expand All @@ -257,8 +286,9 @@ public class ReplayIntegration(
}
}

@Synchronized
override fun close() {
if (!isEnabled.get()) {
if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) {
return
}

Expand All @@ -275,10 +305,11 @@ public class ReplayIntegration(
recorder = null
rootViewsSpy.close()
replayExecutor.gracefullyShutdown(options)
lifecycle.currentState = CLOSED
}

override fun onConfigurationChanged(newConfig: Configuration) {
if (!isEnabled.get() || !isRecording.get()) {
if (!isEnabled.get() || !isRecording()) {
return
}

Expand All @@ -289,6 +320,10 @@ public class ReplayIntegration(
captureStrategy?.onConfigurationChanged(recorderConfig)

recorder?.start(recorderConfig)
// we have to restart recorder with a new config and pause immediately if the replay is paused
if (lifecycle.currentState == PAUSED) {
recorder?.pause()
}
}

override fun onConnectionStatusChanged(status: ConnectionStatus) {
Expand All @@ -298,10 +333,10 @@ public class ReplayIntegration(
}

if (status == DISCONNECTED) {
pause()
pauseInternal()
} else {
// being positive for other states, even if it's NO_PERMISSION
resume()
resumeInternal()
}
}

Expand All @@ -312,15 +347,18 @@ public class ReplayIntegration(
}

if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) {
pause()
pauseInternal()
} else {
resume()
resumeInternal()
}
}

override fun onLowMemory() = Unit

override fun onTouchEvent(event: MotionEvent) {
if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) {
return
}
captureStrategy?.onTouchEvent(event)
}

Expand All @@ -336,7 +374,7 @@ public class ReplayIntegration(
hub?.rateLimiter?.isActiveForCategory(Replay) == true
)
) {
pause()
pauseInternal()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.sentry.android.replay

internal enum class ReplayState {
/**
* Initial state of a Replay session. This is the state when ReplayIntegration is constructed
* but has not been started yet.
*/
INITIAL,

/**
* Started state for a Replay session. This state is reached after the start() method is called
* and the recording is initialized successfully.
*/
STARTED,

/**
* Resumed state for a Replay session. This state is reached after resume() is called on an
* already started recording.
*/
RESUMED,

/**
* Paused state for a Replay session. This state is reached after pause() is called on a
* resumed recording.
*/
PAUSED,

/**
* Stopped state for a Replay session. This state is reached after stop() is called.
* The recording can be started again from this state.
*/
STOPPED,

/**
* Closed state for a Replay session. This is the terminal state reached after close() is called.
* No further state transitions are possible after this.
*/
CLOSED;
}

/**
* Class to manage state transitions for ReplayIntegration
*/
internal class ReplayLifecycle {
@field:Volatile
internal var currentState = ReplayState.INITIAL

fun isAllowed(newState: ReplayState): Boolean = when (currentState) {
ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED
ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED
ReplayState.CLOSED -> false
}

fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.view.MotionEvent
import io.sentry.Breadcrumb
import io.sentry.DateUtils
import io.sentry.IHub
import io.sentry.SentryLevel.ERROR
import io.sentry.SentryOptions
import io.sentry.SentryReplayEvent.ReplayType
import io.sentry.SentryReplayEvent.ReplayType.BUFFER
Expand Down Expand Up @@ -183,7 +184,11 @@ internal abstract class BaseCaptureStrategy(
task()
}
} else {
task()
try {
task()
} catch (e: Throwable) {
options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e)
}
}
}

Expand Down
Loading
Loading