Skip to content

Commit

Permalink
fix: Replay Duration (#3866)
Browse files Browse the repository at this point in the history
The end of the segment was equal the time the last frame was taken, by the end should be the last frame date plus the duration of the frame
  • Loading branch information
brustolin authored Apr 25, 2024
1 parent 1cafec7 commit 0cc5400
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 45 deletions.
6 changes: 6 additions & 0 deletions Sentry.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@
D8ACE3CD2762187D00F5A213 /* SentryNSDataSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CA2762187D00F5A213 /* SentryNSDataSwizzling.h */; };
D8ACE3CE2762187D00F5A213 /* SentryNSDataTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CB2762187D00F5A213 /* SentryNSDataTracker.h */; };
D8ACE3CF2762187D00F5A213 /* SentryFileIOTrackingIntegration.h in Headers */ = {isa = PBXBuildFile; fileRef = D8ACE3CC2762187D00F5A213 /* SentryFileIOTrackingIntegration.h */; };
D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */; };
D8B0542E2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D8B0542D2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy */; };
D8B088B629C9E3FF00213258 /* SentryTracerConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = D8B088B429C9E3FF00213258 /* SentryTracerConfiguration.h */; };
D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = D8B088B529C9E3FF00213258 /* SentryTracerConfiguration.m */; };
Expand Down Expand Up @@ -1887,6 +1888,7 @@
D8ACE3CA2762187D00F5A213 /* SentryNSDataSwizzling.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryNSDataSwizzling.h; path = include/SentryNSDataSwizzling.h; sourceTree = "<group>"; };
D8ACE3CB2762187D00F5A213 /* SentryNSDataTracker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryNSDataTracker.h; path = include/SentryNSDataTracker.h; sourceTree = "<group>"; };
D8ACE3CC2762187D00F5A213 /* SentryFileIOTrackingIntegration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryFileIOTrackingIntegration.h; path = include/SentryFileIOTrackingIntegration.h; sourceTree = "<group>"; };
D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplayTests.swift; sourceTree = "<group>"; };
D8B0542D2A7D2C720056BAF6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D8B088B429C9E3FF00213258 /* SentryTracerConfiguration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTracerConfiguration.h; path = include/SentryTracerConfiguration.h; sourceTree = "<group>"; };
D8B088B529C9E3FF00213258 /* SentryTracerConfiguration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryTracerConfiguration.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3512,6 +3514,7 @@
D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */,
D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */,
D861301B2BB5A267004C0F5E /* SentrySessionReplayTests.swift */,
D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */,
);
path = SessionReplay;
sourceTree = "<group>";
Expand Down Expand Up @@ -4763,6 +4766,7 @@
7B4260342630315C00B36EDD /* SampleError.swift in Sources */,
D855B3E827D652AF00BCED76 /* SentryCoreDataTrackingIntegrationTest.swift in Sources */,
D855AD62286ED6A4002573E1 /* SentryCrashTests.m in Sources */,
D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */,
0A9415BA28F96CAC006A5DD1 /* TestSentryReachability.swift in Sources */,
D880E3A728573E87008A90DB /* SentryBaggageTests.swift in Sources */,
7B16FD022654F86B008177D3 /* SentrySysctlTests.swift in Sources */,
Expand Down Expand Up @@ -5335,6 +5339,7 @@
);
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "-DCARTHAGE_$(CARTHAGE)";
OTHER_SWIFT_FLAGS = "-DTEST";
PRODUCT_BUNDLE_IDENTIFIER = io.sentry.Sentry;
PRODUCT_NAME = Sentry;
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -6205,6 +6210,7 @@
);
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "-DCARTHAGE_$(CARTHAGE)";
OTHER_SWIFT_FLAGS = "-DTEST";
PRODUCT_BUNDLE_IDENTIFIER = io.sentry.Sentry;
PRODUCT_NAME = Sentry;
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
8 changes: 7 additions & 1 deletion Sources/Sentry/SentryDispatchQueueWrapper.m
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ - (instancetype)init
return self;
}

