@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.1-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 +4 -0
- package/README.md +113 -28
- package/android/build.gradle +73 -6
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/android/gradle.properties +27 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +62 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
- package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
- package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
- package/ios/BoundedLiveSpanStore.swift +138 -0
- package/ios/BridgeCallSink.swift +49 -1
- package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
- package/ios/OTelMobileCallSink.swift +205 -34
- package/ios/RCTDash0MobileModule.swift +10 -2
- package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
- package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
- package/lib/bridge/types.d.ts +35 -0
- package/lib/bridge/types.d.ts.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +42 -2
- package/lib/instrumentation/errors.d.ts.map +1 -1
- package/lib/instrumentation/errors.js +13 -2
- package/lib/instrumentation/fetch.d.ts.map +1 -1
- package/lib/instrumentation/fetch.js +58 -22
- package/lib/instrumentation/navigation.d.ts.map +1 -1
- package/lib/instrumentation/navigation.js +32 -8
- package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
- package/lib/instrumentation/unhandledRejection.js +13 -3
- package/lib/instrumentation/xhr.d.ts.map +1 -1
- package/lib/instrumentation/xhr.js +63 -36
- package/lib/redact.d.ts +30 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +67 -0
- package/package.json +1 -1
- package/src/bridge/types.ts +36 -0
- package/src/index.ts +44 -3
- package/src/instrumentation/errors.ts +12 -2
- package/src/instrumentation/fetch.ts +60 -27
- package/src/instrumentation/navigation.ts +40 -8
- package/src/instrumentation/unhandledRejection.ts +12 -3
- package/src/instrumentation/xhr.ts +65 -40
- package/src/redact.ts +71 -0
|
@@ -1,22 +1,111 @@
|
|
|
1
1
|
// Production BridgeCallSink for iOS. Forwards RN bridge calls into the
|
|
2
2
|
// existing OTelMobileSDK.OTelMobile facade.
|
|
3
3
|
//
|
|
4
|
-
// This file is compiled
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// This file is compiled whenever OTelMobileSDK is linkable. In the RN package
|
|
5
|
+
// it is built + unit-tested against the sibling `otel-ios-mobile` SwiftPM
|
|
6
|
+
// package (see Package.swift). When a host app's pod build can't see the SDK
|
|
7
|
+
// at compile time the RCTDash0MobileModule still falls back to NoopSink.
|
|
8
|
+
//
|
|
9
|
+
// ## Testability seam
|
|
10
|
+
//
|
|
11
|
+
// The sink never reaches into a concrete network exporter directly: every
|
|
12
|
+
// span/log/metric goes through OTel-Swift's `Tracer` / `Logger` / `Meter`
|
|
13
|
+
// handles. Those handles are resolved once, at `start(_:)`:
|
|
14
|
+
//
|
|
15
|
+
// - Production: from a real `OTelMobile.start(config:)` instance, whose
|
|
16
|
+
// providers are wired to the OTLP/HTTP exporters.
|
|
17
|
+
// - Tests: injected via `init(telemetryProvider:)` with handles backed by
|
|
18
|
+
// in-memory / recording exporters, so the sink's real logic (parent
|
|
19
|
+
// linkage, LRU eviction, severity/kind mapping, attribute coercion,
|
|
20
|
+
// counter clamping) runs against real OTel-Swift primitives and the
|
|
21
|
+
// resulting spans/logs/metrics can be asserted on.
|
|
22
|
+
//
|
|
23
|
+
// This keeps the production path byte-for-byte the same while making the
|
|
24
|
+
// whole sink compile + unit-test under `xcodebuild test` in CI.
|
|
7
25
|
|
|
8
26
|
#if canImport(OTelMobileSDK)
|
|
9
27
|
import Foundation
|
|
10
28
|
import OpenTelemetryApi
|
|
11
29
|
import OTelMobileSDK
|
|
12
30
|
|
|
31
|
+
/// The OTel-Swift signal handles the sink needs, plus the lifecycle hooks it
|
|
32
|
+
/// must forward (flush/shutdown). Production builds these from a real
|
|
33
|
+
/// `OTelMobile`; tests build them from capturing exporters.
|
|
34
|
+
struct SinkTelemetry {
|
|
35
|
+
let tracer: Tracer?
|
|
36
|
+
let logger: Logger?
|
|
37
|
+
let meter: Meter?
|
|
38
|
+
/// Synchronous drain of all buffered telemetry. No-op when absent.
|
|
39
|
+
let forceFlush: () -> Void
|
|
40
|
+
/// Selective time-window flush in minutes. No-op when absent.
|
|
41
|
+
let flushWindow: (Int) -> Void
|
|
42
|
+
/// Release the underlying SDK. No-op when absent.
|
|
43
|
+
let shutdown: () -> Void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Builds a `SinkTelemetry` from a started `OTelMobile`, capturing it so the
|
|
47
|
+
/// flush/shutdown closures route to the real instance. Production seam.
|
|
48
|
+
extension SinkTelemetry {
|
|
49
|
+
init(otel: OTelMobile) {
|
|
50
|
+
self.tracer = otel.tracer
|
|
51
|
+
self.logger = otel.logger
|
|
52
|
+
self.meter = otel.meter
|
|
53
|
+
self.forceFlush = { _ = otel.forceFlush() }
|
|
54
|
+
self.flushWindow = { minutes in
|
|
55
|
+
Task.detached { _ = await otel.flushWindow(minutes: UInt64(max(0, minutes))) }
|
|
56
|
+
}
|
|
57
|
+
// OTelMobile has no explicit teardown; dropping the strong reference
|
|
58
|
+
// releases its providers. Captured weakly to avoid a retain cycle is
|
|
59
|
+
// unnecessary because the closure is owned by the sink, not the SDK.
|
|
60
|
+
self.shutdown = {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
13
64
|
final class OTelMobileCallSink: BridgeCallSink {
|
|
14
|
-
|
|
65
|
+
/// Resolved at `start(_:)`. `nil` until then (or if start fails / a test
|
|
66
|
+
/// injected an empty provider), making every emit a safe no-op.
|
|
67
|
+
private var telemetry: SinkTelemetry?
|
|
68
|
+
|
|
69
|
+
/// Test-injection seam: when set, `start(_:)` adopts this telemetry
|
|
70
|
+
/// instead of building a real `OTelMobile`. Lets unit tests drive the
|
|
71
|
+
/// sink against capturing exporters while leaving production untouched.
|
|
72
|
+
private let injectedTelemetry: SinkTelemetry?
|
|
73
|
+
|
|
15
74
|
private let spanLock = NSLock()
|
|
16
|
-
|
|
75
|
+
/// Upper bound on concurrently-live (started-but-not-ended) spans. A
|
|
76
|
+
/// misbehaving JS layer that starts spans without ending them must not
|
|
77
|
+
/// balloon native memory. Matches the historical ~2048 ceiling.
|
|
78
|
+
static let maxLiveSpans = 2048
|
|
79
|
+
|
|
80
|
+
/// O(1) lookup-by-id AND O(1) oldest-eviction. Replaces the previous
|
|
81
|
+
/// unbounded `[String: Span]` (which leaked on never-ended spans) — and
|
|
82
|
+
/// avoids the O(n) `firstIndex` scan a naive bounded list would need on
|
|
83
|
+
/// eviction. All access is serialized under `spanLock`.
|
|
84
|
+
private let liveSpans: BoundedLiveSpanStore<String, Span>
|
|
85
|
+
|
|
86
|
+
/// Production initializer. Builds a real `OTelMobile` lazily in `start(_:)`.
|
|
87
|
+
init() {
|
|
88
|
+
self.injectedTelemetry = nil
|
|
89
|
+
self.liveSpans = BoundedLiveSpanStore(capacity: Self.maxLiveSpans)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Test initializer. Adopts caller-supplied telemetry (handles backed by
|
|
93
|
+
/// in-memory exporters) so the sink's logic is exercised end-to-end without
|
|
94
|
+
/// any network. `capacity` is overridable so eviction tests don't need to
|
|
95
|
+
/// push 2048 spans to trip the cap.
|
|
96
|
+
init(telemetry: SinkTelemetry, capacity: Int = OTelMobileCallSink.maxLiveSpans) {
|
|
97
|
+
self.injectedTelemetry = telemetry
|
|
98
|
+
self.liveSpans = BoundedLiveSpanStore(capacity: capacity)
|
|
99
|
+
}
|
|
17
100
|
|
|
18
101
|
func start(_ config: BridgeStartConfig) {
|
|
19
|
-
guard
|
|
102
|
+
guard telemetry == nil else { return }
|
|
103
|
+
|
|
104
|
+
// Test path: adopt the injected telemetry, skip building a real SDK.
|
|
105
|
+
if let injected = injectedTelemetry {
|
|
106
|
+
telemetry = injected
|
|
107
|
+
return
|
|
108
|
+
}
|
|
20
109
|
|
|
21
110
|
// Dash0 OTLP ingress wants Bearer auth + Dash0-Dataset as explicit
|
|
22
111
|
// headers, not as separate MobileConfig fields. Build them here so
|
|
@@ -45,24 +134,44 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
45
134
|
authToken: config.authToken,
|
|
46
135
|
exportMode: exportMode,
|
|
47
136
|
extraHeaders: extraHeaders,
|
|
137
|
+
// RN sampling default is .alwaysOn (Loper finding #4): RN manual
|
|
138
|
+
// spans are root spans with arbitrary names, so the native SDK's
|
|
139
|
+
// dynamic(0.1) default would silently drop ~90% of a user's first
|
|
140
|
+
// span — and a dropped iOS span is a non-recording PropagatedSpan
|
|
141
|
+
// whose end() is a silent no-op. The JS bridge sends always_on
|
|
142
|
+
// unless the caller opts into sampling; rate-limiting for RN
|
|
143
|
+
// belongs in the collector.
|
|
144
|
+
samplingConfig: Self.samplingConfig(from: config.sampling),
|
|
48
145
|
extraResourceAttributes: mergedAttrs
|
|
49
146
|
)
|
|
50
147
|
do {
|
|
51
|
-
otel = try OTelMobile.start(config: mobileConfig)
|
|
148
|
+
let otel = try OTelMobile.start(config: mobileConfig)
|
|
149
|
+
telemetry = SinkTelemetry(otel: otel)
|
|
52
150
|
} catch {
|
|
53
151
|
// Start failure is logged but non-fatal — JS side stays operational
|
|
54
152
|
// and future emitBatch calls become no-ops until a successful start.
|
|
55
|
-
|
|
153
|
+
// Do NOT interpolate the full error: it can embed the endpoint URL
|
|
154
|
+
// (incl. credentials) or auth token. Log a static message + the
|
|
155
|
+
// error code only, and only when debug logging is enabled.
|
|
156
|
+
Self.debugLog("OTelMobile.start failed (code \((error as NSError).code))")
|
|
56
157
|
}
|
|
57
158
|
}
|
|
58
159
|
|
|
160
|
+
/// Gate diagnostic NSLogs behind a debug flag so production builds don't
|
|
161
|
+
/// emit potentially sensitive init/auth detail to the device log.
|
|
162
|
+
private static func debugLog(_ message: @autoclosure () -> String) {
|
|
163
|
+
#if DEBUG
|
|
164
|
+
NSLog("[@dash0/mobile-react-native] %@", message())
|
|
165
|
+
#endif
|
|
166
|
+
}
|
|
167
|
+
|
|
59
168
|
func emitLog(
|
|
60
169
|
name: String,
|
|
61
170
|
severity: Int,
|
|
62
171
|
attributes: [String: Any],
|
|
63
172
|
timeUnixNano: UInt64
|
|
64
173
|
) {
|
|
65
|
-
guard let logger =
|
|
174
|
+
guard let logger = telemetry?.logger else { return }
|
|
66
175
|
var builder = logger.logRecordBuilder()
|
|
67
176
|
.setBody(AttributeValue.string(name))
|
|
68
177
|
.setSeverity(Self.mapSeverity(severity))
|
|
@@ -76,22 +185,57 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
76
185
|
|
|
77
186
|
func startSpan(
|
|
78
187
|
spanId: String,
|
|
188
|
+
parentSpanId: String?,
|
|
79
189
|
name: String,
|
|
80
190
|
spanKind: String,
|
|
81
191
|
attributes: [String: Any],
|
|
82
192
|
startTimeUnixNano: UInt64
|
|
83
193
|
) {
|
|
84
|
-
guard let tracer =
|
|
85
|
-
|
|
194
|
+
guard let tracer = telemetry?.tracer else { return }
|
|
195
|
+
|
|
196
|
+
// Resolve the parent span (if still live) under the lock, then build
|
|
197
|
+
// OUTSIDE the lock — never hold spanLock across OTel calls.
|
|
198
|
+
var parentSpan: Span?
|
|
199
|
+
if let parentSpanId = parentSpanId {
|
|
200
|
+
spanLock.lock()
|
|
201
|
+
parentSpan = liveSpans.value(forKey: parentSpanId)
|
|
202
|
+
spanLock.unlock()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let builder = tracer.spanBuilder(spanName: name)
|
|
86
206
|
.setStartTime(time: Self.dateFromUnixNano(startTimeUnixNano))
|
|
87
|
-
.setSpanKind(
|
|
88
|
-
|
|
207
|
+
.setSpanKind(spanKind: Self.mapSpanKind(spanKind))
|
|
208
|
+
if let parentSpan = parentSpan {
|
|
209
|
+
// Establish real OTel parent-child linkage so the child inherits
|
|
210
|
+
// the parent's trace id and records it as `parentSpanId` in
|
|
211
|
+
// SpanData — this is what stitches the RN-side span tree together
|
|
212
|
+
// into one trace in Dash0. Also mirror the bridge-supplied id as
|
|
213
|
+
// an attribute so cross-referencing the JS span id stays possible
|
|
214
|
+
// even after sampling drops the parent.
|
|
215
|
+
builder.setParent(parentSpan)
|
|
216
|
+
if let parentSpanId = parentSpanId {
|
|
217
|
+
builder.setAttribute(key: "parent.span.id", value: AttributeValue.string(parentSpanId))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
let span = builder.startSpan()
|
|
89
221
|
for (k, v) in Self.toAttributeValues(attributes) {
|
|
90
222
|
span.setAttribute(key: k, value: v)
|
|
91
223
|
}
|
|
224
|
+
|
|
225
|
+
// Insert + enforce the LRU cap. Capture any victim under the lock,
|
|
226
|
+
// then end it OUTSIDE the lock (span.end / status are OTel calls).
|
|
227
|
+
let evictedTime: UInt64 = startTimeUnixNano
|
|
92
228
|
spanLock.lock()
|
|
93
|
-
|
|
229
|
+
// Inserting at capacity evicts the oldest live span (O(1)). End it so it
|
|
230
|
+
// is not leaked (the JS side never called endSpan for the orphaned
|
|
231
|
+
// spanStart). Marked ERROR so the dropped span is visibly attributable
|
|
232
|
+
// rather than silently OK.
|
|
233
|
+
let evicted = liveSpans.put(spanId, span)
|
|
94
234
|
spanLock.unlock()
|
|
235
|
+
if let evicted = evicted {
|
|
236
|
+
evicted.status = Status.error(description: "span evicted: liveSpans cap (\(liveSpans.capacity)) reached (orphaned spanStart)")
|
|
237
|
+
evicted.end(time: Self.dateFromUnixNano(evictedTime))
|
|
238
|
+
}
|
|
95
239
|
}
|
|
96
240
|
|
|
97
241
|
func endSpan(
|
|
@@ -111,9 +255,9 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
111
255
|
}
|
|
112
256
|
switch status {
|
|
113
257
|
case "OK":
|
|
114
|
-
span.status = .ok
|
|
258
|
+
span.status = Status.ok
|
|
115
259
|
case "ERROR":
|
|
116
|
-
span.status = .error(description: statusMessage ?? "")
|
|
260
|
+
span.status = Status.error(description: statusMessage ?? "")
|
|
117
261
|
default:
|
|
118
262
|
break
|
|
119
263
|
}
|
|
@@ -127,40 +271,51 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
127
271
|
attributes: [String: Any],
|
|
128
272
|
timeUnixNano: UInt64
|
|
129
273
|
) {
|
|
130
|
-
guard let meter =
|
|
274
|
+
guard let meter = telemetry?.meter else { return }
|
|
131
275
|
let otelAttrs = Self.toAttributeValues(attributes)
|
|
132
276
|
switch instrumentType {
|
|
133
277
|
case "histogram":
|
|
134
|
-
|
|
135
|
-
|
|
278
|
+
// `record` is a mutating member on the histogram value type, so the
|
|
279
|
+
// built instrument must be bound to a `var` before recording.
|
|
280
|
+
var histogram = meter.histogramBuilder(name: name).build()
|
|
281
|
+
histogram.record(value: value, attributes: otelAttrs)
|
|
136
282
|
case "gauge":
|
|
137
283
|
// OTel-Swift async gauges require an observer callback; for the
|
|
138
284
|
// bridge's fire-and-forget contract we record a single value via
|
|
139
285
|
// a histogram of size 1 so the last-value aggregation in the
|
|
140
286
|
// backend surfaces the reading. Purpose-built sync gauges land
|
|
141
287
|
// if/when upstream exposes them.
|
|
142
|
-
meter.histogramBuilder(name: name).build()
|
|
143
|
-
|
|
288
|
+
var gauge = meter.histogramBuilder(name: name).build()
|
|
289
|
+
gauge.record(value: value, attributes: otelAttrs)
|
|
144
290
|
default:
|
|
145
291
|
// counter — integer values are the common case. Fractional
|
|
146
292
|
// counters aren't expressible through OTel-Swift's long counter,
|
|
147
|
-
// so truncate
|
|
148
|
-
|
|
149
|
-
|
|
293
|
+
// so truncate. `Int(value)` traps on NaN/Inf/out-of-range doubles,
|
|
294
|
+
// so clamp into Int range first and drop non-finite values.
|
|
295
|
+
guard value.isFinite else { return }
|
|
296
|
+
let clamped = value.rounded(.towardZero)
|
|
297
|
+
let intValue: Int
|
|
298
|
+
if clamped >= Double(Int.max) {
|
|
299
|
+
intValue = Int.max
|
|
300
|
+
} else if clamped <= Double(Int.min) {
|
|
301
|
+
intValue = Int.min
|
|
302
|
+
} else {
|
|
303
|
+
intValue = Int(clamped)
|
|
304
|
+
}
|
|
305
|
+
var counter = meter.counterBuilder(name: name).build()
|
|
306
|
+
counter.add(value: intValue, attributes: otelAttrs)
|
|
150
307
|
}
|
|
151
308
|
}
|
|
152
309
|
|
|
153
310
|
func flushWindow(minutes: Int) {
|
|
154
|
-
|
|
155
|
-
Task.detached { [otel] in
|
|
156
|
-
_ = await otel.flushWindow(minutes: UInt64(max(0, minutes)))
|
|
157
|
-
}
|
|
311
|
+
telemetry?.flushWindow(minutes)
|
|
158
312
|
}
|
|
159
313
|
|
|
160
314
|
func shutdown() {
|
|
161
|
-
|
|
315
|
+
telemetry?.shutdown()
|
|
316
|
+
telemetry = nil
|
|
162
317
|
spanLock.lock()
|
|
163
|
-
liveSpans.removeAll()
|
|
318
|
+
_ = liveSpans.removeAll()
|
|
164
319
|
spanLock.unlock()
|
|
165
320
|
}
|
|
166
321
|
|
|
@@ -177,12 +332,28 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
177
332
|
/// to land in Dash0 — the willTerminate observer doesn't fire on
|
|
178
333
|
/// abort().
|
|
179
334
|
func forceFlush() {
|
|
180
|
-
|
|
181
|
-
_ = otel.forceFlush()
|
|
335
|
+
telemetry?.forceFlush()
|
|
182
336
|
}
|
|
183
337
|
|
|
184
338
|
// MARK: - Helpers
|
|
185
339
|
|
|
340
|
+
/// Translate the bridge sampling config into the SDK's `SamplingConfig`.
|
|
341
|
+
/// Nil (caller sent nothing) maps to `.alwaysOn` to preserve the RN
|
|
342
|
+
/// default — see the `samplingConfig:` comment in `start`.
|
|
343
|
+
private static func samplingConfig(from sampling: BridgeSamplingConfig?) -> SamplingConfig {
|
|
344
|
+
switch sampling?.strategy {
|
|
345
|
+
case .none, .alwaysOn:
|
|
346
|
+
return .alwaysOn()
|
|
347
|
+
case .alwaysOff:
|
|
348
|
+
return .alwaysOff()
|
|
349
|
+
case .dynamic:
|
|
350
|
+
return .dynamic(
|
|
351
|
+
normalRate: sampling?.normalRate ?? 0.05,
|
|
352
|
+
highPriorityRate: sampling?.highPriorityRate ?? 1.0
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
186
357
|
private static func toAttributeValues(_ raw: [String: Any]) -> [String: AttributeValue] {
|
|
187
358
|
var out: [String: AttributeValue] = [:]
|
|
188
359
|
for (k, v) in raw {
|
|
@@ -215,7 +386,7 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
215
386
|
Date(timeIntervalSince1970: TimeInterval(nano) / 1_000_000_000.0)
|
|
216
387
|
}
|
|
217
388
|
|
|
218
|
-
|
|
389
|
+
static func mapSeverity(_ raw: Int) -> Severity {
|
|
219
390
|
// OTel severity numbers: 1=TRACE, 5=DEBUG, 9=INFO, 13=WARN, 17=ERROR,
|
|
220
391
|
// 21=FATAL. JS sends the numeric value directly — fall back to INFO
|
|
221
392
|
// when the value is outside the known range so log records never
|
|
@@ -231,7 +402,7 @@ final class OTelMobileCallSink: BridgeCallSink {
|
|
|
231
402
|
}
|
|
232
403
|
}
|
|
233
404
|
|
|
234
|
-
|
|
405
|
+
static func mapSpanKind(_ raw: String) -> SpanKind {
|
|
235
406
|
switch raw {
|
|
236
407
|
case "CLIENT": return .client
|
|
237
408
|
case "SERVER": return .server
|
|
@@ -22,17 +22,25 @@ public final class Dash0MobileModule: NSObject {
|
|
|
22
22
|
public static var sinkFactory: () -> BridgeCallSink = { NoopSink() }
|
|
23
23
|
|
|
24
24
|
public static func installSink(_ factory: @escaping () -> BridgeCallSink) {
|
|
25
|
-
|
|
25
|
+
Self.debugLog("installSink called")
|
|
26
26
|
sinkFactory = factory
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
override init() {
|
|
30
30
|
let sink = Dash0MobileModule.sinkFactory()
|
|
31
|
-
|
|
31
|
+
Dash0MobileModule.debugLog("Module init with sink type: \(type(of: sink))")
|
|
32
32
|
self.dispatcher = Dash0MobileBridgeDispatcher(sink: sink)
|
|
33
33
|
super.init()
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/// Gate init/installSink diagnostics behind DEBUG so release builds don't
|
|
37
|
+
/// leak sink/config detail to the device log.
|
|
38
|
+
private static func debugLog(_ message: @autoclosure () -> String) {
|
|
39
|
+
#if DEBUG
|
|
40
|
+
NSLog("[Dash0Mobile] %@", message())
|
|
41
|
+
#endif
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
// MARK: - For tests
|
|
37
45
|
|
|
38
46
|
init(dispatcher: Dash0MobileBridgeDispatcher) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Tests for the O(1) bounded live-span store used by OTelMobileCallSink.
|
|
2
|
+
//
|
|
3
|
+
// OTelMobileCallSink itself is excluded from this SwiftPM test target (it
|
|
4
|
+
// depends on OTelMobileSDK), so we test the eviction/lookup logic directly on
|
|
5
|
+
// the generic store with a plain value type. This is the same data structure
|
|
6
|
+
// the call sink specializes to `Span`.
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
import Testing
|
|
10
|
+
@testable import Dash0MobileReactNative
|
|
11
|
+
|
|
12
|
+
@Suite("BoundedLiveSpanStore")
|
|
13
|
+
struct BoundedLiveSpanStoreTests {
|
|
14
|
+
|
|
15
|
+
@Test("start/end pairing: put then removeValue returns the value")
|
|
16
|
+
func putThenRemove() {
|
|
17
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 8)
|
|
18
|
+
#expect(store.put("a", 1) == nil)
|
|
19
|
+
#expect(store.count == 1)
|
|
20
|
+
#expect(store.removeValue(forKey: "a") == 1)
|
|
21
|
+
#expect(store.count == 0)
|
|
22
|
+
// Removing again is a safe nil.
|
|
23
|
+
#expect(store.removeValue(forKey: "a") == nil)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test("removeValue for absent key returns nil and leaves store intact")
|
|
27
|
+
func removeAbsent() {
|
|
28
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 4)
|
|
29
|
+
store.put("a", 1)
|
|
30
|
+
#expect(store.removeValue(forKey: "missing") == nil)
|
|
31
|
+
#expect(store.count == 1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test("eviction at capacity drops the OLDEST entry (FIFO)")
|
|
35
|
+
func evictsOldest() {
|
|
36
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 3)
|
|
37
|
+
#expect(store.put("a", 1) == nil)
|
|
38
|
+
#expect(store.put("b", 2) == nil)
|
|
39
|
+
#expect(store.put("c", 3) == nil)
|
|
40
|
+
// Fourth insert evicts the oldest ("a" → 1).
|
|
41
|
+
#expect(store.put("d", 4) == 1)
|
|
42
|
+
#expect(store.evictedCount == 1)
|
|
43
|
+
#expect(store.count == 3)
|
|
44
|
+
// "a" is gone; b, c, d remain.
|
|
45
|
+
#expect(store.removeValue(forKey: "a") == nil)
|
|
46
|
+
#expect(store.removeValue(forKey: "b") == 2)
|
|
47
|
+
#expect(store.removeValue(forKey: "c") == 3)
|
|
48
|
+
#expect(store.removeValue(forKey: "d") == 4)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test("eviction order stays correct after interleaved removes")
|
|
52
|
+
func evictionAfterRemoves() {
|
|
53
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 3)
|
|
54
|
+
store.put("a", 1)
|
|
55
|
+
store.put("b", 2)
|
|
56
|
+
store.put("c", 3)
|
|
57
|
+
// Remove the current oldest explicitly.
|
|
58
|
+
#expect(store.removeValue(forKey: "a") == 2 - 1) // 1
|
|
59
|
+
// Now b is oldest. Insert two more; capacity is 3, count is 2 → first
|
|
60
|
+
// insert fits, second evicts oldest (b).
|
|
61
|
+
#expect(store.put("d", 4) == nil) // count 3, no eviction
|
|
62
|
+
#expect(store.put("e", 5) == 2) // evicts b
|
|
63
|
+
#expect(store.removeValue(forKey: "b") == nil)
|
|
64
|
+
#expect(store.removeValue(forKey: "c") == 3)
|
|
65
|
+
#expect(store.removeValue(forKey: "d") == 4)
|
|
66
|
+
#expect(store.removeValue(forKey: "e") == 5)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test("re-putting an existing key updates value without eviction or reorder")
|
|
70
|
+
func rePutUpdatesInPlace() {
|
|
71
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 2)
|
|
72
|
+
store.put("a", 1)
|
|
73
|
+
store.put("b", 2)
|
|
74
|
+
// Overwrite "a" — no eviction, count unchanged, "a" keeps its (oldest)
|
|
75
|
+
// position so the NEXT insert still evicts "a".
|
|
76
|
+
#expect(store.put("a", 11) == nil)
|
|
77
|
+
#expect(store.count == 2)
|
|
78
|
+
#expect(store.put("c", 3) == 11) // evicts a (still oldest), now with updated value
|
|
79
|
+
#expect(store.removeValue(forKey: "a") == nil)
|
|
80
|
+
#expect(store.removeValue(forKey: "b") == 2)
|
|
81
|
+
#expect(store.removeValue(forKey: "c") == 3)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test("removeAll returns all values oldest→newest and empties the store")
|
|
85
|
+
func removeAllDrains() {
|
|
86
|
+
let store = BoundedLiveSpanStore<String, Int>(capacity: 8)
|
|
87
|
+
store.put("a", 1)
|
|
88
|
+
store.put("b", 2)
|
|
89
|
+
store.put("c", 3)
|
|
90
|
+
#expect(store.removeAll() == [1, 2, 3])
|
|
91
|
+
#expect(store.count == 0)
|
|
92
|
+
// Store is reusable after removeAll.
|
|
93
|
+
#expect(store.put("x", 9) == nil)
|
|
94
|
+
#expect(store.count == 1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test("no leak: count never exceeds capacity under sustained inserts")
|
|
98
|
+
func noLeakUnderChurn() {
|
|
99
|
+
let cap = 100
|
|
100
|
+
let store = BoundedLiveSpanStore<Int, Int>(capacity: cap)
|
|
101
|
+
for i in 0..<10_000 {
|
|
102
|
+
store.put(i, i)
|
|
103
|
+
#expect(store.count <= cap)
|
|
104
|
+
}
|
|
105
|
+
#expect(store.count == cap)
|
|
106
|
+
#expect(store.evictedCount == 10_000 - cap)
|
|
107
|
+
// The surviving keys are the most-recent `cap` insertions.
|
|
108
|
+
#expect(store.removeValue(forKey: 10_000 - cap) == 10_000 - cap)
|
|
109
|
+
#expect(store.removeValue(forKey: 10_000 - cap - 1) == nil)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Test("concurrent access via an external lock stays consistent")
|
|
113
|
+
func concurrentWithExternalLock() async {
|
|
114
|
+
// Mirrors how OTelMobileCallSink serializes access under spanLock.
|
|
115
|
+
let box = LockedStore(capacity: 256)
|
|
116
|
+
await withTaskGroup(of: Void.self) { group in
|
|
117
|
+
for i in 0..<2000 {
|
|
118
|
+
group.addTask {
|
|
119
|
+
box.put("k\(i % 300)", i)
|
|
120
|
+
_ = box.remove("k\(i % 300)")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// After all start/remove pairs, the store must not have grown beyond
|
|
125
|
+
// capacity and must be internally consistent (count == reachable nodes,
|
|
126
|
+
// enforced by removeAll succeeding without crash).
|
|
127
|
+
#expect(box.count() <= 256)
|
|
128
|
+
_ = box.removeAll()
|
|
129
|
+
#expect(box.count() == 0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Tiny lock wrapper so the concurrency test exercises the same
|
|
133
|
+
/// serialize-under-a-lock pattern the production call sink uses.
|
|
134
|
+
private final class LockedStore: @unchecked Sendable {
|
|
135
|
+
private let lock = NSLock()
|
|
136
|
+
private let store: BoundedLiveSpanStore<String, Int>
|
|
137
|
+
init(capacity: Int) { store = BoundedLiveSpanStore(capacity: capacity) }
|
|
138
|
+
func put(_ k: String, _ v: Int) { lock.lock(); store.put(k, v); lock.unlock() }
|
|
139
|
+
func remove(_ k: String) -> Int? { lock.lock(); defer { lock.unlock() }; return store.removeValue(forKey: k) }
|
|
140
|
+
func count() -> Int { lock.lock(); defer { lock.unlock() }; return store.count }
|
|
141
|
+
func removeAll() -> [Int] { lock.lock(); defer { lock.unlock() }; return store.removeAll() }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -56,6 +56,69 @@ struct Dash0MobileBridgeDispatcherTests {
|
|
|
56
56
|
#expect(sink.starts[0].nativeAutoCapture == [])
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// ── start: sampling (Loper finding #4) ─────────────────────────────────
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
func start_decodes_alwaysOff_sampling() throws {
|
|
63
|
+
let sink = RecordingSink()
|
|
64
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
65
|
+
try d.start(config: [
|
|
66
|
+
"serviceName": "s",
|
|
67
|
+
"endpoint": "https://e",
|
|
68
|
+
"sampling": ["strategy": "always_off"],
|
|
69
|
+
])
|
|
70
|
+
#expect(sink.starts[0].sampling == BridgeSamplingConfig(strategy: .alwaysOff))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
func start_decodes_dynamic_sampling_with_rates() throws {
|
|
75
|
+
let sink = RecordingSink()
|
|
76
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
77
|
+
try d.start(config: [
|
|
78
|
+
"serviceName": "s",
|
|
79
|
+
"endpoint": "https://e",
|
|
80
|
+
"sampling": ["strategy": "dynamic", "normalRate": 0.1, "highPriorityRate": 1.0],
|
|
81
|
+
])
|
|
82
|
+
#expect(sink.starts[0].sampling == BridgeSamplingConfig(
|
|
83
|
+
strategy: .dynamic, normalRate: 0.1, highPriorityRate: 1.0
|
|
84
|
+
))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
func start_decodes_alwaysOn_sampling() throws {
|
|
89
|
+
let sink = RecordingSink()
|
|
90
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
91
|
+
try d.start(config: [
|
|
92
|
+
"serviceName": "s",
|
|
93
|
+
"endpoint": "https://e",
|
|
94
|
+
"sampling": ["strategy": "always_on"],
|
|
95
|
+
])
|
|
96
|
+
#expect(sink.starts[0].sampling == BridgeSamplingConfig(strategy: .alwaysOn))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
func start_sampling_nil_when_absent() throws {
|
|
101
|
+
// The JS bridge always sends `sampling`, but the dispatcher must
|
|
102
|
+
// decode a missing field as nil (the sink then applies the RN
|
|
103
|
+
// .alwaysOn default).
|
|
104
|
+
let sink = RecordingSink()
|
|
105
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
106
|
+
try d.start(config: ["serviceName": "s", "endpoint": "https://e"])
|
|
107
|
+
#expect(sink.starts[0].sampling == nil)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@Test
|
|
111
|
+
func start_unknown_sampling_strategy_falls_back_to_alwaysOn() throws {
|
|
112
|
+
let sink = RecordingSink()
|
|
113
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
114
|
+
try d.start(config: [
|
|
115
|
+
"serviceName": "s",
|
|
116
|
+
"endpoint": "https://e",
|
|
117
|
+
"sampling": ["strategy": "martian"],
|
|
118
|
+
])
|
|
119
|
+
#expect(sink.starts[0].sampling?.strategy == .alwaysOn)
|
|
120
|
+
}
|
|
121
|
+
|
|
59
122
|
@Test
|
|
60
123
|
func start_throws_when_serviceName_missing() {
|
|
61
124
|
let sink = RecordingSink()
|