Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
41 changes: 41 additions & 0 deletions Sources/SentrySwiftUI/Preview/PreviewRedactOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@ public class PreviewRedactOptions: SentryRedactOptions {
*/
public let unmaskedViewClasses: [AnyClass]

/**
* A set of view type identifier strings that should be excluded from subtree traversal.
*
* 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).
*
* Matching uses partial string containment: if a view's class name (from `type(of: view).description()`)
* contains any of these strings, the subtree will be ignored. For example, "MyView" will match
* "MyApp.MyView", "MyViewSubclass", "Some.MyView.Container", etc.
*
* - Note: See ``SentryReplayOptions.DefaultValues.excludedViewClasses`` for the default value.
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* **Default View Classes + Excluded View Classes - Included View Classes**
* Default view classes are defined in `SentryUIRedactBuilder` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
*/
public let excludedViewClasses: Set<String>

/**
* A set of view type identifier strings that should be included in subtree traversal.
*
* View types matching these patterns will be removed from the excluded set, allowing their subtrees
* to be traversed even if they would otherwise be excluded by default or via `excludedViewClasses`.
*
* Matching uses partial string containment: if a view's class name (from `type(of: view).description()`)
* contains any of these strings, it will be removed from the excluded set. For example, "MyView" will
* match "MyApp.MyView", "MyViewSubclass", etc.
*
* - Note: See ``SentryReplayOptions.DefaultValues.includedViewClasses`` for the default value.
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* **Default View Classes + Excluded View Classes - Included View Classes**
* Default view classes are defined in `SentryUIRedactBuilder` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
*/
public let includedViewClasses: Set<String>

/**
* Enables the up to 5x faster view renderer.
*
Expand All @@ -50,6 +85,8 @@ 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.
* - excludedViewClasses: A set of view type identifiers that should be excluded from subtree traversal.
* - includedViewClasses: A set of view type identifiers that should be included in subtree traversal.
* - enableViewRendererV2: Enables the up to 5x faster view renderer.
*
* - Note: See ``SentryReplayOptions.DefaultValues`` for the default values of each parameter.
Expand All @@ -59,12 +96,16 @@ public class PreviewRedactOptions: SentryRedactOptions {
maskAllImages: Bool = SentryReplayOptions.DefaultValues.maskAllImages,
maskedViewClasses: [AnyClass] = SentryReplayOptions.DefaultValues.maskedViewClasses,
unmaskedViewClasses: [AnyClass] = SentryReplayOptions.DefaultValues.unmaskedViewClasses,
excludedViewClasses: Set<String> = SentryReplayOptions.DefaultValues.excludedViewClasses,
includedViewClasses: Set<String> = SentryReplayOptions.DefaultValues.includedViewClasses,
enableViewRendererV2: Bool = SentryReplayOptions.DefaultValues.enableViewRendererV2
) {
self.maskAllText = maskAllText
self.maskAllImages = maskAllImages
self.maskedViewClasses = maskedViewClasses
self.unmaskedViewClasses = unmaskedViewClasses
self.excludedViewClasses = excludedViewClasses
self.includedViewClasses = includedViewClasses
self.enableViewRendererV2 = enableViewRendererV2
}
}
Expand Down
36 changes: 36 additions & 0 deletions Sources/Swift/Core/Protocol/SentryRedactOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public protocol SentryRedactOptions {
var maskAllImages: Bool { get }
var maskedViewClasses: [AnyClass] { get }
var unmaskedViewClasses: [AnyClass] { get }
var excludedViewClasses: Set<String> { get }
var includedViewClasses: Set<String> { get }
}

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

/**
* A set of view type identifier strings that should be excluded from subtree traversal.
*
* 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).
*
* Matching uses partial string containment: if a view's class name (from `type(of: view).description()`)
* contains any of these strings, the subtree will be ignored. For example, "MyView" will match
* "MyApp.MyView", "MyViewSubclass", "Some.MyView.Container", etc.
*
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* **Default View Classes + Excluded View Classes - Included View Classes**
* Default view classes are defined in `SentryUIRedactBuilder` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
*/
public var excludedViewClasses: Set<String> = []

/**
* A set of view type identifier strings that should be included in subtree traversal.
*
* View types matching these patterns will be removed from the excluded set, allowing their subtrees
* to be traversed even if they would otherwise be excluded by default or via `excludedViewClasses`.
*
* Matching uses partial string containment: if a view's class name (from `type(of: view).description()`)
* contains any of these strings, it will be removed from the excluded set. For example, "MyView" will
* match "MyApp.MyView", "MyViewSubclass", etc.
*
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* **Default View Classes + Excluded View Classes - Included View Classes**
* Default view classes are defined in `SentryUIRedactBuilder` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
* For example, you can use this to re-enable traversal for `CameraUI.ChromeSwiftUIView` on iOS 26+.
*/
public var includedViewClasses: Set<String> = []
}
95 changes: 61 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,20 @@ 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 identifier 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.
///
/// Matching is done using partial string matching: if the view's class name contains any of these
/// strings, the subtree will be ignored. For example, "MyView" will match "MyApp.MyView",
/// "MyViewSubclass", etc.
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 +155,7 @@ final class SentryUIRedactBuilder {
redactClasses.insert(ClassIdentifier(classId: "_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView"))

}

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

Expand All @@ -163,7 +173,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 +208,34 @@ 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

// Compute the final set of view types excluded from subtree traversal using the formula:
// Default View Classes + Excluded View Classes - Included View Classes
// Default view classes are defined here (e.g., CameraUI.ChromeSwiftUIView on iOS 26+).
// SDK users can add exclusions via options.excludedViewClasses and remove defaults via options.includedViewClasses.
// Matching uses partial string containment: if a view's class name contains any excluded string,
// the subtree will be ignored. For example, "MyView" will match "MyApp.MyView", "MyViewSubclass", etc.
var defaultExcluded: Set<String> = []
#if os(iOS)
if #available(iOS 26.0, *) {
defaultExcluded.insert("CameraUI.ChromeSwiftUIView")
}
#endif

viewTypesIgnoredFromSubtreeTraversal = defaultExcluded
.union(options.excludedViewClasses)
.subtracting(options.includedViewClasses)

// didSet doesn't run during initialization, so we need to manually build the optimization structures
rebuildOptimizedLookups()
}
Expand All @@ -224,7 +251,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 +310,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 +404,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 +464,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 +591,28 @@ 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
return true

// Check if this view type matches any excluded pattern using partial string matching.
// If the view's class name contains any excluded string, the subtree will be ignored.
// For example, "MyView" will match "MyApp.MyView", "MyViewSubclass", etc.
for exclude in viewTypesIgnoredFromSubtreeTraversal {
if viewTypeId.contains(exclude) {
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 +621,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 +643,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