diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ea7c0205..2ed56b9fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- feat: API to manually start/stop Session Replay (#4414) - Custom redact modifier for SwiftUI (#4362, #4392) ### Removal of Experimental API @@ -20,6 +21,7 @@ via the option `swizzleClassNameExclude`. ### Improvements - Serializing profile on a BG Thread (#4377) to avoid potentially slightly blocking the main thread. +- Session Replay performance for SwiftUI (#4419) ## 8.38.0-beta.1 diff --git a/Sources/Sentry/Public/SentryReplayApi.h b/Sources/Sentry/Public/SentryReplayApi.h index 800dbb0c97..f9f001e9c1 100644 --- a/Sources/Sentry/Public/SentryReplayApi.h +++ b/Sources/Sentry/Public/SentryReplayApi.h @@ -42,6 +42,20 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)resume; +/** + * Start recording a session replay if not started. + * + * @warning This is an experimental feature and may still have bugs. + */ +- (void)start; + +/** + * Stop the current session replay recording. + * + * @warning This is an experimental feature and may still have bugs. + */ +- (void)stop; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 0de5de589f..b80be215d0 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -804,5 +804,4 @@ - (NSString *)debugDescription return [NSString stringWithFormat:@"<%@: {\n%@\n}>", self, propertiesDescription]; } #endif // defined(DEBUG) || defined(TEST) || defined(TESTCI) - @end diff --git a/Sources/Sentry/SentryReplayApi.m b/Sources/Sentry/SentryReplayApi.m index 9d2e7ba4b1..3aad160229 100644 --- a/Sources/Sentry/SentryReplayApi.m +++ b/Sources/Sentry/SentryReplayApi.m @@ -3,8 +3,9 @@ #if SENTRY_TARGET_REPLAY_SUPPORTED # import "SentryHub+Private.h" +# import "SentryOptions+Private.h" # import "SentrySDK+Private.h" -# import "SentrySessionReplayIntegration.h" +# import "SentrySessionReplayIntegration+Private.h" # import "SentrySwift.h" # import @@ -22,18 +23,46 @@ - (void)unmaskView:(UIView *)view - (void)pause { - SentrySessionReplayIntegration *replayIntegration = - [SentrySDK.currentHub getInstalledIntegration:SentrySessionReplayIntegration.class]; + SentrySessionReplayIntegration *replayIntegration + = (SentrySessionReplayIntegration *)[SentrySDK.currentHub + getInstalledIntegration:SentrySessionReplayIntegration.class]; [replayIntegration pause]; } - (void)resume { - SentrySessionReplayIntegration *replayIntegration = - [SentrySDK.currentHub getInstalledIntegration:SentrySessionReplayIntegration.class]; + SentrySessionReplayIntegration *replayIntegration + = (SentrySessionReplayIntegration *)[SentrySDK.currentHub + getInstalledIntegration:SentrySessionReplayIntegration.class]; [replayIntegration resume]; } +- (void)start +{ + SentrySessionReplayIntegration *replayIntegration + = (SentrySessionReplayIntegration *)[SentrySDK.currentHub + getInstalledIntegration:SentrySessionReplayIntegration.class]; + + if (replayIntegration == nil) { + SentryOptions *currentOptions = SentrySDK.currentHub.client.options; + replayIntegration = + [[SentrySessionReplayIntegration alloc] initForManualUse:currentOptions]; + + [SentrySDK.currentHub addInstalledIntegration:replayIntegration + name:NSStringFromClass(SentrySessionReplay.class)]; + } + + [replayIntegration start]; +} + +- (void)stop +{ + SentrySessionReplayIntegration *replayIntegration + = (SentrySessionReplayIntegration *)[SentrySDK.currentHub + getInstalledIntegration:SentrySessionReplayIntegration.class]; + [replayIntegration stop]; +} + @end #endif diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index be03538ee4..e2db6fc3c8 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -37,8 +37,6 @@ */ static SentryTouchTracker *_touchTracker; -static SentrySessionReplayIntegration *_installedInstance; - @interface SentrySessionReplayIntegration () - (void)newSceneActivate; @end @@ -50,9 +48,20 @@ @implementation SentrySessionReplayIntegration { SentryOnDemandReplay *_resumeReplayMaker; } -+ (nullable SentrySessionReplayIntegration *)installed +- (instancetype)init +{ + self = [super init]; + return self; +} + +- (instancetype)initForManualUse:(nonnull SentryOptions *)options { - return _installedInstance; + if (self = [super init]) { + [self setupWith:options.experimental.sessionReplay + enableTouchTracker:options.enableSwizzling]; + [self startWithOptions:options.experimental.sessionReplay fullSession:YES]; + } + return self; } - (BOOL)installWithOptions:(nonnull SentryOptions *)options @@ -61,14 +70,19 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options return NO; } - _replayOptions = options.experimental.sessionReplay; - _viewPhotographer = - [[SentryViewPhotographer alloc] initWithRedactOptions:options.experimental.sessionReplay]; + [self setupWith:options.experimental.sessionReplay enableTouchTracker:options.enableSwizzling]; + return YES; +} + +- (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL)touchTracker +{ + _replayOptions = replayOptions; + _viewPhotographer = [[SentryViewPhotographer alloc] initWithRedactOptions:replayOptions]; - if (options.enableSwizzling) { + if (touchTracker) { _touchTracker = [[SentryTouchTracker alloc] initWithDateProvider:SentryDependencyContainer.sharedInstance.dateProvider - scale:options.experimental.sessionReplay.sizeScale]; + scale:replayOptions.sizeScale]; [self swizzleApplicationTouch]; } @@ -87,9 +101,6 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options }]; [SentryDependencyContainer.sharedInstance.reachability addObserver:self]; - - _installedInstance = self; - return YES; } /** @@ -212,23 +223,30 @@ - (void)startSession return; } + [self runReplayForAvailableWindow]; +} + +- (void)runReplayForAvailableWindow +{ if (SentryDependencyContainer.sharedInstance.application.windows.count > 0) { // If a window its already available start replay right away [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; - } else { + } else if (@available(iOS 13.0, tvOS 13.0, *)) { // Wait for a scene to be available to started the replay - if (@available(iOS 13.0, tvOS 13.0, *)) { - [_notificationCenter addObserver:self - selector:@selector(newSceneActivate) - name:UISceneDidActivateNotification]; - } + [_notificationCenter addObserver:self + selector:@selector(newSceneActivate) + name:UISceneDidActivateNotification]; } } - (void)newSceneActivate { - [SentryDependencyContainer.sharedInstance.notificationCenterWrapper removeObserver:self]; - [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; + if (@available(iOS 13.0, tvOS 13.0, *)) { + [SentryDependencyContainer.sharedInstance.notificationCenterWrapper + removeObserver:self + name:UISceneDidActivateNotification]; + [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; + } } - (void)startWithOptions:(SentryReplayOptions *)replayOptions @@ -277,7 +295,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions [self.sessionReplay startWithRootView:SentryDependencyContainer.sharedInstance.application.windows.firstObject - fullSession:[self shouldReplayFullSession:replayOptions.sessionSampleRate]]; + fullSession:shouldReplayFullSession]; [_notificationCenter addObserver:self selector:@selector(pause) @@ -351,6 +369,25 @@ - (void)resume [self.sessionReplay resume]; } +- (void)start +{ + if (self.sessionReplay != nil) { + if (self.sessionReplay.isFullSession == NO) { + [self.sessionReplay captureReplay]; + } + return; + } + + _startedAsFullSession = YES; + [self runReplayForAvailableWindow]; +} + +- (void)stop +{ + [self.sessionReplay pause]; + self.sessionReplay = nil; +} + - (void)sentrySessionEnded:(SentrySession *)session { [self pause]; @@ -395,10 +432,6 @@ - (void)uninstall [SentrySDK.currentHub unregisterSessionListener:self]; _touchTracker = nil; [self pause]; - - if (_installedInstance == self) { - _installedInstance = nil; - } } - (void)dealloc diff --git a/Sources/Sentry/include/SentrySessionReplayIntegration.h b/Sources/Sentry/include/SentrySessionReplayIntegration.h index f5fd4183a5..1520e6d330 100644 --- a/Sources/Sentry/include/SentrySessionReplayIntegration.h +++ b/Sources/Sentry/include/SentrySessionReplayIntegration.h @@ -10,10 +10,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SentrySessionReplayIntegration : SentryBaseIntegration -/** - * The last instance of the installed integration - */ -@property (class, nonatomic, readonly, nullable) SentrySessionReplayIntegration *installed; +- (instancetype)initForManualUse:(nonnull SentryOptions *)options; /** * Captures Replay. Used by the Hybrid SDKs. @@ -32,6 +29,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)resume; +- (void)stop; + +- (void)start; + @end #endif // SENTRY_TARGET_REPLAY_SUPPORTED NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 4fab0b3a52..3b8b60497b 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -130,7 +130,7 @@ class SentrySessionReplay: NSObject { videoSegmentStart = nil displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) } - + func captureReplayFor(event: Event) { guard isRunning else { return } diff --git a/Sources/Swift/Tools/SentryViewPhotographer.swift b/Sources/Swift/Tools/SentryViewPhotographer.swift index 322a7017a8..d553e4ea17 100644 --- a/Sources/Swift/Tools/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/SentryViewPhotographer.swift @@ -45,6 +45,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { dispatchQueue.dispatchAsync { let screenshot = UIGraphicsImageRenderer(size: imageSize, format: .init(for: .init(displayScale: 1))).image { context in + let imageRect = CGRect(origin: .zero, size: imageSize) context.cgContext.addRect(CGRect(origin: CGPoint.zero, size: imageSize)) context.cgContext.clip(using: .evenOdd) UIColor.blue.setStroke() @@ -52,11 +53,16 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { context.cgContext.interpolationQuality = .none image.draw(at: .zero) + var latestRegion: RedactRegion? for region in redact { let rect = CGRect(origin: CGPoint.zero, size: region.size) var transform = region.transform let path = CGPath(rect: rect, transform: &transform) + defer { latestRegion = region } + + guard latestRegion?.canReplace(as: region) != true && imageRect.intersects(path.boundingBoxOfPath) else { continue } + switch region.type { case .redact, .redactSwiftUI: (region.color ?? UIImageHelper.averageColor(of: context.currentImage, at: rect.applying(region.transform))).setFill() diff --git a/Sources/Swift/Tools/UIRedactBuilder.swift b/Sources/Swift/Tools/UIRedactBuilder.swift index 72ff985d95..cd3f0f3442 100644 --- a/Sources/Swift/Tools/UIRedactBuilder.swift +++ b/Sources/Swift/Tools/UIRedactBuilder.swift @@ -39,6 +39,10 @@ struct RedactRegion { self.type = type self.color = color } + + func canReplace(as other: RedactRegion) -> Bool { + size == other.size && transform == other.transform && type == other.type + } } class UIRedactBuilder { @@ -170,7 +174,7 @@ class UIRedactBuilder { } //The swiftUI type needs to appear first in the list so it always get masked - return swiftUIRedact + otherRegions.reversed() + return (otherRegions + swiftUIRedact).reversed() } private func shouldIgnore(view: UIView) -> Bool { diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index a308c8eb2e..156ea6eb7d 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -43,11 +43,11 @@ class SentrySessionReplayIntegrationTests: XCTestCase { return try XCTUnwrap(SentrySDK.currentHub().installedIntegrations().first as? SentrySessionReplayIntegration) } - private func startSDK(sessionSampleRate: Float, errorSampleRate: Float, enableSwizzling: Bool = true, configure: ((Options) -> Void)? = nil) { + private func startSDK(sessionSampleRate: Float, errorSampleRate: Float, enableSwizzling: Bool = true, noIntegrations: Bool = false, configure: ((Options) -> Void)? = nil) { SentrySDK.start { $0.dsn = "https://user@test.com/test" $0.experimental.sessionReplay = SentryReplayOptions(sessionSampleRate: sessionSampleRate, onErrorSampleRate: errorSampleRate) - $0.setIntegrations([SentrySessionReplayIntegration.self]) + $0.setIntegrations(noIntegrations ? [] : [SentrySessionReplayIntegration.self]) $0.enableSwizzling = enableSwizzling $0.cacheDirectoryPath = FileManager.default.temporaryDirectory.path configure?($0) @@ -309,6 +309,55 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertTrue(redactBuilder.containsIgnoreClass(AnotherLabel.self)) } + func testStop() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = sut.sessionReplay + XCTAssertTrue(sessionReplay?.isRunning ?? false) + + SentrySDK.replay.stop() + + XCTAssertFalse(sessionReplay?.isRunning ?? true) + XCTAssertNil(sut.sessionReplay) + } + + func testStartWithNoSessionReplay() throws { + startSDK(sessionSampleRate: 0, errorSampleRate: 0, noIntegrations: true) + var sut = SentrySDK.currentHub().installedIntegrations().first as? SentrySessionReplayIntegration + XCTAssertNil(sut) + SentrySDK.replay.start() + sut = try getSut() + + let sessionReplay = sut?.sessionReplay + XCTAssertTrue(sessionReplay?.isRunning ?? false) + XCTAssertTrue(sessionReplay?.isFullSession ?? false) + XCTAssertNotNil(sut?.sessionReplay) + } + + func testStartWithSessionReplayRunning() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = try XCTUnwrap(sut.sessionReplay) + let replayId = sessionReplay.sessionReplayId + + SentrySDK.replay.start() + + //Test whether the integration keeps the same instance of the session replay + XCTAssertEqual(sessionReplay, sut.sessionReplay) + //Test whether the session Id is still the same + XCTAssertEqual(sessionReplay.sessionReplayId, replayId) + } + + func testStartWithBufferSessionReplay() throws { + startSDK(sessionSampleRate: 0, errorSampleRate: 1) + let sut = try getSut() + let sessionReplay = try XCTUnwrap(sut.sessionReplay) + + XCTAssertFalse(sessionReplay.isFullSession) + SentrySDK.replay.start() + XCTAssertTrue(sessionReplay.isFullSession) + } + func createLastSessionReplay(writeSessionInfo: Bool = true, errorSampleRate: Double = 1) throws { let options = Options() options.dsn = "https://user@test.com/test" diff --git a/Tests/SentryTests/SentryViewPhotographerTests.swift b/Tests/SentryTests/SentryViewPhotographerTests.swift index 3217e9dca7..69643f483d 100644 --- a/Tests/SentryTests/SentryViewPhotographerTests.swift +++ b/Tests/SentryTests/SentryViewPhotographerTests.swift @@ -180,6 +180,21 @@ class SentryViewPhotographerTests: XCTestCase { assertColor(pixel2, .white) } + func testSkipSameRegion() throws { + let label1 = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) + label1.text = "Test" + label1.textColor = .red + + let label2 = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 25)) + label2.text = "Test" + label2.textColor = .green + + let image = try XCTUnwrap(prepare(views: [label1, label2])) + let pixel1 = color(at: CGPoint(x: 10, y: 10), in: image) + + assertColor(pixel1, .green) + } + private func assertColor(_ color1: UIColor, _ color2: UIColor) { let sRGBColor1 = color1.cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil) let sRGBColor2 = color2.cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)