@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.
- package/Dash0Mobile.podspec +29 -0
- package/README.md +117 -0
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +67 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +198 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +14 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +208 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +413 -0
- package/ios/BridgeCallSink.swift +83 -0
- package/ios/Dash0MobileBridgeDispatcher.swift +142 -0
- package/ios/OTelMobileCallSink.swift +262 -0
- package/ios/RCTDash0MobileModule.m +28 -0
- package/ios/RCTDash0MobileModule.swift +104 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +341 -0
- package/lib/src/NativeDash0Mobile.d.ts +27 -0
- package/lib/src/NativeDash0Mobile.d.ts.map +1 -0
- package/lib/src/NativeDash0Mobile.js +19 -0
- package/lib/src/bridge/NativeBridge.d.ts +38 -0
- package/lib/src/bridge/NativeBridge.d.ts.map +1 -0
- package/lib/src/bridge/NativeBridge.js +95 -0
- package/lib/src/bridge/types.d.ts +166 -0
- package/lib/src/bridge/types.d.ts.map +1 -0
- package/lib/src/bridge/types.js +10 -0
- package/lib/src/index.d.ts +35 -0
- package/lib/src/index.d.ts.map +1 -0
- package/lib/src/index.js +408 -0
- package/lib/src/instrumentation/errors.d.ts +14 -0
- package/lib/src/instrumentation/errors.d.ts.map +1 -0
- package/lib/src/instrumentation/errors.js +65 -0
- package/lib/src/instrumentation/fetch.d.ts +16 -0
- package/lib/src/instrumentation/fetch.d.ts.map +1 -0
- package/lib/src/instrumentation/fetch.js +75 -0
- package/lib/src/instrumentation/navigation.d.ts +19 -0
- package/lib/src/instrumentation/navigation.d.ts.map +1 -0
- package/lib/src/instrumentation/navigation.js +39 -0
- package/lib/src/instrumentation/touch.d.ts +12 -0
- package/lib/src/instrumentation/touch.d.ts.map +1 -0
- package/lib/src/instrumentation/touch.js +18 -0
- package/lib/src/instrumentation/unhandledRejection.d.ts +9 -0
- package/lib/src/instrumentation/unhandledRejection.d.ts.map +1 -0
- package/lib/src/instrumentation/unhandledRejection.js +52 -0
- package/lib/src/instrumentation/xhr.d.ts +14 -0
- package/lib/src/instrumentation/xhr.d.ts.map +1 -0
- package/lib/src/instrumentation/xhr.js +88 -0
- package/lib/src/otel-compat.d.ts +67 -0
- package/lib/src/otel-compat.d.ts.map +1 -0
- package/lib/src/otel-compat.js +84 -0
- package/package.json +72 -0
- package/react-native.config.js +17 -0
- package/src/NativeDash0Mobile.ts +29 -0
- package/src/bridge/NativeBridge.ts +101 -0
- package/src/bridge/types.ts +188 -0
- package/src/index.ts +456 -0
- package/src/instrumentation/errors.ts +84 -0
- package/src/instrumentation/fetch.ts +93 -0
- package/src/instrumentation/navigation.ts +52 -0
- package/src/instrumentation/touch.ts +32 -0
- package/src/instrumentation/unhandledRejection.ts +75 -0
- package/src/instrumentation/xhr.ts +125 -0
- 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
|
+
}
|