From 84a16053b7e56783cd0b88200ca6e8b95a4364bc Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Thu, 16 Apr 2026 17:24:21 -0600 Subject: [PATCH 1/7] feat(session-replay): embed sessionId at creation time in push event payloads Attach sessionId to IdentifyItemPayload, ImageItemPayload, TouchInteraction, and PressInteraction at object creation time rather than at export time. Injects a @Sendable () -> String sessionIdProvider closure through InputCaptureCoordinator and CaptureManager so each interaction and frame captures the live session ID the moment it is created, correctly tracking session rotations that occur while capture is running continuously. Co-Authored-By: Claude Sonnet 4.6 --- .../Client/ObservabilityService.swift | 2 +- .../InputCaptureCoordinator.swift | 15 ++++--- .../UIInteractions/PressCaptureModels.swift | 7 ++- .../UIInteractions/PressInterpreter.swift | 3 +- .../UIInteractions/TouchInteraction.swift | 1 + .../UIInteractions/TouchInterpreter.swift | 43 +++++++++++-------- .../UserInteractionManager.swift | 7 ++- .../CompressionBenchmarkRunner.swift | 2 +- .../Exporter/IdentifyItemPayload.swift | 9 ++-- .../Exporter/ImageItemPayload.swift | 7 +-- .../Exporter/SessionReplayExporter.swift | 2 +- .../ScreenCapture/CaptureManager.swift | 9 ++-- .../SessionReplayService.swift | 7 ++- .../RRWebEventGeneratorTests.swift | 21 +++++---- .../RawFramesRRWebEventGeneratorTests.swift | 28 ++++++------ 15 files changed, 97 insertions(+), 66 deletions(-) diff --git a/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift b/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift index 12cbe2b6..0ed6b941 100644 --- a/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift +++ b/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift @@ -169,7 +169,7 @@ final class ObservabilityService: InternalObserve { ) self.tracer = appTraceClient - let userInteractionManager = UserInteractionManager(options: options) { interaction in + let userInteractionManager = UserInteractionManager(options: options, sessionManaging: sessionManager) { interaction in interaction.startEndSpan(tracer: tracerDecorator) } self.userInteractionManager = userInteractionManager diff --git a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift index b5f9a9b2..ca3bd276 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift @@ -48,16 +48,19 @@ final class InputCaptureCoordinator { private let touchInterpreter: TouchInterpreter private let pressInterpreter: PressInterpreter private let receiverChecker: UIEventReceiverChecker + private let sessionIdProvider: @Sendable () -> String var onTouch: TouchInteractionYield? var onPress: PressInteractionYield? - + init(targetResolver: TargetResolving = TargetResolver(), - receiverChecker: UIEventReceiverChecker = UIEventReceiverChecker()) { + receiverChecker: UIEventReceiverChecker = UIEventReceiverChecker(), + sessionIdProvider: @Sendable @escaping () -> String) { self.targetResolver = targetResolver self.touchInterpreter = TouchInterpreter() self.pressInterpreter = PressInterpreter() self.source = UIWindowSwizzleSource() self.receiverChecker = receiverChecker + self.sessionIdProvider = sessionIdProvider } func start() { @@ -93,7 +96,8 @@ final class InputCaptureCoordinator { for await item in captureStream { switch item { case .touch(let touchSample): - touchInterpreter.process(touchSample: touchSample, yield: onTouch) + let sessionId = sessionIdProvider() + touchInterpreter.process(touchSample: touchSample, sessionId: sessionId, yield: onTouch) case .press(let sample): if let onPress { pressInterpreter.process(pressInteraction: sample, yield: onPress) @@ -116,7 +120,8 @@ final class InputCaptureCoordinator { phase: PressInteraction.phase(forTouch: touch.phase), kind: .untrackedWindowTouch, timestamp: touch.timestamp, - target: target + target: target, + sessionId: sessionIdProvider() ) continuation.yield(.press(interaction)) } @@ -150,7 +155,7 @@ final class InputCaptureCoordinator { for press in pressesEvent.allPresses { guard press.phase == .began else { continue } let target = targetResolver.resolve(press: press, window: window) - let interaction = PressInteraction(press: press, target: target) + let interaction = PressInteraction(press: press, target: target, sessionId: sessionIdProvider()) if case .other = interaction.kind { continue } continuation.yield(.press(interaction)) diff --git a/Sources/LaunchDarklyObservability/UIInteractions/PressCaptureModels.swift b/Sources/LaunchDarklyObservability/UIInteractions/PressCaptureModels.swift index f34e5c10..93135294 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/PressCaptureModels.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/PressCaptureModels.swift @@ -38,23 +38,26 @@ public struct PressInteraction: Sendable { public let kind: RemotePressKind public let timestamp: TimeInterval public let target: TouchTarget? + public let sessionId: String public var isKeyboard: Bool { kind == .keyboard || kind == .untrackedWindowTouch } - init(press: UIPress, target: TouchTarget?) { + init(press: UIPress, target: TouchTarget?, sessionId: String) { self.phase = Self.phase(for: press.phase) self.kind = press.key != nil ? .keyboard : RemotePressKind(pressType: press.type) self.timestamp = press.timestamp self.target = target + self.sessionId = sessionId } - init(phase: Phase, kind: RemotePressKind = .untrackedWindowTouch, timestamp: TimeInterval, target: TouchTarget?) { + init(phase: Phase, kind: RemotePressKind = .untrackedWindowTouch, timestamp: TimeInterval, target: TouchTarget?, sessionId: String) { self.phase = phase self.kind = kind self.timestamp = timestamp self.target = target + self.sessionId = sessionId } static func phase(forTouch touchPhase: UITouch.Phase) -> Phase { diff --git a/Sources/LaunchDarklyObservability/UIInteractions/PressInterpreter.swift b/Sources/LaunchDarklyObservability/UIInteractions/PressInterpreter.swift index 64b9957f..720eeb5d 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/PressInterpreter.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/PressInterpreter.swift @@ -7,7 +7,8 @@ final class PressInterpreter { phase: pressInteraction.phase, kind: pressInteraction.kind, timestamp: pressInteraction.timestamp + uptimeDifference, - target: pressInteraction.target + target: pressInteraction.target, + sessionId: pressInteraction.sessionId ) yield(corrected) } diff --git a/Sources/LaunchDarklyObservability/UIInteractions/TouchInteraction.swift b/Sources/LaunchDarklyObservability/UIInteractions/TouchInteraction.swift index a2034714..62de64e8 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/TouchInteraction.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/TouchInteraction.swift @@ -26,6 +26,7 @@ public struct TouchInteraction: Sendable { public let startTimestamp: TimeInterval public let timestamp: TimeInterval public let target: TouchTarget? + public let sessionId: String } public enum SwipeDirection: Sendable { diff --git a/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift b/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift index fb5434c3..ecfd563a 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift @@ -24,7 +24,7 @@ final class TouchInterpreter { return id } - func process(touchSample: TouchSample, yield: TouchInteractionYield) { + func process(touchSample: TouchSample, sessionId: String, yield: TouchInteractionYield) { // UITouch and UIEvent use time based on systemUptime getting and we needed adjustment for proper time let uptimeDifference = Date().timeIntervalSince1970 - ProcessInfo.processInfo.systemUptime switch touchSample.phase { @@ -35,29 +35,31 @@ final class TouchInterpreter { points: [TouchPoint(position: touchSample.location, timestamp: touchSample.timestamp + uptimeDifference)], target: touchSample.target) tracks[touchSample.id] = track - + let downInteraction = TouchInteraction(id: incrementingId, kind: .touchDown(touchSample.location), startTimestamp: touchSample.timestamp + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, - target: touchSample.target) + target: touchSample.target, + sessionId: sessionId) yield(downInteraction) - + case .moved: guard var track = tracks[touchSample.id] else { return } - + let trackDuration = touchSample.timestamp - track.start guard trackDuration <= TouchConstants.touchPathDuration else { // flush movements of long touch path do not have dead time in the replay player let lastPoint = TouchPoint(position: touchSample.location, timestamp: touchSample.timestamp + uptimeDifference) track.points.append(lastPoint) track.target = touchSample.target - + let moveInteraction = TouchInteraction(id: incrementingId, kind: .touchPath(points: track.points), startTimestamp: track.start + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, - target: touchSample.target) + target: touchSample.target, + sessionId: sessionId) track.points.removeAll() track.start = lastPoint.timestamp - uptimeDifference track.startPoint = touchSample.location @@ -65,24 +67,24 @@ final class TouchInterpreter { yield(moveInteraction) return } - + if let prevPoint = tracks[touchSample.id]?.points.last { let duration = touchSample.timestamp + uptimeDifference - prevPoint.timestamp guard duration >= TouchConstants.touchMoveThrottle else { return } } - + let distance = squaredDistance(from: track.startPoint, to: touchSample.location) guard distance >= TouchConstants.tapMaxDistanceSquared else { return } - + track.end = touchSample.timestamp track.target = touchSample.target track.points.append(TouchPoint(position: touchSample.location, timestamp: touchSample.timestamp + uptimeDifference)) tracks[touchSample.id] = track - + case .ended, .cancelled: // touchUp let startTimestamp = tracks[touchSample.id]?.start ?? touchSample.timestamp @@ -90,31 +92,34 @@ final class TouchInterpreter { kind: .touchUp(touchSample.location), startTimestamp: startTimestamp + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, - target: touchSample.target) + target: touchSample.target, + sessionId: sessionId) yield(upInteraction) - + // touchPath guard let track = tracks.removeValue(forKey: touchSample.id), track.points.isNotEmpty else { return } - + let moveInteraction = TouchInteraction(id: incrementingId, kind: .touchPath(points: track.points), startTimestamp: startTimestamp + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, - target: touchSample.target) + target: touchSample.target, + sessionId: sessionId) yield(moveInteraction) case .unknown: () //NOOP } } - - func flushMovements(touchSample: TouchSample, uptimeDifference: TimeInterval, startTimestamp: TimeInterval, yield: TouchInteractionYield) { + + func flushMovements(touchSample: TouchSample, uptimeDifference: TimeInterval, startTimestamp: TimeInterval, sessionId: String, yield: TouchInteractionYield) { guard var track = tracks[touchSample.id], track.points.isNotEmpty else { return } - + let moveInteraction = TouchInteraction(id: incrementingId, kind: .touchPath(points: track.points), startTimestamp: startTimestamp + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, - target: touchSample.target) + target: touchSample.target, + sessionId: sessionId) if let lastPoint = track.points.last { track.points.removeAll() track.start = lastPoint.timestamp - uptimeDifference diff --git a/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift b/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift index 86cd3439..0c082f24 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift @@ -13,9 +13,12 @@ public final class UserInteractionManager { interactionEventSubject.eraseToAnyPublisher() } - init(options: ObservabilityOptions, yield: @escaping TouchInteractionYield) { + init(options: ObservabilityOptions, sessionManaging: SessionManaging, yield: @escaping TouchInteractionYield) { let targetResolver = TargetResolver() - self.inputCaptureCoordinator = InputCaptureCoordinator(targetResolver: targetResolver) + self.inputCaptureCoordinator = InputCaptureCoordinator( + targetResolver: targetResolver, + sessionIdProvider: { sessionManaging.sessionInfo.id } + ) self.inputCaptureCoordinator.onTouch = { [interactionEventSubject] interaction in yield(interaction) interactionEventSubject.send(.touch(interaction)) diff --git a/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift b/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift index 3253fbb2..769f84b2 100644 --- a/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift +++ b/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift @@ -27,7 +27,7 @@ public final class CompressionBenchmarkRunner { continue } - let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame)) + let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame, sessionId: "")) let events = await eventGenerator.generateEvents(items: [item]) if let data = try? encoder.encode(events) { diff --git a/Sources/LaunchDarklySessionReplay/Exporter/IdentifyItemPayload.swift b/Sources/LaunchDarklySessionReplay/Exporter/IdentifyItemPayload.swift index 6e808348..b95409b7 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/IdentifyItemPayload.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/IdentifyItemPayload.swift @@ -4,11 +4,12 @@ import LaunchDarklyObservability struct IdentifyItemPayload: EventQueueItemPayload { let attributes: [String: String] var timestamp: TimeInterval + let sessionId: String var exporterClass: AnyClass { SessionReplayExporter.self } - + func cost() -> Int { attributes.count * 100 } @@ -57,7 +58,7 @@ extension IdentifyItemPayload { } @MainActor - init(options: ObservabilityOptions, sessionAttributes: [String: AttributeValue]?, ldContext: LDContext? = nil, timestamp: TimeInterval) { + init(options: ObservabilityOptions, sessionAttributes: [String: AttributeValue]?, ldContext: LDContext? = nil, timestamp: TimeInterval, sessionId: String) { let canonicalKey = ldContext?.fullyQualifiedKey() ?? "unknown" let contextKeys = ldContext?.contextKeys() ?? [:] @@ -68,11 +69,12 @@ extension IdentifyItemPayload { canonicalKey: canonicalKey ) self.timestamp = timestamp + self.sessionId = sessionId } /// Proxy-friendly initialiser that accepts pre-extracted context keys /// instead of LDContext, so the MAUI bridge can call it with simple types. - init(options: ObservabilityOptions, sessionAttributes: [String: AttributeValue]?, contextKeys: [String: String], canonicalKey: String, timestamp: TimeInterval) { + init(options: ObservabilityOptions, sessionAttributes: [String: AttributeValue]?, contextKeys: [String: String], canonicalKey: String, timestamp: TimeInterval, sessionId: String) { self.attributes = Self.buildAttributes( options: options, sessionAttributes: sessionAttributes, @@ -80,6 +82,7 @@ extension IdentifyItemPayload { canonicalKey: canonicalKey ) self.timestamp = timestamp + self.sessionId = sessionId } } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift b/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift index 872d070a..a04b1c58 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/ImageItemPayload.swift @@ -5,14 +5,15 @@ struct ImageItemPayload: EventQueueItemPayload { var exporterClass: AnyClass { SessionReplayExporter.self } - + var timestamp: TimeInterval { exportFrame.timestamp } - + func cost() -> Int { exportFrame.addImages.reduce(0) { $0 + $1.data.count } } - + let exportFrame: ExportFrame + let sessionId: String } diff --git a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift index 0133157c..889975eb 100644 --- a/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift +++ b/Sources/LaunchDarklySessionReplay/Exporter/SessionReplayExporter.swift @@ -81,7 +81,7 @@ actor SessionReplayExporter: EventExporting { let session = try await initializeSession(sessionSecureId: sessionInfo.id) var identifyPayload = self.identifyPayload if identifyPayload == nil { - identifyPayload = await IdentifyItemPayload(options: context.observabilityContext.options, sessionAttributes: context.observabilityContext.sessionAttributes, timestamp: Date().timeIntervalSince1970) + identifyPayload = await IdentifyItemPayload(options: context.observabilityContext.options, sessionAttributes: context.observabilityContext.sessionAttributes, timestamp: Date().timeIntervalSince1970, sessionId: sessionInfo.id) } if let identifyPayload { try await identifySession(sessionSecureId: session.secureId, userObject: identifyPayload.attributes) diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift index eff22c41..7157f9e6 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/CaptureManager.swift @@ -19,7 +19,8 @@ final class CaptureManager: EventSource { private var cancellables = Set() private let debugFrameWriter = false private let rawFrameWriter: RawFrameWriter? - + private let sessionIdProvider: @Sendable () -> String + @MainActor var isEnabled: Bool = false { didSet { @@ -35,12 +36,14 @@ final class CaptureManager: EventSource { init(captureService: ImageCaptureService, compression: SessionReplayOptions.CompressionMethod, appLifecycleManager: AppLifecycleManaging, - eventQueue: EventQueue) { + eventQueue: EventQueue, + sessionIdProvider: @Sendable @escaping () -> String) { self.captureService = captureService self.exportDiffManager = ExportDiffManager(compression: compression, scale: 1.0) self.eventQueue = eventQueue self.appLifecycleManager = appLifecycleManager self.rawFrameWriter = debugFrameWriter ? (try? RawFrameWriter()) : nil + self.sessionIdProvider = sessionIdProvider let sessionExporterId = self.sessionExporterId Task { @MainActor in @@ -133,7 +136,7 @@ final class CaptureManager: EventSource { return } - await self.eventQueue.send(ImageItemPayload(exportFrame: exportFrame)) + await self.eventQueue.send(ImageItemPayload(exportFrame: exportFrame, sessionId: self.sessionIdProvider())) } } } diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index 86be2e48..4adade84 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -80,7 +80,8 @@ final class SessionReplayService: SessionReplayServicing { self.captureManager = CaptureManager(captureService: captureService, compression: sessonReplayOptions.compression, appLifecycleManager: observabilityContext.appLifecycleManager, - eventQueue: transportService.eventQueue) + eventQueue: transportService.eventQueue, + sessionIdProvider: { observabilityContext.sessionManager.sessionInfo.id }) self.userInteractionManager = observabilityContext.userInteractionManager let sessionReplayContext = SessionReplayContext( @@ -107,13 +108,15 @@ final class SessionReplayService: SessionReplayServicing { func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool) { guard completed else { return } + let sessionId = observabilityContext.sessionManager.sessionInfo.id Task { let identifyPayload = IdentifyItemPayload( options: observabilityContext.options, sessionAttributes: observabilityContext.sessionAttributes, contextKeys: contextKeys, canonicalKey: canonicalKey, - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + sessionId: sessionId ) await scheduleIdentifySession(identifyPayload: identifyPayload) } diff --git a/Tests/SessionReplayTests/RRWebEventGeneratorTests.swift b/Tests/SessionReplayTests/RRWebEventGeneratorTests.swift index 8ae12883..9d50ef54 100644 --- a/Tests/SessionReplayTests/RRWebEventGeneratorTests.swift +++ b/Tests/SessionReplayTests/RRWebEventGeneratorTests.swift @@ -45,8 +45,8 @@ struct RRWebEventGeneratorTests { let secondImage = makeExportFrame(dataSize: 256, width: 320, height: 480, timestamp: 2.0) let items: [EventQueueItem] = [ - EventQueueItem(payload: ImageItemPayload(exportFrame: firstImage)), - EventQueueItem(payload: ImageItemPayload(exportFrame: secondImage)) + EventQueueItem(payload: ImageItemPayload(exportFrame: firstImage, sessionId: "test-session")), + EventQueueItem(payload: ImageItemPayload(exportFrame: secondImage, sessionId: "test-session")) ] // Act @@ -75,8 +75,8 @@ struct RRWebEventGeneratorTests { let secondImageSameSize = makeExportFrame(dataSize: 256, width: 320, height: 480, timestamp: 2.0) let items: [EventQueueItem] = [ - EventQueueItem(payload: ImageItemPayload(exportFrame: largeFirstImage)), - EventQueueItem(payload: ImageItemPayload(exportFrame: secondImageSameSize)) + EventQueueItem(payload: ImageItemPayload(exportFrame: largeFirstImage, sessionId: "test-session")), + EventQueueItem(payload: ImageItemPayload(exportFrame: secondImageSameSize, sessionId: "test-session")) ] // Act @@ -121,8 +121,8 @@ struct RRWebEventGeneratorTests { ) let items: [EventQueueItem] = [ - EventQueueItem(payload: ImageItemPayload(exportFrame: keyframeImage)), - EventQueueItem(payload: ImageItemPayload(exportFrame: nonKeyframeImage)) + EventQueueItem(payload: ImageItemPayload(exportFrame: keyframeImage, sessionId: "test-session")), + EventQueueItem(payload: ImageItemPayload(exportFrame: nonKeyframeImage, sessionId: "test-session")) ] // Act @@ -147,7 +147,8 @@ struct RRWebEventGeneratorTests { phase: .began, kind: .select, timestamp: 99.0, - target: nil + target: nil, + sessionId: "test-session" ) let items: [EventQueueItem] = [EventQueueItem(payload: PressInteractionPayload(pressInteraction: pressInteraction))] let events = await generator.generateEvents(items: items) @@ -174,7 +175,8 @@ struct RRWebEventGeneratorTests { phase: .began, kind: .keyboard, timestamp: 12.0, - target: nil + target: nil, + sessionId: "test-session" ) let items: [EventQueueItem] = [EventQueueItem(payload: PressInteractionPayload(pressInteraction: pressInteraction))] let events = await generator.generateEvents(items: items) @@ -200,7 +202,8 @@ struct RRWebEventGeneratorTests { phase: .began, kind: .untrackedWindowTouch, timestamp: 50.0, - target: nil + target: nil, + sessionId: "test-session" ) let items: [EventQueueItem] = [EventQueueItem(payload: PressInteractionPayload(pressInteraction: pressInteraction))] let events = await generator.generateEvents(items: items) diff --git a/Tests/SessionReplayTests/RawFramesRRWebEventGeneratorTests.swift b/Tests/SessionReplayTests/RawFramesRRWebEventGeneratorTests.swift index 2cc46062..4393992c 100644 --- a/Tests/SessionReplayTests/RawFramesRRWebEventGeneratorTests.swift +++ b/Tests/SessionReplayTests/RawFramesRRWebEventGeneratorTests.swift @@ -35,7 +35,7 @@ struct RawFramesRRWebEventGeneratorTests { } // Mirrors BenchmarkExecutor flow: exportFrame -> EventQueueItem -> generateEvents. - let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame)) + let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame, sessionId: "test-session")) let events = await eventGenerator.generateEvents(items: [item]) extractedColors.append(contentsOf: extractEventImageColors(events: events)) extractedSizes.append(contentsOf: extractEventImageSizes(events: events)) @@ -70,19 +70,19 @@ struct RawFramesRRWebEventGeneratorTests { #expect(Bool(false), "Expected export frame for base frame") return } - let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1))]) + let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1, sessionId: "test-session"))]) guard let exportFrame2 = exportDiffManager.exportFrame(from: navBarFrame) else { #expect(Bool(false), "Expected export frame for nav bar frame") return } - let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2))]) + let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2, sessionId: "test-session"))]) guard let exportFrame3 = exportDiffManager.exportFrame(from: rollbackFrame) else { #expect(Bool(false), "Expected export frame for rollback frame") return } - let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3))]) + let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3, sessionId: "test-session"))]) let firstSize = firstAddedImageSize(events: events1) let secondSize = firstAddedImageSize(events: events2) @@ -122,31 +122,31 @@ struct RawFramesRRWebEventGeneratorTests { #expect(Bool(false), "Expected export frame for frame 1") return } - let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1))]) + let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1, sessionId: "test-session"))]) guard let exportFrame2 = exportDiffManager.exportFrame(from: frame2) else { #expect(Bool(false), "Expected export frame for frame 2") return } - let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2))]) + let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2, sessionId: "test-session"))]) guard let exportFrame3 = exportDiffManager.exportFrame(from: frame3) else { #expect(Bool(false), "Expected export frame for frame 3") return } - let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3))]) + let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3, sessionId: "test-session"))]) guard let exportFrame4 = exportDiffManager.exportFrame(from: frame4) else { #expect(Bool(false), "Expected export frame for frame 4") return } - let events4 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame4))]) + let events4 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame4, sessionId: "test-session"))]) guard let exportFrame5 = exportDiffManager.exportFrame(from: frame5) else { #expect(Bool(false), "Expected export frame for frame 5") return } - let events5 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame5))]) + let events5 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame5, sessionId: "test-session"))]) let firstSize = firstAddedImageSize(events: events1) let secondSize = firstAddedImageSize(events: events2) @@ -202,21 +202,21 @@ struct RawFramesRRWebEventGeneratorTests { #expect(Bool(false), "Expected export frame for frame 1") return } - let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1))]) + let events1 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame1, sessionId: "test-session"))]) trackedNodeIds.formUnion(addedNodeIds(events: events1)) guard let exportFrame2 = exportDiffManager.exportFrame(from: frame2) else { #expect(Bool(false), "Expected export frame for frame 2") return } - let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2))]) + let events2 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame2, sessionId: "test-session"))]) trackedNodeIds.formUnion(addedNodeIds(events: events2)) guard let exportFrame3 = exportDiffManager.exportFrame(from: frame3) else { #expect(Bool(false), "Expected export frame for frame 3") return } - let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3))]) + let events3 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame3, sessionId: "test-session"))]) trackedNodeIds.formUnion(addedNodeIds(events: events3)) #expect(trackedNodeIds.count == 3) @@ -225,7 +225,7 @@ struct RawFramesRRWebEventGeneratorTests { return } #expect(exportFrame4.isKeyframe, "Frame 4 should be a keyframe with layers: 3") - let events4 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame4))]) + let events4 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame4, sessionId: "test-session"))]) guard let fourthMutation = firstMutationData(events: events4) else { #expect(Bool(false), "Expected mutation event for frame 4") @@ -244,7 +244,7 @@ struct RawFramesRRWebEventGeneratorTests { #expect(Bool(false), "Expected export frame for frame 5") return } - let events5 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame5))]) + let events5 = await eventGenerator.generateEvents(items: [EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame5, sessionId: "test-session"))]) guard let fifthMutation = firstMutationData(events: events5) else { #expect(Bool(false), "Expected mutation event for frame 5") From 1e5aff1300e74032d17068abbd29691f2d14e789 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 20 Apr 2026 15:14:07 -0600 Subject: [PATCH 2/7] fix: getting sessionId could be placed outside of loop --- .../UIInteractions/InputCaptureCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift index ca3bd276..4ee8900d 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift @@ -152,10 +152,11 @@ final class InputCaptureCoordinator { continuation: AsyncStream.Continuation ) { guard let pressesEvent = event as? UIPressesEvent else { return } + let sessionId = sessionIdProvider() for press in pressesEvent.allPresses { guard press.phase == .began else { continue } let target = targetResolver.resolve(press: press, window: window) - let interaction = PressInteraction(press: press, target: target, sessionId: sessionIdProvider()) + let interaction = PressInteraction(press: press, target: target, sessionId: sessionId) if case .other = interaction.kind { continue } continuation.yield(.press(interaction)) From 5e6dcf0f2e08e55bbe2dd515555333960791b73b Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 20 Apr 2026 16:44:27 -0600 Subject: [PATCH 3/7] fix(session-replay): use real session id in CompressionBenchmarkRunner Co-Authored-By: Claude Sonnet 4.6 --- .../BenchMark/CompressionBenchmarkRunner.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift b/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift index 769f84b2..f2257771 100644 --- a/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift +++ b/Sources/LaunchDarklySessionReplay/BenchMark/CompressionBenchmarkRunner.swift @@ -18,6 +18,7 @@ public final class CompressionBenchmarkRunner { var captureTime: TimeInterval = 0 let start = CFAbsoluteTimeGetCurrent() + let sessionId = SessionInfo().id for frame in frames { let captureStart = CFAbsoluteTimeGetCurrent() @@ -27,7 +28,7 @@ public final class CompressionBenchmarkRunner { continue } - let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame, sessionId: "")) + let item = EventQueueItem(payload: ImageItemPayload(exportFrame: exportFrame, sessionId: sessionId)) let events = await eventGenerator.generateEvents(items: [item]) if let data = try? encoder.encode(events) { From fbf3d0e0bc5a7d50c46e29c0975cf9ccc3061182 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 20 Apr 2026 17:01:41 -0600 Subject: [PATCH 4/7] fix: make it first parameter, it is not defaultable --- .../LaunchDarklyObservability/Session/SessionManager.swift | 4 ++++ .../UIInteractions/UserInteractionManager.swift | 2 +- Sources/LaunchDarklySessionReplay/SessionReplayService.swift | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/LaunchDarklyObservability/Session/SessionManager.swift b/Sources/LaunchDarklyObservability/Session/SessionManager.swift index 897f978b..e46fe8b6 100644 --- a/Sources/LaunchDarklyObservability/Session/SessionManager.swift +++ b/Sources/LaunchDarklyObservability/Session/SessionManager.swift @@ -16,6 +16,10 @@ extension SessionManaging { func start(sessionId: String) { start(sessionId: sessionId, isCustomSession: true) } + + public var sessionIdProvider: @Sendable () -> String { + { [self] in self.sessionInfo.id } + } } final class SessionManager: SessionManaging { diff --git a/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift b/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift index 0c082f24..7d8f89a1 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/UserInteractionManager.swift @@ -17,7 +17,7 @@ public final class UserInteractionManager { let targetResolver = TargetResolver() self.inputCaptureCoordinator = InputCaptureCoordinator( targetResolver: targetResolver, - sessionIdProvider: { sessionManaging.sessionInfo.id } + sessionIdProvider: sessionManaging.sessionIdProvider ) self.inputCaptureCoordinator.onTouch = { [interactionEventSubject] interaction in yield(interaction) diff --git a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift index 4adade84..c2e23e6e 100644 --- a/Sources/LaunchDarklySessionReplay/SessionReplayService.swift +++ b/Sources/LaunchDarklySessionReplay/SessionReplayService.swift @@ -81,7 +81,7 @@ final class SessionReplayService: SessionReplayServicing { compression: sessonReplayOptions.compression, appLifecycleManager: observabilityContext.appLifecycleManager, eventQueue: transportService.eventQueue, - sessionIdProvider: { observabilityContext.sessionManager.sessionInfo.id }) + sessionIdProvider: observabilityContext.sessionManager.sessionIdProvider) self.userInteractionManager = observabilityContext.userInteractionManager let sessionReplayContext = SessionReplayContext( From d82c647d7c1f7b3b229157119d147ea511b38cb3 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 20 Apr 2026 17:09:25 -0600 Subject: [PATCH 5/7] feat(session-replay): include sessionId in TouchSample creation Updated TouchSample to include a sessionId parameter at initialization, ensuring that the session ID is captured at the time of touch event creation. Adjusted TouchInterpreter to utilize the sessionId from TouchSample, streamlining the processing of touch interactions. --- .../UIInteractions/InputCaptureCoordinator.swift | 16 +++++++++++----- .../UIInteractions/TouchInterpreter.swift | 7 ++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift index 4ee8900d..ac049a27 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift @@ -21,13 +21,15 @@ struct TouchSample: Sendable { // relative to system startup time let timestamp: TimeInterval let target: TouchTarget? + /// Session id fixed at main-thread capture time (with location/target). + let sessionId: String - - init(touch: UITouch, window: UIWindow, target: TouchTarget?) { + init(touch: UITouch, window: UIWindow, target: TouchTarget?, sessionId: String) { self.id = ObjectIdentifier(touch) self.location = touch.location(in: window) self.timestamp = touch.timestamp self.target = target + self.sessionId = sessionId self.phase = switch touch.phase { case .began: .began case .moved: .moved @@ -96,8 +98,7 @@ final class InputCaptureCoordinator { for await item in captureStream { switch item { case .touch(let touchSample): - let sessionId = sessionIdProvider() - touchInterpreter.process(touchSample: touchSample, sessionId: sessionId, yield: onTouch) + touchInterpreter.process(touchSample: touchSample, yield: onTouch) case .press(let sample): if let onPress { pressInterpreter.process(pressInteraction: sample, yield: onPress) @@ -141,7 +142,12 @@ final class InputCaptureCoordinator { target = nil } - let touchSample = TouchSample(touch: touch, window: window, target: target) + let touchSample = TouchSample( + touch: touch, + window: window, + target: target, + sessionId: sessionIdProvider() + ) continuation.yield(.touch(touchSample)) } } diff --git a/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift b/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift index ecfd563a..f3ef0989 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/TouchInterpreter.swift @@ -24,7 +24,8 @@ final class TouchInterpreter { return id } - func process(touchSample: TouchSample, sessionId: String, yield: TouchInteractionYield) { + func process(touchSample: TouchSample, yield: TouchInteractionYield) { + let sessionId = touchSample.sessionId // UITouch and UIEvent use time based on systemUptime getting and we needed adjustment for proper time let uptimeDifference = Date().timeIntervalSince1970 - ProcessInfo.processInfo.systemUptime switch touchSample.phase { @@ -111,7 +112,7 @@ final class TouchInterpreter { } } - func flushMovements(touchSample: TouchSample, uptimeDifference: TimeInterval, startTimestamp: TimeInterval, sessionId: String, yield: TouchInteractionYield) { + func flushMovements(touchSample: TouchSample, uptimeDifference: TimeInterval, startTimestamp: TimeInterval, yield: TouchInteractionYield) { guard var track = tracks[touchSample.id], track.points.isNotEmpty else { return } let moveInteraction = TouchInteraction(id: incrementingId, @@ -119,7 +120,7 @@ final class TouchInterpreter { startTimestamp: startTimestamp + uptimeDifference, timestamp: touchSample.timestamp + uptimeDifference, target: touchSample.target, - sessionId: sessionId) + sessionId: touchSample.sessionId) if let lastPoint = track.points.last { track.points.removeAll() track.start = lastPoint.timestamp - uptimeDifference From b24c8c993e03661e8d86b76aabb639b433aa04e9 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Mon, 20 Apr 2026 17:17:37 -0600 Subject: [PATCH 6/7] refactor(InputCaptureCoordinator): retrieve sessionId outside of loop for efficiency Moved the sessionId retrieval outside of the touch event loop in InputCaptureCoordinator to enhance performance and maintainability. This change ensures that the sessionId is captured once per event rather than multiple times during iteration. --- .../UIInteractions/InputCaptureCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift index ac049a27..a2b6f65c 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift @@ -114,6 +114,7 @@ final class InputCaptureCoordinator { continuation: AsyncStream.Continuation ) { guard let touches = event.allTouches else { return } + let sessionId = sessionIdProvider() for touch in touches { guard touch.phase == .began else { continue } let target = targetResolver.resolve(view: touch.view, window: window, event: event) @@ -122,7 +123,7 @@ final class InputCaptureCoordinator { kind: .untrackedWindowTouch, timestamp: touch.timestamp, target: target, - sessionId: sessionIdProvider() + sessionId: sessionId ) continuation.yield(.press(interaction)) } From 0d23f7a2425817a2b57e13639ef1ea5db9e49693 Mon Sep 17 00:00:00 2001 From: mario-launchdarkly Date: Tue, 21 Apr 2026 17:45:32 -0600 Subject: [PATCH 7/7] refactor(InputCaptureCoordinator): optimize sessionId retrieval for touch events Moved the sessionId retrieval outside of the touch event loop in InputCaptureCoordinator to improve efficiency. This change ensures that the sessionId is captured once per event, enhancing performance and maintainability. --- .../UIInteractions/InputCaptureCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift index a2b6f65c..798137ba 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/InputCaptureCoordinator.swift @@ -135,6 +135,7 @@ final class InputCaptureCoordinator { continuation: AsyncStream.Continuation ) { guard let touches = event.allTouches else { return } + let sessionId = sessionIdProvider() for touch in touches { let target: TouchTarget? if touch.phase == .began || touch.phase == .ended { @@ -147,7 +148,7 @@ final class InputCaptureCoordinator { touch: touch, window: window, target: target, - sessionId: sessionIdProvider() + sessionId: sessionId ) continuation.yield(.touch(touchSample)) }