Skip to content

Commit 915ef63

Browse files
feat: Bridging hooks calls from MAUI (#142)
- Expose hooks for MAUI - Fix sending internal traces through Customer API <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes hook/span lifecycle handling and tracing client plumbing, and introduces new thread-safe caching structures; mistakes could cause missing/duplicated spans or incorrect telemetry attributes. > > **Overview** > **Adds MAUI hook bridging** by introducing `ObservabilityHookProxy` (stored on `LDObserve.shared`) and a shared `ObservabilityHookExporter` used by both the native Swift `ObservabilityHook` and the MAUI bridge. > > **Refactors evaluation tracing** to track in-flight spans by `evaluationId` (via new FIFO-evicting `BoundedMap`) and centralizes span start/end + attribute/event emission in the exporter; `InternalObserve`/`ObservabilityService` now expose `traceClient` for this path. > > **Improves cross-boundary value passing** with `LDValue`↔Foundation (`NSObject`/`NSDictionary`/`NSArray`) conversion helpers, and extends `AtomicDictionary` with synchronous barrier `setValue`/`removeValue`; adds unit tests for `BoundedMap` and `LDValue` conversions. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 484f8bb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5cbc63c commit 915ef63

13 files changed

+725
-119
lines changed

Sources/Common/AtomicDictionary.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,29 @@ where Key: Hashable, Key: Sendable {
1414

1515
public init() {}
1616

17+
// Asynchronous barrier write:
18+
// callers rely on this mutation being visible after return.
1719
public subscript(key: Key) -> Value? {
1820
get { queue.sync { storage[key] }}
1921
set { queue.async(flags: .barrier) { [weak self] in self?.storage[key] = newValue } }
2022
}
2123

24+
public func setValue(_ value: Value?, forKey key: Key) {
25+
// Synchronous barrier write:
26+
// callers rely on this mutation being visible immediately after return.
27+
queue.sync(flags: .barrier) {
28+
storage[key] = value
29+
}
30+
}
31+
32+
public func removeValue(forKey key: Key) -> Value? {
33+
// Synchronous barrier remove:
34+
// this acts like an atomic "pop" (read + remove in one critical section).
35+
queue.sync(flags: .barrier) {
36+
storage.removeValue(forKey: key)
37+
}
38+
}
39+
2240
public var debugDescription: String {
2341
return queue.sync { storage.debugDescription }
2442
}

Sources/Common/BoundedMap.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
3+
/// Thread-safe bounded dictionary with FIFO eviction.
4+
///
5+
/// - Insertions beyond `capacity` evict the oldest key/value pair.
6+
/// - `setValue` and eviction happen atomically in one barrier section.
7+
public final class BoundedMap<Key, Value>: @unchecked Sendable
8+
where Key: Hashable, Key: Sendable {
9+
private var storage = [Key: Value]()
10+
private var insertionOrder = [Key]()
11+
private let capacity: Int
12+
13+
private let queue = DispatchQueue(
14+
label: "com.observability.bounded-map.\(UUID().uuidString)",
15+
qos: .utility,
16+
attributes: .concurrent,
17+
autoreleaseFrequency: .inherit,
18+
target: .global()
19+
)
20+
21+
public init(capacity: Int) {
22+
self.capacity = max(1, capacity)
23+
}
24+
25+
/// Atomically sets a value and optionally evicts the oldest item.
26+
/// - Returns: Evicted key/value if capacity overflow occurs, otherwise nil.
27+
@discardableResult
28+
public func setValue(_ value: Value, forKey key: Key) -> (key: Key, value: Value)? {
29+
queue.sync(flags: .barrier) {
30+
if storage[key] != nil, let idx = insertionOrder.firstIndex(of: key) {
31+
insertionOrder.remove(at: idx)
32+
}
33+
34+
storage[key] = value
35+
insertionOrder.append(key)
36+
37+
guard insertionOrder.count > capacity else { return nil }
38+
let evictedKey = insertionOrder.removeFirst()
39+
guard let evictedValue = storage.removeValue(forKey: evictedKey) else { return nil }
40+
return (evictedKey, evictedValue)
41+
}
42+
}
43+
44+
/// Atomically removes and returns the value for a key.
45+
public func removeValue(forKey key: Key) -> Value? {
46+
queue.sync(flags: .barrier) {
47+
let removed = storage.removeValue(forKey: key)
48+
if removed != nil, let idx = insertionOrder.firstIndex(of: key) {
49+
insertionOrder.remove(at: idx)
50+
}
51+
return removed
52+
}
53+
}
54+
55+
public var count: Int {
56+
queue.sync { storage.count }
57+
}
58+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Foundation
2+
import LaunchDarkly
3+
4+
/// Converts between Swift LDValue (enum) and Foundation types (NSObject hierarchy)
5+
/// so values can cross the Obj-C / MAUI boundary without JSON string parsing.
6+
///
7+
/// Mapping:
8+
/// .null <-> NSNull
9+
/// .bool(Bool) <-> NSNumber(value: Bool)
10+
/// .number(Double) <-> NSNumber(value: Double)
11+
/// .string(String) <-> NSString
12+
/// .array([...]) <-> NSArray
13+
/// .object({...}) <-> NSDictionary
14+
extension LDValue {
15+
16+
public func toFoundation() -> NSObject {
17+
switch self {
18+
case .null:
19+
return NSNull()
20+
case .bool(let b):
21+
return NSNumber(value: b)
22+
case .number(let d):
23+
return NSNumber(value: d)
24+
case .string(let s):
25+
return s as NSString
26+
case .array(let arr):
27+
return arr.map { $0.toFoundation() } as NSArray
28+
case .object(let dict):
29+
let ns = NSMutableDictionary(capacity: dict.count)
30+
for (k, v) in dict {
31+
ns[k] = v.toFoundation()
32+
}
33+
return ns
34+
}
35+
}
36+
37+
public static func fromFoundation(_ obj: Any?) -> LDValue {
38+
guard let obj = obj else { return .null }
39+
if obj is NSNull { return .null }
40+
41+
if let num = obj as? NSNumber {
42+
if CFGetTypeID(num) == CFBooleanGetTypeID() {
43+
return .bool(num.boolValue)
44+
}
45+
return .number(num.doubleValue)
46+
}
47+
48+
if let str = obj as? String { return .string(str) }
49+
50+
if let arr = obj as? [Any] {
51+
return .array(arr.map { fromFoundation($0) })
52+
}
53+
54+
if let dict = obj as? [String: Any] {
55+
return .object(dict.mapValues { fromFoundation($0) })
56+
}
57+
58+
return .null
59+
}
60+
}
61+
62+
extension Dictionary where Key == String, Value == LDValue {
63+
64+
public func toFoundation() -> NSDictionary {
65+
let ns = NSMutableDictionary(capacity: count)
66+
for (k, v) in self {
67+
ns[k] = v.toFoundation()
68+
}
69+
return ns
70+
}
71+
72+
public static func fromFoundation(_ dict: NSDictionary?) -> [String: LDValue]? {
73+
guard let dict = dict, dict.count > 0 else { return nil }
74+
var result = [String: LDValue]()
75+
for (k, v) in dict {
76+
guard let key = k as? String else { continue }
77+
result[key] = LDValue.fromFoundation(v)
78+
}
79+
return result
80+
}
81+
}

Sources/LaunchDarklyObservability/LDObserve.swift renamed to Sources/LaunchDarklyObservability/API/LDObserve.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ public final class LDObserve {
1717
}
1818
public static let shared = LDObserve()
1919
public var context: ObservabilityContext?
20-
20+
public var hookProxy: ObservabilityHookProxy?
21+
var plugin: Observability?
22+
2123
init(client: Observe = NoOpObservabilityService.shared) {
2224
self._client = client
2325
}

Sources/LaunchDarklyObservability/API/ObjcLDObserveBridge.swift renamed to Sources/LaunchDarklyObservability/Bridge/ObjcLDObserveBridge.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ public final class ObjcLDObserveBridge: NSObject {
2828

2929
LDObserve.shared.recordLog(message: message, severity: sev, attributes: attrs)
3030
}
31+
3132
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
protocol InternalObserve: Observe {
22
var logClient: LogsApi { get }
3+
var traceClient: TracesApi { get }
34
}

Sources/LaunchDarklyObservability/Client/ObservabilityService.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import OSLog
77

88
final class ObservabilityService: InternalObserve {
99
var logClient: LogsApi { loggerClient }
10+
var traceClient: TracesApi { _traceClient }
1011
private let logger: LogsApi
1112
private let meter: MetricsApi
1213
private let tracer: TracesApi
@@ -24,7 +25,7 @@ final class ObservabilityService: InternalObserve {
2425

2526
private let metricsClient: MetricsApi
2627

27-
private let traceClient: TraceClient
28+
private let _traceClient: TraceClient
2829
private let tracerDecorator: TracerDecorator
2930

3031
private var instruments = [AutoInstrumentation]()
@@ -157,7 +158,7 @@ final class ObservabilityService: InternalObserve {
157158
options: options.tracesApi,
158159
tracer: tracerDecorator
159160
)
160-
self.traceClient = traceClient
161+
self._traceClient = traceClient
161162

162163
let appTraceClient = AppTraceClient(
163164
options: options.tracesApi,
@@ -195,7 +196,7 @@ extension ObservabilityService {
195196
instruments
196197
.append(
197198
InstrumentationTask<TraceClient>(
198-
instrument: traceClient,
199+
instrument: _traceClient,
199200
samplingInterval: autoInstrumentationSamplingInterval
200201
) {
201202
await launchTracker.trace(using: $0)

Sources/LaunchDarklyObservability/Plugin/Observability.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public final class Observability: Plugin {
5454
observabilityService = service
5555
LDObserve.shared.client = service
5656
LDObserve.shared.context = service.context
57+
LDObserve.shared.plugin = self
5758

5859
if options.isEnabled {
5960
service.start()
@@ -64,11 +65,14 @@ public final class Observability: Plugin {
6465
}
6566

6667
public func getHooks(metadata: EnvironmentMetadata) -> [any Hook] {
67-
[ObservabilityHook(plugin: self,
68-
withSpans: true,
69-
withValue: true,
70-
version: options.serviceVersion,
71-
options: options)]
68+
let exporter = ObservabilityHookExporter(
69+
plugin: self,
70+
withSpans: true,
71+
withValue: true,
72+
options: options
73+
)
74+
LDObserve.shared.hookProxy = ObservabilityHookProxy(exporter: exporter)
75+
return [ObservabilityHook(exporter: exporter)]
7276
}
7377
}
7478

0 commit comments

Comments
 (0)