|
1 | 1 | import Foundation |
2 | 2 | import OpenTelemetryApi |
3 | 3 |
|
4 | | -/// Converts Foundation types (from Obj-C bridge) into OpenTelemetry `AttributeValue`. |
| 4 | +/// Converts Foundation / Swift values into OpenTelemetry `AttributeValue`s. |
5 | 5 | /// |
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. |
11 | 23 | public enum AttributeConverter { |
12 | 24 |
|
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] { |
16 | 28 | var result: [String: AttributeValue] = [:] |
17 | 29 | for (key, value) in source { |
18 | | - result[key] = convertValue(value) |
| 30 | + if let converted = convertValue(value, stringifyUnknown: stringifyUnknown) { |
| 31 | + result[key] = converted |
| 32 | + } |
19 | 33 | } |
20 | 34 | return result |
21 | 35 | } |
22 | 36 |
|
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`). |
24 | 39 | /// |
25 | 40 | /// 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` |
29 | 46 | /// (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. |
33 | 63 | if let nsDict = value as? NSDictionary { |
34 | 64 | 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 |
38 | 69 | } |
39 | 70 | } |
40 | 71 | return .set(AttributeSet(labels: labels)) |
41 | 72 | } |
42 | 73 |
|
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) })) |
49 | 77 | } |
50 | 78 |
|
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) |
62 | 88 | } |
63 | 89 | } |
64 | 90 |
|
65 | | - if let s = value as? String { |
66 | | - return .string(s) |
67 | | - } |
| 91 | + if let string = value as? String { return .string(string) } |
68 | 92 |
|
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 |
70 | 99 | } |
71 | 100 | } |
0 commit comments