Skip to content

Commit 56d86da

Browse files
feat: Tile compression - backtracking layers level (#137)
Implemented tile compression, which divides the screen into multiple tiles and changes from the previous screen being detected. If possible, only a portion of the screen is sent; otherwise, the entire screen is sent. If it’s possible to restore the previous state by removing a layer, the layer is removed. Added an option `compression: overlayTiles(layers = 10)`. The `layers` parameter specifies the number of layers that can be stacked on top of each other. <img width="1183" height="779" alt="image" src="https://github.com/user-attachments/assets/128b081e-0e25-46e8-8288-7b15f7d63a70" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the core capture/compression and RRWeb event-generation paths; bugs could lead to missing/incorrect frames or increased payloads, but the change is scoped to session replay data generation (not auth/security). > > **Overview** > Introduces a configurable session replay compression mode (`SessionReplayOptions.compression`) with an `overlayTiles(layers:)` strategy that periodically emits keyframes and otherwise sends only the changed screen region. > > Refactors capture/export to produce `ExportFrame` (one or more image tiles + rects, keyframe flag, and optional `ImageSignature`) via a new `CaptureManager` + `TileDiffManager`, and updates the RRWeb pipeline to apply incremental updates using a new `.mutation` `IncrementalSource` (`MutationData` add/remove nodes with backtracking) instead of always redrawing a single full-screen canvas. > > Updates supporting RRWeb encodings (`WindowData` sizing, canvas commands to accept rects), renames `SessionReplayEventGenerator` to `RRWebEventGenerator`, and adjusts tests and the test app for the new capture API. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 135e672. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 98c35a6 commit 56d86da

21 files changed

+650
-268
lines changed

Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,26 @@ public struct SessionReplayOptions {
4444
}
4545
}
4646

47+
public enum CompressionMethod {
48+
case screenImage
49+
case overlayTiles(layers: Int = 10)
50+
}
51+
4752
public var isEnabled: Bool
53+
public var compression: CompressionMethod = .overlayTiles()
4854
public var serviceName: String
4955
public var privacy = PrivacyOptions()
5056
public var log: OSLog
5157

5258
public init(isEnabled: Bool = true,
5359
serviceName: String = "sessionreplay-swift",
5460
privacy: PrivacyOptions = PrivacyOptions(),
61+
compression: CompressionMethod = .overlayTiles(),
5562
log: OSLog = OSLog(subsystem: "com.launchdarkly", category: "LaunchDarklySessionReplayPlugin")) {
5663
self.isEnabled = isEnabled
5764
self.serviceName = serviceName
5865
self.privacy = privacy
66+
self.compression = compression
5967
self.log = log
6068
}
6169
}

Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ struct ImageItemPayload: EventQueueItemPayload {
77
}
88

99
var timestamp: TimeInterval {
10-
exportImage.timestamp
10+
exportFrame.timestamp
1111
}
1212

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

17-
let exportImage: ExportImage
17+
let exportFrame: ExportFrame
1818
}

Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift renamed to Sources/LaunchDarklySessionReplay/Exporter/RRWebEventGenerator.swift

Lines changed: 143 additions & 76 deletions
Large diffs are not rendered by default.

Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ actor SessionReplayExporter: EventExporting {
1111
private let context: SessionReplayContext
1212
private let sessionManager: SessionManaging
1313
private var isInitializing = false
14-
private var eventGenerator: SessionReplayEventGenerator
14+
private var eventGenerator: RRWebEventGenerator
1515
private var log: OSLog
1616
private var initializedSession: InitializeSessionResponse?
1717
private var sessionInfo: SessionInfo?
@@ -32,7 +32,7 @@ actor SessionReplayExporter: EventExporting {
3232
self.replayApiService = replayApiService
3333
self.sessionManager = context.observabilityContext.sessionManager
3434
self.title = title
35-
self.eventGenerator = SessionReplayEventGenerator(log: context.log, title: title)
35+
self.eventGenerator = RRWebEventGenerator(log: context.log, title: title)
3636
self.log = context.log
3737
self.sessionInfo = sessionManager.sessionInfo
3838

@@ -47,7 +47,7 @@ actor SessionReplayExporter: EventExporting {
4747

4848
private func updateSessionInfo(_ sessionInfo: SessionInfo) async {
4949
self.sessionInfo = sessionInfo
50-
self.eventGenerator = SessionReplayEventGenerator(log: log, title: title)
50+
self.eventGenerator = RRWebEventGenerator(log: log, title: title)
5151
self.initializedSession = nil
5252
}
5353

Sources/LaunchDarklySessionReplay/Exporter/SessionReplayStats.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ final class SessionReplayStats {
2020
return Double(images) / elapsedTime
2121
}
2222

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

2929
logIfNeeded()
3030
}

Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,11 @@ struct ClearRect: CommandPayload {
6969

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

72-
init(x: Int, y: Int, width: Int, height: Int) {
73-
self.x = x; self.y = y; self.width = width; self.height = height
72+
init(rect: CGRect) {
73+
self.x = Int(rect.minX)
74+
self.y = Int(rect.minY)
75+
self.width = Int(rect.size.width)
76+
self.height = Int(rect.size.height)
7477
}
7578

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

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

107-
init(image: AnyRRNode, dx: Int, dy: Int, dw: Int, dh: Int) {
108-
self.image = image; self.dx = dx; self.dy = dy; self.dw = dw; self.dh = dh
110+
init(image: AnyRRNode, rect: CGRect) {
111+
self.image = image
112+
self.dx = Int(rect.minX)
113+
self.dy = Int(rect.minY)
114+
self.dw = Int(rect.size.width)
115+
self.dh = Int(rect.size.height)
109116
}
110117

111118
init(from decoder: Decoder) throws {
@@ -126,8 +133,11 @@ struct DrawImage: CommandPayload {
126133
try a.encode(image)
127134
try a.encode(dx)
128135
try a.encode(dy)
129-
try a.encode(dw)
130-
try a.encode(dh)
136+
137+
if (dx > 0 || dy > 0) {
138+
try a.encode(dw)
139+
try a.encode(dh)
140+
}
131141
}
132142
}
133143

Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,76 @@ struct DomData: EventDataProtocol {
2626
}
2727
}
2828

29+
// MARK: - DOM Mutation Data (for incremental DOM updates)
30+
31+
struct MutationData: EventDataProtocol {
32+
var source: IncrementalSource
33+
var adds: [AddedNode]
34+
var removes: [RemovedNode]
35+
var texts: [TextMutation]
36+
var attributes: [AttributeMutation]
37+
38+
// Transitional
39+
var canvasSize: Int
40+
41+
init(adds: [AddedNode] = [],
42+
removes: [RemovedNode] = [],
43+
texts: [TextMutation] = [],
44+
attributes: [AttributeMutation] = [],
45+
canvasSize: Int = 0) {
46+
self.source = .mutation
47+
self.adds = adds
48+
self.removes = removes
49+
self.texts = texts
50+
self.attributes = attributes
51+
self.canvasSize = canvasSize
52+
}
53+
54+
private enum CodingKeys: String, CodingKey {
55+
case source, adds, removes, texts, attributes
56+
}
57+
58+
init(from decoder: Decoder) throws {
59+
let container = try decoder.container(keyedBy: CodingKeys.self)
60+
self.source = try container.decode(IncrementalSource.self, forKey: .source)
61+
self.adds = try container.decode([AddedNode].self, forKey: .adds)
62+
self.removes = try container.decode([RemovedNode].self, forKey: .removes)
63+
self.texts = try container.decode([TextMutation].self, forKey: .texts)
64+
self.attributes = try container.decode([AttributeMutation].self, forKey: .attributes)
65+
self.canvasSize = 0
66+
}
67+
68+
func encode(to encoder: Encoder) throws {
69+
var container = encoder.container(keyedBy: CodingKeys.self)
70+
try container.encode(source, forKey: .source)
71+
try container.encode(adds, forKey: .adds)
72+
try container.encode(removes, forKey: .removes)
73+
try container.encode(texts, forKey: .texts)
74+
try container.encode(attributes, forKey: .attributes)
75+
}
76+
}
77+
78+
struct AddedNode: Codable {
79+
var parentId: Int
80+
var nextId: Int?
81+
var node: EventNode
82+
}
83+
84+
struct RemovedNode: Codable {
85+
var parentId: Int
86+
var id: Int
87+
}
88+
89+
struct TextMutation: Codable {
90+
var id: Int
91+
var value: String
92+
}
93+
94+
struct AttributeMutation: Codable {
95+
var id: Int
96+
var attributes: [String: String?]
97+
}
98+
2999
struct EventNode: Codable {
30100
var type: NodeType
31101
var name: String?

Sources/LaunchDarklySessionReplay/RRWeb/Event.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ struct AnyEventData: Codable {
3333
self.value = try CanvasDrawData(from: decoder)
3434
} else if src == .mouseMove {
3535
self.value = try MouseMoveEventData(from: decoder)
36+
} else if src == .mutation {
37+
self.value = try MutationData(from: decoder)
3638
} else {
3739
self.value = try MouseInteractionData(from: decoder)
3840
}

Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ struct WindowData: EventDataProtocol {
55
var width: Int?
66
var height: Int?
77

8-
init(href: String? = nil, width: Int? = nil, height: Int? = nil) {
8+
init(href: String? = nil, size: CGSize) {
99
self.href = href
10-
self.width = width
11-
self.height = height
10+
self.width = Int(size.width)
11+
self.height = Int(size.height)
1212
}
1313
}

Sources/LaunchDarklySessionReplay/ScreenCapture/SnapshotTaker.swift renamed to Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import Combine
33
import LaunchDarklyObservability
44
import UIKit
55

6-
final class SnapshotTaker: EventSource {
7-
private let captureService: ScreenCaptureService
6+
final class CaptureManager: EventSource {
7+
private let captureService: ImageCaptureService
8+
private let tileDiffManager: TileDiffManager
89
private let appLifecycleManager: AppLifecycleManaging
910
@MainActor
1011
private var displayLink: CADisplayLink?
@@ -29,10 +30,12 @@ final class SnapshotTaker: EventSource {
2930
}
3031
}
3132

32-
init(captureService: ScreenCaptureService,
33+
init(captureService: ImageCaptureService,
34+
compression: SessionReplayOptions.CompressionMethod,
3335
appLifecycleManager: AppLifecycleManaging,
3436
eventQueue: EventQueue) {
3537
self.captureService = captureService
38+
self.tileDiffManager = TileDiffManager(compression: compression, scale: 1.0)
3639
self.eventQueue = eventQueue
3740
self.appLifecycleManager = appLifecycleManager
3841

@@ -115,21 +118,44 @@ final class SnapshotTaker: EventSource {
115118
let lastFrameDispatchTime = DispatchTime.now()
116119
self.lastFrameDispatchTime = lastFrameDispatchTime
117120

118-
captureService.captureUIImage { capturedImage in
119-
guard let capturedImage else {
121+
captureService.captureRawFrame { rawFrame in
122+
guard let rawFrame else {
123+
// dropped frame
124+
return
125+
}
126+
127+
guard let capturedFrame = self.tileDiffManager.computeDiffCapture(frame: rawFrame) else {
120128
// dropped frame
121129
return
122130
}
123131

124-
guard let exportImage = capturedImage.image.exportImage(format: .jpeg(quality: 0.3),
125-
originalSize: capturedImage.renderSize,
126-
scale: capturedImage.scale,
127-
timestamp: capturedImage.timestamp,
128-
orientation: capturedImage.orientation) else {
132+
guard let exportFrame = self.exportFrame(from: capturedFrame) else {
133+
// dropped frame
129134
return
130135
}
131136

132-
await self.eventQueue.send(ImageItemPayload(exportImage: exportImage))
137+
await self.eventQueue.send(ImageItemPayload(exportFrame: exportFrame))
133138
}
134139
}
140+
141+
private func exportFrame(from capturedFrame: TiledFrame) -> ExportFrame? {
142+
let format = ExportFormat.jpeg(quality: 0.3)
143+
var exportedFrames = [ExportFrame.ExportImage]()
144+
for tile in capturedFrame.tiles {
145+
guard let exportedFrame = tile.image.asExportedImage(format: format, rect: tile.rect) else {
146+
return nil
147+
}
148+
exportedFrames.append(exportedFrame)
149+
}
150+
guard !exportedFrames.isEmpty else { return nil }
151+
152+
return ExportFrame(images: exportedFrames,
153+
originalSize: capturedFrame.originalSize,
154+
scale: capturedFrame.scale,
155+
format: format,
156+
timestamp: capturedFrame.timestamp,
157+
orientation: capturedFrame.orientation,
158+
isKeyframe: capturedFrame.isKeyframe,
159+
imageSignature: capturedFrame.imageSignature)
160+
}
135161
}

0 commit comments

Comments
 (0)