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

feat: Support orientation change for session replay #4194

Merged
merged 64 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
4412fcc
wip
brustolin Jul 9, 2024
fcb7c9e
wip
brustolin Jul 9, 2024
709c576
wip
brustolin Jul 15, 2024
1f9d3e0
Merge branch 'main' into feat(SR)/replay-for-crashes
brustolin Jul 15, 2024
b172c30
wip
brustolin Jul 15, 2024
05d2879
Is working
brustolin Jul 16, 2024
b657a92
Merge branch 'main' into feat(SR)/replay-for-crashes
brustolin Jul 16, 2024
30fab87
Update CHANGELOG.md
brustolin Jul 16, 2024
412e5c4
Format code
getsentry-bot Jul 16, 2024
a5f57e3
lint
brustolin Jul 16, 2024
5a83ddc
Merge branch 'feat(SR)/replay-for-crashes' of https://github.com/gets…
brustolin Jul 16, 2024
f261481
Update CHANGELOG.md
brustolin Jul 16, 2024
d01cbaa
Update SentrySessionReplayTests.swift
brustolin Jul 17, 2024
dd6958a
Update SentryOptionsTest.m
brustolin Jul 17, 2024
0a092a0
test replayintegration
brustolin Jul 18, 2024
92078df
Format code
getsentry-bot Jul 18, 2024
488ce82
lint
brustolin Jul 18, 2024
a97bb4e
Merge branch 'feat(SR)/replay-for-crashes' of https://github.com/gets…
brustolin Jul 18, 2024
bbc5240
Merge branch 'main' into feat(SR)/replay-for-crashes
brustolin Jul 23, 2024
721cf13
Merge branch 'feat(SR)/replay-for-crashes' into feat/support-size-change
brustolin Jul 23, 2024
8a219c5
Merge branch 'main' into feat(SR)/replay-for-crashes
brustolin Jul 23, 2024
936f00c
Apply suggestions from code review
brustolin Jul 24, 2024
96dba1f
Format code
getsentry-bot Jul 24, 2024
c72b8db
wip
brustolin Jul 24, 2024
44f4343
some fixes
brustolin Jul 24, 2024
d3c9998
refs
brustolin Jul 24, 2024
ebb769a
Format code
getsentry-bot Jul 24, 2024
1c27e31
respect sample rate
brustolin Jul 24, 2024
d83f4ee
Update SentryOnDemandReplayTests.swift
brustolin Jul 24, 2024
f529724
Merge branch 'main' into feat/support-size-change
brustolin Jul 24, 2024
ae02841
Update CHANGELOG.md
brustolin Jul 24, 2024
e764f02
Format code
getsentry-bot Jul 24, 2024
357f3e9
session reset
brustolin Jul 25, 2024
ca690b2
Merge branch 'main' into feat/support-size-change
brustolin Jul 25, 2024
e13488d
Merge branch 'main' into feat(SR)/replay-for-crashes
brustolin Jul 25, 2024
bc3affe
Merge branch 'feat(SR)/replay-for-crashes' into feat/support-size-change
brustolin Jul 25, 2024
cd1661d
Update CHANGELOG.md
brustolin Jul 25, 2024
72aa5ef
Update SentrySessionReplayIntegration.m
brustolin Jul 25, 2024
d429316
Update AppDelegate.swift
brustolin Jul 25, 2024
db270a3
Update SentryOnDemandReplay.swift
brustolin Jul 25, 2024
5355385
ref
brustolin Jul 25, 2024
6b9f527
Format code
getsentry-bot Jul 25, 2024
023f0e0
Merge branch 'feat(SR)/replay-for-crashes' into feat/support-size-change
brustolin Jul 25, 2024
0860e15
Update SentryOnDemandReplayTests.swift
brustolin Jul 25, 2024
59f4beb
Merge branch 'feat/support-size-change' of https://github.com/getsent…
brustolin Jul 25, 2024
49fa6ab
Update SentryOnDemandReplay.swift
brustolin Jul 25, 2024
98d477f
ondemand replay improvement
brustolin Jul 26, 2024
bd1403d
Remove file for test
brustolin Jul 26, 2024
a534e2f
Update SentryOnDemandReplay.swift
brustolin Jul 26, 2024
f5cacda
Update SentryOnDemandReplay.swift
brustolin Jul 26, 2024
6118782
async test
brustolin Jul 26, 2024
a183399
Merge branch 'main' into feat/support-size-change
brustolin Jul 29, 2024
762e6bc
Update SentryOnDemandReplay.swift
brustolin Jul 29, 2024
5359893
Merge branch 'main' into feat/support-size-change
brustolin Jul 31, 2024
4b0ec85
Update Sources/Sentry/SentrySessionReplayIntegration.m
brustolin Jul 31, 2024
9c50e4f
Apply suggestions from code review
brustolin Aug 1, 2024
29037f0
fixes
brustolin Aug 1, 2024
857b9d8
Merge branch 'feat/support-size-change' of https://github.com/getsent…
brustolin Aug 1, 2024
3ce6e99
Merge branch 'main' into feat/support-size-change
brustolin Aug 1, 2024
5b3f454
chore
brustolin Aug 1, 2024
6d99f45
Format code
getsentry-bot Aug 1, 2024
028bfbb
Update Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.…
brustolin Aug 1, 2024
b6e226c
fix
brustolin Aug 2, 2024
748b30b
Update SentrySessionReplayIntegration.m
brustolin Aug 5, 2024
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Support orientation change for session replay (#4194)
- Replay for crashes (#4171)
- Redact web view from replay (#4203)
- Add beforeCaptureViewHierarchy callback (#4210)
Expand All @@ -18,6 +19,10 @@
### Fixes

- Session replay crash when writing the replay (#4186)

### Features

- Replay for crashes (#4171)
brustolin marked this conversation as resolved.
Show resolved Hide resolved
- Collect only unique UIWindow references (#4159)

### Deprecated
Expand Down
61 changes: 32 additions & 29 deletions Sources/Sentry/SentrySessionReplayIntegration.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# import "SentryClient+Private.h"
# import "SentryDependencyContainer.h"
# import "SentryDispatchQueueWrapper.h"
# import "SentryDisplayLinkWrapper.h"
# import "SentryEvent+Private.h"
# import "SentryFileManager.h"
Expand All @@ -21,7 +22,6 @@
# import "SentrySwizzle.h"
# import "SentryUIApplication.h"
# import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

static NSString *SENTRY_REPLAY_FOLDER = @"replay";
Expand All @@ -42,6 +42,7 @@ @implementation SentrySessionReplayIntegration {
BOOL _startedAsFullSession;
SentryReplayOptions *_replayOptions;
SentryNSNotificationCenterWrapper *_notificationCenter;
SentryDispatchQueueWrapper *_dispatchQueue;
SentryOnDemandReplay *_resumeReplayMaker;
}

Expand Down Expand Up @@ -114,39 +115,41 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event
}
}

_resumeReplayMaker = [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path];
_resumeReplayMaker.bitRate = _replayOptions.replayBitRate;
_resumeReplayMaker.videoScale = _replayOptions.sizeScale;
SentryOnDemandReplay *resumeReplayMaker =
[[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path];
resumeReplayMaker.bitRate = _replayOptions.replayBitRate;
resumeReplayMaker.videoScale = _replayOptions.sizeScale;

NSDate *beginning = hasCrashInfo
? [NSDate dateWithTimeIntervalSinceReferenceDate:crashInfo.lastSegmentEnd]
: [_resumeReplayMaker oldestFrameDate];
: [resumeReplayMaker oldestFrameDate];

if (beginning == nil) {
return; // no frames to send
}

NSError *error;
if (![_resumeReplayMaker
_dispatchQueue = [[SentryDispatchQueueWrapper alloc] init];
brustolin marked this conversation as resolved.
Show resolved Hide resolved

[_dispatchQueue dispatchAsyncWithBlock:^{
SentryReplayType _type = type;
int _segmentId = segmentId;

NSError *error;
NSArray<SentryVideoInfo *> *videos = [resumeReplayMaker
createVideoWithBeginning:beginning
end:[beginning dateByAddingTimeInterval:duration]
outputFileURL:[lastReplayURL URLByAppendingPathComponent:@"lastVideo.mp4"]
error:&error
completion:^(SentryVideoInfo *video, NSError *renderError) {
if (renderError != nil) {
SENTRY_LOG_ERROR(
@"Could not create replay video: %@", renderError);
} else {
[self captureVideo:video
replayId:replayId
segmentId:segmentId
type:type];
}
self->_resumeReplayMaker = nil;
}]) {
SENTRY_LOG_ERROR(@"Could not create replay video: %@", error);
return;
}
error:&error];
if (videos == nil) {
brustolin marked this conversation as resolved.
Show resolved Hide resolved
SENTRY_LOG_ERROR(@"Could not create replay video: %@", error);
return;
}

brustolin marked this conversation as resolved.
Show resolved Hide resolved
for (SentryVideoInfo *video in videos) {
[self captureVideo:video replayId:replayId segmentId:_segmentId++ type:_type];
// type buffer is only for the first segment
_type = SentryReplayTypeSession;
}
}];

NSMutableDictionary *eventContext = event.context.mutableCopy;
eventContext[@"replay"] =
Expand Down Expand Up @@ -174,13 +177,15 @@ - (void)captureVideo:(SentryVideoInfo *)video

NSError *error = nil;
if (![[NSFileManager defaultManager] removeItemAtURL:video.path error:&error]) {
NSLog(@"[SentrySessionReplay:%d] Could not delete replay segment from disk: %@", __LINE__,
error.localizedDescription);
SENTRY_LOG_DEBUG(
@"Could not delete replay segment from disk: %@", error.localizedDescription);
}
}

- (void)startSession
{
[self.sessionReplay stop];

_startedAsFullSession = [self shouldReplayFullSession:_replayOptions.sessionSampleRate];

if (!_startedAsFullSession && _replayOptions.errorSampleRate == 0) {
Expand Down Expand Up @@ -247,6 +252,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions
touchTracker:_touchTracker
dateProvider:SentryDependencyContainer.sharedInstance.dateProvider
delegate:self
dispatchQueue:[[SentryDispatchQueueWrapper alloc] init]
brustolin marked this conversation as resolved.
Show resolved Hide resolved
displayLinkWrapper:[[SentryDisplayLinkWrapper alloc] init]];

[self.sessionReplay
Expand Down Expand Up @@ -320,9 +326,6 @@ - (void)sentrySessionEnded:(SentrySession *)session

- (void)sentrySessionStarted:(SentrySession *)session
{
if (_sessionReplay) {
return;
}
[self startSession];
}

Expand Down
173 changes: 97 additions & 76 deletions Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,16 @@ struct SentryReplayFrame {
let screenName: String?
}

private struct VideoFrames {
let framesPaths: [String]
let screens: [String]
let start: Date
let end: Date
}

enum SentryOnDemandReplayError: Error {
case cantReadVideoSize
case assetWriterNotReady
case cantCreatePixelBuffer
case errorRenderingVideo
}

@objcMembers
class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker {

private let _outputPath: String
private var _currentPixelBuffer: SentryPixelBuffer?
private var _totalFrames = 0
private let dateProvider: SentryCurrentDateProvider
private let workingQueue: SentryDispatchQueueWrapper
Expand All @@ -42,16 +35,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker {
set { _frames = newValue }
}
#endif // TEST || TESTCI || DEBUG

var videoWidth = 200
var videoHeight = 434
var videoScale: Float = 1
var bitRate = 20_000
var frameRate = 1
var cacheMaxSize = UInt.max

private var actualWidth: Int { Int(Float(videoWidth) * videoScale) }
private var actualHeight: Int { Int(Float(videoHeight) * videoScale) }

init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) {
self._outputPath = outputPath
Expand Down Expand Up @@ -85,10 +72,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker {
self.init(withContentFrom: outputPath,
workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil),
dateProvider: SentryCurrentDateProvider())

guard let last = _frames.last, let image = UIImage(contentsOfFile: last.imagePath) else { return }
videoWidth = Int(image.size.width)
videoHeight = Int(image.size.height)
}

func addFrameAsync(image: UIImage, forScreen: String?) {
Expand Down Expand Up @@ -140,92 +123,130 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker {
return _frames.first?.time
}

func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws {
var frameCount = 0
func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] {
let videoFrames = filterFrames(beginning: beginning, end: end)
if videoFrames.framesPaths.isEmpty { return }
var frameCount = 0

var videos = [SentryVideoInfo]()

while frameCount < videoFrames.count {
let outputFileURL = URL(fileURLWithPath: _outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4"))
if let info = try renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) {
brustolin marked this conversation as resolved.
Show resolved Hide resolved
videos.append(info)
} else {
frameCount++
}
}
return videos
}

private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout Int, at outputFileURL: URL) throws -> SentryVideoInfo? {
guard from < videoFrames.count, let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { return nil }
let videoWidth = image.size.width * CGFloat(videoScale)
let videoHeight = image.size.height * CGFloat(videoScale)

let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4)
let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings())
let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings(width: videoWidth, height: videoHeight))

_currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: actualWidth, height: actualHeight), videoWriterInput: videoWriterInput)
if _currentPixelBuffer == nil { return }
guard let currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput)
else { throw SentryOnDemandReplayError.cantCreatePixelBuffer }

videoWriter.add(videoWriterInput)
videoWriter.startWriting()
videoWriter.startSession(atSourceTime: .zero)

var lastImageSize: CGSize = image.size
var usedFrames = [SentryReplayFrame]()
let group = DispatchGroup()

var result: Result<SentryVideoInfo?, Error>?
var frameCount = from

group.enter()
videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { [weak self] in
guard let self = self, videoWriter.status == .writing else {
videoWriter.cancelWriting()
completion(nil, SentryOnDemandReplayError.assetWriterNotReady)
result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo )
group.leave()
brustolin marked this conversation as resolved.
Show resolved Hide resolved
return
}

if frameCount < videoFrames.framesPaths.count {
let imagePath = videoFrames.framesPaths[frameCount]
if let image = UIImage(contentsOfFile: imagePath) {
let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate))

guard self._currentPixelBuffer?.append(image: image, presentationTime: presentTime) == true
else {
completion(nil, videoWriter.error)
videoWriterInput.markAsFinished()
return
}
if frameCount >= videoFrames.count {
result = self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter)
group.leave()
return
}

let frame = videoFrames[frameCount]
if let image = UIImage(contentsOfFile: frame.imagePath) {
if lastImageSize != image.size {
result = self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter)
group.leave()
return
}
frameCount += 1
} else {
videoWriterInput.markAsFinished()
videoWriter.finishWriting {
var videoInfo: SentryVideoInfo?
if videoWriter.status == .completed {
do {
let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path)
guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else {
completion(nil, SentryOnDemandReplayError.cantReadVideoSize)
return
}
videoInfo = SentryVideoInfo(path: outputFileURL, height: self.actualHeight, width: self.actualWidth, duration: TimeInterval(videoFrames.framesPaths.count / self.frameRate), frameCount: videoFrames.framesPaths.count, frameRate: self.frameRate, start: videoFrames.start, end: videoFrames.end, fileSize: fileSize, screens: videoFrames.screens)
} catch {
completion(nil, error)
}
}
completion(videoInfo, videoWriter.error)
lastImageSize = image.size

let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate))

