From 412be0f4da0dc12500a3415cd531f29bcff24b64 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Sun, 14 Dec 2025 11:36:23 -0800 Subject: [PATCH 01/13] limit accumulated canvas draw image size --- Sources/Common/GraphQL/GraphQLClient.swift | 25 ++++++++++++++----- .../SessionReplayEventGenerator.swift | 18 ++++++++++--- .../Exporter/SessionReplayExporter.swift | 5 ++-- .../Operations/PushPayloadOperation.swift | 8 +++--- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/Sources/Common/GraphQL/GraphQLClient.swift b/Sources/Common/GraphQL/GraphQLClient.swift index 7ba26c8..dd2b50c 100644 --- a/Sources/Common/GraphQL/GraphQLClient.swift +++ b/Sources/Common/GraphQL/GraphQLClient.swift @@ -2,7 +2,7 @@ import Foundation import DataCompression public final class GraphQLClient { - public let endpoint: URL + private let endpoint: URL private let network: HttpServicing private let decoder: JSONDecoder private let defaultHeaders: [String: String] @@ -31,19 +31,32 @@ public final class GraphQLClient { query: String, variables: Variables? = nil, operationName: String? = nil, - headers: [String: String] = [:] + headers: [String: String] = [:], ) async throws -> Output { let gqlRequest = GraphQLRequest(query: query, variables: variables, operationName: operationName) + let rawData = try gqlRequest.httpBody() + + return try await execute(data: rawData, headers: headers) as Output + } + + /// Execute a GraphQL operation from stirng query + /// - Parameters: + /// - query: Query in graphql format + /// - variables: Codable variables (optional) + /// - operationName: Operation name (optional) + /// - headers: Extra headers (merged over defaultHeaders) + public func execute( + data: Data, + headers: [String: String] = [:], + ) async throws -> Output { var request = URLRequest(url: endpoint) request.httpMethod = "POST" - let rawData = try gqlRequest.httpBody() - - if isCompressed, let compressedData = rawData.gzip() { + if isCompressed, let compressedData = data.gzip() { request.httpBody = compressedData request.setValue("gzip", forHTTPHeaderField: "Content-Encoding") } else { - request.httpBody = rawData + request.httpBody = data } let combinedHeaders = defaultHeaders.merging(headers) { _, new in new } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index 9015fac..55849de 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -8,9 +8,14 @@ import OSLog import Common #endif +enum RRWebPlayerConstants { + static let padding = CGSize(width: 11, height: 11) + static let canvasBufferLimit: Int = 10_000_000 // ~10mb, limits the size of accumulated operations in the RRWeb player need to create in the browser +} + actor SessionReplayEventGenerator { private var title: String - let padding = CGSize(width: 11, height: 11) + let padding = RRWebPlayerConstants.padding var sid = 0 var nextSid: Int { sid += 1 @@ -22,7 +27,8 @@ actor SessionReplayEventGenerator { id += 1 return id } - + private var pushedSizeBeforeFullSnapshot: Int = 0 + var imageId: Int? var lastExportImage: ExportImage? var stats: SessionReplayStats? @@ -91,7 +97,8 @@ actor SessionReplayEventGenerator { if let imageId, let lastExportImage, lastExportImage.originalWidth == exportImage.originalWidth, - lastExportImage.originalHeight == exportImage.originalHeight { + lastExportImage.originalHeight == exportImage.originalHeight, + pushedSizeBeforeFullSnapshot < RRWebPlayerConstants.canvasBufferLimit { events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId)) } else { // if screen changed size we send fullSnapshot as canvas resizing might take to many hours on the server @@ -264,6 +271,7 @@ actor SessionReplayEventGenerator { func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event { id = 0 + pushedSizeBeforeFullSnapshot = 0 let rootNode = fullSnapshotNode(exportImage: exportImage) let eventData = EventData(node: rootNode) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) @@ -292,4 +300,8 @@ actor SessionReplayEventGenerator { events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp)) events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp)) } + + func addPushedPayloadSize(_ size: Int) { + pushedSizeBeforeFullSnapshot += size + } } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift index f0036dc..97d0990 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift @@ -23,7 +23,6 @@ actor SessionReplayExporter: EventExporting { payloadId += 1 return payloadId } - private var identifyPayload: IdentifyItemPayload? init(context: SessionReplayContext, @@ -100,7 +99,9 @@ actor SessionReplayExporter: EventExporting { guard events.isNotEmpty else { return } let input = PushPayloadVariables(sessionSecureId: initializedSession.secureId, payloadId: "\(nextPayloadId)", events: events) - try await replayApiService.pushPayload(input) + var payloadSize = 0 + try await replayApiService.pushPayload(input, payloadSize: &payloadSize) + await eventGenerator.addPushedPayloadSize(payloadSize) } private func initializeSession(sessionSecureId: String) async throws -> InitializeSessionResponse { diff --git a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift index d38f2bb..3feb7b6 100644 --- a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift +++ b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift @@ -52,9 +52,8 @@ struct PushPayloadVariables: Codable { } extension SessionReplayAPIService { - func pushPayload(_ variables: PushPayloadVariables) async throws { - let _: GraphQLEmptyData = try await gqlClient.execute( - query: """ + func pushPayload(_ variables: PushPayloadVariables, payloadSize: inout Int) async throws { + let gqlRequest = GraphQLRequest(query: """ mutation PushPayload( $session_secure_id: String! $payload_id: ID! @@ -83,5 +82,8 @@ extension SessionReplayAPIService { """, variables: variables, operationName: "PushPayload") + + let data = try gqlRequest.httpBody() + let _: GraphQLEmptyData = try await gqlClient.execute(data: data) } } From ac1bb4a60c05640cd2ef113699b28f5404de6093 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Sun, 14 Dec 2025 18:28:58 -0800 Subject: [PATCH 02/13] using generatingCanvasSize --- Sources/Common/GraphQL/GraphQLClient.swift | 23 ++---- .../SessionReplayEventGenerator.swift | 75 ++++++++++--------- .../Exporter/SessionReplayExporter.swift | 8 +- .../Operations/PushPayloadOperation.swift | 5 +- .../RRWeb/CanvasDrawData.swift | 13 +++- .../RRWeb/DomData.swift | 37 +++++++++ .../RRWeb/Event.swift | 12 +-- .../RRWeb/MouseInteractionData.swift | 22 ++++++ .../RRWeb/MouseMoveEventData.swift | 4 +- .../RRWeb/WindowData.swift | 13 ++++ .../ScreenCapture/ExportImage.swift | 2 +- 11 files changed, 145 insertions(+), 69 deletions(-) create mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift create mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift create mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift diff --git a/Sources/Common/GraphQL/GraphQLClient.swift b/Sources/Common/GraphQL/GraphQLClient.swift index dd2b50c..d9cd046 100644 --- a/Sources/Common/GraphQL/GraphQLClient.swift +++ b/Sources/Common/GraphQL/GraphQLClient.swift @@ -31,32 +31,19 @@ public final class GraphQLClient { query: String, variables: Variables? = nil, operationName: String? = nil, - headers: [String: String] = [:], + headers: [String: String] = [:] ) async throws -> Output { let gqlRequest = GraphQLRequest(query: query, variables: variables, operationName: operationName) - let rawData = try gqlRequest.httpBody() - - return try await execute(data: rawData, headers: headers) as Output - } - - /// Execute a GraphQL operation from stirng query - /// - Parameters: - /// - query: Query in graphql format - /// - variables: Codable variables (optional) - /// - operationName: Operation name (optional) - /// - headers: Extra headers (merged over defaultHeaders) - public func execute( - data: Data, - headers: [String: String] = [:], - ) async throws -> Output { var request = URLRequest(url: endpoint) request.httpMethod = "POST" - if isCompressed, let compressedData = data.gzip() { + let rawData = try gqlRequest.httpBody() + + if isCompressed, let compressedData = rawData.gzip() { request.httpBody = compressedData request.setValue("gzip", forHTTPHeaderField: "Content-Encoding") } else { - request.httpBody = data + request.httpBody = rawData } let combinedHeaders = defaultHeaders.merging(headers) { _, new in new } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index 55849de..f3d1197 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -9,30 +9,33 @@ import OSLog #endif enum RRWebPlayerConstants { + // padding requiered by used html dom structure static let padding = CGSize(width: 11, height: 11) - static let canvasBufferLimit: Int = 10_000_000 // ~10mb, limits the size of accumulated operations in the RRWeb player need to create in the browser + // size limit of accumulated continues canvas operations on the RRWeb player + static let canvasBufferLimit: Int = 10_000_000 // ~10mb } actor SessionReplayEventGenerator { private var title: String - let padding = RRWebPlayerConstants.padding - var sid = 0 - var nextSid: Int { + private let padding = RRWebPlayerConstants.padding + private var sid = 0 + private var nextSid: Int { sid += 1 return sid } - var id = 16 - var nextId: Int { + private var id = 16 + private var nextId: Int { id += 1 return id } - private var pushedSizeBeforeFullSnapshot: Int = 0 - - var imageId: Int? - var lastExportImage: ExportImage? - var stats: SessionReplayStats? - let isDebug = false + private var pushedCanvasSize: Int = 0 + private var generatingCanvasSize: Int = 0 + + private var imageId: Int? + private var lastExportImage: ExportImage? + private var stats: SessionReplayStats? + private let isDebug = false init(log: OSLog, title: String) { if isDebug { @@ -43,6 +46,7 @@ actor SessionReplayEventGenerator { func generateEvents(items: [EventQueueItem]) -> [Event] { var events = [Event]() + self.generatingCanvasSize = pushedCanvasSize for item in items { appendEvents(item: item, events: &events) } @@ -62,7 +66,7 @@ actor SessionReplayEventGenerator { fileprivate func wakeUpPlayerEvents(_ events: inout [Event], _ imageId: Int, _ timestamp: TimeInterval) { // artificial mouse movement to wake up session replay player events.append(Event(type: .IncrementalSnapshot, - data: AnyEventData(EventData(source: .mouseInteraction, + data: AnyEventData(MouseInteractionData(source: .mouseInteraction, type: .mouseDown, id: imageId, x: padding.width, @@ -70,7 +74,7 @@ actor SessionReplayEventGenerator { timestamp: timestamp, _sid: nextSid)) events.append(Event(type: .IncrementalSnapshot, - data: AnyEventData(EventData(source: .mouseInteraction, + data: AnyEventData(MouseInteractionData(source: .mouseInteraction, type: .mouseUp, id: imageId, x: padding.width, @@ -98,7 +102,7 @@ actor SessionReplayEventGenerator { let lastExportImage, lastExportImage.originalWidth == exportImage.originalWidth, lastExportImage.originalHeight == exportImage.originalHeight, - pushedSizeBeforeFullSnapshot < RRWebPlayerConstants.canvasBufferLimit { + generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit { events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId)) } else { // if screen changed size we send fullSnapshot as canvas resizing might take to many hours on the server @@ -129,14 +133,14 @@ actor SessionReplayEventGenerator { fileprivate func appendTouchInteraction(interaction: TouchInteraction, events: inout [Event]) { if let touchEventData: EventDataProtocol = switch interaction.kind { case .touchDown(let point): - EventData(source: .mouseInteraction, + MouseInteractionData(source: .mouseInteraction, type: .touchStart, id: imageId, x: point.x + padding.width, y: point.y + padding.height) case .touchUp(let point): - EventData(source: .mouseInteraction, + MouseInteractionData(source: .mouseInteraction, type: .touchEnd, id: imageId, x: point.x + padding.width, @@ -152,7 +156,7 @@ actor SessionReplayEventGenerator { timeOffset: p.timestamp - interaction.timestamp) }) default: - Optional.none + Optional.none } { let event = Event(type: .IncrementalSnapshot, data: AnyEventData(touchEventData), @@ -181,10 +185,8 @@ actor SessionReplayEventGenerator { return event } - - func windowEvent(href: String, width: Int, height: Int, timestamp: TimeInterval) -> Event { - let eventData = EventData(href: href, width: width, height: height) + let eventData = WindowData(href: href, width: width, height: height) let event = Event(type: .Meta, data: AnyEventData(eventData), timestamp: timestamp, @@ -239,7 +241,8 @@ actor SessionReplayEventGenerator { func drawImageEvent(exportImage: ExportImage, timestamp: TimeInterval, imageId: Int) -> Event { let clearRectCommand = ClearRect(x: 0, y: 0, width: exportImage.originalWidth, height: exportImage.originalHeight) - let arrayBuffer = RRArrayBuffer(base64: exportImage.data.base64EncodedString()) + let base64String = exportImage.data.base64EncodedString() + let arrayBuffer = RRArrayBuffer(base64: base64String) let blob = AnyRRNode(RRBlob(data: [AnyRRNode(arrayBuffer)], type: exportImage.mimeType)) let drawImageCommand = DrawImage(image: AnyRRNode(RRImageBitmap(args: [blob])), dx: 0, @@ -251,10 +254,13 @@ actor SessionReplayEventGenerator { id: imageId, type: .mouseUp, commands: [ - AnyCommand(clearRectCommand), - AnyCommand(drawImageCommand) + AnyCommand(clearRectCommand, canvasSize: 80), + AnyCommand(drawImageCommand, canvasSize: base64String.count) ]) - let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) + let event = Event(type: .IncrementalSnapshot, + data: AnyEventData(eventData), + timestamp: timestamp, _sid: nextSid) + generatingCanvasSize += eventData.canvasSize return event } @@ -271,28 +277,29 @@ actor SessionReplayEventGenerator { func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event { id = 0 - pushedSizeBeforeFullSnapshot = 0 - let rootNode = fullSnapshotNode(exportImage: exportImage) - let eventData = EventData(node: rootNode) + let eventData = fullSnapshotData(exportImage: exportImage) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) + // start again counting canvasSize + generatingCanvasSize = eventData.canvasSize return event } - func fullSnapshotNode(exportImage: ExportImage) -> EventNode { + func fullSnapshotData(exportImage: ExportImage) -> DomData { var rootNode = EventNode(id: nextId, type: .Document) let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html") rootNode.childNodes.append(htmlDocNode) - + let base64String = exportImage.data.base64EncodedString() + let htmlNode = EventNode(id: nextId, type: .Element, tagName: "html", attributes: ["lang": "en"], childNodes: [ EventNode(id: nextId, type: .Element, tagName: "head", attributes: [:]), EventNode(id: nextId, type: .Element, tagName: "body", attributes: [:], childNodes: [ - exportImage.eventNode(id: nextId) + exportImage.eventNode(id: nextId, rr_dataURL: base64String) ]), ]) imageId = id rootNode.childNodes.append(htmlNode) - return rootNode + return DomData(node: rootNode, canvasSize: base64String.count) } private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) { @@ -301,7 +308,7 @@ actor SessionReplayEventGenerator { events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp)) } - func addPushedPayloadSize(_ size: Int) { - pushedSizeBeforeFullSnapshot += size + func updatePushedCanvasSize() { + pushedCanvasSize += generatingCanvasSize } } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift index 97d0990..1cffc3d 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift @@ -99,9 +99,11 @@ actor SessionReplayExporter: EventExporting { guard events.isNotEmpty else { return } let input = PushPayloadVariables(sessionSecureId: initializedSession.secureId, payloadId: "\(nextPayloadId)", events: events) - var payloadSize = 0 - try await replayApiService.pushPayload(input, payloadSize: &payloadSize) - await eventGenerator.addPushedPayloadSize(payloadSize) + + try await replayApiService.pushPayload(input) + + // flushes generating canvas size into pushedCanvasSize + await eventGenerator.updatePushedCanvasSize() } private func initializeSession(sessionSecureId: String) async throws -> InitializeSessionResponse { diff --git a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift index 3feb7b6..36b623d 100644 --- a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift +++ b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift @@ -52,7 +52,7 @@ struct PushPayloadVariables: Codable { } extension SessionReplayAPIService { - func pushPayload(_ variables: PushPayloadVariables, payloadSize: inout Int) async throws { + func pushPayload(_ variables: PushPayloadVariables) async throws { let gqlRequest = GraphQLRequest(query: """ mutation PushPayload( $session_secure_id: String! @@ -82,8 +82,5 @@ extension SessionReplayAPIService { """, variables: variables, operationName: "PushPayload") - - let data = try gqlRequest.httpBody() - let _: GraphQLEmptyData = try await gqlClient.execute(data: data) } } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift index 37661c3..1be960d 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift @@ -10,6 +10,10 @@ struct CanvasDrawData: EventDataProtocol { var id: Int var type: MouseInteractions var commands: [AnyCommand] + + var canvasSize: Int { + commands.reduce(0) { $0 + $1.canvasSize } + } } enum CommandName: String, Codable { @@ -24,6 +28,7 @@ protocol CommandPayload: Codable { struct AnyCommand: Codable { let value: any CommandPayload + let canvasSize: Int private enum K: String, CodingKey { case property } @@ -32,8 +37,11 @@ struct AnyCommand: Codable { .drawImage: { try DrawImage(from: $0) } ] - init(_ value: any CommandPayload) { self.value = value } - + init(_ value: any CommandPayload, canvasSize: Int) { + self.value = value + self.canvasSize = canvasSize + } + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: K.self) let name = try c.decode(CommandName.self, forKey: .property) @@ -42,6 +50,7 @@ struct AnyCommand: Codable { debugDescription: "Unknown command \(name)")) } self.value = try factory(decoder) + self.canvasSize = 0 } func encode(to encoder: Encoder) throws { diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift new file mode 100644 index 0000000..9322744 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -0,0 +1,37 @@ +import Foundation + +struct DomData: EventDataProtocol { + var node: EventNode + var canvasSize: Int + + init(node: EventNode, canvasSize: Int) { + self.node = node + self.canvasSize = canvasSize + } +} + +struct EventNode: Codable { + var type: NodeType + var name: String? + var tagName: String? + var attributes: [String: String]? + var childNodes: [EventNode] + var rootId: Int? + var id: Int? + + init(id: Int? = nil, + rootId: Int? = nil, + type: NodeType, + name: String? = nil, + tagName: String? = nil, + attributes: [String : String]? = nil, + childNodes: [EventNode] = []) { + self.id = id + self.rootId = rootId + self.type = type + self.name = name + self.tagName = tagName + self.attributes = attributes + self.childNodes = childNodes + } +} diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift b/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift index f2dd67f..79ec9b0 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift @@ -6,12 +6,12 @@ struct Event: Codable { var timestamp: Int64 var _sid: Int - public init(type: EventType, data: AnyEventData, timestamp: TimeInterval, _sid: Int) { + init(type: EventType, data: AnyEventData, timestamp: TimeInterval, _sid: Int) { self.type = type self.data = data self.timestamp = timestamp.milliseconds self._sid = _sid - } + } } protocol EventDataProtocol: Codable { @@ -19,10 +19,12 @@ protocol EventDataProtocol: Codable { struct AnyEventData: Codable { let value: any EventDataProtocol - + private enum ProbeKey: String, CodingKey { case source, tag } - init(_ value: any EventDataProtocol) { self.value = value } + init(_ value: any EventDataProtocol) { + self.value = value + } init(from decoder: Decoder) throws { let probe = try decoder.container(keyedBy: ProbeKey.self) @@ -32,7 +34,7 @@ struct AnyEventData: Codable { } else if src == .mouseMove { self.value = try MouseMoveEventData(from: decoder) } else { - self.value = try EventData(from: decoder) + self.value = try MouseInteractionData(from: decoder) } } else if let tag = try probe.decodeIfPresent(CustomDataTag.self, forKey: .tag) { self.value = switch tag { diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift new file mode 100644 index 0000000..0f40aab --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift @@ -0,0 +1,22 @@ +import Foundation + +struct MouseInteractionData: EventDataProtocol { + var source: IncrementalSource? + var type: MouseInteractions? + var texts = [String]() + var id: Int? + var x: CGFloat? + var y: CGFloat? + + init(source: IncrementalSource? = nil, + type: MouseInteractions? = nil, + id: Int? = nil, + x: CGFloat? = nil, + y: CGFloat? = nil) { + self.source = source + self.type = type + self.id = id + self.x = x + self.y = y + } +} diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/MouseMoveEventData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/MouseMoveEventData.swift index 12f390d..9776584 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/MouseMoveEventData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/MouseMoveEventData.swift @@ -1,7 +1,7 @@ import Foundation -public struct MouseMoveEventData: EventDataProtocol { - public struct Position: Codable { +struct MouseMoveEventData: EventDataProtocol { + struct Position: Codable { var x: Int var y: Int var id: Int? diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift new file mode 100644 index 0000000..b1a35a8 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift @@ -0,0 +1,13 @@ +import Foundation + +struct WindowData: EventDataProtocol { + var href: String? + var width: Int? + var height: Int? + + init(href: String? = nil, width: Int? = nil, height: Int? = nil) { + self.href = href + self.width = width + self.height = height + } +} diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift index 3e505fb..e350c89 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift @@ -20,7 +20,7 @@ struct ExportImage: Equatable { self.timestamp = timestamp } - func eventNode(id: Int, use_rr_dataURL: Bool = true) -> EventNode { + func eventNode(id: Int, rr_dataURL: String) -> EventNode { EventNode( id: id, type: .Element, From 4a9513bbc6a3103c0710ce0a576ba9b0fc081ecd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Sun, 14 Dec 2025 18:29:28 -0800 Subject: [PATCH 03/13] missed old file --- .../RRWeb/EventData.swift | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift deleted file mode 100644 index 763c72e..0000000 --- a/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation - -public struct EventData: EventDataProtocol { - public struct Attributes: Codable { - var id: Int? - var attributes: [String: String]? - } - - public struct Removal: Codable { - var parentId: Int - var id: Int - } - - public struct Addition: Codable { - var parentId: Int - var nextId: Int?? - var node: EventNode - } - - var source: IncrementalSource? - var type: MouseInteractions? - var texts = [String]() - var attributes: [Attributes]? - var href: String? - var width: Int? - var height: Int? - var node: EventNode? - var removes: [Removal]? - var adds: [Addition]? - var id: Int? - var x: CGFloat? - var y: CGFloat? - - public init(source: IncrementalSource? = nil, - type: MouseInteractions? = nil, - node: EventNode? = nil, - href: String? = nil, - width: Int? = nil, - height: Int? = nil, - attributes: [Attributes]? = nil, - adds: [Addition]? = nil, - removes: [Removal]? = nil, - id: Int? = nil, - x: CGFloat? = nil, - y: CGFloat? = nil) { - self.source = source - self.type = type - self.node = node - self.href = href - self.width = width - self.height = height - self.attributes = attributes - self.adds = adds - self.removes = removes - self.id = id - self.x = x - self.y = y - } -} - -public struct EventNode: Codable { - public var type: NodeType - public var name: String? - public var tagName: String? - public var attributes: [String: String]? - public var childNodes: [EventNode] - public var rootId: Int? - public var id: Int? - - public init(id: Int? = nil, - rootId: Int? = nil, - type: NodeType, - name: String? = nil, - tagName: String? = nil, - attributes: [String : String]? = nil, - childNodes: [EventNode] = []) { - self.id = id - self.rootId = rootId - self.type = type - self.name = name - self.tagName = tagName - self.attributes = attributes - self.childNodes = childNodes - } -} From a477dac82184ff905c880fcd66d3d33fd8563171 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 09:08:13 -0800 Subject: [PATCH 04/13] address feadback --- .../Exporter/SessionReplayEventGenerator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index f3d1197..a9d451b 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -309,6 +309,6 @@ actor SessionReplayEventGenerator { } func updatePushedCanvasSize() { - pushedCanvasSize += generatingCanvasSize + pushedCanvasSize = generatingCanvasSize } } From 4c26e3a726d2f6b3daeb464ec320edae812ffe31 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 09:11:29 -0800 Subject: [PATCH 05/13] fix compilation --- .../LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift | 2 +- TestApp/Sources/AppDelegate.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift index e350c89..81a4846 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift @@ -26,7 +26,7 @@ struct ExportImage: Equatable { type: .Element, tagName: "canvas", attributes: [ - "rr_dataURL": asBase64PNGDataURL(), + "rr_dataURL": rr_dataURL, "width": "\(originalWidth)", "height": "\(originalHeight)"] ) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 4279597..7f84c08 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -36,7 +36,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { // serviceName: "i-os-sessions", sessionBackgroundTimeout: 3, - autoInstrumentation: [.memory, .urlSession])), + )), SessionReplay(options: .init( isEnabled: true, privacy: .init( From 3fe12c2ca79ef0c18f574d0d4f8db210481f7075 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 09:49:43 -0800 Subject: [PATCH 06/13] address feedback --- .../SessionReplayEventGenerator.swift | 2 +- .../RRWeb/DomData.swift | 27 ++++++++++--- .../ScreenCapture/ExportImage.swift | 2 +- TestApp/Sources/AppDelegate.swift | 38 +++++++++---------- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index a9d451b..feeabfe 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -288,7 +288,7 @@ actor SessionReplayEventGenerator { var rootNode = EventNode(id: nextId, type: .Document) let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html") rootNode.childNodes.append(htmlDocNode) - let base64String = exportImage.data.base64EncodedString() + let base64String = exportImage.base64DataURL() let htmlNode = EventNode(id: nextId, type: .Element, tagName: "html", attributes: ["lang": "en"], childNodes: [ EventNode(id: nextId, type: .Element, tagName: "head", attributes: [:]), diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift index 9322744..620bdc9 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -8,6 +8,21 @@ struct DomData: EventDataProtocol { self.node = node self.canvasSize = canvasSize } + + private enum CodingKeys: String, CodingKey { + case node + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.node = try container.decode(EventNode.self, forKey: .node) + self.canvasSize = 0 + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(node, forKey: .node) + } } struct EventNode: Codable { @@ -20,12 +35,12 @@ struct EventNode: Codable { var id: Int? init(id: Int? = nil, - rootId: Int? = nil, - type: NodeType, - name: String? = nil, - tagName: String? = nil, - attributes: [String : String]? = nil, - childNodes: [EventNode] = []) { + rootId: Int? = nil, + type: NodeType, + name: String? = nil, + tagName: String? = nil, + attributes: [String : String]? = nil, + childNodes: [EventNode] = []) { self.id = id self.rootId = rootId self.type = type diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift index 81a4846..8f7cd85 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift @@ -41,7 +41,7 @@ struct ExportImage: Equatable { } } - func asBase64PNGDataURL() -> String { + func base64DataURL() -> String { "data:\(mimeType);base64,\(data.base64EncodedString())" } diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 7f84c08..3a51c4b 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -10,30 +10,30 @@ final class AppDelegate: NSObject, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { //let mobileKey = "mob-48fd3788-eab7-4b72-b607-e41712049dbd" - let mobileKey = "mob-a211d8b4-9f80-4170-ba05-0120566a7bd7" // Andrey Sessions stg production +// let mobileKey = "mob-a211d8b4-9f80-4170-ba05-0120566a7bd7" // Andrey Sessions stg production +// +// +// //let mobileKey = "mob-d6e200b8-4a13-4c47-8ceb-7eb1f1705070" // Spree demo app Alexis Perflet config = { () -> LDConfig in +// let config = { () -> LDConfig in +// var config = LDConfig( +// mobileKey: mobileKey, +// autoEnvAttributes: .enabled +// ) +// config.plugins = [ +// Observability(options: .init( +// serviceName: "alexis-perf", +// otlpEndpoint: "https://otel.observability.ld-stg.launchdarkly.com:4318", +// backendUrl: "https://pub.observability.ld-stg.launchdarkly.com/", - - //let mobileKey = "mob-d6e200b8-4a13-4c47-8ceb-7eb1f1705070" // Spree demo app Alexis Perflet config = { () -> LDConfig in + let mobileKey = "mob-f2aca03d-4a84-4b9d-bc35-db20cbb4ca0a" // iOS Session Production let config = { () -> LDConfig in var config = LDConfig( - mobileKey: mobileKey, - autoEnvAttributes: .enabled - ) + mobileKey: mobileKey, + autoEnvAttributes: .enabled + ) config.plugins = [ Observability(options: .init( - serviceName: "alexis-perf", - otlpEndpoint: "https://otel.observability.ld-stg.launchdarkly.com:4318", - backendUrl: "https://pub.observability.ld-stg.launchdarkly.com/", - - //let mobileKey = "mob-f2aca03d-4a84-4b9d-bc35-db20cbb4ca0a" // iOS Session Production - //let config = { () -> LDConfig in - // var config = LDConfig( - // mobileKey: mobileKey, - // autoEnvAttributes: .enabled - // ) - // config.plugins = [ - // Observability(options: .init( - // serviceName: "i-os-sessions", + serviceName: "i-os-sessions", sessionBackgroundTimeout: 3, )), From 468c59ec88935b4787302711a843b04ff516b6e4 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 10:03:13 -0800 Subject: [PATCH 07/13] fix --- Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift | 1 + Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift index 1be960d..487578f 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift @@ -28,6 +28,7 @@ protocol CommandPayload: Codable { struct AnyCommand: Codable { let value: any CommandPayload + // Transitional let canvasSize: Int private enum K: String, CodingKey { case property } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift index 620bdc9..7dc1348 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -2,6 +2,7 @@ import Foundation struct DomData: EventDataProtocol { var node: EventNode + var texts = [String]() var canvasSize: Int init(node: EventNode, canvasSize: Int) { From e1e882da191c2b0fb6e5ed6765b21ebf637cbfdb Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 10:13:16 -0800 Subject: [PATCH 08/13] debug code --- .../Exporter/SessionReplayEventGenerator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index feeabfe..aff1bb8 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -100,7 +100,7 @@ actor SessionReplayEventGenerator { if let imageId, let lastExportImage, - lastExportImage.originalWidth == exportImage.originalWidth, + lastExportImage.originalWidth == exportImage.originalWidth + 1, lastExportImage.originalHeight == exportImage.originalHeight, generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit { events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId)) From 8234b50bbc297dfe8838173f63f7d7e40d68fe95 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 11:48:29 -0800 Subject: [PATCH 09/13] wip --- .../SessionReplayEventGenerator.swift | 6 +- .../RRWeb/EventData.swift | 67 +++++++++++++++++++ TestApp/Sources/AppDelegate.swift | 41 ++++++------ 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index aff1bb8..c607428 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -280,11 +280,11 @@ actor SessionReplayEventGenerator { let eventData = fullSnapshotData(exportImage: exportImage) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) // start again counting canvasSize - generatingCanvasSize = eventData.canvasSize + //generatingCanvasSize = eventData.canvasSize return event } - func fullSnapshotData(exportImage: ExportImage) -> DomData { + func fullSnapshotData(exportImage: ExportImage) -> EventData { var rootNode = EventNode(id: nextId, type: .Document) let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html") rootNode.childNodes.append(htmlDocNode) @@ -299,7 +299,7 @@ actor SessionReplayEventGenerator { imageId = id rootNode.childNodes.append(htmlNode) - return DomData(node: rootNode, canvasSize: base64String.count) + return EventData(node: rootNode) } private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) { diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift new file mode 100644 index 0000000..deda165 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift @@ -0,0 +1,67 @@ +// +// EventData.swift +// swift-launchdarkly-observability +// +// Created by Andrey Belonogov on 12/15/25. +// + + +import Foundation + +struct EventData: EventDataProtocol { + public struct Attributes: Codable { + var id: Int? + var attributes: [String: String]? + } + + public struct Removal: Codable { + var parentId: Int + var id: Int + } + + public struct Addition: Codable { + var parentId: Int + var nextId: Int?? + var node: EventNode + } + + var source: IncrementalSource? + var type: MouseInteractions? + var texts = [String]() + var attributes: [Attributes]? + var href: String? + var width: Int? + var height: Int? + var node: EventNode? + var removes: [Removal]? + var adds: [Addition]? + var id: Int? + var x: CGFloat? + var y: CGFloat? + + init(source: IncrementalSource? = nil, + type: MouseInteractions? = nil, + node: EventNode? = nil, + href: String? = nil, + width: Int? = nil, + height: Int? = nil, + attributes: [Attributes]? = nil, + adds: [Addition]? = nil, + removes: [Removal]? = nil, + id: Int? = nil, + x: CGFloat? = nil, + y: CGFloat? = nil) { + self.source = source + self.type = type + self.node = node + self.href = href + self.width = width + self.height = height + self.attributes = attributes + self.adds = adds + self.removes = removes + self.id = id + self.x = x + self.y = y + } +} diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 3a51c4b..004e20c 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -10,33 +10,33 @@ final class AppDelegate: NSObject, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { //let mobileKey = "mob-48fd3788-eab7-4b72-b607-e41712049dbd" -// let mobileKey = "mob-a211d8b4-9f80-4170-ba05-0120566a7bd7" // Andrey Sessions stg production -// -// -// //let mobileKey = "mob-d6e200b8-4a13-4c47-8ceb-7eb1f1705070" // Spree demo app Alexis Perflet config = { () -> LDConfig in -// let config = { () -> LDConfig in -// var config = LDConfig( -// mobileKey: mobileKey, -// autoEnvAttributes: .enabled -// ) -// config.plugins = [ -// Observability(options: .init( -// serviceName: "alexis-perf", -// otlpEndpoint: "https://otel.observability.ld-stg.launchdarkly.com:4318", -// backendUrl: "https://pub.observability.ld-stg.launchdarkly.com/", + let mobileKey = "mob-a211d8b4-9f80-4170-ba05-0120566a7bd7" // Andrey Sessions stg production - let mobileKey = "mob-f2aca03d-4a84-4b9d-bc35-db20cbb4ca0a" // iOS Session Production + + //let mobileKey = "mob-d6e200b8-4a13-4c47-8ceb-7eb1f1705070" // Spree demo app Alexis Perflet config = { () -> LDConfig in let config = { () -> LDConfig in var config = LDConfig( - mobileKey: mobileKey, - autoEnvAttributes: .enabled - ) + mobileKey: mobileKey, + autoEnvAttributes: .enabled + ) config.plugins = [ Observability(options: .init( - serviceName: "i-os-sessions", + serviceName: "alexis-perf", + otlpEndpoint: "https://otel.observability.ld-stg.launchdarkly.com:4318", + backendUrl: "https://pub.observability.ld-stg.launchdarkly.com/", + + //let mobileKey = "mob-f2aca03d-4a84-4b9d-bc35-db20cbb4ca0a" // iOS Session Production + //let config = { () -> LDConfig in + // var config = LDConfig( + // mobileKey: mobileKey, + // autoEnvAttributes: .enabled + // ) + // config.plugins = [ + // Observability(options: .init( + // serviceName: "i-os-sessions", sessionBackgroundTimeout: 3, - )), + )), SessionReplay(options: .init( isEnabled: true, privacy: .init( @@ -50,7 +50,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate { return config }() - let context = { () -> LDContext in var contextBuilder = LDContextBuilder( key: "12345" From 0c667793ffa75e38f03307a3c99368219d95efcb Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 14:46:08 -0800 Subject: [PATCH 10/13] comment --- Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift index 7dc1348..955577f 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -3,6 +3,7 @@ import Foundation struct DomData: EventDataProtocol { var node: EventNode var texts = [String]() + // Transitional var canvasSize: Int init(node: EventNode, canvasSize: Int) { From b8a5d6c0ad8719e9532df4337d561f599f520860 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 15:08:36 -0800 Subject: [PATCH 11/13] fix replay working --- .../SessionReplayEventGenerator.swift | 8 +-- .../Operations/PushPayloadOperation.swift | 7 +- .../RRWeb/EventData.swift | 67 ------------------- 3 files changed, 8 insertions(+), 74 deletions(-) delete mode 100644 Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift index c607428..feeabfe 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift @@ -100,7 +100,7 @@ actor SessionReplayEventGenerator { if let imageId, let lastExportImage, - lastExportImage.originalWidth == exportImage.originalWidth + 1, + lastExportImage.originalWidth == exportImage.originalWidth, lastExportImage.originalHeight == exportImage.originalHeight, generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit { events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId)) @@ -280,11 +280,11 @@ actor SessionReplayEventGenerator { let eventData = fullSnapshotData(exportImage: exportImage) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) // start again counting canvasSize - //generatingCanvasSize = eventData.canvasSize + generatingCanvasSize = eventData.canvasSize return event } - func fullSnapshotData(exportImage: ExportImage) -> EventData { + func fullSnapshotData(exportImage: ExportImage) -> DomData { var rootNode = EventNode(id: nextId, type: .Document) let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html") rootNode.childNodes.append(htmlDocNode) @@ -299,7 +299,7 @@ actor SessionReplayEventGenerator { imageId = id rootNode.childNodes.append(htmlNode) - return EventData(node: rootNode) + return DomData(node: rootNode, canvasSize: base64String.count) } private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) { diff --git a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift index 36b623d..f8f99df 100644 --- a/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift +++ b/Sources/LaunchDarklySessionReplay/Operations/PushPayloadOperation.swift @@ -4,11 +4,11 @@ import Foundation #endif struct PushPayloadVariables: Codable { - public struct EventsInput: Codable { + struct EventsInput: Codable { var events: [Event] } - public struct ErrorInput: Codable { + struct ErrorInput: Codable { } @@ -53,7 +53,8 @@ struct PushPayloadVariables: Codable { extension SessionReplayAPIService { func pushPayload(_ variables: PushPayloadVariables) async throws { - let gqlRequest = GraphQLRequest(query: """ + let _: GraphQLEmptyData = try await gqlClient.execute( + query: """ mutation PushPayload( $session_secure_id: String! $payload_id: ID! diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift deleted file mode 100644 index deda165..0000000 --- a/Sources/LaunchDarklySessionReplay/RRWeb/EventData.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// EventData.swift -// swift-launchdarkly-observability -// -// Created by Andrey Belonogov on 12/15/25. -// - - -import Foundation - -struct EventData: EventDataProtocol { - public struct Attributes: Codable { - var id: Int? - var attributes: [String: String]? - } - - public struct Removal: Codable { - var parentId: Int - var id: Int - } - - public struct Addition: Codable { - var parentId: Int - var nextId: Int?? - var node: EventNode - } - - var source: IncrementalSource? - var type: MouseInteractions? - var texts = [String]() - var attributes: [Attributes]? - var href: String? - var width: Int? - var height: Int? - var node: EventNode? - var removes: [Removal]? - var adds: [Addition]? - var id: Int? - var x: CGFloat? - var y: CGFloat? - - init(source: IncrementalSource? = nil, - type: MouseInteractions? = nil, - node: EventNode? = nil, - href: String? = nil, - width: Int? = nil, - height: Int? = nil, - attributes: [Attributes]? = nil, - adds: [Addition]? = nil, - removes: [Removal]? = nil, - id: Int? = nil, - x: CGFloat? = nil, - y: CGFloat? = nil) { - self.source = source - self.type = type - self.node = node - self.href = href - self.width = width - self.height = height - self.attributes = attributes - self.adds = adds - self.removes = removes - self.id = id - self.x = x - self.y = y - } -} From 0ede2869e5c0d6af21b3fc732090bb36980a1247 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 15:29:13 -0800 Subject: [PATCH 12/13] unit test --- Package.swift | 7 ++ .../RRWeb/DomData.swift | 1 - .../RRWeb/MouseInteractionData.swift | 1 - .../SessionReplayEventGeneratorTests.swift | 81 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift diff --git a/Package.swift b/Package.swift index f3e5b3f..2c424b6 100644 --- a/Package.swift +++ b/Package.swift @@ -92,5 +92,12 @@ let package = Package( ], resources: [.process("GraphQL/Queries")] ), + .testTarget( + name: "SessionReplayTests", + dependencies: [ + "LaunchDarklySessionReplay", + "LaunchDarklyObservability" + ] + ), ] ) diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift index 955577f..edac064 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -2,7 +2,6 @@ import Foundation struct DomData: EventDataProtocol { var node: EventNode - var texts = [String]() // Transitional var canvasSize: Int diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift index 0f40aab..ba92102 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift @@ -3,7 +3,6 @@ import Foundation struct MouseInteractionData: EventDataProtocol { var source: IncrementalSource? var type: MouseInteractions? - var texts = [String]() var id: Int? var x: CGFloat? var y: CGFloat? diff --git a/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift b/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift new file mode 100644 index 0000000..aa01cdf --- /dev/null +++ b/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import LaunchDarklySessionReplay +import LaunchDarklyObservability +import OSLog +import CoreGraphics + +struct SessionReplayEventGeneratorTests { + + private func makeExportImage(dataSize: Int, width: Int, height: Int, timestamp: TimeInterval) -> ExportImage { + let data = Data(count: dataSize) + return ExportImage( + data: data, + originalWidth: width, + originalHeight: height, + scale: 1.0, + format: .png, + timestamp: timestamp + ) + } + + @Test("Appends draw image event when same size and below limit") + func appendsDrawImageEventWhenSameSizeAndBelowLimit() async { + // Arrange + let generator = SessionReplayEventGenerator( + log: OSLog(subsystem: "test", category: "test"), + title: "Test" + ) + + // First image triggers full snapshot (sets imageId and lastExportImage) + let firstImage = makeExportImage(dataSize: 128, width: 320, height: 480, timestamp: 1.0) + // Second image has same dimensions but different data -> should append drawImageEvent branch + let secondImage = makeExportImage(dataSize: 256, width: 320, height: 480, timestamp: 2.0) + + let items: [EventQueueItem] = [ + EventQueueItem(payload: ImageItemPayload(exportImage: firstImage)), + EventQueueItem(payload: ImageItemPayload(exportImage: secondImage)) + ] + + // Act + let events = await generator.generateEvents(items: items) + + // Assert + #expect(events.count == 4) // window/meta + fullSnapshot + viewport + drawImage + #expect(events[0].type == .Meta) + #expect(events[1].type == .FullSnapshot) + #expect(events[2].type == .Custom) + #expect(events[3].type == .IncrementalSnapshot) // drawImageEvent + } + + @Test("Appends full snapshot when canvas buffer limit exceeded") + func appendsFullSnapshotWhenCanvasBufferLimitExceeded() async { + // Arrange + let generator = SessionReplayEventGenerator( + log: OSLog(subsystem: "test", category: "test"), + title: "Test" + ) + + // Choose a first image whose base64 string length will exceed the canvasBufferLimit (~10MB) + // Base64 inflates ~4/3, so ~8MB raw data is sufficient. + let largeFirstImage = makeExportImage(dataSize: 8_000_000, width: 320, height: 480, timestamp: 1.0) + let secondImageSameSize = makeExportImage(dataSize: 256, width: 320, height: 480, timestamp: 2.0) + + let items: [EventQueueItem] = [ + EventQueueItem(payload: ImageItemPayload(exportImage: largeFirstImage)), + EventQueueItem(payload: ImageItemPayload(exportImage: secondImageSameSize)) + ] + + // Act + let events = await generator.generateEvents(items: items) + + // Assert + #expect(events.count == 6) // two full snapshots (window/meta + fullSnapshot + viewport) + #expect(events[0].type == .Meta) + #expect(events[1].type == .FullSnapshot) + #expect(events[2].type == .Custom) + #expect(events[3].type == .Meta) + #expect(events[4].type == .FullSnapshot) + #expect(events[5].type == .Custom) + } +} + From 841f98983071d0fc757708f6d1a85bed30da93ff Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 15 Dec 2025 17:09:55 -0800 Subject: [PATCH 13/13] introduce entourage --- .../swift-launchdarkly-observability-Package.xcscheme | 10 ++++++++++ .../Exporter/SessionReplayEventGenerator.swift | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme index 91ea3f3..fa1d749 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-launchdarkly-observability-Package.xcscheme @@ -78,6 +78,16 @@ ReferencedContainer = "container:"> + + + + Event? { guard case .touchDown = interaction.kind else { return nil } - let viewName = interaction.target?.className let eventData = CustomEventData(tag: .click, payload: ClickPayload( clickTarget: interaction.target?.className ?? "", clickTextContent: interaction.target?.accessibilityIdentifier ?? "", @@ -260,7 +261,7 @@ actor SessionReplayEventGenerator { let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) - generatingCanvasSize += eventData.canvasSize + generatingCanvasSize += eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage return event } @@ -280,7 +281,7 @@ actor SessionReplayEventGenerator { let eventData = fullSnapshotData(exportImage: exportImage) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) // start again counting canvasSize - generatingCanvasSize = eventData.canvasSize + generatingCanvasSize = eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage return event }