Skip to content

Commit 14746b7

Browse files
feat: dictionary-based track API and Obj-C track bridge (#218)
## Summary - Make `Observe.track` (and the `LDObserve` / `ObservabilityService` / `NoOpObservabilityService` conformances) take a plain `[String: Any]?` dictionary instead of `LDValue`, so callers need not depend on the LaunchDarkly flag model types. Object members are mapped to span attributes via the existing `AttributeConverter`. - Add `ObjcLDObserveBridge.track` so the Flutter observability plugin can forward custom track events across the pigeon bridge into the native SDK — emitting the `track` span (gated by `analytics.trackEvents`) and the Session Replay `Track` timeline event. - Update the `TestApp` track button to pass a plain dictionary. The `afterTrack` hook path is unchanged: it already converts `LDValue` to attributes and routes through the dedicated `afterTrack` exporter, so it does not use `Observe.track`. ## Compatibility This changes the public `track(key:data:metricValue:)` signature (`LDValue?` → `[String: Any]?`), which is a breaking change for direct callers of `LDObserve.shared.track`. `LDClient.track` (the flag SDK API) is unaffected. ## Test plan - [x] `xcodebuild build -scheme LaunchDarklyObservability -destination 'generic/platform=iOS Simulator'` → BUILD SUCCEEDED - [ ] Verify a manual `LDObserve.shared.track(key:data:)` call produces a `track` span and a Session Replay `Track` event Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Public breaking signature change on `track`, plus altered span gating/kind and new bridge path that affects analytics attribution for cross-platform hosts. > > **Overview** > **Breaking API change:** `Observe.track` / `LDObserve.track` now take `[String: Any]?` instead of `LDValue?`, so direct callers no longer need LaunchDarkly value types. Payload keys are converted to OpenTelemetry span attributes via new `[String: Any].toOtelAttributes()` (nested dicts → sets, arrays → attribute arrays, unsupported values dropped). > > Adds **`ObjcLDObserveBridge.track`** for Flutter/pigeon: dictionary payload, optional metric, and optional **context kind→key** map to annotate spans when the LD client lives outside this SDK; otherwise it falls back to `LDObserve.shared.track`. > > **Telemetry behavior:** `track` spans are emitted only when both `analytics.trackEvents` and **`tracesApi.includeSpans`** are enabled, and spans are created as **CONSUMER** (not internal) via the tracer decorator. Session Replay `Track` events still broadcast regardless. > > `.gitignore` now ignores all `*.xcconfig` (replacing a single `Secrets.xcconfig` entry). TestApp adds a nested “Checkout Started” demo; **`OtelAttributesTests`** cover the new mapping. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 313edb7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dd0134f commit 14746b7

18 files changed

Lines changed: 624 additions & 100 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ DerivedData/
77
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
88
.netrc
99
Package.resolved
10-
Secrets.xcconfig
10+
*.xcconfig

README.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,13 @@ LDObserve.shared.recordIncr(metric: .init(name: "page_views", value: 1.0))
381381
LDObserve.shared.recordHistogram(metric: .init(name: "response_time", value: 150.0))
382382
LDObserve.shared.recordUpDownCounter(metric: .init(name: "active_connections", value: 1.0))
383383

384-
// Record logs
384+
// Record logs — pass attributes as a plain dictionary via `properties`.
385385
LDObserve.shared.recordLog(
386386
message: "User performed action",
387387
severity: .info,
388-
attributes: [
389-
"user_id": .string("12345"),
390-
"action": .string("button_click")
388+
properties: [
389+
"user_id": "12345",
390+
"action": "button_click"
391391
]
392392
)
393393

@@ -403,9 +403,9 @@ LDObserve.shared.recordError(
403403
// Create spans for tracing
404404
let span = LDObserve.shared.startSpan(
405405
name: "api_request",
406-
attributes: [
407-
"endpoint": .string("/api/users"),
408-
"method": .string("GET")
406+
properties: [
407+
"endpoint": "/api/users",
408+
"method": "GET"
409409
]
410410
)
411411

@@ -415,11 +415,18 @@ span.end()
415415
// (Calling LDClient.get()?.track(key:) records the same span automatically via the afterTrack hook.)
416416
LDObserve.shared.track(
417417
key: "checkout_completed",
418-
data: ["currency": "USD"],
418+
properties: ["currency": "USD"],
419419
metricValue: 42.0
420420
)
421421
```
422422

423+
`recordLog`, `startSpan`, and `track` accept attributes/data as a plain Swift
424+
dictionary via the `properties:` label — pass `String`, `Bool`, `Int`, `Double`,
425+
arrays, and nested dictionaries directly, with no need to import `OpenTelemetryApi`
426+
or build `AttributeValue`s. The `attributes:` (`AttributeValue`) overloads remain
427+
available when you need precise OpenTelemetry typing (as shown for `recordError`
428+
above).
429+
423430
### Tracking Screen Views
424431

425432
A `screen_view` event captures when a screen is shown, using the `event.*` attribute namespace (`event.name`, `event.screen_class`, `event.screen_id`, `event.previous_screen`, `event.category`). `previous_screen` is resolved automatically from a shared screen stack, so it stays correct regardless of whether a screen was captured automatically or manually.
@@ -524,6 +531,29 @@ LDObserve.shared.trackScreenView(name: "Profile")
524531
LDObserve.shared.trackScreenView(name: "Profile", category: "Account")
525532
```
526533

534+
You can attach custom attributes to the `screen_view` span via the optional
535+
`properties:` parameter — a plain dictionary, using the same conversion rules as a
536+
`track` event's `properties` (no need to build `AttributeValue`s). Custom
537+
properties are applied at lower precedence than the reserved `event.*` taxonomy
538+
fields, so they can never clobber them:
539+
540+
```swift
541+
LDObserve.shared.trackScreenView(
542+
name: "Profile",
543+
screenClass: "ProfileView",
544+
screenId: "MyApp.ProfileView",
545+
category: "Account",
546+
properties: [
547+
"tab": "overview",
548+
"is_owner": true
549+
]
550+
)
551+
552+
// Also available on the convenience overloads:
553+
LDObserve.shared.trackScreenView(name: "Profile", properties: ["tab": "overview"])
554+
LDObserve.shared.trackScreenView(name: "Profile", category: "Account", properties: ["tab": "overview"])
555+
```
556+
527557
Manual `trackScreenView(...)` calls work even when automatic detection (`instrumentation.screens`) is disabled. The emitted `screen_view` span is still gated by `analytics.screenViews`.
528558

529559
## Contributing

Sources/LaunchDarklyObservability/API/LDObserve.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ extension LDObserve: Observe {
6868
client.startSpan(name: name, attributes: attributes)
6969
}
7070

71-
public func track(key: String, data: LDValue? = nil, metricValue: Double? = nil) {
72-
client.track(key: key, data: data, metricValue: metricValue)
71+
public func track(key: String, properties: [String: Any]? = nil, metricValue: Double? = nil) {
72+
client.track(key: key, properties: properties, metricValue: metricValue)
7373
}
7474

75-
public func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?) {
76-
client.trackScreenView(name: name, screenClass: screenClass, screenId: screenId, category: category)
75+
public func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?, properties: [String: Any]?) {
76+
client.trackScreenView(name: name, screenClass: screenClass, screenId: screenId, category: category, properties: properties)
7777
}
7878
}

Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ public struct ObservabilityOptions {
194194
/// by either flag.
195195
let taps: FeatureFlag
196196
/// Whether to emit a `track` span when a custom event is tracked
197-
/// (via the LD `afterTrack` hook or ``LDObserve/track(key:data:metricValue:)``).
197+
/// (via the LD `afterTrack` hook or ``LDObserve/track(key:properties:metricValue:)``).
198198
let trackEvents: FeatureFlag
199199
/// Whether to emit a `screen_view` span when a screen is shown
200200
/// (automatically via the `UIViewController` swizzle or manually via

Sources/LaunchDarklyObservability/API/Observe.swift

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ public protocol Observe: AnyObject, MetricsApi, LogsApi, TracesApi, ObserveConte
88
func start()
99
/// Record a custom track event as a `track` span.
1010
///
11-
/// Mirrors `LDClient.track(key:data:metricValue:)` so the same call shape
12-
/// works whether the event is recorded through the LaunchDarkly client (via
13-
/// the `afterTrack` hook) or directly through this API.
11+
/// Mirrors `LDClient.track(...)` so the same call shape works whether the
12+
/// event is recorded through the LaunchDarkly client (via the `afterTrack`
13+
/// hook) or directly through this API. The payload is passed as `properties`,
14+
/// a plain dictionary, so callers need not depend on `LDValue` — matching the
15+
/// `properties:` overloads of `recordLog`/`startSpan`.
1416
/// - Parameters:
1517
/// - key: The key for the event.
16-
/// - data: The data associated with the event, if any.
18+
/// - properties: The data associated with the event, if any. Object members
19+
/// are attached as span attributes.
1720
/// - metricValue: A numeric value used by LaunchDarkly experimentation for
1821
/// numeric custom metrics, if any.
19-
func track(key: String, data: LDValue?, metricValue: Double?)
22+
func track(key: String, properties: [String: Any]?, metricValue: Double?)
2023
/// Manually record a `screen_view` event as a `screen_view` span.
2124
///
2225
/// Use this for screens that automatic capture cannot observe (e.g. pure
@@ -27,16 +30,31 @@ public protocol Observe: AnyObject, MetricsApi, LogsApi, TracesApi, ObserveConte
2730
/// - screenClass: The screen's class/type (`event.screen_class`).
2831
/// - screenId: A stable screen identifier (`event.screen_id`).
2932
/// - category: An optional screen group (`event.category`).
30-
func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?)
33+
/// - properties: Optional custom attributes, supplied as a plain dictionary
34+
/// (same conversion rules as a `track` event's `properties`). They are
35+
/// attached at lower precedence than the reserved `event.*` fields, so
36+
/// they can never clobber the taxonomy.
37+
func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?, properties: [String: Any]?)
3138
}
3239

3340
extension Observe {
3441
public func trackScreenView(name: String) {
35-
trackScreenView(name: name, screenClass: nil, screenId: nil, category: nil)
42+
trackScreenView(name: name, screenClass: nil, screenId: nil, category: nil, properties: nil)
3643
}
3744

3845
public func trackScreenView(name: String, category: String?) {
39-
trackScreenView(name: name, screenClass: nil, screenId: nil, category: category)
46+
trackScreenView(name: name, screenClass: nil, screenId: nil, category: category, properties: nil)
47+
}
48+
49+
/// Convenience that omits the screen class/id. See the full overload for the
50+
/// `properties` semantics.
51+
public func trackScreenView(name: String, category: String?, properties: [String: Any]?) {
52+
trackScreenView(name: name, screenClass: nil, screenId: nil, category: category, properties: properties)
53+
}
54+
55+
/// Convenience overload without `properties`, preserving the prior call shape.
56+
public func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?) {
57+
trackScreenView(name: name, screenClass: screenClass, screenId: screenId, category: category, properties: nil)
4058
}
4159
}
4260

