Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2895563
remove after buffer image comparisons (+4 squashed commits)
abelonogov-ld Jan 5, 2026
bc3df48
fix concurrency issues
abelonogov-ld Jan 5, 2026
c21e76d
sending just rect (+3 squashed commits)
abelonogov-ld Jan 5, 2026
29fcaae
Merge branch 'andrey/send-changed-rectangles' into andrey/tile-compre…
abelonogov-ld Feb 5, 2026
7bf2142
more
abelonogov-ld Feb 5, 2026
a342038
small fixes
abelonogov-ld Feb 5, 2026
b7d50e2
move check
abelonogov-ld Feb 5, 2026
c78596b
works with blinking
abelonogov-ld Feb 5, 2026
e119e60
still blinks
abelonogov-ld Feb 6, 2026
7183c5e
renaming
abelonogov-ld Feb 15, 2026
07b0fa7
renaming stuff
abelonogov-ld Feb 15, 2026
a394026
no back glitch
abelonogov-ld Feb 15, 2026
2086a4d
renaming
abelonogov-ld Feb 16, 2026
aafeb93
TiledDiffManager
abelonogov-ld Feb 16, 2026
7fe37d0
CaptureManager rename
abelonogov-ld Feb 16, 2026
52d1ec1
more renaming
abelonogov-ld Feb 16, 2026
b124ed0
fix overflow
abelonogov-ld Feb 16, 2026
2fca24c
adjustable tile sizes
abelonogov-ld Feb 16, 2026
140ee73
old comments
abelonogov-ld Feb 16, 2026
222600d
fix tile signature matching
abelonogov-ld Feb 16, 2026
cff72e6
first shippable version
abelonogov-ld Feb 16, 2026
1364793
old code
abelonogov-ld Feb 16, 2026
deaed12
with-removal
abelonogov-ld Feb 17, 2026
a266ef3
fix removal algo
abelonogov-ld Feb 17, 2026
9b4d363
rename to compression property and make option turn off able
abelonogov-ld Feb 17, 2026
6f8dd6a
address feadback
abelonogov-ld Feb 17, 2026
54fcb41
renaming
abelonogov-ld Feb 17, 2026
8888ab1
Merge branch 'main' into andrey/tile-removals1
abelonogov-ld Feb 17, 2026
ca9a893
fix handling new keyFrame
abelonogov-ld Feb 17, 2026
135e672
introduce constants
abelonogov-ld Feb 17, 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 @@ -44,18 +44,26 @@ public struct SessionReplayOptions {
}
}

public enum CompressionMethod {
case screenImage
case overlayTiles(layers: Int = 10)
}

public var isEnabled: Bool
public var compression: CompressionMethod = .overlayTiles()
public var serviceName: String
public var privacy = PrivacyOptions()
public var log: OSLog

