@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
@@ -0,0 +1,381 @@
1
+ /*
2
+ * Unit tests for OTelNetworkInterceptor — the OkHttp interceptor that wraps the
3
+ * host app's ENTIRE OkHttp pipeline (classic fetch, XMLHttpRequest, axios, AND
4
+ * expo/fetch all bottom out on RN's shared OkHttp client).
5
+ *
6
+ * Because a throw from this interceptor would break ALL host networking, the
7
+ * highest-value assertions here are the fault-isolation ones: a telemetry
8
+ * failure must NEVER stop `chain.proceed` nor alter the response/exception the
9
+ * host sees.
10
+ *
11
+ * Everything is exercised on a pure JVM: real OkHttp request/response objects, a
12
+ * real OpenTelemetry SDK tracer wired to an InMemorySpanExporter so we can read
13
+ * back the exact span the interceptor produced and the traceparent it injected,
14
+ * and hand-rolled Interceptor.Chain test doubles. No React types, no Android
15
+ * runtime, no emulator — the interceptor depends only on okhttp3 + otel-api.
16
+ */
17
+ package com.dash0.mobile.reactnative
18
+
19
+ import io.opentelemetry.api.trace.Tracer
20
+ import io.opentelemetry.sdk.OpenTelemetrySdk
21
+ import io.opentelemetry.sdk.trace.SdkTracerProvider
22
+ import io.opentelemetry.sdk.trace.`data`.SpanData
23
+ import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor
24
+ import io.opentelemetry.sdk.trace.samplers.Sampler
25
+ import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter
26
+ import okhttp3.Interceptor
27
+ import okhttp3.Protocol
28
+ import okhttp3.Request
29
+ import okhttp3.RequestBody.Companion.toRequestBody
30
+ import okhttp3.Response
31
+ import okhttp3.ResponseBody.Companion.toResponseBody
32
+ import org.junit.After
33
+ import org.junit.Assert.assertEquals
34
+ import org.junit.Assert.assertFalse
35
+ import org.junit.Assert.assertNotNull
36
+ import org.junit.Assert.assertNull
37
+ import org.junit.Assert.assertSame
38
+ import org.junit.Assert.assertTrue
39
+ import org.junit.Assert.fail
40
+ import org.junit.Before
41
+ import org.junit.Test
42
+
43
+ class OTelNetworkInterceptorTest {
44
+
45
+ private lateinit var spanExporter: InMemorySpanExporter
46
+ private lateinit var sdk: OpenTelemetrySdk
47
+ private lateinit var tracer: Tracer
48
+ private lateinit var interceptor: OTelNetworkInterceptor
49
+
50
+ @Before
51
+ fun setUp() {
52
+ spanExporter = InMemorySpanExporter.create()
53
+ sdk = OpenTelemetrySdk.builder()
54
+ .setTracerProvider(
55
+ SdkTracerProvider.builder()
56
+ .addSpanProcessor(SimpleSpanProcessor.create(spanExporter))
57
+ .setSampler(Sampler.alwaysOn())
58
+ .build(),
59
+ )
60
+ .build()
61
+ tracer = sdk.getTracer("test")
62
+ interceptor = OTelNetworkInterceptor()
63
+ }
64
+
65
+ @After
66
+ fun tearDown() {
67
+ sdk.close()
68
+ }
69
+
70
+ // ── unarmed → pure pass-through ──────────────────────────────────────────
71
+
72
+ @Test
73
+ fun unarmed_passesThrough_unmodifiedRequest_noSpan() {
74
+ // No arm() call → interceptor must be a transparent no-op.
75
+ val chain = FakeChain(get("https://api.example.com/widgets"))
76
+
77
+ val response = interceptor.intercept(chain)
78
+
79
+ // chain.proceed was called exactly once with the ORIGINAL request.
80
+ assertEquals(1, chain.proceedCount)
81
+ assertSame(chain.request, chain.proceededRequest)
82
+ // No traceparent was injected.
83
+ assertNull(chain.proceededRequest!!.header("traceparent"))
84
+ // The exact response object flows back untouched.
85
+ assertSame(chain.cannedResponse, response)
86
+ // No telemetry produced.
87
+ assertTrue(spanExporter.finishedSpanItems.isEmpty())
88
+ }
89
+
90
+ // ── armed → CLIENT span + W3C traceparent ────────────────────────────────
91
+
92
+ @Test
93
+ fun armed_recordsClientSpan_andInjectsWellFormedTraceparent() {
94
+ interceptor.arm(tracer, collectorEndpoint = "https://ingress.dash0.com/v1/traces")
95
+ val chain = FakeChain(get("https://api.example.com/orders?id=7"), responseCode = 200)
96
+
97
+ val response = interceptor.intercept(chain)
98
+
99
+ // Host request proceeded exactly once and the response is unchanged.
100
+ assertEquals(1, chain.proceedCount)
101
+ assertSame(chain.cannedResponse, response)
102
+
103
+ // Exactly one CLIENT span, named by method, with the expected attributes.
104
+ val spans = spanExporter.finishedSpanItems
105
+ assertEquals(1, spans.size)
106
+ val span: SpanData = spans[0]
107
+ assertEquals("GET", span.name)
108
+ assertEquals(io.opentelemetry.api.trace.SpanKind.CLIENT, span.kind)
109
+ assertEquals("GET", span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.request.method")))
110
+ assertEquals("https://api.example.com/orders?id=7", span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("url.full")))
111
+ assertEquals("api.example.com", span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("server.address")))
112
+ assertEquals("https", span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("url.scheme")))
113
+ assertEquals(200L, span.attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code")))
114
+ assertNotNull(span.attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("http.client.request.duration_ms")))
115
+ assertEquals(io.opentelemetry.api.trace.StatusCode.OK, span.status.statusCode)
116
+
117
+ // The traceparent the host request carried must be a well-formed W3C
118
+ // header derived from THIS span's context.
119
+ val traceparent = chain.proceededRequest!!.header("traceparent")
120
+ assertNotNull(traceparent)
121
+ assertTrue(
122
+ "traceparent not W3C-shaped: $traceparent",
123
+ Regex("^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$").matches(traceparent!!),
124
+ )
125
+ // ...and it carries the span's REAL trace + span ids (distributed-trace stitch).
126
+ assertEquals("00-${span.traceId}-${span.spanId}-01", traceparent)
127
+ }
128
+
129
+ // ── invalid/not-sampled context → traceparent OMITTED (not malformed) ─────
130
+
131
+ @Test
132
+ fun invalidSpanContext_omitsTraceparent_doesNotInjectMalformedHeader() {
133
+ // A tracer whose spans always carry an INVALID span context (mirrors the
134
+ // SDK handing back a no-op span when sampling is off). The interceptor
135
+ // must inject NO traceparent rather than a malformed "00-000…-000…".
136
+ interceptor.arm(InvalidContextTracer(), collectorEndpoint = null)
137
+ val chain = FakeChain(get("https://api.example.com/x"))
138
+
139
+ val response = interceptor.intercept(chain)
140
+
141
+ assertEquals(1, chain.proceedCount)
142
+ assertSame(chain.cannedResponse, response)
143
+ assertNull("must omit traceparent for invalid context", chain.proceededRequest!!.header("traceparent"))
144
+ }
145
+
146
+ // ── collector host in ignore list → NOT instrumented ─────────────────────
147
+
148
+ @Test
149
+ fun collectorHost_isNotInstrumented_noSpanNoHeader() {
150
+ // Arm with a collector endpoint; a request to that same host is our own
151
+ // telemetry export and must NOT be instrumented (no recursion).
152
+ interceptor.arm(tracer, collectorEndpoint = "https://ingress.dash0.com/v1/traces")
153
+ val chain = FakeChain(get("https://ingress.dash0.com/v1/traces"))
154
+
155
+ val response = interceptor.intercept(chain)
156
+
157
+ assertEquals(1, chain.proceedCount)
158
+ assertSame(chain.request, chain.proceededRequest) // untouched request
159
+ assertNull(chain.proceededRequest!!.header("traceparent"))
160
+ assertSame(chain.cannedResponse, response)
161
+ assertTrue(spanExporter.finishedSpanItems.isEmpty())
162
+ }
163
+
164
+ @Test
165
+ fun collectorHost_matchIsCaseInsensitive() {
166
+ interceptor.arm(tracer, collectorEndpoint = "https://Ingress.Dash0.com/v1/traces")
167
+ val chain = FakeChain(get("https://INGRESS.DASH0.COM/v1/traces"))
168
+
169
+ interceptor.intercept(chain)
170
+
171
+ assertTrue(spanExporter.finishedSpanItems.isEmpty())
172
+ assertNull(chain.proceededRequest!!.header("traceparent"))
173
+ }
174
+
175
+ @Test
176
+ fun nonCollectorHost_isInstrumented_evenWhenIgnoreListPresent() {
177
+ interceptor.arm(tracer, collectorEndpoint = "https://ingress.dash0.com/v1/traces")
178
+ val chain = FakeChain(get("https://api.example.com/y"))
179
+
180
+ interceptor.intercept(chain)
181
+
182
+ assertEquals(1, spanExporter.finishedSpanItems.size)
183
+ assertNotNull(chain.proceededRequest!!.header("traceparent"))
184
+ }
185
+
186
+ // ── FAULT ISOLATION (the critical contract) ──────────────────────────────
187
+
188
+ @Test
189
+ fun throwingTracer_duringSetup_stillProceedsWithOriginalRequest_andReturnsResponse() {
190
+ // The tracer throws as soon as the interceptor tries to start a span.
191
+ // The host request must still proceed (with the UNMODIFIED request) and
192
+ // the original response must come back verbatim. Telemetry failure must
193
+ // never become a host-networking failure.
194
+ interceptor.arm(ThrowingTracer(), collectorEndpoint = null)
195
+ val chain = FakeChain(get("https://api.example.com/z"))
196
+
197
+ val response = interceptor.intercept(chain)
198
+
199
+ assertEquals(1, chain.proceedCount)
200
+ assertSame("must proceed with the original, unmodified request", chain.request, chain.proceededRequest)
201
+ assertNull(chain.proceededRequest!!.header("traceparent"))
202
+ assertSame(chain.cannedResponse, response)
203
+ assertTrue(spanExporter.finishedSpanItems.isEmpty())
204
+ }
205
+
206
+ @Test
207
+ fun throwingTracer_doesNotPropagateTelemetryExceptionToHost() {
208
+ // Belt-and-suspenders: the telemetry exception must be swallowed, not
209
+ // surfaced to the host caller.
210
+ interceptor.arm(ThrowingTracer(), collectorEndpoint = null)
211
+ val chain = FakeChain(get("https://api.example.com/z"))
212
+ try {
213
+ interceptor.intercept(chain)
214
+ } catch (t: Throwable) {
215
+ fail("telemetry exception leaked to host: $t")
216
+ }
217
+ }
218
+
219
+ // ── network failure → span ERROR, original throwable rethrown verbatim ────
220
+
221
+ @Test
222
+ fun networkException_endsSpanWithError_andRethrowsOriginalThrowableUnchanged() {
223
+ interceptor.arm(tracer, collectorEndpoint = null)
224
+ val boom = java.net.UnknownHostException("api.example.com: nodename nor servname provided")
225
+ val chain = FakeChain(get("https://api.example.com/down"), throwOnProceed = boom)
226
+
227
+ val thrown = try {
228
+ interceptor.intercept(chain)
229
+ null
230
+ } catch (t: Throwable) {
231
+ t
232
+ }
233
+
234
+ // The EXACT original throwable instance is rethrown (not wrapped).
235
+ assertSame(boom, thrown)
236
+
237
+ // The span was still recorded, ended, and marked ERROR.
238
+ val spans = spanExporter.finishedSpanItems
239
+ assertEquals(1, spans.size)
240
+ assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, spans[0].status.statusCode)
241
+ // No status_code attribute since the request never produced a response.
242
+ assertNull(spans[0].attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code")))
243
+ }
244
+
245
+ // ── status-code mapping ──────────────────────────────────────────────────
246
+
247
+ @Test
248
+ fun http2xx_mapsToOkStatus() {
249
+ interceptor.arm(tracer, collectorEndpoint = null)
250
+ interceptor.intercept(FakeChain(get("https://api.example.com/ok"), responseCode = 204))
251
+ assertEquals(io.opentelemetry.api.trace.StatusCode.OK, spanExporter.finishedSpanItems[0].status.statusCode)
252
+ }
253
+
254
+ @Test
255
+ fun http4xx_mapsToErrorStatus() {
256
+ interceptor.arm(tracer, collectorEndpoint = null)
257
+ interceptor.intercept(FakeChain(get("https://api.example.com/missing"), responseCode = 404))
258
+ val span = spanExporter.finishedSpanItems[0]
259
+ assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, span.status.statusCode)
260
+ assertEquals(404L, span.attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code")))
261
+ }
262
+
263
+ @Test
264
+ fun http5xx_mapsToErrorStatus() {
265
+ interceptor.arm(tracer, collectorEndpoint = null)
266
+ interceptor.intercept(FakeChain(get("https://api.example.com/boom"), responseCode = 503))
267
+ assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, spanExporter.finishedSpanItems[0].status.statusCode)
268
+ }
269
+
270
+ // ── method handling ──────────────────────────────────────────────────────
271
+
272
+ @Test
273
+ fun postRequest_spanNamedByUppercasedMethod_andRecordsMethodAttribute() {
274
+ interceptor.arm(tracer, collectorEndpoint = null)
275
+ val req = Request.Builder()
276
+ .url("https://api.example.com/orders")
277
+ .post("{}".toRequestBody())
278
+ .build()
279
+ interceptor.intercept(FakeChain(req, responseCode = 201))
280
+ val span = spanExporter.finishedSpanItems[0]
281
+ assertEquals("POST", span.name)
282
+ assertEquals("POST", span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.request.method")))
283
+ }
284
+
285
+ // ── disarm → back to pass-through ────────────────────────────────────────
286
+
287
+ @Test
288
+ fun disarm_returnsToPassThrough() {
289
+ interceptor.arm(tracer, collectorEndpoint = null)
290
+ interceptor.intercept(FakeChain(get("https://api.example.com/1")))
291
+ assertEquals(1, spanExporter.finishedSpanItems.size)
292
+
293
+ interceptor.disarm()
294
+ val chain = FakeChain(get("https://api.example.com/2"))
295
+ interceptor.intercept(chain)
296
+
297
+ // Still just the one span from before the disarm.
298
+ assertEquals(1, spanExporter.finishedSpanItems.size)
299
+ assertNull(chain.proceededRequest!!.header("traceparent"))
300
+ }
301
+
302
+ // ── helpers ──────────────────────────────────────────────────────────────
303
+
304
+ private fun get(url: String): Request = Request.Builder().url(url).build()
305
+ }
306
+
307
+ /**
308
+ * Minimal okhttp3 Interceptor.Chain test double. Captures whatever request the
309
+ * interceptor proceeds with, counts proceed calls, and either returns a canned
310
+ * response or throws a supplied exception (network-failure simulation).
311
+ */
312
+ private class FakeChain(
313
+ val request: Request,
314
+ responseCode: Int = 200,
315
+ private val throwOnProceed: Throwable? = null,
316
+ ) : Interceptor.Chain {
317
+
318
+ var proceedCount = 0
319
+ var proceededRequest: Request? = null
320
+
321
+ val cannedResponse: Response = Response.Builder()
322
+ .request(request)
323
+ .protocol(Protocol.HTTP_1_1)
324
+ .code(responseCode)
325
+ .message("status $responseCode")
326
+ .body("".toResponseBody())
327
+ .build()
328
+
329
+ override fun request(): Request = request
330
+
331
+ override fun proceed(request: Request): Response {
332
+ proceedCount++
333
+ proceededRequest = request
334
+ throwOnProceed?.let { throw it }
335
+ // Return the SAME response instance so identity-based assertions can
336
+ // prove the interceptor hands the host's response back verbatim.
337
+ return cannedResponse
338
+ }
339
+
340
+ override fun connection() = null
341
+ override fun call(): okhttp3.Call = throw UnsupportedOperationException()
342
+ override fun connectTimeoutMillis(): Int = 0
343
+ override fun withConnectTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this
344
+ override fun readTimeoutMillis(): Int = 0
345
+ override fun withReadTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this
346
+ override fun writeTimeoutMillis(): Int = 0
347
+ override fun withWriteTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit): Interceptor.Chain = this
348
+ }
349
+
350
+ /** A Tracer whose span builder throws the moment startSpan() is called. */
351
+ private class ThrowingTracer : Tracer {
352
+ override fun spanBuilder(spanName: String): io.opentelemetry.api.trace.SpanBuilder =
353
+ throw RuntimeException("boom: tracer is broken")
354
+ }
355
+
356
+ /**
357
+ * A Tracer that produces spans whose context is always INVALID — the SDK's
358
+ * behavior when a span is dropped (e.g. sampling off) and it hands back a
359
+ * no-op/invalid context. Used to prove the interceptor omits the traceparent
360
+ * rather than emitting a malformed all-zeros header.
361
+ */
362
+ private class InvalidContextTracer : Tracer {
363
+ override fun spanBuilder(spanName: String): io.opentelemetry.api.trace.SpanBuilder =
364
+ InvalidContextSpanBuilder()
365
+ }
366
+
367
+ private class InvalidContextSpanBuilder : io.opentelemetry.api.trace.SpanBuilder {
368
+ override fun setParent(context: io.opentelemetry.context.Context) = this
369
+ override fun setNoParent() = this
370
+ override fun addLink(spanContext: io.opentelemetry.api.trace.SpanContext) = this
371
+ override fun addLink(spanContext: io.opentelemetry.api.trace.SpanContext, attributes: io.opentelemetry.api.common.Attributes) = this
372
+ override fun setAttribute(key: String, value: String) = this
373
+ override fun setAttribute(key: String, value: Long) = this
374
+ override fun setAttribute(key: String, value: Double) = this
375
+ override fun setAttribute(key: String, value: Boolean) = this
376
+ override fun <T : Any?> setAttribute(key: io.opentelemetry.api.common.AttributeKey<T>, value: T) = this
377
+ override fun setSpanKind(spanKind: io.opentelemetry.api.trace.SpanKind) = this
378
+ override fun setStartTimestamp(startTimestamp: Long, unit: java.util.concurrent.TimeUnit) = this
379
+ // Span.getInvalid() carries an invalid SpanContext and is a no-op.
380
+ override fun startSpan(): io.opentelemetry.api.trace.Span = io.opentelemetry.api.trace.Span.getInvalid()
381
+ }
@@ -0,0 +1,138 @@
1
+ // Bounded, insertion-ordered store of live spans keyed by spanId.
2
+ //
3
+ // The RN bridge holds a span object between `startSpan` and `endSpan` so it can
4
+ // apply end-time attributes and status. A misbehaving JS layer that starts spans
5
+ // but never ends them (navigation churn, leaked timers) would otherwise grow
6
+ // this map without bound. We cap it and evict the oldest entry when full.
7
+ //
8
+ // Design goals (all O(1)):
9
+ // - lookup / remove by spanId → dictionary
10
+ // - evict the OLDEST inserted entry → intrusive doubly-linked list
11
+ //
12
+ // A naive bounded list does an O(n) `firstIndex(where:)` scan to evict; this
13
+ // structure keeps a `head`/`tail` of a doubly-linked list of nodes plus a
14
+ // `[Key: Node]` index, so both lookup-by-id AND oldest-eviction are O(1).
15
+ //
16
+ // This type is deliberately generic and has **no** dependency on OTelMobileSDK
17
+ // or React, so it lives in the base RN package target and is unit-testable with
18
+ // plain `swift test`. `OTelMobileCallSink` specializes it to `Span`.
19
+ //
20
+ // NOT thread-safe on its own — the caller (`OTelMobileCallSink`) serializes all
21
+ // access under its existing `spanLock`, matching the prior `liveSpans` usage.
22
+ final class BoundedLiveSpanStore<Key: Hashable, Value> {
23
+
24
+ private final class Node {
25
+ let key: Key
26
+ var value: Value
27
+ var prev: Node?
28
+ var next: Node?
29
+ init(key: Key, value: Value) {
30
+ self.key = key
31
+ self.value = value
32
+ }
33
+ }
34
+
35
+ /// Max live entries before the oldest is evicted on insert. Exposed
36
+ /// (read-only) so the owning sink can name the limit in its eviction
37
+ /// diagnostics without hard-coding a second copy of the number.
38
+ let capacity: Int
39
+ private var index: [Key: Node] = [:]
40
+ // head = oldest (evict from here), tail = newest (insert here). FIFO order.
41
+ private var head: Node?
42
+ private var tail: Node?
43
+
44
+ /// Count of entries evicted because the store hit `capacity`. Exposed for
45
+ /// tests and a future health gauge.
46
+ private(set) var evictedCount: Int = 0
47
+
48
+ init(capacity: Int) {
49
+ precondition(capacity > 0, "capacity must be positive")
50
+ self.capacity = capacity
51
+ index.reserveCapacity(capacity)
52
+ }
53
+
54
+ var count: Int { index.count }
55
+
56
+ /// Insert or replace the value for `key`. Returns the value that was
57
+ /// evicted to make room (the oldest entry), or `nil` if nothing was evicted.
58
+ /// O(1). Re-inserting an existing key updates its value in place and does
59
+ /// NOT change its insertion position (it is not "refreshed" to newest) —
60
+ /// startSpan for a duplicate id is a degenerate case we simply overwrite.
61
+ @discardableResult
62
+ func put(_ key: Key, _ value: Value) -> Value? {
63
+ if let existing = index[key] {
64
+ existing.value = value
65
+ return nil
66
+ }
67
+
68
+ var evicted: Value?
69
+ // Evict oldest if at capacity. `>=` because we are about to add one.
70
+ if index.count >= capacity, let oldest = head {
71
+ evicted = oldest.value
72
+ removeNode(oldest)
73
+ index[oldest.key] = nil
74
+ evictedCount += 1
75
+ }
76
+
77
+ let node = Node(key: key, value: value)
78
+ appendNode(node)
79
+ index[key] = node
80
+ return evicted
81
+ }
82
+
83
+ /// Look up the value for `key` without changing its insertion position
84
+ /// or removing it. Returns `nil` if absent. O(1). Used for parent-span
85
+ /// resolution in startSpan — a read-only peek, so the LRU order is left
86
+ /// untouched (the entry is still removed normally by its own endSpan).
87
+ func value(forKey key: Key) -> Value? {
88
+ return index[key]?.value
89
+ }
90
+
91
+ /// Remove and return the value for `key`, or `nil` if absent. O(1).
92
+ @discardableResult
93
+ func removeValue(forKey key: Key) -> Value? {
94
+ guard let node = index[key] else { return nil }
95
+ removeNode(node)
96
+ index[key] = nil
97
+ return node.value
98
+ }
99
+
100
+ /// Remove all entries. Returns the values in insertion (oldest→newest) order
101
+ /// so the caller can end/clean them up if needed. O(n).
102
+ @discardableResult
103
+ func removeAll() -> [Value] {
104
+ var values: [Value] = []
105
+ values.reserveCapacity(index.count)
106
+ var cur = head
107
+ while let node = cur {
108
+ values.append(node.value)
109
+ cur = node.next
110
+ }
111
+ index.removeAll(keepingCapacity: true)
112
+ head = nil
113
+ tail = nil
114
+ return values
115
+ }
116
+
117
+ // MARK: - Intrusive list ops (all O(1))
118
+
119
+ private func appendNode(_ node: Node) {
120
+ node.prev = tail
121
+ node.next = nil
122
+ if let t = tail {
123
+ t.next = node
124
+ } else {
125
+ head = node
126
+ }
127
+ tail = node
128
+ }
129
+
130
+ private func removeNode(_ node: Node) {
131
+ let p = node.prev
132
+ let n = node.next
133
+ if let p = p { p.next = n } else { head = n }
134
+ if let n = n { n.prev = p } else { tail = p }
135
+ node.prev = nil
136
+ node.next = nil
137
+ }
138
+ }
@@ -44,6 +44,40 @@ extension BridgeCallSink {
44
44
  }
