From 676b27647506a7d209b8bf146fad94ef680fcf4b Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 25 Mar 2025 15:22:35 +0100 Subject: [PATCH 01/18] fix(session-replay): change multi-threading of session replay processing --- .../iOS-Swift/SentrySDKWrapper.swift | 2 +- Sentry.xcodeproj/project.pbxproj | 8 + Sources/Sentry/SentryDispatchQueueWrapper.m | 8 + .../Sentry/SentrySessionReplayIntegration.m | 103 +++-- .../include/SentryDispatchQueueWrapper.h | 2 + .../SessionReplay/SentryOnDemandReplay.swift | 372 ++++++++++++------ .../SentryOnDemandReplayError.swift | 6 + .../SessionReplay/SentryReplayFrame.swift | 7 + .../SentryReplayVideoMaker.swift | 2 +- .../SessionReplay/SentrySessionReplay.swift | 112 +++--- 10 files changed, 404 insertions(+), 218 deletions(-) create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplayError.swift create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryReplayFrame.swift diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 40453c79ad5..26dd4425c9f 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -19,7 +19,7 @@ struct SentrySDKWrapper { options.debug = true if #available(iOS 16.0, *), enableSessionReplay { - options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.sessionReplay = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) options.sessionReplay.quality = .high } diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 37fc1ee7a0c..813153ffbb5 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -819,6 +819,8 @@ D43B26D62D70964C007747FD /* SentrySpanOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D52D709648007747FD /* SentrySpanOperation.m */; }; D43B26D82D70A550007747FD /* SentryTraceOrigin.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D72D70A54A007747FD /* SentryTraceOrigin.m */; }; D43B26DA2D70A612007747FD /* SentrySpanDataKey.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D92D70A60E007747FD /* SentrySpanDataKey.m */; }; + D451ED5D2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */; }; + D451ED5F2D92ECDE00C9BEA8 /* SentryReplayFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */; }; D456B4322D706BDF007068CB /* SentrySpanOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4312D706BDD007068CB /* SentrySpanOperation.h */; }; D456B4362D706BF2007068CB /* SentryTraceOrigin.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4352D706BEE007068CB /* SentryTraceOrigin.h */; }; D456B4382D706BFE007068CB /* SentrySpanDataKey.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4372D706BFB007068CB /* SentrySpanDataKey.h */; }; @@ -1977,6 +1979,8 @@ D43B26D52D709648007747FD /* SentrySpanOperation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySpanOperation.m; sourceTree = ""; }; D43B26D72D70A54A007747FD /* SentryTraceOrigin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryTraceOrigin.m; sourceTree = ""; }; D43B26D92D70A60E007747FD /* SentrySpanDataKey.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySpanDataKey.m; sourceTree = ""; }; + D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplayError.swift; sourceTree = ""; }; + D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayFrame.swift; sourceTree = ""; }; D456B4312D706BDD007068CB /* SentrySpanOperation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanOperation.h; path = include/SentrySpanOperation.h; sourceTree = ""; }; D456B4352D706BEE007068CB /* SentryTraceOrigin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTraceOrigin.h; path = include/SentryTraceOrigin.h; sourceTree = ""; }; D456B4372D706BFB007068CB /* SentrySpanDataKey.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanDataKey.h; path = include/SentrySpanDataKey.h; sourceTree = ""; }; @@ -4259,6 +4263,8 @@ D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, + D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */, + D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */, D8F67B1A2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift */, @@ -5028,6 +5034,7 @@ 7B30B67E26527894006B2752 /* SentryDisplayLinkWrapper.m in Sources */, 63FE711D20DA4C1000CDBAE8 /* SentryCrashCPU_arm64.c in Sources */, 844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */, + D451ED5F2D92ECDE00C9BEA8 /* SentryReplayFrame.swift in Sources */, D8739CF92BECFFB5007D2F66 /* SentryTransactionNameSource.swift in Sources */, 630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */, 6281C5722D3E4F12009D0978 /* DecodeArbitraryData.swift in Sources */, @@ -5120,6 +5127,7 @@ D83D079C2B7F9D1C00CC9674 /* SentryMsgPackSerializer.m in Sources */, 7B6D1261265F784000C9BE4B /* PrivateSentrySDKOnly.mm in Sources */, 63BE85711ECEC6DE00DC44F5 /* SentryDateUtils.m in Sources */, + D451ED5D2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift in Sources */, D4E829D82D75E57900D375AD /* SentryMaskRenderer.swift in Sources */, 7BD4BD4927EB2A5D0071F4FF /* SentryDiscardedEvent.m in Sources */, 628308612D50ADAC00EAEF77 /* SentryRequestCodable.swift in Sources */, diff --git a/Sources/Sentry/SentryDispatchQueueWrapper.m b/Sources/Sentry/SentryDispatchQueueWrapper.m index 2663c12f49e..aa208253a37 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 0eb4de59798..9cc198a4226 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -57,6 +57,9 @@ @implementation SentrySessionReplayIntegration { // This is the easiest way to ensure segment 0 will always reach the server, because session // replay absolutely needs segment 0 to make replay work. BOOL _rateLimited; + id _dateProvider; + SentryDispatchQueueWrapper *_replayProcessingQueue; + SentryDispatchQueueWrapper *_replayAssetWorkerQueue; } - (instancetype)init @@ -120,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]; [self moveCurrentReplay]; [self cleanUp]; @@ -189,30 +205,54 @@ - (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; NSDate *beginning = hasCrashInfo ? [NSDate dateWithTimeIntervalSinceReferenceDate:crashInfo.lastSegmentEnd] : [resumeReplayMaker oldestFrameDate]; - if (beginning == nil) { return; // no frames to send } - - SentryReplayType _type = type; - int _segmentId = segmentId; - - NSError *error; - NSArray *videos = - [resumeReplayMaker createVideoWithBeginning:beginning - end:[beginning dateByAddingTimeInterval:duration] - error:&error]; + NSDate *end = [beginning dateByAddingTimeInterval:duration]; + + // This method is called from a background thread, so we can synchronize the creation of the + // video with a dispatch group. + __block NSArray *videos; + __block NSError *_Nullable error; + + dispatch_group_t group = dispatch_group_create(); + dispatch_group_enter(group); + [resumeReplayMaker + createVideoAsyncWithBeginning:beginning + end:end + completion:^(NSArray *_Nullable resultVideos, + NSError *_Nullable resultError) { + videos = resultVideos; + error = resultError; + dispatch_group_leave(group); + }]; + // Wait for the video creation to finish without a timeout, because the video creation is + // expected to finish in a reasonable time frame. + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + + // 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: %@", error); + 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; + SentryReplayType _type = type; for (SentryVideoInfo *video in videos) { [self captureVideo:video replayId:replayId segmentId:_segmentId++ type:_type]; // type buffer is only for the first segment @@ -224,8 +264,10 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event [NSDictionary dictionaryWithObjectsAndKeys:replayId.sentryIdString, @"replay_id", nil]; event.context = eventContext; - if ([NSFileManager.defaultManager removeItemAtURL:lastReplayURL error:&error] == NO) { - SENTRY_LOG_ERROR(@"Can`t delete '%@': %@", SENTRY_LAST_REPLAY, error); + NSError *_Nullable removeError; + BOOL result = [NSFileManager.defaultManager removeItemAtURL:lastReplayURL error:&removeError]; + if (result == NO) { + SENTRY_LOG_ERROR(@"Can`t delete '%@': %@", SENTRY_LAST_REPLAY, removeError); } } @@ -316,30 +358,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 7859c74ac55..56748389cde 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 6017a98a5f9..5f37dd62343 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -7,27 +7,17 @@ import CoreGraphics import Foundation import UIKit -struct SentryReplayFrame { - let imagePath: String - let time: Date - let screenName: String? -} - -enum SentryOnDemandReplayError: Error { - case cantReadVideoSize - case cantCreatePixelBuffer - case errorRenderingVideo -} - +// swiftlint:disable type_body_length @objcMembers 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 //This is exposed only for tests, no need to make it thread safe. var frames: [SentryReplayFrame] { @@ -40,44 +30,57 @@ 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) + } + + /// Loads the frames from the given path. + /// + /// - 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 { - let content = try FileManager.default.contentsOfDirectory(atPath: outputPath) - _frames = content.compactMap { - guard $0.hasSuffix(".png") else { return SentryReplayFrame?.none } - guard let time = Double($0.dropLast(4)) else { return nil } - return SentryReplayFrame(imagePath: "\(outputPath)/\($0)", time: Date(timeIntervalSinceReferenceDate: time), screenName: nil) + let content = try FileManager.default.contentsOfDirectory(atPath: path) + _frames = content.compactMap { frameFilePath in + guard frameFilePath.hasSuffix(".png") else { return SentryReplayFrame?.none } + guard let time = Double(frameFilePath.dropLast(4)) else { return nil } + let timestamp = Date(timeIntervalSinceReferenceDate: time) + return SentryReplayFrame(imagePath: "\(path)/\(frameFilePath)", time: timestamp, screenName: nil) }.sorted { $0.time < $1.time } + SentryLog.debug("[Session Replay] Loaded \(content.count) files into \(_frames.count) frames from path: \(path)") } catch { - SentryLog.debug("Could not list frames from replay: \(error.localizedDescription)") - return + SentryLog.debug("[Session Replay] Could not list frames from replay: \(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({ + processingQueue.dispatchAsync { self.addFrame(image: image, forScreen: forScreen) - }) + } } private func addFrame(image: UIImage, forScreen: String?) { @@ -88,7 +91,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { do { try data.write(to: URL(fileURLWithPath: imagePath)) } catch { - SentryLog.debug("Could not save replay frame. Error: \(error)") + SentryLog.debug("[Session Replay] Could not save replay frame. Error: \(error)") return } _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) @@ -111,135 +114,247 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } func releaseFramesUntil(_ date: Date) { - workingQueue.dispatchAsync ({ + processingQueue.dispatchAsync { + SentryLog.debug("[Session Replay] Releasing frames until date: \(date), current queue: \(self.processingQueue.queue.label)") while let first = self._frames.first, first.time < date { self._frames.removeFirst() - try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + let fileUrl = URL(fileURLWithPath: first.imagePath) + do { + SentryLog.debug("[Session Replay] Removing frame at url: \(fileUrl.path)") + try FileManager.default.removeItem(at: fileUrl) + SentryLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)") + } catch { + SentryLog.warning("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error.localizedDescription), ignoring error") + } } - }) + } } var oldestFrameDate: Date? { return _frames.first?.time } - - func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { - let videoFrames = filterFrames(beginning: beginning, end: 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++ - } + + func createVideoAsyncWith(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 here. + SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") + let videoFrames = getFilteredFrames(beginning: beginning, end: end) + + // Dispatch the video creation to a background queue to avoid blocking the calling queue. + let outputPath = self._outputPath + processingQueue.dispatchAsync { + SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end), current queue: \(self.processingQueue.queue.label)") + + do { + // Use a semaphore to wait for each video segment to finish. + let semaphore = DispatchSemaphore(value: 0) + var currentError: Error? + var frameCount = 0 + var videos = [SentryVideoInfo]() + while frameCount < videoFrames.count { + let outputFileURL = URL(fileURLWithPath: outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) + + self.renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { result in + // Do not use `processingQueue` here, since it will be blocked by the semaphore. + switch result { + case .success(let videoInfo): + if let videoInfo = videoInfo { + videos.append(videoInfo) + } + case .failure(let error): + currentError = error + } + semaphore.signal() + } + + // Calling semaphore.wait will block the `processingQueue` until the video rendering completes or a timeout occurs. + // It is imporant that the renderVideo completion block signals the semaphore. + // 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. + if semaphore.wait(timeout: .now() + 2) == .timedOut { + currentError = SentryOnDemandReplayError.errorRenderingVideo + break + } + + // If there was an error, throw it to exit the loop. + if let error = currentError { + throw error + } + } + completion(videos, nil) + } catch { + SentryLog.debug("[Session Replay] Failed to create video with error: \(error)") + completion(nil, error) + } } - 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 } + + private func getFilteredFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { + var frames = [SentryReplayFrame]() + // Using dispatch queue as sync mechanism since we need a queue already to generate the video. + processingQueue.dispatchSync { + frames = self._frames.filter { $0.time >= beginning && $0.time <= end } + } + return frames + } + + // swiftlint:disable function_body_length + private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout 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.debug("[Session Replay] Failed to render video, reason: index out of bounds or can't read image at path: \(videoFrames[from].imagePath)") + return completion(.success(nil)) + } + 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.debug("[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.debug("[Session Replay] Failed to create pixel buffer, reason: \(SentryOnDemandReplayError.cantCreatePixelBuffer)") + return completion(.failure(SentryOnDemandReplayError.cantCreatePixelBuffer)) + } videoWriter.add(videoWriterInput) + videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - + + // 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. var lastImageSize: CGSize = image.size var usedFrames = [SentryReplayFrame]() - let group = DispatchGroup() - - var result: Result? var frameCount = from - - group.enter() - videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { + videoWriterInput.requestMediaDataWhenReady(on: assetWorkerQueue.queue) { [weak self] in + guard let self = self else { + SentryLog.warning("[Session Replay] On-demand replay is deallocated, completing writing session without output video info") + return completion(.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 completion(.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 frameCount < videoFrames.count else { + SentryLog.debug("[Session Replay] No more frames available to process, finishing the video") + return self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter, onCompletion: completion) } + 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 + guard lastImageSize == image.size else { + SentryLog.debug("[Session Replay] Image size changed, finishing the video") + return self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter, onCompletion: completion) } lastImageSize = image.size - + let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) - if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true { + guard currentPixelBuffer.append(image: image, presentationTime: presentTime) == true else { + SentryLog.debug("[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 completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } usedFrames.append(frame) } frameCount += 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 + + 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 { - defer { group.leave() } - if videoWriter.status == .completed { + videoWriter.finishWriting { [weak self] in + 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 completion(.success(nil)) + } + + switch videoWriter.status { + case .writing: + // noop + break + case .cancelled: + 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") 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 })) + let result = try strongSelf.getVideoInfo( + from: outputFileURL, + usedFrames: usedFrames, + videoWidth: Int(videoWidth), + videoHeight: Int(videoHeight) + ) + completion(.success(result)) } catch { - finishError = error + SentryLog.warning("[Session Replay] Failed to create video info from file attributes, reason: \(error.localizedDescription)") + completion(.failure(error)) } + case .failed: + SentryLog.warning("[Session Replay] Finish writing video failed, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") + 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")") + 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 { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) + guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else { + SentryLog.warning("[Session Replay] Failed to read video size from video file, reason: size attribute not found") + throw SentryOnDemandReplayError.cantReadVideoSize + } + guard let start = usedFrames.min(by: { $0.time < $1.time })?.time else { + SentryLog.warning("[Session Replay] Failed to read video start time from used frames, reason: no frames found") + throw SentryOnDemandReplayError.cantReadVideoStartTime + } + let duration = TimeInterval(usedFrames.count / self.frameRate) + return SentryVideoInfo( + path: outputFileURL, + height: videoHeight, + width: videoWidth, + duration: duration, + frameCount: usedFrames.count, + frameRate: self.frameRate, + start: start, + end: start.addingTimeInterval(duration), + fileSize: fileSize, + screens: usedFrames.compactMap({ $0.screenName }) + ) } - + private func createVideoSettings(width: CGFloat, height: CGFloat) -> [String: Any] { return [ AVVideoCodecKey: AVVideoCodecType.h264, @@ -252,6 +367,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { ] } } +// swiftlint:enable type_body_length #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplayError.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplayError.swift new file mode 100644 index 00000000000..7b060ad97d1 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplayError.swift @@ -0,0 +1,6 @@ +enum SentryOnDemandReplayError: Error { + case cantReadVideoSize + case cantCreatePixelBuffer + case errorRenderingVideo + case cantReadVideoStartTime +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayFrame.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayFrame.swift new file mode 100644 index 00000000000..0497b784f61 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayFrame.swift @@ -0,0 +1,7 @@ +import Foundation + +struct SentryReplayFrame { + let imagePath: String + let time: Date + let screenName: String? +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 32831f38642..9ea18313eea 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -6,7 +6,7 @@ import UIKit protocol SentryReplayVideoMaker: NSObjectProtocol { func addFrameAsync(image: UIImage, forScreen: String?) func releaseFramesUntil(_ date: Date) - func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] + func createVideoAsyncWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) } extension SentryReplayVideoMaker { diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 49e856871ab..ae57daf7bad 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -21,7 +21,7 @@ protocol SentrySessionReplayDelegate: NSObjectProtocol { class SentrySessionReplay: NSObject { private(set) var isFullSession = false private(set) var sessionReplayId: SentryId? - + private var urlToCache: URL? private var rootView: UIView? private var lastScreenShot: Date? @@ -39,7 +39,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 +49,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 @@ -74,7 +72,7 @@ class SentrySessionReplay: NSObject { } deinit { displayLink.invalidate() } - + func start(rootView: UIView, fullSession: Bool) { guard !isRunning else { return } displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) @@ -84,19 +82,19 @@ class SentrySessionReplay: NSObject { currentSegmentId = 0 sessionReplayId = SentryId() imageCollection = [] - + if fullSession { startFullReplay() } } - + private func startFullReplay() { sessionStart = lastScreenShot isFullSession = true guard let sessionReplayId = sessionReplayId else { return } delegate?.sessionReplayStarted(replayId: sessionReplayId) } - + func pauseSessionMode() { lock.lock() defer { lock.unlock() } @@ -115,7 +113,7 @@ class SentrySessionReplay: NSObject { } isSessionPaused = false } - + func resume() { lock.lock() defer { lock.unlock() } @@ -131,57 +129,57 @@ class SentrySessionReplay: NSObject { videoSegmentStart = nil displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) } - + func captureReplayFor(event: Event) { guard isRunning else { return } - + if isFullSession { setEventContext(event: event) return } - + guard (event.error != nil || event.exceptions?.isEmpty == false) - && captureReplay() else { return } + && captureReplay() else { return } setEventContext(event: event) } - + @discardableResult func captureReplay() -> Bool { guard isRunning else { return false } guard !isFullSession else { return true } - + guard delegate?.sessionReplayShouldCaptureReplayForError() == true else { return false } - + startFullReplay() let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - - createAndCapture(startedAt: replayStart, replayType: .buffer) + + createAndCaptureAsync(startedAt: replayStart, replayType: .buffer) return true } - + private func setEventContext(event: Event) { guard let sessionReplayId = sessionReplayId, event.type != "replay_video" else { return } - + var context = event.context ?? [:] context["replay"] = ["replay_id": sessionReplayId.sentryIdString] event.context = context - + var tags = ["replayId": sessionReplayId.sentryIdString] if let eventTags = event.tags { tags.merge(eventTags) { (_, new) in new } } event.tags = tags } - + @objc private func newFrame(_ sender: CADisplayLink) { 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() if let sessionStart = sessionStart, isFullSession && now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { @@ -189,7 +187,7 @@ class SentrySessionReplay: NSObject { pause() return } - + if now.timeIntervalSince(lastScreenShot) >= Double(1 / replayOptions.frameRate) { takeScreenshot() self.lastScreenShot = now @@ -202,11 +200,11 @@ class SentrySessionReplay: NSObject { } } } - + private func prepareSegmentUntil(date: Date) { guard var pathToSegment = urlToCache?.appendingPathComponent("segments") else { return } let fileManager = FileManager.default - + if !fileManager.fileExists(atPath: pathToSegment.path) { do { try fileManager.createDirectory(atPath: pathToSegment.path, withIntermediateDirectories: true, attributes: nil) @@ -215,30 +213,32 @@ class SentrySessionReplay: NSObject { return } } - + pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - - createAndCapture(startedAt: segmentStart, replayType: .session) + + createAndCaptureAsync(startedAt: segmentStart, replayType: .session) } - - 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()) + + private func createAndCaptureAsync(startedAt: Date, replayType: SentryReplayType) { + SentryLog.debug("[Session Replay] Creating replay video started at date: \(startedAt), replayType: \(replayType)") + // Creating a video is computationally expensive, therefore perform it on a background queue. + self.replayMaker.createVideoAsyncWith(beginning: startedAt, end: self.dateProvider.date()) { videos, error in + if let error = error { + SentryLog.debug("[Session Replay] Could not create replay video - \(error.localizedDescription)") + } else if let videos = videos { + SentryLog.debug("[Session Replay] Created replay video with \(videos.count) segments") for video in videos { self.newSegmentAvailable(videoInfo: video, replayType: replayType) } - } catch { - SentryLog.debug("Could not create replay video - \(error.localizedDescription)") + } else { + SentryLog.debug("[Session Replay] Finished replay video creation without any 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 } captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: replayType) replayMaker.releaseFramesUntil(videoInfo.end) @@ -254,7 +254,7 @@ class SentrySessionReplay: NSObject { replayEvent.urls = video.screens let breadcrumbs = delegate?.breadcrumbsForSessionReplay() ?? [] - + var events = convertBreadcrumbs(breadcrumbs: breadcrumbs, from: video.start, until: video.end) if let touchTracker = touchTracker { events.append(contentsOf: touchTracker.replayEvents(from: videoSegmentStart ?? video.start, until: video.end)) @@ -270,9 +270,9 @@ class SentrySessionReplay: NSObject { } let recording = SentryReplayRecording(segmentId: segment, video: video, extraEvents: events) - + delegate?.sessionReplayNewSegment(replayEvent: replayEvent, replayRecording: recording, videoUrl: video.path) - + do { try FileManager.default.removeItem(at: video.path) } catch { @@ -302,7 +302,7 @@ class SentrySessionReplay: NSObject { private func takeScreenshot() { guard let rootView = rootView, !processingScreenshot else { return } - + lock.lock() guard !processingScreenshot else { lock.unlock() @@ -310,14 +310,14 @@ class SentrySessionReplay: NSObject { } processingScreenshot = true lock.unlock() - + let screenName = delegate?.currentScreenNameForSessionReplay() screenshotProvider.image(view: rootView) { [weak self] screenshot in self?.newImage(image: screenshot, forScreen: screenName) } } - + private func newImage(image: UIImage, forScreen screen: String?) { lock.synchronized { processingScreenshot = false From 2fda9c4f94479d4929451fac7ba36ec538c0e156 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 25 Mar 2025 15:36:07 +0100 Subject: [PATCH 02/18] small fixes --- .../iOS-Swift/SentrySDKWrapper.swift | 2 +- .../SessionReplay/SentrySessionReplay.swift | 73 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 26dd4425c9f..40453c79ad5 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -19,7 +19,7 @@ struct SentrySDKWrapper { options.debug = true if #available(iOS 16.0, *), enableSessionReplay { - options.sessionReplay = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) options.sessionReplay.quality = .high } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index ae57daf7bad..363def8ed1c 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -21,7 +21,7 @@ protocol SentrySessionReplayDelegate: NSObjectProtocol { class SentrySessionReplay: NSObject { private(set) var isFullSession = false private(set) var sessionReplayId: SentryId? - + private var urlToCache: URL? private var rootView: UIView? private var lastScreenShot: Date? @@ -72,7 +72,7 @@ class SentrySessionReplay: NSObject { } deinit { displayLink.invalidate() } - + func start(rootView: UIView, fullSession: Bool) { guard !isRunning else { return } displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) @@ -82,19 +82,19 @@ class SentrySessionReplay: NSObject { currentSegmentId = 0 sessionReplayId = SentryId() imageCollection = [] - + if fullSession { startFullReplay() } } - + private func startFullReplay() { sessionStart = lastScreenShot isFullSession = true guard let sessionReplayId = sessionReplayId else { return } delegate?.sessionReplayStarted(replayId: sessionReplayId) } - + func pauseSessionMode() { lock.lock() defer { lock.unlock() } @@ -113,7 +113,7 @@ class SentrySessionReplay: NSObject { } isSessionPaused = false } - + func resume() { lock.lock() defer { lock.unlock() } @@ -129,57 +129,57 @@ class SentrySessionReplay: NSObject { videoSegmentStart = nil displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) } - + func captureReplayFor(event: Event) { guard isRunning else { return } - + if isFullSession { setEventContext(event: event) return } - + guard (event.error != nil || event.exceptions?.isEmpty == false) && captureReplay() else { return } setEventContext(event: event) } - + @discardableResult func captureReplay() -> Bool { guard isRunning else { return false } guard !isFullSession else { return true } - + guard delegate?.sessionReplayShouldCaptureReplayForError() == true else { return false } - + startFullReplay() let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - + createAndCaptureAsync(startedAt: replayStart, replayType: .buffer) return true } - + private func setEventContext(event: Event) { guard let sessionReplayId = sessionReplayId, event.type != "replay_video" else { return } - + var context = event.context ?? [:] context["replay"] = ["replay_id": sessionReplayId.sentryIdString] event.context = context - + var tags = ["replayId": sessionReplayId.sentryIdString] if let eventTags = event.tags { tags.merge(eventTags) { (_, new) in new } } event.tags = tags } - + @objc private func newFrame(_ sender: CADisplayLink) { 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() if let sessionStart = sessionStart, isFullSession && now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { @@ -187,7 +187,7 @@ class SentrySessionReplay: NSObject { pause() return } - + if now.timeIntervalSince(lastScreenShot) >= Double(1 / replayOptions.frameRate) { takeScreenshot() self.lastScreenShot = now @@ -200,11 +200,11 @@ class SentrySessionReplay: NSObject { } } } - + private func prepareSegmentUntil(date: Date) { guard var pathToSegment = urlToCache?.appendingPathComponent("segments") else { return } let fileManager = FileManager.default - + if !fileManager.fileExists(atPath: pathToSegment.path) { do { try fileManager.createDirectory(atPath: pathToSegment.path, withIntermediateDirectories: true, attributes: nil) @@ -213,30 +213,31 @@ class SentrySessionReplay: NSObject { return } } - + pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - + createAndCaptureAsync(startedAt: segmentStart, replayType: .session) } - + private func createAndCaptureAsync(startedAt: Date, replayType: SentryReplayType) { SentryLog.debug("[Session Replay] Creating replay video started at date: \(startedAt), replayType: \(replayType)") // Creating a video is computationally expensive, therefore perform it on a background queue. self.replayMaker.createVideoAsyncWith(beginning: startedAt, end: self.dateProvider.date()) { videos, error in if let error = error { SentryLog.debug("[Session Replay] Could not create replay video - \(error.localizedDescription)") - } else if let videos = videos { - SentryLog.debug("[Session Replay] Created replay video with \(videos.count) segments") - for video in videos { - self.newSegmentAvailable(videoInfo: video, replayType: replayType) - } - } else { + } + guard let videos = videos else { SentryLog.debug("[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) } } } - + 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 } @@ -254,7 +255,7 @@ class SentrySessionReplay: NSObject { replayEvent.urls = video.screens let breadcrumbs = delegate?.breadcrumbsForSessionReplay() ?? [] - + var events = convertBreadcrumbs(breadcrumbs: breadcrumbs, from: video.start, until: video.end) if let touchTracker = touchTracker { events.append(contentsOf: touchTracker.replayEvents(from: videoSegmentStart ?? video.start, until: video.end)) @@ -270,9 +271,9 @@ class SentrySessionReplay: NSObject { } let recording = SentryReplayRecording(segmentId: segment, video: video, extraEvents: events) - + delegate?.sessionReplayNewSegment(replayEvent: replayEvent, replayRecording: recording, videoUrl: video.path) - + do { try FileManager.default.removeItem(at: video.path) } catch { @@ -302,7 +303,7 @@ class SentrySessionReplay: NSObject { private func takeScreenshot() { guard let rootView = rootView, !processingScreenshot else { return } - + lock.lock() guard !processingScreenshot else { lock.unlock() @@ -317,7 +318,7 @@ class SentrySessionReplay: NSObject { self?.newImage(image: screenshot, forScreen: screenName) } } - + private func newImage(image: UIImage, forScreen screen: String?) { lock.synchronized { processingScreenshot = false From aa2691583f9cc30f7a47711210dbed8968337dd1 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 25 Mar 2025 15:38:13 +0100 Subject: [PATCH 03/18] improvements --- .../SessionReplay/SentrySessionReplay.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 363def8ed1c..a46fe461a32 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -138,8 +138,9 @@ class SentrySessionReplay: NSObject { return } - guard (event.error != nil || event.exceptions?.isEmpty == false) - && captureReplay() else { return } + guard (event.error != nil || event.exceptions?.isEmpty == false) && captureReplay() else { + return + } setEventContext(event: event) } @@ -225,16 +226,17 @@ class SentrySessionReplay: NSObject { // Creating a video is computationally expensive, therefore perform it on a background queue. self.replayMaker.createVideoAsyncWith(beginning: startedAt, end: self.dateProvider.date()) { videos, error in if let error = error { - SentryLog.debug("[Session Replay] Could not create replay video - \(error.localizedDescription)") + SentryLog.error("[Session Replay] Could not create replay video - \(error.localizedDescription)") } guard let videos = videos else { - SentryLog.debug("[Session Replay] Finished replay video creation without any segments") + 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 replay video creation with \(videos.count) segments") } } From a27d74d4f8cd7bcf0c136b4d242ef0e0faeae638 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 25 Mar 2025 15:42:35 +0100 Subject: [PATCH 04/18] WIP --- .../SessionReplay/SentryOnDemandReplay.swift | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 5f37dd62343..fc8841e9270 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -137,14 +137,14 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { func createVideoAsyncWith(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 here. - SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") - let videoFrames = getFilteredFrames(beginning: beginning, end: end) + SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") // Dispatch the video creation to a background queue to avoid blocking the calling queue. - let outputPath = self._outputPath processingQueue.dispatchAsync { SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end), current queue: \(self.processingQueue.queue.label)") + let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } + do { // Use a semaphore to wait for each video segment to finish. let semaphore = DispatchSemaphore(value: 0) @@ -152,7 +152,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { var frameCount = 0 var videos = [SentryVideoInfo]() while frameCount < videoFrames.count { - let outputFileURL = URL(fileURLWithPath: outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) + let outputFileURL = URL(fileURLWithPath: self._outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) self.renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { result in // Do not use `processingQueue` here, since it will be blocked by the semaphore. @@ -189,15 +189,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } - private func getFilteredFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { - var frames = [SentryReplayFrame]() - // Using dispatch queue as sync mechanism since we need a queue already to generate the video. - processingQueue.dispatchSync { - frames = self._frames.filter { $0.time >= beginning && $0.time <= end } - } - return frames - } - // swiftlint:disable function_body_length private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout 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)") From 86341269caa65efb2360f7899dcda7186a8ff6e3 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 11 Apr 2025 12:41:06 +0200 Subject: [PATCH 05/18] wip --- .../iOS-Swift/SentrySDKWrapper.swift | 7 +- Sentry.xcodeproj/project.pbxproj | 4 + .../SessionReplay/SentryOnDemandReplay.swift | 95 +++++++++++++------ .../SentryRenderVideoResult.swift | 4 + .../SentrySessionReplayTests.swift | 8 +- 5 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 1b7590fbef3..cf1119685d0 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -19,7 +19,12 @@ struct SentrySDKWrapper { options.debug = true if #available(iOS 16.0, *), enableSessionReplay { - options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.sessionReplay = SentryReplayOptions( + sessionSampleRate: 0, + onErrorSampleRate: 1, + maskAllText: true, + maskAllImages: true + ) options.sessionReplay.quality = .high } diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index e1cddf21f07..32bbdd382fc 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -835,6 +835,7 @@ 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 */; }; @@ -2014,6 +2015,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 = ""; }; D4C5F5992D4249E0002A9BF6 /* DataSentryTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSentryTracingIntegrationTests.swift; sourceTree = ""; }; D4E829D12D75E2DE00D375AD /* SentryDefaultViewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryDefaultViewRenderer.swift; sourceTree = ""; }; D4E829D32D75E34A00D375AD /* SentryExperimentalViewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExperimentalViewRenderer.swift; sourceTree = ""; }; @@ -4269,6 +4271,7 @@ D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, + D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */, D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */, D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, @@ -4959,6 +4962,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 */, diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index fc8841e9270..9312be314c3 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) @@ -65,19 +66,21 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { SentryLog.debug("[Session Replay] Loading frames from path: \(path)") do { let content = try FileManager.default.contentsOfDirectory(atPath: path) - _frames = content.compactMap { frameFilePath in - guard frameFilePath.hasSuffix(".png") else { return SentryReplayFrame?.none } + _frames = content.compactMap { frameFilePath -> SentryReplayFrame? in + guard frameFilePath.hasSuffix(".png") else { return nil } guard let time = Double(frameFilePath.dropLast(4)) else { return nil } let timestamp = Date(timeIntervalSinceReferenceDate: time) return SentryReplayFrame(imagePath: "\(path)/\(frameFilePath)", time: timestamp, screenName: nil) }.sorted { $0.time < $1.time } SentryLog.debug("[Session Replay] Loaded \(content.count) files into \(_frames.count) frames from path: \(path)") } catch { - SentryLog.debug("[Session Replay] Could not list frames from replay: \(error.localizedDescription)") + SentryLog.error("[Session Replay] Could not list frames from replay: \(error.localizedDescription)") } } func addFrameAsync(image: UIImage, forScreen: String?) { + // 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) } @@ -91,11 +94,12 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { do { try data.write(to: URL(fileURLWithPath: imagePath)) } catch { - SentryLog.debug("[Session Replay] Could not save replay frame. Error: \(error)") + SentryLog.error("[Session Replay] Could not save replay frame. Error: \(error)") return } _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) - + + // 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)) @@ -120,11 +124,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { self._frames.removeFirst() let fileUrl = URL(fileURLWithPath: first.imagePath) do { - SentryLog.debug("[Session Replay] Removing frame at url: \(fileUrl.path)") try FileManager.default.removeItem(at: fileUrl) SentryLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)") } catch { - SentryLog.warning("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error.localizedDescription), ignoring error") + SentryLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error.localizedDescription), ignoring error") } } } @@ -149,19 +152,23 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // Use a semaphore to wait for each video segment to finish. let semaphore = DispatchSemaphore(value: 0) var currentError: Error? - var frameCount = 0 + var frameIndex = 0 var videos = [SentryVideoInfo]() - while frameCount < videoFrames.count { - let outputFileURL = URL(fileURLWithPath: self._outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) - - self.renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { result in + while frameIndex < videoFrames.count { + let frame = videoFrames[frameIndex] + let outputFileURL = URL(fileURLWithPath: self._outputPath) + .appendingPathComponent("\(frame.time.timeIntervalSinceReferenceDate)") + .appendingPathExtension("mp4") + self.renderVideo(with: videoFrames, from: frameIndex, at: outputFileURL) { result in // Do not use `processingQueue` here, since it will be blocked by the semaphore. switch result { - case .success(let videoInfo): - if let videoInfo = videoInfo { + case .success(let videoResult): + frameIndex = videoResult.finalFrameIndex + 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 } semaphore.signal() @@ -172,6 +179,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // 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. if semaphore.wait(timeout: .now() + 2) == .timedOut { + SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") currentError = SentryOnDemandReplayError.errorRenderingVideo break } @@ -180,21 +188,26 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { if let error = currentError { throw error } + + SentryLog.debug("[Session Replay] Finished rendering video, frame count moved to: \(frameIndex)") } completion(videos, nil) } catch { - SentryLog.debug("[Session Replay] Failed to create video with error: \(error)") + SentryLog.error("[Session Replay] Failed to create video with error: \(error)") completion(nil, error) } } } - // swiftlint:disable function_body_length - private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout Int, at outputFileURL: URL, completion: @escaping (Result) -> Void) { + // 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.debug("[Session Replay] Failed to render video, reason: index out of bounds or can't read image at path: \(videoFrames[from].imagePath)") - return completion(.success(nil)) + return completion(.success(SentryRenderVideoResult( + info: nil, + finalFrameIndex: from + ))) } let videoWidth = image.size.width * CGFloat(videoScale) @@ -232,31 +245,58 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // This is important to avoid a deadlock, as this method is called on the processing queue. var lastImageSize: CGSize = image.size var usedFrames = [SentryReplayFrame]() - var frameCount = from + var frameIndex = from + + // Convenience wrapper to handle the completion callback + 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)) + } + } videoWriterInput.requestMediaDataWhenReady(on: assetWorkerQueue.queue) { [weak self] in guard let self = self else { SentryLog.warning("[Session Replay] On-demand replay is deallocated, completing writing session without output video info") - return completion(.success(nil)) + 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() return completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } - guard frameCount < videoFrames.count else { + guard frameIndex < videoFrames.count else { SentryLog.debug("[Session Replay] No more frames available to process, finishing the video") - return self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter, onCompletion: completion) + return self.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) { guard lastImageSize == image.size else { SentryLog.debug("[Session Replay] Image size changed, finishing the video") - return self.finishVideo(outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), videoWidth: Int(videoWidth), videoWriter: videoWriter, onCompletion: completion) + return self.finishVideo( + outputFileURL: outputFileURL, + usedFrames: usedFrames, + videoHeight: Int(videoHeight), + videoWidth: Int(videoWidth), + videoWriter: videoWriter, + onCompletion: deferredCompletionCallback + ) } lastImageSize = image.size - let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) + let presentTime = CMTime(seconds: Double(frameIndex), preferredTimescale: CMTimeScale(1 / self.frameRate)) guard currentPixelBuffer.append(image: image, presentationTime: presentTime) == true else { SentryLog.debug("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") videoWriter.cancelWriting() @@ -264,10 +304,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } usedFrames.append(frame) } - frameCount += 1 + frameIndex += 1 } } - // swiftlint:enable function_body_length + // swiftlint:enable function_body_length cyclomatic_complexity private func finishVideo( outputFileURL: URL, @@ -362,3 +402,4 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) +// swiftlint:enable file_length diff --git a/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift b/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift new file mode 100644 index 00000000000..4b6e6056700 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentryRenderVideoResult.swift @@ -0,0 +1,4 @@ +struct SentryRenderVideoResult { + let info: SentryVideoInfo? + let finalFrameIndex: Int +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index f04d71a00e0..42eb29a097d 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -35,7 +35,11 @@ class SentrySessionReplayTests: XCTestCase { } var lastCallToCreateVideo: CreateVideoCall? - func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + func createVideoAsyncWith( + beginning: Date, + end: Date, + completion: @escaping ([Sentry.SentryVideoInfo]?, (any Error)?) -> Void + ) { lastCallToCreateVideo = CreateVideoCall(beginning: beginning, end: end) let outputFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("tempvideo.mp4") @@ -43,7 +47,7 @@ class SentrySessionReplayTests: XCTestCase { let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: end.timeIntervalSince(overrideBeginning ?? beginning), frameCount: 5, frameRate: 1, start: overrideBeginning ?? beginning, end: end, fileSize: 10, screens: screens) createVideoCallBack?(videoInfo) - return [videoInfo] + return completion([videoInfo], nil) } var lastFrame: UIImage? From 3ff7c842cc3d98d9db57e61a5f08a8bbe693f6ff Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 11 Apr 2025 12:43:10 +0200 Subject: [PATCH 06/18] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67eeebb846a..9a2321e3f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Crash in setMeasurement when name is nil (#5064) - Make setMeasurement thread safe (#5067, #5078) +- Fix thread inversion warning in session replay (#5018) ## 8.49.0 From 44c4af72f68c588083c5c351b853ed8b76821275 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 11 Apr 2025 15:49:33 +0200 Subject: [PATCH 07/18] Apply suggestions from code review --- .../Integrations/SessionReplay/SentryOnDemandReplay.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index c628aa712b4..3cb85628bfc 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -139,7 +139,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { func createVideoAsyncWith(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 here. + // Due to interoperability with Objective-C and @objc, we can not use Result for the completion callback. SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") // Dispatch the video creation to a background queue to avoid blocking the calling queue. @@ -203,7 +203,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { 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.debug("[Session Replay] Failed to render video, reason: index out of bounds or can't read image at path: \(videoFrames[from].imagePath)") + 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 @@ -218,7 +218,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { do { videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) } catch { - SentryLog.debug("[Session Replay] Failed to create video writer, reason: \(error)") + SentryLog.error("[Session Replay] Failed to create video writer, reason: \(error)") return completion(.failure(error)) } From 773fcadd69ea8faef80bbb70cd24f7217dbb4c9f Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Apr 2025 10:30:17 +0200 Subject: [PATCH 08/18] reverting changes --- CHANGELOG.md | 6 + .../Sentry/SentrySessionReplayIntegration.m | 23 +--- .../SessionReplay/SentryOnDemandReplay.swift | 110 +++++++++--------- .../SentryReplayVideoMaker.swift | 3 +- .../SessionReplay/SentrySessionReplay.swift | 8 +- 5 files changed, 74 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d75dc3f26..391b4a5d7c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix thread inversion warning in session replay (#5018) + ## 8.49.1 ### Fixes diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index dffd0b9ef17..30ed5de58e9 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -223,25 +223,10 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event } NSDate *end = [beginning dateByAddingTimeInterval:duration]; - // This method is called from a background thread, so we can synchronize the creation of the - // video with a dispatch group. - __block NSArray *videos; - __block NSError *_Nullable error; - - dispatch_group_t group = dispatch_group_create(); - dispatch_group_enter(group); - [resumeReplayMaker - createVideoAsyncWithBeginning:beginning - end:end - completion:^(NSArray *_Nullable resultVideos, - NSError *_Nullable resultError) { - videos = resultVideos; - error = resultError; - dispatch_group_leave(group); - }]; - // Wait for the video creation to finish without a timeout, because the video creation is - // expected to finish in a reasonable time frame. - dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + NSError *error; + NSArray *videos = [resumeReplayMaker createVideoWithBeginning:beginning + end:end + error:&error]; // Either error or videos should be set. if (error != nil) { diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 3cb85628bfc..4c01c960fb8 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -1,4 +1,4 @@ -// swiftlint:disable file_length +// swiftlint:disable file_length type_body_length #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) @@ -137,66 +137,72 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return _frames.first?.time } - func createVideoAsyncWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) { + 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 with beginning: \(beginning), end: \(end)") - - // Dispatch the video creation to a background queue to avoid blocking the calling queue. + SentryLog.debug("[Session Replay] Creating video in background with beginning: \(beginning), end: \(end)") processingQueue.dispatchAsync { - SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end), current queue: \(self.processingQueue.queue.label)") - - let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } - do { - // Use a semaphore to wait for each video segment to finish. - let semaphore = DispatchSemaphore(value: 0) - var currentError: Error? - var frameIndex = 0 - var videos = [SentryVideoInfo]() - while frameIndex < videoFrames.count { - let frame = videoFrames[frameIndex] - let outputFileURL = URL(fileURLWithPath: self._outputPath) - .appendingPathComponent("\(frame.time.timeIntervalSinceReferenceDate)") - .appendingPathExtension("mp4") - self.renderVideo(with: videoFrames, from: frameIndex, at: outputFileURL) { result in - // Do not use `processingQueue` here, since it will be blocked by the semaphore. - switch result { - case .success(let videoResult): - frameIndex = videoResult.finalFrameIndex - 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 - } - semaphore.signal() - } + 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) + } + } + } - // Calling semaphore.wait will block the `processingQueue` until the video rendering completes or a timeout occurs. - // It is imporant that the renderVideo completion block signals the semaphore. - // 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. - if semaphore.wait(timeout: .now() + 2) == .timedOut { - SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") - currentError = SentryOnDemandReplayError.errorRenderingVideo - break - } + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") - // If there was an error, throw it to exit the loop. - if let error = currentError { - throw error - } + let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } + + var frameCount = 0 + var videos = [SentryVideoInfo]() - SentryLog.debug("[Session Replay] Finished rendering video, frame count moved to: \(frameIndex)") + while frameCount < videoFrames.count { + let outputFileURL = URL(fileURLWithPath: _outputPath) + .appendingPathComponent("\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate)") + .appendingPathExtension("mp4") + + let group = DispatchGroup() + var currentError: Error? + + group.enter() + self.renderVideo(with: videoFrames, from: frameCount, at: outputFileURL) { result in + // Do not use `processingQueue` here, since it will be blocked by the semaphore. + switch result { + case .success(let videoResult): + frameCount = videoResult.finalFrameIndex + 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 } - completion(videos, nil) - } catch { - SentryLog.error("[Session Replay] Failed to create video with error: \(error)") - completion(nil, error) + group.leave() } + + // Calling semaphore.wait will block the `processingQueue` until the video rendering completes or a timeout occurs. + // It is imporant that the renderVideo completion block signals the semaphore. + // 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. + guard group.wait(timeout: .now() + 2) == .success else { + SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") + currentError = SentryOnDemandReplayError.errorRenderingVideo + break + } + + // If there was an error, throw it to exit the loop. + if let error = currentError { + throw error + } + + SentryLog.debug("[Session Replay] Finished rendering video, frame count moved to: \(frameCount)") } + return videos } // swiftlint:disable function_body_length cyclomatic_complexity @@ -402,4 +408,4 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { #endif // os(iOS) || os(tvOS) #endif // canImport(UIKit) -// swiftlint:enable file_length +// swiftlint:enable file_length type_body_length diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 9ea18313eea..66a19bab4ef 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -6,7 +6,8 @@ import UIKit protocol SentryReplayVideoMaker: NSObjectProtocol { func addFrameAsync(image: UIImage, forScreen: String?) func releaseFramesUntil(_ date: Date) - func createVideoAsyncWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) + func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]?, Error?) -> Void) + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] } extension SentryReplayVideoMaker { diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index a46fe461a32..a179710f541 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -157,7 +157,7 @@ class SentrySessionReplay: NSObject { startFullReplay() let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - createAndCaptureAsync(startedAt: replayStart, replayType: .buffer) + createAndCaptureInBackground(startedAt: replayStart, replayType: .buffer) return true } @@ -218,13 +218,13 @@ class SentrySessionReplay: NSObject { pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - createAndCaptureAsync(startedAt: segmentStart, replayType: .session) + createAndCaptureInBackground(startedAt: segmentStart, replayType: .session) } - private func createAndCaptureAsync(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 computationally expensive, therefore perform it on a background queue. - self.replayMaker.createVideoAsyncWith(beginning: startedAt, end: self.dateProvider.date()) { videos, error in + 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)") } From 0e9c3e2450a5ff7e330007ca741297c039f3bb85 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Apr 2025 10:37:02 +0200 Subject: [PATCH 09/18] small changes --- CHANGELOG.md | 1 - .../Swift/Integrations/SessionReplay/SentrySessionReplay.swift | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391b4a5d7c9..845233b4121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ - Crash in setMeasurement when name is nil (#5064) - Make setMeasurement thread safe (#5067, #5078) -- Fix thread inversion warning in session replay (#5018) - Truncation of Swift crash messages (#5036) - Add error logging for move current replay to last path (#5083) - Async safe log for backtrace in CPPException (#5098) diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index a179710f541..82e2396e01f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -227,6 +227,7 @@ class SentrySessionReplay: NSObject { 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") @@ -236,7 +237,7 @@ class SentrySessionReplay: NSObject { for video in videos { self.newSegmentAvailable(videoInfo: video, replayType: replayType) } - SentryLog.debug("[Session Replay] Finished replay video creation with \(videos.count) segments") + SentryLog.debug("[Session Replay] Finished processing replay video with \(videos.count) segments") } } From c4ba77c7acd9ed9109593d18a91fed08d3e9f6bd Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Apr 2025 10:56:08 +0200 Subject: [PATCH 10/18] WIP --- .../SessionReplay/SentryOnDemandReplay.swift | 19 ++++-- .../SentryOnDemandReplayTests.swift | 61 ++++++++++++------- .../SentrySessionReplayTests.swift | 18 ++++-- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 4c01c960fb8..86b74999070 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -156,8 +156,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") - let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end } - + let videoFrames = self.filterFrames(beginning: beginning, end: end) + var frameCount = 0 var videos = [SentryVideoInfo]() @@ -189,7 +189,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // It is imporant that the renderVideo completion block signals the semaphore. // 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. - guard group.wait(timeout: .now() + 2) == .success else { + guard group.wait(timeout: .now() + 120) == .success else { SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") currentError = SentryOnDemandReplayError.errorRenderingVideo break @@ -304,7 +304,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { let presentTime = CMTime(seconds: Double(frameIndex), preferredTimescale: CMTimeScale(1 / self.frameRate)) guard currentPixelBuffer.append(image: image, presentationTime: presentTime) == true else { - SentryLog.debug("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") + SentryLog.error("[Session Replay] Failed to append image to pixel buffer, cancelling the writing session, reason: \(videoWriter.error?.localizedDescription ?? "Unknown error")") videoWriter.cancelWriting() return completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } @@ -313,6 +313,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { frameIndex += 1 } } + // swiftlint:enable function_body_length cyclomatic_complexity private func finishVideo( @@ -367,7 +368,17 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } + 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. + processingQueue.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 { + SentryLog.debug("[Session Replay] Getting video info from file: \(outputFileURL.path), width: \(videoWidth), height: \(videoHeight), used frames count: \(usedFrames.count)") let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) guard let fileSize = fileAttributes[FileAttributeKey.size] as? Int else { SentryLog.warning("[Session Replay] Failed to read video size from video file, reason: size attribute not found") diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 85c59e15a69..7d5eab9e7a5 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -26,9 +26,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() { @@ -107,11 +110,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() @@ -125,16 +132,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() @@ -149,24 +160,30 @@ 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) 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/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index 42eb29a097d..63ae9f1da96 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -35,11 +35,22 @@ class SentrySessionReplayTests: XCTestCase { } var lastCallToCreateVideo: CreateVideoCall? - func createVideoAsyncWith( + 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") @@ -47,7 +58,7 @@ class SentrySessionReplayTests: XCTestCase { let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: end.timeIntervalSince(overrideBeginning ?? beginning), frameCount: 5, frameRate: 1, start: overrideBeginning ?? beginning, end: end, fileSize: 10, screens: screens) createVideoCallBack?(videoInfo) - return completion([videoInfo], nil) + return [videoInfo] } var lastFrame: UIImage? @@ -80,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, @@ -89,7 +100,6 @@ class SentrySessionReplayTests: XCTestCase { touchTracker: touchTracker ?? SentryTouchTracker(dateProvider: dateProvider, scale: 0), dateProvider: dateProvider, delegate: self, - dispatchQueue: dispatchQueue, displayLinkWrapper: displayLink) } From ef3d279bec4968cc5447cac84aef9d62c9f45645 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 25 Apr 2025 10:43:02 +0200 Subject: [PATCH 11/18] fix merge error --- .../Integrations/SessionReplay/SentryOnDemandReplay.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 4db68859eaa..0965221f0ee 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -304,7 +304,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { lastImageSize = image.size let presentTime = SentryOnDemandReplay.calculatePresentationTime( - forFrameAtIndex: frameCount, + forFrameAtIndex: frameIndex, frameRate: self.frameRate ).timeValue guard currentPixelBuffer.append(image: image, presentationTime: presentTime) == true else { @@ -319,7 +319,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } // swiftlint:enable function_body_length cyclomatic_complexity - private func finishVideo( outputFileURL: URL, usedFrames: [SentryReplayFrame], From 51beda1ac171c65c4626b55ede7764371f413ee4 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 25 Apr 2025 10:57:10 +0200 Subject: [PATCH 12/18] small fixes --- CHANGELOG.md | 8 ++++---- .../Integrations/SessionReplay/SentryOnDemandReplay.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154e5f865b1..564513cbc5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ## Unreleased -### Fixes - -- Fix thread inversion warning in session replay (#5018) - ### Features - Added ability to bring your own button for user feedback form display (#5107) +### Fixes + +- Fix thread inversion warning in session replay (#5018) + ### Improvements - More logging for Session Replay video info (#5132) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 0965221f0ee..e494d02e657 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -157,9 +157,9 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") - let videoFrames = self.filterFrames(beginning: beginning, end: end) - + let videoFrames = filterFrames(beginning: beginning, end: end) var frameCount = 0 + var videos = [SentryVideoInfo]() while frameCount < videoFrames.count { From 8614798932fb0dc30695cbc501959cfeeb3c0e20 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 25 Apr 2025 13:51:52 +0200 Subject: [PATCH 13/18] small reverts --- .../SessionReplay/SentryOnDemandReplay.swift | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index e494d02e657..25ffa40262a 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -236,25 +236,15 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return completion(.failure(SentryOnDemandReplayError.cantCreatePixelBuffer)) } videoWriter.add(videoWriterInput) - videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - // 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. var lastImageSize: CGSize = image.size var usedFrames = [SentryReplayFrame]() var frameIndex = from - // Convenience wrapper to handle the completion callback + // 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): @@ -266,19 +256,30 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { completion(.failure(error)) } } + + // 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 self = self else { + 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() - return completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) + return deferredCompletionCallback(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } guard frameIndex < videoFrames.count else { SentryLog.debug("[Session Replay] No more frames available to process, finishing the video") - return self.finishVideo( + return strongSelf.finishVideo( outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), @@ -292,7 +293,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { if let image = UIImage(contentsOfFile: frame.imagePath) { guard lastImageSize == image.size else { SentryLog.debug("[Session Replay] Image size changed, finishing the video") - return self.finishVideo( + return strongSelf.finishVideo( outputFileURL: outputFileURL, usedFrames: usedFrames, videoHeight: Int(videoHeight), @@ -305,12 +306,12 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { let presentTime = SentryOnDemandReplay.calculatePresentationTime( forFrameAtIndex: frameIndex, - frameRate: self.frameRate + frameRate: strongSelf.frameRate ).timeValue 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() - return completion(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) + return deferredCompletionCallback(.failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo)) } usedFrames.append(frame) } From bdfd4bc64019d3c25e07081510cdbfc0280f1494 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 25 Apr 2025 14:01:59 +0200 Subject: [PATCH 14/18] small reverts --- .../SessionReplay/SentryOnDemandReplay.swift | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 25ffa40262a..e52104ad8eb 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -119,8 +119,8 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } func releaseFramesUntil(_ date: Date) { + SentryLog.debug("[Session Replay] Releasing frames until date: \(date)") processingQueue.dispatchAsync { - SentryLog.debug("[Session Replay] Releasing frames until date: \(date)") while let first = self._frames.first, first.time < date { self._frames.removeFirst() let fileUrl = URL(fileURLWithPath: first.imagePath) @@ -163,8 +163,9 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { var videos = [SentryVideoInfo]() while frameCount < videoFrames.count { + let frame = videoFrames[frameCount] let outputFileURL = URL(fileURLWithPath: _outputPath) - .appendingPathComponent("\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate)") + .appendingPathComponent("\(frame.time.timeIntervalSinceReferenceDate)") .appendingPathExtension("mp4") let group = DispatchGroup() @@ -172,10 +173,14 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { group.enter() self.renderVideo(with: videoFrames, from: frameCount, at: outputFileURL) { result in - // Do not use `processingQueue` here, since it will be blocked by the semaphore. 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 + + // 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) } @@ -186,11 +191,11 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { group.leave() } - // Calling semaphore.wait will block the `processingQueue` until the video rendering completes or a timeout occurs. - // It is imporant that the renderVideo completion block signals the semaphore. + // 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. - guard group.wait(timeout: .now() + 120) == .success else { + // Otherwise, it could lead to queue starvation and a deadlock/timeout. + guard group.wait(timeout: .now() + 2) == .success else { SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") currentError = SentryOnDemandReplayError.errorRenderingVideo break @@ -232,7 +237,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { 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: pixelSize, videoWriterInput: videoWriterInput) else { - SentryLog.debug("[Session Replay] Failed to create pixel buffer, reason: \(SentryOnDemandReplayError.cantCreatePixelBuffer)") + SentryLog.error("[Session Replay] Failed to create pixel buffer, reason: \(SentryOnDemandReplayError.cantCreatePixelBuffer)") return completion(.failure(SentryOnDemandReplayError.cantCreatePixelBuffer)) } videoWriter.add(videoWriterInput) @@ -315,6 +320,9 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } usedFrames.append(frame) } + + // 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 } } @@ -388,7 +396,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { SentryLog.warning("[Session Replay] Failed to read video size from video file, reason: size attribute not found") throw SentryOnDemandReplayError.cantReadVideoSize } - let minFrame = usedFrames.min(by: { $0.time < $1.time }) guard let start = minFrame?.time else { // Note: This code path is currently not reached, because the `getVideoInfo` method is only called after the video is successfully created, therefore at least one frame was used. From 0cfab3885b1e60798969fd66c501557165961b8c Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 30 Apr 2025 13:11:59 +0200 Subject: [PATCH 15/18] fix sync filtering --- .../SessionReplay/SentryOnDemandReplay.swift | 21 ++++++------------- .../SentryOnDemandReplayTests.swift | 3 +++ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index e52104ad8eb..db5e332d776 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -155,9 +155,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { - SentryLog.debug("[Session Replay] Creating video with beginning: \(beginning), end: \(end)") - - 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]() @@ -195,10 +196,9 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // 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() + 2) == .success else { + guard group.wait(timeout: .now() + 120) == .success else { SentryLog.error("[Session Replay] Timeout while waiting for video rendering to finish.") - currentError = SentryOnDemandReplayError.errorRenderingVideo - break + throw SentryOnDemandReplayError.errorRenderingVideo } // If there was an error, throw it to exit the loop. @@ -380,15 +380,6 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } - 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. - processingQueue.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 { SentryLog.debug("[Session Replay] Getting video info from file: \(outputFileURL.path), width: \(videoWidth), height: \(videoHeight), used frames count: \(usedFrames.count)") let fileAttributes = try FileManager.default.attributesOfItem(atPath: outputFileURL.path) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 202179c13ec..07d60f86421 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -179,6 +179,9 @@ class SentryOnDemandReplayTests: XCTestCase { 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() From 015e2ee6f593a5c67e255d66d010ea308b23d47c Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 30 Apr 2025 13:26:10 +0200 Subject: [PATCH 16/18] Added more logs and tests; moved types to own files due to linter warnings --- .../iOS-Swift/SentrySDKWrapper.swift | 2 +- Sentry.xcodeproj/project.pbxproj | 12 +++++++ .../SessionReplay/SentryOnDemandReplay.swift | 28 +++++++++++---- .../SessionReplay/SentryReplayType.swift | 8 +++++ .../SessionReplay/SentrySessionReplay.swift | 21 ++++-------- .../SentrySessionReplayDelegate.swift | 14 ++++++++ .../SessionReplay/SessionReplayError.swift | 4 +++ .../SessionReplay/SentryReplayTypeTests.swift | 34 +++++++++++++++++++ 8 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 Sources/Swift/Integrations/SessionReplay/SentrySessionReplayDelegate.swift create mode 100644 Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift create mode 100644 Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 4695f86e2d4..c4aba0df64e 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -29,7 +29,7 @@ struct SentrySDKWrapper { if #available(iOS 16.0, *), !SentrySDKOverrides.Other.disableSessionReplay.boolValue { options.sessionReplay = SentryReplayOptions( - sessionSampleRate: 0, + sessionSampleRate: 1, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 078308bf957..e2bd4f19e62 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -835,6 +835,9 @@ 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 */; }; + D49480D52DC23FD500A3B6E9 /* SessionReplayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D42DC23FD500A3B6E9 /* SessionReplayError.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 */; }; @@ -2010,6 +2013,9 @@ 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 = ""; }; + D49480D42DC23FD500A3B6E9 /* SessionReplayError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionReplayError.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 = ""; }; @@ -3964,6 +3970,7 @@ D80694C12B7CC85800B820E6 /* SessionReplay */ = { isa = PBXGroup; children = ( + D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */, D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, D86130112BB563FD004C0F5E /* SentrySessionReplayIntegrationTests.swift */, @@ -4282,12 +4289,14 @@ D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, + D49480D42DC23FD500A3B6E9 /* SessionReplayError.swift */, D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */, D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */, D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */, D8F67B1A2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift */, + D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */, D81988BF2BEBFFF70020E36C /* SentryReplayRecording.swift */, D8BC28C72BFF5EBB0054DA4D /* SentryTouchTracker.swift */, D84D2CC22C29AD120011AF8A /* SentrySessionReplay.swift */, @@ -5054,6 +5063,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 */, @@ -5116,6 +5126,7 @@ 63FE715F20DA4C1100CDBAE8 /* SentryCrashID.c in Sources */, 84B0E0072CD963FD007FB332 /* SentryIconography.swift in Sources */, 7DB3A687238EA75E00A2D442 /* SentryHttpTransport.m in Sources */, + D49480D52DC23FD500A3B6E9 /* SessionReplayError.swift in Sources */, 63FE70D520DA4C1000CDBAE8 /* SentryCrashMonitor_NSException.m in Sources */, D80CD8D12B751442002F710B /* HTTPHeaderSanitizer.swift in Sources */, 62F70E952D423BCD00634054 /* SentryMechanismCodable.swift in Sources */, @@ -5331,6 +5342,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/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index db5e332d776..83991ef88eb 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -64,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 } @@ -75,11 +75,12 @@ 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)") } } func addFrameAsync(image: UIImage, forScreen: String?) { + 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 { @@ -88,14 +89,20 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } 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)) @@ -103,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? { @@ -179,6 +189,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // 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. @@ -203,11 +214,14 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { // 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 } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayType.swift index 32579786b2a..c7f0c8f55f8 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/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 82e2396e01f..1334323052a 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 @@ -243,7 +230,10 @@ class SentrySessionReplay: NSObject { 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 00000000000..23048bace74 --- /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/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift b/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift new file mode 100644 index 00000000000..d79fa500cef --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift @@ -0,0 +1,4 @@ +enum SessionReplayError: Error { + case cantCreateReplayDirectory + case noFramesAvailable +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift new file mode 100644 index 00000000000..546f01b4859 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift @@ -0,0 +1,34 @@ +@testable import Sentry +import XCTest + +class SentryReplayTypeTests: XCTestCase { + func testRawValue_bufferMode_shouldBeCorrect() { + let buffer = SentryReplayType.buffer.rawValue + XCTAssertEqual(buffer, 0) + } + + func testRawValue_sessionMode_shouldBeCorrect() { + let replay = SentryReplayType.session.rawValue + XCTAssertEqual(replay, 1) + } + + func testDescription_bufferMode_shouldBeCorrect() { + let replay = SentryReplayType.session.description + XCTAssertEqual(replay, "buffer") + } + + func testDescription_sessionMode_shouldBeCorrect() { + let replay = SentryReplayType.session.description + XCTAssertEqual(replay, "session") + } + + func testToString_bufferMode_shouldBeCorrect() { + let replay = SentryReplayType.buffer + XCTAssertEqual(replay.toString(), "buffer") + } + + func testToString_sessionMode_shouldBeCorrect() { + let replay = SentryReplayType.session + XCTAssertEqual(replay.toString(), "session") + } +} From e1ef89dd59284568d6b214f113f69f76134c20f3 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 30 Apr 2025 13:28:05 +0200 Subject: [PATCH 17/18] removed unused type --- Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift | 2 +- Sentry.xcodeproj/project.pbxproj | 6 +----- .../Integrations/SessionReplay/SessionReplayError.swift | 4 ---- 3 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index c4aba0df64e..4695f86e2d4 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -29,7 +29,7 @@ struct SentrySDKWrapper { if #available(iOS 16.0, *), !SentrySDKOverrides.Other.disableSessionReplay.boolValue { options.sessionReplay = SentryReplayOptions( - sessionSampleRate: 1, + sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index e2bd4f19e62..bcf19bc6fe0 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -836,7 +836,6 @@ 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 */; }; - D49480D52DC23FD500A3B6E9 /* SessionReplayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D42DC23FD500A3B6E9 /* SessionReplayError.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 */; }; @@ -2014,7 +2013,6 @@ 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 = ""; }; - D49480D42DC23FD500A3B6E9 /* SessionReplayError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionReplayError.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 = ""; }; @@ -4289,17 +4287,16 @@ D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, - D49480D42DC23FD500A3B6E9 /* SessionReplayError.swift */, D4B0DC7E2DA9257200DE61B6 /* SentryRenderVideoResult.swift */, D451ED5E2D92ECDE00C9BEA8 /* SentryReplayFrame.swift */, D451ED5C2D92ECD200C9BEA8 /* SentryOnDemandReplayError.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */, D8F67B1A2BE9728600C9197B /* SentrySRDefaultBreadcrumbConverter.swift */, - D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */, D81988BF2BEBFFF70020E36C /* SentryReplayRecording.swift */, D8BC28C72BFF5EBB0054DA4D /* SentryTouchTracker.swift */, D84D2CC22C29AD120011AF8A /* SentrySessionReplay.swift */, + D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */, D84D2CDC2C2BF7370011AF8A /* SentryReplayEvent.swift */, D84D2CDE2C2BF9370011AF8A /* SentryReplayType.swift */, ); @@ -5126,7 +5123,6 @@ 63FE715F20DA4C1100CDBAE8 /* SentryCrashID.c in Sources */, 84B0E0072CD963FD007FB332 /* SentryIconography.swift in Sources */, 7DB3A687238EA75E00A2D442 /* SentryHttpTransport.m in Sources */, - D49480D52DC23FD500A3B6E9 /* SessionReplayError.swift in Sources */, 63FE70D520DA4C1000CDBAE8 /* SentryCrashMonitor_NSException.m in Sources */, D80CD8D12B751442002F710B /* HTTPHeaderSanitizer.swift in Sources */, 62F70E952D423BCD00634054 /* SentryMechanismCodable.swift in Sources */, diff --git a/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift b/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift deleted file mode 100644 index d79fa500cef..00000000000 --- a/Sources/Swift/Integrations/SessionReplay/SessionReplayError.swift +++ /dev/null @@ -1,4 +0,0 @@ -enum SessionReplayError: Error { - case cantCreateReplayDirectory - case noFramesAvailable -} From d723f27921e40c72e725f19408e61de1e17f2478 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 30 Apr 2025 14:03:31 +0200 Subject: [PATCH 18/18] fix tests --- .../SessionReplay/SentryReplayTypeTests.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift index 546f01b4859..3e0a7ab7b3d 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayTypeTests.swift @@ -2,33 +2,33 @@ import XCTest class SentryReplayTypeTests: XCTestCase { - func testRawValue_bufferMode_shouldBeCorrect() { - let buffer = SentryReplayType.buffer.rawValue - XCTAssertEqual(buffer, 0) - } - func testRawValue_sessionMode_shouldBeCorrect() { - let replay = SentryReplayType.session.rawValue - XCTAssertEqual(replay, 1) + let replayType = SentryReplayType.session.rawValue + XCTAssertEqual(replayType, 0) } - func testDescription_bufferMode_shouldBeCorrect() { - let replay = SentryReplayType.session.description - XCTAssertEqual(replay, "buffer") + func testRawValue_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer.rawValue + XCTAssertEqual(replayType, 1) } func testDescription_sessionMode_shouldBeCorrect() { - let replay = SentryReplayType.session.description - XCTAssertEqual(replay, "session") + let replayType = SentryReplayType.session.description + XCTAssertEqual(replayType, "session") } - func testToString_bufferMode_shouldBeCorrect() { - let replay = SentryReplayType.buffer - XCTAssertEqual(replay.toString(), "buffer") + func testDescription_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer.description + XCTAssertEqual(replayType, "buffer") } func testToString_sessionMode_shouldBeCorrect() { - let replay = SentryReplayType.session - XCTAssertEqual(replay.toString(), "session") + let replayType = SentryReplayType.session + XCTAssertEqual(replayType.toString(), "session") + } + + func testToString_bufferMode_shouldBeCorrect() { + let replayType = SentryReplayType.buffer + XCTAssertEqual(replayType.toString(), "buffer") } }