@barrysolomon/mobile-react-native 0.1.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/Dash0Mobile.podspec +29 -0
  2. package/README.md +117 -0
  3. package/android/build.gradle +68 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +67 -0
  6. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +198 -0
  7. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +14 -0
  8. package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +208 -0
  9. package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +413 -0
  10. package/ios/BridgeCallSink.swift +83 -0
  11. package/ios/Dash0MobileBridgeDispatcher.swift +142 -0
  12. package/ios/OTelMobileCallSink.swift +262 -0
  13. package/ios/RCTDash0MobileModule.m +28 -0
  14. package/ios/RCTDash0MobileModule.swift +104 -0
  15. package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +341 -0
  16. package/lib/src/NativeDash0Mobile.d.ts +27 -0
  17. package/lib/src/NativeDash0Mobile.d.ts.map +1 -0
  18. package/lib/src/NativeDash0Mobile.js +19 -0
  19. package/lib/src/bridge/NativeBridge.d.ts +38 -0
  20. package/lib/src/bridge/NativeBridge.d.ts.map +1 -0
  21. package/lib/src/bridge/NativeBridge.js +95 -0
  22. package/lib/src/bridge/types.d.ts +166 -0
  23. package/lib/src/bridge/types.d.ts.map +1 -0
  24. package/lib/src/bridge/types.js +10 -0
  25. package/lib/src/index.d.ts +35 -0
  26. package/lib/src/index.d.ts.map +1 -0
  27. package/lib/src/index.js +408 -0
  28. package/lib/src/instrumentation/errors.d.ts +14 -0
  29. package/lib/src/instrumentation/errors.d.ts.map +1 -0
  30. package/lib/src/instrumentation/errors.js +65 -0
  31. package/lib/src/instrumentation/fetch.d.ts +16 -0
  32. package/lib/src/instrumentation/fetch.d.ts.map +1 -0
  33. package/lib/src/instrumentation/fetch.js +75 -0
  34. package/lib/src/instrumentation/navigation.d.ts +19 -0
  35. package/lib/src/instrumentation/navigation.d.ts.map +1 -0
  36. package/lib/src/instrumentation/navigation.js +39 -0
  37. package/lib/src/instrumentation/touch.d.ts +12 -0
  38. package/lib/src/instrumentation/touch.d.ts.map +1 -0
  39. package/lib/src/instrumentation/touch.js +18 -0
  40. package/lib/src/instrumentation/unhandledRejection.d.ts +9 -0
  41. package/lib/src/instrumentation/unhandledRejection.d.ts.map +1 -0
  42. package/lib/src/instrumentation/unhandledRejection.js +52 -0
  43. package/lib/src/instrumentation/xhr.d.ts +14 -0
  44. package/lib/src/instrumentation/xhr.d.ts.map +1 -0
  45. package/lib/src/instrumentation/xhr.js +88 -0
  46. package/lib/src/otel-compat.d.ts +67 -0
  47. package/lib/src/otel-compat.d.ts.map +1 -0
  48. package/lib/src/otel-compat.js +84 -0
  49. package/package.json +72 -0
  50. package/react-native.config.js +17 -0
  51. package/src/NativeDash0Mobile.ts +29 -0
  52. package/src/bridge/NativeBridge.ts +101 -0
  53. package/src/bridge/types.ts +188 -0
  54. package/src/index.ts +456 -0
  55. package/src/instrumentation/errors.ts +84 -0
  56. package/src/instrumentation/fetch.ts +93 -0
  57. package/src/instrumentation/navigation.ts +52 -0
  58. package/src/instrumentation/touch.ts +32 -0
  59. package/src/instrumentation/unhandledRejection.ts +75 -0
  60. package/src/instrumentation/xhr.ts +125 -0
  61. package/src/otel-compat.ts +159 -0
