@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
|
@@ -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
|
-
|
|
62
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
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" ->
|
|
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
|
|
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 }
|