Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8b22246
perf(session-replay): Reduce capture stutters
romtsn Jun 15, 2026
6ab1d14
fix(session-replay): Skip excluded capture subtrees
romtsn Jun 15, 2026
cc1ffe8
test(session-replay): Cover interaction pacing
romtsn Jun 16, 2026
37bf227
docs(session-replay): Explain pending pause segment
romtsn Jun 16, 2026
1c1db7b
docs(session-replay): Clarify pause queue
romtsn Jun 17, 2026
2223ff8
ref(session-replay): Inject capture scheduler
romtsn Jun 17, 2026
8beec03
fix(session-replay): Reuse safe subtree traversal
romtsn Jun 17, 2026
a3b6e8d
ref(session-replay): Make capture guard stateless
romtsn Jun 17, 2026
7d3ad77
ref(session-replay): Use system nanosecond constant
romtsn Jun 17, 2026
5c5ea62
fix(session-replay): Read screenshot date on main
romtsn Jun 17, 2026
efec05b
fix(session-replay): Preserve deferred timestamp
romtsn Jun 17, 2026
7345a1a
ref(session-replay): Inline single-use helpers
romtsn Jun 17, 2026
d925768
fix(session-replay): Ignore stale scheduler stops
romtsn Jun 17, 2026
320f23e
docs(session-replay): Explain scheduler ownership
romtsn Jun 17, 2026
0dcf4d6
fix(session-replay): Avoid duplicate segments after resume
romtsn Jun 17, 2026
e2f825c
fix(session-replay): Anchor segments after resume
romtsn Jun 17, 2026
d81b31c
fix(session-replay): Guard scheduler state
romtsn Jun 17, 2026
01a428d
fix(session-replay): Cancel stale scheduler starts
romtsn Jun 17, 2026
3f72ac6
perf(session-replay): Reuse traversal options
romtsn Jun 17, 2026
fca1bbc
fix(session-replay): Stop scheduler async on deinit
romtsn Jun 17, 2026
504fc52
revert: Stop scheduler async on deinit
romtsn Jun 17, 2026
abcc541
fix(session-replay): Preserve pacing while paused
romtsn Jun 17, 2026
2663ee6
fix(session-replay): Roll back failed scheduler starts
romtsn Jun 17, 2026
58bdffb
ref(session-replay): Use token for scheduler state
romtsn Jun 17, 2026
90a636a
docs(session-replay): Restore class lookup context
romtsn Jun 19, 2026
f498fbf
ref(session-replay): Name default view pattern
romtsn Jun 19, 2026
a8ed6f9
docs(session-replay): Document subtree matching
romtsn Jun 19, 2026
da48e28
ref(session-replay): Remove default replay init
romtsn Jun 19, 2026
1d33cf3
ref(session-replay): Keep replay init public
romtsn Jun 19, 2026
afe432b
ref(session-replay): Simplify subtree guard
romtsn Jun 19, 2026
25764a8
test(session-replay): Update mock replay scheduler
romtsn Jun 19, 2026
5f74b10
fix(session-replay): Drain queued pause on prep failure
romtsn Jun 19, 2026
8d7f5ca
changelog
romtsn Jun 19, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
> [!WARNING]
> **The minimum macOS deployment target will be raised to macOS 12 (Monterey)** with the upcoming release that adopts Xcode 27. Xcode 27 no longer supports deployment targets below macOS 12. If your app must support macOS 11 or earlier, please stay on the last SDK version released before this change. See [#8113](https://github.com/getsentry/sentry-cocoa/issues/8113) for full details.

### Improvements

- Reduce Session Replay capture stutters by scheduling screenshots after run loop UI work instead of from display refresh callbacks (#7851)

### Fixes

- Session replay video assembly: drop empty video segments, avoid duplicating frames at segment boundaries, and keep video timing stable when captured frames are skipped or unreadable (#8041)
Expand Down
8 changes: 0 additions & 8 deletions SentryTestUtils/Sources/TestDisplayLinkWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ public enum FrameRate: UInt64 {
/// The smallest magnitude of time that is significant to how frames are classified as normal/slow/frozen.
public let timeEpsilon = 0.001

public var _isRunning: Bool = false

@_spi(Private) public init(dateProvider: TestCurrentDateProvider? = nil) {
self.dateProvider = dateProvider ?? TestCurrentDateProvider()

Expand All @@ -49,17 +47,12 @@ public enum FrameRate: UInt64 {
linkInvocations.record(Void())
self.target = target as AnyObject
self.selector = sel
_isRunning = true
}
}

public override var timestamp: CFTimeInterval {
return dateProvider.systemTime().toTimeInterval()
}

public override func isRunning() -> Bool {
_isRunning
}

public override var targetTimestamp: CFTimeInterval {
return dateProvider.systemTime().toTimeInterval() + currentFrameRate.tickDuration
Expand All @@ -69,7 +62,6 @@ public enum FrameRate: UInt64 {
public override func invalidate() {
target = nil
selector = nil
_isRunning = false
invalidateInvocations.record(Void())
}

Expand Down
8 changes: 4 additions & 4 deletions Sources/Swift/Core/Protocol/SentryRedactOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ public protocol SentryRedactOptions {
* 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:
* - Note: The final set of excluded view types is computed by `SentryViewSubtreeTraversal` 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+).
* Default view classes are defined in `SentryViewSubtreeTraversal` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
*/
public var excludedViewClasses: Set<String> = []

Expand All @@ -58,9 +58,9 @@ public protocol SentryRedactOptions {
* must exactly equal one of these strings. For example, "MyApp.MyView" will only match exactly "MyApp.MyView",
* not "MyApp.MyViewSubclass".
*
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* - Note: The final set of excluded view types is computed by `SentryViewSubtreeTraversal` 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+).
* Default view classes are defined in `SentryViewSubtreeTraversal` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
* For example, you can use this to re-enable traversal for `CameraUI.ChromeSwiftUIView` on iOS 26+.
* - Note: Included patterns use exact matching (not partial) to prevent accidental matches. For example,
* if "ChromeCameraUI" is excluded and "Camera" is included, "ChromeCameraUI" will still be excluded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,6 @@ final class SentryUIRedactBuilder {
}
}

// MARK: - Constants

/// 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")

// MARK: - Properties

/// This is a wrapper which marks it's direct children to be ignored
Expand Down Expand Up @@ -251,25 +242,9 @@ final class SentryUIRedactBuilder {
redactClassesIdentifiers = redactClasses
redactLayerClassIds = redactLayers

// Compile excluded and included patterns into separate sets for efficient lookup.
// The final decision is computed at runtime using the formula:
//
// Default View Classes + Excluded View Classes - Included View Classes
//
// SDK users can add exclusions via options.excludedViewClasses and remove defaults via options.includedViewClasses.
// Matching rules:
// - Excluded patterns use partial matching (contains): "MyView" matches "MyApp.MyView", "MyViewSubclass", etc.
// - Included patterns use exact matching (Set.contains): "MyViewSubclass" only matches exactly "MyViewSubclass"
//
// This prevents accidental matches where "ChromeCameraUI" is excluded but "Camera" is included from causing crashes.
var defaultExcluded: Set<String> = []
#if os(iOS)
if #available(iOS 26.0, *) {
defaultExcluded.insert(Self.cameraSwiftUIViewClassId.classId)
}
#endif

excludedViewClassPatterns = defaultExcluded.union(options.excludedViewClasses)
// Matching rules are implemented in SentryViewSubtreeTraversal.
excludedViewClassPatterns = SentryViewSubtreeTraversal.defaultExcludedViewClassPatterns
.union(options.excludedViewClasses)
includedViewClassPatterns = options.includedViewClasses

// didSet doesn't run during initialization, so we need to manually build the optimization structures
Expand Down Expand Up @@ -694,38 +669,11 @@ final class 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] Sources/Swift/Core/Integrations/Performance/SentrySubClassFinder.swift
let viewTypeId = type(of: view).description()

// Check if the view type id is in the list of included view classes (exact matching).
// If yes we can exit early as this list overrules other matchings.
if includedViewClassPatterns.contains(viewTypeId) {
// Matches included pattern exactly, so don't ignore subtree
return false
}

// Check excluded patterns using partial matching, with overruling using the included patterns with exact matching.
//
// For example, excluding "ChromeCameraUI" will match "MyApp.ChromeCameraUI", "ChromeCameraUISubclass", etc.
//
// However, if "ChromeCameraUI" is excluded and "Camera" is included, "ChromeCameraUI" will
// still be excluded because "Camera" doesn't exactly match "ChromeCameraUI".
for pattern in excludedViewClassPatterns where viewTypeId.contains(pattern) {
// Matches excluded but not exactly included, so ignore subtree
if SentryViewSubtreeTraversal.isExcluded(
view,
excludedViewClassPatterns: excludedViewClassPatterns,
includedViewClassPatterns: includedViewClassPatterns
) {
return true
}

Expand All @@ -734,6 +682,7 @@ final class SentryUIRedactBuilder {
// 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
let viewTypeId = type(of: view).description()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: You removed quite some context from the comment block of why we use type(of:) here. Is that intentional?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, was just slop, addressed in a00514d

if viewTypeId == "UISwitch" && containsIgnoreClassId(ClassIdentifier(classId: viewTypeId)) {
return true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#if canImport(UIKit) && !SENTRY_NO_UI_FRAMEWORK
#if os(iOS) || os(tvOS)
import UIKit

// Centralizes subtree traversal and exclusion matching for redaction and capture activity checks.
//
// The final decision is computed at runtime using the formula:
//
// Default View Classes + Excluded View Classes - Included View Classes
//
// SDK users can add exclusions via options.excludedViewClasses and remove defaults via options.includedViewClasses.
// Matching rules:
// - Excluded patterns use partial matching (contains): "MyView" matches "MyApp.MyView", "MyViewSubclass", etc.
// - Included patterns use exact matching (Set.contains): "MyViewSubclass" only matches exactly "MyViewSubclass"
//
// This prevents accidental matches where "ChromeCameraUI" is excluded but "Camera" is included from causing crashes.
enum SentryViewSubtreeTraversal {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: I see the benefit of extracting this, but to me it feels like a lot of additional context is missing which was added to SentryUIRedactBuilder in the many iterations of trying to handle all cases.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to restore all the context and put it into this class, let me know what you think! 2ac9a60 and a00514d

/// Class identifier for ``CameraUI.ChromeSwiftUIView``, if it exists.
///
/// This class name is used to identify views of this class type during subtree exclusion.
/// 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 cameraChromeSwiftUIViewClassPattern = "CameraUI.ChromeSwiftUIView"

static func isExcluded(_ view: UIView, options: SentryRedactOptions) -> Bool {
isExcluded(
view,
excludedViewClassPatterns: defaultExcludedViewClassPatterns.union(options.excludedViewClasses),
includedViewClassPatterns: options.includedViewClasses
)
Comment thread
cursor[bot] marked this conversation as resolved.
}

@discardableResult
static func traverse(_ view: UIView, options: SentryRedactOptions, _ visit: (UIView) -> Bool) -> Bool {
traverse(
view,
excludedViewClassPatterns: defaultExcludedViewClassPatterns.union(options.excludedViewClasses),
includedViewClassPatterns: options.includedViewClasses,
visit
)
}

@discardableResult
private static func traverse(
_ view: UIView,
excludedViewClassPatterns: Set<String>,
includedViewClassPatterns: Set<String>,
_ visit: (UIView) -> Bool
) -> Bool {
if isExcluded(
view,
excludedViewClassPatterns: excludedViewClassPatterns,
includedViewClassPatterns: includedViewClassPatterns
) {
return false
}

if visit(view) {
return true
}

return view.subviews.contains {
traverse(
$0,
excludedViewClassPatterns: excludedViewClassPatterns,
includedViewClassPatterns: includedViewClassPatterns,
visit
)
}
}

static var defaultExcludedViewClassPatterns: Set<String> {
var result: Set<String> = []
#if os(iOS)
if #available(iOS 26.0, *) {
result.insert(cameraChromeSwiftUIViewClassPattern)
}
#endif
return result
}

static func isExcluded(
_ view: UIView,
excludedViewClassPatterns: Set<String>,
includedViewClassPatterns: Set<String>
) -> 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] Sources/Swift/Core/Integrations/Performance/SentrySubClassFinder.swift
let viewTypeId = type(of: view).description()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: You removed all the context of why we use type(of:) here. The comment were elaborate on purpose, so please put it back in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Check if the view type id is in the list of included view classes (exact matching).
// If yes we can exit early as this list overrules other matchings.
if includedViewClassPatterns.contains(viewTypeId) {
// Matches included pattern exactly, so don't ignore subtree
return false
}

// Check excluded patterns using partial matching, with overruling using the included patterns with exact matching.
//
// For example, excluding "ChromeCameraUI" will match "MyApp.ChromeCameraUI", "ChromeCameraUISubclass", etc.
//
// However, if "ChromeCameraUI" is excluded and "Camera" is included, "ChromeCameraUI" will
// still be excluded because "Camera" doesn't exactly match "ChromeCameraUI".
for pattern in excludedViewClassPatterns where viewTypeId.contains(pattern) {
// Matches excluded but not exactly included, so ignore subtree
return true
}

return false
}
}
#endif
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,7 @@ import UIKit
displayLink?.invalidate()
displayLink = nil
}

@objc public func isRunning() -> Bool {
!(displayLink?.isPaused ?? true)
}
}

#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK
extension SentryDisplayLinkWrapper: SentryReplayDisplayLinkWrapper {}
#endif

#endif
// swiftlint:enable missing_docs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ public final class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
*
* - Note: You should use the methods ``excludeViewTypeFromSubtreeTraversal(_:)`` and ``includeViewTypeInSubtreeTraversal(_:)``
* to add and remove view types, so you do not accidentally remove our defaults.
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* - Note: The final set of excluded view types is computed by `SentryViewSubtreeTraversal` 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+).
* Default view classes are defined in `SentryViewSubtreeTraversal` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
*/
public private(set) var excludedViewClasses: Set<String>

Expand All @@ -128,9 +128,9 @@ public final class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
*
* - Note: You should use the methods ``excludeViewTypeFromSubtreeTraversal(_:)`` and ``includeViewTypeInSubtreeTraversal(_:)``
* to add and remove view types, so you do not accidentally remove our defaults.
* - Note: The final set of excluded view types is computed by `SentryUIRedactBuilder` using the formula:
* - Note: The final set of excluded view types is computed by `SentryViewSubtreeTraversal` 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+).
* Default view classes are defined in `SentryViewSubtreeTraversal` (e.g., `CameraUI.ChromeSwiftUIView` on iOS 26+).
* For example, you can use this to re-enable traversal for `CameraUI.ChromeSwiftUIView` on iOS 26+
* by calling ``includeViewTypeInSubtreeTraversal("CameraUI.ChromeSwiftUIView")``.
* - Note: Included patterns use exact matching (not partial) to prevent accidental matches. For example,
Expand All @@ -148,7 +148,7 @@ public final class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
* "MyViewSubclass", etc.
*
* - Note: This method adds the pattern to `excludedViewClasses`, which is then combined with
* default excluded types (defined in `SentryUIRedactBuilder`) and filtered by `includedViewClasses`
* default excluded types (defined in `SentryViewSubtreeTraversal`) and filtered by `includedViewClasses`
* to produce the final set.
*/
public func excludeViewTypeFromSubtreeTraversal(_ viewType: String) {
Expand All @@ -163,7 +163,7 @@ public final class SentryViewScreenshotOptions: NSObject, SentryRedactOptions {
* For example, "MyApp.MyView" will only match exactly "MyApp.MyView".
*
* - Note: This method adds the view type to `includedViewClasses`, which filters the combined set
* of default excluded types (defined in `SentryUIRedactBuilder`) and `excludedViewClasses`.
* of default excluded types (defined in `SentryViewSubtreeTraversal`) and `excludedViewClasses`.
* For example, you can use this to re-enable traversal for `CameraUI.ChromeSwiftUIView` on iOS 26+.
* - Note: Included patterns use exact matching (not partial) to prevent accidental matches.
*/
Expand Down
Loading
Loading