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] { var events = [Event]() + self.generatingCanvasSize = pushedCanvasSize for item in items { appendEvents(item: item, events: &events) } @@ -56,7 +68,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, @@ -64,7 +76,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, @@ -91,7 +103,8 @@ actor SessionReplayEventGenerator { if let imageId, let lastExportImage, lastExportImage.originalWidth == exportImage.originalWidth, - lastExportImage.originalHeight == exportImage.originalHeight { + lastExportImage.originalHeight == exportImage.originalHeight, + 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 @@ -122,14 +135,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, @@ -145,7 +158,7 @@ actor SessionReplayEventGenerator { timeOffset: p.timestamp - interaction.timestamp) }) default: - Optional.none + Optional.none } { let event = Event(type: .IncrementalSnapshot, data: AnyEventData(touchEventData), @@ -162,7 +175,6 @@ actor SessionReplayEventGenerator { func clickEvent(interaction: TouchInteraction) -> 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 ?? "", @@ -174,10 +186,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, @@ -232,7 +242,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, @@ -244,10 +255,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 + RRWebPlayerConstants.canvasDrawEntourage return event } @@ -264,27 +278,29 @@ actor SessionReplayEventGenerator { func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event { id = 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 + RRWebPlayerConstants.canvasDrawEntourage 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.base64DataURL() + 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]) { @@ -292,4 +308,8 @@ actor SessionReplayEventGenerator { events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp)) events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp)) } + + func updatePushedCanvasSize() { + pushedCanvasSize = generatingCanvasSize + } } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift index f0036dc..1cffc3d 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,11 @@ actor SessionReplayExporter: EventExporting { guard events.isNotEmpty else { return } let input = PushPayloadVariables(sessionSecureId: initializedSession.secureId, payloadId: "\(nextPayloadId)", events: events) + 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 d38f2bb..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 { } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift index 37661c3..487578f 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,8 @@ protocol CommandPayload: Codable { struct AnyCommand: Codable { let value: any CommandPayload + // Transitional + let canvasSize: Int private enum K: String, CodingKey { case property } @@ -32,8 +38,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 +51,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..edac064 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -0,0 +1,53 @@ +import Foundation + +struct DomData: EventDataProtocol { + var node: EventNode + // Transitional + var canvasSize: Int + + init(node: EventNode, canvasSize: Int) { + 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 { + 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/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 - } -} diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift new file mode 100644 index 0000000..ba92102 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/RRWeb/MouseInteractionData.swift @@ -0,0 +1,21 @@ +import Foundation + +struct MouseInteractionData: EventDataProtocol { + var source: IncrementalSource? + var type: MouseInteractions? + 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..8f7cd85 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift @@ -20,13 +20,13 @@ 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, tagName: "canvas", attributes: [ - "rr_dataURL": asBase64PNGDataURL(), + "rr_dataURL": rr_dataURL, "width": "\(originalWidth)", "height": "\(originalHeight)"] ) @@ -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 4279597..004e20c 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( @@ -50,7 +50,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate { return config }() - let context = { () -> LDContext in var contextBuilder = LDContextBuilder( key: "12345" 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) + } +} +