From 2590326f7168a3e20d4b2f13cb63bab60083e8c6 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 31 Oct 2025 17:50:50 +0100 Subject: [PATCH 1/2] fix(session-replay): Fix conversion of frame rate to time interval --- .../Swift/Integrations/SessionReplay/SentrySessionReplay.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 1bd84ba955d..484048acf0b 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -233,7 +233,7 @@ import UIKit return } - if now.timeIntervalSince(lastScreenShot) >= Double(1 / replayOptions.frameRate) { + if now.timeIntervalSince(lastScreenShot) >= 1.0 / Double(replayOptions.frameRate) { takeScreenshot() self.lastScreenShot = now From 812dd9683614fa37591d64ab5eed69f1048ca5bf Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 3 Nov 2025 10:11:33 +0100 Subject: [PATCH 2/2] add changelog entry and tests --- CHANGELOG.md | 1 + .../SentrySessionReplayTests.swift | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 731725c477e..0321d642083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ - Add layer class filtering for views used in multiple contexts (e.g., SwiftUI._UIGraphicsView) - Improve transform calculations for views with custom anchor points - Fix axis-aligned transform detection for optimized opaque view clipping +- Fix conversion of frame rate to time interval for session replay (#6623) ### Improvements diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index a8d5eb0ad64..0c3519175bd 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -577,6 +577,120 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertTrue(SentrySessionReplay.shouldEnableSessionReplay(environmentChecker: environmentChecker, experimentalOptions: experimentalOptions)) } + // MARK: - Frame Rate Tests + + func testFrameRate_1FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 1 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.screenshotProvider.lastImageCall = nil + + // Act & Assert - advance by 0.9 seconds, screenshot should NOT be taken + fixture.dateProvider.advance(by: 0.9) + Dynamic(sut).newFrame(nil) + 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) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 1 second interval for 1 FPS") + } + + func testFrameRate_2FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 2 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + fixture.screenshotProvider.lastImageCall = nil + + // Act & Assert - advance by 0.4 seconds, screenshot should NOT be taken + fixture.dateProvider.advance(by: 0.4) + Dynamic(sut).newFrame(nil) + 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) + 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) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "Screenshot should not be taken before another 0.5 seconds") + + fixture.dateProvider.advance(by: 0.1) + Dynamic(sut).newFrame(nil) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at next 0.5 second interval") + } + + func testFrameRate_10FPS_takesScreenshotsAtCorrectInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 10 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // 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) + 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) + 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) + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot should be taken at 0.1 second interval for 10 FPS") + } + + func testFrameRate_multipleScreenshots_respectsInterval() { + // Arrange + let fixture = Fixture() + let options = SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1) + options.frameRate = 5 + let sut = fixture.getSut(options: options) + sut.start(rootView: fixture.rootView, fullSession: true) + + // Expected interval: 1.0 / 5.0 = 0.2 seconds + var screenshotCount = 0 + + // Act & Assert - take 5 screenshots over 1 second + // Each screenshot resets the timer, so we need to advance by the full interval each time + for i in 0..<5 { + // Advance by full interval + fixture.dateProvider.advance(by: 0.2) + Dynamic(sut).newFrame(nil) + + XCTAssertNotNil(fixture.screenshotProvider.lastImageCall, "Screenshot #\(i + 1) should be taken at \(Double(i + 1) * 0.2) seconds") + screenshotCount += 1 + fixture.screenshotProvider.lastImageCall = nil + + // 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) + XCTAssertNil(fixture.screenshotProvider.lastImageCall, "No screenshot should be taken at \(Double(i + 1) * 0.2 + 0.1) seconds") + } + } + + XCTAssertEqual(screenshotCount, 5, "Should have taken exactly 5 screenshots in 1 second for 5 FPS") + } + // MARK: - Helpers private func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) {