diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index 65ad7be235a..0f706a8d055 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -23,7 +23,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.debug = true if #available(iOS 16.0, *) { - options.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1) + options.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1, redactAllText: false, redactAllImages: true) } if #available(iOS 15.0, *) { diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 285782c1291..771304d6804 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -813,6 +813,8 @@ D86F419827C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */; }; D8751FA5274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8751FA4274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift */; }; D875ED0B276CC84700422FAC /* SentryNSDataTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */; }; + D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */; }; + D87C892B2BC67BC20086C7DF /* SentryExperimentalOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */; }; D880E3A728573E87008A90DB /* SentryBaggageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D880E3A628573E87008A90DB /* SentryBaggageTests.swift */; }; D884A20527C80F6300074664 /* SentryCoreDataTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D884A20327C80F2700074664 /* SentryCoreDataTrackerTest.swift */; }; D885266427739D01001269FC /* SentryFileIOTrackingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D885266327739D01001269FC /* SentryFileIOTrackingIntegrationTests.swift */; }; @@ -1808,6 +1810,8 @@ D8751FA4274743710032F4DE /* SentryNSURLSessionTaskSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryNSURLSessionTaskSearchTests.swift; sourceTree = ""; }; D8757D142A209F7300BFEFCC /* SentrySampleDecision+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentrySampleDecision+Private.h"; path = "include/SentrySampleDecision+Private.h"; sourceTree = ""; }; D875ED0A276CC84700422FAC /* SentryNSDataTrackerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentryNSDataTrackerTests.swift; sourceTree = ""; }; + D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactOptions.swift; sourceTree = ""; }; + D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExperimentalOptions.swift; sourceTree = ""; }; D880E3A628573E87008A90DB /* SentryBaggageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBaggageTests.swift; sourceTree = ""; }; D880E3B02860A5A0008A90DB /* SentryEvent+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryEvent+Private.h"; path = "include/SentryEvent+Private.h"; sourceTree = ""; }; D884A20327C80F2700074664 /* SentryCoreDataTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCoreDataTrackerTest.swift; sourceTree = ""; }; @@ -3409,6 +3413,7 @@ D8F016B12B9622B7007B9AFB /* Protocol */, D856272A2A374A6800FB8062 /* Tools */, D800942628F82F3A005D3943 /* SwiftDescriptor.swift */, + D87C892A2BC67BC20086C7DF /* SentryExperimentalOptions.swift */, D8B665BB2B95F5A100BD0E7B /* module.modulemap */, ); path = Swift; @@ -3522,6 +3527,7 @@ children = ( D856272B2A374A8600FB8062 /* UrlSanitized.swift */, D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */, + D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */, ); path = Tools; sourceTree = ""; @@ -3599,7 +3605,6 @@ children = ( D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, D8CAC02B2BA0663E00E38F34 /* SentryVideoInfo.swift */, - D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */, D802994D2BA836EF000F0081 /* SentryOnDemandReplay.swift */, D802994F2BA83A88000F0081 /* SentryPixelBuffer.swift */, ); @@ -3619,6 +3624,7 @@ children = ( D8F016B22B9622D6007B9AFB /* SentryId.swift */, D8CAC0402BA0984500E38F34 /* SentryIntegrationProtocol.swift */, + D87C89022BC43C9C0086C7DF /* SentryRedactOptions.swift */, ); path = Protocol; sourceTree = ""; @@ -4213,6 +4219,7 @@ 0A2D8D9628997845008720F6 /* NSLocale+Sentry.m in Sources */, 7B0DC730288698F70039995F /* NSMutableDictionary+Sentry.m in Sources */, 7BD4BD4527EB29F50071F4FF /* SentryClientReport.m in Sources */, + D87C89032BC43C9C0086C7DF /* SentryRedactOptions.swift in Sources */, 631E6D341EBC679C00712345 /* SentryQueueableRequestManager.m in Sources */, 7B8713B426415BAA006D6004 /* SentryAppStartTracker.m in Sources */, 7BDB03BB2513652900BAE198 /* SentryDispatchQueueWrapper.m in Sources */, @@ -4284,6 +4291,7 @@ 844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */, 630435FF1EBCA9D900C4D3FA /* SentryNSURLRequest.m in Sources */, 62C1AFAB2B7E10EA0038C5F7 /* SentrySpotlightTransport.m in Sources */, + D87C892B2BC67BC20086C7DF /* SentryExperimentalOptions.swift in Sources */, 7B5CAF7727F5A68C00ED0DB6 /* SentryNSURLRequestBuilder.m in Sources */, 639FCFA11EBC804600778193 /* SentryException.m in Sources */, D80CD8D42B75144B002F710B /* SwiftDescriptor.swift in Sources */, diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index 7d2e6c8a328..c410475cfb3 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -5,6 +5,7 @@ NS_ASSUME_NONNULL_BEGIN @class SentryDsn, SentryMeasurementValue, SentryHttpStatusCodeRange, SentryScope, SentryReplayOptions; +@class SentryExperimentalOptions; NS_SWIFT_NAME(Options) @interface SentryOptions : NSObject @@ -271,14 +272,6 @@ NS_SWIFT_NAME(Options) */ @property (nonatomic, assign) BOOL enablePreWarmedAppStartTracing; -/** - * @warning This is an experimental feature and may still have bugs. - * Settings to configure the session replay. - * @node Default value is @c nil . - */ -@property (nonatomic, strong) - SentryReplayOptions *sessionReplayOptions API_AVAILABLE(ios(16.0), tvos(16.0)); - #endif // SENTRY_UIKIT_AVAILABLE /** @@ -567,6 +560,12 @@ NS_SWIFT_NAME(Options) */ @property (nonatomic, copy) NSString *spotlightUrl; +/** + * This will agreggate options for all experimental features. + * Be aware that the options available for experimental can change at any time. + */ +@property (nonatomic, readonly) SentryExperimentalOptions *experimental; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryBaseIntegration.m b/Sources/Sentry/SentryBaseIntegration.m index dee148cf0bd..e1fc0b7ad81 100644 --- a/Sources/Sentry/SentryBaseIntegration.m +++ b/Sources/Sentry/SentryBaseIntegration.m @@ -144,8 +144,8 @@ - (BOOL)shouldBeEnabledWithOptions:(SentryOptions *)options if (integrationOptions & kIntegrationOptionEnableReplay) { if (@available(iOS 16.0, tvOS 16.0, *)) { - if (options.sessionReplayOptions.replaysOnErrorSampleRate == 0 - && options.sessionReplayOptions.replaysSessionSampleRate == 0) { + if (options.experimental.sessionReplay.errorSampleRate == 0 + && options.experimental.sessionReplay.sessionSampleRate == 0) { [self logWithOptionName:@"sessionReplaySettings"]; return NO; } diff --git a/Sources/Sentry/SentryEnvelope.m b/Sources/Sentry/SentryEnvelope.m index 52e13dc81a5..e98ba01ee44 100644 --- a/Sources/Sentry/SentryEnvelope.m +++ b/Sources/Sentry/SentryEnvelope.m @@ -227,6 +227,11 @@ - (nullable instancetype)initWithReplayEvent:(SentryReplayEvent *)replayEvent } NSData *envelopeItemContent = [NSData dataWithContentsOfURL:envelopeContentUrl]; + + NSError *error; + if (![NSFileManager.defaultManager removeItemAtURL:envelopeContentUrl error:&error]) { + SENTRY_LOG_ERROR(@"Cound not delete temporary replay content from disk: %@", error); + } return [self initWithHeader:[[SentryEnvelopeItemHeader alloc] initWithType:SentryEnvelopeItemTypeReplayVideo length:envelopeItemContent.length] diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 59e7ba8fe53..151e6a981dc 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -16,6 +16,7 @@ #import "SentrySDK.h" #import "SentryScope.h" #import "SentrySessionReplayIntegration.h" +#import "SentrySwift.h" #import "SentrySwiftAsyncIntegration.h" #import @@ -105,7 +106,7 @@ - (instancetype)init self.enableTimeToFullDisplayTracing = NO; self.initialScope = ^SentryScope *(SentryScope *scope) { return scope; }; - + _experimental = [[SentryExperimentalOptions alloc] init]; _enableTracing = NO; _enableTracingManual = NO; #if SENTRY_HAS_UIKIT @@ -403,13 +404,6 @@ - (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enablePreWarmedAppStartTracing"] block:^(BOOL value) { self->_enablePreWarmedAppStartTracing = value; }]; - if (@available(iOS 16.0, tvOS 16.0, *)) { - if ([options[@"sessionReplayOptions"] isKindOfClass:NSDictionary.class]) { - self.sessionReplayOptions = - [[SentryReplayOptions alloc] initWithDictionary:options[@"sessionReplayOptions"]]; - } - } - #endif // SENTRY_HAS_UIKIT [self setBool:options[@"enableAppHangTracking"] @@ -505,6 +499,10 @@ - (BOOL)validateOptions:(NSDictionary *)options self.spotlightUrl = options[@"spotlightUrl"]; } + if ([options[@"experimental"] isKindOfClass:NSDictionary.class]) { + [self.experimental validateOptions:options[@"experimental"]]; + } + return YES; } @@ -745,4 +743,4 @@ - (NSString *)debugDescription } #endif // defined(DEBUG) || defined(TEST) || defined(TESTCI) -@end +@end \ No newline at end of file diff --git a/Sources/Sentry/SentrySessionReplay.m b/Sources/Sentry/SentrySessionReplay.m index e49c3ebed6c..d6830ed4f19 100644 --- a/Sources/Sentry/SentrySessionReplay.m +++ b/Sources/Sentry/SentrySessionReplay.m @@ -18,6 +18,8 @@ @interface SentrySessionReplay () +@property (nonatomic) BOOL isRunning; + @property (nonatomic) BOOL isFullSession; @end @@ -37,8 +39,8 @@ @implementation SentrySessionReplay { id _sentryRandom; id _screenshotProvider; int _currentSegmentId; - BOOL _isRunning; BOOL _processingScreenshot; + BOOL _reachedMaximumDuration; } - (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions @@ -55,9 +57,10 @@ - (instancetype)initWithSettings:(SentryReplayOptions *)replayOptions _sentryRandom = random; _screenshotProvider = screenshotProvider; _displayLink = displayLinkWrapper; - _isRunning = false; + _isRunning = NO; _urlToCache = folderPath; _replayMaker = replayMaker; + _reachedMaximumDuration = NO; } return self; } @@ -72,22 +75,31 @@ - (void)start:(UIView *)rootView fullSession:(BOOL)full if (_isRunning) { return; } + @synchronized(self) { if (_isRunning) { return; } [_displayLink linkWithTarget:self selector:@selector(newFrame:)]; - _isRunning = true; + _isRunning = YES; } + _rootView = rootView; _lastScreenShot = _dateProvider.date; _videoSegmentStart = nil; - _sessionStart = _lastScreenShot; _currentSegmentId = 0; sessionReplayId = [[SentryId alloc] init]; imageCollection = [NSMutableArray array]; - _isFullSession = full; + if (full) { + [self startFullReplay]; + } +} + +- (void)startFullReplay +{ + _sessionStart = _lastScreenShot; + _isFullSession = YES; } - (void)stop @@ -108,7 +120,7 @@ - (void)captureReplayForEvent:(SentryEvent *)event; return; } - if ([_sentryRandom nextNumber] > _replayOptions.replaysOnErrorSampleRate) { + if ([_sentryRandom nextNumber] > _replayOptions.errorSampleRate) { return; } @@ -120,13 +132,25 @@ - (void)captureReplayForEvent:(SentryEvent *)event; duration:_replayOptions.errorReplayDuration startedAt:replayStart]; - self->_isFullSession = YES; + [self startFullReplay]; } - (void)newFrame:(CADisplayLink *)sender { + if (!_isRunning) { + return; + } + NSDate *now = _dateProvider.date; + if (_isFullSession && + [now timeIntervalSinceDate:_sessionStart] > _replayOptions.maximumDuration) { + _reachedMaximumDuration = YES; + [self prepareSegmentUntil:now]; + [self stop]; + return; + } + if ([now timeIntervalSinceDate:_lastScreenShot] >= 1) { [self takeScreenshot]; _lastScreenShot = now; @@ -143,8 +167,6 @@ - (void)newFrame:(CADisplayLink *)sender - (void)prepareSegmentUntil:(NSDate *)date { - NSTimeInterval from = [_videoSegmentStart timeIntervalSinceDate:_sessionStart]; - NSTimeInterval to = [date timeIntervalSinceDate:_sessionStart]; NSURL *pathToSegment = [_urlToCache URLByAppendingPathComponent:@"segments"]; if (![NSFileManager.defaultManager fileExistsAtPath:pathToSegment.path]) { @@ -160,7 +182,7 @@ - (void)prepareSegmentUntil:(NSDate *)date } pathToSegment = [pathToSegment - URLByAppendingPathComponent:[NSString stringWithFormat:@"%f-%f.mp4", from, to]]; + URLByAppendingPathComponent:[NSString stringWithFormat:@"%i.mp4", _currentSegmentId]]; NSDate *segmentStart = [_dateProvider.date dateByAddingTimeInterval:-_replayOptions.sessionSegmentDuration]; @@ -219,6 +241,11 @@ - (void)captureSegment:(NSInteger)segment [SentrySDK.currentHub captureReplayEvent:replayEvent replayRecording:recording video:videoInfo.path]; + + NSError *error; + if (![NSFileManager.defaultManager removeItemAtURL:videoInfo.path error:&error]) { + SENTRY_LOG_ERROR(@"Cound not delete replay segment from disk: %@", error); + } } - (void)takeScreenshot @@ -233,7 +260,7 @@ - (void)takeScreenshot _processingScreenshot = YES; } - UIImage *screenshot = [_screenshotProvider imageWithView:_rootView]; + UIImage *screenshot = [_screenshotProvider imageWithView:_rootView options:_replayOptions]; _processingScreenshot = NO; diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index 147c11c3175..3664c37e058 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -44,11 +44,12 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options } if (@available(iOS 16.0, tvOS 16.0, *)) { + SentryReplayOptions *replayOptions = options.experimental.sessionReplay; + BOOL shouldReplayFullSession = - [self shouldReplayFullSession:options.sessionReplayOptions.replaysSessionSampleRate]; + [self shouldReplayFullSession:replayOptions.sessionSampleRate]; - if (!shouldReplayFullSession - && options.sessionReplayOptions.replaysOnErrorSampleRate == 0) { + if (!shouldReplayFullSession && replayOptions.errorSampleRate == 0) { return NO; } @@ -67,13 +68,13 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options SentryOnDemandReplay *replayMaker = [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; - replayMaker.bitRate = options.sessionReplayOptions.replayBitRate; - replayMaker.cacheMaxSize = (NSInteger)(shouldReplayFullSession - ? options.sessionReplayOptions.sessionSegmentDuration - : options.sessionReplayOptions.errorReplayDuration); + replayMaker.bitRate = replayOptions.replayBitRate; + replayMaker.cacheMaxSize + = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + : replayOptions.errorReplayDuration); self.sessionReplay = [[SentrySessionReplay alloc] - initWithSettings:options.sessionReplayOptions + initWithSettings:replayOptions replayFolderPath:docs screenshotProvider:SentryViewPhotographer.shared replayMaker:replayMaker @@ -84,8 +85,7 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options [self.sessionReplay start:SentryDependencyContainer.sharedInstance.application.windows.firstObject - fullSession:[self shouldReplayFullSession:options.sessionReplayOptions - .replaysSessionSampleRate]]; + fullSession:[self shouldReplayFullSession:replayOptions.sessionSampleRate]]; [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(stop) diff --git a/Sources/Sentry/include/SentrySessionReplay.h b/Sources/Sentry/include/SentrySessionReplay.h index 705902b2779..953f11fdf81 100644 --- a/Sources/Sentry/include/SentrySessionReplay.h +++ b/Sources/Sentry/include/SentrySessionReplay.h @@ -11,6 +11,7 @@ @class SentryVideoInfo; @protocol SentryRandom; +@protocol SentryRedactOptions; NS_ASSUME_NONNULL_BEGIN @@ -28,7 +29,7 @@ NS_ASSUME_NONNULL_BEGIN @end @protocol SentryViewScreenshotProvider -- (UIImage *)imageWithView:(UIView *)view; +- (UIImage *)imageWithView:(UIView *)view options:(id)options; @end API_AVAILABLE(ios(16.0), tvos(16.0)) diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 2c77b97b43b..8c069b3c540 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -123,13 +123,14 @@ class SentryOnDemandReplay: NSObject { if frameCount < frames.count { let imagePath = frames[frameCount] + if let image = UIImage(contentsOfFile: imagePath) { let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(self.frameRate)) - - if self._currentPixelBuffer?.append(image: image, pixelBufferAdapter: pixelBufferAdaptor, presentationTime: presentTime) != true { + guard self._currentPixelBuffer?.append(image: image, pixelBufferAdapter: pixelBufferAdaptor, presentationTime: presentTime) == true else { completion(nil, videoWriter.error) videoWriterInput.markAsFinished() - } + return + } } frameCount += 1 } else { diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index 2cf51c8d158..ce0f3fcfb32 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -1,25 +1,41 @@ import Foundation @objcMembers -public class SentryReplayOptions: NSObject { +public class SentryReplayOptions: NSObject, SentryRedactOptions { /** * Indicates the percentage in which the replay for the session will be created. - * @discussion Specifying @c 0 means never, @c 1.0 means always. - * @note The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it + * - Specifying @c 0 means never, @c 1.0 means always. + * - note: The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it * to the default. - * @note The default is @c 0. + * - note: The default is 0. */ - public let replaysSessionSampleRate: Float + public var sessionSampleRate: Float /** * Indicates the percentage in which a 30 seconds replay will be send with error events. - * @discussion Specifying @c 0 means never, @c 1.0 means always. - * @note The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it + * - Specifying 0 means never, 1.0 means always. + * - note: The value needs to be >= 0.0 and \<= 1.0. When setting a value out of range the SDK sets it * to the default. - * @note The default is @c 0. + * - note: The default is 0. */ - public let replaysOnErrorSampleRate: Float - + public var errorSampleRate: Float + + /** + * Indicates whether session replay should redact all text in the app + * by drawing a black rectangle over it. + * + * - note: The default is true + */ + public var redactAllText = true + + /** + * Indicates whether session replay should redact all non-bundled image + * in the app by drawing a black rectangle over it. + * + * - note: The default is true + */ + public var redactAllImages = true + /** * Defines the quality of the session replay. * Higher bit rates better quality, but also bigger files to transfer. @@ -48,12 +64,17 @@ public class SentryReplayOptions: NSObject { */ let sessionSegmentDuration = TimeInterval(5) + /** + * The maximum duration of a replay session. + */ + let maximumDuration = TimeInterval(3_600) + /** * Inittialize session replay options disabled */ public override init() { - self.replaysSessionSampleRate = 0 - self.replaysOnErrorSampleRate = 0 + self.sessionSampleRate = 0 + self.errorSampleRate = 0 } /** @@ -63,14 +84,18 @@ public class SentryReplayOptions: NSObject { * - errorSampleRate Indicates the percentage in which a 30 seconds replay will be send with * error events. */ - public init(sessionSampleRate: Float, errorSampleRate: Float) { - self.replaysSessionSampleRate = sessionSampleRate - self.replaysOnErrorSampleRate = errorSampleRate + public init(sessionSampleRate: Float = 0, errorSampleRate: Float = 0, redactAllText: Bool = true, redactAllImages: Bool = true) { + self.sessionSampleRate = sessionSampleRate + self.errorSampleRate = errorSampleRate + self.redactAllText = redactAllText + self.redactAllImages = redactAllImages } - convenience init(dictionary: NSDictionary) { - let sessionSampleRate = (dictionary["replaysSessionSampleRate"] as? NSNumber)?.floatValue ?? 0 - let onErrorSampleRate = (dictionary["replaysOnErrorSampleRate"] as? NSNumber)?.floatValue ?? 0 - self.init(sessionSampleRate: sessionSampleRate, errorSampleRate: onErrorSampleRate) + convenience init(dictionary: [String: Any]) { + let sessionSampleRate = (dictionary["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 + let onErrorSampleRate = (dictionary["errorSampleRate"] as? NSNumber)?.floatValue ?? 0 + let redactAllText = (dictionary["redactAllText"] as? NSNumber)?.boolValue ?? true + let redactAllImages = (dictionary["redactAllImages"] as? NSNumber)?.boolValue ?? true + self.init(sessionSampleRate: sessionSampleRate, errorSampleRate: onErrorSampleRate, redactAllText: redactAllText, redactAllImages: redactAllImages) } } diff --git a/Sources/Swift/Protocol/SentryRedactOptions.swift b/Sources/Swift/Protocol/SentryRedactOptions.swift new file mode 100644 index 00000000000..cdd38e819a1 --- /dev/null +++ b/Sources/Swift/Protocol/SentryRedactOptions.swift @@ -0,0 +1,7 @@ +import Foundation + +@objc +protocol SentryRedactOptions { + var redactAllText: Bool { get } + var redactAllImages: Bool { get } +} diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift new file mode 100644 index 00000000000..9cf1a1947fd --- /dev/null +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -0,0 +1,18 @@ +@objcMembers +public class SentryExperimentalOptions: NSObject { + #if canImport(UIKit) + /** + * Settings to configure the session replay. + */ + public var sessionReplay = SentryReplayOptions(sessionSampleRate: 0, errorSampleRate: 0) + #endif + + func validateOptions(_ options: [String: Any]?) { + #if canImport(UIKit) + if let sessionReplayOptions = options?["sessionReplay"] as? [String: Any] { + sessionReplay = SentryReplayOptions(dictionary: sessionReplayOptions) + } + #endif + } + +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentryViewPhotographer.swift b/Sources/Swift/Tools/SentryViewPhotographer.swift similarity index 72% rename from Sources/Swift/Integrations/SessionReplay/SentryViewPhotographer.swift rename to Sources/Swift/Tools/SentryViewPhotographer.swift index 09d27f9ad40..36667e4dec2 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/SentryViewPhotographer.swift @@ -29,7 +29,8 @@ class SentryViewPhotographer: NSObject { ].compactMap { NSClassFromString($0) } } - func image(view: UIView) -> UIImage? { + @objc(imageWithView:options:) + func image(view: UIView, options: SentryRedactOptions) -> UIImage? { UIGraphicsBeginImageContextWithOptions(view.bounds.size, true, 0) defer { @@ -39,15 +40,19 @@ class SentryViewPhotographer: NSObject { guard let currentContext = UIGraphicsGetCurrentContext() else { return nil } view.layer.render(in: currentContext) - self.mask(view: view, context: currentContext) + self.mask(view: view, context: currentContext, options: options) guard let screenshot = UIGraphicsGetImageFromCurrentImageContext() else { return nil } return screenshot } - private func mask(view: UIView, context: CGContext) { + private func mask(view: UIView, context: CGContext, options: SentryRedactOptions?) { UIColor.black.setFill() - let maskPath = self.buildPath(view: view, path: CGMutablePath(), area: view.frame) + let maskPath = self.buildPath(view: view, + path: CGMutablePath(), + area: view.frame, + redactText: options?.redactAllText ?? true, + redactImage: options?.redactAllImages ?? true) context.addPath(maskPath) context.fillPath() } @@ -57,24 +62,20 @@ class SentryViewPhotographer: NSObject { } private func shouldRedact(view: UIView) -> Bool { - if let imageView = view as? UIImageView { - return shouldRedact(imageView: imageView) - } - - return redactClasses.contains { view.isKind(of: $0) } + return redactClasses.contains { view.isKind(of: $0) } } private func shouldRedact(imageView: UIImageView) -> Bool { // Checking the size is to avoid redact gradient backgroud that // are usually small lines repeating guard let image = imageView.image, image.size.width > 10 && image.size.height > 10 else { return false } - return image.imageAsset?.value(forKey: "_containingBundle") != nil + return image.imageAsset?.value(forKey: "_containingBundle") == nil } - private func buildPath(view: UIView, path: CGMutablePath, area: CGRect) -> CGMutablePath { + private func buildPath(view: UIView, path: CGMutablePath, area: CGRect, redactText: Bool, redactImage: Bool) -> CGMutablePath { let rectInWindow = view.convert(view.bounds, to: nil) - if !area.intersects(rectInWindow) || view.isHidden || view.alpha == 0 { + if (!redactImage && !redactText) || !area.intersects(rectInWindow) || view.isHidden || view.alpha == 0 { return path } @@ -82,7 +83,14 @@ class SentryViewPhotographer: NSObject { let ignore = shouldIgnore(view: view) - if !ignore && shouldRedact(view: view) { + let redact: Bool = { + if redactImage, let imageView = view as? UIImageView { + return shouldRedact(imageView: imageView) + } + return redactText && shouldRedact(view: view) + }() + + if !ignore && redact { result.addRect(rectInWindow) return result } else if isOpaqueOrHasBackground(view) { @@ -91,7 +99,7 @@ class SentryViewPhotographer: NSObject { if !ignore { for subview in view.subviews { - result = buildPath(view: subview, path: path, area: area) + result = buildPath(view: subview, path: path, area: area, redactText: redactText, redactImage: redactImage) } } diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index cd225c29602..57af570ad3f 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -6,11 +6,10 @@ import XCTest #if os(iOS) || os(tvOS) -@available(iOS 16.0, tvOS 16.0, *) class SentrySessionReplayIntegrationTests: XCTestCase { override func setUpWithError() throws { - if #unavailable(iOS 16.0, tvOS 16.0) { + guard #available(iOS 16.0, tvOS 16.0, *) else { throw XCTSkip("iOS version not supported") } } @@ -20,21 +19,24 @@ class SentrySessionReplayIntegrationTests: XCTestCase { clearTestState() } - func testNoInstall() { - SentrySDK.start { - $0.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 0, errorSampleRate: 0) - $0.setIntegrations([SentrySessionReplayIntegration.self]) + func startSDK(sessionSampleRate: Float, errorSampleRate: Float) { + if #available(iOS 16.0, tvOS 16.0, *) { + SentrySDK.start { + $0.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: sessionSampleRate, errorSampleRate: errorSampleRate) + $0.setIntegrations([SentrySessionReplayIntegration.self]) + } } + } + + func testNoInstall() { + startSDK(sessionSampleRate: 0, errorSampleRate: 0) expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 0 expect(SentryGlobalEventProcessor.shared().processors.count) == 0 } func testInstallFullSessionReplay() { - SentrySDK.start { - $0.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 0) - $0.setIntegrations([SentrySessionReplayIntegration.self]) - } + startSDK(sessionSampleRate: 1, errorSampleRate: 0) expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 expect(SentryGlobalEventProcessor.shared().processors.count) == 1 @@ -44,10 +46,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase { SentryDependencyContainer.sharedInstance().random = TestRandom(value: 0.3) - SentrySDK.start { - $0.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 0.2, errorSampleRate: 0) - $0.setIntegrations([SentrySessionReplayIntegration.self]) - } + startSDK(sessionSampleRate: 0.2, errorSampleRate: 0) expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 0 expect(SentryGlobalEventProcessor.shared().processors.count) == 0 @@ -57,20 +56,14 @@ class SentrySessionReplayIntegrationTests: XCTestCase { SentryDependencyContainer.sharedInstance().random = TestRandom(value: 0.1) - SentrySDK.start { - $0.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 0.2, errorSampleRate: 0) - $0.setIntegrations([SentrySessionReplayIntegration.self]) - } + startSDK(sessionSampleRate: 0.2, errorSampleRate: 0) expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 expect(SentryGlobalEventProcessor.shared().processors.count) == 1 } func testInstallErrorReplay() { - SentrySDK.start { - $0.sessionReplayOptions = SentryReplayOptions(sessionSampleRate: 0, errorSampleRate: 0.1) - $0.setIntegrations([SentrySessionReplayIntegration.self]) - } + startSDK(sessionSampleRate: 0, errorSampleRate: 0.1) expect(SentrySDK.currentHub().trimmedInstalledIntegrationNames().count) == 1 expect(SentryGlobalEventProcessor.shared().processors.count) == 1 diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index 055a7a74240..1925b4eb2e9 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -5,11 +5,10 @@ import SentryTestUtils import XCTest #if os(iOS) || os(tvOS) -@available(iOS 16.0, tvOS 16.0, *) class SentrySessionReplayTests: XCTestCase { private class ScreenshotProvider: NSObject, SentryViewScreenshotProvider { - func image(with view: UIView) -> UIImage { UIImage.add } + func image(with view: UIView, options: SentryRedactOptions) -> UIImage { UIImage.add } } private class TestReplayMaker: NSObject, SentryReplayMaker { @@ -17,11 +16,11 @@ class SentrySessionReplayTests: XCTestCase { var duration: TimeInterval var beginning: Date var outputFileURL: URL - var completion: ((Sentry.SentryVideoInfo?, (any Error)?) -> Void) + var completion: ((Sentry.SentryVideoInfo?, Error?) -> Void) } var lastCallToCreateVideo: CreateVideoCall? - func createVideo(withDuration duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (Sentry.SentryVideoInfo?, (any Error)?) -> Void) throws { + func createVideo(withDuration duration: TimeInterval, beginning: Date, outputFileURL: URL, completion: @escaping (SentryVideoInfo?, Error?) -> Void) throws { lastCallToCreateVideo = CreateVideoCall(duration: duration, beginning: beginning, outputFileURL: outputFileURL, @@ -43,7 +42,6 @@ class SentrySessionReplayTests: XCTestCase { func releaseFrames(until date: Date) { lastReleaseUntil = date } - } private class ReplayHub: SentryHub { @@ -58,6 +56,7 @@ class SentrySessionReplayTests: XCTestCase { } } + @available(iOS 16.0, tvOS 16.0, *) private class Fixture { let dateProvider = TestCurrentDateProvider() let random = TestRandom(value: 0) @@ -80,14 +79,13 @@ class SentrySessionReplayTests: XCTestCase { } override func setUpWithError() throws { - if #unavailable(iOS 16.0, tvOS 16.0) { + guard #available(iOS 16.0, tvOS 16.0, *) else { throw XCTSkip("iOS version not supported") } } override func setUp() { super.setUp() - SentrySDK.setCurrentHub(fixture.hub) } override func tearDown() { @@ -95,9 +93,16 @@ class SentrySessionReplayTests: XCTestCase { clearTestState() } - private let fixture = Fixture() + @available(iOS 16.0, tvOS 16, *) + private func startFixture() -> Fixture { + let fixture = Fixture() + SentrySDK.setCurrentHub(fixture.hub) + return fixture + } + @available(iOS 16.0, tvOS 16, *) func testDontSentReplay_NoFullSession() { + let fixture = startFixture() let sut = fixture.getSut() sut.start(fixture.rootView, fullSession: false) @@ -106,10 +111,12 @@ class SentrySessionReplayTests: XCTestCase { fixture.dateProvider.advance(by: 5) Dynamic(sut).newFrame(nil) - expect(self.fixture.hub.lastEvent) == nil + expect(fixture.hub.lastEvent) == nil } + @available(iOS 16.0, tvOS 16, *) func testSentReplay_FullSession() { + let fixture = startFixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(fixture.rootView, fullSession: true) @@ -128,14 +135,16 @@ class SentrySessionReplayTests: XCTestCase { expect(videoArguments.duration) == 5 expect(videoArguments.beginning) == start - expect(videoArguments.outputFileURL) == fixture.cacheFolder.appendingPathComponent("segments/1.000000-6.000000.mp4") + expect(videoArguments.outputFileURL) == fixture.cacheFolder.appendingPathComponent("segments/0.mp4") - expect(self.fixture.hub.lastRecording) != nil - expect(self.fixture.hub.lastVideo) == videoArguments.outputFileURL + expect(fixture.hub.lastRecording) != nil + expect(fixture.hub.lastVideo) == videoArguments.outputFileURL assertFullSession(sut, expected: true) } + @available(iOS 16.0, tvOS 16, *) func testDontSentReplay_NotFullSession() { + let fixture = startFixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(fixture.rootView, fullSession: false) @@ -151,7 +160,9 @@ class SentrySessionReplayTests: XCTestCase { assertFullSession(sut, expected: false) } + @available(iOS 16.0, tvOS 16, *) func testChangeReplayMode_forErrorEvent() { + let fixture = startFixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(fixture.rootView, fullSession: false) @@ -161,16 +172,36 @@ class SentrySessionReplayTests: XCTestCase { assertFullSession(sut, expected: true) } + @available(iOS 16.0, tvOS 16, *) func testDontChangeReplayMode_forNonErrorEvent() { + let fixture = startFixture() let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) sut.start(fixture.rootView, fullSession: false) let event = Event(level: .info) sut.capture(for: event) + assertFullSession(sut, expected: false) } + @available(iOS 16.0, tvOS 16, *) + func testSessionReplayMaximumDuration() { + let fixture = startFixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, errorSampleRate: 1)) + sut.start(fixture.rootView, fullSession: true) + + Dynamic(sut).newFrame(nil) + fixture.dateProvider.advance(by: 5) + Dynamic(sut).newFrame(nil) + expect(Dynamic(sut).isRunning) == true + fixture.dateProvider.advance(by: 3_600) + Dynamic(sut).newFrame(nil) + + expect(Dynamic(sut).isRunning) == false + } + + @available(iOS 16.0, tvOS 16, *) func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) { expect(Dynamic(sessionReplay).isFullSession) == expected } diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index d8de03496a5..1ceb3dbc5d2 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -604,9 +604,8 @@ - (void)assertDefaultValues:(SentryOptions *)options XCTAssertEqual(options.enableUserInteractionTracing, YES); XCTAssertEqual(options.enablePreWarmedAppStartTracing, NO); XCTAssertEqual(options.attachViewHierarchy, NO); - if (@available(iOS 16.0, tvOS 16.0, *)) { - XCTAssertNil(options.sessionReplayOptions); - } + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 0); + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 0); #endif XCTAssertFalse(options.enableTracing); XCTAssertTrue(options.enableAppHangTracking); @@ -786,11 +785,11 @@ - (void)testSessionReplaySettingsInit { if (@available(iOS 16.0, tvOS 16.0, *)) { SentryOptions *options = [self getValidOptions:@{ - @"sessionReplayOptions" : - @ { @"replaysSessionSampleRate" : @2, @"replaysOnErrorSampleRate" : @4 } + @"experimental" : + @ { @"sessionReplay" : @ { @"sessionSampleRate" : @2, @"errorSampleRate" : @4 } } }]; - XCTAssertEqual(options.sessionReplayOptions.replaysSessionSampleRate, 2); - XCTAssertEqual(options.sessionReplayOptions.replaysOnErrorSampleRate, 4); + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 2); + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 4); } } @@ -798,8 +797,8 @@ - (void)testSessionReplaySettingsDefaults { if (@available(iOS 16.0, tvOS 16.0, *)) { SentryOptions *options = [self getValidOptions:@{ @"sessionReplayOptions" : @ {} }]; - XCTAssertEqual(options.sessionReplayOptions.replaysSessionSampleRate, 0); - XCTAssertEqual(options.sessionReplayOptions.replaysOnErrorSampleRate, 0); + XCTAssertEqual(options.experimental.sessionReplay.sessionSampleRate, 0); + XCTAssertEqual(options.experimental.sessionReplay.errorSampleRate, 0); } }