diff --git a/ExampleApp/ExampleApp/Client.swift b/ExampleApp/ExampleApp/Client.swift index d1716fdb..766d3b17 100644 --- a/ExampleApp/ExampleApp/Client.swift +++ b/ExampleApp/ExampleApp/Client.swift @@ -21,12 +21,12 @@ struct Client { crashReporting: .enabled, instrumentation: .init( urlSession: .enabled, - userTaps: .enabled, memory: .enabled, memoryWarnings: .enabled, cpu: .disabled, launchTimes: .enabled - ) + ), + productAnalytics: .enabled ) ), SessionReplay( diff --git a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift index 9cfdb0e5..41c4ee35 100644 --- a/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift +++ b/MultiSceneExampleApp/MultiwindowPad/Config/Client.swift @@ -17,15 +17,14 @@ struct Client { tracesApi: .enabled, metricsApi: .enabled, crashReporting: .disabled, - autoInstrumentation: [.urlSession, .userTaps, .memory, .cpu, .memoryWarnings], instrumentation: .init( urlSession: .enabled, - userTaps: .enabled, memory: .enabled, memoryWarnings: .enabled, cpu: .disabled, launchTimes: .enabled - ) + ), + productAnalytics: .enabled ) ) ] diff --git a/README.md b/README.md index b1e9c667..55b9a608 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,8 @@ let config = { () -> LDConfig in ("X-Custom-Header", "custom-value") ], sessionBackgroundTimeout: 60, - isDebug: true + isDebug: true, + productAnalytics: .enabled ) ) ] @@ -341,6 +342,13 @@ let config = { () -> LDConfig in }() ``` +`productAnalytics` controls product-analytics telemetry, emitted as OpenTelemetry spans: + +- `taps` (default `.enabled`): emit a `click` span for each tap. Session Replay capture is unaffected by this flag. +- `trackEvents` (default `.enabled`): emit a `launchdarkly.track` span when a custom event is tracked, either automatically via the LaunchDarkly `afterTrack` hook (`LDClient.track(...)`) or manually via `LDObserve.shared.track(...)`. + +Use the `.enabled` / `.disabled` presets, or configure fields individually with `ProductAnalytics(taps:trackEvents:)`. + ### Recording Observability Data After initialization of the LaunchDarkly iOS Client SDK, use `LDObserve` to record metrics, logs, errors, and traces: @@ -385,6 +393,16 @@ let span = LDObserve.shared.startSpan( ) span.end() + +// Record a custom track event as a `launchdarkly.track` span. +// (Calling LDClient.get()?.track(key:) records the same span automatically via the afterTrack hook.) +LDObserve.shared.track( + name: "checkout_completed", + value: 42.0, + attributes: [ + "currency": .string("USD") + ] +) ``` ## Contributing diff --git a/Sources/LaunchDarklyObservability/API/LDObserve.swift b/Sources/LaunchDarklyObservability/API/LDObserve.swift index a82eb55b..ef99122e 100644 --- a/Sources/LaunchDarklyObservability/API/LDObserve.swift +++ b/Sources/LaunchDarklyObservability/API/LDObserve.swift @@ -66,4 +66,8 @@ extension LDObserve: Observe { public func startSpan(name: String, attributes: [String : AttributeValue]) -> any Span { client.startSpan(name: name, attributes: attributes) } + + public func track(name: String, value: Double?, attributes: [String : AttributeValue]) { + client.track(name: name, value: value, attributes: attributes) + } } diff --git a/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift b/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift index 74b8fd87..619f308e 100644 --- a/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift +++ b/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift @@ -151,7 +151,6 @@ public struct ObservabilityOptions { } public struct Instrumentation { let urlSession: FeatureFlag - let userTaps: FeatureFlag let memory: FeatureFlag let memoryWarnings: FeatureFlag let cpu: FeatureFlag @@ -159,20 +158,42 @@ public struct ObservabilityOptions { public init( urlSession: FeatureFlag = .disabled, - userTaps: FeatureFlag = .disabled, memory: FeatureFlag = .disabled, memoryWarnings: FeatureFlag = .disabled, cpu: FeatureFlag = .disabled, launchTimes: FeatureFlag = .disabled ) { self.urlSession = urlSession - self.userTaps = userTaps self.memory = memory self.memoryWarnings = memoryWarnings self.cpu = cpu self.launchTimes = launchTimes } } + /// Configuration for product analytics telemetry. + /// + /// Controls which user-behaviour signals are emitted as OpenTelemetry spans. + public struct ProductAnalytics { + /// Whether to emit a `click` span for each tap. Capture for Session Replay + /// is unaffected by this flag. + let taps: FeatureFlag + /// Whether to emit a `launchdarkly.track` span when a custom event is tracked + /// (via the LD `afterTrack` hook or ``LDObserve/track(name:value:attributes:)``). + let trackEvents: FeatureFlag + + public static var enabled: Self { + .init(taps: .enabled, trackEvents: .enabled) + } + + public static var disabled: Self { + .init(taps: .disabled, trackEvents: .disabled) + } + + public init(taps: FeatureFlag = .enabled, trackEvents: FeatureFlag = .enabled) { + self.taps = taps + self.trackEvents = trackEvents + } + } public var isEnabled: Bool public var serviceName: String public var serviceVersion: String @@ -191,6 +212,7 @@ public struct ObservabilityOptions { public var log: OSLog public var crashReporting: CrashReporting public var instrumentation: Instrumentation + public var productAnalytics: ProductAnalytics /// Creates a configuration for the Observability plugin. /// @@ -230,7 +252,9 @@ public struct ObservabilityOptions { /// - crashReporting: Crash-reporting configuration, including which provider to use /// (KSCrash or MetricKit). Defaults to ``CrashReporting/enabled`` (KSCrash). /// - instrumentation: Per-feature toggles for automatic instrumentation (URLSession, - /// user taps, memory, CPU, launch times, …). Defaults to all features disabled. + /// memory, CPU, launch times, …). Defaults to all features disabled. + /// - productAnalytics: Toggles for product-analytics telemetry (taps, track events). + /// Defaults to taps enabled and track events enabled. public init( isEnabled: Bool = true, serviceName: String = "observability-swift", @@ -249,7 +273,8 @@ public struct ObservabilityOptions { metricsApi: AppMetrics = .enabled, log: OSLog = OSLog(subsystem: "com.launchdarkly", category: "LaunchDarklyObservabilityPlugin"), crashReporting: CrashReporting = .enabled, - instrumentation: Instrumentation = .init() + instrumentation: Instrumentation = .init(), + productAnalytics: ProductAnalytics = .init() ) { self.serviceName = serviceName self.serviceVersion = serviceVersion @@ -268,6 +293,7 @@ public struct ObservabilityOptions { self.log = log self.crashReporting = crashReporting self.instrumentation = instrumentation + self.productAnalytics = productAnalytics self.isEnabled = isEnabled } } diff --git a/Sources/LaunchDarklyObservability/API/Observe.swift b/Sources/LaunchDarklyObservability/API/Observe.swift index 9691bf3b..2c6b7c5c 100644 --- a/Sources/LaunchDarklyObservability/API/Observe.swift +++ b/Sources/LaunchDarklyObservability/API/Observe.swift @@ -5,6 +5,26 @@ public protocol Observe: AnyObject, MetricsApi, LogsApi, TracesApi, ObserveContext { func start(sessionId: String) func start() + /// Record a custom track event as a `launchdarkly.track` span. + /// - Parameters: + /// - name: The event key/name. + /// - value: An optional metric value associated with the event. + /// - attributes: Additional attributes to record with the event. + func track(name: String, value: Double?, attributes: [String: AttributeValue]) +} + +extension Observe { + public func track(name: String) { + track(name: name, value: nil, attributes: [:]) + } + + public func track(name: String, value: Double?) { + track(name: name, value: value, attributes: [:]) + } + + public func track(name: String, attributes: [String: AttributeValue]) { + track(name: name, value: nil, attributes: attributes) + } } /// Context for transfer data from Observability to SessionReplay during initialization diff --git a/Sources/LaunchDarklyObservability/API/SemanticConvention.swift b/Sources/LaunchDarklyObservability/API/SemanticConvention.swift index 4256b7e1..d577cc28 100644 --- a/Sources/LaunchDarklyObservability/API/SemanticConvention.swift +++ b/Sources/LaunchDarklyObservability/API/SemanticConvention.swift @@ -13,4 +13,5 @@ public enum SemanticConvention { public static let systemMemoryWarning = "system.memory.memory_warning" public static let deviceModelName = "device.model.name" public static let deviceModelIdentifier = "device.model.identifier" + public static let trackSpanName = "launchdarkly.track" } diff --git a/Sources/LaunchDarklyObservability/Client/NoOpObservabilityService.swift b/Sources/LaunchDarklyObservability/Client/NoOpObservabilityService.swift index b6f24851..83900ffc 100644 --- a/Sources/LaunchDarklyObservability/Client/NoOpObservabilityService.swift +++ b/Sources/LaunchDarklyObservability/Client/NoOpObservabilityService.swift @@ -19,6 +19,8 @@ final class NoOpObservabilityService: Observe { func startSpan(name: String, attributes: [String: AttributeValue]) -> any Span { NoOpTracer().startSpan(name: name, attributes: attributes) } + + func track(name: String, value: Double?, attributes: [String: AttributeValue]) {} } extension NoOpObservabilityService { diff --git a/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift b/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift index 0ed6b941..56c6855e 100644 --- a/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift +++ b/Sources/LaunchDarklyObservability/Client/ObservabilityService.swift @@ -39,6 +39,13 @@ final class ObservabilityService: InternalObserve { private let startQueue = DispatchQueue(label: "com.launchdarkly.observability.service.start") private var task: Task? + private let contextKeysQueue = DispatchQueue(label: "com.launchdarkly.observability.service.contextKeys") + private var _cachedContextKeyAttributes: [String: AttributeValue] = [:] + private var cachedContextKeyAttributes: [String: AttributeValue] { + get { contextKeysQueue.sync { _cachedContextKeyAttributes } } + set { contextKeysQueue.sync { _cachedContextKeyAttributes = newValue } } + } + init( options: ObservabilityOptions, mobileKey: String, @@ -169,7 +176,10 @@ final class ObservabilityService: InternalObserve { ) self.tracer = appTraceClient + let tapsEnabled = options.productAnalytics.taps.isEnabled let userInteractionManager = UserInteractionManager(options: options, sessionManaging: sessionManager) { interaction in + // Gate only the telemetry span; capture still flows to Session Replay. + guard tapsEnabled else { return } interaction.startEndSpan(tracer: tracerDecorator) } self.userInteractionManager = userInteractionManager @@ -192,6 +202,9 @@ final class ObservabilityService: InternalObserve { withValue: true, options: options ) + // Route the afterTrack hook and identify context keys back into this service, + // so it remains the single emitter of launchdarkly.track spans. + self.hookExporter.trackEmitter = self } } @@ -368,4 +381,47 @@ extension ObservabilityService: Observe { ) -> any Span { tracer.startSpan(name: name, attributes: attributes) } + + func track(name: String, value: Double?, attributes: [String: AttributeValue]) { + track(name: name, value: value, attributes: attributes, contextKeyAttributes: nil) + } +} + +extension ObservabilityService: TrackEmitting { + /// Single emitter for `launchdarkly.track` spans. Both the LD `afterTrack` hook and the + /// manual `LDObserve.track` path funnel through here. + func track( + name: String, + value: Double?, + attributes: [String: AttributeValue], + contextKeyAttributes: [String: AttributeValue]? + ) { + guard options.productAnalytics.trackEvents.isEnabled else { return } + + // Apply in increasing precedence so event identity can never be clobbered: user-supplied + // track data first, then context keys, then the reserved key/value attributes last. + var spanAttributes: [String: AttributeValue] = [:] + for (k, v) in attributes { + spanAttributes[k] = v + } + // Fresh context keys from the hook take precedence; otherwise use the cached identify keys. + for (k, v) in (contextKeyAttributes ?? cachedContextKeyAttributes) { + spanAttributes[k] = v + } + spanAttributes["key"] = .string(name) + if let value { + spanAttributes["value"] = .double(value) + } + + let span = tracer.startSpan(name: SemanticConvention.trackSpanName, attributes: spanAttributes) + span.end() + } + + func updateCachedContextKeys(_ contextKeys: [String: String]) { + var attributes = [String: AttributeValue]() + for (k, v) in contextKeys { + attributes[k] = .string(v) + } + cachedContextKeyAttributes = attributes + } } diff --git a/Sources/LaunchDarklyObservability/Plugin/ObservabilityHook.swift b/Sources/LaunchDarklyObservability/Plugin/ObservabilityHook.swift index 4579537e..d98f1662 100644 --- a/Sources/LaunchDarklyObservability/Plugin/ObservabilityHook.swift +++ b/Sources/LaunchDarklyObservability/Plugin/ObservabilityHook.swift @@ -9,6 +9,8 @@ protocol ObservabilityHookExporting: AnyObject { func afterEvaluation(evaluationId: String, flagKey: String, contextKey: String, value: LDValue, variationIndex: Int?, reason: [String: LDValue]?) func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool) + func afterTrack(eventKey: String, metricValue: Double?, + attributes: [String: AttributeValue], contextKeys: [String: String]) } /// Hook protocol adapter for native Swift SDK usage. @@ -66,4 +68,28 @@ final class ObservabilityHook: Hook { completed: true) return seriesData } + + public func afterTrack(seriesContext: TrackSeriesContext) { + var attributes = [String: AttributeValue]() + if case let .object(data) = seriesContext.data { + for (k, v) in data { + if let attr = Self.attributeValue(from: v) { + attributes[k] = attr + } + } + } + delegate?.afterTrack(eventKey: seriesContext.key, + metricValue: seriesContext.metricValue, + attributes: attributes, + contextKeys: seriesContext.context.contextKeys()) + } + + private static func attributeValue(from value: LDValue) -> AttributeValue? { + switch value { + case .bool(let b): return .bool(b) + case .number(let n): return .double(n) + case .string(let s): return .string(s) + case .null, .array, .object: return nil + } + } } diff --git a/Sources/LaunchDarklyObservability/Plugin/ObservabilityHookExporter.swift b/Sources/LaunchDarklyObservability/Plugin/ObservabilityHookExporter.swift index cc3e2628..c59ace7c 100644 --- a/Sources/LaunchDarklyObservability/Plugin/ObservabilityHookExporter.swift +++ b/Sources/LaunchDarklyObservability/Plugin/ObservabilityHookExporter.swift @@ -10,6 +10,14 @@ import Common /// Takes only simple Swift types — no Hook protocol, no @objc. /// Both ObservabilityHook (native Swift) and ObservabilityHookProxy (C# bridge) /// delegate here so the tracing logic is written exactly once. +/// Receives track events and identify context keys so the single span emitter +/// (`ObservabilityService`) can produce `launchdarkly.track` spans and cache context keys. +protocol TrackEmitting: AnyObject { + func track(name: String, value: Double?, attributes: [String: AttributeValue], + contextKeyAttributes: [String: AttributeValue]?) + func updateCachedContextKeys(_ contextKeys: [String: String]) +} + final class ObservabilityHookExporter { private let spans: BoundedMap @@ -18,6 +26,8 @@ final class ObservabilityHookExporter { private let withValue: Bool private let traceClient: TracesApi private let logClient: InternalLogsApi + /// The single track-span emitter. Set by `ObservabilityService` after construction. + weak var trackEmitter: TrackEmitting? init(traceClient: TracesApi, logClient: InternalLogsApi, @@ -128,6 +138,9 @@ extension ObservabilityHookExporter: ObservabilityHookExporting { func afterIdentify(contextKeys: [String: String], canonicalKey: String, completed: Bool) { guard completed else { return } + // Cache context keys so the manual track path can attribute events. + trackEmitter?.updateCachedContextKeys(contextKeys) + var attributes = [String: AttributeValue]() for (k, v) in contextKeys { attributes[k] = .string(v) @@ -143,6 +156,19 @@ extension ObservabilityHookExporter: ObservabilityHookExporting { attributes: attributes ) } + + func afterTrack(eventKey: String, metricValue: Double?, + attributes: [String: AttributeValue], contextKeys: [String: String]) { + var contextKeyAttributes = [String: AttributeValue]() + for (k, v) in contextKeys { + contextKeyAttributes[k] = .string(v) + } + // Route through the single emitter so gating/caching stay in one place. + trackEmitter?.track(name: eventKey, + value: metricValue, + attributes: attributes, + contextKeyAttributes: contextKeyAttributes) + } } // MARK: - Constants diff --git a/Sources/LaunchDarklyObservability/UIInteractions/UInteraction+Span.swift b/Sources/LaunchDarklyObservability/UIInteractions/UInteraction+Span.swift index ba2970af..35550e59 100644 --- a/Sources/LaunchDarklyObservability/UIInteractions/UInteraction+Span.swift +++ b/Sources/LaunchDarklyObservability/UIInteractions/UInteraction+Span.swift @@ -13,7 +13,7 @@ extension TouchInteraction { attributes["position.x"] = .string(point.x.toString()) attributes["position.y"] = .string(point.y.toString()) - let span = tracer.startSpan(name: "user.tap", + let span = tracer.startSpan(name: "click", attributes: attributes, startTime: Date(timeIntervalSince1970: startTimestamp)) span.end(time: Date(timeIntervalSince1970: timestamp)) diff --git a/TestApp/Sources/MainMenuView.swift b/TestApp/Sources/MainMenuView.swift index 31fc0fd3..e116e9bf 100644 --- a/TestApp/Sources/MainMenuView.swift +++ b/TestApp/Sources/MainMenuView.swift @@ -262,6 +262,16 @@ struct MainMenuView: View { .buttonStyle(.borderedProminent) } + Text("Track") + .fontWeight(.bold) + + HStack { + Button("Track (LDClient)") { viewModel.trackViaLDClient() } + .buttonStyle(.borderedProminent) + Button("Track (LDObserve)") { viewModel.trackViaLDObserve() } + .buttonStyle(.borderedProminent) + } + Text("Customer API") .fontWeight(.bold) diff --git a/TestApp/Sources/MainMenuViewModel.swift b/TestApp/Sources/MainMenuViewModel.swift index 6675197f..e83b00e7 100644 --- a/TestApp/Sources/MainMenuViewModel.swift +++ b/TestApp/Sources/MainMenuViewModel.swift @@ -115,6 +115,20 @@ final class MainMenuViewModel: ObservableObject { ) } + func trackViaLDClient() { + // Records a launchdarkly.track span automatically via the Observability afterTrack hook. + LDClient.get()?.track(key: "track-via-ld-client") + } + + func trackViaLDObserve() { + // Records a launchdarkly.track span directly through the Observability API. + LDObserve.shared.track( + name: "track-via-ld-observe", + value: 7.0, + attributes: ["source": .string("ld-observe")] + ) + } + func crash() -> Never { fatalError() } diff --git a/TestApp/TestApp.xcodeproj/project.pbxproj b/TestApp/TestApp.xcodeproj/project.pbxproj index 585697c1..66a1f92b 100644 --- a/TestApp/TestApp.xcodeproj/project.pbxproj +++ b/TestApp/TestApp.xcodeproj/project.pbxproj @@ -10,11 +10,12 @@ 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 */; }; + 50FD5D662FCE26D7008B9E06 /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = 50FD5D652FCE26D7008B9E06 /* LaunchDarkly */; }; E7904AD72E6A52CE00A15337 /* LaunchDarklyObservability in Frameworks */ = {isa = PBXBuildFile; productRef = E7904AD62E6A52CE00A15337 /* LaunchDarklyObservability */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Secrets.xcconfig; path = "../TestAppShared/Secrets.xcconfig"; sourceTree = ""; }; + AA00000000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Secrets.xcconfig; path = ../TestAppShared/Secrets.xcconfig; sourceTree = ""; }; E7904AC42E6A523D00A15337 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -47,6 +48,7 @@ 503DA8C82F31678400E7E47F /* LaunchDarklyObservability in Frameworks */, 5006D6B52EEB4D460081FEA5 /* LaunchDarklySessionReplay in Frameworks */, E7904AD72E6A52CE00A15337 /* LaunchDarklyObservability in Frameworks */, + 50FD5D662FCE26D7008B9E06 /* LaunchDarkly in Frameworks */, 50C584CA2EE23723006A0045 /* LaunchDarkly in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -103,6 +105,7 @@ 50C584C92EE23723006A0045 /* LaunchDarkly */, 5006D6B22EEB4D460081FEA5 /* LaunchDarklyObservability */, 5006D6B42EEB4D460081FEA5 /* LaunchDarklySessionReplay */, + 50FD5D652FCE26D7008B9E06 /* LaunchDarkly */, ); productName = ExampleApp; productReference = E7904AC42E6A523D00A15337 /* TestApp.app */; @@ -135,6 +138,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 5006D6B12EEB4D460081FEA5 /* XCLocalSwiftPackageReference "../../swift-launchdarkly-observability" */, + 50FD5D642FCE26D7008B9E06 /* XCLocalSwiftPackageReference "../../ios-client-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = E7904AC52E6A523D00A15337 /* Products */; @@ -383,6 +387,10 @@ isa = XCLocalSwiftPackageReference; relativePath = "../../swift-launchdarkly-observability"; }; + 50FD5D642FCE26D7008B9E06 /* XCLocalSwiftPackageReference "../../ios-client-sdk" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../ios-client-sdk"; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -398,6 +406,10 @@ isa = XCSwiftPackageProductDependency; productName = LaunchDarkly; }; + 50FD5D652FCE26D7008B9E06 /* LaunchDarkly */ = { + isa = XCSwiftPackageProductDependency; + productName = LaunchDarkly; + }; E7904AD62E6A52CE00A15337 /* LaunchDarklyObservability */ = { isa = XCSwiftPackageProductDependency; productName = LaunchDarklyObservability;