diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9f825ffd9..6e90a638963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/SentryTestUtils/Sources/TestDisplayLinkWrapper.swift b/SentryTestUtils/Sources/TestDisplayLinkWrapper.swift index f978a255755..e21bc3d487c 100644 --- a/SentryTestUtils/Sources/TestDisplayLinkWrapper.swift +++ b/SentryTestUtils/Sources/TestDisplayLinkWrapper.swift @@ -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() @@ -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 @@ -69,7 +62,6 @@ public enum FrameRate: UInt64 { public override func invalidate() { target = nil selector = nil - _isRunning = false invalidateInvocations.record(Void()) } diff --git a/Sources/Swift/Core/Protocol/SentryRedactOptions.swift b/Sources/Swift/Core/Protocol/SentryRedactOptions.swift index 05b146b5e48..1ad6b6f6ea5 100644 --- a/Sources/Swift/Core/Protocol/SentryRedactOptions.swift +++ b/Sources/Swift/Core/Protocol/SentryRedactOptions.swift @@ -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 = [] @@ -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 diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift index 1c25678fe30..03a35e4f7c0 100644 --- a/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryUIRedactBuilder.swift @@ -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 @@ -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 = [] - #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 @@ -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 } @@ -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() if viewTypeId == "UISwitch" && containsIgnoreClassId(ClassIdentifier(classId: viewTypeId)) { return true } diff --git a/Sources/Swift/Core/Tools/ViewCapture/SentryViewSubtreeTraversal.swift b/Sources/Swift/Core/Tools/ViewCapture/SentryViewSubtreeTraversal.swift new file mode 100644 index 00000000000..2862549db8a --- /dev/null +++ b/Sources/Swift/Core/Tools/ViewCapture/SentryViewSubtreeTraversal.swift @@ -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 { + /// 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 + ) + } + + @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, + includedViewClassPatterns: Set, + _ 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 { + var result: Set = [] + #if os(iOS) + if #available(iOS 26.0, *) { + result.insert(cameraChromeSwiftUIViewClassPattern) + } + #endif + return result + } + + static func isExcluded( + _ view: UIView, + excludedViewClassPatterns: Set, + includedViewClassPatterns: Set + ) -> 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 + return true + } + + return false + } +} +#endif +#endif diff --git a/Sources/Swift/Integrations/Performance/SentryDisplayLinkWrapper.swift b/Sources/Swift/Integrations/Performance/SentryDisplayLinkWrapper.swift index aaff746e9f1..a5136ee94a6 100644 --- a/Sources/Swift/Integrations/Performance/SentryDisplayLinkWrapper.swift +++ b/Sources/Swift/Integrations/Performance/SentryDisplayLinkWrapper.swift @@ -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 diff --git a/Sources/Swift/Integrations/Screenshot/SentryScreenshotOptions.swift b/Sources/Swift/Integrations/Screenshot/SentryScreenshotOptions.swift index 89cb0f51326..961b8d1ddbf 100644 --- a/Sources/Swift/Integrations/Screenshot/SentryScreenshotOptions.swift +++ b/Sources/Swift/Integrations/Screenshot/SentryScreenshotOptions.swift @@ -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 @@ -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, @@ -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) { @@ -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. */ diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index e668ec8a637..2f8469d459e 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -185,9 +185,9 @@ public class SentryReplayOptions: 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 var excludedViewClasses: Set @@ -203,9 +203,9 @@ public class SentryReplayOptions: 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, @@ -223,7 +223,7 @@ public class SentryReplayOptions: 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) { @@ -238,7 +238,7 @@ public class SentryReplayOptions: 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. */ diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index cfab32c157c..e9e937ef3a5 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -4,22 +4,21 @@ import Foundation @_implementationOnly import _SentryPrivate import UIKit -@objc -@_spi(Private) public protocol SentryReplayDisplayLinkWrapper { - func isRunning() -> Bool - func invalidate() - func link(withTarget: Any, selector: Selector) -} - // swiftlint:disable type_body_length @objcMembers @_spi(Private) public class SentrySessionReplay: NSObject { - public private(set) var isFullSession = false + public private(set) var isFullSession: Bool { + get { lock.synchronized { _isFullSession } } + set { lock.synchronized { _isFullSession = newValue } } + } public private(set) var sessionReplayId: SentryId? private var urlToCache: URL? private var rootView: UIView? - private var lastScreenShot: Date? + /// Capture pacing state; main-thread confined and deliberately not guarded by `lock` (see its docs). + private var lastScreenshotAt: Date? + /// Capture pacing state; main-thread confined and deliberately not guarded by `lock` (see its docs). + private var nextScreenshotAt: Date? private var videoSegmentStart: Date? private var sessionStart: Date? private var imageCollection: [UIImage] = [] @@ -29,22 +28,53 @@ import UIKit private var reachedMaximumDuration = false private var replayType = SentryReplayType.buffer private(set) var isSessionPaused = false - + private var _isFullSession = false + private let replayOptions: SentryReplayOptions private let replayMaker: SentryReplayVideoMaker - private let displayLink: SentryReplayDisplayLinkWrapper private let dateProvider: SentryCurrentDateProvider private let touchTracker: SentryTouchTracker? + /// Guards the state shared between the main thread and background queues: segment + /// bookkeeping (`sessionStart`, `videoSegmentStart`, `pendingSegmentEnd`, `pendingPauseSegmentEnd`, + /// `currentSegmentId`), capture scheduler state (`captureSchedulerToken`, + /// `captureSchedulerGeneration`, `nextCaptureActivityCheckAt`) and the `_isFullSession`, + /// `processingScreenshot`, `isSessionPaused` and `reachedMaximumDuration` flags. + /// + /// Capture pacing state (`lastScreenshotAt`, `nextScreenshotAt`, `adaptiveScreenshotInterval`, + /// `deferredScreenshotStart`) is not guarded by this lock; it is main-thread confined and only + /// mutated from the run-loop capture scheduler callbacks, blocks dispatched to the main + /// thread, and `start`. private let lock = NSLock() + private let captureScheduler: SentrySessionReplayRunLoopCaptureScheduler + private var captureSchedulerToken: NSObject? + /// Capture pacing state; main-thread confined and deliberately not guarded by `lock` (see its docs). + private var adaptiveScreenshotInterval: TimeInterval = 0 + /// Capture pacing state; main-thread confined and deliberately not guarded by `lock` (see its docs). + private var deferredScreenshotStart: Date? + /// Invalidates resume starts queued before a later pause. + /// Example: reachability resumes off-main, then the app backgrounds before the main-queue + /// start runs. Without this, the stale start can mark capture as running after pause, so the + /// next foreground resume may skip restarting the scheduler. + private var captureSchedulerGeneration = 0 + private var nextCaptureActivityCheckAt: Date? + /// Segment end currently being rendered on the replay maker's background queue. + private var pendingSegmentEnd: Date? + /// Pause timestamp to process after the current pending segment finishes rendering. + /// + /// Example: if `0...5s` is rendering and pause happens at `7s`, this queues `5...7s` + /// without overwriting the in-flight `pendingSegmentEnd`. + private var pendingPauseSegmentEnd: Date? public var replayTags: [String: Any]? var isRunning: Bool { - displayLink.isRunning() + lock.synchronized { + captureSchedulerToken != nil + } } - + public var screenshotProvider: SentryViewScreenshotProvider public var breadcrumbConverter: SentryReplayBreadcrumbConverter - + public init( replayOptions: SentryReplayOptions, replayFolderPath: URL, @@ -54,99 +84,144 @@ import UIKit touchTracker: SentryTouchTracker?, dateProvider: SentryCurrentDateProvider, delegate: SentrySessionReplayDelegate, - displayLinkWrapper: SentryReplayDisplayLinkWrapper + captureScheduler: SentrySessionReplayRunLoopCaptureScheduler ) { self.replayOptions = replayOptions self.dateProvider = dateProvider self.delegate = delegate self.screenshotProvider = screenshotProvider - self.displayLink = displayLinkWrapper self.urlToCache = replayFolderPath self.replayMaker = replayMaker self.breadcrumbConverter = breadcrumbConverter self.touchTracker = touchTracker + self.captureScheduler = captureScheduler + } + + deinit { + stopCaptureScheduler() } - - deinit { displayLink.invalidate() } - + public func start(rootView: UIView?, fullSession: Bool) { SentrySDKLog.debug("[Session Replay] Starting session replay with full session: \(fullSession)") guard !isRunning else { SentrySDKLog.debug("[Session Replay] Session replay is already running, not starting again") return } - - displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) + self.rootView = rootView - lastScreenShot = dateProvider.date() - videoSegmentStart = nil - currentSegmentId = 0 + let now = dateProvider.date() + resetCapturePacing(at: now) + startCaptureScheduler() + lock.synchronized { + videoSegmentStart = nil + pendingSegmentEnd = nil + pendingPauseSegmentEnd = nil + currentSegmentId = 0 + } sessionReplayId = SentryId() imageCollection = [] replayType = fullSession ? .session : .buffer if fullSession { - startFullReplay() + startFullReplay(startedAt: lastScreenshotAt) } } - private func startFullReplay() { + private func startFullReplay(startedAt: Date?) { SentrySDKLog.debug("[Session Replay] Starting full session replay") - sessionStart = lastScreenShot - isFullSession = true + lock.synchronized { + sessionStart = startedAt + videoSegmentStart = startedAt + _isFullSession = true + } guard let sessionReplayId = sessionReplayId else { return } delegate?.sessionReplayStarted(replayId: sessionReplayId) } public func pauseSessionMode() { SentrySDKLog.debug("[Session Replay] Pausing session mode") - lock.lock() - defer { lock.unlock() } - - self.isSessionPaused = true - self.videoSegmentStart = nil + let pauseDate = dateProvider.date() + lock.synchronized { + isSessionPaused = true + + if !queuePendingPauseSegmentIfNeeded(at: pauseDate) { + videoSegmentStart = nil + } + } } - + public func pause() { SentrySDKLog.debug("[Session Replay] Pausing session") - lock.lock() - defer { lock.unlock() } - - displayLink.invalidate() - if isFullSession { - prepareSegmentUntil(date: dateProvider.date()) + stopCaptureScheduler() + + let pauseDate = dateProvider.date() + let shouldPreparePauseSegment = lock.synchronized { () -> Bool in + guard _isFullSession else { return false } + guard !queuePendingPauseSegmentIfNeeded(at: pauseDate) else { return false } + return true + } + + if shouldPreparePauseSegment { + prepareSegmentUntil(date: pauseDate) } - isSessionPaused = false + } + + private func queuePendingPauseSegmentIfNeeded(at pauseDate: Date) -> Bool { + guard _isFullSession, pendingSegmentEnd != nil else { return false } + pendingPauseSegmentEnd = pauseDate + return true } public func resume() { SentrySDKLog.debug("[Session Replay] Resuming session") - lock.lock() - defer { lock.unlock() } - - if isSessionPaused { - isSessionPaused = false - return + let resumeDate = dateProvider.date() + let schedulerGeneration = lock.synchronized { () -> Int? in + if _isFullSession && isSessionPaused { + return nil + } + + guard !reachedMaximumDuration else { + SentrySDKLog.warning("[Session Replay] Reached maximum duration, not resuming") + return nil + } + guard captureSchedulerToken == nil else { + SentrySDKLog.debug("[Session Replay] Session is already running, not resuming") + return nil + } + + captureSchedulerGeneration += 1 + videoSegmentStart = _isFullSession ? resumeDate : nil + return captureSchedulerGeneration } - - guard !reachedMaximumDuration else { - SentrySDKLog.warning("[Session Replay] Reached maximum duration, not resuming") - return + guard let schedulerGeneration = schedulerGeneration else { return } + + runOnMainThread { [weak self] in + guard let self = self else { return } + + if self.startCaptureScheduler(expectedGeneration: schedulerGeneration) { + self.resetCapturePacing(at: self.dateProvider.date()) + } } - guard !isRunning else { - SentrySDKLog.debug("[Session Replay] Session is already running, not resuming") - return + } + + func resumeSessionMode(restartCaptureScheduler: Bool = true) { + SentrySDKLog.debug("[Session Replay] Resuming session mode") + let resumeDate = dateProvider.date() + lock.synchronized { + isSessionPaused = false + if _isFullSession { + videoSegmentStart = resumeDate + } } - - videoSegmentStart = nil - displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) + guard restartCaptureScheduler else { return } + resume() } public func captureReplayFor(event: Event) { SentrySDKLog.debug("[Session Replay] Capturing replay for event: \(event)") - guard isRunning else { + guard isRunning else { SentrySDKLog.debug("[Session Replay] Session replay is not running, not capturing replay") - return + return } if isFullSession { @@ -159,7 +234,7 @@ import UIKit SentrySDKLog.debug("[Session Replay] Not capturing replay, reason: event is not an error or exceptions are empty") return } - + setEventContext(event: event) } @@ -184,19 +259,21 @@ import UIKit return false } + // `lastScreenshotAt` is main-thread confined with the capture pacing state. + let startedAt = runOnMainThreadSync { lastScreenshotAt } self.replayType = replayType - startFullReplay() + startFullReplay(startedAt: startedAt) let replayStart = dateProvider.date().addingTimeInterval(-replayOptions.errorReplayDuration - (Double(replayOptions.frameRate) / 2.0)) - createAndCaptureInBackground(startedAt: replayStart, replayType: replayType) + createAndCaptureInBackground(startedAt: replayStart, endedAt: dateProvider.date(), replayType: replayType) return true } private func setEventContext(event: Event) { SentrySDKLog.debug("[Session Replay] Setting event context") - guard let sessionReplayId = sessionReplayId, event.type != "replay_video" else { + guard let sessionReplayId = sessionReplayId, event.type != "replay_video" else { SentrySDKLog.debug("[Session Replay] Not setting event context, reason: session replay id is nil or event type is replay_video") - return + return } var context = event.context ?? [:] @@ -210,41 +287,329 @@ import UIKit event.tags = tags } - @objc - private func newFrame(_ sender: CADisplayLink) { - guard let lastScreenShot = lastScreenShot, isRunning && - !(isFullSession && isSessionPaused) //If replay is in session mode but it is paused we dont take screenshots - else { return } + /// Decides on each run-loop pass whether to capture a screenshot. Stages, in order: + /// 1. Skip captures while session mode is paused or once the maximum duration is reached. + /// 2. Skip while a previous screenshot is still being processed. + /// 3. Throttle view-hierarchy activity checks via `shouldCheckCaptureActivity`. + /// 4. Apply pacing: interaction captures use the base frame interval, idle captures the + /// adaptive backoff interval. + /// 5. Defer captures while animations are running (up to a maximum deferral), then capture. + /// + /// Early exits after stage 1 still call `prepareFullSessionSegmentsIfNeeded` so session + /// segments are cut on time even when no screenshot is taken. + // swiftlint:disable function_body_length cyclomatic_complexity + private func captureFrameIfNeeded(isInteractiveRunLoopMode: Bool = false) { + guard isRunning else { return } let now = dateProvider.date() - - if let sessionStart = sessionStart, isFullSession && now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { + let fullSessionState = lock.synchronized { (isFullSession: _isFullSession, sessionStart: self.sessionStart) } + + if fullSessionState.isFullSession && lock.synchronized({ isSessionPaused }) { + return + } + + if let sessionStart = fullSessionState.sessionStart, + fullSessionState.isFullSession, + now.timeIntervalSince(sessionStart) > replayOptions.maximumDuration { SentrySDKLog.debug("[Session Replay] Reached maximum duration, pausing session") - reachedMaximumDuration = true + lock.synchronized { reachedMaximumDuration = true } pause() - // Notify the delegate that the session replay has ended so it can clear the session replay id. delegate?.sessionReplayEnded() return } - if now.timeIntervalSince(lastScreenShot) >= 1.0 / Double(replayOptions.frameRate) { - takeScreenshot() - self.lastScreenShot = now - + guard !lock.synchronized({ processingScreenshot }) else { + prepareFullSessionSegmentsIfNeeded(until: now) + return + } + + guard shouldCheckCaptureActivity(at: now, isInteractiveRunLoopMode: isInteractiveRunLoopMode) else { + prepareFullSessionSegmentsIfNeeded(until: now) + return + } + + let captureActivityReason = isInteractiveRunLoopMode + ? nil + : rootView.flatMap { SentrySessionReplayCaptureGuard.captureActivityReason(rootView: $0, options: replayOptions) } + let isInteractionCapture = isInteractiveRunLoopMode || captureActivityReason == .interaction + + guard shouldCaptureScreenshot(at: now, usesAdaptiveBackoff: !isInteractionCapture) else { + let activityCheckInterval: TimeInterval + if let nextScreenshotAt = nextScreenshotAt { + activityCheckInterval = max(0, min(baseScreenshotInterval, nextScreenshotAt.timeIntervalSince(now))) + } else { + activityCheckInterval = baseScreenshotInterval + } + lock.synchronized { nextCaptureActivityCheckAt = now.addingTimeInterval(activityCheckInterval) } + prepareFullSessionSegmentsIfNeeded(until: now) + return + } + + let deferralDecision = screenshotDeferralDecision( + activityReason: isInteractionCapture ? nil : captureActivityReason, + at: now + ) + if deferralDecision == .defer { + scheduleNextScreenshot(after: CapturePacing.captureDeferralInterval, from: now) + prepareFullSessionSegmentsIfNeeded(until: now) + return + } + + guard takeScreenshot(timestamp: now, completion: { [weak self] captureDuration in + guard let self = self else { return } + self.runOnMainThread { [weak self] in + guard let self = self else { return } + defer { self.lock.synchronized { self.processingScreenshot = false } } + + if deferralDecision == .captureAfterDeferral { + self.adaptiveScreenshotInterval = 0 + } else if !isInteractionCapture { + self.updateAdaptiveScreenshotInterval(captureDuration) + } + + let finishedAt = self.dateProvider.date() + self.lastScreenshotAt = finishedAt + self.scheduleNextScreenshot(after: self.screenshotInterval(usesAdaptiveBackoff: !isInteractionCapture), from: finishedAt) + + let shouldPrepareSegment = self.lock.synchronized { + self.captureSchedulerToken != nil && !self.isSessionPaused && !self.reachedMaximumDuration + } + if shouldPrepareSegment { + self.prepareFullSessionSegmentsIfNeeded(until: finishedAt) + } + } + }) else { + let finishedAt = dateProvider.date() + lastScreenshotAt = finishedAt + scheduleNextScreenshot(after: screenshotInterval(usesAdaptiveBackoff: !isInteractionCapture), from: finishedAt) + prepareFullSessionSegmentsIfNeeded(until: finishedAt) + return + } + } + // swiftlint:enable function_body_length cyclomatic_complexity + + private var baseScreenshotInterval: TimeInterval { + 1.0 / Double(replayOptions.frameRate) + } + + private func resetCapturePacing(at date: Date) { + lastScreenshotAt = date + adaptiveScreenshotInterval = 0 + deferredScreenshotStart = nil + scheduleNextScreenshot(after: screenshotInterval(), from: date) + } + + private func screenshotInterval(usesAdaptiveBackoff: Bool = true) -> TimeInterval { + usesAdaptiveBackoff ? max(baseScreenshotInterval, adaptiveScreenshotInterval) : baseScreenshotInterval + } + + private func shouldCaptureScreenshot(at date: Date, usesAdaptiveBackoff: Bool = true) -> Bool { + if !usesAdaptiveBackoff, let lastScreenshotAt = lastScreenshotAt { + return isDeadlineReached(lastScreenshotAt.addingTimeInterval(baseScreenshotInterval), at: date) + } + + guard let nextScreenshotAt = nextScreenshotAt else { return true } + return isDeadlineReached(nextScreenshotAt, at: date) + } + + /// Whether `date` is at or past `deadline`, within ``CapturePacing/screenshotIntervalTolerance`` + /// to absorb run-loop scheduling jitter. + private func isDeadlineReached(_ deadline: Date, at date: Date) -> Bool { + date.timeIntervalSince(deadline) >= -CapturePacing.screenshotIntervalTolerance + } + + private func scheduleNextScreenshot(after interval: TimeInterval, from date: Date) { + nextScreenshotAt = date.addingTimeInterval(interval) + lock.synchronized { + nextCaptureActivityCheckAt = date.addingTimeInterval(min(interval, baseScreenshotInterval)) + } + } + + private func shouldCheckCaptureActivity(at date: Date, isInteractiveRunLoopMode: Bool) -> Bool { + if isInteractiveRunLoopMode { + return shouldCaptureScreenshot(at: date, usesAdaptiveBackoff: false) + } + + if shouldCaptureScreenshot(at: date) { + return true + } + + let nextCaptureActivityCheckAt: Date? = lock.synchronized { + self.nextCaptureActivityCheckAt + } + guard let nextCaptureActivityCheckAt = nextCaptureActivityCheckAt else { return true } + return isDeadlineReached(nextCaptureActivityCheckAt, at: date) + } + + @discardableResult + private func startCaptureScheduler(expectedGeneration: Int? = nil) -> Bool { + let token = NSObject() + let shouldInstallObserver = lock.synchronized { + if let expectedGeneration, captureSchedulerGeneration != expectedGeneration { + return false + } + guard captureSchedulerToken == nil else { return false } + + if expectedGeneration == nil { + captureSchedulerGeneration += 1 + } + captureSchedulerToken = token + return true + } + guard shouldInstallObserver else { return false } + + captureScheduler.start(token: token) { [weak self] isInteractiveRunLoopMode in + self?.captureFrameIfNeeded(isInteractiveRunLoopMode: isInteractiveRunLoopMode) + } + return true + } + + private func stopCaptureScheduler() { + let token = lock.synchronized { () -> NSObject? in + captureSchedulerGeneration += 1 + let token = captureSchedulerToken + captureSchedulerToken = nil + nextCaptureActivityCheckAt = nil + return token + } + + guard let token = token else { return } + captureScheduler.stop(token: token) + } + + /// Tuning constants for the screenshot capture pacing and adaptive backoff policy. + private enum CapturePacing { + /// Interval to wait before re-evaluating a capture that was deferred due to ongoing animations. + static let captureDeferralInterval: TimeInterval = 0.25 + /// Captures taking at least this long double the adaptive screenshot interval; + /// faster captures halve it again. + static let slowCaptureThreshold: TimeInterval = 0.05 + /// Upper bound for the adaptive screenshot interval. + static let maximumAdaptiveCaptureInterval: TimeInterval = 5 + /// Maximum time captures can be deferred due to animations before forcing a capture. + static let maximumAnimationCaptureDeferralInterval: TimeInterval = 1 + /// Tolerance applied when comparing dates against capture deadlines, to absorb + /// run-loop scheduling jitter. + static let screenshotIntervalTolerance: TimeInterval = 0.001 + } + + private enum ScreenshotDeferralDecision { + case none + case `defer` + case captureAfterDeferral + } + + private func screenshotDeferralDecision( + activityReason: SentrySessionReplayCaptureGuard.CaptureActivityReason?, + at date: Date + ) -> ScreenshotDeferralDecision { + guard activityReason == .animation else { + deferredScreenshotStart = nil + return .none + } + + guard let deferredScreenshotStart = deferredScreenshotStart else { + self.deferredScreenshotStart = date + return .defer + } + + let deferralDuration = date.timeIntervalSince(deferredScreenshotStart) + guard deferralDuration >= CapturePacing.maximumAnimationCaptureDeferralInterval else { + return .defer + } + + SentrySDKLog.debug("[Session Replay] Forcing screenshot after deferring for \(deferralDuration)s") + self.deferredScreenshotStart = nil + return .captureAfterDeferral + } + + private func updateAdaptiveScreenshotInterval(_ captureDuration: TimeInterval) { + guard captureDuration > 0 else { return } + + guard captureDuration >= CapturePacing.slowCaptureThreshold else { + guard adaptiveScreenshotInterval > 0 else { return } + + let nextInterval = adaptiveScreenshotInterval / 2 + adaptiveScreenshotInterval = nextInterval <= baseScreenshotInterval ? 0 : nextInterval + return + } + + let nextInterval = adaptiveScreenshotInterval > 0 ? adaptiveScreenshotInterval * 2 : baseScreenshotInterval * 2 + adaptiveScreenshotInterval = min(nextInterval, CapturePacing.maximumAdaptiveCaptureInterval) + SentrySDKLog.debug("[Session Replay] Screenshot capture took \(captureDuration)s, backing off to \(adaptiveScreenshotInterval)s") + } + + // swiftlint:disable:next cyclomatic_complexity + private func prepareFullSessionSegmentsIfNeeded(until date: Date) { + let sessionSegmentDuration = replayOptions.sessionSegmentDuration + guard sessionSegmentDuration > 0 else { + SentrySDKLog.debug("[Session Replay] Not preparing segment, reason: session segment duration is not positive") + return + } + + let segmentBounds: (start: Date, end: Date)? = lock.synchronized { + guard _isFullSession else { return nil } + guard pendingSegmentEnd == nil else { return nil } if videoSegmentStart == nil { - videoSegmentStart = now - } else if let videoSegmentStart = videoSegmentStart, isFullSession && - now.timeIntervalSince(videoSegmentStart) >= replayOptions.sessionSegmentDuration { - prepareSegmentUntil(date: now) + videoSegmentStart = date + } + + guard let segmentStart = videoSegmentStart, + date.timeIntervalSince(segmentStart) >= sessionSegmentDuration + else { return nil } + + let segmentEnd = segmentStart.addingTimeInterval(sessionSegmentDuration) + pendingSegmentEnd = segmentEnd + return (segmentStart, segmentEnd) + } + guard let (segmentStart, segmentEnd) = segmentBounds else { return } + + if !prepareSegment(from: segmentStart, until: segmentEnd, completion: { [weak self] in + self?.finishPendingSegment(segmentEnd) + }) { + finishPendingSegment(segmentEnd) + } + } + + private func finishPendingSegment(_ segmentEnd: Date) { + let pauseSegmentEnd = lock.synchronized { () -> Date? in + if pendingSegmentEnd == segmentEnd { + pendingSegmentEnd = nil } + + let pauseSegmentEnd = pendingPauseSegmentEnd + pendingPauseSegmentEnd = nil + return pauseSegmentEnd + } + + if let pauseSegmentEnd = pauseSegmentEnd { + prepareSegmentUntil(date: pauseSegmentEnd) } } private func prepareSegmentUntil(date: Date) { + let fallbackSegmentStart = date.addingTimeInterval(-replayOptions.sessionSegmentDuration) + let segmentStart = lock.synchronized { + videoSegmentStart ?? max(sessionStart ?? fallbackSegmentStart, fallbackSegmentStart) + } + prepareSegment(from: segmentStart, until: date) + } + + @discardableResult + private func prepareSegment( + from segmentStart: Date, + until date: Date, + completion: (() -> Void)? = nil + ) -> Bool { SentrySDKLog.debug("[Session Replay] Preparing segment until date: \(date)") - guard var pathToSegment = urlToCache?.appendingPathComponent("segments") else { + guard date > segmentStart else { + SentrySDKLog.debug("[Session Replay] Not preparing segment, reason: segment duration is empty") + return false + } + + guard let pathToSegment = urlToCache?.appendingPathComponent("segments") else { SentrySDKLog.debug("[Session Replay] Not preparing segment, reason: could not create path to segments folder") - return + return false } let fileManager = FileManager.default @@ -254,24 +619,34 @@ import UIKit SentrySDKLog.debug("[Session Replay] Created segments folder at path: \(pathToSegment.path)") } catch { SentrySDKLog.debug("Can't create session replay segment folder. Error: \(error.localizedDescription)") - return + return false } } - pathToSegment = pathToSegment.appendingPathComponent("\(currentSegmentId).mp4") - let segmentStart = videoSegmentStart ?? dateProvider.date().addingTimeInterval(-replayOptions.sessionSegmentDuration) - - createAndCaptureInBackground(startedAt: segmentStart, replayType: replayType) + createAndCaptureInBackground( + startedAt: segmentStart, + endedAt: date, + replayType: replayType, + completion: completion + ) + return true } - private func createAndCaptureInBackground(startedAt: Date, replayType: SentryReplayType) { + private func createAndCaptureInBackground( + startedAt: Date, + endedAt: Date, + replayType: SentryReplayType, + completion: (() -> Void)? = nil + ) { SentrySDKLog.debug("[Session Replay] Creating replay video started at date: \(startedAt), replayType: \(replayType)") // Creating a video is computationally expensive, therefore perform it on a background queue. - self.replayMaker.createVideoInBackgroundWith(beginning: startedAt, end: self.dateProvider.date()) { videos in + self.replayMaker.createVideoInBackgroundWith(beginning: startedAt, end: endedAt) { [weak self] videos in + guard let self = self else { return } SentrySDKLog.debug("[Session Replay] Created replay video with \(videos.count) segments") for video in videos { self.processNewlyAvailableSegment(videoInfo: video, replayType: replayType) } + completion?() SentrySDKLog.debug("[Session Replay] Finished processing replay video with \(videos.count) segments") } } @@ -282,30 +657,38 @@ import UIKit SentrySDKLog.warning("[Session Replay] No session replay ID available, ignoring segment.") return } - captureSegment(segment: currentSegmentId, video: videoInfo, replayId: sessionReplayId, replayType: replayType) + let segmentId = lock.synchronized { () -> Int in + let segmentId = currentSegmentId + currentSegmentId++ + return segmentId + } + + captureSegment(segment: segmentId, video: videoInfo, replayId: sessionReplayId, replayType: replayType) replayMaker.releaseFramesUntil(videoInfo.end) - videoSegmentStart = videoInfo.end - currentSegmentId++ - SentrySDKLog.debug("[Session Replay] Processed segment, incrementing currentSegmentId to: \(currentSegmentId)") + lock.synchronized { + // Advance the segment start monotonically; never move it backwards. + videoSegmentStart = max(videoSegmentStart ?? videoInfo.end, videoInfo.end) + } + SentrySDKLog.debug("[Session Replay] Processed segment, incrementing currentSegmentId to: \(segmentId + 1)") } - + private func captureSegment(segment: Int, video: SentryVideoInfo, replayId: SentryId, replayType: SentryReplayType) { SentrySDKLog.debug("[Session Replay] Capturing segment: \(segment), replayId: \(replayId), replayType: \(replayType)") let replayEvent = SentryReplayEvent(eventId: replayId, replayStartTimestamp: video.start, replayType: replayType, segmentId: segment) - + replayEvent.sdk = self.replayOptions.sdkInfo replayEvent.timestamp = video.end replayEvent.urls = video.screens - + let breadcrumbs = delegate?.breadcrumbsForSessionReplay() ?? [] var events = convertBreadcrumbs(breadcrumbs: breadcrumbs, from: video.start, until: video.end) if let touchTracker = touchTracker { SentrySDKLog.debug("[Session Replay] Adding touch tracker events") - events.append(contentsOf: touchTracker.replayEvents(from: videoSegmentStart ?? video.start, until: video.end)) + events.append(contentsOf: touchTracker.replayEvents(from: video.start, until: video.end)) touchTracker.flushFinishedEvents() } - + if segment == 0 { SentrySDKLog.debug("[Session Replay] Adding options event to segment 0") if let customOptions = replayTags { @@ -314,7 +697,7 @@ import UIKit events.append(SentryRRWebOptionsEvent(timestamp: video.start, options: self.replayOptions)) } } - + let recording = SentryReplayRecording(segmentId: segment, video: video, extraEvents: events) delegate?.sessionReplayNewSegment(replayEvent: replayEvent, replayRecording: recording, videoUrl: video.path) @@ -326,59 +709,78 @@ import UIKit SentrySDKLog.debug("[Session Replay] Could not delete replay segment from disk: \(error)") } } - + private func convertBreadcrumbs(breadcrumbs: [Breadcrumb], from: Date, until: Date) -> [any SentryRRWebEventProtocol] { SentrySDKLog.debug("[Session Replay] Converting breadcrumbs from: \(from) until: \(until)") var filteredResult: [Breadcrumb] = [] var lastNavigationTime: Date = from.addingTimeInterval(-1) - + for breadcrumb in breadcrumbs { - guard let time = breadcrumb.timestamp, time >= from && time < until else { + guard let time = breadcrumb.timestamp, time >= from && time < until else { continue } - + // If it's a "navigation" breadcrumb, check the timestamp difference from the previous breadcrumb. // Skip any breadcrumbs that have occurred within 50ms of the last one, - // as these represent child view controllers that don’t need their own navigation breadcrumb. + // as these represent child view controllers that don't need their own navigation breadcrumb. if breadcrumb.type == "navigation" { if time.timeIntervalSince(lastNavigationTime) < 0.05 { continue } lastNavigationTime = time } filteredResult.append(breadcrumb) } - + return filteredResult.compactMap(breadcrumbConverter.convert(from:)) } - - private func takeScreenshot() { - guard let rootView = rootView, !processingScreenshot else { - SentrySDKLog.debug("[Session Replay] Not taking screenshot, reason: root view is nil or processing screenshot") - return + + private func takeScreenshot(timestamp: Date, completion: @escaping (TimeInterval) -> Void) -> Bool { + guard let rootView = rootView else { + SentrySDKLog.debug("[Session Replay] Not taking screenshot, reason: root view is nil") + return false } SentrySDKLog.debug("[Session Replay] Taking screenshot of root view: \(rootView)") - + lock.lock() guard !processingScreenshot else { SentrySDKLog.debug("[Session Replay] Not taking screenshot, reason: processing screenshot") lock.unlock() - return + return false } processingScreenshot = true lock.unlock() - + SentrySDKLog.debug("[Session Replay] Getting screenshot from screenshot provider") - let timestamp = dateProvider.date() let screenName = delegate?.currentScreenNameForSessionReplay() + let captureStart = dateProvider.systemTime() screenshotProvider.image(view: rootView) { [weak self] screenshot in - self?.newImage(timestamp: timestamp, maskedViewImage: screenshot, forScreen: screenName) + guard let self = self else { return } + + let captureEnd = self.dateProvider.systemTime() + let captureDuration = captureEnd >= captureStart + ? TimeInterval(captureEnd - captureStart) / TimeInterval(NSEC_PER_SEC) + : 0 + SentrySDKLog.debug("[Session Replay] New frame available, for screen: \(screenName ?? "nil")") + self.lock.synchronized { + self.replayMaker.addFrameAsync(timestamp: timestamp, maskedViewImage: screenshot, forScreen: screenName) + } + completion(captureDuration) } + return true } - private func newImage(timestamp: Date, maskedViewImage: UIImage, forScreen screen: String?) { - SentrySDKLog.debug("[Session Replay] New frame available, for screen: \(screen ?? "nil")") - lock.synchronized { - processingScreenshot = false - replayMaker.addFrameAsync(timestamp: timestamp, maskedViewImage: maskedViewImage, forScreen: screen) + private func runOnMainThread(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } + + private func runOnMainThreadSync(_ block: () -> T) -> T { + if Thread.isMainThread { + return block() + } else { + return DispatchQueue.main.sync(execute: block) } } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayCaptureGuard.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayCaptureGuard.swift new file mode 100644 index 00000000000..88d0dde6781 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayCaptureGuard.swift @@ -0,0 +1,51 @@ +import Foundation +#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK +import UIKit + +/// Inspects the view hierarchy to detect ongoing activity (user interactions or animations) +/// that should influence when ``SentrySessionReplay`` captures the next screenshot. +enum SentrySessionReplayCaptureGuard { + private static let activeAnimationThreshold = 4 + + enum CaptureActivityReason { + case interaction + case animation + } + + static func captureActivityReason(rootView: UIView, options: SentryRedactOptions) -> CaptureActivityReason? { + if containsActiveInteraction(in: rootView, options: options) { + return .interaction + } + + if activeAnimationCount(in: rootView, options: options, upTo: Self.activeAnimationThreshold) >= Self.activeAnimationThreshold { + return .animation + } + + return nil + } + + private static func containsActiveInteraction(in view: UIView, options: SentryRedactOptions) -> Bool { + return SentryViewSubtreeTraversal.traverse(view, options: options) { view in + if let scrollView = view as? UIScrollView, scrollView.isDragging || scrollView.isDecelerating || scrollView.isTracking { + return true + } + + if let control = view as? UIControl, control.isTracking { + return true + } + + return view.gestureRecognizers?.contains(where: { $0.state == .began || $0.state == .changed }) == true + } + } + + private static func activeAnimationCount(in view: UIView, options: SentryRedactOptions, upTo limit: Int) -> Int { + var count = 0 + SentryViewSubtreeTraversal.traverse(view, options: options) { view in + count += view.layer.animationKeys()?.count ?? 0 + return count >= limit + } + return count + } +} + +#endif diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayIntegration.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayIntegration.swift index 024bc2c1545..6e0b39af0e6 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayIntegration.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayIntegration.swift @@ -4,7 +4,7 @@ #if (os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK import UIKit -typealias SessionReplayIntegrationScope = NotificationCenterProvider & RateLimitsProvider & CurrentDateProvider & RandomProvider & FileManagerProvider & CrashWrapperProvider & ReachabilityProvider & GlobalEventProcessorProvider & DispatchQueueWrapperProvider & ApplicationProvider & DispatchFactoryProvider +typealias SessionReplayIntegrationScope = NotificationCenterProvider & RateLimitsProvider & CurrentDateProvider & RandomProvider & FileManagerProvider & CrashWrapperProvider & ReachabilityProvider & GlobalEventProcessorProvider & DispatchQueueWrapperProvider & ApplicationProvider & DispatchFactoryProvider & SessionReplayCaptureSchedulerProvider // This is static because it will be used for swizzling and would cause retain cycles private var touchTracker: SentryTouchTracker? @@ -23,6 +23,7 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS private let replayOptions: SentryReplayOptions private let rateLimits: RateLimits private let random: SentryRandomProtocol + private let captureScheduler: SentrySessionReplayRunLoopCaptureScheduler private var startedAsFullSession = false private let experimentalOptions: SentryExperimentalOptions private let notificationCenter: SentryNSNotificationCenterWrapper @@ -35,6 +36,16 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS private var replayRecovery: SessionReplayRecovery? private var backgroundForegroundObserver: SentrySessionReplayBackgroundForegroundObserver? + /// Guards `isApplicationStatePaused`, which is written by `pause`/`resume` (application + /// lifecycle, usually the main thread) and read from the reachability queue in + /// `connectivityChanged`. + private let applicationStateLock = NSLock() + private var _isApplicationStatePaused = false + private var isApplicationStatePaused: Bool { + get { applicationStateLock.synchronized { _isApplicationStatePaused } } + set { applicationStateLock.synchronized { _isApplicationStatePaused = newValue } } + } + /// Getter to get the current application at runtime /// /// When initializing the Sentry SDK from ``SwiftUI/App.init`` the ``UIKit/UIApplication.shared`` returns `nil`, therefore we need to @@ -83,6 +94,7 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS self.rateLimits = dependencies.rateLimits self.dateProvider = dependencies.dateProvider self.random = dependencies.random + self.captureScheduler = dependencies.sessionReplayCaptureScheduler self.crashWrapper = dependencies.crashWrapper self.getApplication = dependencies.application @@ -296,7 +308,7 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS let newSessionReplay = SentrySessionReplay( replayOptions: replayOptions, replayFolderPath: sessionDocs, screenshotProvider: screenshotProvider, replayMaker: replayMaker, breadcrumbConverter: breadcrumbConverter, touchTracker: touchTracker, - dateProvider: dateProvider, delegate: self, displayLinkWrapper: SentryDisplayLinkWrapper()) + dateProvider: dateProvider, delegate: self, captureScheduler: captureScheduler) self.sessionReplay = newSessionReplay newSessionReplay.start(rootView: rootView, fullSession: fullSession) @@ -361,11 +373,13 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS @objc public func pause() { SentrySDKLog.debug("[Session Replay] Pausing session") + isApplicationStatePaused = true sessionReplay?.pause() } @objc public func resume() { SentrySDKLog.debug("[Session Replay] Resuming session") + isApplicationStatePaused = false sessionReplay?.resume() } @@ -480,7 +494,11 @@ public class SentrySessionReplayIntegration: NSObject, SwiftIntegration, SentryS // MARK: - SentryReachabilityObserver public func connectivityChanged(_ connected: Bool, typeDescription: String) { SentrySDKLog.debug("[Session Replay] Connectivity changed to: \(connected ? "connected" : "disconnected"), type: \(typeDescription)") - if connected { sessionReplay?.resume() } else { sessionReplay?.pauseSessionMode() } + if connected { + sessionReplay?.resumeSessionMode(restartCaptureScheduler: !isApplicationStatePaused) + } else { + sessionReplay?.pauseSessionMode() + } } // MARK: - Test only diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayRunLoopCaptureScheduler.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayRunLoopCaptureScheduler.swift new file mode 100644 index 00000000000..66ea48f8bf9 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayRunLoopCaptureScheduler.swift @@ -0,0 +1,131 @@ +// swiftlint:disable missing_docs +import Foundation + +#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK + +@_spi(Private) public protocol SentrySessionReplayRunLoopCaptureScheduler: AnyObject { + // The token owns the installed observer so stale stops from an old replay cannot remove a newer replay's observer. + func start(token: AnyObject, capture: @escaping (_ isInteractiveRunLoopMode: Bool) -> Void) + func stop(token: AnyObject) +} + +final class DefaultSentrySessionReplayRunLoopCaptureScheduler: SentrySessionReplayRunLoopCaptureScheduler { + private var observer: T? + private var token: AnyObject? + private var didProcessRunLoopWork = false + private let createObserver: CreateObserverFunc + private let addObserver: AddObserverFunc + private let removeObserver: RemoveObserverFunc + private let currentRunLoopMode: () -> RunLoop.Mode? + private let isValidObserver: (T) -> Bool + + init( + createObserver: @escaping CreateObserverFunc, + addObserver: @escaping AddObserverFunc, + removeObserver: @escaping RemoveObserverFunc, + currentRunLoopMode: @escaping () -> RunLoop.Mode? = { RunLoop.current.currentMode }, + isValidObserver: @escaping (T) -> Bool = { _ in true } + ) { + self.createObserver = createObserver + self.addObserver = addObserver + self.removeObserver = removeObserver + self.currentRunLoopMode = currentRunLoopMode + self.isValidObserver = isValidObserver + } + + func start(token: AnyObject, capture: @escaping (Bool) -> Void) { + runOnMainThreadSync { [weak self] in + self?.startOnMainThread(token: token, capture: capture) + } + } + + func stop(token: AnyObject) { + runOnMainThreadSync { [weak self] in + self?.stopOnMainThread(token: token) + } + } + + private func startOnMainThread(token: AnyObject, capture: @escaping (Bool) -> Void) { + if let currentToken = self.token { + guard currentToken !== token else { return } + removeCurrentObserver() + } + + let activities = CFRunLoopActivity.afterWaiting.rawValue + | CFRunLoopActivity.beforeTimers.rawValue + | CFRunLoopActivity.beforeSources.rawValue + | CFRunLoopActivity.beforeWaiting.rawValue + | CFRunLoopActivity.exit.rawValue + + let observer = createObserver( + kCFAllocatorDefault, + activities, + true, + CFIndex.max + ) { [weak self] observer, activity in + guard let observer = observer, + let self = self, + self.isValidObserver(observer), + self.shouldCapture(activity: activity) + else { return } + + capture(self.currentRunLoopMode() == .tracking) + } + guard let observer = observer else { return } + + self.observer = observer + self.token = token + addObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + private func stopOnMainThread(token: AnyObject) { + guard self.token === token else { return } + removeCurrentObserver() + } + + private func removeCurrentObserver() { + didProcessRunLoopWork = false + self.token = nil + guard let observer = observer else { return } + + self.observer = nil + removeObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + private func shouldCapture(activity: CFRunLoopActivity) -> Bool { + if activity.contains(.afterWaiting) + || activity.contains(.beforeTimers) + || activity.contains(.beforeSources) { + didProcessRunLoopWork = true + return false + } + + guard activity.contains(.beforeWaiting) || activity.contains(.exit) else { return false } + guard didProcessRunLoopWork else { return false } + + didProcessRunLoopWork = false + return true + } + + private func runOnMainThreadSync(_ block: () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.sync(execute: block) + } + } +} + +extension DefaultSentrySessionReplayRunLoopCaptureScheduler where T == CFRunLoopObserver { + convenience init() { + self.init( + createObserver: CFRunLoopObserverCreateWithHandler, + addObserver: CFRunLoopAddObserver, + removeObserver: CFRunLoopRemoveObserver, + isValidObserver: CFRunLoopObserverIsValid + ) + } +} + +#endif +// swiftlint:enable missing_docs diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index 8b7c7257f0c..70bd2e3cc5e 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -91,6 +91,9 @@ extension SentryFileManager: SentryFileManagerProtocol { } #if os(iOS) && !SENTRY_NO_UI_FRAMEWORK var windowFactoryOverride: SentryUserFeedbackWindowFactory? #endif +#endif +#if (os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK + var sessionReplayCaptureScheduler: SentrySessionReplayRunLoopCaptureScheduler = DefaultSentrySessionReplayRunLoopCaptureScheduler() #endif @objc public func application() -> SentryApplication? { #if SENTRY_TEST || SENTRY_TEST_CI @@ -478,6 +481,12 @@ protocol ViewHierarchyProviderProvider { } extension SentryDependencyContainer: ViewHierarchyProviderProvider { } + +protocol SessionReplayCaptureSchedulerProvider { + var sessionReplayCaptureScheduler: SentrySessionReplayRunLoopCaptureScheduler { get } +} + +extension SentryDependencyContainer: SessionReplayCaptureSchedulerProvider { } #endif protocol ExtraContextProviderProvider { diff --git a/Sources/Swift/SentrySwiftUI/Preview/PreviewRedactOptions.swift b/Sources/Swift/SentrySwiftUI/Preview/PreviewRedactOptions.swift index bdfff184a13..3b5855fa5a6 100644 --- a/Sources/Swift/SentrySwiftUI/Preview/PreviewRedactOptions.swift +++ b/Sources/Swift/SentrySwiftUI/Preview/PreviewRedactOptions.swift @@ -46,9 +46,9 @@ public final class PreviewRedactOptions: SentryRedactOptions { * "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: + * - 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 let excludedViewClasses: Set @@ -63,9 +63,9 @@ public final class PreviewRedactOptions: SentryRedactOptions { * not "MyApp.MyViewSubclass". * * - Note: See ``SentryReplayOptions.DefaultValues.includedViewClasses`` for the default value. - * - 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+). * - 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 * because "Camera" doesn't exactly match "ChromeCameraUI". diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 6bcdc03978c..a2c45d08316 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -10,6 +10,13 @@ class SentrySessionReplayIntegrationTests: XCTestCase { private var uiApplication: TestSentryUIApplication! private var globalEventProcessor: SentryGlobalEventProcessor! private var dateProvider: TestCurrentDateProvider! + private var captureScheduler: DefaultSentrySessionReplayRunLoopCaptureScheduler! + private var createdObservationBlock: ((TestSessionReplayRunLoopObserver?, CFRunLoopActivity) -> Void)? + private var observationBlock: ((TestSessionReplayRunLoopObserver?, CFRunLoopActivity) -> Void)? + private let testObserver = TestSessionReplayRunLoopObserver() + private let currentRunLoopMode = RunLoop.Mode.default + + private struct TestSessionReplayRunLoopObserver: RunLoopObserver { } private class TestCrashWrapper: NSObject, SentryCrashReporter { let traced: Bool @@ -47,11 +54,21 @@ class SentrySessionReplayIntegrationTests: XCTestCase { globalEventProcessor = SentryGlobalEventProcessor() uiApplication.windows = [UIWindow()] dateProvider = TestCurrentDateProvider() + captureScheduler = DefaultSentrySessionReplayRunLoopCaptureScheduler( + createObserver: { [unowned self] _, _, _, _, block in + self.createdObservationBlock = block + return self.testObserver + }, + addObserver: { [unowned self] _, _, _ in self.observationBlock = self.createdObservationBlock }, + removeObserver: { [unowned self] _, _, _ in self.observationBlock = nil }, + currentRunLoopMode: { [unowned self] in self.currentRunLoopMode } + ) SentryDependencyContainer.sharedInstance().applicationOverride = uiApplication SentryDependencyContainer.sharedInstance().reachability = TestSentryReachability() SentryDependencyContainer.sharedInstance().globalEventProcessor = globalEventProcessor SentryDependencyContainer.sharedInstance().dateProvider = dateProvider + SentryDependencyContainer.sharedInstance().sessionReplayCaptureScheduler = captureScheduler } override func tearDown() { @@ -90,6 +107,23 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertEqual(SentrySDKInternal.currentHub().trimmedInstalledIntegrationNames().count, 1) XCTAssertEqual(globalEventProcessor.processors.count, 1) } + + func testRunLoopScheduler_whenStaleStopRunsAfterNewStart_shouldKeepNewObserver() { + let oldToken = NSObject() + let newToken = NSObject() + var oldCaptures = 0 + var newCaptures = 0 + + captureScheduler.start(token: oldToken) { _ in oldCaptures += 1 } + captureScheduler.start(token: newToken) { _ in newCaptures += 1 } + captureScheduler.stop(token: oldToken) + + observationBlock?(testObserver, .afterWaiting) + observationBlock?(testObserver, .beforeWaiting) + + XCTAssertEqual(oldCaptures, 0) + XCTAssertEqual(newCaptures, 1) + } func testInstallNoSwizzlingNoTouchTracker() { startSDK(sessionSampleRate: 1, errorSampleRate: 0, enableSwizzling: false) @@ -406,7 +440,25 @@ class SentrySessionReplayIntegrationTests: XCTestCase { sut.connectivityChanged(true, typeDescription: "") XCTAssertFalse(sut.sessionReplay?.isSessionPaused ?? false) } - + + func testConnectivityReconnect_whenApplicationPaused_shouldWaitForForeground() throws { + startSDK(sessionSampleRate: 1, errorSampleRate: 0) + let sut = try getSut() + let sessionReplay = try XCTUnwrap(sut.sessionReplay) + + sut.connectivityChanged(false, typeDescription: "") + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + sut.connectivityChanged(true, typeDescription: "") + + XCTAssertFalse(sessionReplay.isSessionPaused) + XCTAssertFalse(sessionReplay.isRunning) + + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + XCTAssertTrue(sessionReplay.isRunning) + } + func testMaskViewFromSDK() throws { // -- Arrange -- class AnotherLabel: UILabel {} @@ -818,11 +870,11 @@ class SentrySessionReplayIntegrationTests: XCTestCase { // -- Act -- // Advance time past the maximum duration (60 minutes) - Dynamic(sessionReplay).newFrame(nil) + runLoopCapture() dateProvider.advance(by: 5) - Dynamic(sessionReplay).newFrame(nil) + runLoopCapture() dateProvider.advance(by: 3_600) - Dynamic(sessionReplay).newFrame(nil) + runLoopCapture() // -- Assert -- XCTAssertFalse(sessionReplay.isRunning) @@ -833,6 +885,11 @@ class SentrySessionReplayIntegrationTests: XCTestCase { XCTAssertNil(sut.sessionReplay) } + private func runLoopCapture() { + observationBlock?(testObserver, .afterWaiting) + observationBlock?(testObserver, .beforeWaiting) + } + func testReplayIdAndSessionReplayCleared_whenSessionEnds() throws { // -- Arrange -- startSDK(sessionSampleRate: 1, errorSampleRate: 0) diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index ab512cbc7a8..61539660fb9 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -1,6 +1,10 @@ +// swiftlint:disable file_length import Foundation @_spi(Private) @testable import Sentry @_spi(Private) import SentryTestUtils +#if canImport(UIKit) +import UIKit +#endif import XCTest #if os(iOS) || os(tvOS) @@ -8,9 +12,62 @@ class SentrySessionReplayTests: XCTestCase { private class ScreenshotProvider: NSObject, SentryViewScreenshotProvider { var lastImageCall: UIView? + var imageCallCount = 0 + var beforeComplete: (() -> Void)? + var completeAsync = false + private var pendingCompletions = [Sentry.ScreenshotCallback]() + func image(view: UIView, onComplete: @escaping Sentry.ScreenshotCallback) { - onComplete(UIImage.add) lastImageCall = view + imageCallCount += 1 + if completeAsync { + pendingCompletions.append(onComplete) + return + } + complete(onComplete) + } + + func completePendingImage() { + complete(pendingCompletions.removeFirst()) + } + + private func complete(_ completion: Sentry.ScreenshotCallback) { + beforeComplete?() + completion(UIImage.add) + } + } + + private class DraggingScrollView: UIScrollView { + override var isDragging: Bool { true } + } + + private class CountingDraggingScrollView: UIScrollView { + var interactionStateReadCount = 0 + + override var isDragging: Bool { + interactionStateReadCount += 1 + return true + } + } + + private class ExcludedActivityView: UIView {} + + private class CountingScrollView: UIScrollView { + var interactionStateReadCount = 0 + + override var isDragging: Bool { + interactionStateReadCount += 1 + return false + } + + override var isDecelerating: Bool { + interactionStateReadCount += 1 + return false + } + + override var isTracking: Bool { + interactionStateReadCount += 1 + return false } } @@ -22,6 +79,21 @@ class SentrySessionReplayTests: XCTestCase { return super.replayEvents(from: from, until: until) } } + + private struct TestSessionReplayRunLoopObserver: RunLoopObserver { } + + private class RecordingCaptureScheduler: SentrySessionReplayRunLoopCaptureScheduler { + var startedTokens = [AnyObject]() + var stoppedTokens = [AnyObject]() + + func start(token: AnyObject, capture: @escaping (Bool) -> Void) { + startedTokens.append(token) + } + + func stop(token: AnyObject) { + stoppedTokens.append(token) + } + } private class TestReplayMaker: NSObject, SentryReplayVideoMaker { var screens = [String]() @@ -34,7 +106,15 @@ class SentrySessionReplayTests: XCTestCase { var end: Date } - var lastCallToCreateVideo: CreateVideoCall? + var createVideoResults = [[SentryVideoInfo]]() + var createVideoCalls = [CreateVideoCall]() + var deferCreateVideoCompletion = false + private var pendingCreateVideoCompletions = [(CreateVideoCall, ([SentryVideoInfo]) -> Void)]() + + var lastCallToCreateVideo: CreateVideoCall? { + createVideoCalls.last + } + func createVideoInBackgroundWith( beginning: Date, end: Date, @@ -42,15 +122,41 @@ class SentrySessionReplayTests: XCTestCase { ) { // Note: This implementation is just to satisfy the protocol. // If possible, keep the tests logic the synchronous version `createVideoWith` + if deferCreateVideoCompletion { + let call = CreateVideoCall(beginning: beginning, end: end) + createVideoCalls.append(call) + pendingCreateVideoCompletions.append((call, completion)) + return + } + let videos = createVideoWith(beginning: beginning, end: end) completion(videos) } func createVideoWith(beginning: Date, end: Date) -> [Sentry.SentryVideoInfo] { - lastCallToCreateVideo = CreateVideoCall(beginning: beginning, end: end) + let call = CreateVideoCall(beginning: beginning, end: end) + createVideoCalls.append(call) + + return videos(for: call) + } + + func completeNextCreateVideo() { + let (call, completion) = pendingCreateVideoCompletions.removeFirst() + completion(videos(for: call)) + } + + private func videos(for call: CreateVideoCall) -> [Sentry.SentryVideoInfo] { + if !createVideoResults.isEmpty { + let videos = createVideoResults.removeFirst() + videos.forEach { createVideoCallBack?($0) } + return videos + } + let outputFileURL = FileManager.default.temporaryDirectory.appendingPathComponent("tempvideo.mp4") XCTAssertNoThrow(try "Video Data".write(to: outputFileURL, atomically: true, encoding: .utf8)) + let beginning = call.beginning + let end = call.end let videoInfo = SentryVideoInfo(path: outputFileURL, height: 1_024, width: 480, duration: end.timeIntervalSince(overrideBeginning ?? beginning), frameCount: 5, frameRate: 1, start: overrideBeginning ?? beginning, end: end, fileSize: 10, screens: screens) createVideoCallBack?(videoInfo) @@ -76,10 +182,22 @@ class SentrySessionReplayTests: XCTestCase { let dateProvider = TestCurrentDateProvider() let random = TestRandom(value: 0) let screenshotProvider = ScreenshotProvider() - let displayLink = TestDisplayLinkWrapper() let rootView = UIView() let replayMaker = TestReplayMaker() let cacheFolder = FileManager.default.temporaryDirectory + private let testObserver = TestSessionReplayRunLoopObserver() + private var createdObservationBlock: ((TestSessionReplayRunLoopObserver?, CFRunLoopActivity) -> Void)? + private var observationBlock: ((TestSessionReplayRunLoopObserver?, CFRunLoopActivity) -> Void)? + private var currentRunLoopMode: RunLoop.Mode? + lazy var captureScheduler: SentrySessionReplayRunLoopCaptureScheduler = DefaultSentrySessionReplayRunLoopCaptureScheduler( + createObserver: { [unowned self] _, _, _, _, block in + self.createdObservationBlock = block + return self.testObserver + }, + addObserver: { [unowned self] _, _, _ in self.observationBlock = self.createdObservationBlock }, + removeObserver: { [unowned self] _, _, _ in self.observationBlock = nil }, + currentRunLoopMode: { [unowned self] in self.currentRunLoopMode } + ) var breadcrumbs: [Breadcrumb]? var isFullSession = true @@ -103,9 +221,16 @@ class SentrySessionReplayTests: XCTestCase { touchTracker: touchTracker ?? SentryTouchTracker(dateProvider: dateProvider, scale: 0), dateProvider: dateProvider, delegate: self, - displayLinkWrapper: displayLink + captureScheduler: captureScheduler ) } + + func runLoopCapture(isInteractiveRunLoopMode: Bool = false) { + currentRunLoopMode = isInteractiveRunLoopMode ? .tracking : .default + observationBlock?(testObserver, .afterWaiting) + observationBlock?(testObserver, .beforeWaiting) + currentRunLoopMode = nil + } func sessionReplayShouldCaptureReplayForError() -> Bool { return isFullSession @@ -149,12 +274,67 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: false) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.lastReplayEvent) } + + func testStart_StartsCaptureScheduler() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut() + + // -- Act -- + sut.start(rootView: fixture.rootView, fullSession: false) + + // -- Assert -- + XCTAssertTrue(sut.isRunning) + } + + func testPause_FromBackgroundThread_ShouldStopCaptureScheduler() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut() + sut.start(rootView: fixture.rootView, fullSession: false) + let expectation = expectation(description: "Pause from background") + + // -- Act -- + DispatchQueue.global().async { + sut.pause() + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + + // -- Assert -- + XCTAssertFalse(sut.isRunning) + } + + func testCaptureFrame_whenRunLoopIsTracking_shouldThrottleCapture() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture(isInteractiveRunLoopMode: true) + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + fixture.dateProvider.advance(by: 0.5) + fixture.runLoopCapture(isInteractiveRunLoopMode: true) + + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + fixture.dateProvider.advance(by: 0.51) + fixture.runLoopCapture(isInteractiveRunLoopMode: true) + + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } func testSentReplay_FullSession() { let fixture = Fixture() @@ -163,21 +343,20 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) XCTAssertEqual(fixture.lastReplayId, sut.sessionReplayId) + let sessionStart = fixture.dateProvider.date() + fixture.dateProvider.advance(by: 1) - - let startEvent = fixture.dateProvider.date() - - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() guard let videoArguments = fixture.replayMaker.lastCallToCreateVideo else { XCTFail("Replay maker create video was not called") return } - XCTAssertEqual(videoArguments.end, startEvent.addingTimeInterval(5)) - XCTAssertEqual(videoArguments.beginning, startEvent) + XCTAssertEqual(videoArguments.end, sessionStart.addingTimeInterval(5)) + XCTAssertEqual(videoArguments.beginning, sessionStart) XCTAssertNotNil(fixture.lastReplayRecording) assertFullSession(sut, expected: true) @@ -191,14 +370,14 @@ class SentrySessionReplayTests: XCTestCase { for i in 1...6 { fixture.currentScreen = "Screen \(i)" fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() } let urls = try XCTUnwrap(fixture.lastReplayEvent?.urls) - guard urls.count == 6 else { - XCTFail("Expected 6 screen names") - return + guard urls.count == 5 else { + XCTFail("Expected 5 screen names") + return } XCTAssertEqual(urls[0], "Screen 1") XCTAssertEqual(urls[1], "Screen 2") @@ -214,9 +393,9 @@ class SentrySessionReplayTests: XCTestCase { fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let videoArguments = fixture.replayMaker.lastCallToCreateVideo @@ -249,7 +428,7 @@ class SentrySessionReplayTests: XCTestCase { let firstSegment = try XCTUnwrap(fixture.lastReplayEvent) fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let secondSegment = try XCTUnwrap(fixture.lastReplayEvent) // -- Assert -- @@ -293,7 +472,7 @@ class SentrySessionReplayTests: XCTestCase { let firstSegment = try XCTUnwrap(fixture.lastReplayEvent) fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let secondSegment = try XCTUnwrap(fixture.lastReplayEvent) // -- Assert -- @@ -303,6 +482,34 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertEqual(secondSegment.segmentId, 1) } + func testCaptureReplay_whenCalledFromBackground_shouldStartSessionAtLastScreenshot() throws { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + let expectedSessionStart = fixture.dateProvider.date() + fixture.replayMaker.createVideoCalls.removeAll() + let expectation = expectation(description: "Capture replay from background") + + // -- Act -- + DispatchQueue.global().async { + _ = sut.captureReplay(replayType: .session) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + + fixture.replayMaker.createVideoCalls.removeAll() + fixture.dateProvider.advance(by: 5) + fixture.runLoopCapture() + let fullSessionSegment = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + // -- Assert -- + XCTAssertEqual(fullSessionSegment.beginning, expectedSessionStart) + } + func testSessionReplayMaximumDuration() { // -- Arrange -- let fixture = Fixture() @@ -310,15 +517,15 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) // -- Act -- - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) - XCTAssertTrue(fixture.displayLink.isRunning()) + fixture.runLoopCapture() + XCTAssertTrue(sut.isRunning) fixture.dateProvider.advance(by: 3_600) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() // -- Assert -- - XCTAssertFalse(fixture.displayLink.isRunning()) + XCTAssertFalse(sut.isRunning) XCTAssertEqual(fixture.sessionReplayEndedInvocations.count, 1) } @@ -329,12 +536,12 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) // -- Act -- - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() // -- Assert -- - XCTAssertTrue(fixture.displayLink.isRunning()) + XCTAssertTrue(sut.isRunning) XCTAssertEqual(fixture.sessionReplayEndedInvocations.count, 0) } @@ -347,9 +554,9 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let event = try XCTUnwrap(fixture.lastReplayEvent) @@ -363,10 +570,180 @@ class SentrySessionReplayTests: XCTestCase { let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) sut.start(rootView: fixture.rootView, fullSession: false) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) } + + func testNewFrame_whenBufferModeExceedsSegmentDuration_shouldNotPrepareSegment() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) + XCTAssertNil(fixture.replayMaker.lastCallToCreateVideo) + } + + func testNewFrame_whenSessionSegmentDurationIsNotPositive_shouldNotPrepareSegment() { + for duration in [0, -1] { + // -- Arrange -- + let fixture = Fixture() + let replayOptions = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + replayOptions.sessionSegmentDuration = TimeInterval(duration) + let sut = fixture.getSut(options: replayOptions) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertNil(fixture.replayMaker.lastCallToCreateVideo) + XCTAssertNil(fixture.lastReplayRecording) + } + } + + func testNewFrame_whenSegmentCreationReturnsNoVideo_shouldRetrySameSegmentWindow() throws { + // -- Arrange -- + let fixture = Fixture() + fixture.replayMaker.createVideoResults = [[]] + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + let firstCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + let secondCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + // -- Assert -- + let expectedStart = TestCurrentDateProvider.defaultStartingDate + let expectedEnd = expectedStart.addingTimeInterval(5) + XCTAssertEqual(firstCall.beginning, expectedStart) + XCTAssertEqual(firstCall.end, expectedEnd) + XCTAssertEqual(secondCall.beginning, expectedStart) + XCTAssertEqual(secondCall.end, expectedEnd) + XCTAssertNotNil(fixture.lastReplayRecording) + } + + func testPause_whenSegmentCreationIsPending_shouldPreparePauseSegmentAfterPendingCompletes() throws { + // -- Arrange -- + let fixture = Fixture() + fixture.replayMaker.deferCreateVideoCompletion = true + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + let firstCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + sut.pause() + let createCallsAfterPause = fixture.replayMaker.createVideoCalls + + fixture.replayMaker.completeNextCreateVideo() + let createCallsAfterPendingCompletes = fixture.replayMaker.createVideoCalls + + // -- Assert -- + XCTAssertEqual(firstCall.beginning, TestCurrentDateProvider.defaultStartingDate) + XCTAssertEqual(firstCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(5)) + XCTAssertEqual(createCallsAfterPause.count, 1) + XCTAssertEqual(createCallsAfterPendingCompletes.count, 2) + + let pauseCall = try XCTUnwrap(createCallsAfterPendingCompletes.last) + XCTAssertEqual(pauseCall.beginning, firstCall.end) + XCTAssertEqual(pauseCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(6)) + } + + func testPauseSessionMode_whenSegmentCreationIsPending_shouldPreparePauseSegmentAfterPendingCompletes() throws { + // -- Arrange -- + let fixture = Fixture() + fixture.replayMaker.deferCreateVideoCompletion = true + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + let firstCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + sut.pauseSessionMode() + let createCallsAfterPause = fixture.replayMaker.createVideoCalls + + fixture.replayMaker.completeNextCreateVideo() + let createCallsAfterPendingCompletes = fixture.replayMaker.createVideoCalls + + // -- Assert -- + XCTAssertTrue(sut.isRunning) + XCTAssertTrue(sut.isSessionPaused) + XCTAssertEqual(firstCall.beginning, TestCurrentDateProvider.defaultStartingDate) + XCTAssertEqual(firstCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(5)) + XCTAssertEqual(createCallsAfterPause.count, 1) + XCTAssertEqual(createCallsAfterPendingCompletes.count, 2) + + let pauseCall = try XCTUnwrap(createCallsAfterPendingCompletes.last) + XCTAssertEqual(pauseCall.beginning, firstCall.end) + XCTAssertEqual(pauseCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(6)) + } + + func testPause_whenSegmentCreationReturnsNoVideo_shouldRetrySameSegmentWindow() throws { + // -- Arrange -- + let fixture = Fixture() + fixture.replayMaker.createVideoResults = [[]] + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 2) + sut.pause() + let firstCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + fixture.dateProvider.advance(by: 1) + sut.pause() + let secondCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + + // -- Assert -- + let expectedStart = TestCurrentDateProvider.defaultStartingDate + XCTAssertEqual(firstCall.beginning, expectedStart) + XCTAssertEqual(firstCall.end, expectedStart.addingTimeInterval(2)) + XCTAssertEqual(secondCall.beginning, expectedStart) + XCTAssertEqual(secondCall.end, expectedStart.addingTimeInterval(3)) + } + + func testPause_whenScreenshotCaptureIsPending_shouldNotPrepareSegmentAfterCompletion() throws { + // -- Arrange -- + let fixture = Fixture() + fixture.screenshotProvider.completeAsync = true + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + sut.pause() + let createCallsAfterPause = fixture.replayMaker.createVideoCalls + + fixture.dateProvider.advance(by: 6) + fixture.screenshotProvider.completePendingImage() + let createCallsAfterScreenshotCompletes = fixture.replayMaker.createVideoCalls + + // -- Assert -- + XCTAssertEqual(createCallsAfterPause.count, 1) + XCTAssertEqual(createCallsAfterScreenshotCompletes.count, 1) + + let pauseCall = try XCTUnwrap(createCallsAfterPause.last) + XCTAssertEqual(pauseCall.beginning, TestCurrentDateProvider.defaultStartingDate) + XCTAssertEqual(pauseCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(6)) + } func testPauseResume_FullSession() { let fixture = Fixture() @@ -375,29 +752,96 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) sut.pauseSessionMode() fixture.screenshotProvider.lastImageCall = nil fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall) fixture.dateProvider.advance(by: 4) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.replayMaker.lastCallToCreateVideo) - sut.resume() + sut.resumeSessionMode() fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.replayMaker.lastCallToCreateVideo) } + + func testResumeSessionMode_whenCaptureWasDueWhilePaused_shouldCaptureImmediately() { + let fixture = Fixture() + + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) + sut.pauseSessionMode() + fixture.screenshotProvider.lastImageCall = nil + + fixture.dateProvider.advance(by: 5) + fixture.runLoopCapture() + XCTAssertNil(fixture.screenshotProvider.lastImageCall) + + sut.resumeSessionMode() + fixture.runLoopCapture() + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) + } + + func testPauseResume_whenSegmentAlreadyCreated_shouldNotRestartFromSessionStart() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + XCTAssertEqual(fixture.replayMaker.lastCallToCreateVideo?.beginning, TestCurrentDateProvider.defaultStartingDate) + fixture.replayMaker.createVideoCalls.removeAll() + + // -- Act -- + sut.pauseSessionMode() + let resumeDate = fixture.dateProvider.date() + sut.resumeSessionMode() + + fixture.dateProvider.advance(by: 5) + fixture.runLoopCapture() + + // -- Assert -- + let resumedSegment = fixture.replayMaker.lastCallToCreateVideo + XCTAssertEqual(resumedSegment?.beginning, resumeDate) + XCTAssertEqual(resumedSegment?.end, resumeDate.addingTimeInterval(5)) + } + + func testPauseResume_whenSessionModePaused_shouldWaitForSessionModeResume() { + let fixture = Fixture() + + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + sut.pauseSessionMode() + sut.pause() + fixture.screenshotProvider.lastImageCall = nil + + sut.resume() + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + XCTAssertNil(fixture.screenshotProvider.lastImageCall) + + sut.resumeSessionMode() + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) + } func testPause_BufferSession() { let fixture = Fixture() @@ -407,17 +851,17 @@ class SentrySessionReplayTests: XCTestCase { fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) sut.pauseSessionMode() fixture.screenshotProvider.lastImageCall = nil fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall) fixture.dateProvider.advance(by: 4) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let event = Event(error: NSError(domain: "Some error", code: 1)) sut.captureReplayFor(event: event) @@ -427,9 +871,71 @@ class SentrySessionReplayTests: XCTestCase { //After changing to session mode the replay should pause fixture.screenshotProvider.lastImageCall = nil fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall) } + + func testResume_whenBufferSessionModePaused_shouldRestartCaptureScheduler() { + let fixture = Fixture() + + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + + sut.pauseSessionMode() + sut.pause() + XCTAssertFalse(sut.isRunning) + + sut.resume() + XCTAssertTrue(sut.isRunning) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + let event = Event(error: NSError(domain: "Some error", code: 1)) + sut.captureReplayFor(event: event) + + XCTAssertNotNil(fixture.replayMaker.lastCallToCreateVideo) + } + + func testResume_whenSchedulerStopIsPending_shouldUseNewSchedulerToken() throws { + let fixture = Fixture() + let captureScheduler = RecordingCaptureScheduler() + fixture.captureScheduler = captureScheduler + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + + let firstToken = try XCTUnwrap(captureScheduler.startedTokens.last) + + sut.pause() + sut.resume() + + XCTAssertIdentical(try XCTUnwrap(captureScheduler.stoppedTokens.last), firstToken) + XCTAssertNotIdentical(try XCTUnwrap(captureScheduler.startedTokens.last), firstToken) + } + + func testResume_whenPauseRunsBeforeQueuedStart_shouldNotStartCaptureScheduler() { + let fixture = Fixture() + let captureScheduler = RecordingCaptureScheduler() + fixture.captureScheduler = captureScheduler + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: false) + sut.pause() + + let startCount = captureScheduler.startedTokens.count + let resumeReturned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + sut.resume() + resumeReturned.signal() + } + + XCTAssertEqual(resumeReturned.wait(timeout: .now() + 1), .success) + sut.pause() + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + + XCTAssertFalse(sut.isRunning) + XCTAssertEqual(captureScheduler.startedTokens.count, startCount) + } func testFilterCloseNavigationBreadcrumbs() { let fixture = Fixture() @@ -450,9 +956,9 @@ class SentrySessionReplayTests: XCTestCase { .navigation(screen: "Another Screen", date: startEvent.addingTimeInterval(0.16)) // This should not filter out ] - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let event = Event(error: NSError(domain: "Some error", code: 1)) sut.captureReplayFor(event: event) @@ -472,22 +978,22 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) //Starting session replay at time 0 - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() //Advancing one second and capturing another frame fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() //Advancing 5 more second to complete one segment fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() - let endOfFirstSegment = fixture.dateProvider.date() + let endOfFirstSegment = TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(5) //Advancing 2 seconds to start another segment at second 7 //This means session replay didnt capture screens between seconds 5 and 7 fixture.dateProvider.advance(by: 2) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let expect = expectation(description: "Touch Tracker called") touchTracker.replayEventsCallback = { begin, end in @@ -495,16 +1001,13 @@ class SentrySessionReplayTests: XCTestCase { // we should capture all touch events since the end of the first segment. XCTAssertEqual(begin, endOfFirstSegment) - XCTAssertEqual(end, fixture.dateProvider.date()) + XCTAssertEqual(end, endOfFirstSegment.addingTimeInterval(5)) expect.fulfill() } - // This will make the mock videoInfo starts at second 7 as well - fixture.replayMaker.overrideBeginning = Date(timeIntervalSinceReferenceDate: 7) - //Advancing another 5 seconds to close the second segment fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() wait(for: [expect], timeout: 1) } @@ -516,9 +1019,9 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let breadCrumbRREvents = fixture.lastReplayRecording?.events.compactMap({ $0 as? SentryRRWebOptionsEvent }) ?? [] XCTAssertEqual(breadCrumbRREvents.count, 1) @@ -548,9 +1051,9 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let breadCrumbRREvents = fixture.lastReplayRecording?.events.compactMap({ $0 as? SentryRRWebOptionsEvent }) ?? [] XCTAssertEqual(breadCrumbRREvents.count, 1) @@ -573,9 +1076,9 @@ class SentrySessionReplayTests: XCTestCase { sut.start(rootView: fixture.rootView, fullSession: true) sut.replayTags = ["SomeOption": "SomeValue", "AnotherOption": "AnotherValue"] fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let breadCrumbRREvents = fixture.lastReplayRecording?.events.compactMap({ $0 as? SentryRRWebOptionsEvent }) ?? [] XCTAssertEqual(breadCrumbRREvents.count, 1) @@ -596,29 +1099,73 @@ class SentrySessionReplayTests: XCTestCase { // First Segment fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() // Second Segment fixture.dateProvider.advance(by: 1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() fixture.dateProvider.advance(by: 5) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() let breadCrumbRREvents = fixture.lastReplayRecording?.events.compactMap({ $0 as? SentryRRWebOptionsEvent }) ?? [] XCTAssertEqual(breadCrumbRREvents.count, 0) } @available(iOS 16.0, tvOS 16, *) - func testDealloc_CallsStop() { + func testDealloc_DoesNotRetainStartedSessionReplay() { let fixture = Fixture() - func sutIsDeallocatedAfterCallingMe() { - _ = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + + weak var weakSut: SentrySessionReplay? + autoreleasepool { + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + weakSut = sut + sut.start(rootView: fixture.rootView, fullSession: true) + XCTAssertTrue(sut.isRunning) } - sutIsDeallocatedAfterCallingMe() - - XCTAssertEqual(fixture.displayLink.invalidateInvocations.count, 1) + + XCTAssertNil(weakSut) + } + + @available(iOS 16.0, tvOS 16, *) + func testDealloc_DoesNotRetainSessionReplayDuringAsyncScreenshot() { + let fixture = Fixture() + fixture.screenshotProvider.completeAsync = true + + weak var weakSut: SentrySessionReplay? + autoreleasepool { + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + weakSut = sut + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + } + + XCTAssertNil(weakSut) + fixture.screenshotProvider.completePendingImage() + XCTAssertNil(weakSut) + } + + @available(iOS 16.0, tvOS 16, *) + func testDealloc_DoesNotRetainSessionReplayDuringVideoCreation() throws { + let fixture = Fixture() + fixture.replayMaker.deferCreateVideoCompletion = true + + weak var weakSut: SentrySessionReplay? + autoreleasepool { + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + weakSut = sut + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + XCTAssertNotNil(fixture.replayMaker.lastCallToCreateVideo) + } + + XCTAssertNil(weakSut) } // MARK: - Frame Rate Tests @@ -635,12 +1182,12 @@ class SentrySessionReplayTests: XCTestCase { // Act & Assert - advance by 0.9 seconds, screenshot should NOT be taken fixture.dateProvider.advance(by: 0.9) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 1 second interval") // Act & Assert - advance to exactly 1.0 seconds, screenshot SHOULD be taken fixture.dateProvider.advance(by: 0.1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 1 second interval for 1 FPS") } @@ -656,22 +1203,22 @@ class SentrySessionReplayTests: XCTestCase { // Act & Assert - advance by 0.4 seconds, screenshot should NOT be taken fixture.dateProvider.advance(by: 0.4) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 0.5 second interval") // Act & Assert - advance to 0.5 seconds, screenshot SHOULD be taken fixture.dateProvider.advance(by: 0.1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 0.5 second interval for 2 FPS") // Act & Assert - reset and test second screenshot fixture.screenshotProvider.lastImageCall = nil fixture.dateProvider.advance(by: 0.4) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before another 0.5 seconds") fixture.dateProvider.advance(by: 0.1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at next 0.5 second interval") } @@ -686,19 +1233,19 @@ class SentrySessionReplayTests: XCTestCase { // Expected interval: 1.0 / 10.0 = 0.1 seconds // Take first screenshot to establish baseline fixture.dateProvider.advance(by: 0.1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "First screenshot should be taken") fixture.screenshotProvider.lastImageCall = nil // Act & Assert - advance by 0.09 seconds, screenshot should NOT be taken fixture.dateProvider.advance(by: 0.09) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before 0.1 second interval") // Act & Assert - advance to reach 0.1 second interval, screenshot SHOULD be taken fixture.dateProvider.advance(by: 0.01) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 0.1 second interval for 10 FPS") } @@ -718,7 +1265,7 @@ class SentrySessionReplayTests: XCTestCase { for i in 0..<5 { // Advance by full interval fixture.dateProvider.advance(by: 0.2) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot #\(i + 1) should be taken at \(Double(i + 1) * 0.2) seconds") screenshotCount += 1 @@ -727,7 +1274,7 @@ class SentrySessionReplayTests: XCTestCase { // Advance by less than interval and verify no screenshot if i < 4 { // Don't test after the last screenshot fixture.dateProvider.advance(by: 0.1) - Dynamic(sut).newFrame(nil) + fixture.runLoopCapture() XCTAssertNil(fixture.screenshotProvider.lastImageCall, "No screenshot should be taken at \(Double(i + 1) * 0.2 + 0.1) seconds") } } @@ -735,8 +1282,312 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertEqual(screenshotCount, 5, "Should have taken exactly 5 screenshots in 1 second for 5 FPS") } + func testNewFrame_whenCaptureIntervalNotReached_shouldNotScanViewHierarchy() { + // -- Arrange -- + let fixture = Fixture() + let scrollView = CountingScrollView(frame: fixture.rootView.bounds) + fixture.rootView.addSubview(scrollView) + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + scrollView.interactionStateReadCount = 0 + + // -- Act -- + fixture.dateProvider.advance(by: 0.5) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(scrollView.interactionStateReadCount, 0) + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 0) + + fixture.dateProvider.advance(by: 0.5) + fixture.runLoopCapture() + + XCTAssertGreaterThan(scrollView.interactionStateReadCount, 0) + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + } + + func testNewFrame_whenScreenshotCaptureIsSlow_shouldBackOffCaptureInterval() { + // -- Arrange -- + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + fixture.screenshotProvider.beforeComplete = { + fixture.dateProvider.advance(by: 0.06) + } + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + let capturesAfterSlowFrame = fixture.screenshotProvider.imageCallCount + fixture.screenshotProvider.beforeComplete = nil + + fixture.dateProvider.advance(by: 1.99) + fixture.runLoopCapture() + let capturesBeforeBackoffExpires = fixture.screenshotProvider.imageCallCount + + fixture.dateProvider.advance(by: 3.1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(capturesAfterSlowFrame, 1) + XCTAssertEqual(capturesBeforeBackoffExpires, 1) + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testNewFrame_whenAsyncScreenshotCaptureIsSlow_shouldBackOffCaptureInterval() { + // -- Arrange -- + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + fixture.screenshotProvider.completeAsync = true + fixture.screenshotProvider.beforeComplete = { + fixture.dateProvider.advance(by: 0.06) + } + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + fixture.screenshotProvider.completePendingImage() + let capturesAfterSlowFrame = fixture.screenshotProvider.imageCallCount + fixture.screenshotProvider.completeAsync = false + fixture.screenshotProvider.beforeComplete = nil + + fixture.dateProvider.advance(by: 1.99) + fixture.runLoopCapture() + let capturesBeforeBackoffExpires = fixture.screenshotProvider.imageCallCount + + fixture.dateProvider.advance(by: 0.02) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(capturesAfterSlowFrame, 1) + XCTAssertEqual(capturesBeforeBackoffExpires, 1) + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testResume_whenCaptureBackedOff_shouldResetCaptureInterval() { + // -- Arrange -- + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + fixture.screenshotProvider.beforeComplete = { + fixture.dateProvider.advance(by: 0.06) + } + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + fixture.screenshotProvider.beforeComplete = nil + + // -- Act -- + sut.pause() + sut.resume() + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testNewFrame_whenScrollViewIsDragging_shouldCaptureAtFrameRate() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + let scrollView = DraggingScrollView(frame: fixture.rootView.bounds) + fixture.rootView.addSubview(scrollView) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + fixture.dateProvider.advance(by: 0.5) + fixture.runLoopCapture() + + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + fixture.dateProvider.advance(by: 0.51) + fixture.runLoopCapture() + + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testNewFrame_whenInteractionCaptureIsSlow_shouldNotBackOffCaptureInterval() { + // -- Arrange -- + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + fixture.screenshotProvider.beforeComplete = { + fixture.dateProvider.advance(by: 0.06) + } + let sut = fixture.getSut(options: options) + let scrollView = DraggingScrollView(frame: fixture.rootView.bounds) + fixture.rootView.addSubview(scrollView) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + let capturesAfterSlowInteractionFrame = fixture.screenshotProvider.imageCallCount + fixture.screenshotProvider.beforeComplete = nil + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(capturesAfterSlowInteractionFrame, 1) + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testNewFrame_whenInteractionStartsDuringAdaptiveBackoff_shouldCaptureAtFrameRate() { + // -- Arrange -- + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + fixture.screenshotProvider.beforeComplete = { + fixture.dateProvider.advance(by: 0.06) + } + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + fixture.screenshotProvider.beforeComplete = nil + + let scrollView = DraggingScrollView(frame: fixture.rootView.bounds) + fixture.rootView.addSubview(scrollView) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 2) + } + + func testNewFrame_whenScreenshotDeferredPastSegmentDuration_shouldPrepareSegment() throws { + // -- Arrange -- + let fixture = Fixture() + let replayOptions = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + replayOptions.frameRate = 1 + let sut = fixture.getSut(options: replayOptions) + let scrollView = DraggingScrollView(frame: fixture.rootView.bounds) + fixture.rootView.addSubview(scrollView) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 6) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + + let createVideoCall = try XCTUnwrap(fixture.replayMaker.lastCallToCreateVideo) + XCTAssertEqual(createVideoCall.beginning, TestCurrentDateProvider.defaultStartingDate) + XCTAssertEqual(createVideoCall.end, TestCurrentDateProvider.defaultStartingDate.addingTimeInterval(5)) + + let recording = try XCTUnwrap(fixture.lastReplayRecording) + XCTAssertEqual(recording.segmentId, 0) + } + + func testNewFrame_whenViewHasManyActiveAnimations_shouldDeferScreenshotTemporarily() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + for index in 0..<4 { + let animatedView = UIView(frame: CGRect(x: index, y: index, width: 1, height: 1)) + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 0 + animation.toValue = 1 + animation.duration = 1 + animatedView.layer.add(animation, forKey: "opacity") + fixture.rootView.addSubview(animatedView) + } + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 0) + + fixture.dateProvider.advance(by: 1.01) + fixture.runLoopCapture() + + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + } + + func testNewFrame_whenAnimationDefersScreenshot_shouldNotThrottleFollowingInteractionCapture() { + // -- Arrange -- + let fixture = Fixture() + let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1)) + sut.start(rootView: fixture.rootView, fullSession: true) + + for index in 0..<4 { + let animatedView = UIView(frame: CGRect(x: index, y: index, width: 1, height: 1)) + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 0 + animation.toValue = 1 + animation.duration = 1 + animatedView.layer.add(animation, forKey: "opacity") + fixture.rootView.addSubview(animatedView) + } + + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Act -- + fixture.dateProvider.advance(by: 0.1) + fixture.runLoopCapture(isInteractiveRunLoopMode: true) + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + } + + func testNewFrame_whenActivityIsInExcludedSubview_shouldCapture() { + // -- Arrange -- + let fixture = Fixture() + let replayOptions = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + replayOptions.excludedViewClasses = ["ExcludedActivityView"] + let sut = fixture.getSut(options: replayOptions) + + let excludedView = ExcludedActivityView(frame: fixture.rootView.bounds) + let scrollView = CountingDraggingScrollView(frame: excludedView.bounds) + excludedView.addSubview(scrollView) + fixture.rootView.addSubview(excludedView) + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Act -- + fixture.dateProvider.advance(by: 1) + fixture.runLoopCapture() + + // -- Assert -- + XCTAssertEqual(fixture.screenshotProvider.imageCallCount, 1) + XCTAssertEqual(scrollView.interactionStateReadCount, 0) + } + // MARK: - Helpers + private func runRunLoop(mode: RunLoop.Mode, file: StaticString = #filePath, line: UInt = #line) { + var didRun = false + let timer = Timer(timeInterval: 0.01, repeats: false) { _ in + didRun = true + } + RunLoop.current.add(timer, forMode: mode) + RunLoop.current.run(mode: mode, before: Date(timeIntervalSinceNow: 0.1)) + XCTAssertTrue(didRun, "Expected run loop to process timer", file: file, line: line) + } + private func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) { XCTAssertEqual(sessionReplay.isFullSession, expected) } diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 1b744a282e1..c971e2c592d 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -1772,7 +1772,7 @@ class SentryHubTests: XCTestCase { touchTracker: nil, dateProvider: TestCurrentDateProvider(), delegate: MockReplayDelegate(), - displayLinkWrapper: TestDisplayLinkWrapper() + captureScheduler: DefaultSentrySessionReplayRunLoopCaptureScheduler() ) } }