Skip to content

Commit 0a531f5

Browse files
feat: Limit accumulating canvas buffer (#101)
RRWeb Canvas is being updated by draw commands and they all kept in the browser memory. Pr makes SR to reset this Canvas cache by pushing FullSnapshot after 10mb of canvas payloads <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Tracks canvas payload size (~10MB limit) to decide between incremental draws and full snapshots, refactors event data types, and adds SessionReplay tests. > > - **Session Replay**: > - Enforce RRWeb canvas buffer cap (~10MB) via `generatingCanvasSize`/`pushedCanvasSize`; trigger `FullSnapshot` when exceeded; otherwise issue incremental `drawImage`. > - Add `RRWebPlayerConstants`, track per-event `canvasSize`, and update size after payload push (`updatePushedCanvasSize`). > - Optimize image handling (reuse base64 string, pass `rr_dataURL` into `ExportImage.eventNode`). > - **RRWeb event model refactor**: > - Replace generic `EventData` with specific `WindowData`, `MouseInteractionData`, `MouseMoveEventData`, and `DomData` (with transitional `canvasSize`). > - Extend `CanvasDrawData/AnyCommand` to carry `canvasSize`; adjust decoding in `AnyEventData`. > - **Exporter**: > - After `pushPayload`, flush accumulated canvas size in `SessionReplayEventGenerator`. > - **GraphQL**: > - Make `GraphQLClient.endpoint` private. > - **Build & Tests**: > - Add `SessionReplayTests` target and scheme entry. > - New tests validate draw vs full snapshot behavior based on canvas buffer limit. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 841f989. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2797716 commit 0a531f5

File tree

16 files changed

+266
-132
lines changed

16 files changed

+266
-132
lines changed

.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@
7878
ReferencedContainer = "container:">
7979
</BuildableReference>
8080
</TestableReference>
81+
<TestableReference
82+
skipped = "NO">
83+
<BuildableReference
84+
BuildableIdentifier = "primary"
85+
BlueprintIdentifier = "SessionReplayTests"
86+
BuildableName = "SessionReplayTests"
87+
BlueprintName = "SessionReplayTests"
88+
ReferencedContainer = "container:">
89+
</BuildableReference>
90+
</TestableReference>
8191
</Testables>
8292
</TestAction>
8393
<LaunchAction

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,12 @@ let package = Package(
9292
],
9393
resources: [.process("GraphQL/Queries")]
9494
),
95+
.testTarget(
96+
name: "SessionReplayTests",
97+
dependencies: [
98+
"LaunchDarklySessionReplay",
99+
"LaunchDarklyObservability"
100+
]
101+
),
95102
]
96103
)

Sources/Common/GraphQL/GraphQLClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import DataCompression
33

