Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions Sources/SentrySwiftUI/Preview/PreviewRedactOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ public class PreviewRedactOptions: SentryRedactOptions {
*/
public let 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 ``SentryReplayOptions.DefaultValues.viewTypesIgnoredFromSubtreeTraversal`` for the default value.
* - Note: By default, this includes `CameraUI.ChromeSwiftUIView` on iOS 26+ to avoid crashes
* when accessing `CameraUI.ModeLoupeLayer`.
*/
public let viewTypesIgnoredFromSubtreeTraversal: Set<String>

/**
* Enables the up to 5x faster view renderer.
*
Expand All @@ -50,6 +65,7 @@ public class PreviewRedactOptions: SentryRedactOptions {
* - maskAllImages: Flag to redact all images in the app by drawing a rectangle over it.
* - maskedViewClasses: The classes of views to mask.
* - unmaskedViewClasses: The classes of views to exclude from masking.
* - viewTypesIgnoredFromSubtreeTraversal: A set of view type identifiers for which subtree traversal should be ignored.
* - enableViewRendererV2: Enables the up to 5x faster view renderer.
*
* - Note: See ``SentryReplayOptions.DefaultValues`` for the default values of each parameter.
Expand All @@ -59,12 +75,14 @@ public class PreviewRedactOptions: SentryRedactOptions {
maskAllImages: Bool = SentryReplayOptions.DefaultValues.maskAllImages,
maskedViewClasses: [AnyClass] = SentryReplayOptions.DefaultValues.maskedViewClasses,
unmaskedViewClasses: [AnyClass] = SentryReplayOptions.DefaultValues.unmaskedViewClasses,
viewTypesIgnoredFromSubtreeTraversal: Set<String> = SentryReplayOptions.DefaultValues.viewTypesIgnoredFromSubtreeTraversal,
enableViewRendererV2: Bool = SentryReplayOptions.DefaultValues.enableViewRendererV2
) {
self.maskAllText = maskAllText
self.maskAllImages = maskAllImages
self.maskedViewClasses = maskedViewClasses
self.unmaskedViewClasses = unmaskedViewClasses
self.viewTypesIgnoredFromSubtreeTraversal = viewTypesIgnoredFromSubtreeTraversal
self.enableViewRendererV2 = enableViewRendererV2
}
}
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 viewTypesIgnoredFromSubtreeTraversal: 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 viewTypesIgnoredFromSubtreeTraversal: 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
}
}
71 changes: 37 additions & 34 deletions Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift
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 @@ -102,10 +102,16 @@ final class SentryUIRedactBuilder {

/// Optimized lookup: class IDs that should be redacted without layer constraints
private var unconstrainedRedactClasses: Set<ClassIdentifier> = []

/// 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 viewTypesIgnoredFromSubtreeTraversal: 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 @@ -145,7 +151,7 @@ final class SentryUIRedactBuilder {
redactClasses.insert(ClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView"))

}

if options.maskAllImages {
redactClasses.insert(ClassIdentifier(objcType: UIImageView.self))

Expand All @@ -163,7 +169,7 @@ final class SentryUIRedactBuilder {
// Used by React Native to display images
redactClasses.insert(ClassIdentifier(classId: "RCTImageView"))
}

#if os(iOS)
redactClasses.insert(ClassIdentifier(objcType: PDFView.self))
redactClasses.insert(ClassIdentifier(objcType: WKWebView.self))
Expand Down Expand Up @@ -198,17 +204,18 @@ final class SentryUIRedactBuilder {
#else
ignoreClassesIdentifiers = []
#endif

for type in options.unmaskedViewClasses {
ignoreClassesIdentifiers.insert(ClassIdentifier(class: type))
}

for type in options.maskedViewClasses {
redactClasses.insert(ClassIdentifier(class: type))
}

redactClassesIdentifiers = redactClasses

viewTypesIgnoredFromSubtreeTraversal = options.viewTypesIgnoredFromSubtreeTraversal

// didSet doesn't run during initialization, so we need to manually build the optimization structures
rebuildOptimizedLookups()
}
Expand All @@ -224,7 +231,7 @@ final class SentryUIRedactBuilder {
private func rebuildOptimizedLookups() {
unconstrainedRedactClasses.removeAll()
constrainedRedactClasses.removeAll()

for identifier in redactClassesIdentifiers {
if identifier.layerId == nil {
// No layer constraint - add to unconstrained set
Expand Down Expand Up @@ -283,40 +290,40 @@ final class SentryUIRedactBuilder {
/// - `UIImageView` will match the class rule; the final decision is refined by `shouldRedact(imageView:)`.
func containsRedactClass(viewClass: AnyClass, layerClass: AnyClass) -> Bool {
var currentClass: AnyClass? = viewClass

while let iteratorClass = currentClass {
// Check if this class is in the unconstrained set (O(1) lookup)
// This matches any layer type
if unconstrainedRedactClasses.contains(ClassIdentifier(class: iteratorClass)) {
return true
}

// Check if this class+layer combination is in the constrained set (O(1) lookup)
// This only matches specific layer types
if constrainedRedactClasses.contains(ClassIdentifier(class: iteratorClass, layer: layerClass)) {
return true
}

currentClass = iteratorClass.superclass()
}
return false
}

/// Adds a class to the ignore list.
func addIgnoreClass(_ ignoreClass: AnyClass) {
ignoreClassesIdentifiers.insert(ClassIdentifier(class: ignoreClass))
}

/// Adds a class to the redact list.
func addRedactClass(_ redactClass: AnyClass) {
redactClassesIdentifiers.insert(ClassIdentifier(class: redactClass))
}

/// Adds multiple classes to the ignore list.
func addIgnoreClasses(_ ignoreClasses: [AnyClass]) {
ignoreClasses.forEach(addIgnoreClass(_:))
}

/// Adds multiple classes to the redact list.
func addRedactClasses(_ redactClasses: [AnyClass]) {
redactClasses.forEach(addRedactClass(_:))
Expand Down Expand Up @@ -377,19 +384,19 @@ final class SentryUIRedactBuilder {

var swiftUIRedact = [SentryRedactRegion]()
var otherRegions = [SentryRedactRegion]()

for region in redactingRegions {
if region.type == .redactSwiftUI {
swiftUIRedact.append(region)
} else {
otherRegions.append(region)
}
}

//The swiftUI type needs to appear first in the list so it always get masked
return (otherRegions + swiftUIRedact).reversed()
}

private func shouldIgnore(view: UIView) -> Bool {
return SentryRedactViewHelper.shouldUnmask(view) || containsIgnoreClassId(ClassIdentifier(class: type(of: view))) || shouldIgnoreParentContainer(view)
}
Expand Down Expand Up @@ -437,7 +444,7 @@ final class SentryUIRedactBuilder {

return true
}

/// Special handling for `UIImageView` to avoid masking tiny gradient strips and
/// bundle‑provided assets (e.g. SF Symbols or app assets), which are unlikely to contain PII.
private func shouldRedact(imageView: UIImageView) -> Bool {
Expand Down Expand Up @@ -564,28 +571,24 @@ final class SentryUIRedactBuilder {
// - 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
// [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 viewTypesIgnoredFromSubtreeTraversal.contains(viewTypeId) {
return true
}

#if os(iOS)
#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)
#endif // os(iOS)

return false
}

Expand All @@ -594,14 +597,14 @@ final class SentryUIRedactBuilder {
let size = layer.bounds.size
let anchorPoint = CGPoint(x: size.width * layer.anchorPoint.x, y: size.height * layer.anchorPoint.y)
let position = parentLayer?.convert(layer.position, to: nil) ?? layer.position

var newTransform = transform
newTransform.tx = position.x
newTransform.ty = position.y
newTransform = CATransform3DGetAffineTransform(layer.transform).concatenating(newTransform)
return newTransform.translatedBy(x: -anchorPoint.x, y: -anchorPoint.y)
}

/// Whether the transform does not contain rotation or skew.
private func isAxisAligned(_ transform: CGAffineTransform) -> Bool {
// Rotation exists if b or c are not zero
Expand All @@ -616,7 +619,7 @@ final class SentryUIRedactBuilder {
private func color(for view: UIView) -> UIColor? {
return (view as? UILabel)?.textColor.withAlphaComponent(1)
}

/// Indicates whether the view is opaque and will block other views behind it.
///
/// A view is considered opaque if it completely covers and hides any content behind it.
Expand Down
Loading
Loading