- (instancetype)initWithName:(const char *)name attributes:(dispatch_queue_attr_t)attributes;
- (instancetype)initWithName:(const char *)name
attributes:(nullable dispatch_queue_attr_t)attributes;
{
if (self = [super init]) {
_queue = dispatch_queue_create(name, attributes);
Expand Down Expand Up @@ -47,6 +48,11 @@ - (void)dispatchOnMainQueue:(void (^)(void))block
[SentryThreadWrapper onMainThread:block];
}

- (void)dispatchSync:(void (^)(void))block
{
dispatch_sync(_queue, block);
}

- (void)dispatchSyncOnMainQueue:(void (^)(void))block
{
if ([NSThread isMainThread]) {
Expand Down
4 changes: 1 addition & 3 deletions Sources/Sentry/SentrySessionReplay.m
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,7 @@ - (void)takeScreenshot

_processingScreenshot = NO;

dispatch_queue_t backgroundQueue
= dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(backgroundQueue, ^{ [self->_replayMaker addFrameWithImage:screenshot]; });
[self->_replayMaker addFrameAsyncWithImage:screenshot];
}

@end
Expand Down
5 changes: 4 additions & 1 deletion Sources/Sentry/include/SentryDispatchQueueWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ NS_ASSUME_NONNULL_BEGIN

@property (strong, nonatomic) dispatch_queue_t queue;

- (instancetype)initWithName:(const char *)name attributes:(dispatch_queue_attr_t)attributes;
- (instancetype)initWithName:(const char *)name
attributes:(nullable dispatch_queue_attr_t)attributes;

- (void)dispatchAsyncWithBlock:(void (^)(void))block;

- (void)dispatchSync:(void (^)(void))block;

- (void)dispatchAsyncOnMainQueue:(void (^)(void))block
NS_SWIFT_NAME(dispatchAsyncOnMainQueue(block:));

Expand Down
2 changes: 1 addition & 1 deletion Sources/Sentry/include/SentrySessionReplay.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN

@protocol SentryReplayMaker <NSObject>

- (void)addFrameWithImage:(UIImage *)image;
- (void)addFrameAsyncWithImage:(UIImage *)image;
- (void)releaseFramesUntil:(NSDate *)date;
- (BOOL)createVideoWithDuration:(NSTimeInterval)duration
beginning:(NSDate *)beginning
Expand Down
90 changes: 52 additions & 38 deletions Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,40 +21,52 @@ enum SentryOnDemandReplayError: Error {
case cantReadVideoSize
}

@available(iOS 16.0, tvOS 16.0, *)
@objcMembers
class SentryOnDemandReplay: NSObject {
private let _outputPath: String
private let _onDemandDispatchQueue: DispatchQueue

private var _starttime = Date()
private var _frames = [SentryReplayFrame]()
private var _currentPixelBuffer: SentryPixelBuffer?
private var _totalFrames = 0
private let dateProvider: SentryCurrentDateProvider
private let workingQueue: SentryDispatchQueueWrapper
private var _frames = [SentryReplayFrame]()

#if TEST
//This is exposed only for tests, no need to make it thread safe.
var frames: [SentryReplayFrame] {
get { _frames }
set { _frames = newValue }
}
#endif

var videoWidth = 200
var videoHeight = 434

var bitRate = 20_000
var frameRate = 1
var cacheMaxSize = UInt.max

init(outputPath: String) {
convenience init(outputPath: String) {
self.init(outputPath: outputPath,
workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil),
dateProvider: SentryCurrentDateProvider())
}

init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) {
self._outputPath = outputPath
_onDemandDispatchQueue = DispatchQueue(label: "io.sentry.sessionreplay.ondemand")
self.dateProvider = dateProvider
self.workingQueue = workingQueue
}

func addFrame(image: UIImage) {
_onDemandDispatchQueue.async {
self.asyncAddFrame(image: image)
}
func addFrameAsync(image: UIImage) {
workingQueue.dispatchAsync({
self.addFrame(image: image)
})
}

private func asyncAddFrame(image: UIImage) {
private func addFrame(image: UIImage) {
guard let data = resizeImage(image, maxWidth: 300)?.pngData() else { return }

let date = Date()
let interval = date.timeIntervalSince(_starttime)
let imagePath = (_outputPath as NSString).appendingPathComponent("\(interval).png")
let date = dateProvider.date()
let imagePath = (_outputPath as NSString).appendingPathComponent("\(_totalFrames).png")
do {
try data.write(to: URL(fileURLWithPath: imagePath))
} catch {
Expand All @@ -67,6 +79,7 @@ class SentryOnDemandReplay: NSObject {
let first = _frames.removeFirst()
try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath))
}
_totalFrames += 1
}

private func resizeImage(_ originalImage: UIImage, maxWidth: CGFloat) -> UIImage? {
Expand All @@ -87,12 +100,12 @@ class SentryOnDemandReplay: NSObject {
}

func releaseFramesUntil(_ date: Date) {
_onDemandDispatchQueue.async {
workingQueue.dispatchAsync ({
while let first = self._frames.first, first.time < date {
self._frames.removeFirst()
try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath))
}
}
})
}

func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws {
Expand All @@ -112,17 +125,17 @@ class SentryOnDemandReplay: NSObject {
videoWriter.startSession(atSourceTime: .zero)

var frameCount = 0
let (frames, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration))
let (framesPaths, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration))

if frames.isEmpty { return }
if framesPaths.isEmpty { return }

_currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight))

videoWriterInput.requestMediaDataWhenReady(on: _onDemandDispatchQueue) { [weak self] in
videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { [weak self] in
guard let self = self else { return }

if frameCount < frames.count {
let imagePath = frames[frameCount]
if frameCount < framesPaths.count {
let imagePath = framesPaths[frameCount]

if let image = UIImage(contentsOfFile: imagePath) {
let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(self.frameRate))
Expand All @@ -144,7 +157,7 @@ class SentryOnDemandReplay: NSObject {
completion(nil, SentryOnDemandReplayError.cantReadVideoSize)
return
}
videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(frames.count / self.frameRate), frameCount: frames.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize)
videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(framesPaths.count / self.frameRate), frameCount: framesPaths.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize)
} catch {
completion(nil, error)
}
Expand All @@ -155,20 +168,21 @@ class SentryOnDemandReplay: NSObject {
}
}

