diff --git a/CHANGELOG.md b/CHANGELOG.md index af94a73922..48b1d901bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ - Correctly rate limit envelopes from the new UI profiling system (#5131) - Race condition in ANRTrackerV1 (#5137) +### Fixes + +- Fix thread inversion warning in session replay (#5018) + ### Improvements - More logging for Session Replay video info (#5132) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 05b54be214..bcf19bc6fe 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -835,9 +835,12 @@ D48724E22D354D16005DE483 /* SentryTraceOriginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */; }; D48E8B8B2D3E79610032E35E /* SentryTraceOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */; }; D48E8B9D2D3E82AC0032E35E /* SentrySpanOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */; }; + D49480D32DC23E9300A3B6E9 /* SentryReplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */; }; + D49480D72DC23FE300A3B6E9 /* SentrySessionReplayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */; }; D4AF00212D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */; }; D4AF00232D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */; }; D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */; }; + D4B0DC7F2DA9257A00DE61B6 /* SentryRenderVideoResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */; }; D4C5F59A2D4249E6002A9BF6 /* DataSentryTracingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */; }; D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */; }; D4E3F35E2D4A877300F79E2B /* SentryNSDictionarySanitize+Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */; }; @@ -2009,6 +2012,8 @@ D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOriginTests.swift; sourceTree = ""; }; D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOrigin.swift; sourceTree = ""; }; D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySpanOperation.swift; sourceTree = ""; }; + D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayTypeTests.swift; sourceTree = ""; }; + D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayDelegate.swift; sourceTree = ""; }; D4A236012D5F838200D55C58 /* macOS-Swift_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "macOS-Swift_Base.xctestplan"; sourceTree = ""; }; D4A236062D5F846200D55C58 /* iOS-ObjectiveC_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "iOS-ObjectiveC_Base.xctestplan"; sourceTree = ""; }; D4A236072D5F846F00D55C58 /* iOS-Swift6_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "iOS-Swift6_Base.xctestplan"; sourceTree = ""; }; @@ -2020,6 +2025,7 @@ D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSFileManagerSwizzling.m; sourceTree = ""; }; D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryNSFileManagerSwizzling.h; path = include/SentryNSFileManagerSwizzling.h; sourceTree = ""; }; D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSFileManagerSwizzlingTests.m; sourceTree = ""; }; + D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRenderVideoResult.swift; sourceTree = ""; }; D4BCA0C22DA93C25009E49AB /* SentrySessionReplayIntegration+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SentrySessionReplayIntegration+Test.h"; sourceTree = ""; }; D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSentryTracingIntegrationTests.swift; sourceTree = ""; }; D4E829D12D75E2DE00D375AD /* SentryDefaultViewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryDefaultViewRenderer.swift; sourceTree = ""; }; @@ -3962,6 +3968,7 @@ D80694C12B7CC85800B820E6 /* SessionReplay */ = { isa = PBXGroup; children = ( + D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */, D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */, @@ -4280,6 +4287,7 @@ D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, + D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */, D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */, D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, @@ -4288,6 +4296,7 @@ D81988BF2BEBFFF70020E36C /* SentryReplayRecording.swift */, D8BC28C72BFF5EBB0054DA4D /* SentryTouchTracker.swift */, D84D2CC22C29AD120011AF8A /* SentrySessionReplay.swift */, + D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */, D84D2CDC2C2BF7370011AF8A /* SentryReplayEvent.swift */, D84D2CDE2C2BF9370011AF8A /* SentryReplayType.swift */, ); @@ -4970,6 +4979,7 @@ 7B98D7D325FB65AE00C5A389 /* SentryWatchdogTerminationTracker.m in Sources */, 8E564AE8267AF22600FE117D /* SentryNetworkTrackingIntegration.m in Sources */, 63AA75EF1EB8B3C400D153DE /* SentryClient.m in Sources */, + D4B0DC7F2DA9257A00DE61B6 /* SentryRenderVideoResult.swift in Sources */, 7B7D873624864C9D00D2ECFF /* SentryCrashDefaultMachineContextWrapper.m in Sources */, 63FE712F20DA4C1100CDBAE8 /* SentryCrashSysCtl.c in Sources */, 62212B872D520CB00062C2FA /* SentryEventCodable.swift in Sources */, @@ -5050,6 +5060,7 @@ 63FE711D20DA4C1000CDBAE8 /* SentryCrashCPU_arm64.c in Sources */, 844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */, D451ED5F2D92ECDE00C9BEA8 /* SentryReplayFrame.swift in Sources */, + D49480D72DC23FE300A3B6E9 /* SentrySessionReplayDelegate.swift in Sources */, D8739CF92BECFFB5007D2F66 /* SentryTransactionNameSource.swift in Sources */, 630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */, 6281C5722D3E4F12009D0978 /* DecodeArbitraryData.swift in Sources */, @@ -5327,6 +5338,7 @@ 7B3B473E25D6CEA500D01640 /* SentryNSErrorTests.swift in Sources */, 632331F62404FFA8008D91D6 /* SentryScopeTests.m in Sources */, D808FB88281AB33C009A2A33 /* SentryUIEventTrackerTests.swift in Sources */, + D49480D32DC23E9300A3B6E9 /* SentryReplayTypeTests.swift in Sources */, D8F8F5572B835BC600AC5465 /* SentryMsgPackSerializerTests.m in Sources */, 0A283E79291A67E000EF4126 /* SentryUIDeviceWrapperTests.swift in Sources */, 63FE720D20DA66EC00CDBAE8 /* SentryCrashNSErrorUtilTests.m in Sources */, diff --git a/Sources/Sentry/SentryDispatchQueueWrapper.m b/Sources/Sentry/SentryDispatchQueueWrapper.m index 2663c12f49..aa208253a3 100644 --- a/Sources/Sentry/SentryDispatchQueueWrapper.m +++ b/Sources/Sentry/SentryDispatchQueueWrapper.m @@ -105,6 +105,14 @@ - (nullable dispatch_block_t)createDispatchBlock:(void (^)(void))block return dispatch_block_create(0, block); } ++ (SentryDispatchQueueWrapper *)createBackgroundDispatchQueueWithName:(const char *)name + relativePriority:(int)relativePriority + +{ + dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class( + DISPATCH_QUEUE_SERIAL, QOS_CLASS_BACKGROUND, relativePriority); + return [[SentryDispatchQueueWrapper alloc] initWithName:name attributes:attributes]; +} @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index 30911d7caa..f0a9bce04f 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -58,6 +58,8 @@ @implementation SentrySessionReplayIntegration { // replay absolutely needs segment 0 to make replay work. BOOL _rateLimited; id _dateProvider; + SentryDispatchQueueWrapper *_replayProcessingQueue; + SentryDispatchQueueWrapper *_replayAssetWorkerQueue; } - (instancetype)init @@ -121,6 +123,19 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions } _notificationCenter = SentryDependencyContainer.sharedInstance.notificationCenterWrapper; + _dateProvider = SentryDependencyContainer.sharedInstance.dateProvider; + + // The asset worker queue is used to work on video and frames data. + // Use a relative priority of -1 to make it lower than the default background priority. + _replayAssetWorkerQueue = [SentryDispatchQueueWrapper + createBackgroundDispatchQueueWithName:"io.sentry.session-replay.asset-worker" + relativePriority:-1]; + // The dispatch queue is used to asynchronously wait for the asset worker queue to finish its + // work. To avoid a deadlock, the priority of the processing queue must be lower than the asset + // worker queue. Use a relative priority of -2 to make it lower than the asset worker queue. + _replayProcessingQueue = [SentryDispatchQueueWrapper + createBackgroundDispatchQueueWithName:"io.sentry.session-replay.processing" + relativePriority:-2]; // The asset worker queue is used to work on video and frames data. @@ -192,7 +207,10 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event } SentryOnDemandReplay *resumeReplayMaker = - [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path]; + [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path + processingQueue:_replayProcessingQueue + assetWorkerQueue:_replayAssetWorkerQueue + dateProvider:_dateProvider]; resumeReplayMaker.bitRate = _replayOptions.replayBitRate; resumeReplayMaker.videoScale = _replayOptions.sizeScale; @@ -208,10 +226,16 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event NSArray *videos = [resumeReplayMaker createVideoWithBeginning:beginning end:end error:&error]; - if (videos == nil) { + + // Either error or videos should be set. + if (error != nil) { SENTRY_LOG_ERROR(@"Could not create replay video, reason: %@", error); return; } + if (videos == nil) { + SENTRY_LOG_ERROR(@"Could not create replay video, reason: no videos available"); + return; + } // For each segment we need to create a new event with the video. int _segmentId = segmentId; @@ -322,30 +346,27 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions error:nil]; } - SentryOnDemandReplay *replayMaker = [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; + SentryOnDemandReplay *replayMaker = + [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path + processingQueue:_replayProcessingQueue + assetWorkerQueue:_replayAssetWorkerQueue + dateProvider:_dateProvider]; replayMaker.bitRate = replayOptions.replayBitRate; replayMaker.videoScale = replayOptions.sizeScale; replayMaker.cacheMaxSize = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + 1 : replayOptions.errorReplayDuration + 1); - dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class( - DISPATCH_QUEUE_SERIAL, DISPATCH_QUEUE_PRIORITY_LOW, 0); - SentryDispatchQueueWrapper *dispatchQueue = - [[SentryDispatchQueueWrapper alloc] initWithName:"io.sentry.session-replay" - attributes:attributes]; - - self.sessionReplay = [[SentrySessionReplay alloc] - initWithReplayOptions:replayOptions - replayFolderPath:docs - screenshotProvider:screenshotProvider - replayMaker:replayMaker - breadcrumbConverter:breadcrumbConverter - touchTracker:_touchTracker - dateProvider:SentryDependencyContainer.sharedInstance.dateProvider - delegate:self - dispatchQueue:dispatchQueue - displayLinkWrapper:[[SentryDisplayLinkWrapper alloc] init]]; + SentryDisplayLinkWrapper *displayLinkWrapper = [[SentryDisplayLinkWrapper alloc] init]; + self.sessionReplay = [[SentrySessionReplay alloc] initWithReplayOptions:replayOptions + replayFolderPath:docs + screenshotProvider:screenshotProvider + replayMaker:replayMaker + breadcrumbConverter:breadcrumbConverter + touchTracker:_touchTracker + dateProvider:_dateProvider + delegate:self + displayLinkWrapper:displayLinkWrapper]; [self.sessionReplay startWithRootView:SentryDependencyContainer.sharedInstance.application.windows.firstObject diff --git a/Sources/Sentry/include/SentryDispatchQueueWrapper.h b/Sources/Sentry/include/SentryDispatchQueueWrapper.h index 7859c74ac5..56748389cd 100644 --- a/Sources/Sentry/include/SentryDispatchQueueWrapper.h +++ b/Sources/Sentry/include/SentryDispatchQueueWrapper.h @@ -32,6 +32,8 @@ NS_ASSUME_NONNULL_BEGIN - (nullable dispatch_block_t)createDispatchBlock:(void (^)(void))block; ++ (SentryDispatchQueueWrapper *)createBackgroundDispatchQueueWithName:(const char *)name + relativePriority:(int)relativePriority; @end NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index e3195440e8..83991ef88e 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length type_body_length #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) @@ -15,7 +16,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { private let _outputPath: String private var _totalFrames = 0 private let dateProvider: SentryCurrentDateProvider - private let workingQueue: SentryDispatchQueueWrapper + private let processingQueue: SentryDispatchQueueWrapper + private let assetWorkerQueue: SentryDispatchQueueWrapper private var _frames = [SentryReplayFrame]() #if SENTRY_TEST || SENTRY_TEST_CI || DEBUG @@ -30,14 +32,31 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { var frameRate = 1 var cacheMaxSize = UInt.max - init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { + init( + outputPath: String, + processingQueue: SentryDispatchQueueWrapper, + assetWorkerQueue: SentryDispatchQueueWrapper, + dateProvider: SentryCurrentDateProvider + ) { + assert(processingQueue != assetWorkerQueue, "Processing and asset worker queue must not be the same.") self._outputPath = outputPath self.dateProvider = dateProvider - self.workingQueue = workingQueue + self.processingQueue = processingQueue + self.assetWorkerQueue = assetWorkerQueue } - convenience init(withContentFrom outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { - self.init(outputPath: outputPath, workingQueue: workingQueue, dateProvider: dateProvider) + convenience init( + withContentFrom outputPath: String, + processingQueue: SentryDispatchQueueWrapper, + assetWorkerQueue: SentryDispatchQueueWrapper, + dateProvider: SentryCurrentDateProvider + ) { + self.init( + outputPath: outputPath, + processingQueue: processingQueue, + assetWorkerQueue: assetWorkerQueue, + dateProvider: dateProvider + ) loadFrames(fromPath: outputPath) } @@ -45,8 +64,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { /// /// - Parameter path: The path to the directory containing the frames. private func loadFrames(fromPath path: String) { - SentryLog.debug("[Session Replay] Loading frames from path: \(path)") do { + SentryLog.debug("[Session Replay] Loading frames from path: \(path)") let content = try FileManager.default.contentsOfDirectory(atPath: path) _frames = content.compactMap { frameFilePath -> SentryReplayFrame? in guard frameFilePath.hasSuffix(".png") else { return nil } @@ -56,37 +75,34 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { }.sorted { $0.time < $1.time } SentryLog.debug("[Session Replay] Loaded \(content.count) files into \(_frames.count) frames from path: \(path)") } catch { - SentryLog.error("[Session Replay] Could not list frames from replay: \(error.localizedDescription)") + SentryLog.error("[Session Replay] Could not list frames from replay, reason: \(error.localizedDescription)") } } - convenience init(outputPath: String) { - self.init(outputPath: outputPath, - workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), - dateProvider: SentryDefaultCurrentDateProvider()) - } - - convenience init(withContentFrom outputPath: String) { - self.init(withContentFrom: outputPath, - workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), - dateProvider: SentryDefaultCurrentDateProvider()) - } - func addFrameAsync(image: UIImage, forScreen: String?) { - workingQueue.dispatchAsync({ + SentryLog.debug("[Session Replay] Adding frame async for screen: \(forScreen ?? "nil")") + // Dispatch the frame addition to a background queue to avoid blocking the main queue. + // This must be on the processing queue to avoid deadlocks. + processingQueue.dispatchAsync { self.addFrame(image: image, forScreen: forScreen) - }) + } } private func addFrame(image: UIImage, forScreen: String?) { - guard let data = rescaleImage(image)?.pngData() else { return } - + SentryLog.debug("[Session Replay] Adding frame for screen: \(forScreen ?? "nil")") + guard let data = rescaleImage(image)?.pngData() else { + SentryLog.warning("[Session Replay] Could not rescale image to PNG data, ignoring frame") + return + } + let date = dateProvider.date() let imagePath = (_outputPath as NSString).appendingPathComponent("\(date.timeIntervalSinceReferenceDate).png") do { - try data.write(to: URL(fileURLWithPath: imagePath)) + let url = URL(fileURLWithPath: imagePath) + SentryLog.debug("[Session Replay] Saving replay frame to: \(url.path)") + try data.write(to: url) } catch { - SentryLog.error("[Session Replay] Could not save replay frame. Error: \(error)") + SentryLog.error("[Session Replay] Could not save replay frame, reason: \(error)") return } _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) @@ -94,9 +110,12 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // Remove the oldest frames if the cache size exceeds the maximum size. while _frames.count > cacheMaxSize { let first = _frames.removeFirst() - try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + let url = URL(fileURLWithPath: first.imagePath) + SentryLog.debug("[Session Replay] Removing frame at url: \(url.path)") + try? FileManager.default.removeItem(at: url) } _totalFrames += 1 + SentryLog.debug("[Session Replay] Added frame, total frames counter: \(_totalFrames), current frames count: \(_frames.count)") } private func rescaleImage(_ originalImage: UIImage) -> UIImage? { @@ -111,7 +130,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { func releaseFramesUntil(_ date: Date) { SentryLog.debug("[Session Replay] Releasing frames until date: \(date)") - workingQueue.dispatchAsync ({ + processingQueue.dispatchAsync { while let first = self._frames.first, first.time < date { self._frames.removeFirst() let fileUrl = URL(fileURLWithPath: first.imagePath) @@ -122,150 +141,257 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { SentryLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error.localizedDescription), ignoring error") } } - }) + } } var oldestFrameDate: Date? { return _frames.first?.time } - + + func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) { + // Note: In Swift it is best practice to use `Result` instead of `(Value?, Error?)` + // Due to interoperability with Objective-C and @objc, we can not use Result for the completion callback. + SentryLog.debug("[Session Replay] Creating video in background with beginning: \(beginning), end: \(end)") + processingQueue.dispatchAsync { + do { + let videos = try self.createVideoWith(beginning: beginning, end: end) + SentryLog.debug("[Session Replay] Finished creating video in backgroundwith \(videos.count) segments") + completion(videos, nil) + } catch { + SentryLog.error("[Session Replay] Failed to create video in background with error: \(error)") + completion(nil, error) + } + } + } + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { - let videoFrames = filterFrames(beginning: beginning, end: end) + SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") + // Note: In previous implementations this method was wrapped by a sync call to the processing queue. + // As this method is already called from the processing queue, we must remove the sync call. + let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } var frameCount = 0 - + var videos = [SentryVideoInfo]() - + while frameCount < videoFrames.count { - let outputFileURL = URL(fileURLWithPath: _outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) - if let videoInfo = try renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { - videos.append(videoInfo) - } else { - frameCount++ - } + let frame = videoFrames[frameCount] + let outputFileURL = URL(fileURLWithPath: _outputPath) + .appendingPathComponent("\(frame.time.timeIntervalSinceReferenceDate)") + .appendingPathExtension("mp4") + + let group = DispatchGroup() + var currentError: Error? + + group.enter() + self.renderVideo(with: videoFrames, from: frameCount, at: outputFileURL) { result in + switch result { + case .success(let videoResult): + // Set the frame count/offset to the new index that is returned by the completion block. + // This is important to avoid processing the same frame multiple times. + frameCount = videoResult.finalFrameIndex + SentryLog.debug("[Session Replay] Finished rendering video, frame count moved to: \(frameCount)") + + // Append the video info to the videos array. + // In case no video info is returned, skip the segment. + if let videoInfo = videoResult.info { + videos.append(videoInfo) + } + case .failure(let error): + SentryLog.error("[Session Replay] Failed to render video with error: \(error)") + currentError = error + } + group.leave() + } + + // Calling group.wait will block the `processingQueue` until the video rendering completes or a timeout occurs. + // It is imporant that the renderVideo completion block signals the group. + // The queue used by render video must have a higher priority than the processing queue to reduce thread inversion. + // Otherwise, it could lead to queue starvation and a deadlock/timeout. + guard group.wait(timeout: .now() + 120) == .success else { + SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") + throw SentryOnDemandReplayError.errorRenderingVideo + } + + // If there was an error, throw it to exit the loop. + if let error = currentError { + SentryLog.error("[Session Replay] Error while rendering video: \(error), cancelling video segment creation") + throw error + } + + SentryLog.debug("[Session Replay] Finished rendering video, frame count moved to: \(frameCount)") } + + SentryLog.debug("[Session Replay] Finished creating video with \(videos.count) segments") return videos } - // swiftlint:disable:next function_body_length - 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 } + // swiftlint:disable function_body_length cyclomatic_complexity + private func renderVideo(with videoFrames: [SentryReplayFrame], from: Int, at outputFileURL: URL, completion: @escaping (Result) -> Void) { + SentryLog.debug("[Session Replay] Rendering video with \(videoFrames.count) frames, from index: \(from), to output url: \(outputFileURL)") + guard from < videoFrames.count, let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { + SentryLog.error("[Session Replay] Failed to render video, reason: index out of bounds or can't read image at path: \(videoFrames[from].imagePath)") + return completion(.success(SentryRenderVideoResult( + info: nil, + finalFrameIndex: from + ))) + } + let videoWidth = image.size.width * CGFloat(videoScale) let videoHeight = image.size.height * CGFloat(videoScale) - - let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) + let pixelSize = CGSize(width: videoWidth, height: videoHeight) + + let videoWriter: AVAssetWriter + do { + videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) + } catch { + SentryLog.error("[Session Replay] Failed to create video writer, reason: \(error)") + return completion(.failure(error)) + } + + SentryLog.debug("[Session Replay] Creating pixel buffer based video writer input") let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings(width: videoWidth, height: videoHeight)) - - guard let currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput) - else { throw SentryOnDemandReplayError.cantCreatePixelBuffer } - + guard let currentPixelBuffer = SentryPixelBuffer(size: pixelSize, videoWriterInput: videoWriterInput) else { + SentryLog.error("[Session Replay] Failed to create pixel buffer, reason: \(SentryOnDemandReplayError.cantCreatePixelBuffer)") + return completion(.failure(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? - var frameCount = from + var frameIndex = from + + // Convenience wrapper to handle the completion callback to return the video info and the final frame index + // It is not possible to use an inout frame index here, because the closure is escaping and the frameIndex variable is captured. + let deferredCompletionCallback: (Result) -> Void = { result in + switch result { + case .success(let videoResult): + completion(.success(SentryRenderVideoResult( + info: videoResult, + finalFrameIndex: frameIndex + ))) + case .failure(let error): + completion(.failure(error)) + } + } - group.enter() - videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { + // Append frames to the video writer input in a pull-style manner when the input is ready to receive more media data. + // + // Inside the callback: + // 1. We append media data until `isReadyForMoreMediaData` becomes false + // 2. Or until there's no more media data to process (then we mark input as finished) + // 3. If we don't mark the input as finished, the callback will be invoked again + // when the input is ready for more data + // + // By setting the queue to the asset worker queue, we ensure that the callback is invoked on the asset worker queue. + // This is important to avoid a deadlock, as this method is called on the processing queue. + videoWriterInput.requestMediaDataWhenReady(on: assetWorkerQueue.queue) { [weak self] in + guard let strongSelf = self else { + SentryLog.warning("[Session Replay] On-demand replay is deallocated, completing writing session without output video info") + return deferredCompletionCallback(.success(nil)) + } guard videoWriter.status == .writing else { + SentryLog.warning("[Session Replay] Video writer is not writing anymore, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") videoWriter.cancelWriting() - result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) - group.leave() - return + return deferredCompletionCallback(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } - if frameCount >= videoFrames.count { - result = self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter) - group.leave() - return + guard frameIndex < videoFrames.count else { + SentryLog.debug("[Session Replay] No more frames available to process, finishing the video") + return strongSelf.finishVideo( + outputFileURL: outputFileURL, + usedFrames: usedFrames, + videoHeight: Int(videoHeight), + videoWidth: Int(videoWidth), + videoWriter: videoWriter, + onCompletion: deferredCompletionCallback + ) } - let frame = videoFrames[frameCount] + + let frame = videoFrames[frameIndex] 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 + guard lastImageSize == image.size else { + SentryLog.debug("[Session Replay] Image size changed, finishing the video") + return strongSelf.finishVideo( + outputFileURL: outputFileURL, + usedFrames: usedFrames, + videoHeight: Int(videoHeight), + videoWidth: Int(videoWidth), + videoWriter: videoWriter, + onCompletion: deferredCompletionCallback + ) } lastImageSize = image.size - + let presentTime = SentryOnDemandReplay.calculatePresentationTime( - forFrameAtIndex: frameCount, - frameRate: self.frameRate + forFrameAtIndex: frameIndex, + frameRate: strongSelf.frameRate ).timeValue - if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true { + guard currentPixelBuffer.append(image: image, presentationTime: presentTime) == true else { + SentryLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") videoWriter.cancelWriting() - result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) - group.leave() - return + return deferredCompletionCallback(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } usedFrames.append(frame) } - frameCount += 1 + + // Increment the frame index even if the image could not be appended to the pixel buffer. + // This is important to avoid an infinite loop. + frameIndex += 1 } - guard group.wait(timeout: .now() + 2) == .success else { throw SentryOnDemandReplayError.errorRenderingVideo } - from = frameCount - - return try result?.get() } - - private func finishVideo(outputFileURL: URL, usedFrames: [SentryReplayFrame], videoHeight: Int, videoWidth: Int, videoWriter: AVAssetWriter) -> Result { - let group = DispatchGroup() - var finishError: Error? - var result: SentryVideoInfo? - - group.enter() + + // swiftlint:enable function_body_length cyclomatic_complexity + private func finishVideo( + outputFileURL: URL, + usedFrames: [SentryReplayFrame], + videoHeight: Int, + videoWidth: Int, + videoWriter: AVAssetWriter, + onCompletion completion: @escaping (Result) -> Void + ) { + // Note: This method is expected to be called from the asset worker queue and *not* the processing queue. + SentryLog.debug("[Session Replay] Finishing video with output file URL: \(outputFileURL.path)") videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.finishWriting { [weak self] in - defer { group.leave() } - SentryLog.debug("[Session Replay] Finished video writing, status: \(videoWriter.status)") guard let strongSelf = self else { SentryLog.warning("[Session Replay] On-demand replay is deallocated, completing writing session without output video info") - return + return completion(.success(nil)) } switch videoWriter.status { case .writing: - SentryLog.error("[Session Replay] Finish writing video was called with status writing, this is unexpected! Completing with no video info") + // noop + break case .cancelled: - SentryLog.warning("[Session Replay] Finish writing video was cancelled, completing with no video info.") + SentryLog.debug("[Session Replay] Finish writing video was cancelled, completing with no video info") + completion(.success(nil)) case .completed: - SentryLog.debug("[Session Replay] Finish writing video was completed, creating video info from file attributes.") + SentryLog.debug("[Session Replay] Finish writing video was completed, creating video info from file attributes") do { - result = try strongSelf.getVideoInfo( + let result = try strongSelf.getVideoInfo( from: outputFileURL, usedFrames: usedFrames, videoWidth: Int(videoWidth), videoHeight: Int(videoHeight) ) + completion(.success(result)) } catch { SentryLog.warning("[Session Replay] Failed to create video info from file attributes, reason: \(error.localizedDescription)") - finishError = error + completion(.failure(error)) } - case .failed, .unknown: + case .failed: SentryLog.warning("[Session Replay] Finish writing video failed, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") - finishError = videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo + completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) + case .unknown: + SentryLog.warning("[Session Replay] Finish writing video with unknown status, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") + completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) @unknown default: SentryLog.warning("[Session Replay] Finish writing video failed, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") - finishError = videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo + completion(.failure(SentryOnDemandReplayError.errorRenderingVideo)) } } - group.wait() - - if let finishError = finishError { return .failure(finishError) } - return .success(result) - } - - private func filterFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { - var frames = [SentryReplayFrame]() - // Using dispatch queue as sync mechanism since we need a queue already to generate the video. - workingQueue.dispatchSync { - frames = self._frames.filter { $0.time >= beginning && $0.time <= end } - } - return frames } fileprivate func getVideoInfo(from outputFileURL: URL, usedFrames: [SentryReplayFrame], videoWidth: Int, videoHeight: Int) throws -> SentryVideoInfo { @@ -381,3 +507,4 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) +// swiftlint:enable file_length type_body_length diff --git a/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift b/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift new file mode 100644 index 0000000000..4b6e605670 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift @@ -0,0 +1,4 @@ +struct SentryRenderVideoResult { + let info: SentryVideoInfo? + let finalFrameIndex: Int +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift index 32579786b2..c7f0c8f55f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift @@ -6,6 +6,14 @@ enum SentryReplayType: Int { case buffer } +// Implementing the CustomStringConvertible protocol to provide a string representation of the enum values. +// This method will be called by the Swift runtime when converting the enum to a string, i.e. in String interpolations. +extension SentryReplayType: CustomStringConvertible { + var description: String { + return toString() + } +} + extension SentryReplayType { func toString() -> String { switch self { diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 32831f3864..66a19bab4e 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -6,6 +6,7 @@ import UIKit protocol SentryReplayVideoMaker: NSObjectProtocol { func addFrameAsync(image: UIImage, forScreen: String?) func releaseFramesUntil(_ date: Date) + func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 648973f630..1334323052 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -3,20 +3,7 @@ import Foundation @_implementationOnly import _SentryPrivate import UIKit -enum SessionReplayError: Error { - case cantCreateReplayDirectory - case noFramesAvailable -} - -@objc -protocol SentrySessionReplayDelegate: NSObjectProtocol { - func sessionReplayShouldCaptureReplayForError() -> Bool - func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL) - func sessionReplayStarted(replayId: SentryId) - func breadcrumbsForSessionReplay() -> [Breadcrumb] - func currentScreenNameForSessionReplay() -> String? -} - +// swiftlint:disable type_body_length @objcMembers class SentrySessionReplay: NSObject { private(set) var isFullSession = false @@ -39,7 +26,6 @@ class SentrySessionReplay: NSObject { private let displayLink: SentryDisplayLinkWrapper private let dateProvider: SentryCurrentDateProvider private let touchTracker: SentryTouchTracker? - private let dispatchQueue: SentryDispatchQueueWrapper private let lock = NSLock() var replayTags: [String: Any]? @@ -50,18 +36,17 @@ class SentrySessionReplay: NSObject { var screenshotProvider: SentryViewScreenshotProvider var breadcrumbConverter: SentryReplayBreadcrumbConverter - init(replayOptions: SentryReplayOptions, - replayFolderPath: URL, - screenshotProvider: SentryViewScreenshotProvider, - replayMaker: SentryReplayVideoMaker, - breadcrumbConverter: SentryReplayBreadcrumbConverter, - touchTracker: SentryTouchTracker?, - dateProvider: SentryCurrentDateProvider, - delegate: SentrySessionReplayDelegate, - dispatchQueue: SentryDispatchQueueWrapper, - displayLinkWrapper: SentryDisplayLinkWrapper) { - - self.dispatchQueue = dispatchQueue + init( + replayOptions: SentryReplayOptions, + replayFolderPath: URL, + screenshotProvider: SentryViewScreenshotProvider, + replayMaker: SentryReplayVideoMaker, + breadcrumbConverter: SentryReplayBreadcrumbConverter, + touchTracker: SentryTouchTracker?, + dateProvider: SentryCurrentDateProvider, + delegate: SentrySessionReplayDelegate, + displayLinkWrapper: SentryDisplayLinkWrapper + ) { self.replayOptions = replayOptions self.dateProvider = dateProvider self.delegate = delegate @@ -159,7 +144,7 @@ class SentrySessionReplay: NSObject { startFullReplay() let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - createAndCapture(startedAt: replayStart, replayType: .buffer) + createAndCaptureInBackground(startedAt: replayStart, replayType: .buffer) return true } @@ -220,30 +205,35 @@ class SentrySessionReplay: NSObject { pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - createAndCapture(startedAt: segmentStart, replayType: .session) + createAndCaptureInBackground(startedAt: segmentStart, replayType: .session) } - private func createAndCapture(startedAt: Date, replayType: SentryReplayType) { + private func createAndCaptureInBackground(startedAt: Date, replayType: SentryReplayType) { SentryLog.debug("[Session Replay] Creating replay video started at date: \(startedAt), replayType: \(replayType)") - //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, replayType: replayType) - } - SentryLog.debug("[Session Replay] Finished replay video creation with \(videos.count) segments") - } catch { - SentryLog.debug("Could not create replay video - \(error.localizedDescription)") + // Creating a video is computationally expensive, therefore perform it on a background queue. + self.replayMaker.createVideoInBackgroundWith(beginning: startedAt, end: self.dateProvider.date()) { videos, error in + if let error = error { + SentryLog.error("[Session Replay] Could not create replay video - \(error.localizedDescription)") + return } + guard let videos = videos else { + SentryLog.warning("[Session Replay] Finished replay video creation without any segments") + return + } + SentryLog.debug("[Session Replay] Created replay video with \(videos.count) segments") + for video in videos { + self.newSegmentAvailable(videoInfo: video, replayType: replayType) + } + SentryLog.debug("[Session Replay] Finished processing replay video with \(videos.count) segments") } } private func newSegmentAvailable(videoInfo: SentryVideoInfo, replayType: SentryReplayType) { SentryLog.debug("[Session Replay] New segment available for replayType: \(replayType), videoInfo: \(videoInfo)") - guard let sessionReplayId = sessionReplayId else { return } + guard let sessionReplayId = sessionReplayId else { + SentryLog.warning("[Session Replay] No session replay ID available, ignoring segment.") + return + } captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: replayType) replayMaker.releaseFramesUntil(videoInfo.end) videoSegmentStart = videoInfo.end @@ -329,5 +319,6 @@ class SentrySessionReplay: NSObject { } } } +// swiftlint:enable type_body_length #endif diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayDelegate.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayDelegate.swift new file mode 100644 index 0000000000..23048bace7 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayDelegate.swift @@ -0,0 +1,14 @@ +import Foundation +#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UIKIT +@_implementationOnly import _SentryPrivate + +@objc +protocol SentrySessionReplayDelegate: NSObjectProtocol { + func sessionReplayShouldCaptureReplayForError() -> Bool + func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL) + func sessionReplayStarted(replayId: SentryId) + func breadcrumbsForSessionReplay() -> [Breadcrumb] + func currentScreenNameForSessionReplay() -> String? +} + +#endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 60e3aacc18..07d60f8642 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -28,9 +28,12 @@ class SentryOnDemandReplayTests: XCTestCase { } private func getSut(trueDispatchQueueWrapper: Bool = false) -> SentryOnDemandReplay { - return SentryOnDemandReplay(outputPath: outputPath.path, - workingQueue: trueDispatchQueueWrapper ? SentryDispatchQueueWrapper() : TestSentryDispatchQueueWrapper(), - dateProvider: dateProvider) + return SentryOnDemandReplay( + outputPath: outputPath.path, + processingQueue: trueDispatchQueueWrapper ? SentryDispatchQueueWrapper() : TestSentryDispatchQueueWrapper(), + assetWorkerQueue: trueDispatchQueueWrapper ? SentryDispatchQueueWrapper() : TestSentryDispatchQueueWrapper(), + dateProvider: dateProvider + ) } func testAddFrame() { @@ -109,11 +112,15 @@ class SentryOnDemandReplayTests: XCTestCase { } func testAddFrameIsThreadSafe() { - let queue = SentryDispatchQueueWrapper() - let sut = SentryOnDemandReplay(outputPath: outputPath.path, - workingQueue: queue, - dateProvider: dateProvider) - + let processingQueue = SentryDispatchQueueWrapper() + let workerQueue = SentryDispatchQueueWrapper() + let sut = SentryOnDemandReplay( + outputPath: outputPath.path, + processingQueue: processingQueue, + assetWorkerQueue: workerQueue, + dateProvider: dateProvider + ) + dateProvider.driftTimeForEveryRead = true dateProvider.driftTimeInterval = 1 let group = DispatchGroup() @@ -127,16 +134,20 @@ class SentryOnDemandReplayTests: XCTestCase { } group.wait() - queue.queue.sync {} //Wait for all enqueued operation to finish + processingQueue.queue.sync {} // Wait for all enqueued operation to finish XCTAssertEqual(sut.frames.map({ ($0.imagePath as NSString).lastPathComponent }), (0..<10).map { "\($0).0.png" }) } func testReleaseIsThreadSafe() { - let queue = SentryDispatchQueueWrapper() - let sut = SentryOnDemandReplay(outputPath: outputPath.path, - workingQueue: queue, - dateProvider: dateProvider) - + let processingQueue = SentryDispatchQueueWrapper() + let workerQueue = SentryDispatchQueueWrapper() + let sut = SentryOnDemandReplay( + outputPath: outputPath.path, + processingQueue: processingQueue, + assetWorkerQueue: workerQueue, + dateProvider: dateProvider + ) + sut.frames = (0..<100).map { SentryReplayFrame(imagePath: outputPath.path + "/\($0).png", time: Date(timeIntervalSinceReferenceDate: Double($0)), screenName: nil) } let group = DispatchGroup() @@ -151,24 +162,33 @@ class SentryOnDemandReplayTests: XCTestCase { group.wait() - queue.queue.sync {} //Wait for all enqueued operation to finish + processingQueue.queue.sync {} //Wait for all enqueued operation to finish XCTAssertEqual(sut.frames.count, 0) } func testInvalidWriter() throws { - let queue = TestSentryDispatchQueueWrapper() - let sut = SentryOnDemandReplay(outputPath: outputPath.path, - workingQueue: queue, - dateProvider: dateProvider) - + // -- Arrange -- + let processingQueue = SentryDispatchQueueWrapper() + let workerQueue = SentryDispatchQueueWrapper() + let sut = SentryOnDemandReplay( + outputPath: outputPath.path, + processingQueue: processingQueue, + assetWorkerQueue: workerQueue, + dateProvider: dateProvider + ) + let start = dateProvider.date() sut.addFrameAsync(image: UIImage.add) + processingQueue.dispatchSync { + // Wait for the frame to be added by adding a sync operation to the serial queue + } dateProvider.advance(by: 1) let end = dateProvider.date() - //Creating a file where the replay would be written to cause an error in the writer + // Creating a file where the replay would be written to cause an error in the writer try Data("tempFile".utf8).write(to: outputPath.appendingPathComponent("0.0.mp4")) - + + // -- Act & Assert -- XCTAssertThrowsError(try sut.createVideoWith(beginning: start, end: end)) } diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift new file mode 100644 index 0000000000..3e0a7ab7b3 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift @@ -0,0 +1,34 @@ +@testable import Sentry +import XCTest + +class SentryReplayTypeTests: XCTestCase { + func testRawValue_sessionMode_shouldBeCorrect() { + let replayType = SentryReplayType.session.rawValue + XCTAssertEqual(replayType, 0) + } + + func testRawValue_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer.rawValue + XCTAssertEqual(replayType, 1) + } + + func testDescription_sessionMode_shouldBeCorrect() { + let replayType = SentryReplayType.session.description + XCTAssertEqual(replayType, "session") + } + + func testDescription_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer.description + XCTAssertEqual(replayType, "buffer") + } + + func testToString_sessionMode_shouldBeCorrect() { + let replayType = SentryReplayType.session + XCTAssertEqual(replayType.toString(), "session") + } + + func testToString_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer + XCTAssertEqual(replayType.toString(), "buffer") + } +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index f04d71a00e..63ae9f1da9 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -35,7 +35,22 @@ class SentrySessionReplayTests: XCTestCase { } var lastCallToCreateVideo: CreateVideoCall? - func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + func createVideoInBackgroundWith( + beginning: Date, + end: Date, + completion: @escaping ([Sentry.SentryVideoInfo]?, (any Error)?) -> Void + ) { + // Note: This implementation is just to satisfy the protocol. + // If possible, keep the tests logic the synchronous version `createVideoWith` + do { + let videos = try createVideoWith(beginning: beginning, end: end) + completion(videos, nil) + } catch { + completion(nil, error) + } + } + + func createVideoWith(beginning: Date, end: Date) throws -> [Sentry.SentryVideoInfo] { lastCallToCreateVideo = CreateVideoCall(beginning: beginning, end: end) let outputFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("tempvideo.mp4") @@ -76,7 +91,7 @@ class SentrySessionReplayTests: XCTestCase { var lastReplayId: SentryId? var currentScreen: String? - func getSut(options: SentryReplayOptions = .init(sessionSampleRate: 0, onErrorSampleRate: 0), dispatchQueue: SentryDispatchQueueWrapper = TestSentryDispatchQueueWrapper(), touchTracker: SentryTouchTracker? = nil) -> SentrySessionReplay { + func getSut(options: SentryReplayOptions = .init(sessionSampleRate: 0, onErrorSampleRate: 0), touchTracker: SentryTouchTracker? = nil) -> SentrySessionReplay { return SentrySessionReplay(replayOptions: options, replayFolderPath: cacheFolder, screenshotProvider: screenshotProvider, @@ -85,7 +100,6 @@ class SentrySessionReplayTests: XCTestCase { touchTracker: touchTracker ?? SentryTouchTracker(dateProvider: dateProvider, scale: 0), dateProvider: dateProvider, delegate: self, - dispatchQueue: dispatchQueue, displayLinkWrapper: displayLink) }