@@ -76,6 +94,18 @@ extension LogsApi {
7694
public func recordLog(message: String, severity: Severity, spanContext: SpanContext? = nil) {
7795
recordLog(message: message, severity: severity, attributes: [:], spanContext: spanContext)
7896
}
97+
98+
/// Record a log whose attributes are supplied as a plain dictionary.
99+
///
100+
/// Prefer this over ``recordLog(message:severity:attributes:spanContext:)``
101+
/// for everyday use: pass native values (`String`, `Bool`, `Int`, `Double`,
102+
/// arrays, nested dictionaries) and they are converted to OTel attributes with
103+
/// the same rules as a `track` event's `properties`. The `attributes:`
104+
/// (`AttributeValue`) overload remains available when you need precise OTel
105+
/// typing. A distinct label keeps the two overloads unambiguous.
106+
public func recordLog(message: String, severity: Severity, properties: [String: Any], spanContext: SpanContext? = nil) {
107+
recordLog(message: message, severity: severity, attributes: properties.toOtelAttributes(), spanContext: spanContext)
108+
}
79109
}
80110

81111
public protocol TracesApi {
@@ -108,4 +138,21 @@ extension TracesApi {
108138
public func startSpan(name: String, attributes: [String : AttributeValue], spanKind: SpanKind) -> Span {
109139
startSpan(name: name, attributes: attributes)
110140
}
141+
142+
/// Start a span whose attributes are supplied as a plain dictionary.
143+
///
144+
/// Prefer this over ``startSpan(name:attributes:)`` for everyday use: pass
145+
/// native values and they are converted to OTel attributes with the same
146+
/// rules as a `track` event's `properties`. The `attributes:`
147+
/// (`AttributeValue`) overload remains available when you need precise OTel
148+
/// typing.
149+
public func startSpan(name: String, properties: [String: Any]) -> Span {
150+
startSpan(name: name, attributes: properties.toOtelAttributes())
151+
}
152+
153+
/// Start a span with an explicit kind whose attributes are supplied as a
154+
/// plain dictionary. See ``startSpan(name:properties:)``.
155+
public func startSpan(name: String, properties: [String: Any], spanKind: SpanKind) -> Span {
156+
startSpan(name: name, attributes: properties.toOtelAttributes(), spanKind: spanKind)
157+
}
111158
}

Sources/LaunchDarklyObservability/API/TrackEvent.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import OpenTelemetryApi
44
/// A custom analytics `track` event broadcast to in-process consumers such as Session Replay.
55
///
66
/// Emitted by the single `track` emitter for every track path — `LDClient.track` (via the
7-
/// observability hook) and the manual ``LDObserve/track(key:data:metricValue:)`` API, including
7+
/// observability hook) and the manual ``LDObserve/track(key:properties:metricValue:)`` API, including
88
/// standalone init without `LDClient`. Session Replay maps these to RRWeb `Custom` events tagged
99
/// `"Track"`, mirroring the web SDK.
1010
public struct TrackEvent {
Lines changed: 70 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,100 @@
11
import Foundation
22
import OpenTelemetryApi
33

4-
/// Converts Foundation types (from Obj-C bridge) into OpenTelemetry `AttributeValue`.
4+
/// Converts Foundation / Swift values into OpenTelemetry `AttributeValue`s.
55
///
6-
/// Values arriving from a .NET MAUI / Obj-C bridge are Foundation types
7-
/// (`NSString`, `NSNumber`, `NSDictionary`, `NSArray`).
8-
/// `AttributeValue.init?(Any)` does not handle Foundation collection types,
9-
/// so this converter checks for `NSDictionary` / `NSArray` explicitly and
10-
/// recurses into nested structures.
6+
/// Values reach this converter from several sources: the LD `track` hook
7+
/// (`LDValue` bridged to Foundation), the manual `LDObserve` track/log/span
8+
/// APIs (`[String: Any]`), and the Obj-C / .NET MAUI bridge (`NSString`,
9+
/// `NSNumber`, `NSDictionary`, `NSArray`). `AttributeValue.init?(Any)` does not
10+
/// handle Foundation collection/number types, so this converter checks for
11+
/// `NSDictionary` / `NSArray` / `NSNumber` explicitly and recurses into nested
12+
/// structures. Anything else is handed to `AttributeValue.init?` so the
13+
/// representation choice stays OpenTelemetry's, not ours.
14+
///
15+
/// `stringifyUnknown` controls what happens to values OpenTelemetry cannot
16+
/// represent (e.g. a `Date`, which has no OTel attribute form):
17+
/// - `false` (default): drop the value, so it never appears as an attribute.
18+
/// - `true`: fall back to `.string(String(describing:))`.
19+
///
20+
/// `null` (`NSNull`) is always dropped regardless of `stringifyUnknown`, mirroring
21+
/// the `.null <-> NSNull` Foundation mapping — there is no OTel attribute form for
22+
/// null, and emitting a `"<null>"` string would be misleading.
1123
public enum AttributeConverter {
1224

13-
/// Converts a `[String: Any]` dictionary of Foundation values into
14-
/// `[String: AttributeValue]`.
15-
public static func convert(_ source: [String: Any]) -> [String: AttributeValue] {
25+
/// Converts a `[String: Any]` dictionary into `[String: AttributeValue]`,
26+
/// omitting any entries whose value cannot be represented.
27+
public static func convert(_ source: [String: Any], stringifyUnknown: Bool = false) -> [String: AttributeValue] {
1628
var result: [String: AttributeValue] = [:]
1729
for (key, value) in source {
18-
result[key] = convertValue(value)
30+
if let converted = convertValue(value, stringifyUnknown: stringifyUnknown) {
31+
result[key] = converted
32+
}
1933
}
2034
return result
2135
}
2236

23-
/// Converts a single Foundation value into an `AttributeValue`.
37+
/// Converts a single value into an `AttributeValue`, or `nil` when it has no
38+
/// representable form (and `stringifyUnknown` is `false`).
2439
///
2540
/// Resolution order:
26-
/// 1. `NSDictionary` → `.set(AttributeSet(labels: …))` (recursive)
27-
/// 2. `NSArray` → `.array(AttributeArray(values: …))` (recursive)
28-
/// 3. `NSNumber` → `.bool` / `.int` / `.double` based on `objCType`
41+
/// 1. `AttributeValue` / `[String: AttributeValue]` → used directly.
42+
/// 2. `NSNull` → dropped (no OTel null form).
43+
/// 3. `NSDictionary` → `.set(AttributeSet(labels: …))` (recursive)
44+
/// 4. `NSArray` → `.array(AttributeArray(values: …))` (recursive)
45+
/// 5. `NSNumber` → `.bool` / `.int` / `.double` based on `objCType`
2946
/// (avoids Swift's special bridging that converts NSNumber(0/1) to Bool)
30-
/// 4. `String` → `.string`
31-
/// 5. Fallback → `.string(String(describing: value))`
32-
public static func convertValue(_ value: Any) -> AttributeValue {
47+
/// 6. `String` → `.string`
48+
/// 7. Otherwise → delegated to `AttributeValue(_:)`. Whatever OTel cannot
49+
/// represent (e.g. `Date`) becomes `.string(String(describing:))` when
50+
/// `stringifyUnknown` is set, else `nil`.
51+
public static func convertValue(_ value: Any, stringifyUnknown: Bool = false) -> AttributeValue? {
52+
// Already an attribute value, or a whole attribute set: use directly.
53+
if let attributeValue = value as? AttributeValue { return attributeValue }
54+
if let labels = value as? [String: AttributeValue] {
55+
return .set(AttributeSet(labels: labels))
56+
}
57+
58+
// Explicit null has no OTel attribute form; drop it (matching the
59+
// `.null <-> NSNull` Foundation mapping) rather than emitting "<null>".
60+
if value is NSNull { return nil }
61+
62+
// Nested dictionary -> nested attribute set.
3363
if let nsDict = value as? NSDictionary {
3464
var labels: [String: AttributeValue] = [:]
35-
for (key, val) in nsDict {
36-
if let strKey = key as? String {
37-
labels[strKey] = convertValue(val)
65+
for (rawKey, nestedValue) in nsDict {
66+
if let key = rawKey as? String,
67+
let converted = convertValue(nestedValue, stringifyUnknown: stringifyUnknown) {
68+
labels[key] = converted
3869
}
3970
}
4071
return .set(AttributeSet(labels: labels))
4172
}
4273

43-
if let nsArr = value as? NSArray {
44-
var values: [AttributeValue] = []
45-
for item in nsArr {
46-
values.append(convertValue(item))
47-
}
48-
return .array(AttributeArray(values: values))
74+
// Array -> attribute array, dropping elements that can't be represented.
75+
if let nsArray = value as? NSArray {
76+
return .array(AttributeArray(values: nsArray.compactMap { convertValue($0, stringifyUnknown: stringifyUnknown) }))
4977
}
5078

51-
// Handle NSNumber before AttributeValue.init?(Any) to avoid Swift's
52-
// special Bool bridging: the runtime converts NSNumber(0) and NSNumber(1)
53-
// to Bool regardless of the original numeric type.
54-
if let nsNum = value as? NSNumber {
55-
switch String(cString: nsNum.objCType) {
56-
case "c", "B":
57-
return .bool(nsNum.boolValue)
58-
case "d", "f":
59-
return .double(nsNum.doubleValue)
60-
default:
61-
return .int(nsNum.intValue)
79+
// NSNumber covers values bridged from the Obj-C/pigeon bridge and, on
80+
// Apple platforms, native Swift Bool/Int/Double (which bridge to NSNumber).
81+
// objCType disambiguates bool vs numeric so an NSNumber(0/1) is not misread
82+
// as a Bool (and vice versa).
83+
if let number = value as? NSNumber {
84+
switch String(cString: number.objCType) {
85+
case "c", "B": return .bool(number.boolValue)
86+
case "d", "f": return .double(number.doubleValue)
87+
default: return .int(number.intValue)
6288
}
6389
}
6490

65-
if let s = value as? String {
66-
return .string(s)
67-
}
91+
if let string = value as? String { return .string(string) }
6892

69-
return .string(String(describing: value))
93+
// Delegate anything else to OpenTelemetry's own conversion rather than
94+
// inventing a representation. It maps the remaining scalar/array types it
95+
// supports and returns nil for everything it has no attribute form for
96+
// (e.g. `Date`), which we then stringify only when asked, otherwise drop.
97+
if let otelValue = AttributeValue(value) { return otelValue }
98+
return stringifyUnknown ? .string(String(describing: value)) : nil
7099
}
71100
}

0 commit comments

Comments
 (0)