Skip to content

Commit

Permalink
feat: Add options to redact or ignore view for Replay (#4228)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
brustolin authored Aug 6, 2024
1 parent de3c570 commit 8f22c40
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Sentry.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1931,6 +1932,7 @@
D8AE48B12C5786AA0092A2A6 /* SentryLogC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryLogC.h; path = include/SentryLogC.h; sourceTree = "<group>"; };
D8AE48BE2C578D540092A2A6 /* SentryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLog.swift; sourceTree = "<group>"; };
D8AE48C02C57B1550092A2A6 /* SentryLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLevelTests.swift; sourceTree = "<group>"; };
D8AE49172C5D09720092A2A6 /* UIViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = "<group>"; };
D8AFC0002BD252B900118BE1 /* SentryOnDemandReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryOnDemandReplayTests.swift; sourceTree = "<group>"; };
D8AFC0192BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewScreenshotProvider.swift; sourceTree = "<group>"; };
D8AFC03C2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayVideoMaker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3886,6 +3888,7 @@
D8F016B52B962548007B9AFB /* StringExtensions.swift */,
62872B5E2BA1B7F300A4FA7D /* NSLock.swift */,
D8BC28C92BFF68CA0054DA4D /* NumberExtensions.swift */,
D8AE49172C5D09720092A2A6 /* UIViewExtensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
20 changes: 20 additions & 0 deletions Sources/Sentry/Public/SentrySDK.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@class SentryOptions, SentryEvent, SentryBreadcrumb, SentryScope, SentryUser, SentryId,
SentryUserFeedback, SentryTransactionContext;
@class SentryMetricsAPI;
@class UIView;

NS_ASSUME_NONNULL_BEGIN

Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions Sources/Sentry/SentrySDK.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions Sources/Sentry/SentrySessionReplayIntegration.m
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options
return event;
}];

[SentryViewPhotographer.shared addIgnoreClasses:_replayOptions.ignoreRedactViewTypes];
[SentryViewPhotographer.shared addRedactClasses:_replayOptions.redactViewTypes];

return YES;
}

Expand Down
27 changes: 27 additions & 0 deletions Sources/Swift/Extensions/UIViewExtensions.swift
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 10 additions & 4 deletions Sources/Swift/Tools/SentryViewPhotographer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
67 changes: 59 additions & 8 deletions Sources/Swift/Tools/UIRedactBuilder.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ObjectIdentifier>
//This is a list of UIView subclasses that need to be redacted from screenshot
var redactClasses: [AnyClass]
private var redactClassesIdentifiers: Set<ObjectIdentifier>

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",
Expand All @@ -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] {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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://[email protected]/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()
}
Expand Down Expand Up @@ -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"
Expand Down
Loading

0 comments on commit 8f22c40

Please sign in to comment.