Skip to content

Commit

Permalink
feat: Pause replay in session mode when offline (#4264)
Browse files Browse the repository at this point in the history
If the device has no connection we stop trying to send segments.
We do capture replay for errors,
  • Loading branch information
brustolin authored Aug 13, 2024
1 parent 80f2f39 commit 5c23b77
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Pause replay in session mode when offline (#4264)
- Add replay quality option for Objective-C (#4267)

## 8.33.0
Expand Down
19 changes: 17 additions & 2 deletions Sources/Sentry/SentrySessionReplayIntegration.m
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# import "SentryNSNotificationCenterWrapper.h"
# import "SentryOptions.h"
# import "SentryRandom.h"
# import "SentryReachability.h"
# import "SentrySDK+Private.h"
# import "SentryScope+Private.h"
# import "SentrySerialization.h"
Expand All @@ -22,6 +23,7 @@
# import "SentrySwizzle.h"
# import "SentryUIApplication.h"
# import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

static NSString *SENTRY_REPLAY_FOLDER = @"replay";
Expand All @@ -34,7 +36,7 @@
static SentryTouchTracker *_touchTracker;

@interface
SentrySessionReplayIntegration ()
SentrySessionReplayIntegration () <SentryReachabilityObserver>
- (void)newSceneActivate;
@end

Expand Down Expand Up @@ -74,6 +76,7 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options
return event;
}];

[SentryDependencyContainer.sharedInstance.reachability addObserver:self];
[SentryViewPhotographer.shared addIgnoreClasses:_replayOptions.ignoreRedactViewTypes];
[SentryViewPhotographer.shared addRedactClasses:_replayOptions.redactViewTypes];

Expand Down Expand Up @@ -425,7 +428,7 @@ - (SentryTouchTracker *)getTouchTracker

# pragma mark - SessionReplayDelegate

- (BOOL)sessionReplayIsFullSession
- (BOOL)sessionReplayShouldCaptureReplayForError
{
return SentryDependencyContainer.sharedInstance.random.nextNumber
<= _replayOptions.onErrorSampleRate;
Expand Down Expand Up @@ -464,6 +467,18 @@ - (nullable NSString *)currentScreenNameForSessionReplay
.firstObject;
}

# pragma mark - SentryReachabilityObserver

- (void)connectivityChanged:(BOOL)connected typeDescription:(nonnull NSString *)typeDescription
{

if (connected) {
[_sessionReplay resume];
} else {
[_sessionReplay pause];
}
}

@end

NS_ASSUME_NONNULL_END
Expand Down
53 changes: 36 additions & 17 deletions Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ enum SessionReplayError: Error {

@objc
protocol SentrySessionReplayDelegate: NSObjectProtocol {
func sessionReplayIsFullSession() -> Bool
func sessionReplayShouldCaptureReplayForError() -> Bool
func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL)
func sessionReplayStarted(replayId: SentryId)
func breadcrumbsForSessionReplay() -> [Breadcrumb]
Expand All @@ -32,6 +32,7 @@ class SentrySessionReplay: NSObject {
private var currentSegmentId = 0
private var processingScreenshot = false
private var reachedMaximumDuration = false
private(set) var isSessionPaused = false

private let replayOptions: SentryReplayOptions
private let replayMaker: SentryReplayVideoMaker
Expand Down Expand Up @@ -70,6 +71,8 @@ class SentrySessionReplay: NSObject {
self.breadcrumbConverter = breadcrumbConverter
self.touchTracker = touchTracker
}

deinit { displayLink.invalidate() }

func start(rootView: UIView, fullSession: Bool) {
guard !isRunning else { return }
Expand All @@ -93,26 +96,41 @@ class SentrySessionReplay: NSObject {
delegate?.sessionReplayStarted(replayId: sessionReplayId)
}

func pause() {
lock.lock()
defer { lock.unlock() }

self.isSessionPaused = true
self.videoSegmentStart = nil
}

func stop() {
lock.lock()
defer { lock.unlock() }

displayLink.invalidate()
prepareSegmentUntil(date: dateProvider.date())
if isFullSession {
prepareSegmentUntil(date: dateProvider.date())
}
isSessionPaused = false
}

func resume() {
guard !reachedMaximumDuration else { return }

lock.lock()
defer { lock.unlock() }

if isSessionPaused {
isSessionPaused = false
return
}

guard !reachedMaximumDuration else { return }
guard !isRunning else { return }

videoSegmentStart = nil
displayLink.link(withTarget: self, selector: #selector(newFrame(_:)))
}

deinit {
displayLink.invalidate()
}

func captureReplayFor(event: Event) {
guard isRunning else { return }

Expand All @@ -132,15 +150,14 @@ class SentrySessionReplay: NSObject {
guard isRunning else { return false }
guard !isFullSession else { return true }

guard delegate?.sessionReplayIsFullSession() == true else {
guard delegate?.sessionReplayShouldCaptureReplayForError() == true else {
return false
}

startFullReplay()
let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0))

createAndCapture(startedAt: replayStart)

createAndCapture(startedAt: replayStart, replayType: .buffer)
return true
}

Expand All @@ -160,7 +177,9 @@ class SentrySessionReplay: NSObject {

@objc
private func newFrame(_ sender: CADisplayLink) {
guard let lastScreenShot = lastScreenShot, isRunning else { return }
guard let lastScreenShot = lastScreenShot, isRunning &&
!(isFullSession && isSessionPaused) //If replay is in session mode but it is paused we dont take screenshots
else { return }

let now = dateProvider.date()

Expand Down Expand Up @@ -199,28 +218,28 @@ class SentrySessionReplay: NSObject {
pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4")
let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration)

createAndCapture(startedAt: segmentStart)
createAndCapture(startedAt: segmentStart, replayType: .session)
}

private func createAndCapture(startedAt: Date) {
private func createAndCapture(startedAt: Date, replayType: SentryReplayType) {
//Creating a video is heavy and blocks the thread
//Since this function is always called in the main thread
//we dispatch it to a background thread.
dispatchQueue.dispatchAsync {
do {
let videos = try self.replayMaker.createVideoWith(beginning: startedAt, end: self.dateProvider.date())
for video in videos {
self.newSegmentAvailable(videoInfo: video)
self.newSegmentAvailable(videoInfo: video, replayType: replayType)
}
} catch {
SentryLog.debug("Could not create replay video - \(error.localizedDescription)")
}
}
}

private func newSegmentAvailable(videoInfo: SentryVideoInfo) {
private func newSegmentAvailable(videoInfo: SentryVideoInfo, replayType: SentryReplayType) {
guard let sessionReplayId = sessionReplayId else { return }
captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: .session)
captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: replayType)
replayMaker.releaseFramesUntil(videoInfo.end)
videoSegmentStart = videoInfo.end
currentSegmentId++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase {

override func setUp() {
SentryDependencyContainer.sharedInstance().application = uiApplication
SentryDependencyContainer.sharedInstance().reachability = TestSentryReachability()
}

override func tearDown() {
Expand Down Expand Up @@ -273,6 +274,15 @@ class SentrySessionReplayIntegrationTests: XCTestCase {
XCTAssertEqual(hub.capturedReplayRecordingVideo.count, 0)
}

func testPauseSessionReplayWithReacheability() throws {
startSDK(sessionSampleRate: 1, errorSampleRate: 0)
let sut = try getSut()
(sut as? SentryReachabilityObserver)?.connectivityChanged(false, typeDescription: "")
XCTAssertTrue(sut.sessionReplay.isSessionPaused)
(sut as? SentryReachabilityObserver)?.connectivityChanged(true, typeDescription: "")
XCTAssertFalse(sut.sessionReplay.isSessionPaused)
}

func testMaskViewFromSDK() {
class AnotherLabel: UILabel {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class SentrySessionReplayTests: XCTestCase {
displayLinkWrapper: displayLink)
}

func sessionReplayIsFullSession() -> Bool {
func sessionReplayShouldCaptureReplayForError() -> Bool {
return isFullSession
}

Expand Down Expand Up @@ -251,6 +251,69 @@ class SentrySessionReplayTests: XCTestCase {
XCTAssertNotNil(fixture.screenshotProvider.lastImageCall)
}

func testPauseResume_FullSession() {
let fixture = Fixture()

let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1))
sut.start(rootView: fixture.rootView, fullSession: true)

fixture.dateProvider.advance(by: 1)
Dynamic(sut).newFrame(nil)
XCTAssertNotNil(fixture.screenshotProvider.lastImageCall)
sut.pause()
fixture.screenshotProvider.lastImageCall = nil

fixture.dateProvider.advance(by: 1)
Dynamic(sut).newFrame(nil)
XCTAssertNil(fixture.screenshotProvider.lastImageCall)

fixture.dateProvider.advance(by: 4)
Dynamic(sut).newFrame(nil)
XCTAssertNil(fixture.replayMaker.lastCallToCreateVideo)

sut.resume()

fixture.dateProvider.advance(by: 1)
Dynamic(sut).newFrame(nil)
XCTAssertNotNil(fixture.screenshotProvider.lastImageCall)

fixture.dateProvider.advance(by: 5)
Dynamic(sut).newFrame(nil)
XCTAssertNotNil(fixture.replayMaker.lastCallToCreateVideo)
}

func testPause_BufferSession() {
let fixture = Fixture()

let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1))
sut.start(rootView: fixture.rootView, fullSession: false)

fixture.dateProvider.advance(by: 1)

Dynamic(sut).newFrame(nil)
XCTAssertNotNil(fixture.screenshotProvider.lastImageCall)
sut.pause()
fixture.screenshotProvider.lastImageCall = nil

fixture.dateProvider.advance(by: 1)
Dynamic(sut).newFrame(nil)
XCTAssertNotNil(fixture.screenshotProvider.lastImageCall)

fixture.dateProvider.advance(by: 4)
Dynamic(sut).newFrame(nil)

let event = Event(error: NSError(domain: "Some error", code: 1))
sut.captureReplayFor(event: event)

XCTAssertNotNil(fixture.replayMaker.lastCallToCreateVideo)

//After changing to session mode the replay should pause
fixture.screenshotProvider.lastImageCall = nil
fixture.dateProvider.advance(by: 1)
Dynamic(sut).newFrame(nil)
XCTAssertNil(fixture.screenshotProvider.lastImageCall)
}

@available(iOS 16.0, tvOS 16, *)
func testDealloc_CallsStop() {
let fixture = Fixture()
Expand Down

0 comments on commit 5c23b77

Please sign in to comment.