Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d30d4cf
ExportDiffManager
abelonogov-ld Feb 22, 2026
297ede9
visible working compression before buffering
abelonogov-ld Feb 23, 2026
1f4d2c2
test script for iOS
abelonogov-ld Feb 23, 2026
ba94817
all tests fixed and 0 warnings
abelonogov-ld Feb 23, 2026
4fcdf40
Enhance RRWebEventGenerator with keyframe handling and new test for c…
abelonogov-ld Feb 23, 2026
7db0115
Enhance test script with improved options and usage documentation
abelonogov-ld Feb 23, 2026
61a0aa0
rename file
abelonogov-ld Feb 23, 2026
af82a03
fix test script
abelonogov-ld Feb 23, 2026
4e5497f
remove dead code
abelonogov-ld Feb 23, 2026
038616c
benchmark files
abelonogov-ld Feb 23, 2026
97a37e4
Enhance Benchmark functionality by adding execution time tracking and…
abelonogov-ld Feb 23, 2026
e62e2f2
adjust compression parameters
abelonogov-ld Feb 23, 2026
c0a4fe9
wip
abelonogov-ld Feb 23, 2026
13798ea
wip
abelonogov-ld Feb 23, 2026
9668c70
Merge branch 'main' into andrey/exportdiffmanager
abelonogov-ld Feb 24, 2026
028123e
Refactor ExportFrame to use ImageSignature instead of TileSignature
abelonogov-ld Feb 24, 2026
a0e951a
expand unit tests
abelonogov-ld Feb 28, 2026
8ea1c7b
do account disk reading into benchmark
abelonogov-ld Mar 2, 2026
642bea6
Alert errors
abelonogov-ld Mar 2, 2026
ad4ddc3
separated time
abelonogov-ld Mar 2, 2026
0fe4bdf
Merge branch 'main' into andrey/exportdiffmanager
abelonogov-ld Mar 4, 2026
423dd7a
Clear node IDs before generating full snapshot data in RRWebEventGene…
abelonogov-ld Mar 4, 2026
2f811e4
Merge branch 'andrey/exportdiffmanager' of github.com:launchdarkly/sw…
abelonogov-ld Mar 4, 2026
c67581b
Remove unused eventNode function from ExportFrame struct to streamlin…
abelonogov-ld Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public struct SessionReplayOptions {

public enum CompressionMethod {
case screenImage
case overlayTiles(layers: Int = 10)
case overlayTiles(layers: Int = 10, backtracking: Bool = true)
}

public var isEnabled: Bool
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#if canImport(UIKit)

import Foundation
import OSLog
import LaunchDarklyObservability

public final class BenchmarkExecutor {
public typealias CompressionResult = (
compression: SessionReplayOptions.CompressionMethod,
bytes: Int,
captureTime: TimeInterval,
totalTime: TimeInterval
)

private static let compressionMethods: [SessionReplayOptions.CompressionMethod] = [
.screenImage,
.overlayTiles(layers: 15, backtracking: false),
.overlayTiles(layers: 15, backtracking: true),
]

public init() {}

public func compression(framesDirectory: URL, runs: Int = 1) async throws -> [CompressionResult] {
let reader = try RawFrameReader(directory: framesDirectory)
let frames = Array(reader)

var results = [CompressionResult]()
let runCount = max(1, runs)

for method in Self.compressionMethods {
var bytes = 0
var captureTime: TimeInterval = 0
var totalTime: TimeInterval = 0

for _ in 0..<runCount {
let runResult = await runCompression(method, frames: frames)
bytes = runResult.bytes
captureTime += runResult.captureTime
totalTime += runResult.totalTime
}

results.append((compression: method, bytes: bytes, captureTime: captureTime, totalTime: totalTime))
}

return results
}

private func runCompression(_ method: SessionReplayOptions.CompressionMethod, frames: [RawFrame]) async -> (bytes: Int, captureTime: TimeInterval, totalTime: TimeInterval) {
let exportDiffManager = ExportDiffManager(compression: method, scale: 1.0)
let eventGenerator = RRWebEventGenerator(log: OSLog.default, title: "Benchmark", method: method)
let encoder = JSONEncoder()
var bytes = 0
var captureTime: TimeInterval = 0

let start = CFAbsoluteTimeGetCurrent()

for frame in frames {
let captureStart = CFAbsoluteTimeGetCurrent()
guard let exportFrame = exportDiffManager.exportFrame(from: frame, onTiledFrameComputed: {
captureTime += CFAbsoluteTimeGetCurrent() - captureStart
}) else {
continue
}

let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame))
let events = await eventGenerator.generateEvents(items: [item])

if let data = try? encoder.encode(events) {
bytes += data.count
}
}

return (bytes: bytes, captureTime: captureTime, totalTime: CFAbsoluteTimeGetCurrent() - start)
}
}

#endif
166 changes: 166 additions & 0 deletions Sources/LaunchDarklySessionReplay/BenchMark/RawFrameIO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#if canImport(UIKit)

import UIKit
import Foundation