@@ -0,0 +1,83 @@
1
+ // Seam between the RN bridge and the native iOS OTel Mobile SDK.
2
+ //
3
+ // Production: OTelMobileCallSink forwards into OTelMobileSDK.OTelMobile.
4
+ // Tests: a fake sink records calls so we can assert the module's dispatch
5
+ // behavior without standing up the full SDK.
6
+
7
+ import Foundation
8
+
9
+ public protocol BridgeCallSink: AnyObject {
10
+ func start(_ config: BridgeStartConfig)
11
+ func emitLog(name: String, severity: Int, attributes: [String: Any], timeUnixNano: UInt64)
12
+ func startSpan(spanId: String, parentSpanId: String?, name: String, spanKind: String, attributes: [String: Any], startTimeUnixNano: UInt64)
13
+ func endSpan(spanId: String, status: String, statusMessage: String?, attributes: [String: Any], endTimeUnixNano: UInt64)
14
+ func recordMetric(name: String, instrumentType: String, value: Double, attributes: [String: Any], timeUnixNano: UInt64)
15
+ func flushWindow(minutes: Int)
16
+ func shutdown()
17
+
18
+ /// Synchronously flush every buffered telemetry record through the
19
+ /// underlying SDK's exporter, persisting any in-flight records to
20
+ /// disk on export failure. Called by `Dash0MobileBridgeDispatcher`
21
+ /// immediately after dispatching a FATAL-severity (>=21) log emit,
22
+ /// before continuing to the next payload in the batch.
23
+ ///
24
+ /// **Why this is in the protocol, not the dispatcher:** the
25
+ /// dispatcher could call something like `sink.flushWindow(...)`,
26
+ /// but that's selective + async. FATAL needs synchronous flush of
27
+ /// everything because the process is about to die on the calling
28
+ /// thread (RN's fatal reporter terminates via `abort()` / `_exit()`,
29
+ /// skipping `UIApplication.willTerminateNotification`). The sink
30
+ /// has the underlying SDK handle and knows how to do a real
31
+ /// synchronous flush; the dispatcher only knows the protocol.
32
+ ///
33
+ /// Default implementation is a no-op — sinks that don't have an
34
+ /// underlying SDK to flush (e.g. `NoopSink`, lightweight test
35
+ /// fakes) inherit safe behavior. Production sinks like
36
+ /// `OTelMobileCallSink` override.
37
+ func forceFlush()
38
+ }
39
+
40
+ extension BridgeCallSink {
41
+ public func forceFlush() {
42
+ // Default no-op. Override in concrete sinks that wrap a real
43
+ // exporter (e.g. OTelMobileCallSink calls `OTelMobile.forceFlush`).
44
+ }
45
+ }
46
+
47
+ public struct BridgeStartConfig: Equatable {
48
+ public let serviceName: String
49
+ public let serviceVersion: String?
50
+ public let endpoint: String
51
+ public let authToken: String?
52
+ public let dataset: String?
53
+ /// Extra resource attributes from the JS caller (RN bridge populates
54
+ /// `telemetry.distro.name` / `telemetry.distro.version` by default).
55
+ public let extraResourceAttributes: [String: String]
56
+ /// Native iOS auto-capture suites the JS caller wants enabled. Empty array
57
+ /// (default) means the iOS SDK installs no UI/network/error instrumentation
58
+ /// — RN apps get those signals from the JS-side shims (fetch + XHR +
59
+ /// ErrorUtils + unhandledRejection + withTapTelemetry). Apps wanting the
60
+ /// native iOS suite can opt in via `Dash0Mobile.start({ autoCapture: { vitals: true } })`.
61
+ ///
62
+ /// Supported tokens: "network", "errors", "lifecycle", "freeze", "vitals",
63
+ /// "screen", "deviceStats". Unknown tokens are ignored (forward compat).
64
+ public let nativeAutoCapture: [String]
65
+
66
+ public init(
67
+ serviceName: String,
68
+ serviceVersion: String?,
69
+ endpoint: String,
70
+ authToken: String?,
71
+ dataset: String?,
72
+ extraResourceAttributes: [String: String] = [:],
73
+ nativeAutoCapture: [String] = []
74
+ ) {
75
+ self.serviceName = serviceName
76
+ self.serviceVersion = serviceVersion
77
+ self.endpoint = endpoint
78
+ self.authToken = authToken
79
+ self.dataset = dataset
80
+ self.extraResourceAttributes = extraResourceAttributes
81
+ self.nativeAutoCapture = nativeAutoCapture
82
+ }
83
+ }
@@ -0,0 +1,142 @@
1
+ // Dispatches RN bridge payloads onto a BridgeCallSink.
2
+ //
3
+ // Kept as a free-standing type (not an RCTBridgeModule subclass) so it can
4
+ // be unit-tested without importing React/RCTBridgeModule.h. The
5
+ // RCTDash0MobileModule wrapper (Objective-C bridge + thin Swift shim) just
6
+ // calls through into this.
7
+
8
+ import Foundation
9
+
10
+ public enum Dash0MobileBridgeError: Error {
11
+ case missingField(String)
12
+ case invalidConfig(String)
13
+ }
14
+
15
+ public final class Dash0MobileBridgeDispatcher {
16
+ private let sink: BridgeCallSink
17
+
18
+ public init(sink: BridgeCallSink) {
19
+ self.sink = sink
20
+ }
21
+
22
+ public func start(config: [String: Any]) throws {
23
+ guard let serviceName = config["serviceName"] as? String else {
24
+ throw Dash0MobileBridgeError.missingField("serviceName")
25
+ }
26
+ guard let endpoint = config["endpoint"] as? String else {
27
+ throw Dash0MobileBridgeError.missingField("endpoint")
28
+ }
29
+ let extras: [String: String] = (config["extraResourceAttributes"] as? [String: Any])?
30
+ .compactMapValues { $0 as? String } ?? [:]
31
+ let nativeAutoCapture = (config["nativeAutoCapture"] as? [Any])?.compactMap { $0 as? String } ?? []
32
+ sink.start(BridgeStartConfig(
33
+ serviceName: serviceName,
34
+ serviceVersion: config["serviceVersion"] as? String,
35
+ endpoint: endpoint,
36
+ authToken: config["authToken"] as? String,
37
+ dataset: config["dataset"] as? String,
38
+ extraResourceAttributes: extras,
39
+ nativeAutoCapture: nativeAutoCapture
40
+ ))
41
+ }
42
+
43
+ public func emitBatch(_ payloads: [[String: Any]]) {
44
+ for p in payloads {
45
+ dispatch(p)
46
+ }
47
+ }
48
+
49
+ public func flushWindow(minutes: Double) {
50
+ sink.flushWindow(minutes: Int(minutes))
51
+ }
52
+
53
+ public func shutdown() {
54
+ sink.shutdown()
55
+ }
56
+
57
+ private func dispatch(_ p: [String: Any]) {
58
+ guard let kind = p["kind"] as? String else { return }
59
+ let attrs = (p["attributes"] as? [String: Any]) ?? [:]
60
+ switch kind {
61
+ case "log":
62
+ guard let name = p["name"] as? String else { return }
63
+ let severity = intValue(p["severity"]) ?? 9
64
+ sink.emitLog(
65
+ name: name,
66
+ severity: severity,
67
+ attributes: attrs,
68
+ timeUnixNano: stringAsUInt64(p["timeUnixNano"])
69
+ )
70
+ // FATAL-severity logs (OTel semconv 21..24) are the crash
71
+ // path. JS-side bypasses the 50ms debounce via emitSync, but
72
+ // the payload still sits in MobileLogRecordProcessor's RAM
73
+ // buffer. The willTerminate observer (1a69c7e) doesn't fire
74
+ // on RN's abort()/_exit() termination, so we eagerly flush
75
+ // here BEFORE the next payload in the batch. The sink's
76
+ // forceFlush() is synchronous: it drains RAM and persists
77
+ // any export failures to disk so the next launch can recover.
78
+ //
79
+ // Lives in the dispatcher rather than each sink so every
80
+ // BridgeCallSink consumer benefits — was previously wired
81
+ // demo-app-locally in OTelMobileCallSink.emitLog, which left
82
+ // non-RN consumers exposed to the same bridge-RAM-buffer
83
+ // race.
84
+ if severity >= 21 {
85
+ sink.forceFlush()
86
+ }
87
+ case "spanStart":
88
+ guard
89
+ let spanId = p["spanId"] as? String,
90
+ let name = p["name"] as? String
91
+ else { return }
92
+ sink.startSpan(
93
+ spanId: spanId,
94
+ parentSpanId: p["parentSpanId"] as? String,
95
+ name: name,
96
+ spanKind: (p["spanKind"] as? String) ?? "INTERNAL",
97
+ attributes: attrs,
98
+ startTimeUnixNano: stringAsUInt64(p["startTimeUnixNano"])
99
+ )
100
+ case "spanEnd":
101
+ guard let spanId = p["spanId"] as? String else { return }
102
+ sink.endSpan(
103
+ spanId: spanId,
104
+ status: (p["status"] as? String) ?? "UNSET",
105
+ statusMessage: p["statusMessage"] as? String,
106
+ attributes: attrs,
107
+ endTimeUnixNano: stringAsUInt64(p["endTimeUnixNano"])
108
+ )
109
+ case "metric":
110
+ guard let name = p["name"] as? String else { return }
111
+ sink.recordMetric(
112
+ name: name,
113
+ instrumentType: (p["instrumentType"] as? String) ?? "counter",
114
+ value: doubleValue(p["value"]) ?? 0,
115
+ attributes: attrs,
116
+ timeUnixNano: stringAsUInt64(p["timeUnixNano"])
117
+ )
118
+ default:
119
+ // Unknown kinds silently dropped — forward-compatible with future
120
+ // payload shapes the SDK's newer JS versions might send.
121
+ return
122
+ }
123
+ }
124
+
125
+ // NSNumber crossing the bridge may arrive as Int or Double. Be liberal.
126
+ private func intValue(_ v: Any?) -> Int? {
127
+ if let n = v as? Int { return n }
128
+ if let n = v as? Double { return Int(n) }
129
+ if let n = v as? NSNumber { return n.intValue }
130
+ return nil
131
+ }
132
+ private func doubleValue(_ v: Any?) -> Double? {
133
+ if let n = v as? Double { return n }
134
+ if let n = v as? Int { return Double(n) }
135
+ if let n = v as? NSNumber { return n.doubleValue }
136
+ return nil
137
+ }
138
+ private func stringAsUInt64(_ v: Any?) -> UInt64 {
139
+ if let s = v as? String, let n = UInt64(s) { return n }
140
+ return 0
141
+ }
142
+ }
@@ -0,0 +1,262 @@
1
+ // Production BridgeCallSink for iOS. Forwards RN bridge calls into the
2
+ // existing OTelMobileSDK.OTelMobile facade.
3
+ //
4
+ // This file is compiled ONLY when OTelMobileSDK is linkable. When the RN
5
+ // package is built standalone (CI cold-compile without a host app) the
6
+ // RCTDash0MobileModule falls back to NoopSink.
7
+
8
+ #if canImport(OTelMobileSDK)
9
+ import Foundation
10
+ import OpenTelemetryApi
11
+ import OTelMobileSDK
12
+
13
+ final class OTelMobileCallSink: BridgeCallSink {
14
+ private var otel: OTelMobile?
15
+ private let spanLock = NSLock()
16
+ private var liveSpans: [String: Span] = [:]
17
+
18
+ func start(_ config: BridgeStartConfig) {
19
+ guard otel == nil else { return }
20
+
21
+ // Dash0 OTLP ingress wants Bearer auth + Dash0-Dataset as explicit
22
+ // headers, not as separate MobileConfig fields. Build them here so
23
+ // the rest of the SDK sees a uniform headers map.
24
+ var extraHeaders: [String: String] = [:]
25
+ if let dataset = config.dataset, !dataset.isEmpty {
26
+ extraHeaders["Dash0-Dataset"] = dataset
27
+ }
28
+
29
+ // UAT launch-arg overrides: when present, the native side controls
30
+ // export mode and injects cell_id so the UAT matrix can drive all
31
+ // 12 cells without rebuilding the app.
32
+ let exportMode = Self.exportModeFromArgs() ?? .continuous
33
+ var mergedAttrs = config.extraResourceAttributes
34
+ if let mode = Self.launchArg("-DASH0_EXPORT_MODE") {
35
+ mergedAttrs["dash0.test.export_mode"] = mode
36
+ }
37
+ if let cellId = Self.launchArg("-DASH0_CELL_ID"), !cellId.isEmpty {
38
+ mergedAttrs["dash0.test.cell_id"] = cellId
39
+ }
40
+
41
+ let mobileConfig = MobileConfig(
42
+ serviceName: config.serviceName,
43
+ serviceVersion: config.serviceVersion ?? "unknown",
44
+ endpoint: config.endpoint,
45
+ authToken: config.authToken,
46
+ exportMode: exportMode,
47
+ extraHeaders: extraHeaders,
48
+ extraResourceAttributes: mergedAttrs
49
+ )
50
+ do {
51
+ otel = try OTelMobile.start(config: mobileConfig)
52
+ } catch {
53
+ // Start failure is logged but non-fatal — JS side stays operational
54
+ // and future emitBatch calls become no-ops until a successful start.
55
+ NSLog("[@dash0/mobile-react-native] OTelMobile.start failed: \(error)")
56
+ }
57
+ }
58
+
59
+ func emitLog(
60
+ name: String,
61
+ severity: Int,
62
+ attributes: [String: Any],
63
+ timeUnixNano: UInt64
64
+ ) {
65
+ guard let logger = otel?.logger else { return }
66
+ var builder = logger.logRecordBuilder()
67
+ .setBody(AttributeValue.string(name))
68
+ .setSeverity(Self.mapSeverity(severity))
69
+ .setTimestamp(Self.dateFromUnixNano(timeUnixNano))
70
+ let otelAttrs = Self.toAttributeValues(attributes)
71
+ if !otelAttrs.isEmpty {
72
+ builder = builder.setAttributes(otelAttrs)
73
+ }
74
+ builder.emit()
75
+ }
76
+
77
+ func startSpan(
78
+ spanId: String,
79
+ name: String,
80
+ spanKind: String,
81
+ attributes: [String: Any],
82
+ startTimeUnixNano: UInt64
83
+ ) {
84
+ guard let tracer = otel?.tracer else { return }
85
+ let span = tracer.spanBuilder(spanName: name)
86
+ .setStartTime(time: Self.dateFromUnixNano(startTimeUnixNano))
87
+ .setSpanKind(kind: Self.mapSpanKind(spanKind))
88
+ .startSpan()
89
+ for (k, v) in Self.toAttributeValues(attributes) {
90
+ span.setAttribute(key: k, value: v)
91
+ }
92
+ spanLock.lock()
93
+ liveSpans[spanId] = span
94
+ spanLock.unlock()
95
+ }
96
+
97
+ func endSpan(
98
+ spanId: String,
99
+ status: String,
100
+ statusMessage: String?,
101
+ attributes: [String: Any],
102
+ endTimeUnixNano: UInt64
103
+ ) {
104
+ spanLock.lock()
105
+ let span = liveSpans.removeValue(forKey: spanId)
106
+ spanLock.unlock()
107
+ guard let span = span else { return }
108
+
109
+ for (k, v) in Self.toAttributeValues(attributes) {
110
+ span.setAttribute(key: k, value: v)
111
+ }
112
+ switch status {
113
+ case "OK":
114
+ span.status = .ok
115
+ case "ERROR":
116
+ span.status = .error(description: statusMessage ?? "")
117
+ default:
118
+ break
119
+ }
120
+ span.end(time: Self.dateFromUnixNano(endTimeUnixNano))
121
+ }
122
+
123
+ func recordMetric(
124
+ name: String,
125
+ instrumentType: String,
126
+ value: Double,
127
+ attributes: [String: Any],
128
+ timeUnixNano: UInt64
129
+ ) {
130
+ guard let meter = otel?.meter else { return }
131
+ let otelAttrs = Self.toAttributeValues(attributes)
132
+ switch instrumentType {
133
+ case "histogram":
134
+ meter.histogramBuilder(name: name).build()
135
+ .record(value: value, attributes: otelAttrs)
136
+ case "gauge":
137
+ // OTel-Swift async gauges require an observer callback; for the
138
+ // bridge's fire-and-forget contract we record a single value via
139
+ // a histogram of size 1 so the last-value aggregation in the
140
+ // backend surfaces the reading. Purpose-built sync gauges land
141
+ // if/when upstream exposes them.
142
+ meter.histogramBuilder(name: name).build()
143
+ .record(value: value, attributes: otelAttrs)
144
+ default:
145
+ // counter — integer values are the common case. Fractional
146
+ // counters aren't expressible through OTel-Swift's long counter,
147
+ // so truncate and log once if the JS side ever sends a non-int.
148
+ meter.counterBuilder(name: name).build()
149
+ .add(value: Int(value), attributes: otelAttrs)
150
+ }
151
+ }
152
+
153
+ func flushWindow(minutes: Int) {
154
+ guard let otel = otel else { return }
155
+ Task.detached { [otel] in
156
+ _ = await otel.flushWindow(minutes: UInt64(max(0, minutes)))
157
+ }
158
+ }
159
+
160
+ func shutdown() {
161
+ otel = nil
162
+ spanLock.lock()
163
+ liveSpans.removeAll()
164
+ spanLock.unlock()
165
+ }
166
+
167
+ /// Drain all RAM-buffered telemetry through the OTLP exporter,
168
+ /// persisting on export failure. Called by `Dash0MobileBridgeDispatcher`
169
+ /// after dispatching a FATAL-severity log emit, before continuing
170
+ /// to the next payload in the same batch.
171
+ ///
172
+ /// `OTelMobile.forceFlush` is synchronous and routes both logs and
173
+ /// pending spans through the configured persistence path
174
+ /// (`MobileLogRecordProcessor` for logs, `BatchSpanProcessor`'s
175
+ /// shutdown drain for spans). On RN's abort()/_exit() termination
176
+ /// path this is the only thing that gives the FATAL log a chance
177
+ /// to land in Dash0 — the willTerminate observer doesn't fire on
178
+ /// abort().
179
+ func forceFlush() {
180
+ guard let otel = otel else { return }
181
+ _ = otel.forceFlush()
182
+ }
183
+
184
+ // MARK: - Helpers
185
+
186
+ private static func toAttributeValues(_ raw: [String: Any]) -> [String: AttributeValue] {
187
+ var out: [String: AttributeValue] = [:]
188
+ for (k, v) in raw {
189
+ if let s = v as? String {
190
+ out[k] = .string(s)
191
+ } else if let b = v as? Bool {
192
+ out[k] = .bool(b)
193
+ } else if let i = v as? Int {
194
+ out[k] = .int(i)
195
+ } else if let n = v as? NSNumber {
196
+ // React Native bridges numerics as NSNumber. Integer-valued
197
+ // NSNumbers should land as ints so attribute keys like `qty`
198
+ // don't surface as `2.0` in backends — mirrors the Android
199
+ // OTelMobileCallSink behavior.
200
+ if CFNumberIsFloatType(n) {
201
+ out[k] = .double(n.doubleValue)
202
+ } else {
203
+ out[k] = .int(n.intValue)
204
+ }
205
+ } else if let d = v as? Double {
206
+ out[k] = .double(d)
207
+ } else {
208
+ out[k] = .string(String(describing: v))
209
+ }
210
+ }
211
+ return out
212
+ }
213
+
214
+ private static func dateFromUnixNano(_ nano: UInt64) -> Date {
215
+ Date(timeIntervalSince1970: TimeInterval(nano) / 1_000_000_000.0)
216
+ }
217
+
218
+ private static func mapSeverity(_ raw: Int) -> Severity {
219
+ // OTel severity numbers: 1=TRACE, 5=DEBUG, 9=INFO, 13=WARN, 17=ERROR,
220
+ // 21=FATAL. JS sends the numeric value directly — fall back to INFO
221
+ // when the value is outside the known range so log records never
222
+ // get dropped silently.
223
+ switch raw {
224
+ case 1...4: return .trace
225
+ case 5...8: return .debug
226
+ case 9...12: return .info
227
+ case 13...16: return .warn
228
+ case 17...20: return .error
229
+ case 21...: return .fatal
230
+ default: return .info
231
+ }
232
+ }
233
+
234
+ private static func mapSpanKind(_ raw: String) -> SpanKind {
235
+ switch raw {
236
+ case "CLIENT": return .client
237
+ case "SERVER": return .server
238
+ case "PRODUCER": return .producer
239
+ case "CONSUMER": return .consumer
240
+ default: return .internal
241
+ }
242
+ }
243
+
244
+ // MARK: - UAT launch-arg helpers
245
+
246
+ private static func launchArg(_ flag: String) -> String? {
247
+ let args = CommandLine.arguments
248
+ guard let idx = args.firstIndex(of: flag), idx + 1 < args.count else { return nil }
249
+ return args[idx + 1]
250
+ }
251
+
252
+ private static func exportModeFromArgs() -> ExportMode? {
253
+ guard let raw = launchArg("-DASH0_EXPORT_MODE") else { return nil }
254
+ switch raw.lowercased() {
255
+ case "cont", "continuous": return .continuous
256
+ case "cond", "conditional": return .conditional
257
+ case "hyb", "hybrid": return .hybrid
258
+ default: return nil
259
+ }
260
+ }
261
+ }
262
+ #endif
@@ -0,0 +1,28 @@
1
+ // Objective-C interface required by React Native's RCTBridgeModule macro.
2
+ // The real implementation lives in RCTDash0MobileModule.swift and is
3
+ // resolved via the {ModuleName}-Swift.h header at build time.
4
+
5
+ #import <React/RCTBridgeModule.h>
6
+
7
+ @interface RCT_EXTERN_MODULE(Dash0Mobile, NSObject)
8
+
9
+ RCT_EXTERN_METHOD(start:(NSDictionary *)config
10
+ resolver:(RCTPromiseResolveBlock)resolve
11
+ rejecter:(RCTPromiseRejectBlock)reject)
12
+
13
+ RCT_EXTERN_METHOD(emitBatch:(NSArray *)payloads
14
+ resolver:(RCTPromiseResolveBlock)resolve
15
+ rejecter:(RCTPromiseRejectBlock)reject)
16
+
17
+ RCT_EXTERN_METHOD(flushWindow:(double)minutes
18
+ resolver:(RCTPromiseResolveBlock)resolve
19
+ rejecter:(RCTPromiseRejectBlock)reject)
20
+
21
+ RCT_EXTERN_METHOD(shutdown:(RCTPromiseResolveBlock)resolve
22
+ rejecter:(RCTPromiseRejectBlock)reject)
23
+
24
+ + (BOOL)requiresMainQueueSetup {
25
+ return NO;
26
+ }
27
+
28
+ @end
@@ -0,0 +1,104 @@
1
+ // Swift half of the RN native module. The ObjC side (RCTDash0MobileModule.m)
2
+ // declares the RN-visible interface; this file implements the methods.
3
+ //
4
+ // Deliberately thin — all dispatch logic is in Dash0MobileBridgeDispatcher.
5
+
6
+ import Foundation
7
+
8
+ @objc(Dash0Mobile)
9
+ public final class Dash0MobileModule: NSObject {
10
+ private let dispatcher: Dash0MobileBridgeDispatcher
11
+
12
+ /// Host-injected sink factory. When the RN bridge pod can't see the
13
+ /// native SDK at compile time (common in hybrid CocoaPods+SwiftPM
14
+ /// setups — the pod builds without the app's Swift Package graph),
15
+ /// the host app calls `Dash0MobileModule.installSink(...)` from its
16
+ /// AppDelegate to wire the real `OTelMobileCallSink`. Without that,
17
+ /// we fall back to a no-op sink so the JS surface stays functional
18
+ /// in tests / SSR / apps that haven't opted in yet.
19
+ ///
20
+ /// Not `@objc` — the closure's parameter type is a Swift-only protocol.
21
+ /// App-side callers must `import Dash0Mobile` and invoke it from Swift.
22
+ public static var sinkFactory: () -> BridgeCallSink = { NoopSink() }
23
+
24
+ public static func installSink(_ factory: @escaping () -> BridgeCallSink) {
25
+ NSLog("[Dash0Mobile] installSink called")
26
+ sinkFactory = factory
27
+ }
28
+
29
+ override init() {
30
+ let sink = Dash0MobileModule.sinkFactory()
31
+ NSLog("[Dash0Mobile] Module init with sink type: \(type(of: sink))")
32
+ self.dispatcher = Dash0MobileBridgeDispatcher(sink: sink)
33
+ super.init()
34
+ }
35
+
36
+ // MARK: - For tests
37
+
38
+ init(dispatcher: Dash0MobileBridgeDispatcher) {
39
+ self.dispatcher = dispatcher
40
+ super.init()
41
+ }
42
+
43
+ // MARK: - RN-visible methods
44
+
45
+ @objc(start:resolver:rejecter:)
46
+ func start(
47
+ config: [String: Any],
48
+ resolver: @escaping (Any?) -> Void,
49
+ rejecter: @escaping (String, String?, Error?) -> Void
50
+ ) {
51
+ do {
52
+ try dispatcher.start(config: config)
53
+ resolver(nil)
54
+ } catch {
55
+ rejecter("Dash0Mobile.start", error.localizedDescription, error)
56
+ }
57
+ }
58
+
59
+ @objc(emitBatch:resolver:rejecter:)
60
+ func emitBatch(
61
+ payloads: [[String: Any]],
62
+ resolver: @escaping (Any?) -> Void,
63
+ rejecter: @escaping (String, String?, Error?) -> Void
64
+ ) {
65
+ dispatcher.emitBatch(payloads)
66
+ resolver(nil)
67
+ }
68
+
69
+ @objc(flushWindow:resolver:rejecter:)
70
+ func flushWindow(
71
+ minutes: Double,
72
+ resolver: @escaping (Any?) -> Void,
73
+ rejecter: @escaping (String, String?, Error?) -> Void
74
+ ) {
75
+ dispatcher.flushWindow(minutes: minutes)
76
+ resolver(nil)
77
+ }
78
+
79
+ @objc(shutdown:rejecter:)
80
+ func shutdown(
81
+ resolver: @escaping (Any?) -> Void,
82
+ rejecter: @escaping (String, String?, Error?) -> Void
83
+ ) {
84
+ dispatcher.shutdown()
85
+ resolver(nil)
86
+ }
87
+ }
88
+
89
+ // Fallback used when no host-provided sink has been installed. Swallows
90
+ // calls so the module can be instantiated in CI builds that only verify
91
+ // the RN surface. Apps opt in to real telemetry by calling
92
+ // `Dash0MobileModule.installSink { OTelMobileCallSink() }` from their
93
+ // AppDelegate (OTelMobileCallSink lives in the app target so it can see
94
+ // the app's SwiftPM-attached OTelMobileSDK).
95
+ public final class NoopSink: BridgeCallSink {
96
+ public init() {}
97
+ public func start(_ config: BridgeStartConfig) {}
98
+ public func emitLog(name: String, severity: Int, attributes: [String: Any], timeUnixNano: UInt64) {}
99
+ public func startSpan(spanId: String, parentSpanId: String?, name: String, spanKind: String, attributes: [String: Any], startTimeUnixNano: UInt64) {}
100
+ public func endSpan(spanId: String, status: String, statusMessage: String?, attributes: [String: Any], endTimeUnixNano: UInt64) {}
101
+ public func recordMetric(name: String, instrumentType: String, value: Double, attributes: [String: Any], timeUnixNano: UInt64) {}
102
+ public func flushWindow(minutes: Int) {}
103
+ public func shutdown() {}
104
+ }