@barrysolomon/mobile-react-native 0.1.0-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.
- package/Dash0Mobile.podspec +4 -0
- package/README.md +27 -0
- 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/NativeDash0Mobile.d.ts.map +1 -0
- package/lib/bridge/NativeBridge.d.ts.map +1 -0
- package/lib/{src/bridge → bridge}/types.d.ts +35 -0
- package/lib/bridge/types.d.ts.map +1 -0
- package/lib/{src/index.d.ts → index.d.ts} +1 -1
- package/lib/index.d.ts.map +1 -0
- package/lib/{src/index.js → index.js} +41 -1
- package/lib/instrumentation/errors.d.ts.map +1 -0
- package/lib/{src/instrumentation → instrumentation}/errors.js +13 -2
- package/lib/instrumentation/fetch.d.ts.map +1 -0
- package/lib/instrumentation/fetch.js +111 -0
- package/lib/instrumentation/navigation.d.ts.map +1 -0
- package/lib/instrumentation/navigation.js +63 -0
- package/lib/instrumentation/touch.d.ts.map +1 -0
- package/lib/instrumentation/unhandledRejection.d.ts.map +1 -0
- package/lib/{src/instrumentation → instrumentation}/unhandledRejection.js +13 -3
- package/lib/instrumentation/xhr.d.ts.map +1 -0
- package/lib/instrumentation/xhr.js +115 -0
- package/lib/otel-compat.d.ts.map +1 -0
- package/lib/redact.d.ts +30 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +67 -0
- package/package.json +4 -4
- package/src/bridge/types.ts +36 -0
- package/src/index.ts +43 -2
- 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
- package/lib/src/NativeDash0Mobile.d.ts.map +0 -1
- package/lib/src/bridge/NativeBridge.d.ts.map +0 -1
- package/lib/src/bridge/types.d.ts.map +0 -1
- package/lib/src/index.d.ts.map +0 -1
- package/lib/src/instrumentation/errors.d.ts.map +0 -1
- package/lib/src/instrumentation/fetch.d.ts.map +0 -1
- package/lib/src/instrumentation/fetch.js +0 -75
- package/lib/src/instrumentation/navigation.d.ts.map +0 -1
- package/lib/src/instrumentation/navigation.js +0 -39
- package/lib/src/instrumentation/touch.d.ts.map +0 -1
- package/lib/src/instrumentation/unhandledRejection.d.ts.map +0 -1
- package/lib/src/instrumentation/xhr.d.ts.map +0 -1
- package/lib/src/instrumentation/xhr.js +0 -88
- package/lib/src/otel-compat.d.ts.map +0 -1
- /package/lib/{src/NativeDash0Mobile.d.ts → NativeDash0Mobile.d.ts} +0 -0
- /package/lib/{src/NativeDash0Mobile.js → NativeDash0Mobile.js} +0 -0
- /package/lib/{src/bridge → bridge}/NativeBridge.d.ts +0 -0
- /package/lib/{src/bridge → bridge}/NativeBridge.js +0 -0
- /package/lib/{src/bridge → bridge}/types.js +0 -0
- /package/lib/{src/instrumentation → instrumentation}/errors.d.ts +0 -0
- /package/lib/{src/instrumentation → instrumentation}/fetch.d.ts +0 -0
- /package/lib/{src/instrumentation → instrumentation}/navigation.d.ts +0 -0
- /package/lib/{src/instrumentation → instrumentation}/touch.d.ts +0 -0
- /package/lib/{src/instrumentation → instrumentation}/touch.js +0 -0
- /package/lib/{src/instrumentation → instrumentation}/unhandledRejection.d.ts +0 -0
- /package/lib/{src/instrumentation → instrumentation}/xhr.d.ts +0 -0
- /package/lib/{src/otel-compat.d.ts → otel-compat.d.ts} +0 -0
- /package/lib/{src/otel-compat.js → otel-compat.js} +0 -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
|
+
}
|
package/ios/BridgeCallSink.swift
CHANGED
|
@@ -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)
|