From 8f22c40af65dc76e97689e8a13b662d6fd3ecaaf Mon Sep 17 00:00:00 2001 From: Dhiogo Brustolin Date: Tue, 6 Aug 2024 15:43:17 +0200 Subject: [PATCH] feat: Add options to redact or ignore view for Replay (#4228) Added options to ReplayOptions for users to specify which classes to redact or ignore during replay. Also added functions to SentrySDK and a UIView extension to choose specific views to redact or ignore. --- CHANGELOG.md | 1 + Sentry.xcodeproj/project.pbxproj | 4 + Sources/Sentry/Public/SentrySDK.h | 20 +++++ Sources/Sentry/SentrySDK.m | 12 +++ .../Sentry/SentrySessionReplayIntegration.m | 3 + .../Swift/Extensions/UIViewExtensions.swift | 27 +++++++ .../SessionReplay/SentryReplayOptions.swift | 14 ++++ .../Swift/Tools/SentryViewPhotographer.swift | 14 +++- Sources/Swift/Tools/UIRedactBuilder.swift | 67 +++++++++++++++-- .../SentrySessionReplayIntegrationTests.swift | 27 ++++++- Tests/SentryTests/UIRedactBuilderTests.swift | 73 ++++++++++++++++++- 11 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 Sources/Swift/Extensions/UIViewExtensions.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e7f3ec1ce..3827ca8eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Redact web view from replay (#4203) - Add beforeCaptureViewHierarchy callback (#4210) - Rename session replay `errorSampleRate` property to `onErrorSampleRate` (#4218) +- Add options to redact or ignore view for Replay (#4228) ### Fixes diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index a5975894d1..8fca179e12 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -870,6 +870,7 @@ D8AE48B02C5782EC0092A2A6 /* SentryLogOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AE48AF2C5782EC0092A2A6 /* SentryLogOutput.swift */; }; D8AE48BF2C578D540092A2A6 /* SentryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AE48BE2C578D540092A2A6 /* SentryLog.swift */; }; D8AE48C12C57B1550092A2A6 /* SentryLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AE48C02C57B1550092A2A6 /* SentryLevelTests.swift */; }; + D8AE49182C5D09720092A2A6 /* UIViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AE49172C5D09720092A2A6 /* UIViewExtensions.swift */; }; D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */; }; D8AFC01A2BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AFC0192BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift */; }; D8AFC03D2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */; }; @@ -1931,6 +1932,7 @@ D8AE48B12C5786AA0092A2A6 /* SentryLogC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryLogC.h; path = include/SentryLogC.h; sourceTree = ""; }; D8AE48BE2C578D540092A2A6 /* SentryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLog.swift; sourceTree = ""; }; D8AE48C02C57B1550092A2A6 /* SentryLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLevelTests.swift; sourceTree = ""; }; + D8AE49172C5D09720092A2A6 /* UIViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = ""; }; D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplayTests.swift; sourceTree = ""; }; D8AFC0192BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewScreenshotProvider.swift; sourceTree = ""; }; D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayVideoMaker.swift; sourceTree = ""; }; @@ -3886,6 +3888,7 @@ D8F016B52B962548007B9AFB /* StringExtensions.swift */, 62872B5E2BA1B7F300A4FA7D /* NSLock.swift */, D8BC28C92BFF68CA0054DA4D /* NumberExtensions.swift */, + D8AE49172C5D09720092A2A6 /* UIViewExtensions.swift */, ); path = Extensions; sourceTree = ""; @@ -4683,6 +4686,7 @@ 0A2D8DA9289BC905008720F6 /* SentryViewHierarchy.m in Sources */, D84D2CDD2C2BF7370011AF8A /* SentryReplayEvent.swift in Sources */, D8BC28CC2BFF78220054DA4D /* SentryRRWebTouchEvent.swift in Sources */, + D8AE49182C5D09720092A2A6 /* UIViewExtensions.swift in Sources */, 8EA1ED0B2668F8C400E62B98 /* SentryUIViewControllerSwizzling.m in Sources */, 7B98D7CF25FB650F00C5A389 /* SentryWatchdogTerminationTrackingIntegration.m in Sources */, 8E5D38DD261D4A3E000D363D /* SentryPerformanceTrackingIntegration.m in Sources */, diff --git a/Sources/Sentry/Public/SentrySDK.h b/Sources/Sentry/Public/SentrySDK.h index 8747ca1dd4..ec9d48f29e 100644 --- a/Sources/Sentry/Public/SentrySDK.h +++ b/Sources/Sentry/Public/SentrySDK.h @@ -5,6 +5,7 @@ @class SentryOptions, SentryEvent, SentryBreadcrumb, SentryScope, SentryUser, SentryId, SentryUserFeedback, SentryTransactionContext; @class SentryMetricsAPI; +@class UIView; NS_ASSUME_NONNULL_BEGIN @@ -333,6 +334,25 @@ SENTRY_NO_INIT */ + (void)close; +#if SENTRY_HAS_UIKIT + +/** + * @warning This is an experimental feature and may still have bugs. + * + * Marks this view to be redacted during replays. + */ ++ (void)replayRedactView:(UIView *)view; + +/** + * @warning This is an experimental feature and may still have bugs. + * + * Marks this view to be ignored during redact step + * of session replay. All its content will be visible in the replay. + */ ++ (void)replayIgnoreView:(UIView *)view; + +#endif + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index a788d64926..b6b5e35d6e 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -571,6 +571,18 @@ + (void)stopProfiler } #endif // SENTRY_TARGET_PROFILING_SUPPORTED +#if SENTRY_HAS_UIKIT ++ (void)replayRedactView:(UIView *)view +{ + [SentryRedactViewHelper redactView:view]; +} + ++ (void)replayIgnoreView:(UIView *)view +{ + [SentryRedactViewHelper ignoreView:view]; +} +#endif + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index cfcc449741..be7439bdd5 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -74,6 +74,9 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options return event; }]; + [SentryViewPhotographer.shared addIgnoreClasses:_replayOptions.ignoreRedactViewTypes]; + [SentryViewPhotographer.shared addRedactClasses:_replayOptions.redactViewTypes]; + return YES; } diff --git a/Sources/Swift/Extensions/UIViewExtensions.swift b/Sources/Swift/Extensions/UIViewExtensions.swift new file mode 100644 index 0000000000..53292e32e4 --- /dev/null +++ b/Sources/Swift/Extensions/UIViewExtensions.swift @@ -0,0 +1,27 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) +import Foundation +import UIKit + +public extension UIView { + + /** + * Marks this view to be redacted during replays. + * - warning: This is an experimental feature and may still have bugs. + */ + func sentryReplayRedact() { + SentryRedactViewHelper.redactView(self) + } + + /** + * Marks this view to be ignored during redact step + * of session replay. All its content will be visible in the replay. + * - warning: This is an experimental feature and may still have bugs. + */ + func sentryReplayIgnore() { + SentryRedactViewHelper.ignoreView(self) + } +} + +#endif +#endif diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index 75cb2a6165..bd5cca99a6 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -66,6 +66,20 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { */ public var quality = SentryReplayQuality.low + /** + * A list of custom UIView subclasses that need + * to be masked during session replay. + * By default Sentry already mask text elements from UIKit + */ + public var redactViewTypes = [AnyClass]() + + /** + * A list of custom UIView subclasses to be ignored + * during masking step of the session replay. + * The view itself and any child will be ignored and not masked. + */ + public var ignoreRedactViewTypes = [AnyClass]() + /** * Defines the quality of the session replay. * Higher bit rates better quality, but also bigger files to transfer. diff --git a/Sources/Swift/Tools/SentryViewPhotographer.swift b/Sources/Swift/Tools/SentryViewPhotographer.swift index d1089a801f..0db478d531 100644 --- a/Sources/Swift/Tools/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/SentryViewPhotographer.swift @@ -10,8 +10,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { static let shared = SentryViewPhotographer() - //This is a list of UIView subclasses that will be ignored during redact process - private var redactBuilder = UIRedactBuilder() + private let redactBuilder = UIRedactBuilder() func image(view: UIView, options: SentryRedactOptions, onComplete: @escaping ScreenshotCallback ) { let image = UIGraphicsImageRenderer(size: view.bounds.size).image { _ in @@ -36,13 +35,20 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { @objc(addIgnoreClasses:) func addIgnoreClasses(classes: [AnyClass]) { - redactBuilder.ignoreClasses += classes + redactBuilder.addIgnoreClasses(classes) } @objc(addRedactClasses:) func addRedactClasses(classes: [AnyClass]) { - redactBuilder.redactClasses += classes + redactBuilder.addRedactClasses(classes) } + +#if TEST || TESTCI + func getRedactBuild() -> UIRedactBuilder { + redactBuilder + } +#endif + } #endif // os(iOS) || os(tvOS) diff --git a/Sources/Swift/Tools/UIRedactBuilder.swift b/Sources/Swift/Tools/UIRedactBuilder.swift index 2746e19f1d..bab1268153 100644 --- a/Sources/Swift/Tools/UIRedactBuilder.swift +++ b/Sources/Swift/Tools/UIRedactBuilder.swift @@ -1,6 +1,7 @@ #if canImport(UIKit) && !SENTRY_NO_UIKIT #if os(iOS) || os(tvOS) import Foundation +import ObjectiveC.NSObjCRuntime import UIKit #if os(iOS) import WebKit @@ -53,13 +54,13 @@ struct RedactRegion { class UIRedactBuilder { //This is a list of UIView subclasses that will be ignored during redact process - var ignoreClasses: [AnyClass] + private var ignoreClassesIdentifiers: Set //This is a list of UIView subclasses that need to be redacted from screenshot - var redactClasses: [AnyClass] + private var redactClassesIdentifiers: Set init() { - redactClasses = [ UILabel.self, UITextView.self, UITextField.self ] + + var redactClasses = [ UILabel.self, UITextView.self, UITextField.self ] + //this classes are used by SwiftUI to display images. ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", @@ -68,10 +69,35 @@ class UIRedactBuilder { #if os(iOS) redactClasses += [ WKWebView.self ] - ignoreClasses = [ UISlider.self, UISwitch.self ] + ignoreClassesIdentifiers = [ ObjectIdentifier(UISlider.self), ObjectIdentifier(UISwitch.self) ] #else - ignoreClasses = [] + ignoreClassesIdentifiers = [] #endif + redactClassesIdentifiers = Set(redactClasses.map( { ObjectIdentifier($0) })) + } + + func containsIgnoreClass(_ ignoreClass: AnyClass) -> Bool { + return ignoreClassesIdentifiers.contains(ObjectIdentifier(ignoreClass)) + } + + func containsRedactClass(_ redactClass: AnyClass) -> Bool { + return redactClassesIdentifiers.contains(ObjectIdentifier(redactClass)) + } + + func addIgnoreClass(_ ignoreClass: AnyClass) { + ignoreClassesIdentifiers.insert(ObjectIdentifier(ignoreClass)) + } + + func addRedactClass(_ redactClass: AnyClass) { + redactClassesIdentifiers.insert(ObjectIdentifier(redactClass)) + } + + func addIgnoreClasses(_ ignoreClasses: [AnyClass]) { + ignoreClasses.forEach(addIgnoreClass(_:)) + } + + func addRedactClasses(_ redactClasses: [AnyClass]) { + redactClasses.forEach(addRedactClass(_:)) } func redactRegionsFor(view: UIView, options: SentryRedactOptions?) -> [RedactRegion] { @@ -86,16 +112,19 @@ class UIRedactBuilder { return redactingRegions } - + private func shouldIgnore(view: UIView) -> Bool { - ignoreClasses.contains { view.isKind(of: $0) } + return SentryRedactViewHelper.shouldIgnoreView(view) || containsIgnoreClass(type(of: view)) } private func shouldRedact(view: UIView, redactText: Bool, redactImage: Bool) -> Bool { + if SentryRedactViewHelper.shouldRedactView(view) { + return true + } if redactImage, let imageView = view as? UIImageView { return shouldRedact(imageView: imageView) } - return redactText && redactClasses.contains { view.isKind(of: $0) } + return redactText && containsRedactClass(type(of: view)) } private func shouldRedact(imageView: UIImageView) -> Bool { @@ -140,5 +169,27 @@ class UIRedactBuilder { } } +@objcMembers +class SentryRedactViewHelper: NSObject { + private static var associatedRedactObjectHandle: UInt8 = 0 + private static var associatedIgnoreObjectHandle: UInt8 = 0 + + static func shouldRedactView(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedRedactObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static func shouldIgnoreView(_ view: UIView) -> Bool { + (objc_getAssociatedObject(view, &associatedIgnoreObjectHandle) as? NSNumber)?.boolValue ?? false + } + + static func redactView(_ view: UIView) { + objc_setAssociatedObject(view, &associatedRedactObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } + + static func ignoreView(_ view: UIView) { + objc_setAssociatedObject(view, &associatedIgnoreObjectHandle, true, .OBJC_ASSOCIATION_ASSIGN) + } +} + #endif #endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index a38c7c8ce4..90ba6a2cd4 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -42,13 +42,14 @@ class SentrySessionReplayIntegrationTests: XCTestCase { return try XCTUnwrap(SentrySDK.currentHub().installedIntegrations().first as? SentrySessionReplayIntegration) } - private func startSDK(sessionSampleRate: Float, errorSampleRate: Float, enableSwizzling: Bool = true) { + private func startSDK(sessionSampleRate: Float, errorSampleRate: Float, enableSwizzling: Bool = true, 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.enableSwizzling = enableSwizzling $0.cacheDirectoryPath = FileManager.default.temporaryDirectory.path + configure?($0) } SentrySDK.currentHub().startSession() } @@ -272,6 +273,30 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertEqual(hub.capturedReplayRecordingVideo.count, 0) } + func testMaskViewFromSDK() { + class AnotherLabel: UILabel { + } + + startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in + options.experimental.sessionReplay.redactViewTypes = [AnotherLabel.self] + } + + let redactBuilder = SentryViewPhotographer.shared.getRedactBuild() + XCTAssertTrue(redactBuilder.containsRedactClass(AnotherLabel.self)) + } + + func testIgnoreViewFromSDK() { + class AnotherLabel: UILabel { + } + + startSDK(sessionSampleRate: 1, errorSampleRate: 1) { options in + options.experimental.sessionReplay.ignoreRedactViewTypes = [AnotherLabel.self] + } + + let redactBuilder = SentryViewPhotographer.shared.getRedactBuild() + XCTAssertTrue(redactBuilder.containsIgnoreClass(AnotherLabel.self)) + } + func createLastSessionReplay(writeSessionInfo: Bool = true, errorSampleRate: Double = 1) throws { let replayFolder = SentryDependencyContainer.sharedInstance().fileManager.sentryPath + "/replay" let jsonPath = replayFolder + "/lastreplay" diff --git a/Tests/SentryTests/UIRedactBuilderTests.swift b/Tests/SentryTests/UIRedactBuilderTests.swift index f8256d05ae..725a869879 100644 --- a/Tests/SentryTests/UIRedactBuilderTests.swift +++ b/Tests/SentryTests/UIRedactBuilderTests.swift @@ -1,6 +1,7 @@ #if os(iOS) import Foundation @testable import Sentry +import SentryTestUtils import UIKit import XCTest @@ -149,13 +150,78 @@ class UIRedactBuilderTests: XCTestCase { } let sut = UIRedactBuilder() - sut.ignoreClasses.append(AnotherLabel.self) + sut.addIgnoreClass(AnotherLabel.self) rootView.addSubview(AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40))) let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) XCTAssertEqual(result.count, 0) } + func testRedactlasses() { + class AnotherView: UIView { + } + + let sut = UIRedactBuilder() + let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + sut.addRedactClass(AnotherView.self) + rootView.addSubview(view) + + let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) + XCTAssertEqual(result.count, 1) + } + + func testIgnoreView() { + class AnotherLabel: UILabel { + } + + let sut = UIRedactBuilder() + let label = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + SentrySDK.replayIgnore(label) + rootView.addSubview(label) + + let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) + XCTAssertEqual(result.count, 0) + } + + func testRedactView() { + class AnotherView: UIView { + } + + let sut = UIRedactBuilder() + let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + SentrySDK.replayRedactView(view) + rootView.addSubview(view) + + let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) + XCTAssertEqual(result.count, 1) + } + + func testIgnoreViewWithExtension() { + class AnotherLabel: UILabel { + } + + let sut = UIRedactBuilder() + let label = AnotherLabel(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + label.sentryReplayIgnore() + rootView.addSubview(label) + + let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) + XCTAssertEqual(result.count, 0) + } + + func testRedactViewWithExtension() { + class AnotherView: UIView { + } + + let sut = UIRedactBuilder() + let view = AnotherView(frame: CGRect(x: 20, y: 20, width: 40, height: 40)) + view.sentryReplayRedact() + rootView.addSubview(view) + + let result = sut.redactRegionsFor(view: rootView, options: RedactOptions()) + XCTAssertEqual(result.count, 1) + } + func testRedactList() { let expectedList = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", @@ -164,7 +230,7 @@ class UIRedactBuilderTests: XCTestCase { let sut = UIRedactBuilder() expectedList.forEach { element in - XCTAssertTrue(sut.redactClasses.contains { element == $0 }, "\(element) not found") + XCTAssertTrue(sut.containsRedactClass(element), "\(element) not found") } } @@ -173,10 +239,9 @@ class UIRedactBuilderTests: XCTestCase { let sut = UIRedactBuilder() expectedList.forEach { element in - XCTAssertTrue(sut.ignoreClasses.contains { element == $0 }, "\(element) not found") + XCTAssertTrue(sut.containsIgnoreClass(element), "\(element) not found") } } - } #endif