diff --git a/.github/last-release-runid b/.github/last-release-runid index 0d572a877dc..773bdf51c12 100644 --- a/.github/last-release-runid +++ b/.github/last-release-runid @@ -1 +1 @@ -9866174689 +9945131293 diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cb960c934..b1f60cc1c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 8.31.1 + +### Fixes + +- Session replay video duration from seconds to milliseconds (#4163) + +## 8.31.0 + +### Features + +- Include the screen names in the session replay (#4126) + +### Fixes + +- Properly handle invalid value for `NSUnderlyingErrorKey` (#4144) +- Session replay in buffer mode not working (#4160) + ## 8.30.1 ### Fixes diff --git a/Package.swift b/Package.swift index 213fefb733a..5632e271bcf 100644 --- a/Package.swift +++ b/Package.swift @@ -12,13 +12,13 @@ let package = Package( targets: [ .binaryTarget( name: "Sentry", - url: "https://github.com/getsentry/sentry-cocoa/releases/download/8.30.1/Sentry.xcframework.zip", - checksum: "62ba39319f3a9d433b8000dd3e94819cd79bafae920d97a20da1ec294c0d0ff0" //Sentry-Static + url: "https://github.com/getsentry/sentry-cocoa/releases/download/8.31.1/Sentry.xcframework.zip", + checksum: "078d3aaf2b3abba23b41fa7ed3fb6e58a981189ef4f9793afaab4ac1b6ec12e0" //Sentry-Static ), .binaryTarget( name: "Sentry-Dynamic", - url: "https://github.com/getsentry/sentry-cocoa/releases/download/8.30.1/Sentry-Dynamic.xcframework.zip", - checksum: "d45423698ed4d61f7f28aaf24156827052584ec580170db511994dee3de102fb" //Sentry-Dynamic + url: "https://github.com/getsentry/sentry-cocoa/releases/download/8.31.1/Sentry-Dynamic.xcframework.zip", + checksum: "f2848f30888df1f186549e27d86f8ca400bb3b7379d8a213156738115d51cbf0" //Sentry-Dynamic ), .target ( name: "SentrySwiftUI", dependencies: ["Sentry", "SentryInternal"], diff --git a/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj b/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj index 3e3009c33a4..eb7f401a64f 100644 --- a/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj +++ b/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj @@ -1251,7 +1251,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.30.1; + MARKETING_VERSION = 8.31.1; PRODUCT_BUNDLE_IDENTIFIER = "io.sentry.sample.iOS-Swift"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development io.sentry.sample.iOS-Swift"; @@ -1280,7 +1280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.30.1; + MARKETING_VERSION = 8.31.1; PRODUCT_BUNDLE_IDENTIFIER = "io.sentry.sample.iOS-Swift"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore io.sentry.sample.iOS-Swift"; @@ -1929,7 +1929,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.30.1; + MARKETING_VERSION = 8.31.1; PRODUCT_BUNDLE_IDENTIFIER = "io.sentry.sample.iOS-Swift.Clip"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match Development io.sentry.sample.iOS-Swift.Clip"; @@ -1964,7 +1964,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 8.30.1; + MARKETING_VERSION = 8.31.1; PRODUCT_BUNDLE_IDENTIFIER = "io.sentry.sample.iOS-Swift.Clip"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore io.sentry.sample.iOS-Swift.Clip"; diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index 6c0b94f7dc7..a2d845bf219 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -72,7 +72,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.enableAppLaunchProfiling = args.contains("--profile-app-launches") options.enableAutoSessionTracking = !args.contains("--disable-automatic-session-tracking") - options.sessionTrackingIntervalMillis = 5_000 + //options.sessionTrackingIntervalMillis = 5_000 options.attachScreenshot = true options.attachViewHierarchy = true diff --git a/Sentry.podspec b/Sentry.podspec index 72ad5fc85a0..186be1c2c06 100644 --- a/Sentry.podspec +++ b/Sentry.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Sentry" - s.version = "8.30.1" + s.version = "8.31.1" s.summary = "Sentry client for cocoa" s.homepage = "https://github.com/getsentry/sentry-cocoa" s.license = "mit" diff --git a/SentryPrivate.podspec b/SentryPrivate.podspec index cc8f7190b2e..8a3245154e4 100644 --- a/SentryPrivate.podspec +++ b/SentryPrivate.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SentryPrivate" - s.version = "8.30.1" + s.version = "8.31.1" s.summary = "Sentry Private Library." s.homepage = "https://github.com/getsentry/sentry-cocoa" s.license = "mit" diff --git a/SentrySwiftUI.podspec b/SentrySwiftUI.podspec index ab450a7028e..ebbd251c118 100644 --- a/SentrySwiftUI.podspec +++ b/SentrySwiftUI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SentrySwiftUI" - s.version = "8.30.1" + s.version = "8.31.1" s.summary = "Sentry client for SwiftUI" s.homepage = "https://github.com/getsentry/sentry-cocoa" s.license = "mit" @@ -19,5 +19,5 @@ Pod::Spec.new do |s| s.watchos.framework = 'WatchKit' s.source_files = "Sources/SentrySwiftUI/**/*.{swift,h,m}" - s.dependency 'Sentry', "8.30.1" + s.dependency 'Sentry', "8.31.1" end diff --git a/Sources/Configuration/SDK.xcconfig b/Sources/Configuration/SDK.xcconfig index 705b14c428e..6a9702b36d9 100644 --- a/Sources/Configuration/SDK.xcconfig +++ b/Sources/Configuration/SDK.xcconfig @@ -10,7 +10,7 @@ DYLIB_INSTALL_NAME_BASE = @rpath MACH_O_TYPE = mh_dylib FRAMEWORK_VERSION = A -CURRENT_PROJECT_VERSION = 8.30.1 +CURRENT_PROJECT_VERSION = 8.31.1 ALWAYS_SEARCH_USER_PATHS = NO CLANG_ENABLE_OBJC_ARC = YES diff --git a/Sources/Sentry/SentryAsyncSafeLog.c b/Sources/Sentry/SentryAsyncSafeLog.c index 9a2dbb1a212..c720f1019f4 100644 --- a/Sources/Sentry/SentryAsyncSafeLog.c +++ b/Sources/Sentry/SentryAsyncSafeLog.c @@ -91,7 +91,6 @@ writeToLog(const char *const str) pos += bytesWritten; } } - write(STDOUT_FILENO, str, strlen(str)); #if SENTRY_ASYNC_SAFE_LOG_ALSO_WRITE_TO_CONSOLE // if we're debugging, also write the log statements to the console; we only check once for @@ -150,10 +149,10 @@ sentry_asyncLogSetFileName(const char *filename, bool overwrite) } void -sentry_asyncLogC(const char *const level, const char *const file, const int line, - const char *const function, const char *const fmt, ...) +sentry_asyncLogC( + const char *const level, const char *const file, const int line, const char *const fmt, ...) { - writeFmtToLog("%s: %s (%u): %s: ", level, lastPathEntry(file), line, function); + writeFmtToLog("%s: %s (%u): ", level, lastPathEntry(file), line); va_list args; va_start(args, fmt); writeFmtArgsToLog(fmt, args); diff --git a/Sources/Sentry/SentryAsyncSafeLog.h b/Sources/Sentry/SentryAsyncSafeLog.h index b86167ae6bf..40738e34fa6 100644 --- a/Sources/Sentry/SentryAsyncSafeLog.h +++ b/Sources/Sentry/SentryAsyncSafeLog.h @@ -47,8 +47,7 @@ extern "C" { static char g_logFilename[1024]; -void sentry_asyncLogC( - const char *level, const char *file, int line, const char *function, const char *fmt, ...); +void sentry_asyncLogC(const char *level, const char *file, int line, const char *fmt, ...); #define i_SENTRY_ASYNC_SAFE_LOG sentry_asyncLogC @@ -62,7 +61,7 @@ void sentry_asyncLogC( #define SENTRY_ASYNC_SAFE_LOG_LEVEL SENTRY_ASYNC_SAFE_LOG_LEVEL_ERROR #define a_SENTRY_ASYNC_SAFE_LOG(LEVEL, FMT, ...) \ - i_SENTRY_ASYNC_SAFE_LOG(LEVEL, __FILE__, __LINE__, __PRETTY_FUNCTION__, FMT, ##__VA_ARGS__) + i_SENTRY_ASYNC_SAFE_LOG(LEVEL, __FILE__, __LINE__, FMT, ##__VA_ARGS__) // ============================================================================ #pragma mark - API - diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 7ecd4a1cbbc..a02f38f7d15 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -249,10 +249,29 @@ - (SentryEvent *)buildErrorEvent:(NSError *)error // as a list of exceptions with error mechanisms, sorted oldest to newest (so, the leaf node // underlying error as oldest, with the root as the newest) NSMutableArray *errors = [NSMutableArray arrayWithObject:error]; - NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; + NSError *underlyingError; + if ([error.userInfo[NSUnderlyingErrorKey] isKindOfClass:[NSError class]]) { + underlyingError = error.userInfo[NSUnderlyingErrorKey]; + } else if (error.userInfo[NSUnderlyingErrorKey] != nil) { + SENTRY_LOG_WARN(@"Invalid value for NSUnderlyingErrorKey in user info. Data at key: %@. " + @"Class type: %@.", + error.userInfo[NSUnderlyingErrorKey], [error.userInfo[NSUnderlyingErrorKey] class]); + } + while (underlyingError != nil) { [errors addObject:underlyingError]; - underlyingError = underlyingError.userInfo[NSUnderlyingErrorKey]; + + if ([underlyingError.userInfo[NSUnderlyingErrorKey] isKindOfClass:[NSError class]]) { + underlyingError = underlyingError.userInfo[NSUnderlyingErrorKey]; + } else { + if (underlyingError.userInfo[NSUnderlyingErrorKey] != nil) { + SENTRY_LOG_WARN(@"Invalid value for NSUnderlyingErrorKey in user info. Data at " + @"key: %@. Class type: %@.", + underlyingError.userInfo[NSUnderlyingErrorKey], + [underlyingError.userInfo[NSUnderlyingErrorKey] class]); + } + underlyingError = nil; + } } NSMutableArray *exceptions = [NSMutableArray array]; diff --git a/Sources/Sentry/SentryMeta.m b/Sources/Sentry/SentryMeta.m index cf9f37bfe78..7eaf0912e34 100644 --- a/Sources/Sentry/SentryMeta.m +++ b/Sources/Sentry/SentryMeta.m @@ -5,7 +5,7 @@ @implementation SentryMeta // Don't remove the static keyword. If you do the compiler adds the constant name to the global // symbol table and it might clash with other constants. When keeping the static keyword the // compiler replaces all occurrences with the value. -static NSString *versionString = @"8.30.1"; +static NSString *versionString = @"8.31.1"; static NSString *sdkName = @"sentry.cocoa"; + (NSString *)versionString diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index ad919f34555..85e9c87f852 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -30,7 +30,7 @@ static SentryTouchTracker *_touchTracker; @interface -SentrySessionReplayIntegration () +SentrySessionReplayIntegration () - (void)newSceneActivate; @end @@ -126,8 +126,8 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions SentryOnDemandReplay *replayMaker = [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; replayMaker.bitRate = replayOptions.replayBitRate; replayMaker.cacheMaxSize - = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration - : replayOptions.errorReplayDuration); + = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + 1 + : replayOptions.errorReplayDuration + 1); self.sessionReplay = [[SentrySessionReplay alloc] initWithReplayOptions:replayOptions @@ -187,7 +187,7 @@ - (void)sentrySessionStarted:(SentrySession *)session - (void)captureReplay { - //[self.sessionReplay captureReplay]; + [self.sessionReplay captureReplay]; } - (void)configureReplayWith:(nullable id)breadcrumbConverter @@ -307,6 +307,13 @@ - (void)sessionReplayStartedWithReplayId:(SentryId *)replayId return result; } +- (nullable NSString *)currentScreenNameForSessionReplay +{ + return SentrySDK.currentHub.scope.currentScreen + ?: [SentryDependencyContainer.sharedInstance.application relevantViewControllersNames] + .firstObject; +} + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentrySessionReplayIntegration+Private.h b/Sources/Sentry/include/SentrySessionReplayIntegration+Private.h index a58329704f1..f4293d661ac 100644 --- a/Sources/Sentry/include/SentrySessionReplayIntegration+Private.h +++ b/Sources/Sentry/include/SentrySessionReplayIntegration+Private.h @@ -7,7 +7,8 @@ @class SentrySessionReplay; @interface -SentrySessionReplayIntegration () +SentrySessionReplayIntegration () @property (nonatomic, strong) SentrySessionReplay *sessionReplay; diff --git a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift index b5d3785c7b3..44f53b1b515 100644 --- a/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift +++ b/Sources/Swift/Integrations/SessionReplay/RRWeb/SentryRRWebVideoEvent.swift @@ -9,7 +9,7 @@ class SentryRRWebVideoEvent: SentryRRWebCustomEvent { "timestamp": timestamp.timeIntervalSince1970, "segmentId": segmentId, "size": size, - "duration": duration, + "duration": Int(duration * 1_000), "encoding": encoding, "container": container, "height": height, diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 4b299e46fa5..5773b803883 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -10,11 +10,14 @@ import UIKit struct SentryReplayFrame { let imagePath: String let time: Date - - init(imagePath: String, time: Date) { - self.imagePath = imagePath - self.time = time - } + let screenName: String? +} + +private struct VideoFrames { + let framesPaths: [String] + let screens: [String] + let start: Date + let end: Date } enum SentryOnDemandReplayError: Error { @@ -56,14 +59,14 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { self.workingQueue = workingQueue } - func addFrameAsync(image: UIImage) { + func addFrameAsync(image: UIImage, forScreen: String?) { workingQueue.dispatchAsync({ - self.addFrame(image: image) + self.addFrame(image: image, forScreen: forScreen) }) } - private func addFrame(image: UIImage) { - guard let data = resizeImage(image, maxWidth: 300)?.pngData() else { return } + private func addFrame(image: UIImage, forScreen: String?) { + guard let data = rescaleImage(image)?.pngData() else { return } let date = dateProvider.date() let imagePath = (_outputPath as NSString).appendingPathComponent("\(_totalFrames).png") @@ -73,7 +76,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { print("[SentryOnDemandReplay] Could not save replay frame. Error: \(error)") return } - _frames.append(SentryReplayFrame(imagePath: imagePath, time: date)) + _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) while _frames.count > cacheMaxSize { let first = _frames.removeFirst() @@ -82,21 +85,14 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { _totalFrames += 1 } - private func resizeImage(_ originalImage: UIImage, maxWidth: CGFloat) -> UIImage? { - let originalSize = originalImage.size - let aspectRatio = originalSize.width / originalSize.height - - let newWidth = min(originalSize.width, maxWidth) - let newHeight = newWidth / aspectRatio + private func rescaleImage(_ originalImage: UIImage) -> UIImage? { + guard originalImage.scale > 1 else { return originalImage } - let newSize = CGSize(width: newWidth, height: newHeight) + UIGraphicsBeginImageContextWithOptions(originalImage.size, false, 1) + defer { UIGraphicsEndImageContext() } - UIGraphicsBeginImageContextWithOptions(newSize, false, 1) - originalImage.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)) - let resizedImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return resizedImage + originalImage.draw(in: CGRect(origin: .zero, size: originalImage.size)) + return UIGraphicsGetImageFromCurrentImageContext() } func releaseFramesUntil(_ date: Date) { @@ -107,43 +103,36 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } }) } - - func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { - let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mov) - let videoSettings = createVideoSettings() + func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { + var frameCount = 0 + let videoFrames = filterFrames(beginning: beginning, end: end) + if videoFrames.framesPaths.isEmpty { return } - let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) - let bufferAttributes: [String: Any] = [ - String(kCVPixelBufferPixelFormatTypeKey): kCVPixelFormatType_32ARGB - ] + let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) + let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings()) - let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoWriterInput, sourcePixelBufferAttributes: bufferAttributes) + _currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput) + if _currentPixelBuffer == nil { return } videoWriter.add(videoWriterInput) videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - var frameCount = 0 - let (framesPaths, start, end) = filterFrames(beginning: beginning, end: beginning.addingTimeInterval(duration)) - - if framesPaths.isEmpty { return } - - _currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight)) - videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { [weak self] in guard let self = self else { return } - if frameCount < framesPaths.count { - let imagePath = framesPaths[frameCount] - + if frameCount < videoFrames.framesPaths.count { + let imagePath = videoFrames.framesPaths[frameCount] if let image = UIImage(contentsOfFile: imagePath) { - let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(self.frameRate)) - guard self._currentPixelBuffer?.append(image: image, pixelBufferAdapter: pixelBufferAdaptor, presentationTime: presentTime) == true else { + let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) + + guard self._currentPixelBuffer?.append(image: image, presentationTime: presentTime) == true + else { completion(nil, videoWriter.error) videoWriterInput.markAsFinished() return - } + } } frameCount += 1 } else { @@ -157,7 +146,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { completion(nil, SentryOnDemandReplayError.cantReadVideoSize) return } - videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(framesPaths.count / self.frameRate), frameCount: framesPaths.count, frameRate: self.frameRate, start: start, end: end, fileSize: fileSize) + videoInfo = SentryVideoInfo(path: outputFileURL, height: self.videoHeight, width: self.videoWidth, duration: TimeInterval(videoFrames.framesPaths.count / self.frameRate), frameCount: videoFrames.framesPaths.count, frameRate: self.frameRate, start: videoFrames.start, end: videoFrames.end, fileSize: fileSize, screens: videoFrames.screens) } catch { completion(nil, error) } @@ -168,21 +157,28 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } - private func filterFrames(beginning: Date, end: Date) -> ([String], start: Date, end: Date) { + private func filterFrames(beginning: Date, end: Date) -> VideoFrames { var framesPaths = [String]() + var screens = [String]() + var start = dateProvider.date() var actualEnd = start workingQueue.dispatchSync({ for frame in self._frames { if frame.time < beginning { continue } else if frame.time > end { break } + if frame.time < start { start = frame.time } + if let screenName = frame.screenName { + screens.append(screenName) + } + actualEnd = frame.time framesPaths.append(frame.imagePath) } }) - return (framesPaths, start, actualEnd + TimeInterval((1 / Double(frameRate)))) + return VideoFrames(framesPaths: framesPaths, screens: screens, start: start, end: actualEnd + TimeInterval((1 / Double(frameRate)))) } private func createVideoSettings() -> [String: Any] { diff --git a/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift b/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift index 264e2b5c056..6932f82878f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryPixelBuffer.swift @@ -10,16 +10,22 @@ class SentryPixelBuffer { private var pixelBuffer: CVPixelBuffer? private let rgbColorSpace = CGColorSpaceCreateDeviceRGB() private let size: CGSize + private let pixelBufferAdapter: AVAssetWriterInputPixelBufferAdaptor - init?(size: CGSize) { + init?(size: CGSize, videoWriterInput: AVAssetWriterInput) { self.size = size let status = CVPixelBufferCreate(kCFAllocatorDefault, Int(size.width), Int(size.height), kCVPixelFormatType_32ARGB, nil, &pixelBuffer) if status != kCVReturnSuccess { return nil } + let bufferAttributes: [String: Any] = [ + String(kCVPixelBufferPixelFormatTypeKey): kCVPixelFormatType_32ARGB + ] + + pixelBufferAdapter = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoWriterInput, sourcePixelBufferAttributes: bufferAttributes) } - func append(image: UIImage, pixelBufferAdapter: AVAssetWriterInputPixelBufferAdaptor, presentationTime: CMTime) -> Bool { + func append(image: UIImage, presentationTime: CMTime) -> Bool { guard let pixelBuffer = pixelBuffer else { return false } CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index 1ef0b53f35e..75db3405184 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -84,8 +84,13 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { /** * Number of frames per second of the replay. * The more the havier the process is. + * The minimum is 1, if set to zero this will change to 1. */ - let frameRate = 1 + var frameRate: UInt = 1 { + didSet { + if frameRate < 1 { frameRate = 1 } + } + } /** * The maximum duration of replays for error events. diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 571748cb758..2df207a6602 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -7,8 +7,15 @@ protocol SentryReplayVideoMaker: NSObjectProtocol { var videoWidth: Int { get set } var videoHeight: Int { get set } - func addFrameAsync(image: UIImage) + func addFrameAsync(image: UIImage, forScreen: String?) func releaseFramesUntil(_ date: Date) - func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws + func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws } + +extension SentryReplayVideoMaker { + func addFrameAsync(image: UIImage) { + self.addFrameAsync(image: image, forScreen: nil) + } +} + #endif diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index b6f08f17fd6..3a6c1e59f0f 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -9,6 +9,7 @@ protocol SentrySessionReplayDelegate: NSObjectProtocol { func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL) func sessionReplayStarted(replayId: SentryId) func breadcrumbsForSessionReplay() -> [Breadcrumb] + func currentScreenNameForSessionReplay() -> String? } @objcMembers @@ -132,6 +133,7 @@ class SentrySessionReplay: NSObject { setEventContext(event: event) } + @discardableResult func captureReplay() -> Bool { guard isRunning else { return false } guard !isFullSession else { return true } @@ -146,9 +148,9 @@ class SentrySessionReplay: NSObject { print("[SentrySessionReplay:\(#line)] Could not create replay video path") return false } - let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration) + let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - createAndCapture(videoUrl: finalPath, duration: replayOptions.errorReplayDuration, startedAt: replayStart) + createAndCapture(videoUrl: finalPath, startedAt: replayStart) return true } @@ -169,17 +171,17 @@ class SentrySessionReplay: NSObject { @objc private func newFrame(_ sender: CADisplayLink) { - guard let sessionStart = sessionStart, let lastScreenShot = lastScreenShot, isRunning else { return } + guard let lastScreenShot = lastScreenShot, isRunning else { return } let now = dateProvider.date() - if isFullSession && now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { + if let sessionStart = sessionStart, isFullSession && now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { reachedMaximumDuration = true stop() return } - if now.timeIntervalSince(lastScreenShot) >= 1 { + if now.timeIntervalSince(lastScreenShot) >= Double(1 / replayOptions.frameRate) { takeScreenshot() self.lastScreenShot = now @@ -206,14 +208,14 @@ class SentrySessionReplay: NSObject { } pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") - let segmentStart = dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) + let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - createAndCapture(videoUrl: pathToSegment, duration: replayOptions.sessionSegmentDuration, startedAt: segmentStart) + createAndCapture(videoUrl: pathToSegment, startedAt: segmentStart) } - private func createAndCapture(videoUrl: URL, duration: TimeInterval, startedAt: Date) { + private func createAndCapture(videoUrl: URL, startedAt: Date) { do { - try replayMaker.createVideoWith(duration: duration, beginning: startedAt, outputFileURL: videoUrl) { [weak self] videoInfo, error in + try replayMaker.createVideoWith(beginning: startedAt, end: dateProvider.date(), outputFileURL: videoUrl) { [weak self] videoInfo, error in guard let _self = self else { return } if let error = error { print("[SentrySessionReplay:\(#line)] Could not create replay video - \(error.localizedDescription)") @@ -230,15 +232,15 @@ class SentrySessionReplay: NSObject { guard let sessionReplayId = sessionReplayId else { return } captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: .session) replayMaker.releaseFramesUntil(videoInfo.end) - videoSegmentStart = nil + videoSegmentStart = videoInfo.end currentSegmentId++ } private func captureSegment(segment: Int, video: SentryVideoInfo, replayId: SentryId, replayType: SentryReplayType) { let replayEvent = SentryReplayEvent(eventId: replayId, replayStartTimestamp: video.start, replayType: replayType, segmentId: segment) - print("### eventId: \(replayId), replayStartTimestamp: \(video.start), replayType: \(replayType), segmentId: \(segment)") replayEvent.timestamp = video.end + replayEvent.urls = video.screens let breadcrumbs = delegate?.breadcrumbsForSessionReplay() ?? [] @@ -275,14 +277,16 @@ class SentrySessionReplay: NSObject { processingScreenshot = true } + let screenName = delegate?.currentScreenNameForSessionReplay() + screenshotProvider.image(view: rootView, options: replayOptions) { [weak self] screenshot in - self?.newImage(image: screenshot) + self?.newImage(image: screenshot, forScreen: screenName) } } - private func newImage(image: UIImage) { + private func newImage(image: UIImage, forScreen screen: String?) { processingScreenshot = false - replayMaker.addFrameAsync(image: image) + replayMaker.addFrameAsync(image: image, forScreen: screen) } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift b/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift index 2d7518f9e7b..c9f81a3c3e1 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryVideoInfo.swift @@ -12,8 +12,9 @@ class SentryVideoInfo: NSObject { let start: Date let end: Date let fileSize: Int + let screens: [String] - init(path: URL, height: Int, width: Int, duration: TimeInterval, frameCount: Int, frameRate: Int, start: Date, end: Date, fileSize: Int) { + init(path: URL, height: Int, width: Int, duration: TimeInterval, frameCount: Int, frameRate: Int, start: Date, end: Date, fileSize: Int, screens: [String]) { self.height = height self.width = width self.duration = duration @@ -23,6 +24,7 @@ class SentryVideoInfo: NSObject { self.end = end self.path = path self.fileSize = fileSize + self.screens = screens } } diff --git a/Tests/HybridSDKTest/HybridPod.podspec b/Tests/HybridSDKTest/HybridPod.podspec index 5239b916ea7..24be4f988b9 100644 --- a/Tests/HybridSDKTest/HybridPod.podspec +++ b/Tests/HybridSDKTest/HybridPod.podspec @@ -13,6 +13,6 @@ Pod::Spec.new do |s| s.requires_arc = true s.frameworks = 'Foundation' s.swift_versions = "5.5" - s.dependency "Sentry/HybridSDK", "8.30.1" + s.dependency "Sentry/HybridSDK", "8.31.1" s.source_files = "HybridTest.swift" end diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift index 40e69496b10..4d090cefaa8 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryOnDemandReplayTests.swift @@ -46,6 +46,23 @@ class SentryOnDemandReplayTests: XCTestCase { XCTAssertEqual(frames.last?.time, Date(timeIntervalSinceReferenceDate: 9)) } + func testFramesWithScreenName() { + let sut = getSut() + + for i in 0..<4 { + sut.addFrameAsync(image: UIImage.add, forScreen: "\(i)") + dateProvider.advance(by: 1) + } + + sut.releaseFramesUntil(dateProvider.date().addingTimeInterval(-5)) + + let frames = sut.frames + + for i in 0..<4 { + XCTAssertEqual(frames[i].screenName, "\(i)") + } + } + func testGenerateVideo() { let sut = getSut() dateProvider.driftTimeForEveryRead = true @@ -58,7 +75,7 @@ class SentryOnDemandReplayTests: XCTestCase { let output = FileManager.default.temporaryDirectory.appendingPathComponent("video.mp4") let videoExpectation = expectation(description: "Wait for video render") - try? sut.createVideoWith(duration: 10, beginning: Date(timeIntervalSinceReferenceDate: 0), outputFileURL: output) { info, error in + try? sut.createVideoWith(beginning: Date(timeIntervalSinceReferenceDate: 0), end: Date(timeIntervalSinceReferenceDate: 10), outputFileURL: output) { info, error in XCTAssertNil(error) XCTAssertEqual(info?.duration, 10) @@ -102,7 +119,7 @@ class SentryOnDemandReplayTests: XCTestCase { workingQueue: queue, dateProvider: dateProvider) - sut.frames = (0..<100).map { SentryReplayFrame(imagePath: outputPath.path + "/\($0).png", time: Date(timeIntervalSinceReferenceDate: Double($0))) } + sut.frames = (0..<100).map { SentryReplayFrame(imagePath: outputPath.path + "/\($0).png", time: Date(timeIntervalSinceReferenceDate: Double($0)), screenName: nil) } let group = DispatchGroup() diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift index 822caac2d02..1441ac4b0fb 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayRecordingTests.swift @@ -5,7 +5,7 @@ import XCTest class SentryReplayRecordingTests: XCTestCase { func test_serialize() throws { - let sut = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5_000, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: nil) + let sut = SentryReplayRecording(segmentId: 3, size: 200, start: Date(timeIntervalSince1970: 2), duration: 5, frameCount: 5, frameRate: 1, height: 930, width: 390, extraEvents: nil) let data = sut.serialize() @@ -26,7 +26,7 @@ class SentryReplayRecordingTests: XCTestCase { XCTAssertEqual(recordingData?["tag"] as? String, "video") XCTAssertEqual(recordingPayload?["segmentId"] as? Int, 3) XCTAssertEqual(recordingPayload?["size"] as? Int, 200) - XCTAssertEqual(recordingPayload?["duration"] as? Double, 5_000) + XCTAssertEqual(recordingPayload?["duration"] as? Int, 5_000) XCTAssertEqual(recordingPayload?["encoding"] as? String, "h264") XCTAssertEqual(recordingPayload?["container"] as? String, "mp4") XCTAssertEqual(recordingPayload?["height"] as? Int, 930) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index b558ab3516f..5aacd3ed010 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -9,9 +9,16 @@ class SentrySessionReplayIntegrationTests: XCTestCase { private class TestSentryUIApplication: SentryUIApplication { var windowsMock: [UIWindow]? = [UIWindow()] + var screenName: String? + override var windows: [UIWindow]? { windowsMock } + + override func relevantViewControllersNames() -> [String]? { + guard let screenName = screenName else { return nil } + return [screenName] + } } override func setUpWithError() throws { @@ -137,7 +144,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase { SentrySDK.currentHub().endSession() XCTAssertNil(sut.sessionReplay) } - + func testStartFullSessionForError() throws { startSDK(sessionSampleRate: 0, errorSampleRate: 1) let sut = try getSut() @@ -157,6 +164,26 @@ class SentrySessionReplayIntegrationTests: XCTestCase { SentrySDK.currentHub().startSession() XCTAssertNotNil(sut.sessionReplay) } + + func testScreenNameFromSentryUIApplication() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut: SentrySessionReplayDelegate = try getSut() + uiApplication.screenName = "Test Screen" + XCTAssertEqual(sut.currentScreenNameForSessionReplay(), "Test Screen") + } + + func testScreenNameFromSentryScope() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + + SentrySDK.currentHub().configureScope { scope in + scope.currentScreen = "Scope Screen" + } + + let sut: SentrySessionReplayDelegate = try getSut() + uiApplication.screenName = "Test Screen" + XCTAssertEqual(sut.currentScreenNameForSessionReplay(), "Scope Screen") + } + } #endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index 5001dd6b1ca..4e9b7e6d468 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -7,40 +7,45 @@ import XCTest class SentrySessionReplayTests: XCTestCase { private class ScreenshotProvider: NSObject, SentryViewScreenshotProvider { + var lastImageCall: (view: UIView, options: SentryRedactOptions)? func image(view: UIView, options: Sentry.SentryRedactOptions, onComplete: @escaping Sentry.ScreenshotCallback) { onComplete(UIImage.add) + lastImageCall = (view, options) } } private class TestReplayMaker: NSObject, SentryReplayVideoMaker { - var videoWidth: Int = 0 var videoHeight: Int = 0 + + var screens = [String]() struct CreateVideoCall { - var duration: TimeInterval var beginning: Date + var end: Date var outputFileURL: URL var completion: ((Sentry.SentryVideoInfo?, Error?) -> Void) } var lastCallToCreateVideo: CreateVideoCall? - func createVideoWith(duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (Sentry.SentryVideoInfo?, (Error)?) -> Void) throws { - lastCallToCreateVideo = CreateVideoCall(duration: duration, - beginning: beginning, - outputFileURL: outputFileURL, - completion: completion) + func createVideoWith(beginning: Date, end: Date, outputFileURL: URL, completion: @escaping (Sentry.SentryVideoInfo?, (Error)?) -> Void) throws { + lastCallToCreateVideo = CreateVideoCall(beginning: beginning, + end: end, + outputFileURL: outputFileURL, + completion: completion) try? "Video Data".write(to: outputFileURL, atomically: true, encoding: .utf8) - let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: duration, frameCount: 5, frameRate: 1, start: beginning, end: beginning.addingTimeInterval(duration), fileSize: 10) + let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: end.timeIntervalSince(beginning), frameCount: 5, frameRate: 1, start: beginning, end: end, fileSize: 10, screens: screens) completion(videoInfo, nil) } var lastFrame: UIImage? - func addFrameAsync(image: UIImage) { + func addFrameAsync(image: UIImage, forScreen: String?) { lastFrame = image + guard let forScreen = forScreen else { return } + screens.append(forScreen) } var lastReleaseUntil: Date? @@ -64,6 +69,7 @@ class SentrySessionReplayTests: XCTestCase { var lastReplayRecording: SentryReplayRecording? var lastVideoUrl: URL? var lastReplayId: SentryId? + var currentScreen: String? func getSut(options: SentryReplayOptions = .init(sessionSampleRate: 0, errorSampleRate: 0) ) -> SentrySessionReplay { return SentrySessionReplay(replayOptions: options, @@ -94,6 +100,10 @@ class SentrySessionReplayTests: XCTestCase { func breadcrumbsForSessionReplay() -> [Breadcrumb] { breadcrumbs ?? [] } + + func currentScreenNameForSessionReplay() -> String? { + return currentScreen + } } override func setUp() { @@ -104,14 +114,9 @@ class SentrySessionReplayTests: XCTestCase { super.tearDown() clearTestState() } - - private func startFixture() -> Fixture { - let fixture = Fixture() - return fixture - } - + func testDontSentReplay_NoFullSession() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut() sut.start(rootView: fixture.rootView, fullSession: false) @@ -124,7 +129,7 @@ class SentrySessionReplayTests: XCTestCase { } func testVideoSize() { - let fixture = startFixture() + let fixture = Fixture() let options = SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1) let sut = fixture.getSut(options: options) let view = fixture.rootView @@ -136,7 +141,7 @@ class SentrySessionReplayTests: XCTestCase { } func testSentReplay_FullSession() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: true) @@ -155,7 +160,7 @@ class SentrySessionReplayTests: XCTestCase { return } - XCTAssertEqual(videoArguments.duration, 5) + XCTAssertEqual(videoArguments.end, startEvent.addingTimeInterval(5)) XCTAssertEqual(videoArguments.beginning, startEvent) XCTAssertEqual(videoArguments.outputFileURL, fixture.cacheFolder.appendingPathComponent("segments/0.mp4")) @@ -164,8 +169,30 @@ class SentrySessionReplayTests: XCTestCase { assertFullSession(sut, expected: true) } + func testReplayScreenNames() throws { + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + for i in 1...6 { + fixture.currentScreen = "Screen \(i)" + fixture.dateProvider.advance(by: 1) + Dynamic(sut).newFrame(nil) + } + + let urls = try XCTUnwrap(fixture.lastReplayEvent?.urls) + + guard urls.count == 6 else { + XCTFail("Expected 6 screen names") + return + } + XCTAssertEqual(urls[0], "Screen 1") + XCTAssertEqual(urls[1], "Screen 2") + XCTAssertEqual(urls[2], "Screen 3") + } + func testDontSentReplay_NotFullSession() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: false) @@ -184,7 +211,7 @@ class SentrySessionReplayTests: XCTestCase { } func testChangeReplayMode_forErrorEvent() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: false) XCTAssertNil(fixture.lastReplayId) @@ -197,7 +224,7 @@ class SentrySessionReplayTests: XCTestCase { } func testDontChangeReplayMode_forNonErrorEvent() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: false) @@ -208,9 +235,8 @@ class SentrySessionReplayTests: XCTestCase { assertFullSession(sut, expected: false) } - @available(iOS 16.0, tvOS 16, *) func testChangeReplayMode_forHybridSDKEvent() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: false) @@ -220,9 +246,8 @@ class SentrySessionReplayTests: XCTestCase { assertFullSession(sut, expected: true) } - @available(iOS 16.0, tvOS 16, *) func testSessionReplayMaximumDuration() { - let fixture = startFixture() + let fixture = Fixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: true) @@ -236,9 +261,20 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertFalse(sut.isRunning) } + func testSaveScreenShotInBufferMode() { + let fixture = Fixture() + + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, errorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + fixture.dateProvider.advance(by: 1) + Dynamic(sut).newFrame(nil) + + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) + } + @available(iOS 16.0, tvOS 16, *) func testDealloc_CallsStop() { - let fixture = startFixture() + let fixture = Fixture() func sutIsDeallocatedAfterCallingMe() { _ = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index fd73b3b5f55..95dd82cf004 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -643,6 +643,34 @@ class SentryClientTest: XCTestCase { XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.first?.mechanism?.meta?.error).code, 102) XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.last?.mechanism?.meta?.error).code, 100) } + + func testCaptureErrorWithInvalidUnderlyingError() throws { + let error = NSError(domain: "domain", code: 100, userInfo: [ + NSUnderlyingErrorKey: "garbage" + ]) + + fixture.getSut().capture(error: error) + + let lastSentEventArguments = try XCTUnwrap(fixture.transportAdapter.sendEventWithTraceStateInvocations.last) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions).count, 1) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.first?.mechanism?.meta?.error).code, 100) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.last?.mechanism?.meta?.error).code, 100) + } + + func testCaptureErrorWithNestedInvalidUnderlyingError() throws { + let error = NSError(domain: "domain1", code: 100, userInfo: [ + NSUnderlyingErrorKey: NSError(domain: "domain2", code: 101, userInfo: [ + NSUnderlyingErrorKey: "More garbage" + ]) + ]) + + fixture.getSut().capture(error: error) + + let lastSentEventArguments = try XCTUnwrap(fixture.transportAdapter.sendEventWithTraceStateInvocations.last) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions).count, 2) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.first?.mechanism?.meta?.error).code, 101) + XCTAssertEqual(try XCTUnwrap(lastSentEventArguments.event.exceptions?.last?.mechanism?.meta?.error).code, 100) + } func testCaptureErrorWithSession() throws { let sessionBlockExpectation = expectation(description: "session block gets called")