@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dash0Mobile.podspec +4 -0
- package/README.md +113 -28
- package/android/build.gradle +73 -6
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/android/gradle.properties +27 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +62 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
- package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
- package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
- package/ios/BoundedLiveSpanStore.swift +138 -0
- package/ios/BridgeCallSink.swift +49 -1
- package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
- package/ios/OTelMobileCallSink.swift +205 -34
- package/ios/RCTDash0MobileModule.swift +10 -2
- package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
- package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
- package/lib/bridge/types.d.ts +35 -0
- package/lib/bridge/types.d.ts.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +42 -2
- package/lib/instrumentation/errors.d.ts.map +1 -1
- package/lib/instrumentation/errors.js +13 -2
- package/lib/instrumentation/fetch.d.ts.map +1 -1
- package/lib/instrumentation/fetch.js +58 -22
- package/lib/instrumentation/navigation.d.ts.map +1 -1
- package/lib/instrumentation/navigation.js +32 -8
- package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
- package/lib/instrumentation/unhandledRejection.js +13 -3
- package/lib/instrumentation/xhr.d.ts.map +1 -1
- package/lib/instrumentation/xhr.js +63 -36
- package/lib/redact.d.ts +30 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +67 -0
- package/package.json +1 -1
- package/src/bridge/types.ts +36 -0
- package/src/index.ts +44 -3
- package/src/instrumentation/errors.ts +12 -2
- package/src/instrumentation/fetch.ts +60 -27
- package/src/instrumentation/navigation.ts +40 -8
- package/src/instrumentation/unhandledRejection.ts +12 -3
- package/src/instrumentation/xhr.ts +65 -40
- package/src/redact.ts +71 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* OTelNetworkInterceptor — the Android network-instrumentation story for
|
|
3
|
+
* @dash0/mobile-react-native.
|
|
4
|
+
*
|
|
5
|
+
* WHY NATIVE (and not the JS XHR/fetch shim):
|
|
6
|
+
* Expo SDK 52+ replaces the global `fetch` with `expo/fetch`, which is
|
|
7
|
+
* implemented natively on OkHttp rather than on `XMLHttpRequest`. The JS
|
|
8
|
+
* `fetch`/`XHR` shims therefore see ZERO traffic in Expo apps. Installing an
|
|
9
|
+
* OkHttp `Interceptor` on React Native's shared OkHttp client captures every
|
|
10
|
+
* outbound request — classic `fetch`, `XMLHttpRequest`, axios, AND `expo/fetch`
|
|
11
|
+
* — because they all bottom out on that one client.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT DOES, per request:
|
|
14
|
+
* 1. Records a native CLIENT span (name, http.request.method, url.full,
|
|
15
|
+
* server.address, http.response.status_code, timing).
|
|
16
|
+
* 2. Injects a W3C `traceparent` header built from the REAL native span
|
|
17
|
+
* context (trace id + span id + sampled flag), so mobile→backend
|
|
18
|
+
* distributed traces stitch on Android the same way iOS's
|
|
19
|
+
* OTelURLProtocol stitches them.
|
|
20
|
+
*
|
|
21
|
+
* HOST SAFETY (non-negotiable):
|
|
22
|
+
* This interceptor wraps the host app's ENTIRE OkHttp pipeline. If it ever
|
|
23
|
+
* throws, it breaks ALL networking. So every telemetry operation is wrapped
|
|
24
|
+
* such that `chain.proceed(request)` ALWAYS runs and the original
|
|
25
|
+
* response/exception is ALWAYS returned. Telemetry failure = drop telemetry,
|
|
26
|
+
* never affect the host request.
|
|
27
|
+
*
|
|
28
|
+
* LIFECYCLE:
|
|
29
|
+
* The interceptor is registered on RN's OkHttpClientProvider BEFORE any JS
|
|
30
|
+
* runs (in Dash0MobilePackage.createNativeModules) so expo/fetch's OkHttp
|
|
31
|
+
* client picks it up. At that point no tracer exists yet, so it is armed via
|
|
32
|
+
* [arm] when the sink's `start` runs (which is what stands up OTelMobile and
|
|
33
|
+
* hence the tracer). Before [arm], and after [disarm], it is a pure
|
|
34
|
+
* pass-through no-op.
|
|
35
|
+
*/
|
|
36
|
+
package com.dash0.mobile.reactnative
|
|
37
|
+
|
|
38
|
+
import io.opentelemetry.api.trace.Span
|
|
39
|
+
import io.opentelemetry.api.trace.SpanKind
|
|
40
|
+
import io.opentelemetry.api.trace.StatusCode
|
|
41
|
+
import io.opentelemetry.api.trace.Tracer
|
|
42
|
+
import java.util.concurrent.TimeUnit
|
|
43
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
44
|
+
import okhttp3.Interceptor
|
|
45
|
+
import okhttp3.Response
|
|
46
|
+
|
|
47
|
+
// Public (not `internal`) because it is exposed through the public
|
|
48
|
+
// `NetworkInstrumentation.interceptor` property; an `internal` type behind a
|
|
49
|
+
// public property is a Kotlin "exposes internal type" compile error.
|
|
50
|
+
class OTelNetworkInterceptor : Interceptor {
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Everything the interceptor needs to produce telemetry. Held behind a
|
|
54
|
+
* single immutable reference swapped atomically by [arm] / [disarm] so the
|
|
55
|
+
* intercept path reads a consistent snapshot without locking. `null` = not
|
|
56
|
+
* yet armed (or shut down) → pure pass-through.
|
|
57
|
+
*/
|
|
58
|
+
private class Armament(
|
|
59
|
+
val tracer: Tracer,
|
|
60
|
+
/** Lower-cased collector hosts that must NOT be instrumented (our own exports). */
|
|
61
|
+
val ignoredHosts: Set<String>,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
private val armament = AtomicReference<Armament?>(null)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Arm the interceptor with a tracer once the sink has started OTelMobile.
|
|
68
|
+
* Called from [OTelMobileCallSink.start]. The `collectorEndpoint` is the
|
|
69
|
+
* configured OTLP endpoint; its host is added to the ignore list so we do
|
|
70
|
+
* not instrument (and recurse on) our own telemetry exports.
|
|
71
|
+
*/
|
|
72
|
+
fun arm(tracer: Tracer, collectorEndpoint: String?) {
|
|
73
|
+
val ignored = buildSet {
|
|
74
|
+
hostFromUrl(collectorEndpoint)?.let { add(it) }
|
|
75
|
+
}
|
|
76
|
+
armament.set(Armament(tracer, ignored))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Return to pass-through. Called on sink shutdown. */
|
|
80
|
+
fun disarm() {
|
|
81
|
+
armament.set(null)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
85
|
+
val active = armament.get()
|
|
86
|
+
?: return chain.proceed(chain.request())
|
|
87
|
+
|
|
88
|
+
// From here on, EVERY telemetry step is fault-isolated. The original
|
|
89
|
+
// request must always be proceeded, and the original response (or
|
|
90
|
+
// exception) must always be returned to the host.
|
|
91
|
+
|
|
92
|
+
val originalRequest = chain.request()
|
|
93
|
+
|
|
94
|
+
// Decide whether to instrument at all. A throw while computing this
|
|
95
|
+
// must not stop the request — fall back to "don't instrument".
|
|
96
|
+
val host: String? = try {
|
|
97
|
+
originalRequest.url.host.lowercase()
|
|
98
|
+
} catch (_: Throwable) {
|
|
99
|
+
null
|
|
100
|
+
}
|
|
101
|
+
val shouldInstrument = try {
|
|
102
|
+
host == null || !active.ignoredHosts.contains(host)
|
|
103
|
+
} catch (_: Throwable) {
|
|
104
|
+
false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!shouldInstrument) {
|
|
108
|
+
return chain.proceed(originalRequest)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Start the span + build the traceparent-augmented request. If ANY of
|
|
112
|
+
// this throws, we proceed with the ORIGINAL request and skip telemetry.
|
|
113
|
+
var span: Span? = null
|
|
114
|
+
val outgoing = try {
|
|
115
|
+
val method = originalRequest.method.uppercase()
|
|
116
|
+
val url = originalRequest.url
|
|
117
|
+
val startedSpan = active.tracer
|
|
118
|
+
.spanBuilder(method)
|
|
119
|
+
.setSpanKind(SpanKind.CLIENT)
|
|
120
|
+
.startSpan()
|
|
121
|
+
span = startedSpan
|
|
122
|
+
startedSpan.setAttribute("http.request.method", method)
|
|
123
|
+
startedSpan.setAttribute("url.full", url.toString())
|
|
124
|
+
startedSpan.setAttribute("server.address", url.host)
|
|
125
|
+
startedSpan.setAttribute("url.scheme", url.scheme)
|
|
126
|
+
|
|
127
|
+
val traceparent = traceparentOf(startedSpan)
|
|
128
|
+
if (traceparent != null) {
|
|
129
|
+
originalRequest.newBuilder()
|
|
130
|
+
.header("traceparent", traceparent)
|
|
131
|
+
.build()
|
|
132
|
+
} else {
|
|
133
|
+
originalRequest
|
|
134
|
+
}
|
|
135
|
+
} catch (_: Throwable) {
|
|
136
|
+
// Telemetry setup failed. Make sure any half-started span is closed
|
|
137
|
+
// (best-effort) and proceed cleanly with the untouched request.
|
|
138
|
+
try {
|
|
139
|
+
span?.end()
|
|
140
|
+
} catch (_: Throwable) {
|
|
141
|
+
}
|
|
142
|
+
return chain.proceed(originalRequest)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// THE host request. This is the one call that must propagate its result
|
|
146
|
+
// (response OR exception) verbatim to the host. We time it and enrich
|
|
147
|
+
// the span around it, but never let enrichment alter the outcome.
|
|
148
|
+
val startNanos = System.nanoTime()
|
|
149
|
+
val response: Response
|
|
150
|
+
try {
|
|
151
|
+
response = chain.proceed(outgoing)
|
|
152
|
+
} catch (t: Throwable) {
|
|
153
|
+
// Network failure: record it on the span (best-effort) then rethrow
|
|
154
|
+
// the ORIGINAL throwable so the host sees the real error.
|
|
155
|
+
try {
|
|
156
|
+
span?.setStatus(StatusCode.ERROR, t.javaClass.simpleName)
|
|
157
|
+
span?.end()
|
|
158
|
+
} catch (_: Throwable) {
|
|
159
|
+
}
|
|
160
|
+
throw t
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Success path: enrich + end the span. A throw here must NOT corrupt the
|
|
164
|
+
// response we hand back to the host.
|
|
165
|
+
try {
|
|
166
|
+
val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos)
|
|
167
|
+
val code = response.code
|
|
168
|
+
span?.setAttribute("http.response.status_code", code.toLong())
|
|
169
|
+
span?.setAttribute("http.client.request.duration_ms", durationMs)
|
|
170
|
+
if (code >= 400) {
|
|
171
|
+
span?.setStatus(StatusCode.ERROR, "HTTP $code")
|
|
172
|
+
} else {
|
|
173
|
+
span?.setStatus(StatusCode.OK)
|
|
174
|
+
}
|
|
175
|
+
span?.end()
|
|
176
|
+
} catch (_: Throwable) {
|
|
177
|
+
// Telemetry finalization failed — drop it, keep the response.
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return response
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private companion object {
|
|
184
|
+
private val HOST_PATTERN =
|
|
185
|
+
Regex("^[a-z][a-z0-9+.-]*://([^/:?#]+)", RegexOption.IGNORE_CASE)
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* W3C traceparent: `00-<32hex traceId>-<16hex spanId>-<2hex flags>`.
|
|
189
|
+
* Built from the REAL native span context so the backend stitches the
|
|
190
|
+
* mobile CLIENT span to its SERVER span. Returns null for an invalid
|
|
191
|
+
* context (e.g. a no-op span when sampling is off and the SDK hands back
|
|
192
|
+
* an invalid context) so we never inject a malformed header.
|
|
193
|
+
*/
|
|
194
|
+
fun traceparentOf(span: Span): String? {
|
|
195
|
+
val ctx = span.spanContext
|
|
196
|
+
if (!ctx.isValid) return null
|
|
197
|
+
val flags = if (ctx.isSampled) "01" else "00"
|
|
198
|
+
return "00-${ctx.traceId}-${ctx.spanId}-$flags"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fun hostFromUrl(url: String?): String? {
|
|
202
|
+
if (url == null) return null
|
|
203
|
+
return HOST_PATTERN.find(url)?.groupValues?.getOrNull(1)?.lowercase()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -82,6 +82,75 @@ class Dash0MobileModuleTest {
|
|
|
82
82
|
assertTrue(sink.starts[0].nativeAutoCapture.isEmpty())
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// ── start: sampling (Loper finding #4) ───────────────────────────────
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun start_decodes_alwaysOff_sampling() {
|
|
89
|
+
val cfg = JavaOnlyMap.of(
|
|
90
|
+
"serviceName", "s",
|
|
91
|
+
"endpoint", "https://e",
|
|
92
|
+
"sampling", JavaOnlyMap.of("strategy", "always_off"),
|
|
93
|
+
)
|
|
94
|
+
module.start(cfg, RecordingPromise())
|
|
95
|
+
assertEquals(
|
|
96
|
+
BridgeSamplingConfig(strategy = SamplingStrategy.ALWAYS_OFF),
|
|
97
|
+
sink.starts[0].sampling,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun start_decodes_dynamic_sampling_with_rates() {
|
|
103
|
+
val cfg = JavaOnlyMap.of(
|
|
104
|
+
"serviceName", "s",
|
|
105
|
+
"endpoint", "https://e",
|
|
106
|
+
"sampling", JavaOnlyMap.of(
|
|
107
|
+
"strategy", "dynamic",
|
|
108
|
+
"normalRate", 0.1,
|
|
109
|
+
"highPriorityRate", 1.0,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
module.start(cfg, RecordingPromise())
|
|
113
|
+
assertEquals(
|
|
114
|
+
BridgeSamplingConfig(
|
|
115
|
+
strategy = SamplingStrategy.DYNAMIC,
|
|
116
|
+
normalRate = 0.1,
|
|
117
|
+
highPriorityRate = 1.0,
|
|
118
|
+
),
|
|
119
|
+
sink.starts[0].sampling,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@Test
|
|
124
|
+
fun start_decodes_alwaysOn_sampling() {
|
|
125
|
+
val cfg = JavaOnlyMap.of(
|
|
126
|
+
"serviceName", "s",
|
|
127
|
+
"endpoint", "https://e",
|
|
128
|
+
"sampling", JavaOnlyMap.of("strategy", "always_on"),
|
|
129
|
+
)
|
|
130
|
+
module.start(cfg, RecordingPromise())
|
|
131
|
+
assertEquals(SamplingStrategy.ALWAYS_ON, sink.starts[0].sampling?.strategy)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Test
|
|
135
|
+
fun start_sampling_null_when_absent() {
|
|
136
|
+
// The JS bridge always sends `sampling`, but a missing field decodes
|
|
137
|
+
// to null (the sink then applies the RN ALWAYS_ON default).
|
|
138
|
+
val cfg = JavaOnlyMap.of("serviceName", "s", "endpoint", "https://e")
|
|
139
|
+
module.start(cfg, RecordingPromise())
|
|
140
|
+
assertNull(sink.starts[0].sampling)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@Test
|
|
144
|
+
fun start_unknown_sampling_strategy_falls_back_to_alwaysOn() {
|
|
145
|
+
val cfg = JavaOnlyMap.of(
|
|
146
|
+
"serviceName", "s",
|
|
147
|
+
"endpoint", "https://e",
|
|
148
|
+
"sampling", JavaOnlyMap.of("strategy", "martian"),
|
|
149
|
+
)
|
|
150
|
+
module.start(cfg, RecordingPromise())
|
|
151
|
+
assertEquals(SamplingStrategy.ALWAYS_ON, sink.starts[0].sampling?.strategy)
|
|
152
|
+
}
|
|
153
|
+
|
|
85
154
|
@Test
|
|
86
155
|
fun start_rejects_when_serviceName_missing() {
|
|
87
156
|
val cfg = JavaOnlyMap.of("endpoint", "e")
|
|
@@ -395,18 +464,24 @@ private class RecordingSink : BridgeCallSink {
|
|
|
395
464
|
}
|
|
396
465
|
}
|
|
397
466
|
|
|
467
|
+
// Mirrors the react-android 0.76.0 Kotlin `Promise` interface exactly. That
|
|
468
|
+
// interface declares `code` and `userInfo` as NON-null (it is Promise.kt, not
|
|
469
|
+
// the older Java Promise where everything was a nullable platform type), so the
|
|
470
|
+
// override signatures must use non-null `String` / `WritableMap` or Kotlin
|
|
471
|
+
// reports "overrides nothing".
|
|
398
472
|
private class RecordingPromise : Promise {
|
|
399
473
|
var resolved = false
|
|
400
474
|
var rejected = false
|
|
401
475
|
override fun resolve(value: Any?) { resolved = true }
|
|
402
|
-
override fun reject(code: String
|
|
403
|
-
override fun reject(code: String
|
|
404
|
-
override fun reject(code: String
|
|
476
|
+
override fun reject(code: String, message: String?) { rejected = true }
|
|
477
|
+
override fun reject(code: String, throwable: Throwable?) { rejected = true }
|
|
478
|
+
override fun reject(code: String, message: String?, throwable: Throwable?) { rejected = true }
|
|
405
479
|
override fun reject(throwable: Throwable) { rejected = true }
|
|
406
480
|
override fun reject(throwable: Throwable, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
|
|
407
|
-
override fun reject(code: String
|
|
408
|
-
override fun reject(code: String
|
|
409
|
-
override fun reject(code: String
|
|
481
|
+
override fun reject(code: String, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
|
|
482
|
+
override fun reject(code: String, throwable: Throwable?, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
|
|
483
|
+
override fun reject(code: String, message: String?, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
|
|
484
|
+
// The 4-arg overload is declared with all-nullable params in 0.76's Promise.kt.
|
|
410
485
|
override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: com.facebook.react.bridge.WritableMap?) { rejected = true }
|
|
411
486
|
@Suppress("DEPRECATION")
|
|
412
487
|
override fun reject(message: String) { rejected = true }
|