@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.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 (52) hide show
  1. package/Dash0Mobile.podspec +4 -0
  2. package/README.md +27 -0
  3. package/android/build.gradle +73 -6
  4. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  5. package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
  6. package/android/gradle.properties +27 -0
  7. package/android/gradlew +251 -0
  8. package/android/gradlew.bat +94 -0
  9. package/android/settings.gradle +62 -0
  10. package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
  11. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
  12. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
  13. package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
  14. package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
  15. package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
  16. package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
  17. package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
  18. package/ios/BoundedLiveSpanStore.swift +138 -0
  19. package/ios/BridgeCallSink.swift +49 -1
  20. package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
  21. package/ios/OTelMobileCallSink.swift +205 -34
  22. package/ios/RCTDash0MobileModule.swift +10 -2
  23. package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
  24. package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
  25. package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
  26. package/lib/bridge/types.d.ts +35 -0
  27. package/lib/bridge/types.d.ts.map +1 -1
  28. package/lib/index.d.ts +1 -1
  29. package/lib/index.d.ts.map +1 -1
  30. package/lib/index.js +41 -1
  31. package/lib/instrumentation/errors.d.ts.map +1 -1
  32. package/lib/instrumentation/errors.js +13 -2
  33. package/lib/instrumentation/fetch.d.ts.map +1 -1
  34. package/lib/instrumentation/fetch.js +58 -22
  35. package/lib/instrumentation/navigation.d.ts.map +1 -1
  36. package/lib/instrumentation/navigation.js +32 -8
  37. package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
  38. package/lib/instrumentation/unhandledRejection.js +13 -3
  39. package/lib/instrumentation/xhr.d.ts.map +1 -1
  40. package/lib/instrumentation/xhr.js +63 -36
  41. package/lib/redact.d.ts +30 -0
  42. package/lib/redact.d.ts.map +1 -0
  43. package/lib/redact.js +67 -0
  44. package/package.json +1 -1
  45. package/src/bridge/types.ts +36 -0
  46. package/src/index.ts +43 -2
  47. package/src/instrumentation/errors.ts +12 -2
  48. package/src/instrumentation/fetch.ts +60 -27
  49. package/src/instrumentation/navigation.ts +40 -8
  50. package/src/instrumentation/unhandledRejection.ts +12 -3
  51. package/src/instrumentation/xhr.ts +65 -40
  52. 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 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.
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
- private var otel: OTelMobile?
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
- private var liveSpans: [String: Span] = [:]
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 otel == nil else { return }
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
- NSLog("[@dash0/mobile-react-native] OTelMobile.start failed: \(error)")
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 = otel?.logger else { return }
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 = otel?.tracer else { return }
85
- let span = tracer.spanBuilder(spanName: name)
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(kind: Self.mapSpanKind(spanKind))
88
- .startSpan()
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
- liveSpans[spanId] = span
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 = otel?.meter else { return }
274
+ guard let meter = telemetry?.meter else { return }
131
275
  let otelAttrs = Self.toAttributeValues(attributes)
132
276
  switch instrumentType {
133
277
  case "histogram":
134
- meter.histogramBuilder(name: name).build()
135
- .record(value: value, attributes: otelAttrs)
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
- .record(value: value, attributes: otelAttrs)
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 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)
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
- guard let otel = otel else { return }
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
- otel = nil
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
- guard let otel = otel else { return }
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
- private static func mapSeverity(_ raw: Int) -> Severity {
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
- private static func mapSpanKind(_ raw: String) -> SpanKind {
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
- NSLog("[Dash0Mobile] installSink called")
25
+ Self.debugLog("installSink called")
26
26
  sinkFactory = factory
27
27
  }
28
28
 
29
29
  override init() {
30
30
  let sink = Dash0MobileModule.sinkFactory()
31
- NSLog("[Dash0Mobile] Module init with sink type: \(type(of: sink))")
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()