diff --git a/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift b/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift index 2afcfeb7..cfc2d58f 100644 --- a/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift +++ b/Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift @@ -44,7 +44,13 @@ public struct SessionReplayOptions { } } + public enum CompressionMethod { + case screenImage + case overlayTiles(layers: Int = 10) + } + public var isEnabled: Bool + public var compression: CompressionMethod = .overlayTiles() public var serviceName: String public var privacy = PrivacyOptions() public var log: OSLog @@ -52,10 +58,12 @@ public struct SessionReplayOptions { public init(isEnabled: Bool = true, serviceName: String = "sessionreplay-swift", privacy: PrivacyOptions = PrivacyOptions(), + compression: CompressionMethod = .overlayTiles(), log: OSLog = OSLog(subsystem: "com.launchdarkly", category: "LaunchDarklySessionReplayPlugin")) { self.isEnabled = isEnabled self.serviceName = serviceName self.privacy = privacy + self.compression = compression self.log = log } } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift b/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift index d7f02860..828d2a43 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift @@ -7,12 +7,12 @@ struct ImageItemPayload: EventQueueItemPayload { } var timestamp: TimeInterval { - exportImage.timestamp + exportFrame.timestamp } func cost() -> Int { - exportImage.data.count + exportFrame.images.reduce(0) { $0 + $1.data.count } } - let exportImage: ExportImage + let exportFrame: ExportFrame } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift b/Sources/LaunchDarklySessionReplay/Exporter/RRWebEventGenerator.swift similarity index 53% rename from Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift rename to Sources/LaunchDarklySessionReplay/Exporter/RRWebEventGenerator.swift index 0f7ba952..ce51ee6e 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayEventGenerator.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/RRWebEventGenerator.swift @@ -5,7 +5,7 @@ import UIKit import LaunchDarklyObservability import OSLog #if !LD_COCOAPODS - import Common +import Common #endif enum RRWebPlayerConstants { @@ -17,7 +17,17 @@ enum RRWebPlayerConstants { static let canvasDrawEntourage = 300 // bytes } -actor SessionReplayEventGenerator { +actor RRWebEventGenerator { + enum Dom { + static let html = "html" + static let head = "head" + static let body = "body" + static let lang = "lang" + static let en = "en" + static let style = "style" + static let bodyStyle = "position:relative;" + } + private var title: String private let padding = RRWebPlayerConstants.padding private var sid = 0 @@ -35,10 +45,12 @@ actor SessionReplayEventGenerator { private var generatingCanvasSize: Int = 0 private var imageId: Int? - private var lastImageWidth: Int = -1 - private var lastImageHeight: Int = -1 + private var bodyId: Int? + private var lastImageSize: CGSize? private var stats: SessionReplayStats? private let isDebug = false + private var keyNodeIds = [RemovedNode]() + private var frameToLastKeyNodeIndex: [ImageSignature: Int] = [:] init(log: OSLog, title: String) { if isDebug { @@ -70,18 +82,18 @@ actor SessionReplayEventGenerator { // artificial mouse movement to wake up session replay player events.append(Event(type: .IncrementalSnapshot, data: AnyEventData(MouseInteractionData(source: .mouseInteraction, - type: .mouseDown, - id: imageId, - x: padding.width, - y: padding.height)), + type: .mouseDown, + id: imageId, + x: padding.width, + y: padding.height)), timestamp: timestamp, _sid: nextSid)) events.append(Event(type: .IncrementalSnapshot, data: AnyEventData(MouseInteractionData(source: .mouseInteraction, - type: .mouseUp, - id: imageId, - x: padding.width, - y: padding.height)), + type: .mouseUp, + id: imageId, + x: padding.width, + y: padding.height)), timestamp: timestamp, _sid: nextSid)) } @@ -89,24 +101,22 @@ actor SessionReplayEventGenerator { func appendEvents(item: EventQueueItem, events: inout [Event]) { switch item.payload { case let payload as ImageItemPayload: - let exportImage = payload.exportImage + let exportFrame = payload.exportFrame defer { - lastImageWidth = exportImage.originalWidth - lastImageHeight = exportImage.originalHeight + lastImageSize = exportFrame.originalSize } - stats?.addExportImage(exportImage) + stats?.addExportFrame(exportFrame) let timestamp = item.timestamp - if let imageId, - lastImageWidth == exportImage.originalWidth, - lastImageHeight == exportImage.originalHeight, - generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit { - events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId)) + if let bodyId, let imageId, + lastImageSize == exportFrame.originalSize, + generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit { + events.append(contentsOf: addTileReusedNodes(exportFrame: exportFrame, timestamp: timestamp, bodyId: bodyId)) } else { // if screen changed size we send fullSnapshot as canvas resizing might take to many hours on the server - appendFullSnapshotEvents(exportImage, timestamp, &events) + appendFullSnapshotEvents(exportFrame, timestamp, &events) } case let interaction as TouchInteraction: @@ -122,29 +132,25 @@ actor SessionReplayEventGenerator { } } - func paddedWidth(_ width: Int) -> Int { - width + Int(padding.width) * 2 - } - - func paddedHeight(_ height: Int) -> Int { - height + Int(padding.height) * 2 + func paddedSize(_ size: CGSize) -> CGSize { + CGSize(width: size.width + padding.width * 2, height: size.height + padding.height * 2) } fileprivate func appendTouchInteraction(interaction: TouchInteraction, events: inout [Event]) { if let touchEventData: EventDataProtocol = switch interaction.kind { case .touchDown(let point): MouseInteractionData(source: .mouseInteraction, - type: .touchStart, - id: imageId, - x: point.x + padding.width, - y: point.y + padding.height) + type: .touchStart, + id: imageId, + x: point.x + padding.width, + y: point.y + padding.height) case .touchUp(let point): MouseInteractionData(source: .mouseInteraction, - type: .touchEnd, - id: imageId, - x: point.x + padding.width, - y: point.y + padding.height) + type: .touchEnd, + id: imageId, + x: point.x + padding.width, + y: point.y + padding.height) case .touchPath(let points): MouseMoveEventData( @@ -154,7 +160,7 @@ actor SessionReplayEventGenerator { y: p.position.y + padding.height, id: imageId, timeOffset: p.timestamp - interaction.timestamp) }) - + default: Optional.none } { @@ -184,8 +190,8 @@ actor SessionReplayEventGenerator { return event } - func windowEvent(href: String, width: Int, height: Int, timestamp: TimeInterval) -> Event { - let eventData = WindowData(href: href, width: width, height: height) + func windowEvent(href: String, originalSize: CGSize, timestamp: TimeInterval) -> Event { + let eventData = WindowData(href: href, size: paddedSize(originalSize)) let event = Event(type: .Meta, data: AnyEventData(eventData), timestamp: timestamp, @@ -217,14 +223,14 @@ actor SessionReplayEventGenerator { return event } - func viewPortEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event { - let payload = ViewportPayload(width: exportImage.originalWidth, - height: exportImage.originalHeight, - availWidth: exportImage.originalWidth, - availHeight: exportImage.originalHeight, + func viewPortEvent(exportFrame: ExportFrame, timestamp: TimeInterval) -> Event { + let payload = ViewportPayload(width: Int(exportFrame.originalSize.width), + height: Int(exportFrame.originalSize.height), + availWidth: Int(exportFrame.originalSize.width), + availHeight: Int(exportFrame.originalSize.height), colorDepth: 30, pixelDepth: 30, - orientation: exportImage.orientation) + orientation: exportFrame.orientation) let eventData = CustomEventData(tag: .viewport, payload: payload) let event = Event(type: .Custom, data: AnyEventData(eventData), @@ -233,24 +239,70 @@ actor SessionReplayEventGenerator { return event } - func drawImageEvent(exportImage: ExportImage, timestamp: TimeInterval, imageId: Int) -> Event { - let clearRectCommand = ClearRect(x: 0, y: 0, width: exportImage.originalWidth, height: exportImage.originalHeight) - 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, - dy: 0, - dw: exportImage.originalWidth, - dh: exportImage.originalHeight) + func addTileReusedNodes(exportFrame: ExportFrame, timestamp: TimeInterval, bodyId: Int) -> [Event] { + var removes = [RemovedNode]() + var adds = [AddedNode]() + var totalCanvasSize = 0 + + if exportFrame.isKeyframe { + removes = keyNodeIds + keyNodeIds.removeAll(keepingCapacity: true) + frameToLastKeyNodeIndex.removeAll() + } + + if let signature = exportFrame.imageSignature, + let lastKeyNodeIdx = frameToLastKeyNodeIndex[signature], + lastKeyNodeIdx < keyNodeIds.count { + removes = Array(keyNodeIds[(lastKeyNodeIdx + 1)...]) + keyNodeIds = Array(keyNodeIds[0...lastKeyNodeIdx]) + frameToLastKeyNodeIndex = frameToLastKeyNodeIndex.filter { $0.value > lastKeyNodeIdx } + } else { + for image in exportFrame.images { + let tileCanvasId = nextId + let base64DataURL = image.base64DataURL(mimeType: exportFrame.mimeType) + let tileNode = image.tileEventNode(id: tileCanvasId, rr_dataURL: base64DataURL) + adds.append(AddedNode(parentId: bodyId, nextId: nil, node: tileNode)) + keyNodeIds.append(RemovedNode(parentId: bodyId, id: tileCanvasId)) + totalCanvasSize += base64DataURL.count + } + if let signature = exportFrame.imageSignature { + frameToLastKeyNodeIndex[signature] = keyNodeIds.count - 1 + } + } + + if let firstId = keyNodeIds.first?.id, firstId != imageId { + // Keyframe replacement can remove the previously tracked node. + imageId = firstId + } + + let mutationData = MutationData(adds: adds, removes: removes, canvasSize: totalCanvasSize) + let mutationEvent = Event(type: .IncrementalSnapshot, + data: AnyEventData(mutationData), + timestamp: timestamp, + _sid: nextSid) + generatingCanvasSize += mutationData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage + return [mutationEvent] + } + + func drawImageEvent(exportFrame: ExportFrame, timestamp: TimeInterval, imageId: Int) -> Event { + var commands = [AnyCommand]() + for image in exportFrame.images { + if exportFrame.isKeyframe { + let clearRectCommand = ClearRect(rect: image.rect) + commands.append(AnyCommand(clearRectCommand, canvasSize: 80)) + } + let base64String = image.data.base64EncodedString() + let arrayBuffer = RRArrayBuffer(base64: base64String) + let blob = AnyRRNode(RRBlob(data: [AnyRRNode(arrayBuffer)], type: exportFrame.mimeType)) + let drawImageCommand = DrawImage(image: AnyRRNode(RRImageBitmap(args: [blob])), + rect: image.rect) + commands.append(AnyCommand(drawImageCommand, canvasSize: base64String.count)) + } let eventData = CanvasDrawData(source: .canvasMutation, id: imageId, type: .mouseUp, - commands: [ - AnyCommand(clearRectCommand, canvasSize: 80), - AnyCommand(drawImageCommand, canvasSize: base64String.count) - ]) + commands: commands) let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) @@ -269,37 +321,52 @@ actor SessionReplayEventGenerator { return event } - func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event { + func fullSnapshotEvent(exportFrame: ExportFrame, timestamp: TimeInterval) -> Event { id = 0 - let eventData = fullSnapshotData(exportImage: exportImage) + let eventData = fullSnapshotData(exportFrame: exportFrame) let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid) // start again counting canvasSize generatingCanvasSize = eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage return event } - func fullSnapshotData(exportImage: ExportImage) -> DomData { + func fullSnapshotData(exportFrame: ExportFrame) -> DomData { var rootNode = EventNode(id: nextId, type: .Document) - let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html") + let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: Dom.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, rr_dataURL: base64String) - ]), - ]) - imageId = id + let firstImage = exportFrame.images[0] + let base64String = firstImage.base64DataURL(mimeType: exportFrame.mimeType) + + let headNode = EventNode(id: nextId, type: .Element, tagName: Dom.head, attributes: [:]) + let currentBodyId = nextId + let newImageId = nextId + let bodyNode = EventNode(id: currentBodyId, type: .Element, tagName: Dom.body, + attributes: [Dom.style: Dom.bodyStyle], + childNodes: [ + exportFrame.eventNode(id: newImageId, rr_dataURL: base64String) + ]) + let htmlNode = EventNode(id: nextId, type: .Element, tagName: Dom.html, + attributes: [Dom.lang: Dom.en], + childNodes: [headNode, bodyNode]) + imageId = newImageId + bodyId = currentBodyId + keyNodeIds = [RemovedNode(parentId: currentBodyId, id: newImageId)] + frameToLastKeyNodeIndex.removeAll() + if let signature = exportFrame.imageSignature { + frameToLastKeyNodeIndex[signature] = 0 + } rootNode.childNodes.append(htmlNode) return DomData(node: rootNode, canvasSize: base64String.count) } - private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) { - events.append(windowEvent(href: "", width: paddedWidth(exportImage.originalWidth), height: paddedHeight(exportImage.originalHeight), timestamp: timestamp)) - events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp)) - events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp)) + private func appendFullSnapshotEvents(_ exportFrame: ExportFrame, _ timestamp: TimeInterval, _ events: inout [Event]) { + events.append(windowEvent(href: "", originalSize: exportFrame.originalSize, timestamp: timestamp)) + events.append(fullSnapshotEvent(exportFrame: exportFrame, timestamp: timestamp)) + events.append(viewPortEvent(exportFrame: exportFrame, timestamp: timestamp)) + } + + private func appendKeyFrameEvents(_ exportFrame: ExportFrame, _ timestamp: TimeInterval, _ events: inout [Event]) { } func updatePushedCanvasSize() { diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift index 42816aa6..7a1dbc5c 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift @@ -11,7 +11,7 @@ actor SessionReplayExporter: EventExporting { private let context: SessionReplayContext private let sessionManager: SessionManaging private var isInitializing = false - private var eventGenerator: SessionReplayEventGenerator + private var eventGenerator: RRWebEventGenerator private var log: OSLog private var initializedSession: InitializeSessionResponse? private var sessionInfo: SessionInfo? @@ -32,7 +32,7 @@ actor SessionReplayExporter: EventExporting { self.replayApiService = replayApiService self.sessionManager = context.observabilityContext.sessionManager self.title = title - self.eventGenerator = SessionReplayEventGenerator(log: context.log, title: title) + self.eventGenerator = RRWebEventGenerator(log: context.log, title: title) self.log = context.log self.sessionInfo = sessionManager.sessionInfo @@ -47,7 +47,7 @@ actor SessionReplayExporter: EventExporting { private func updateSessionInfo(_ sessionInfo: SessionInfo) async { self.sessionInfo = sessionInfo - self.eventGenerator = SessionReplayEventGenerator(log: log, title: title) + self.eventGenerator = RRWebEventGenerator(log: log, title: title) self.initializedSession = nil } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayStats.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayStats.swift index efaf8187..7e2a93b1 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayStats.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayStats.swift @@ -20,11 +20,11 @@ final class SessionReplayStats { return Double(images) / elapsedTime } - func addExportImage(_ exportImage: ExportImage) { + func addExportFrame(_ exportFrame: ExportFrame) { images += 1 - imagesSize += Int64(exportImage.data.count) - firstImageTimestamp = firstImageTimestamp ?? exportImage.timestamp - lastImageTimestamp = exportImage.timestamp + imagesSize += Int64(exportFrame.images.reduce(0) { $0 + $1.data.count }) + firstImageTimestamp = firstImageTimestamp ?? exportFrame.timestamp + lastImageTimestamp = exportFrame.timestamp logIfNeeded() } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift index 487578f8..f876feb4 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift @@ -69,8 +69,11 @@ struct ClearRect: CommandPayload { private enum K: String, CodingKey { case property, args } - init(x: Int, y: Int, width: Int, height: Int) { - self.x = x; self.y = y; self.width = width; self.height = height + init(rect: CGRect) { + self.x = Int(rect.minX) + self.y = Int(rect.minY) + self.width = Int(rect.size.width) + self.height = Int(rect.size.height) } init(from decoder: Decoder) throws { @@ -104,8 +107,12 @@ struct DrawImage: CommandPayload { private enum K: String, CodingKey { case property, args } - init(image: AnyRRNode, dx: Int, dy: Int, dw: Int, dh: Int) { - self.image = image; self.dx = dx; self.dy = dy; self.dw = dw; self.dh = dh + init(image: AnyRRNode, rect: CGRect) { + self.image = image + self.dx = Int(rect.minX) + self.dy = Int(rect.minY) + self.dw = Int(rect.size.width) + self.dh = Int(rect.size.height) } init(from decoder: Decoder) throws { @@ -126,8 +133,11 @@ struct DrawImage: CommandPayload { try a.encode(image) try a.encode(dx) try a.encode(dy) - try a.encode(dw) - try a.encode(dh) + + if (dx > 0 || dy > 0) { + try a.encode(dw) + try a.encode(dh) + } } } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift index edac064d..acb1d014 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift @@ -26,6 +26,76 @@ struct DomData: EventDataProtocol { } } +// MARK: - DOM Mutation Data (for incremental DOM updates) + +struct MutationData: EventDataProtocol { + var source: IncrementalSource + var adds: [AddedNode] + var removes: [RemovedNode] + var texts: [TextMutation] + var attributes: [AttributeMutation] + + // Transitional + var canvasSize: Int + + init(adds: [AddedNode] = [], + removes: [RemovedNode] = [], + texts: [TextMutation] = [], + attributes: [AttributeMutation] = [], + canvasSize: Int = 0) { + self.source = .mutation + self.adds = adds + self.removes = removes + self.texts = texts + self.attributes = attributes + self.canvasSize = canvasSize + } + + private enum CodingKeys: String, CodingKey { + case source, adds, removes, texts, attributes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.source = try container.decode(IncrementalSource.self, forKey: .source) + self.adds = try container.decode([AddedNode].self, forKey: .adds) + self.removes = try container.decode([RemovedNode].self, forKey: .removes) + self.texts = try container.decode([TextMutation].self, forKey: .texts) + self.attributes = try container.decode([AttributeMutation].self, forKey: .attributes) + self.canvasSize = 0 + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(source, forKey: .source) + try container.encode(adds, forKey: .adds) + try container.encode(removes, forKey: .removes) + try container.encode(texts, forKey: .texts) + try container.encode(attributes, forKey: .attributes) + } +} + +struct AddedNode: Codable { + var parentId: Int + var nextId: Int? + var node: EventNode +} + +struct RemovedNode: Codable { + var parentId: Int + var id: Int +} + +struct TextMutation: Codable { + var id: Int + var value: String +} + +struct AttributeMutation: Codable { + var id: Int + var attributes: [String: String?] +} + struct EventNode: Codable { var type: NodeType var name: String? diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift b/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift index 79ec9b0b..19bf96ee 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/Event.swift @@ -33,6 +33,8 @@ struct AnyEventData: Codable { self.value = try CanvasDrawData(from: decoder) } else if src == .mouseMove { self.value = try MouseMoveEventData(from: decoder) + } else if src == .mutation { + self.value = try MutationData(from: decoder) } else { self.value = try MouseInteractionData(from: decoder) } diff --git a/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift b/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift index b1a35a8b..bfd5f400 100644 --- a/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift +++ b/Sources/LaunchDarklySessionReplay/RRWeb/WindowData.swift @@ -5,9 +5,9 @@ struct WindowData: EventDataProtocol { var width: Int? var height: Int? - init(href: String? = nil, width: Int? = nil, height: Int? = nil) { + init(href: String? = nil, size: CGSize) { self.href = href - self.width = width - self.height = height + self.width = Int(size.width) + self.height = Int(size.height) } } diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/SnapshotTaker.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift similarity index 69% rename from Sources/LaunchDarklySessionReplay/ScreenCapture/SnapshotTaker.swift rename to Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift index c90b2d58..207d253a 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/SnapshotTaker.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift @@ -3,8 +3,9 @@ import Combine import LaunchDarklyObservability import UIKit -final class SnapshotTaker: EventSource { - private let captureService: ScreenCaptureService +final class CaptureManager: EventSource { + private let captureService: ImageCaptureService + private let tileDiffManager: TileDiffManager private let appLifecycleManager: AppLifecycleManaging @MainActor private var displayLink: CADisplayLink? @@ -29,10 +30,12 @@ final class SnapshotTaker: EventSource { } } - init(captureService: ScreenCaptureService, + init(captureService: ImageCaptureService, + compression: SessionReplayOptions.CompressionMethod, appLifecycleManager: AppLifecycleManaging, eventQueue: EventQueue) { self.captureService = captureService + self.tileDiffManager = TileDiffManager(compression: compression, scale: 1.0) self.eventQueue = eventQueue self.appLifecycleManager = appLifecycleManager @@ -115,21 +118,44 @@ final class SnapshotTaker: EventSource { let lastFrameDispatchTime = DispatchTime.now() self.lastFrameDispatchTime = lastFrameDispatchTime - captureService.captureUIImage { capturedImage in - guard let capturedImage else { + captureService.captureRawFrame { rawFrame in + guard let rawFrame else { + // dropped frame + return + } + + guard let capturedFrame = self.tileDiffManager.computeDiffCapture(frame: rawFrame) else { // dropped frame return } - guard let exportImage = capturedImage.image.exportImage(format: .jpeg(quality: 0.3), - originalSize: capturedImage.renderSize, - scale: capturedImage.scale, - timestamp: capturedImage.timestamp, - orientation: capturedImage.orientation) else { + guard let exportFrame = self.exportFrame(from: capturedFrame) else { + // dropped frame return } - await self.eventQueue.send(ImageItemPayload(exportImage: exportImage)) + await self.eventQueue.send(ImageItemPayload(exportFrame: exportFrame)) } } + + private func exportFrame(from capturedFrame: TiledFrame) -> ExportFrame? { + let format = ExportFormat.jpeg(quality: 0.3) + var exportedFrames = [ExportFrame.ExportImage]() + for tile in capturedFrame.tiles { + guard let exportedFrame = tile.image.asExportedImage(format: format, rect: tile.rect) else { + return nil + } + exportedFrames.append(exportedFrame) + } + guard !exportedFrames.isEmpty else { return nil } + + return ExportFrame(images: exportedFrames, + originalSize: capturedFrame.originalSize, + scale: capturedFrame.scale, + format: format, + timestamp: capturedFrame.timestamp, + orientation: capturedFrame.orientation, + isKeyframe: capturedFrame.isKeyframe, + imageSignature: capturedFrame.imageSignature) + } } diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift index 5cd3bcec..84f2ef0c 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ExportImage.swift @@ -1,36 +1,55 @@ import Foundation import UIKit -struct ExportImage: Equatable { - let data: Data - let dataHashValue: Int - let originalWidth: Int - let originalHeight: Int +struct ExportFrame: Equatable { + struct ExportImage: Equatable { + let data: Data + let dataHashValue: Int + let rect: CGRect + + /// Creates an EventNode for a tile image (positioned absolutely on top of main canvas) + func tileEventNode(id: Int, rr_dataURL: String) -> EventNode { + let style = "position:absolute;left:\(Int(rect.minX))px;top:\(Int(rect.minY))px;pointer-events:none;" + return EventNode( + id: id, + type: .Element, + tagName: "img", + attributes: [ + "src": rr_dataURL, + "width": "\(Int(rect.width))", + "height": "\(Int(rect.height))", + "style": style] + ) + } + + func base64DataURL(mimeType: String) -> String { + "data:\(mimeType);base64,\(data.base64EncodedString())" + } + + static func == (lhs: ExportImage, rhs: ExportImage) -> Bool { + lhs.dataHashValue == rhs.dataHashValue && lhs.data.elementsEqual(rhs.data) + } + } + + let images: [ExportImage] + let originalSize: CGSize let scale: CGFloat let format: ExportFormat let timestamp: TimeInterval let orientation: Int - - init(data: Data, originalWidth: Int, originalHeight: Int, scale: CGFloat, format: ExportFormat, timestamp: TimeInterval, orientation: Int) { - self.data = data - self.dataHashValue = data.hashValue - self.originalWidth = originalWidth - self.originalHeight = originalHeight - self.scale = scale - self.format = format - self.timestamp = timestamp - self.orientation = orientation - } + let isKeyframe: Bool + let imageSignature: ImageSignature? + /// Creates an EventNode for the main canvas (full snapshot) func eventNode(id: Int, rr_dataURL: String) -> EventNode { EventNode( id: id, type: .Element, - tagName: "canvas", + tagName: "img", attributes: [ "rr_dataURL": rr_dataURL, - "width": "\(originalWidth)", - "height": "\(originalHeight)"] + "width": "\(Int(originalSize.width))", + "height": "\(Int(originalSize.height))"] ) } @@ -43,25 +62,15 @@ struct ExportImage: Equatable { } } - func base64DataURL() -> String { - "data:\(mimeType);base64,\(data.base64EncodedString())" - } - - static func == (lhs: ExportImage, rhs: ExportImage) -> Bool { - lhs.dataHashValue == rhs.dataHashValue && lhs.data.elementsEqual(rhs.data) + static func == (lhs: ExportFrame, rhs: ExportFrame) -> Bool { + lhs.images == rhs.images } } extension UIImage { - func exportImage(format: ExportFormat, originalSize: CGSize, scale: CGFloat, timestamp: TimeInterval, orientation: Int) -> ExportImage? { + func asExportedImage(format: ExportFormat, rect: CGRect) -> ExportFrame.ExportImage? { guard let data = asData(format: format) else { return nil } - return ExportImage(data: data, - originalWidth: Int(originalSize.width), - originalHeight: Int(originalSize.height), - scale: scale, - format: format, - timestamp: timestamp, - orientation: orientation) + return ExportFrame.ExportImage(data: data, dataHashValue: data.hashValue, rect: rect) } } diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ScreenCaptureService.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift similarity index 51% rename from Sources/LaunchDarklySessionReplay/ScreenCapture/ScreenCaptureService.swift rename to Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift index db51c58f..7379c69d 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ScreenCaptureService.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift @@ -4,51 +4,51 @@ import UIKit import Darwin import Foundation -public struct CapturedImage { - public let image: UIImage - public let scale: CGFloat - public let renderSize: CGSize - public let timestamp: TimeInterval - public let orientation: Int +struct RawCapturedFrame { + let image: UIImage + let timestamp: TimeInterval + let orientation: Int } -public final class ScreenCaptureService { +public final class ImageCaptureService { private let maskingService = MaskApplier() private let maskCollector: MaskCollector - private let tiledSignatureManager = TiledSignatureManager() - private var previousSignature: ImageSignature? - private let signatureLock = NSLock() + private let windowCaptureManager = WindowCaptureManager() @MainActor private var shouldCapture = false + private let scale = 1.0 + public init(options: SessionReplayOptions) { maskCollector = MaskCollector(privacySettings: options.privacy) } // MARK: - Capture - /// Capture as UIImage (must be on main thread). @MainActor - public func captureUIImage(yield: @escaping (CapturedImage?) async -> Void) { + public func captureUIImage(yield: @escaping (UIImage?) async -> Void) { + captureRawFrame { frame in + await yield(frame?.image) + } + } + + /// Capture as masked frame (must be on main thread). + @MainActor + func captureRawFrame(yield: @escaping (RawCapturedFrame?) async -> Void) { #if os(iOS) let orientation = UIDevice.current.orientation.isLandscape ? 1 : 0 #else let orientation = 0 #endif let timestamp = Date().timeIntervalSince1970 - let scale = 1.0 - let format = UIGraphicsImageRendererFormat() - format.scale = scale - format.opaque = false - - let windows = allWindowsInZOrder() - let enclosingBounds = minimalBoundsEnclosingWindows(windows) - let renderer = UIGraphicsImageRenderer(size: enclosingBounds.size, format: format) + let windows = windowCaptureManager.allWindowsInZOrder() + let enclosingBounds = windowCaptureManager.minimalBoundsEnclosingWindows(windows) + let renderer = windowCaptureManager.makeRenderer(size: enclosingBounds.size, scale: scale) CATransaction.flush() let maskOperationsBefore = windows.map { maskCollector.collectViewMasks(in: $0, window: $0, scale: scale) } let image = renderer.image { ctx in - drawWindows(windows, into: ctx.cgContext, bounds: enclosingBounds, afterScreenUpdates: false, scale: scale) + windowCaptureManager.drawWindows(windows, into: ctx.cgContext, bounds: enclosingBounds, afterScreenUpdates: false) } shouldCapture = true // can be set to false from external class to stop capturing work early @@ -57,7 +57,7 @@ public final class ScreenCaptureService { guard let self, let maskCollector, shouldCapture else { return } CATransaction.flush() - let maskOperationsAfter = windows.map { maskCollector.collectViewMasks(in: $0, window: $0, scale: scale) } + let maskOperationsAfter = windows.map { maskCollector.collectViewMasks(in: $0, window: $0, scale: self.scale) } Task { guard maskOperationsBefore.count == maskOperationsAfter.count else { @@ -80,77 +80,20 @@ public final class ScreenCaptureService { image.draw(at: .zero) self.maskingService.applyViewMasks(context: ctx.cgContext, operations: applyOperations.flatMap { $0 }) } - - let signatures = self.tiledSignatureManager.compute(image: image) - - self.signatureLock.lock() - if self.previousSignature == signatures { - self.signatureLock.unlock() - await yield(nil) - return - } - self.previousSignature = signatures - self.signatureLock.unlock() - let capturedImage = CapturedImage(image: image, - scale: scale, - renderSize: enclosingBounds.size, - timestamp: timestamp, - orientation: orientation) - await yield(capturedImage) + await yield(RawCapturedFrame(image: image, timestamp: timestamp, orientation: orientation)) } } } - + @MainActor func interuptCapture() { shouldCapture = false } - - private func allWindowsInZOrder() -> [UIWindow] { - let scenes = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } - let windows = scenes.flatMap { $0.windows } - return windows - .filter { !$0.isHidden && $0.alpha > 0 } - .sorted { $0.windowLevel == $1.windowLevel ? $0.hash < $1.hash : $0.windowLevel < $1.windowLevel } - } - - private func minimalBoundsEnclosingWindows(_ windows: [UIWindow]) -> CGRect { - return windows.reduce(into: CGRect.zero) { rect, window in - rect = rect.enclosing(with: window.frame) - } - } - - private func drawWindows(_ windows: [UIWindow], - into context: CGContext, - bounds: CGRect, - afterScreenUpdates: Bool, - scale: CGFloat) { - context.saveGState() - context.setFillColor(UIColor.clear.cgColor) - context.fill(bounds) - context.restoreGState() - - for (i, window) in windows.enumerated() { - context.saveGState() - - context.translateBy(x: window.frame.origin.x, y: window.frame.origin.y) - context.concatenate(window.transform) - let anchor = CGPoint(x: window.bounds.midX, y: window.bounds.midY) - context.translateBy(x: anchor.x, y: anchor.y) - context.translateBy(x: -anchor.x, y: -anchor.y) - - window.drawHierarchy(in: window.layer.frame, afterScreenUpdates: afterScreenUpdates) - - context.restoreGState() - } - } } // MARK: - Thread CPU Time -private extension ScreenCaptureService { +private extension ImageCaptureService { /// Measure CPU and wall-clock time for work executed on the current thread. /// Returns the closure result alongside CPU and wall elapsed seconds. func measureCurrentThreadCPUTime(_ work: () -> T) -> (result: T, cpu: TimeInterval, wall: TimeInterval) { diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/TileDiffManager.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/TileDiffManager.swift new file mode 100644 index 00000000..4932b8f7 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/TileDiffManager.swift @@ -0,0 +1,114 @@ +import Foundation +import UIKit + +struct TiledFrame { + struct Tile { + public let image: UIImage + public let rect: CGRect + } + + let tiles: [Tile] + let scale: CGFloat + let originalSize: CGSize + let timestamp: TimeInterval + let orientation: Int + let isKeyframe: Bool + let imageSignature: ImageSignature? + + /// Composites all captured images into a single UIImage by drawing each at its rect. + func wholeImage() -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: originalSize, format: format) + return renderer.image { _ in + for capturedImage in tiles { + capturedImage.image.draw(in: capturedImage.rect) + } + } + } +} + +final class TileDiffManager { + private let tiledSignatureManager = TiledSignatureManager() + private let compression: SessionReplayOptions.CompressionMethod + private let scale: CGFloat + private var previousSignature: ImageSignature? + private var incrementalSnapshots = 0 + private let signatureLock = NSLock() + + init(compression: SessionReplayOptions.CompressionMethod, scale: CGFloat) { + self.compression = compression + self.scale = scale + } + + func computeDiffCapture(frame: RawCapturedFrame) -> TiledFrame? { + guard let imageSignature = self.tiledSignatureManager.compute(image: frame.image) else { + return nil + } + + signatureLock.lock() + + guard let diffRect = imageSignature.diffRectangle(other: previousSignature) else { + signatureLock.unlock() + return nil + } + previousSignature = imageSignature + + let needWholeScreen = (diffRect.size.width >= frame.image.size.width && diffRect.size.height >= frame.image.size.height) + let isKeyframe: Bool + if case .overlayTiles(let layers) = compression, layers > 0 { + incrementalSnapshots = (incrementalSnapshots + 1) % layers + isKeyframe = needWholeScreen || incrementalSnapshots == 0 + if needWholeScreen { + incrementalSnapshots = 0 + } + } else { + isKeyframe = true + } + + signatureLock.unlock() + + let finalRect: CGRect + let finalImage: UIImage + + if isKeyframe { + finalImage = frame.image + finalRect = CGRect( + x: 0, + y: 0, + width: frame.image.size.width, + height: frame.image.size.height + ) + } else { + finalRect = CGRect( + x: diffRect.minX, + y: diffRect.minY, + width: min(frame.image.size.width - diffRect.minX, diffRect.width), + height: min(frame.image.size.height - diffRect.minY, diffRect.height) + ) + guard let cropped = frame.image.cgImage?.cropping(to: finalRect) else { + return nil + } + finalImage = UIImage(cgImage: cropped) + } + + let imageSignatureForTransfer: ImageSignature? = { + if case .overlayTiles = compression { + return imageSignature + } + return nil + }() + + let capturedFrame = TiledFrame( + tiles: [TiledFrame.Tile(image: finalImage, rect: finalRect)], + scale: scale, + originalSize: frame.image.size, + timestamp: frame.timestamp, + orientation: frame.orientation, + isKeyframe: isKeyframe, + imageSignature: imageSignatureForTransfer + ) + return capturedFrame + } +} diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/TiledSignatureManager.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/TiledSignatureManager.swift index 020010f3..553ebf4f 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/TiledSignatureManager.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/TiledSignatureManager.swift @@ -5,7 +5,8 @@ import CommonCrypto struct ImageSignature: Hashable { let rows: Int let columns: Int - let tileSize: Int + let tileWidth: Int + let tileHeight: Int let tiledSignatures: [TiledSignature] } @@ -14,14 +15,14 @@ struct TiledSignature: Hashable { } final class TiledSignatureManager { - let tileSize: Int = 64 - func compute(image: UIImage) -> ImageSignature? { guard let image = image.cgImage else { return nil } let width = image.width let height = image.height - let columns = (width + tileSize - 1) / tileSize - let rows = (height + tileSize - 1) / tileSize + let tileWidth = nearestDivisor(value: width, preferred: 64, range: 53...75) + let tileHeight = nearestDivisor(value: height, preferred: 44, range: 44...50) + let columns = (width + tileWidth - 1) / tileWidth + let rows = (height + tileHeight - 1) / tileHeight guard let data = image.dataProvider?.data, let ptr = CFDataGetBytePtr(data) else { return nil } @@ -31,18 +32,18 @@ final class TiledSignatureManager { tiledSignatures.reserveCapacity(columns * rows) for row in 0..) -> Int { + guard value > 0 else { + return preferred + } + + func isDivisor(_ candidate: Int) -> Bool { + candidate > 0 && value.isMultiple(of: candidate) + } + + if range.contains(preferred), isDivisor(preferred) { + return preferred + } + + let maxDistance = max(abs(range.lowerBound - preferred), abs(range.upperBound - preferred)) + guard maxDistance > 0 else { + return preferred + } + + for offset in 1...maxDistance { + let positiveCandidate = preferred + offset + if range.contains(positiveCandidate), isDivisor(positiveCandidate) { + return positiveCandidate + } + + let negativeCandidate = preferred - offset + if range.contains(negativeCandidate), isDivisor(negativeCandidate) { + return negativeCandidate + } + } + + return preferred + } +} + +extension ImageSignature { + // returns nil if signatures equal + func diffRectangle(other: ImageSignature?) -> CGRect? { + guard let other else { + return CGRect(x: 0, + y: 0, + width: columns * tileWidth, + height: rows * tileHeight) + } + + guard rows == other.rows, columns == other.columns, tileWidth == other.tileWidth, tileHeight == other.tileHeight else { + return CGRect(x: 0, + y: 0, + width: columns * tileWidth, + height: rows * tileHeight) + } + + var minRow = Int.max + var maxRow = Int.min + var minColumn = Int.max + var maxColumn = Int.min + + for (i, tile) in tiledSignatures.enumerated() where tile != other.tiledSignatures[i] { + let row = i / columns + let col = i % columns + minRow = min(minRow, row) + maxRow = max(maxRow, row) + minColumn = min(minColumn, col) + maxColumn = max(maxColumn, col) + } + + guard minRow != Int.max else { + return nil + } + + return CGRect(x: minColumn * tileWidth, + y: minRow * tileHeight, + width: (maxColumn - minColumn + 1) * tileWidth, + height: (maxRow - minRow + 1) * tileHeight) + } } + diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/WindowCaptureManager.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/WindowCaptureManager.swift new file mode 100644 index 00000000..9c936321 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/WindowCaptureManager.swift @@ -0,0 +1,51 @@ +import Foundation +import UIKit + +final class WindowCaptureManager { + func makeRenderer(size: CGSize, scale: CGFloat) -> UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = false + return UIGraphicsImageRenderer(size: size, format: format) + } + + func allWindowsInZOrder() -> [UIWindow] { + let scenes = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } + let windows = scenes.flatMap { $0.windows } + return windows + .filter { !$0.isHidden && $0.alpha > 0 } + .sorted { $0.windowLevel == $1.windowLevel ? $0.hash < $1.hash : $0.windowLevel < $1.windowLevel } + } + + func minimalBoundsEnclosingWindows(_ windows: [UIWindow]) -> CGRect { + return windows.reduce(into: CGRect.zero) { rect, window in + rect = rect.enclosing(with: window.frame) + } + } + + func drawWindows(_ windows: [UIWindow], + into context: CGContext, + bounds: CGRect, + afterScreenUpdates: Bool) { + context.saveGState() + context.setFillColor(UIColor.clear.cgColor) + context.fill(bounds) + context.restoreGState() + + for window in windows { + context.saveGState() + + context.translateBy(x: window.frame.origin.x, y: window.frame.origin.y) + context.concatenate(window.transform) + let anchor = CGPoint(x: window.bounds.midX, y: window.bounds.midY) + context.translateBy(x: anchor.x, y: anchor.y) + context.translateBy(x: -anchor.x, y: -anchor.y) + + window.drawHierarchy(in: window.layer.frame, afterScreenUpdates: afterScreenUpdates) + + context.restoreGState() + } + } +} diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index f7ea524f..76dc21ab 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -38,7 +38,7 @@ struct SessionReplayContext { } final class SessionReplayService: SessionReplayServicing { - let snapshotTaker: SnapshotTaker + let captureManager: CaptureManager var transportService: TransportServicing var sessionReplayExporter: SessionReplayExporter let userInteractionManager: UserInteractionManager @@ -66,11 +66,12 @@ final class SessionReplayService: SessionReplayServicing { } self.log = observabilityContext.options.log let graphQLClient = GraphQLClient(endpoint: url) - let captureService = ScreenCaptureService(options: sessonReplayOptions) + let captureService = ImageCaptureService(options: sessonReplayOptions) self.transportService = observabilityContext.transportService - self.snapshotTaker = SnapshotTaker(captureService: captureService, - appLifecycleManager: observabilityContext.appLifecycleManager, - eventQueue: transportService.eventQueue) + self.captureManager = CaptureManager(captureService: captureService, + compression: sessonReplayOptions.compression, + appLifecycleManager: observabilityContext.appLifecycleManager, + eventQueue: transportService.eventQueue) self.userInteractionManager = observabilityContext.userInteractionManager let sessionReplayContext = SessionReplayContext( @@ -118,7 +119,7 @@ final class SessionReplayService: SessionReplayServicing { } .store(in: &cancellables) - snapshotTaker.isEnabled = true + captureManager.isEnabled = true } @MainActor @@ -129,6 +130,6 @@ final class SessionReplayService: SessionReplayServicing { @MainActor private func internalStop() { cancellables.removeAll() - snapshotTaker.isEnabled = false + captureManager.isEnabled = false } } diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 004e20c1..4d2fbd4b 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -25,15 +25,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate { 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", +// 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, )), diff --git a/TestApp/Sources/SessionReplay/Masking/MaskingElementsViewModel.swift b/TestApp/Sources/SessionReplay/Masking/MaskingElementsViewModel.swift index 0d6d90cd..0ebe2031 100644 --- a/TestApp/Sources/SessionReplay/Masking/MaskingElementsViewModel.swift +++ b/TestApp/Sources/SessionReplay/Masking/MaskingElementsViewModel.swift @@ -3,13 +3,13 @@ import LaunchDarklyObservability import LaunchDarklySessionReplay final class MaskingElementsViewModel: ObservableObject { - var screenCaptureService = ScreenCaptureService(options: SessionReplayOptions()) - var capturedImage: CapturedImage? + var screenCaptureService = ImageCaptureService(options: SessionReplayOptions()) + var capturedImage: UIImage? @Published var isImagePresented: Bool = false @MainActor func captureShapShot() { - screenCaptureService.captureUIImage { image in + screenCaptureService.captureUIImage{ image in self.capturedImage = image self.isImagePresented = true } diff --git a/TestApp/Sources/SessionReplay/ScreenshotTester/SnapshotButton.swift b/TestApp/Sources/SessionReplay/ScreenshotTester/SnapshotButton.swift index 6102223c..38196e20 100644 --- a/TestApp/Sources/SessionReplay/ScreenshotTester/SnapshotButton.swift +++ b/TestApp/Sources/SessionReplay/ScreenshotTester/SnapshotButton.swift @@ -10,7 +10,7 @@ struct SnapshotButton: View { Image(systemName: "camera") }.sheet(isPresented: $viewModel.isImagePresented) { print(viewModel.isImagePresented) - let image = viewModel.capturedImage!.image + let image = viewModel.capturedImage ?? UIImage() return CapturedImageView(image: image) } } diff --git a/TestApp/TestApp.xcodeproj/project.pbxproj b/TestApp/TestApp.xcodeproj/project.pbxproj index 646fa602..eef37b24 100644 --- a/TestApp/TestApp.xcodeproj/project.pbxproj +++ b/TestApp/TestApp.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 5006D6B52EEB4D460081FEA5 /* LaunchDarklySessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = 5006D6B42EEB4D460081FEA5 /* LaunchDarklySessionReplay */; }; + 503DA8C82F31678400E7E47F /* LaunchDarklyObservability in Frameworks */ = {isa = PBXBuildFile; productRef = 5006D6B22EEB4D460081FEA5 /* LaunchDarklyObservability */; }; 50C584CA2EE23723006A0045 /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = 50C584C92EE23723006A0045 /* LaunchDarkly */; }; E7904AD72E6A52CE00A15337 /* LaunchDarklyObservability in Frameworks */ = {isa = PBXBuildFile; productRef = E7904AD62E6A52CE00A15337 /* LaunchDarklyObservability */; }; /* End PBXBuildFile section */ @@ -42,6 +43,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 503DA8C82F31678400E7E47F /* LaunchDarklyObservability in Frameworks */, 5006D6B52EEB4D460081FEA5 /* LaunchDarklySessionReplay in Frameworks */, E7904AD72E6A52CE00A15337 /* LaunchDarklyObservability in Frameworks */, 50C584CA2EE23723006A0045 /* LaunchDarkly in Frameworks */, diff --git a/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift b/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift index a0310624..764b3dfe 100644 --- a/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift +++ b/Tests/SessionReplayTests/SessionReplayEventGeneratorTests.swift @@ -4,37 +4,39 @@ import LaunchDarklyObservability import OSLog import CoreGraphics -struct SessionReplayEventGeneratorTests { +struct RRWebEventGeneratorTests { - private func makeExportImage(dataSize: Int, width: Int, height: Int, timestamp: TimeInterval) -> ExportImage { + private func makeExportFrame(dataSize: Int, width: Int, height: Int, timestamp: TimeInterval) -> ExportFrame { let data = Data(count: dataSize) - return ExportImage( - data: data, - originalWidth: width, - originalHeight: height, + let rect = CGRect(x: 0, y: 0, width: width, height: height) + let exportedFrame = ExportFrame.ExportImage(data: data, dataHashValue: data.hashValue, rect: rect) + return ExportFrame( + images: [exportedFrame], + originalSize: CGSize(width: width, height: height), scale: 1.0, format: .png, timestamp: timestamp, - orientation: 0 + orientation: 0, + isKeyframe: true ) } @Test("Appends draw image event when same size and below limit") func appendsDrawImageEventWhenSameSizeAndBelowLimit() async { // Arrange - let generator = SessionReplayEventGenerator( + let generator = RRWebEventGenerator( 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) + let firstImage = makeExportFrame(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 secondImage = makeExportFrame(dataSize: 256, width: 320, height: 480, timestamp: 2.0) let items: [EventQueueItem] = [ - EventQueueItem(payload: ImageItemPayload(exportImage: firstImage)), - EventQueueItem(payload: ImageItemPayload(exportImage: secondImage)) + EventQueueItem(payload: ImageItemPayload(exportFrame: firstImage)), + EventQueueItem(payload: ImageItemPayload(exportFrame: secondImage)) ] // Act @@ -51,19 +53,19 @@ struct SessionReplayEventGeneratorTests { @Test("Appends full snapshot when canvas buffer limit exceeded") func appendsFullSnapshotWhenCanvasBufferLimitExceeded() async { // Arrange - let generator = SessionReplayEventGenerator( + let generator = RRWebEventGenerator( 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 largeFirstImage = makeExportFrame(dataSize: 8_000_000, width: 320, height: 480, timestamp: 1.0) + let secondImageSameSize = makeExportFrame(dataSize: 256, width: 320, height: 480, timestamp: 2.0) let items: [EventQueueItem] = [ - EventQueueItem(payload: ImageItemPayload(exportImage: largeFirstImage)), - EventQueueItem(payload: ImageItemPayload(exportImage: secondImageSameSize)) + EventQueueItem(payload: ImageItemPayload(exportFrame: largeFirstImage)), + EventQueueItem(payload: ImageItemPayload(exportFrame: secondImageSameSize)) ] // Act