44
public final class GraphQLClient {
5-
public let endpoint: URL
5+
private let endpoint: URL
66
private let network: HttpServicing
77
private let decoder: JSONDecoder
88
private let defaultHeaders: [String: String]

Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,36 @@ import OSLog
88
import Common
99
#endif
1010

11+
enum RRWebPlayerConstants {
12+
// padding requiered by used html dom structure
13+
static let padding = CGSize(width: 11, height: 11)
14+
// size limit of accumulated continues canvas operations on the RRWeb player
15+
static let canvasBufferLimit = 10_000_000 // ~10mb
16+
17+
static let canvasDrawEntourage = 300 // bytes
18+
}
19+
1120
actor SessionReplayEventGenerator {
1221
private var title: String
13-
let padding = CGSize(width: 11, height: 11)
14-
var sid = 0
15-
var nextSid: Int {
22+
private let padding = RRWebPlayerConstants.padding
23+
private var sid = 0
24+
private var nextSid: Int {
1625
sid += 1
1726
return sid
1827
}
1928

20-
var id = 16
21-
var nextId: Int {
29+
private var id = 16
30+
private var nextId: Int {
2231
id += 1
2332
return id
2433
}
34+
private var pushedCanvasSize: Int = 0
35+
private var generatingCanvasSize: Int = 0
2536

26-
var imageId: Int?
27-
var lastExportImage: ExportImage?
28-
var stats: SessionReplayStats?
29-
let isDebug = false
37+
private var imageId: Int?
38+
private var lastExportImage: ExportImage?
39+
private var stats: SessionReplayStats?
40+
private let isDebug = false
3041

3142
init(log: OSLog, title: String) {
3243
if isDebug {
@@ -37,6 +48,7 @@ actor SessionReplayEventGenerator {
3748

3849
func generateEvents(items: [EventQueueItem]) -> [Event] {
3950
var events = [Event]()
51+
self.generatingCanvasSize = pushedCanvasSize
4052
for item in items {
4153
appendEvents(item: item, events: &events)
4254
}
@@ -56,15 +68,15 @@ actor SessionReplayEventGenerator {
5668
fileprivate func wakeUpPlayerEvents(_ events: inout [Event], _ imageId: Int, _ timestamp: TimeInterval) {
5769
// artificial mouse movement to wake up session replay player
5870
events.append(Event(type: .IncrementalSnapshot,
59-
data: AnyEventData(EventData(source: .mouseInteraction,
71+
data: AnyEventData(MouseInteractionData(source: .mouseInteraction,
6072
type: .mouseDown,
6173
id: imageId,
6274
x: padding.width,
6375
y: padding.height)),
6476
timestamp: timestamp,
6577
_sid: nextSid))
6678
events.append(Event(type: .IncrementalSnapshot,
67-
data: AnyEventData(EventData(source: .mouseInteraction,
79+
data: AnyEventData(MouseInteractionData(source: .mouseInteraction,
6880
type: .mouseUp,
6981
id: imageId,
7082
x: padding.width,
@@ -91,7 +103,8 @@ actor SessionReplayEventGenerator {
91103
if let imageId,
92104
let lastExportImage,
93105
lastExportImage.originalWidth == exportImage.originalWidth,
94-
lastExportImage.originalHeight == exportImage.originalHeight {
106+
lastExportImage.originalHeight == exportImage.originalHeight,
107+
generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit {
95108
events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId))
96109
} else {
97110
// if screen changed size we send fullSnapshot as canvas resizing might take to many hours on the server
@@ -122,14 +135,14 @@ actor SessionReplayEventGenerator {
122135
fileprivate func appendTouchInteraction(interaction: TouchInteraction, events: inout [Event]) {
123136
if let touchEventData: EventDataProtocol = switch interaction.kind {
124137
case .touchDown(let point):
125-
EventData(source: .mouseInteraction,
138+
MouseInteractionData(source: .mouseInteraction,
126139
type: .touchStart,
127140
id: imageId,
128141
x: point.x + padding.width,
129142
y: point.y + padding.height)
130143

131144
case .touchUp(let point):
132-
EventData(source: .mouseInteraction,
145+
MouseInteractionData(source: .mouseInteraction,
133146
type: .touchEnd,
134147
id: imageId,
135148
x: point.x + padding.width,
@@ -145,7 +158,7 @@ actor SessionReplayEventGenerator {
145158
timeOffset: p.timestamp - interaction.timestamp) })
146159

147160
default:
148-
Optional<EventData>.none
161+
Optional<MouseInteractionData>.none
149162
} {
150163
let event = Event(type: .IncrementalSnapshot,
151164
data: AnyEventData(touchEventData),
@@ -162,7 +175,6 @@ actor SessionReplayEventGenerator {
162175
func clickEvent(interaction: TouchInteraction) -> Event? {
163176
guard case .touchDown = interaction.kind else { return nil }
164177

165-
let viewName = interaction.target?.className
166178
let eventData = CustomEventData(tag: .click, payload: ClickPayload(
167179
clickTarget: interaction.target?.className ?? "",
168180
clickTextContent: interaction.target?.accessibilityIdentifier ?? "",
@@ -174,10 +186,8 @@ actor SessionReplayEventGenerator {
174186
return event
175187
}
176188

177-
178-
179189
func windowEvent(href: String, width: Int, height: Int, timestamp: TimeInterval) -> Event {
180-
let eventData = EventData(href: href, width: width, height: height)
190+
let eventData = WindowData(href: href, width: width, height: height)
181191
let event = Event(type: .Meta,
182192
data: AnyEventData(eventData),
183193
timestamp: timestamp,
@@ -232,7 +242,8 @@ actor SessionReplayEventGenerator {
232242

233243
func drawImageEvent(exportImage: ExportImage, timestamp: TimeInterval, imageId: Int) -> Event {
234244
let clearRectCommand = ClearRect(x: 0, y: 0, width: exportImage.originalWidth, height: exportImage.originalHeight)
235-
let arrayBuffer = RRArrayBuffer(base64: exportImage.data.base64EncodedString())
245+
let base64String = exportImage.data.base64EncodedString()
246+
let arrayBuffer = RRArrayBuffer(base64: base64String)
236247
let blob = AnyRRNode(RRBlob(data: [AnyRRNode(arrayBuffer)], type: exportImage.mimeType))
237248
let drawImageCommand = DrawImage(image: AnyRRNode(RRImageBitmap(args: [blob])),
238249
dx: 0,
@@ -244,10 +255,13 @@ actor SessionReplayEventGenerator {
244255
id: imageId,
245256
type: .mouseUp,
246257
commands: [
247-
AnyCommand(clearRectCommand),
248-
AnyCommand(drawImageCommand)
258+
AnyCommand(clearRectCommand, canvasSize: 80),
259+
AnyCommand(drawImageCommand, canvasSize: base64String.count)
249260
])
250-
let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
261+
let event = Event(type: .IncrementalSnapshot,
262+
data: AnyEventData(eventData),
263+
timestamp: timestamp, _sid: nextSid)
264+
generatingCanvasSize += eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
251265
return event
252266
}
253267

@@ -264,32 +278,38 @@ actor SessionReplayEventGenerator {
264278

265279
func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event {
266280
id = 0
267-
let rootNode = fullSnapshotNode(exportImage: exportImage)
268-
let eventData = EventData(node: rootNode)
281+
let eventData = fullSnapshotData(exportImage: exportImage)
269282
let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
283+
// start again counting canvasSize
284+
generatingCanvasSize = eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
270285
return event
271286
}
272287

273-
func fullSnapshotNode(exportImage: ExportImage) -> EventNode {
288+
func fullSnapshotData(exportImage: ExportImage) -> DomData {
274289
var rootNode = EventNode(id: nextId, type: .Document)
275290
let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html")
276291
rootNode.childNodes.append(htmlDocNode)
277-
292+
let base64String = exportImage.base64DataURL()
293+
278294
let htmlNode = EventNode(id: nextId, type: .Element, tagName: "html", attributes: ["lang": "en"], childNodes: [
279295
EventNode(id: nextId, type: .Element, tagName: "head", attributes: [:]),
280296
EventNode(id: nextId, type: .Element, tagName: "body", attributes: [:], childNodes: [
281-
exportImage.eventNode(id: nextId)
297+
exportImage.eventNode(id: nextId, rr_dataURL: base64String)
282298
]),
283299
])
284300
imageId = id
285301
rootNode.childNodes.append(htmlNode)
286302

287-
return rootNode
303+
return DomData(node: rootNode, canvasSize: base64String.count)
288304
}
289305

290306
private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) {
291307
events.append(windowEvent(href: "", width: paddedWidth(exportImage.originalWidth), height: paddedHeight(exportImage.originalHeight), timestamp: timestamp))
292308
events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp))
293309
events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp))
294310
}
311+
312+
func updatePushedCanvasSize() {
313+
pushedCanvasSize = generatingCanvasSize
314+
}
295315
}

Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ actor SessionReplayExporter: EventExporting {
2323
payloadId += 1
2424
return payloadId
2525
}
26-
2726
private var identifyPayload: IdentifyItemPayload?
2827

2928
init(context: SessionReplayContext,
@@ -100,7 +99,11 @@ actor SessionReplayExporter: EventExporting {
10099
guard events.isNotEmpty else { return }
101100

102101
let input = PushPayloadVariables(sessionSecureId: initializedSession.secureId, payloadId: "\(nextPayloadId)", events: events)
102+
103103
try await replayApiService.pushPayload(input)
104+
105+
// flushes generating canvas size into pushedCanvasSize
106+
await eventGenerator.updatePushedCanvasSize()
104107
}
105108

106109
private func initializeSession(sessionSecureId: String) async throws -> InitializeSessionResponse {

Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import Foundation
44
#endif
55

66
struct PushPayloadVariables: Codable {
7-
public struct EventsInput: Codable {
7+
struct EventsInput: Codable {
88
var events: [Event]
99
}
1010

11-
public struct ErrorInput: Codable {
11+
struct ErrorInput: Codable {
1212

1313
}
1414

Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ struct CanvasDrawData: EventDataProtocol {
1010
var id: Int
1111
var type: MouseInteractions
1212
var commands: [AnyCommand]
13+
14+
var canvasSize: Int {
15+
commands.reduce(0) { $0 + $1.canvasSize }
16+
}
1317
}
1418

1519
enum CommandName: String, Codable {
@@ -24,6 +28,8 @@ protocol CommandPayload: Codable {
2428

2529
struct AnyCommand: Codable {
2630
let value: any CommandPayload
31+
// Transitional
32+
let canvasSize: Int
2733

2834
private enum K: String, CodingKey { case property }
2935

@@ -32,8 +38,11 @@ struct AnyCommand: Codable {
3238
.drawImage: { try DrawImage(from: $0) }
3339
]
3440

35-
init(_ value: any CommandPayload) { self.value = value }
36-
41+
init(_ value: any CommandPayload, canvasSize: Int) {
42+
self.value = value
43+
self.canvasSize = canvasSize
44+
}
45+
3746
init(from decoder: Decoder) throws {
3847
let c = try decoder.container(keyedBy: K.self)
3948
let name = try c.decode(CommandName.self, forKey: .property)
@@ -42,6 +51,7 @@ struct AnyCommand: Codable {
4251
debugDescription: "Unknown command \(name)"))
4352
}
4453
self.value = try factory(decoder)
54+
self.canvasSize = 0
4555
}
4656

4757
func encode(to encoder: Encoder) throws {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
3+
struct DomData: EventDataProtocol {
4+
var node: EventNode
5+
// Transitional
6+
var canvasSize: Int
7+
8+
init(node: EventNode, canvasSize: Int) {
9+
self.node = node
10+
self.canvasSize = canvasSize
11+
}
12+
13+
private enum CodingKeys: String, CodingKey {
14+
case node
15+
}
16+
17+
init(from decoder: Decoder) throws {
18+
let container = try decoder.container(keyedBy: CodingKeys.self)
19+
self.node = try container.decode(EventNode.self, forKey: .node)
20+
self.canvasSize = 0
21+
}
22+
23+
func encode(to encoder: Encoder) throws {
24+
var container = encoder.container(keyedBy: CodingKeys.self)
25+
try container.encode(node, forKey: .node)
26+
}
27+
}
28+
29+
struct EventNode: Codable {
30+
var type: NodeType
31+
var name: String?
32+
var tagName: String?
33+
var attributes: [String: String]?
34+
var childNodes: [EventNode]
35+
var rootId: Int?
36+
var id: Int?
37+
38+
init(id: Int? = nil,
39+
rootId: Int? = nil,
40+
type: NodeType,
41+
name: String? = nil,
42+
tagName: String? = nil,
43+
attributes: [String : String]? = nil,
44+
childNodes: [EventNode] = []) {
45+
self.id = id
46+
self.rootId = rootId
47+
self.type = type
48+
self.name = name
49+
self.tagName = tagName
50+
self.attributes = attributes
51+
self.childNodes = childNodes
52+
}
53+
}

0 commit comments

Comments
 (0)