Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ExampleApp/ExampleApp/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions MultiSceneExampleApp/MultiwindowPad/Config/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
]
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,14 +333,22 @@ let config = { () -> LDConfig in
("X-Custom-Header", "custom-value")
],
sessionBackgroundTimeout: 60,
isDebug: true
isDebug: true,
productAnalytics: .enabled
)
)
]
return config
}()
```

`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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/LaunchDarklyObservability/API/LDObserve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,28 +151,49 @@ public struct ObservabilityOptions {
}
public struct Instrumentation {
let urlSession: FeatureFlag
let userTaps: FeatureFlag
let memory: FeatureFlag
let memoryWarnings: FeatureFlag
let cpu: FeatureFlag
let launchTimes: FeatureFlag

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) {
Comment thread
cursor[bot] marked this conversation as resolved.
self.taps = taps
self.trackEvents = trackEvents
}
}
public var isEnabled: Bool
public var serviceName: String
public var serviceVersion: String
Expand All @@ -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.
///
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -268,6 +293,7 @@ public struct ObservabilityOptions {
self.log = log
self.crashReporting = crashReporting
self.instrumentation = instrumentation
self.productAnalytics = productAnalytics
self.isEnabled = isEnabled
}
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/LaunchDarklyObservability/API/Observe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ final class ObservabilityService: InternalObserve {
private let startQueue = DispatchQueue(label: "com.launchdarkly.observability.service.start")
private var task: Task<Void, Never>?

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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, any Span>
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading