@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.
Files changed (84) 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/NativeDash0Mobile.d.ts.map +1 -0
  27. package/lib/bridge/NativeBridge.d.ts.map +1 -0
  28. package/lib/{src/bridge → bridge}/types.d.ts +35 -0
  29. package/lib/bridge/types.d.ts.map +1 -0
  30. package/lib/{src/index.d.ts → index.d.ts} +1 -1
  31. package/lib/index.d.ts.map +1 -0
  32. package/lib/{src/index.js → index.js} +41 -1
  33. package/lib/instrumentation/errors.d.ts.map +1 -0
  34. package/lib/{src/instrumentation → instrumentation}/errors.js +13 -2
  35. package/lib/instrumentation/fetch.d.ts.map +1 -0
  36. package/lib/instrumentation/fetch.js +111 -0
  37. package/lib/instrumentation/navigation.d.ts.map +1 -0
  38. package/lib/instrumentation/navigation.js +63 -0
  39. package/lib/instrumentation/touch.d.ts.map +1 -0
  40. package/lib/instrumentation/unhandledRejection.d.ts.map +1 -0
  41. package/lib/{src/instrumentation → instrumentation}/unhandledRejection.js +13 -3
  42. package/lib/instrumentation/xhr.d.ts.map +1 -0
  43. package/lib/instrumentation/xhr.js +115 -0
  44. package/lib/otel-compat.d.ts.map +1 -0
  45. package/lib/redact.d.ts +30 -0
  46. package/lib/redact.d.ts.map +1 -0
  47. package/lib/redact.js +67 -0
  48. package/package.json +4 -4
  49. package/src/bridge/types.ts +36 -0
  50. package/src/index.ts +43 -2
  51. package/src/instrumentation/errors.ts +12 -2
  52. package/src/instrumentation/fetch.ts +60 -27
  53. package/src/instrumentation/navigation.ts +40 -8
  54. package/src/instrumentation/unhandledRejection.ts +12 -3
  55. package/src/instrumentation/xhr.ts +65 -40
  56. package/src/redact.ts +71 -0
  57. package/lib/src/NativeDash0Mobile.d.ts.map +0 -1
  58. package/lib/src/bridge/NativeBridge.d.ts.map +0 -1
  59. package/lib/src/bridge/types.d.ts.map +0 -1
  60. package/lib/src/index.d.ts.map +0 -1
  61. package/lib/src/instrumentation/errors.d.ts.map +0 -1
  62. package/lib/src/instrumentation/fetch.d.ts.map +0 -1
  63. package/lib/src/instrumentation/fetch.js +0 -75
  64. package/lib/src/instrumentation/navigation.d.ts.map +0 -1
  65. package/lib/src/instrumentation/navigation.js +0 -39
  66. package/lib/src/instrumentation/touch.d.ts.map +0 -1
  67. package/lib/src/instrumentation/unhandledRejection.d.ts.map +0 -1
  68. package/lib/src/instrumentation/xhr.d.ts.map +0 -1
  69. package/lib/src/instrumentation/xhr.js +0 -88
  70. package/lib/src/otel-compat.d.ts.map +0 -1
  71. /package/lib/{src/NativeDash0Mobile.d.ts → NativeDash0Mobile.d.ts} +0 -0
  72. /package/lib/{src/NativeDash0Mobile.js → NativeDash0Mobile.js} +0 -0
  73. /package/lib/{src/bridge → bridge}/NativeBridge.d.ts +0 -0
  74. /package/lib/{src/bridge → bridge}/NativeBridge.js +0 -0
  75. /package/lib/{src/bridge → bridge}/types.js +0 -0
  76. /package/lib/{src/instrumentation → instrumentation}/errors.d.ts +0 -0
  77. /package/lib/{src/instrumentation → instrumentation}/fetch.d.ts +0 -0
  78. /package/lib/{src/instrumentation → instrumentation}/navigation.d.ts +0 -0
  79. /package/lib/{src/instrumentation → instrumentation}/touch.d.ts +0 -0
  80. /package/lib/{src/instrumentation → instrumentation}/touch.js +0 -0
  81. /package/lib/{src/instrumentation → instrumentation}/unhandledRejection.d.ts +0 -0
  82. /package/lib/{src/instrumentation → instrumentation}/xhr.d.ts +0 -0
  83. /package/lib/{src/otel-compat.d.ts → otel-compat.d.ts} +0 -0
  84. /package/lib/{src/otel-compat.js → otel-compat.js} +0 -0
