Skip to content

Commit 1b551cc

Browse files
committed
Merge branch 'main' into andrey/flush-on-home
* main: fix: UIDevice.current.orientation.isLandscape not main thread access (#102) feat: Limit accumulating canvas buffer (#101)
2 parents 19fb664 + d8ffa0a commit 1b551cc

File tree

18 files changed

+286
-145
lines changed

18 files changed

+286
-145
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: 50 additions & 35 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,
@@ -210,18 +220,13 @@ actor SessionReplayEventGenerator {
210220
}
211221

212222
func viewPortEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event {
213-
#if os(iOS)
214-
let currentOrientation = UIDevice.current.orientation.isLandscape ? 1 : 0
215-
#else
216-
let currentOrientation = 0
217-
#endif
218223
let payload = ViewportPayload(width: exportImage.originalWidth,
219224
height: exportImage.originalHeight,
220225
availWidth: exportImage.originalWidth,
221226
availHeight: exportImage.originalHeight,
222227
colorDepth: 30,
223228
pixelDepth: 30,
224-
orientation: currentOrientation)
229+
orientation: exportImage.orientation)
225230
let eventData = CustomEventData(tag: .viewport, payload: payload)
226231
let event = Event(type: .Custom,
227232
data: AnyEventData(eventData),
@@ -232,7 +237,8 @@ actor SessionReplayEventGenerator {
232237

233238
func drawImageEvent(exportImage: ExportImage, timestamp: TimeInterval, imageId: Int) -> Event {
234239
let clearRectCommand = ClearRect(x: 0, y: 0, width: exportImage.originalWidth, height: exportImage.originalHeight)
235-
let arrayBuffer = RRArrayBuffer(base64: exportImage.data.base64EncodedString())
240+
let base64String = exportImage.data.base64EncodedString()
241+
let arrayBuffer = RRArrayBuffer(base64: base64String)
236242
let blob = AnyRRNode(RRBlob(data: [AnyRRNode(arrayBuffer)], type: exportImage.mimeType))
237243
let drawImageCommand = DrawImage(image: AnyRRNode(RRImageBitmap(args: [blob])),
238244
dx: 0,
@@ -244,10 +250,13 @@ actor SessionReplayEventGenerator {
244250
id: imageId,
245251
type: .mouseUp,
246252
commands: [
247-
AnyCommand(clearRectCommand),
248-
AnyCommand(drawImageCommand)
253+
AnyCommand(clearRectCommand, canvasSize: 80),
254+
AnyCommand(drawImageCommand, canvasSize: base64String.count)
249255
])
250-
let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
256+
let event = Event(type: .IncrementalSnapshot,
257+
data: AnyEventData(eventData),
258+
timestamp: timestamp, _sid: nextSid)
259+
generatingCanvasSize += eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
251260
return event
252261
}
253262

@@ -264,32 +273,38 @@ actor SessionReplayEventGenerator {
264273

265274
func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event {
266275
id = 0
267-
let rootNode = fullSnapshotNode(exportImage: exportImage)
268-
let eventData = EventData(node: rootNode)
276+
let eventData = fullSnapshotData(exportImage: exportImage)
269277
let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
278+
// start again counting canvasSize
279+
generatingCanvasSize = eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
270280
return event
271281
}
272282

273-
func fullSnapshotNode(exportImage: ExportImage) -> EventNode {
283+
func fullSnapshotData(exportImage: ExportImage) -> DomData {
274284
var rootNode = EventNode(id: nextId, type: .Document)
275285
let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html")
276286
rootNode.childNodes.append(htmlDocNode)
277-
287+
let base64String = exportImage.base64DataURL()
288+
278289
let htmlNode = EventNode(id: nextId, type: .Element, tagName: "html", attributes: ["lang": "en"], childNodes: [
279290
EventNode(id: nextId, type: .Element, tagName: "head", attributes: [:]),
280291
EventNode(id: nextId, type: .Element, tagName: "body", attributes: [:], childNodes: [
281-
exportImage.eventNode(id: nextId)
292+
exportImage.eventNode(id: nextId, rr_dataURL: base64String)
282293
]),
283294
])
284295
imageId = id
285296
rootNode.childNodes.append(htmlNode)
286297

287-
return rootNode
298+
return DomData(node: rootNode, canvasSize: base64String.count)
288299
}
289300

290301
private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) {
291302
events.append(windowEvent(href: "", width: paddedWidth(exportImage.originalWidth), height: paddedHeight(exportImage.originalHeight), timestamp: timestamp))
292303
events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp))
293304
events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp))
294305
}
306+
307+
func updatePushedCanvasSize() {
308+
pushedCanvasSize = generatingCanvasSize
309+
}
295310
}

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)