45
45
  }
46
46
 
47
+ /// Bridge-side mirror of the JS `SamplingConfig`. Decoded from the RN
48
+ /// `start()` payload and translated to the native SDK's `SamplingConfig`
49
+ /// in `OTelMobileCallSink.start`. Kept SDK-independent so the dispatcher
50
+ /// and its tests compile without linking `OTelMobileSDK`.
51
+ public struct BridgeSamplingConfig: Equatable {
52
+ public enum Strategy: String, Equatable {
53
+ case alwaysOn = "always_on"
54
+ case alwaysOff = "always_off"
55
+ case dynamic
56
+
57
+ /// Maps the JS `strategy` string; unknown values fall back to
58
+ /// `.alwaysOn` (the RN default — see `BridgeStartConfig.sampling`).
59
+ public static func fromToken(_ raw: String?) -> Strategy {
60
+ switch raw {
61
+ case "always_off": return .alwaysOff
62
+ case "dynamic": return .dynamic
63
+ default: return .alwaysOn
64
+ }
65
+ }
66
+ }
67
+
68
+ public let strategy: Strategy
69
+ /// Baseline rate for `.dynamic`. Nil = native default.
70
+ public let normalRate: Double?
71
+ /// High-priority rate for `.dynamic`. Nil = native default.
72
+ public let highPriorityRate: Double?
73
+
74
+ public init(strategy: Strategy, normalRate: Double? = nil, highPriorityRate: Double? = nil) {
75
+ self.strategy = strategy
76
+ self.normalRate = normalRate
77
+ self.highPriorityRate = highPriorityRate
78
+ }
79
+ }
80
+
47
81
  public struct BridgeStartConfig: Equatable {
48
82
  public let serviceName: String
49
83
  public let serviceVersion: String?
@@ -63,6 +97,18 @@ public struct BridgeStartConfig: Equatable {
63
97
  /// "screen", "deviceStats". Unknown tokens are ignored (forward compat).
64
98
  public let nativeAutoCapture: [String]
65
99
 
100
+ /// Trace sampling strategy from the JS caller, mapped onto the native
101
+ /// `SamplingConfig` in `OTelMobileCallSink.start`.
102
+ ///
103
+ /// The RN bridge defaults this to `.alwaysOn` when the JS caller omits
104
+ /// `sampling`, rather than inheriting the native SDK's `dynamic(0.1)`
105
+ /// default. RN manual spans are root spans with arbitrary names, so a
106
+ /// 10% baseline silently drops ~90% of a user's first span (Loper
107
+ /// finding #4). Nil means the caller sent nothing — the sink falls back
108
+ /// to `.alwaysOn` to preserve the RN default; in practice the JS bridge
109
+ /// always sends a value.
110
+ public let sampling: BridgeSamplingConfig?
111
+
66
112
  public init(
67
113
  serviceName: String,
68
114
  serviceVersion: String?,
@@ -70,7 +116,8 @@ public struct BridgeStartConfig: Equatable {
70
116
  authToken: String?,
71
117
  dataset: String?,
72
118
  extraResourceAttributes: [String: String] = [:],
73
- nativeAutoCapture: [String] = []
119
+ nativeAutoCapture: [String] = [],
120
+ sampling: BridgeSamplingConfig? = nil
74
121
  ) {
75
122
  self.serviceName = serviceName
76
123
  self.serviceVersion = serviceVersion
@@ -79,5 +126,6 @@ public struct BridgeStartConfig: Equatable {
79
126
  self.dataset = dataset
80
127
  self.extraResourceAttributes = extraResourceAttributes
81
128
  self.nativeAutoCapture = nativeAutoCapture
129
+ self.sampling = sampling
82
130
  }
83
131
  }
@@ -29,6 +29,7 @@ public final class Dash0MobileBridgeDispatcher {
29
29
  let extras: [String: String] = (config["extraResourceAttributes"] as? [String: Any])?
30
30
  .compactMapValues { $0 as? String } ?? [:]
31
31
  let nativeAutoCapture = (config["nativeAutoCapture"] as? [Any])?.compactMap { $0 as? String } ?? []
32
+ let sampling = Self.parseSampling(config["sampling"])
32
33
  sink.start(BridgeStartConfig(
33
34
  serviceName: serviceName,
34
35
  serviceVersion: config["serviceVersion"] as? String,
@@ -36,10 +37,31 @@ public final class Dash0MobileBridgeDispatcher {
36
37
  authToken: config["authToken"] as? String,
37
38
  dataset: config["dataset"] as? String,
38
39
  extraResourceAttributes: extras,
39
- nativeAutoCapture: nativeAutoCapture
40
+ nativeAutoCapture: nativeAutoCapture,
41
+ sampling: sampling
40
42
  ))
41
43
  }
42
44
 
45
+ /// Decode the JS `sampling` object into a `BridgeSamplingConfig`.
46
+ /// Returns nil when absent so the sink applies the RN default
47
+ /// (`.alwaysOn`). `normalRate` / `highPriorityRate` may arrive as
48
+ /// Int, Double, or NSNumber across the RN bridge.
49
+ private static func parseSampling(_ raw: Any?) -> BridgeSamplingConfig? {
50
+ guard let map = raw as? [String: Any] else { return nil }
51
+ return BridgeSamplingConfig(
52
+ strategy: .fromToken(map["strategy"] as? String),
53
+ normalRate: doubleOrNil(map["normalRate"]),
54
+ highPriorityRate: doubleOrNil(map["highPriorityRate"])
55
+ )
56
+ }
57
+
58
+ private static func doubleOrNil(_ v: Any?) -> Double? {
59
+ if let n = v as? Double { return n }
60
+ if let n = v as? Int { return Double(n) }
61
+ if let n = v as? NSNumber { return n.doubleValue }
62
+ return nil
63
+ }
64
+
43
65
  public func emitBatch(_ payloads: [[String: Any]]) {
44
66
  for p in payloads {
45
67
  dispatch(p)