final class RawFrameWriter {
let directory: URL
private var frameIndex: Int = 0
private var imageIndex: Int = 0
private var lastImageData: Data?
private let csvHandle: FileHandle

init() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("RawFrames-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.directory = dir
print("RawFrameWriter: directory = \(dir)")
let csvURL = dir.appendingPathComponent("frames.csv")
FileManager.default.createFile(atPath: csvURL.path, contents: nil)
self.csvHandle = try FileHandle(forWritingTo: csvURL)

let header = "frameIndex,imageIndex,timestamp,orientation,areas\n"
csvHandle.write(Data(header.utf8))
}

deinit {
try? csvHandle.close()
}

func write(rawFrame: RawFrame) throws {
let index = frameIndex
frameIndex += 1

guard let pngData = rawFrame.image.pngData() else {
throw CocoaError(.fileWriteUnknown)
}

let currentImageIndex: Int
if pngData == lastImageData {
currentImageIndex = imageIndex - 1
} else {
currentImageIndex = imageIndex
let imageURL = directory.appendingPathComponent(String(format: "%06d.png", currentImageIndex))
try pngData.write(to: imageURL)
lastImageData = pngData
imageIndex += 1
}

let areasArray: [[String: [String: CGFloat]]] = rawFrame.areas.map { area in
[
"rect": [
"x": area.rect.origin.x,
"y": area.rect.origin.y,
"width": area.rect.size.width,
"height": area.rect.size.height
],
"offset": [
"x": area.offset.x,
"y": area.offset.y
]
]
}
let areasData = try JSONSerialization.data(withJSONObject: areasArray)
let areasJSON = String(data: areasData, encoding: .utf8) ?? "[]"
let escapedAreas = areasJSON.replacingOccurrences(of: "\"", with: "\"\"")

let row = "\(index),\(currentImageIndex),\(rawFrame.timestamp),\(rawFrame.orientation),\"\(escapedAreas)\"\n"
csvHandle.write(Data(row.utf8))
}
}

// MARK: - RawFrameReader

final class RawFrameReader: Sequence {
private let directory: URL
private let rows: [String]

init(directory: URL) throws {
self.directory = directory
let csvURL = directory.appendingPathComponent("frames.csv")
let content = try String(contentsOf: csvURL, encoding: .utf8)
self.rows = content.components(separatedBy: "\n")
.dropFirst()
.filter { !$0.isEmpty }
}

func makeIterator() -> Iterator {
Iterator(directory: directory, rows: rows)
}

struct Iterator: IteratorProtocol {
private let directory: URL
private let rows: [String]
private var index = 0
private var imageCache = [Int: UIImage]()

init(directory: URL, rows: [String]) {
self.directory = directory
self.rows = rows
}

mutating func next() -> RawFrame? {
guard index < rows.count else { return nil }
defer { index += 1 }
return parse(line: rows[index])
}

private mutating func parse(line: String) -> RawFrame? {
let columns = Self.parseCSV(line: line)
guard columns.count >= 5,
let imageIndex = Int(columns[1]),
let timestamp = TimeInterval(columns[2]),
let orientation = Int(columns[3])
else { return nil }

let image: UIImage
if let cached = imageCache[imageIndex] {
image = cached
} else {
let imageURL = directory.appendingPathComponent(String(format: "%06d.png", imageIndex))
guard let loaded = UIImage(contentsOfFile: imageURL.path) else { return nil }
imageCache[imageIndex] = loaded
image = loaded
}

let areasJSON = columns[4].replacingOccurrences(of: "\"\"", with: "\"")
var areas = [OffsettedArea]()
if let data = areasJSON.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: [String: CGFloat]]] {
for dict in array {
guard let r = dict["rect"], let o = dict["offset"],
let x = r["x"], let y = r["y"], let w = r["width"], let h = r["height"],
let ox = o["x"], let oy = o["y"]
else { continue }
areas.append(OffsettedArea(
rect: CGRect(x: x, y: y, width: w, height: h),
offset: CGPoint(x: ox, y: oy)
))
}
}

return RawFrame(image: image, timestamp: timestamp, orientation: orientation, areas: areas)
}

private static func parseCSV(line: String) -> [String] {
var result = [String]()
var current = ""
var inQuotes = false
for ch in line {
if ch == "\"" {
inQuotes.toggle()
} else if ch == "," && !inQuotes {
result.append(current)
current = ""
} else {
current.append(ch)
}
}
result.append(current)
return result
}
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ extension IdentifyItemPayload {
}
}

var canonicalKey = ldContext?.fullyQualifiedKey() ?? "unknown"
var ldContextMap = ldContext?.contextKeys()
let canonicalKey = ldContext?.fullyQualifiedKey() ?? "unknown"
let ldContextMap = ldContext?.contextKeys()
if let ldContextMap {
for (k, v) in ldContextMap {
attributes[k] = v
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct ImageItemPayload: EventQueueItemPayload {
}

func cost() -> Int {
exportFrame.images.reduce(0) { $0 + $1.data.count }
exportFrame.addImages.reduce(0) { $0 + $1.data.count }
}

let exportFrame: ExportFrame
Expand Down
Loading