if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true {
videoWriter.cancelWriting()
result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo )
group.leave()
return
}
usedFrames.append(frame)
}
frameCount += 1
}
group.wait()
brustolin marked this conversation as resolved.
Show resolved Hide resolved
from = frameCount

return try result?.get()
}

private func filterFrames(beginning: Date, end: Date) -> VideoFrames {
var framesPaths = [String]()

var screens = [String]()
private func finishVideo(outputFileURL: URL, usedFrames: [SentryReplayFrame], videoHeight: Int, videoWidth: Int, videoWriter: AVAssetWriter) -> Result<SentryVideoInfo?, Error> {
let group = DispatchGroup()
var finishError: Error?
var result: SentryVideoInfo?

var start = dateProvider.date()
var actualEnd = start
workingQueue.dispatchSync({
for frame in self._frames {
if frame.time < beginning { continue } else if frame.time > end { break }

if frame.time < start { start = frame.time }

if let screenName = frame.screenName {
screens.append(screenName)
group.enter()
videoWriter.inputs.forEach { $0.markAsFinished() }
videoWriter.finishWriting {
defer { group.leave() }
if videoWriter.status == .completed {
do {
let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path)
guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else {
finishError = SentryOnDemandReplayError.cantReadVideoSize
return
}
guard let start = usedFrames.min(by: { $0.time < $1.time })?.time else { return }
let duration = TimeInterval(usedFrames.count / self.frameRate)
result = SentryVideoInfo(path: outputFileURL, height: Int(videoHeight), width: Int(videoWidth), duration: duration, frameCount: usedFrames.count, frameRate: self.frameRate, start: start, end: start.addingTimeInterval(duration), fileSize: fileSize, screens: usedFrames.compactMap({ $0.screenName }))
} catch {
finishError = error
}

actualEnd = frame.time
framesPaths.append(frame.imagePath)
}
}
group.wait()

if let finishError = finishError { return .failure(finishError) }
return .success(result)
}

private func filterFrames(beginning: Date, end: Date) -> [SentryReplayFrame] {
var frames = [SentryReplayFrame]()
workingQueue.dispatchSync({
brustolin marked this conversation as resolved.
Show resolved Hide resolved
frames = self._frames.filter { $0.time >= beginning && $0.time <= end }
})
return VideoFrames(framesPaths: framesPaths, screens: screens, start: start, end: actualEnd + TimeInterval((1 / Double(frameRate))))
return frames
}

private func createVideoSettings() -> [String: Any] {
private func createVideoSettings(width: CGFloat, height: CGFloat) -> [String: Any] {
return [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: actualWidth,
AVVideoHeightKey: actualHeight,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: bitRate,
AVVideoProfileLevelKey: AVVideoProfileLevelH264BaselineAutoLevel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@ import UIKit

@objc
protocol SentryReplayVideoMaker: NSObjectProtocol {
var videoWidth: Int { get set }
var videoHeight: Int { get set }

func addFrameAsync(image: UIImage, forScreen: String?)
func releaseFramesUntil(_ date: Date)
func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws
func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo]
}

extension SentryReplayVideoMaker {
Expand Down
Loading
Loading