private func filterFrames(beginning: Date, end: Date) -> ([String], firstFrame: Date, lastFrame: Date) {
var frames = [String]()

var start = Date()
var actualEnd = Date()

for frame in _frames {
if frame.time < beginning { continue } else if frame.time > end { break }
if frame.time < start { start = frame.time }

actualEnd = frame.time
frames.append(frame.imagePath)
}
return (frames, start, actualEnd)
private func filterFrames(beginning: Date, end: Date) -> ([String], start: Date, end: Date) {
var framesPaths = [String]()

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 }

actualEnd = frame.time
framesPaths.append(frame.imagePath)
}
})
return (framesPaths, start, actualEnd + TimeInterval((1 / Double(frameRate))))
}

private func createVideoSettings() -> [String: Any] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Foundation
import Nimble
@testable import Sentry
import SentryTestUtils
import XCTest

#if os(iOS) || os(tvOS)
class SentryOnDemandReplayTests: XCTestCase {

let dateProvider = TestCurrentDateProvider()
let outputPath = FileManager.default.temporaryDirectory

func getSut() -> SentryOnDemandReplay {
let sut = SentryOnDemandReplay(outputPath: outputPath.path,
workingQueue: TestSentryDispatchQueueWrapper(),
dateProvider: dateProvider)
return sut
}

func testAddFrame() {
let sut = getSut()
sut.addFrameAsync(image: UIImage.add)

guard let frame = sut.frames.first else {
fail("Frame was not saved")
return
}

expect(FileManager.default.fileExists(atPath: frame.imagePath)) == true
expect(frame.imagePath.hasPrefix(self.outputPath.path)) == true
}

func testReleaseFrames() {
let sut = getSut()

for _ in 0..<10 {
sut.addFrameAsync(image: UIImage.add)
dateProvider.advance(by: 1)
}

sut.releaseFramesUntil(dateProvider.date().addingTimeInterval(-5))

let frames = sut.frames

expect(frames.count) == 5
expect(frames.first?.time) == Date(timeIntervalSinceReferenceDate: 5)
expect(frames.last?.time) == Date(timeIntervalSinceReferenceDate: 9)
}

func testGenerateVideo() {
let sut = getSut()
dateProvider.driftTimeForEveryRead = true
dateProvider.driftTimeInterval = 1

for _ in 0..<10 {
sut.addFrameAsync(image: UIImage.add)
}

let output = FileManager.default.temporaryDirectory.appendingPathComponent("video.mp4")
let videoExpectation = expectation(description: "Wait for video render")

try? sut.createVideoWith(duration: 10, beginning: Date(timeIntervalSinceReferenceDate: 0), outputFileURL: output) { info, error in
expect(error) == nil

expect(info?.duration) == 10
expect(info?.start) == Date(timeIntervalSinceReferenceDate: 0)
expect(info?.end) == Date(timeIntervalSinceReferenceDate: 10)

expect(FileManager.default.fileExists(atPath: output.path)) == true
videoExpectation.fulfill()
try? FileManager.default.removeItem(at: output)
}

wait(for: [videoExpectation], timeout: 1)
}

func testAddFrameIsThreadSafe() {
let queue = SentryDispatchQueueWrapper()
let sut = SentryOnDemandReplay(outputPath: outputPath.path,
workingQueue: queue,
dateProvider: dateProvider)

dateProvider.driftTimeForEveryRead = true
dateProvider.driftTimeInterval = 1
let group = DispatchGroup()

for _ in 0..<10 {
group.enter()
DispatchQueue.global().async {
sut.addFrameAsync(image: UIImage.add)
group.leave()
}
}

group.wait()
queue.queue.sync {} //Wait for all enqueued operation to finish
expect(sut.frames.map({ ($0.imagePath as NSString).lastPathComponent })) == (0..<10).map { "\($0).png" }
}

func testReleaseIsThreadSafe() {
let queue = SentryDispatchQueueWrapper()
let sut = SentryOnDemandReplay(outputPath: outputPath.path,
workingQueue: queue,
dateProvider: dateProvider)

sut.frames = (0..<100).map { SentryReplayFrame(imagePath: outputPath.path + "/\($0).png", time: Date(timeIntervalSinceReferenceDate: Double($0))) }

let group = DispatchGroup()

for i in 1...10 {
group.enter()
DispatchQueue.global().async {
sut.releaseFramesUntil(Date(timeIntervalSinceReferenceDate: Double(i) * 10.0))
group.leave()
}
}

group.wait()

queue.queue.sync {} //Wait for all enqueued operation to finish
expect(sut.frames.count) == 0
}

}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class SentrySessionReplayTests: XCTestCase {
}

var lastFrame: UIImage?
func addFrame(with image: UIImage) {
func addFrameAsync(with image: UIImage) {
lastFrame = image
}

Expand Down

0 comments on commit 0cc5400

Please sign in to comment.