@@ -48,6 +48,9 @@ class Dash0MobileModule internal constructor(
48
48
  .getArrayOrNull("nativeAutoCapture")
49
49
  ?.toStringList()
50
50
  ?: emptyList(),
51
+ sampling = config
52
+ .getMapOrNull("sampling")
53
+ ?.toSamplingConfig(),
51
54
  ),
52
55
  )
53
56
  promise.resolve(null)
@@ -58,15 +61,20 @@ class Dash0MobileModule internal constructor(
58
61
 
59
62
  @ReactMethod
60
63
  fun emitBatch(payloads: ReadableArray, promise: Promise) {
61
- try {
62
- for (i in 0 until payloads.size()) {
64
+ // Isolate each payload: a single malformed entry (e.g. wrong-typed
65
+ // severity/value) must not reject the whole batch, or JS retries the
66
+ // entire batch forever and never makes progress. Drop the bad one,
67
+ // keep going.
68
+ for (i in 0 until payloads.size()) {
69
+ try {
63
70
  val p = payloads.getMap(i) ?: continue
64
71
  dispatch(p)
72
+ } catch (t: Throwable) {
73
+ // Best-effort: skip the offending payload and continue.
74
+ continue
65
75
  }
66
- promise.resolve(null)
67
- } catch (t: Throwable) {
68
- promise.reject("Dash0Mobile.emitBatch", t)
69
76
  }
77
+ promise.resolve(null)
70
78
  }
71
79
 
72
80
  @ReactMethod
@@ -94,7 +102,7 @@ class Dash0MobileModule internal constructor(
94
102
  val attrs = p.getMapOrNull("attributes")?.toAttributeMap() ?: emptyMap()
95
103
  when (kind) {
96
104
  "log" -> {
97
- val severity = p.getInt("severity")
105
+ val severity = p.getIntOrDefault("severity", 9)
98
106
  sink.emitLog(
99
107
  name = p.getString("name") ?: return,
100
108
  severity = severity,
@@ -132,7 +140,7 @@ class Dash0MobileModule internal constructor(
132
140
  "metric" -> sink.recordMetric(
133
141
  name = p.getString("name") ?: return,
134
142
  instrumentType = p.getString("instrumentType") ?: "counter",
135
- value = p.getDouble("value"),
143
+ value = p.getDoubleOrDefault("value", 0.0),
136
144
  attributes = attrs,
137
145
  timeUnixNano = p.getStringAsLong("timeUnixNano"),
138
146
  )
@@ -169,6 +177,16 @@ private fun ReadableArray.toStringList(): List<String> {
169
177
  private fun ReadableMap.getStringAsLong(key: String): Long =
170
178
  getStringOrNull(key)?.toLongOrNull() ?: 0L
171
179
 
180
+ // Type-guarded numeric reads. ReadableMap.getInt/getDouble throw if the JS
181
+ // value isn't actually a number (e.g. a stringified severity). Check the
182
+ // declared type first and fall back to the default so one malformed payload
183
+ // can't blow up dispatch.
184
+ private fun ReadableMap.getIntOrDefault(key: String, default: Int): Int =
185
+ if (hasKey(key) && getType(key) == ReadableType.Number) getInt(key) else default
186
+
187
+ private fun ReadableMap.getDoubleOrDefault(key: String, default: Double): Double =
188
+ if (hasKey(key) && getType(key) == ReadableType.Number) getDouble(key) else default
189
+
172
190
  private fun ReadableMap.toStringMap(): Map<String, String> {
173
191
  val iter = keySetIterator()
174
192
  val out = LinkedHashMap<String, String>()
@@ -181,6 +199,16 @@ private fun ReadableMap.toStringMap(): Map<String, String> {
181
199
  return out
182
200
  }
183
201
 
202
+ private fun ReadableMap.getDoubleOrNull(key: String): Double? =
203
+ if (hasKey(key) && getType(key) == ReadableType.Number) getDouble(key) else null
204
+
205
+ private fun ReadableMap.toSamplingConfig(): BridgeSamplingConfig =
206
+ BridgeSamplingConfig(
207
+ strategy = SamplingStrategy.fromToken(getStringOrNull("strategy")),
208
+ normalRate = getDoubleOrNull("normalRate"),
209
+ highPriorityRate = getDoubleOrNull("highPriorityRate"),
210
+ )
211
+
184
212
  private fun ReadableMap.toAttributeMap(): Map<String, Any?> {
185
213
  val iter = keySetIterator()
186
214
  val out = LinkedHashMap<String, Any?>()
@@ -3,12 +3,81 @@ package com.dash0.mobile.reactnative
3
3
  import com.facebook.react.ReactPackage
4
4
  import com.facebook.react.bridge.NativeModule
5
5
  import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.modules.network.OkHttpClientFactory
7
+ import com.facebook.react.modules.network.OkHttpClientProvider
6
8
  import com.facebook.react.uimanager.ViewManager
9
+ import java.util.concurrent.atomic.AtomicBoolean
10
+ import okhttp3.OkHttpClient
7
11
 
8
12
  class Dash0MobilePackage : ReactPackage {
9
- override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
- listOf(Dash0MobileModule(reactContext))
13
+
14
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
15
+ // Install the OkHttp interceptor BEFORE any JS runs. createNativeModules
16
+ // is invoked during ReactInstanceManager bring-up, ahead of the JS
17
+ // bundle executing — which is exactly the requirement: expo/fetch (Expo
18
+ // SDK 52+) builds its OkHttp client off OkHttpClientProvider the first
19
+ // time JS issues a request, so the factory must already be replaced by
20
+ // then.
21
+ //
22
+ // The interceptor itself is a pass-through no-op until the sink arms it
23
+ // on `Dash0Mobile.start`, so installing it this early is safe even if
24
+ // the app never calls start().
25
+ installOkHttpInterceptor()
26
+ return listOf(Dash0MobileModule(reactContext))
27
+ }
11
28
 
12
29
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13
30
  emptyList()
31
+
32
+ private fun installOkHttpInterceptor() {
33
+ // Idempotent: createNativeModules can run more than once (JS reload /
34
+ // multiple ReactInstanceManagers). Install exactly once per process; the
35
+ // interceptor instance is a process-wide singleton, so one install
36
+ // covers every later ReactInstanceManager.
37
+ if (!installed.compareAndSet(false, true)) return
38
+ try {
39
+ // Set a factory that builds RN's DEFAULT-configured client and adds
40
+ // our interceptor on top.
41
+ //
42
+ // We use createClientBuilder() rather than capturing+delegating to a
43
+ // "previous" factory because react-native exposes no stable
44
+ // getOkHttpClientFactory() — it is absent in react-android 0.76 (the
45
+ // declared peer), so capturing the previous factory does not compile
46
+ // against the supported RN version. createClientBuilder() returns
47
+ // RN's default builder (timeouts, cookie jar, default interceptors),
48
+ // so we preserve RN's standard client config and only add ours. This
49
+ // is the documented RN interceptor-injection pattern. A host that had
50
+ // installed its OWN custom factory is replaced with default+interceptor
51
+ // (the unavoidable trade without a getter).
52
+ OkHttpClientProvider.setOkHttpClientFactory(
53
+ InterceptorInstallingFactory(NetworkInstrumentation.interceptor),
54
+ )
55
+ } catch (_: Throwable) {
56
+ // Installing instrumentation must NEVER crash host bring-up. If the
57
+ // RN networking module isn't present or the API shape changed, we
58
+ // ship without native network capture rather than taking the app
59
+ // down. Telemetry is always subordinate to the host. Reset the guard
60
+ // so a later attempt can retry.
61
+ installed.set(false)
62
+ }
63
+ }
64
+
65
+ private companion object {
66
+ private val installed = AtomicBoolean(false)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Builds RN's default-configured OkHttp client (via [OkHttpClientProvider.createClientBuilder])
72
+ * and adds the Dash0 network interceptor as an *application* interceptor — so it
73
+ * sees the logical request (redirects collapsed into one call) and injects
74
+ * `traceparent` once per logical request.
75
+ */
76
+ private class InterceptorInstallingFactory(
77
+ private val interceptor: OTelNetworkInterceptor,
78
+ ) : OkHttpClientFactory {
79
+ override fun createNewNetworkModuleClient(): OkHttpClient =
80
+ OkHttpClientProvider.createClientBuilder()
81
+ .addInterceptor(interceptor)
82
+ .build()
14
83
  }
@@ -0,0 +1,28 @@
1
+ /*
2
+ * NetworkInstrumentation — the single shared seam between the two halves of
3
+ * the Android network story that run at different times:
4
+ *
5
+ * 1. [Dash0MobilePackage.createNativeModules] (pre-JS): creates the
6
+ * [OTelNetworkInterceptor] and registers it on React Native's shared
7
+ * OkHttp client via OkHttpClientProvider. This MUST happen before any JS
8
+ * runs so expo/fetch's OkHttp client picks it up.
9
+ *
10
+ * 2. [OTelMobileCallSink.start] (when JS calls `Dash0Mobile.start`): the
11
+ * tracer only exists after OTelMobile has been started, so this is where
12
+ * the interceptor is armed with the tracer + collector-host ignore list.
13
+ *
14
+ * A process-wide singleton bridges the two: the package installs it, the sink
15
+ * arms it. Splitting "install" from "arm" is what keeps the interceptor a safe
16
+ * pass-through no-op for the window between process launch and `start()`.
17
+ */
18
+ package com.dash0.mobile.reactnative
19
+
20
+ object NetworkInstrumentation {
21
+ /**
22
+ * The one interceptor instance registered on the OkHttp pipeline. Created
23
+ * lazily and held for the process lifetime — the OkHttp client factory
24
+ * captures this exact reference, and [OTelMobileCallSink] arms/disarms the
25
+ * same reference on start/shutdown.
26
+ */
27
+ val interceptor: OTelNetworkInterceptor by lazy { OTelNetworkInterceptor() }
28
+ }
@@ -10,6 +10,7 @@ package com.dash0.mobile.reactnative
10
10
  import android.content.Context
11
11
  import io.opentelemetry.android.mobile.OTelMobile
12
12
  import io.opentelemetry.android.mobile.config.MobileConfig
13
+ import io.opentelemetry.android.mobile.sampling.SamplingConfig
13
14
  import io.opentelemetry.api.common.AttributeKey
14
15
  import io.opentelemetry.api.common.Attributes
15
16
  import io.opentelemetry.api.common.AttributesBuilder
@@ -23,6 +24,14 @@ internal class OTelMobileCallSink(
23
24
 
24
25
  private val liveSpans = HashMap<String, io.opentelemetry.api.trace.Span>()
25
26
 
27
+ // Async gauges must be registered exactly ONCE per metric name. Calling
28
+ // buildWithCallback on every event leaks a new never-closed observable +
29
+ // a retained closure each time. We register one observable gauge per name
30
+ // and have its callback report the latest value/attributes stored here.
31
+ private val gaugeRegistered = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
32
+ private val gaugeLatest =
33
+ java.util.concurrent.ConcurrentHashMap<String, Pair<Double, Attributes>>()
34
+
26
35
  override fun start(config: StartConfig) {
27
36
  val app = appContext.applicationContext as android.app.Application
28
37
  // Build optional auth + dataset headers. Dash0's OTLP/HTTP ingress
@@ -48,11 +57,45 @@ internal class OTelMobileCallSink(
48
57
  // time. CONDITIONAL buffers spans for up to 1 hour, which is
49
58
  // surprising behavior for a JS dev who just called startSpan().
50
59
  exportMode = io.opentelemetry.android.mobile.config.ExportMode.CONTINUOUS,
60
+ // RN sampling default is ALWAYS_ON (Loper finding #4): RN manual
61
+ // spans are root spans with arbitrary names, so the native SDK's
62
+ // dynamic(0.1) default would silently drop ~90% of a user's first
63
+ // span. The JS bridge sends always_on unless the caller opts into
64
+ // sampling; rate-limiting for RN belongs in the collector.
65
+ samplingConfig = samplingConfigOf(config.sampling),
51
66
  extraResourceAttributes = config.extraResourceAttributes,
52
67
  ),
53
68
  )
69
+
70
+ // Arm the native OkHttp interceptor now that OTelMobile is up and a
71
+ // tracer exists. The interceptor was installed on RN's OkHttp client
72
+ // pre-JS (Dash0MobilePackage) but stayed a pass-through no-op until
73
+ // this moment. Pass the collector endpoint so the interceptor adds our
74
+ // own ingress host to its ignore list — we must never instrument (or
75
+ // recurse on) telemetry exports. Wrapped defensively: a failure to arm
76
+ // network capture must not fail `Dash0Mobile.start`.
77
+ try {
78
+ NetworkInstrumentation.interceptor.arm(
79
+ tracer = OTelMobile.getTracer(SCOPE),
80
+ collectorEndpoint = config.endpoint,
81
+ )
82
+ } catch (_: Throwable) {
83
+ // No native network capture this session — but start() still
84
+ // succeeds and JS-side telemetry keeps working.
85
+ }
54
86
  }
55
87
 
88
+ private fun samplingConfigOf(sampling: BridgeSamplingConfig?): SamplingConfig =
89
+ when (sampling?.strategy) {
90
+ null,
91
+ SamplingStrategy.ALWAYS_ON -> SamplingConfig.alwaysOn()
92
+ SamplingStrategy.ALWAYS_OFF -> SamplingConfig.alwaysOff()
93
+ SamplingStrategy.DYNAMIC -> SamplingConfig.dynamic(
94
+ normalRate = sampling.normalRate ?: 0.05,
95
+ highPriorityRate = sampling.highPriorityRate ?: 1.0,
96
+ )
97
+ }
98
+
56
99
  override fun emitLog(
57
100
  name: String,
58
101
  severity: Int,
@@ -119,7 +162,18 @@ internal class OTelMobileCallSink(
119
162
  when (instrumentType) {
120
163
  "counter" -> meter.counterBuilder(name).build().add(value.toLong(), otelAttrs)
121
164
  "histogram" -> meter.histogramBuilder(name).build().record(value, otelAttrs)
122
- "gauge" -> meter.gaugeBuilder(name).buildWithCallback { obs -> obs.record(value, otelAttrs) }
165
+ "gauge" -> {
166
+ // Stash the latest reading, then register the observable gauge
167
+ // ONCE per name. The callback re-reads from gaugeLatest on each
168
+ // collection so we don't leak a closure/instrument per event.
169
+ gaugeLatest[name] = value to otelAttrs
170
+ gaugeRegistered.computeIfAbsent(name) { gaugeName ->
171
+ meter.gaugeBuilder(gaugeName).buildWithCallback { obs ->
172
+ gaugeLatest[gaugeName]?.let { (v, a) -> obs.record(v, a) }
173
+ }
174
+ true
175
+ }
176
+ }
123
177
  else -> Unit
124
178
  }
125
179
  }
@@ -135,6 +189,12 @@ internal class OTelMobileCallSink(
135
189
  }
136
190
 
137
191
  override fun shutdown() {
192
+ // Return the interceptor to pass-through BEFORE stopping the SDK so no
193
+ // in-flight request tries to start a span on a torn-down tracer.
194
+ try {
195
+ NetworkInstrumentation.interceptor.disarm()
196
+ } catch (_: Throwable) {
197
+ }
138
198
  OTelMobile.stop()
139
199
  }
140
200
 
@@ -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 }