Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Changelog

## Unreleased

> [!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.

### 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)

## 9.18.0

### Features
Expand Down
102 changes: 85 additions & 17 deletions Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import CoreMedia
import Foundation
import UIKit

func removeReplayFile(at fileURL: URL) {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }

do {
try FileManager.default.removeItem(at: fileURL)
SentrySDKLog.debug("[Session Replay] Removed replay file at: \(fileURL.path)")
} catch {
SentrySDKLog.warning("[Session Replay] Could not delete replay file at: \(fileURL.path), reason: \(error)")
}
}

// swiftlint:disable type_body_length
@objcMembers
@_spi(Private) public class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker {
Expand All @@ -18,6 +29,10 @@ import UIKit
private let processingQueue: SentryDispatchQueueWrapper
private let assetWorkerQueue: SentryDispatchQueueWrapper
private var _frames = [SentryReplayFrame]()
/// Guards `retainedFrameBeforeCurrentFrames`. All other accesses to it happen on
/// `processingQueue`; the lock only exists because `deinit` can run on any thread.
private let retainedFrameLock = NSLock()
private var retainedFrameBeforeCurrentFrames: SentryReplayFrame?

#if SENTRY_TEST || SENTRY_TEST_CI || DEBUG
//This is exposed only for tests, no need to make it thread safe.
Expand All @@ -41,6 +56,19 @@ import UIKit
self.processingQueue = processingQueue
self.assetWorkerQueue = assetWorkerQueue
}

deinit {
// Clean shutdown removes the retained file. If the app crashes, the file stays
// in the replay folder and is loaded during crash recovery on the next launch.
let retainedFrame = retainedFrameLock.synchronized {
Comment thread
philprime marked this conversation as resolved.
let frame = retainedFrameBeforeCurrentFrames
retainedFrameBeforeCurrentFrames = nil
return frame
}
if let retainedFrame = retainedFrame {
removeReplayFile(at: URL(fileURLWithPath: retainedFrame.imagePath))
}
}

public convenience init(
withContentFrom outputPath: String,
Expand Down Expand Up @@ -130,20 +158,26 @@ import UIKit
SentrySDKLog.debug("[Session Replay] Releasing frames until date: \(date)")
while let first = self._frames.first, first.time < date {
self._frames.removeFirst()
let fileUrl = URL(fileURLWithPath: first.imagePath)
do {
try FileManager.default.removeItem(at: fileUrl)
SentrySDKLog.debug("[Session Replay] Removed frame at url: \(fileUrl.path)")
} catch {
SentrySDKLog.error("[Session Replay] Failed to remove frame at: \(fileUrl.path), reason: \(error), ignoring error")
// Retain the released frame so the next segment can still render the screen
// state at its window start; delete the previously retained frame's file once
// it is replaced.
let frameToRemove = self.retainedFrameLock.synchronized { () -> SentryReplayFrame? in
let previousFrame = self.retainedFrameBeforeCurrentFrames
self.retainedFrameBeforeCurrentFrames = first
guard previousFrame?.imagePath != first.imagePath else { return nil }
return previousFrame
}
if let frameToRemove = frameToRemove {
removeReplayFile(at: URL(fileURLWithPath: frameToRemove.imagePath))
}
}
SentrySDKLog.debug("[Session Replay] Frames released, remaining frames count: \(self._frames.count)")
}
}

public var oldestFrameDate: Date? {
return _frames.first?.time
/// Used by replay recovery after `init(withContentFrom:)` loaded surviving frames from disk.
var oldestRecoveredFrameDate: Date? {
_frames.first?.time
}

public func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([SentryVideoInfo]) -> Void) {
Expand All @@ -162,7 +196,23 @@ import UIKit

// Note: In previous implementations this method was wrapped by a sync call to the processing queue.
// As this method is already called from the processing queue, we must remove the sync call.
let videoFrames = self._frames.filter { $0.time >= beginning && $0.time <= end }
guard end > beginning else { return [] }

// Select the frames in the half-open window [beginning, end). When captures were skipped,
// hold the last frame captured before the window at the window start so the segment still
// begins with the correct screen state.
var videoFrames = _frames.filter { $0.time >= beginning && $0.time < end }
if let firstFrame = videoFrames.first {
if firstFrame.time > beginning {
let frameToHold = frameBefore(beginning) ?? firstFrame
videoFrames.insert(SentryReplayFrame(imagePath: frameToHold.imagePath, time: beginning, screenName: frameToHold.screenName), at: 0)
}
} else if let previousFrame = frameBefore(beginning) {
videoFrames = [SentryReplayFrame(imagePath: previousFrame.imagePath, time: beginning, screenName: previousFrame.screenName)]
} else {
return []
}

var frameCount = 0

var videos = [SentryVideoInfo]()
Expand All @@ -177,7 +227,7 @@ import UIKit
var currentError: Error?

group.enter()
self.renderVideo(with: videoFrames, from: frameCount, at: outputFileURL) { result in
self.renderVideo(with: videoFrames, fromIndex: frameCount, until: end, at: outputFileURL) { result in
switch result {
case .success(let videoResult):
// Set the frame count/offset to the new index that is returned by the completion block.
Expand Down Expand Up @@ -221,16 +271,33 @@ import UIKit
return videos
}

private func frameBefore(_ date: Date) -> SentryReplayFrame? {
let retainedFrame = retainedFrameLock.synchronized {
retainedFrameBeforeCurrentFrames
}.flatMap { $0.time < date ? $0 : nil }
let currentFrame = _frames.last(where: { $0.time < date })

guard let retained = retainedFrame else { return currentFrame }
guard let current = currentFrame else { return retained }
return retained.time > current.time ? retained : current
}

// swiftlint:disable function_body_length cyclomatic_complexity
private func renderVideo(with videoFrames: [SentryReplayFrame], from: Int, at outputFileURL: URL, completion: @escaping (Result<SentryRenderVideoResult, Error>) -> Void) {
SentrySDKLog.debug("[Session Replay] Rendering video with \(videoFrames.count) frames, from index: \(from), to output url: \(outputFileURL)")
private func renderVideo(
with videoFrames: [SentryReplayFrame],
fromIndex: Int,
until videoEnd: Date,
at outputFileURL: URL,
completion: @escaping (Result<SentryRenderVideoResult, Error>) -> Void
) {
SentrySDKLog.debug("[Session Replay] Rendering video with \(videoFrames.count) frames, from index: \(fromIndex), to output url: \(outputFileURL)")

guard from < videoFrames.count else {
guard fromIndex < videoFrames.count else {
SentrySDKLog.error("[Session Replay] Failed to render video, reason: index out of bounds")
return completion(.failure(SentryOnDemandReplayError.indexOutOfBounds))
}
guard let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else {
SentrySDKLog.error("[Session Replay] Failed to render video, reason: can't read image at path: \(videoFrames[from].imagePath)")
guard let image = UIImage(contentsOfFile: videoFrames[fromIndex].imagePath) else {
SentrySDKLog.error("[Session Replay] Failed to render video, reason: can't read image at path: \(videoFrames[fromIndex].imagePath)")
return completion(.failure(SentryOnDemandReplayError.cantReadImage))
}

Expand Down Expand Up @@ -265,8 +332,9 @@ import UIKit
videoHeight: videoHeight,
videoWidth: videoWidth,
frameRate: frameRate,
initialFrameIndex: from,
initialImageSize: image.size
initialFrameIndex: fromIndex,
initialImageSize: image.size,
videoEnd: videoEnd
)

// Append frames to the video writer input in a pull-style manner when the input is ready to receive more media data.
Expand Down
Loading
Loading