public init(isEnabled: Bool = true,
serviceName: String = "sessionreplay-swift",
privacy: PrivacyOptions = PrivacyOptions(),
compression: CompressionMethod = .overlayTiles(),
log: OSLog = OSLog(subsystem: "com.launchdarkly", category: "LaunchDarklySessionReplayPlugin")) {
self.isEnabled = isEnabled
self.serviceName = serviceName
self.privacy = privacy
self.compression = compression
self.log = log
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ struct ImageItemPayload: EventQueueItemPayload {
}

var timestamp: TimeInterval {
exportImage.timestamp
exportFrame.timestamp
}

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

let exportImage: ExportImage
let exportFrame: ExportFrame
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ actor SessionReplayExporter: EventExporting {
private let context: SessionReplayContext
private let sessionManager: SessionManaging
private var isInitializing = false
private var eventGenerator: SessionReplayEventGenerator
private var eventGenerator: RRWebEventGenerator
private var log: OSLog
private var initializedSession: InitializeSessionResponse?
private var sessionInfo: SessionInfo?
Expand All @@ -32,7 +32,7 @@ actor SessionReplayExporter: EventExporting {
self.replayApiService = replayApiService
self.sessionManager = context.observabilityContext.sessionManager
self.title = title
self.eventGenerator = SessionReplayEventGenerator(log: context.log, title: title)
self.eventGenerator = RRWebEventGenerator(log: context.log, title: title)
self.log = context.log
self.sessionInfo = sessionManager.sessionInfo

Expand All @@ -47,7 +47,7 @@ actor SessionReplayExporter: EventExporting {

private func updateSessionInfo(_ sessionInfo: SessionInfo) async {
self.sessionInfo = sessionInfo
self.eventGenerator = SessionReplayEventGenerator(log: log, title: title)
self.eventGenerator = RRWebEventGenerator(log: log, title: title)
self.initializedSession = nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ final class SessionReplayStats {
return Double(images) / elapsedTime
}

func addExportImage(_ exportImage: ExportImage) {
func addExportFrame(_ exportFrame: ExportFrame) {
images += 1
imagesSize += Int64(exportImage.data.count)
firstImageTimestamp = firstImageTimestamp ?? exportImage.timestamp
lastImageTimestamp = exportImage.timestamp
imagesSize += Int64(exportFrame.images.reduce(0) { $0 + $1.data.count })
firstImageTimestamp = firstImageTimestamp ?? exportFrame.timestamp
lastImageTimestamp = exportFrame.timestamp

logIfNeeded()
}
Expand Down
22 changes: 16 additions & 6 deletions Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ struct ClearRect: CommandPayload {

private enum K: String, CodingKey { case property, args }

init(x: Int, y: Int, width: Int, height: Int) {
self.x = x; self.y = y; self.width = width; self.height = height
init(rect: CGRect) {
self.x = Int(rect.minX)
self.y = Int(rect.minY)
self.width = Int(rect.size.width)
self.height = Int(rect.size.height)
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -104,8 +107,12 @@ struct DrawImage: CommandPayload {

private enum K: String, CodingKey { case property, args }

init(image: AnyRRNode, dx: Int, dy: Int, dw: Int, dh: Int) {
self.image = image; self.dx = dx; self.dy = dy; self.dw = dw; self.dh = dh
init(image: AnyRRNode, rect: CGRect) {
self.image = image
self.dx = Int(rect.minX)
self.dy = Int(rect.minY)
self.dw = Int(rect.size.width)
self.dh = Int(rect.size.height)
}

init(from decoder: Decoder) throws {
Expand All @@ -126,8 +133,11 @@ struct DrawImage: CommandPayload {
try a.encode(image)
try a.encode(dx)
try a.encode(dy)
try a.encode(dw)
try a.encode(dh)

if (dx > 0 || dy > 0) {
try a.encode(dw)
try a.encode(dh)
}
}
}

Expand Down
70 changes: 70 additions & 0 deletions Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,76 @@ struct DomData: EventDataProtocol {
}
}

// MARK: - DOM Mutation Data (for incremental DOM updates)

struct MutationData: EventDataProtocol {
var source: IncrementalSource
var adds: [AddedNode]
var removes: [RemovedNode]
var texts: [TextMutation]
var attributes: [AttributeMutation]

// Transitional
var canvasSize: Int

init(adds: [AddedNode] = [],
removes: [RemovedNode] = [],
texts: [TextMutation] = [],
attributes: [AttributeMutation] = [],
canvasSize: Int = 0) {
self.source = .mutation
self.adds = adds
self.removes = removes
self.texts = texts
self.attributes = attributes
self.canvasSize = canvasSize
}

private enum CodingKeys: String, CodingKey {
case source, adds, removes, texts, attributes
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.source = try container.decode(IncrementalSource.self, forKey: .source)
self.adds = try container.decode([AddedNode].self, forKey: .adds)
self.removes = try container.decode([RemovedNode].self, forKey: .removes)
self.texts = try container.decode([TextMutation].self, forKey: .texts)
self.attributes = try container.decode([AttributeMutation].self, forKey: .attributes)
self.canvasSize = 0
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(source, forKey: .source)
try container.encode(adds, forKey: .adds)
try container.encode(removes, forKey: .removes)
try container.encode(texts, forKey: .texts)
try container.encode(attributes, forKey: .attributes)
}
}

struct AddedNode: Codable {
var parentId: Int
var nextId: Int?
var node: EventNode
}

struct RemovedNode: Codable {
var parentId: Int
var id: Int
}

struct TextMutation: Codable {
var id: Int
var value: String
}

struct AttributeMutation: Codable {
var id: Int
var attributes: [String: String?]
}

struct EventNode: Codable {
var type: NodeType
var name: String?
Expand Down
2 changes: 2 additions & 0 deletions Sources/LaunchDarklySessionReplay/RRWeb/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct AnyEventData: Codable {
self.value = try CanvasDrawData(from: decoder)
} else if src == .mouseMove {
self.value = try MouseMoveEventData(from: decoder)
} else if src == .mutation {
self.value = try MutationData(from: decoder)
} else {
self.value = try MouseInteractionData(from: decoder)
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ struct WindowData: EventDataProtocol {
var width: Int?
var height: Int?

init(href: String? = nil, width: Int? = nil, height: Int? = nil) {
init(href: String? = nil, size: CGSize) {
self.href = href
self.width = width
self.height = height
self.width = Int(size.width)
self.height = Int(size.height)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import Combine
import LaunchDarklyObservability
import UIKit

final class SnapshotTaker: EventSource {
private let captureService: ScreenCaptureService
final class CaptureManager: EventSource {
private let captureService: ImageCaptureService
private let tileDiffManager: TileDiffManager
private let appLifecycleManager: AppLifecycleManaging
@MainActor
private var displayLink: CADisplayLink?
Expand All @@ -29,10 +30,12 @@ final class SnapshotTaker: EventSource {
}
}

init(captureService: ScreenCaptureService,
init(captureService: ImageCaptureService,
compression: SessionReplayOptions.CompressionMethod,
appLifecycleManager: AppLifecycleManaging,
eventQueue: EventQueue) {
self.captureService = captureService
self.tileDiffManager = TileDiffManager(compression: compression, scale: 1.0)
self.eventQueue = eventQueue
self.appLifecycleManager = appLifecycleManager

Expand Down Expand Up @@ -115,21 +118,44 @@ final class SnapshotTaker: EventSource {
let lastFrameDispatchTime = DispatchTime.now()
self.lastFrameDispatchTime = lastFrameDispatchTime

captureService.captureUIImage { capturedImage in
guard let capturedImage else {
captureService.captureRawFrame { rawFrame in
guard let rawFrame else {
// dropped frame
return
}

guard let capturedFrame = self.tileDiffManager.computeDiffCapture(frame: rawFrame) else {
// dropped frame
return
}

guard let exportImage = capturedImage.image.exportImage(format: .jpeg(quality: 0.3),
originalSize: capturedImage.renderSize,
scale: capturedImage.scale,
timestamp: capturedImage.timestamp,
orientation: capturedImage.orientation) else {
guard let exportFrame = self.exportFrame(from: capturedFrame) else {
// dropped frame
return
}

await self.eventQueue.send(ImageItemPayload(exportImage: exportImage))
await self.eventQueue.send(ImageItemPayload(exportFrame: exportFrame))
}
}

private func exportFrame(from capturedFrame: TiledFrame) -> ExportFrame? {
let format = ExportFormat.jpeg(quality: 0.3)
var exportedFrames = [ExportFrame.ExportImage]()
for tile in capturedFrame.tiles {
guard let exportedFrame = tile.image.asExportedImage(format: format, rect: tile.rect) else {
return nil
}
exportedFrames.append(exportedFrame)
}
guard !exportedFrames.isEmpty else { return nil }

return ExportFrame(images: exportedFrames,
originalSize: capturedFrame.originalSize,
scale: capturedFrame.scale,
format: format,
timestamp: capturedFrame.timestamp,
orientation: capturedFrame.orientation,
isKeyframe: capturedFrame.isKeyframe,
imageSignature: capturedFrame.imageSignature)
}
}
Loading