Skip to content

[Session Replay] Add public API to configure sub-tree traversal #7053

@philprime

Description

@philprime

Description

We need a public API to add and remove view types from the subtree traversal in the SentryUIRedactBuilder:

private func isViewSubtreeIgnored(_ view: UIView) -> Bool {
// We intentionally avoid using `NSClassFromString` or directly referencing class objects here,
// because both approaches can trigger the Objective-C `+initialize` method on the class.
// This has side effects and can cause crashes, especially when performed off the main thread
// or with UIKit classes that expect to be initialized on the main thread.
//
// Instead, we use the string description of the type (i.e., `type(of: view).description()`)
// for comparison. This is a safer, more "Swifty" approach that avoids the pitfalls of
// class initialization side effects.
//
// We have previously encountered related issues:
// - In EmergeTools' snapshotting code where using `NSClassFromString` led to crashes [1]
// - In Sentry's own SubClassFinder where storing or accessing class objects on a background thread caused crashes due to `+initialize` being called on UIKit classes [2]
//
// [1] https://github.com/EmergeTools/SnapshotPreviews/blob/main/Sources/SnapshotPreviewsCore/View%2BSnapshot.swift#L248
// [2] https://github.com/getsentry/sentry-cocoa/blob/00d97404946a37e983eabb21cc64bd3d5d2cb474/Sources/Sentry/SentrySubClassFinder.m#L58-L84
let viewTypeId = type(of: view).description()
if #available(iOS 26.0, *), viewTypeId == Self.cameraSwiftUIViewClassId.classId {
// CameraUI.ChromeSwiftUIView is a special case because it contains layers which can not be iterated due to this error:
//
// Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'CameraUI.ModeLoupeLayer'
//
// This crash only occurs when building with Xcode 16 for iOS 26, so we add a runtime check
return true
}
#if os(iOS)
// UISwitch uses UIImageView internally, which can be in the list of redacted views.
// But UISwitch is in the list of ignored class identifiers by default, because it uses
// non-sensitive images. Therefore we want to ignore the subtree of UISwitch, unless
// it was removed from the list of ignored classes
if viewTypeId == "UISwitch" && containsIgnoreClassId(ClassIdentifier(classId: viewTypeId)) {
return true
}
#endif // os(iOS)
return false
}

It will allow SDK users to extend the list of views, in case they are experiencing crashes caused by the view traversal, e.g. activating internal animations for CoreAnimation.

Example:

Application Specific Information:
-[NSConcreteValue doubleValue]: unrecognized selector sent to instance 0x1409e0cc0

Thread 0 Crashed:
0   CoreFoundation                  0x3252a1994         __exceptionPreprocess
1   libobjc.A.dylib                 0x31f169810         objc_exception_throw
2   CoreFoundation                  0x32533a8fc         -[NSObject(NSObject) doesNotRecognizeSelector:]
3   CoreFoundation                  0x325221358         ___forwarding___
4   CoreFoundation                  0x3252291fc         __forwarding_prep_0___
5   QuartzCore                      0x326843c68         -[NSNumber(CAAnimatableValue) CA_interpolateValue:byFraction:]
6   QuartzCore                      0x32683f99c         -[CABasicAnimation applyForTime:presentationObject:modelObject:]
7   QuartzCore                      0x3265f98bc         CA::Layer::presentation_layer
8   QuartzCore                      0x3265f4008         CA::Layer::sublayers
9   MyApp                           0x200a98d40         SentryUIRedactBuilder.mapRedactRegion
10  MyApp.                          0x200a98ed4         [inlined] SentryUIRedactBuilder.mapRedactRegion

A reference for similar options is the list of masked and unmasked view classes in the session replay options:

/**
* A list of custom UIView subclasses that need
* to be masked during session replay.
* By default Sentry already mask text and image elements from UIKit
* Every child of a view that is redacted will also be redacted.
*
* - Note: See ``SentryReplayOptions.DefaultValues.maskedViewClasses`` for the default value.
*/
public var maskedViewClasses: [AnyClass]
/**
* A list of custom UIView subclasses to be ignored
* during masking step of the session replay.
* The views of given classes will not be redacted but their children may be.
* This property has precedence over `redactViewTypes`.
*
* - Note: See ``SentryReplayOptions.DefaultValues.unmaskedViewClasses`` for the default value.
*/
public var unmaskedViewClasses: [AnyClass]

We want to offer the SDK users to add additional view types, while also removing ours pre-defined ones (for flexibility), which is the CameraUI.ChromeSwiftUIView at this point in time only.

/// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists.
///
/// This object identifier is used to identify views of this class type during the redaction process.
/// This workaround is specifically for Xcode 16 building for iOS 26 where accessing CameraUI.ModeLoupeLayer
/// causes a crash due to unimplemented init(layer:) initializer.
private static let cameraSwiftUIViewClassId = ClassIdentifier(classId: "CameraUI.ChromeSwiftUIView")

Therefore, define the list should be a private field on the SentryReplayOptions.swift with two public helper methods includeSubtreeTraversalForViewType(String) and excludeSubtreeTraversalForViewType(String). The given string value will be compared to type(of: view).description().

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions