Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

- Add options `options.sessionReplay.includeSubtreeTraversalForViewType` and `options.sessionReplay.excludeSubtreeTraversalForViewType` to ignore views from subtree traversal (#7063)

## 8.57.3

### Fixes
Expand Down
17 changes: 17 additions & 0 deletions Sources/Swift/Core/Protocol/SentryRedactOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public protocol SentryRedactOptions {
var maskAllImages: Bool { get }
var maskedViewClasses: [AnyClass] { get }
var unmaskedViewClasses: [AnyClass] { get }
var subtreeTraversalIgnoredViewTypes: Set<String> { get }
}

@objcMembers
Expand All @@ -14,4 +15,20 @@ public protocol SentryRedactOptions {
public var maskAllImages: Bool = true
public var maskedViewClasses: [AnyClass] = []
public var unmaskedViewClasses: [AnyClass] = []

/// Default view types for which subtree traversal should be ignored.
///
/// By default, includes `CameraUI.ChromeSwiftUIView` on iOS 26+ to avoid crashes
/// when accessing `CameraUI.ModeLoupeLayer`.
public var subtreeTraversalIgnoredViewTypes: Set<String> {
var defaults: Set<String> = []
// 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'
#if os(iOS)
if #available(iOS 26.0, *) {
defaults.insert("CameraUI.ChromeSwiftUIView")
}
#endif // os(iOS)
return defaults
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ final class SentryUIRedactBuilder {
/// 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")
static let cameraSwiftUIViewClassId = ClassIdentifier(classId: "CameraUI.ChromeSwiftUIView")

// MARK: - Properties

Expand Down Expand Up @@ -106,6 +106,12 @@ final class SentryUIRedactBuilder {
/// Optimized lookup: class IDs with layer constraints (includes both classId and layerId)
private var constrainedRedactClasses: Set<ClassIdentifier> = []

/// A set of view type identifiers (as strings) for which subtree traversal should be ignored.
///
/// Views matching these types will have their subtrees skipped during redaction to avoid crashes
/// caused by traversing problematic view hierarchies.
private var subtreeTraversalIgnoredViewTypes: Set<String>

/// Initializes a new instance of the redaction process with the specified options.
///
/// This initializer populates allow/deny lists for view types using `ExtendedClassIdentifier`,
Expand Down Expand Up @@ -208,6 +214,7 @@ final class SentryUIRedactBuilder {
}

redactClassesIdentifiers = redactClasses
subtreeTraversalIgnoredViewTypes = options.subtreeTraversalIgnoredViewTypes

// didSet doesn't run during initialization, so we need to manually build the optimization structures
rebuildOptimizedLookups()
Expand Down Expand Up @@ -567,12 +574,8 @@ final class SentryUIRedactBuilder {
// [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
// Check if this view type is in the configurable list of ignored subtree traversal types
if subtreeTraversalIgnoredViewTypes.contains(viewTypeId) {
return true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
public static let maskAllImages: Bool = true
public static let maskedViewClasses: [AnyClass] = []
public static let unmaskedViewClasses: [AnyClass] = []
public static let subtreeTraversalIgnoredViewTypes: Set<String> = []
}

// MARK: - Rendering
Expand Down Expand Up @@ -94,6 +95,19 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
*/
public var unmaskedViewClasses: [AnyClass]

/**
* A set of view type identifiers (as strings) for which subtree traversal should be ignored.
*
* Views matching these types will have their subtrees skipped during redaction to avoid crashes
* caused by traversing problematic view hierarchies (e.g., views that activate internal CoreAnimation
* animations when their layers are accessed).
*
* The string values should match the result of `type(of: view).description()`.
*
* - Note: See ``SentryViewScreenshotOptions.init`` for the default value.
*/
public var subtreeTraversalIgnoredViewTypes: Set<String>

/**
* Initialize screenshot options disabled
*
Expand All @@ -109,7 +123,8 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
maskAllText: DefaultValues.maskAllText,
maskAllImages: DefaultValues.maskAllImages,
maskedViewClasses: DefaultValues.maskedViewClasses,
unmaskedViewClasses: DefaultValues.unmaskedViewClasses
unmaskedViewClasses: DefaultValues.unmaskedViewClasses,
subtreeTraversalIgnoredViewTypes: DefaultValues.subtreeTraversalIgnoredViewTypes
)
}

Expand All @@ -133,7 +148,8 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
}) ?? DefaultValues.maskedViewClasses,
unmaskedViewClasses: (dictionary["unmaskedViewClasses"] as? NSArray)?.compactMap({ element in
NSClassFromString((element as? String) ?? "")
}) ?? DefaultValues.unmaskedViewClasses
}) ?? DefaultValues.unmaskedViewClasses,
subtreeTraversalIgnoredViewTypes: (dictionary["subtreeTraversalIgnoredViewTypes"] as? [String]).map { Set($0) } ?? DefaultValues.subtreeTraversalIgnoredViewTypes
)
}

Expand All @@ -147,6 +163,7 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
* - maskAllImages: Flag to redact all images in the app by drawing a rectangle over it.
* - maskedViewClasses: A list of custom UIView subclasses that need to be masked during the screenshot.
* - unmaskedViewClasses: A list of custom UIView subclasses to be ignored during masking step of the screenshot.
* - subtreeTraversalIgnoredViewTypes: A set of view type identifiers for which subtree traversal should be ignored.
*
* - Note: See ``SentryViewScreenshotOptions.DefaultValues`` for the default values of each parameter.
*/
Expand All @@ -156,7 +173,8 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
maskAllText: Bool = DefaultValues.maskAllText,
maskAllImages: Bool = DefaultValues.maskAllImages,
maskedViewClasses: [AnyClass] = DefaultValues.maskedViewClasses,
unmaskedViewClasses: [AnyClass] = DefaultValues.unmaskedViewClasses
unmaskedViewClasses: [AnyClass] = DefaultValues.unmaskedViewClasses,
subtreeTraversalIgnoredViewTypes: Set<String> = DefaultValues.subtreeTraversalIgnoredViewTypes
) {
// - This initializer is publicly available for Swift, but not for Objective-C, because automatically bridged Swift initializers
// with default values result in a single initializer requiring all parameters.
Expand All @@ -170,6 +188,7 @@ public class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
self.maskAllImages = maskAllImages
self.maskedViewClasses = maskedViewClasses
self.unmaskedViewClasses = unmaskedViewClasses
self.subtreeTraversalIgnoredViewTypes = subtreeTraversalIgnoredViewTypes

super.init()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
public static let maskedViewClasses: [AnyClass] = []
public static let unmaskedViewClasses: [AnyClass] = []

/// Default view types for which subtree traversal should be ignored.
///
/// By default, includes:
/// - `CameraUI.ChromeSwiftUIView` on iOS 26+ to avoid crashes.
fileprivate static var subtreeTraversalIgnoredViewTypes: Set<String> {
var defaults: Set<String> = []
// 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'
if #available(iOS 26.0, *) {
defaults.insert("CameraUI.ChromeSwiftUIView")
}
return defaults
}

// The following properties are defaults which are not configurable by the user.

fileprivate static let sdkInfo: [String: Any]? = nil
Expand Down Expand Up @@ -162,6 +176,49 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
*/
public var unmaskedViewClasses: [AnyClass]

/**
* A set of view type identifiers (as strings) for which subtree traversal should be ignored.
*
* Views matching these types will have their subtrees skipped during redaction to avoid crashes
* caused by traversing problematic view hierarchies (e.g., views that activate internal CoreAnimation
* animations when their layers are accessed).
*
* The string values should match the result of `type(of: view).description()`.
*
* - Note: You must use the methods ``includeSubtreeTraversalForViewType(_:)`` and ``excludeSubtreeTraversalForViewType(_:)``
* to add and remove view types, so do not accidentally remove our defaults.
* - Note: By default, this includes `CameraUI.ChromeSwiftUIView` on iOS 26+ to avoid crashes
* when accessing `CameraUI.ModeLoupeLayer`.
*/
public private(set) var subtreeTraversalIgnoredViewTypes: Set<String>

/**
* Adds a view type to the list of views for which subtree traversal should be ignored.
*
* - Parameter viewType: The view type identifier as a string (e.g., "MyCustomView").
* This should match the result of `type(of: view).description()`.
*
* Use this method to prevent crashes when traversing problematic view hierarchies.
* For example, if you encounter crashes when certain views are traversed, you can add
* their type identifier to skip their subtrees.
*/
public func includeSubtreeTraversalForViewType(_ viewType: String) {
subtreeTraversalIgnoredViewTypes.insert(viewType)
}

/**
* Removes a view type from the list of views for which subtree traversal should be ignored.
*
* - Parameter viewType: The view type identifier as a string (e.g., "CameraUI.ChromeSwiftUIView").
* This should match the result of `type(of: view).description()`.
*
* Use this method to remove default or previously added view types from the ignore list,
* allowing their subtrees to be traversed normally.
*/
public func excludeSubtreeTraversalForViewType(_ viewType: String) {
subtreeTraversalIgnoredViewTypes.remove(viewType)
}

/**
* Alias for ``enableViewRendererV2``.
*
Expand Down Expand Up @@ -410,6 +467,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions {
self.errorReplayDuration = errorReplayDuration ?? DefaultValues.errorReplayDuration
self.sessionSegmentDuration = sessionSegmentDuration ?? DefaultValues.sessionSegmentDuration
self.maximumDuration = maximumDuration ?? DefaultValues.maximumDuration
self.subtreeTraversalIgnoredViewTypes = DefaultValues.subtreeTraversalIgnoredViewTypes

super.init()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -850,4 +850,100 @@ class SentryReplayOptionsTests: XCTestCase {
// -- Assert --
XCTAssertNil(options.sdkInfo)
}

// MARK: subtreeTraversalIgnoredViewTypes

func testSubtreeTraversalIgnoredViewTypes_defaultInitialization_shouldIncludeCameraUIViewOniOS26() {
// -- Act --
let options = SentryReplayOptions()

// -- Assert --
if #available(iOS 26.0, *) {
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView"))
} else {
XCTAssertFalse(options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView"))
}
}

func testSubtreeTraversalIgnoredViewTypes_includeSubtreeTraversalForViewType_shouldAddViewType() {
// -- Arrange --
let options = SentryReplayOptions()

// -- Act --
options.includeSubtreeTraversalForViewType("MyCustomView")

// -- Assert --
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView"))
}

func testSubtreeTraversalIgnoredViewTypes_includeSubtreeTraversalForViewType_multipleCalls_shouldAddAllViewTypes() {
// -- Arrange --
let options = SentryReplayOptions()

// -- Act --
options.includeSubtreeTraversalForViewType("MyCustomView1")
options.includeSubtreeTraversalForViewType("MyCustomView2")
options.includeSubtreeTraversalForViewType("MyCustomView3")

// -- Assert --
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView1"))
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView2"))
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView3"))
XCTAssertEqual(options.subtreeTraversalIgnoredViewTypes.count, 3 + (options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView") ? 1 : 0))
}

func testSubtreeTraversalIgnoredViewTypes_excludeSubtreeTraversalForViewType_shouldRemoveViewType() {
// -- Arrange --
let options = SentryReplayOptions()
options.includeSubtreeTraversalForViewType("MyCustomView")
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView"))

// -- Act --
options.excludeSubtreeTraversalForViewType("MyCustomView")

// -- Assert --
XCTAssertFalse(options.subtreeTraversalIgnoredViewTypes.contains("MyCustomView"))
}

func testSubtreeTraversalIgnoredViewTypes_excludeSubtreeTraversalForViewType_shouldRemoveDefaultCameraUIView() {
// -- Arrange --
let options = SentryReplayOptions()

// Only test on iOS 26+ where CameraUI.ChromeSwiftUIView is included by default
guard #available(iOS 26.0, *) else {
return
}

XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView"))

// -- Act --
options.excludeSubtreeTraversalForViewType("CameraUI.ChromeSwiftUIView")

// -- Assert --
XCTAssertFalse(options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView"))
}

func testSubtreeTraversalIgnoredViewTypes_excludeSubtreeTraversalForViewType_nonExistentViewType_shouldNotCrash() {
// -- Arrange --
let options = SentryReplayOptions()
let initialCount = options.subtreeTraversalIgnoredViewTypes.count

// -- Act --
options.excludeSubtreeTraversalForViewType("NonExistentView")

// -- Assert --
XCTAssertEqual(options.subtreeTraversalIgnoredViewTypes.count, initialCount)
}

func testSubtreeTraversalIgnoredViewTypes_propertyGetter_shouldReturnCurrentValue() {
// -- Arrange --
let options = SentryReplayOptions()
options.includeSubtreeTraversalForViewType("TestView")

// -- Act --
let retrievedSet = options.subtreeTraversalIgnoredViewTypes

// -- Assert --
XCTAssertTrue(retrievedSet.contains("TestView"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,16 @@ class SentryRedactDefaultOptionsTests: XCTestCase {
XCTAssertTrue(options.maskAllImages)
XCTAssertEqual(options.maskedViewClasses.count, 0)
XCTAssertEqual(options.unmaskedViewClasses.count, 0)
// On iOS 26+, CameraUI.ChromeSwiftUIView should be in the ignored set
#if os(iOS)
if #available(iOS 26.0, *) {
XCTAssertEqual(options.subtreeTraversalIgnoredViewTypes.count, 1)
XCTAssertTrue(options.subtreeTraversalIgnoredViewTypes.contains("CameraUI.ChromeSwiftUIView"), "CameraUI.ChromeSwiftUIView should be ignored by default on iOS 26+")
} else {
XCTAssertEqual(options.subtreeTraversalIgnoredViewTypes.count, 0)
}
#else
XCTAssertEqual(options.subtreeTraversalIgnoredViewTypes.count, 0)
#endif
}
}
Loading
Loading