@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.
Files changed (52) hide show
  1. package/Dash0Mobile.podspec +4 -0
  2. package/README.md +113 -28
  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 +42 -2
  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 +44 -3
  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,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?, message: String?) { rejected = true }
403
- override fun reject(code: String?, throwable: Throwable?) { rejected = true }
404
- override fun reject(code: String?, message: String?, throwable: Throwable?) { rejected = true }
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?, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
408
- override fun reject(code: String?, throwable: Throwable?, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
409
- override fun reject(code: String?, message: String?, userInfo: com.facebook.react.bridge.WritableMap) { rejected = true }
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 }