@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,94 @@
1
+ @rem
2
+ @rem Copyright 2015 the original author or authors.
3
+ @rem
4
+ @rem Licensed under the Apache License, Version 2.0 (the "License");
5
+ @rem you may not use this file except in compliance with the License.
6
+ @rem You may obtain a copy of the License at
7
+ @rem
8
+ @rem https://www.apache.org/licenses/LICENSE-2.0
9
+ @rem
10
+ @rem Unless required by applicable law or agreed to in writing, software
11
+ @rem distributed under the License is distributed on an "AS IS" BASIS,
12
+ @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ @rem See the License for the specific language governing permissions and
14
+ @rem limitations under the License.
15
+ @rem
16
+ @rem SPDX-License-Identifier: Apache-2.0
17
+ @rem
18
+
19
+ @if "%DEBUG%"=="" @echo off
20
+ @rem ##########################################################################
21
+ @rem
22
+ @rem Gradle startup script for Windows
23
+ @rem
24
+ @rem ##########################################################################
25
+
26
+ @rem Set local scope for the variables with windows NT shell
27
+ if "%OS%"=="Windows_NT" setlocal
28
+
29
+ set DIRNAME=%~dp0
30
+ if "%DIRNAME%"=="" set DIRNAME=.
31
+ @rem This is normally unused
32
+ set APP_BASE_NAME=%~n0
33
+ set APP_HOME=%DIRNAME%
34
+
35
+ @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36
+ for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37
+
38
+ @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39
+ set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40
+
41
+ @rem Find java.exe
42
+ if defined JAVA_HOME goto findJavaFromJavaHome
43
+
44
+ set JAVA_EXE=java.exe
45
+ %JAVA_EXE% -version >NUL 2>&1
46
+ if %ERRORLEVEL% equ 0 goto execute
47
+
48
+ echo. 1>&2
49
+ echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50
+ echo. 1>&2
51
+ echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52
+ echo location of your Java installation. 1>&2
53
+
54
+ goto fail
55
+
56
+ :findJavaFromJavaHome
57
+ set JAVA_HOME=%JAVA_HOME:"=%
58
+ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59
+
60
+ if exist "%JAVA_EXE%" goto execute
61
+
62
+ echo. 1>&2
63
+ echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64
+ echo. 1>&2
65
+ echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66
+ echo location of your Java installation. 1>&2
67
+
68
+ goto fail
69
+
70
+ :execute
71
+ @rem Setup the command line
72
+
73
+ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74
+
75
+
76
+ @rem Execute Gradle
77
+ "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78
+
79
+ :end
80
+ @rem End local scope for the variables with windows NT shell
81
+ if %ERRORLEVEL% equ 0 goto mainEnd
82
+
83
+ :fail
84
+ rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85
+ rem the _cmd.exe /c_ return code!
86
+ set EXIT_CODE=%ERRORLEVEL%
87
+ if %EXIT_CODE% equ 0 set EXIT_CODE=1
88
+ if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89
+ exit /b %EXIT_CODE%
90
+
91
+ :mainEnd
92
+ if "%OS%"=="Windows_NT" endlocal
93
+
94
+ :omega
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Standalone Gradle settings for the @dash0/mobile-react-native Android module.
3
+ *
4
+ * WHY THIS EXISTS:
5
+ * When consumed by a host React Native app, this module is wired into the
6
+ * host's Gradle build via RN autolinking — the host buildscript supplies the
7
+ * Android Gradle Plugin + Kotlin plugin versions and the repositories. That
8
+ * means the module's Kotlin unit tests (src/test/**) ran NOWHERE in isolation;
9
+ * CI's react-native job was jest-only and the native interceptor that wraps
10
+ * the host's entire OkHttp pipeline shipped without a runnable test.
11
+ *
12
+ * This file makes the module a self-contained Gradle build so
13
+ * `./gradlew :testDebugUnitTest` runs the src/test Kotlin on a pure JVM with
14
+ * no emulator. It supplies exactly what autolinking would otherwise inject:
15
+ * the plugin versions (pluginManagement) and the dependency repositories,
16
+ * including Maven Central where React Native publishes `react-android` and
17
+ * where the OkHttp / OpenTelemetry artifacts live.
18
+ *
19
+ * The shipping `build.gradle` is unchanged in spirit — it still applies the
20
+ * AGP + Kotlin plugins by id without hardcoding classpath versions, exactly
21
+ * as a host-autolinked build expects. The versions are pinned HERE so they
22
+ * only affect the standalone test build, never a host consumer.
23
+ */
24
+ pluginManagement {
25
+ repositories {
26
+ google()
27
+ mavenCentral()
28
+ gradlePluginPortal()
29
+ }
30
+ plugins {
31
+ // AGP 8.11.x runs on the demo-app's Gradle 9.1.0 (and thus JDK 25)
32
+ // while still using the standalone kotlin-android plugin. AGP 9.x was
33
+ // rejected here: its built-in Kotlin integration registers its own
34
+ // `kotlin` extension and clashes with org.jetbrains.kotlin.android
35
+ // ("extension already registered with name 'kotlin'").
36
+ id 'com.android.library' version '8.11.1'
37
+ id 'org.jetbrains.kotlin.android' version '2.1.20'
38
+ }
39
+ }
40
+
41
+ dependencyResolutionManagement {
42
+ // The module's own build.gradle declares google()/mavenCentral() in a
43
+ // `repositories {}` block (it must, to stay autolinking-friendly), so do
44
+ // NOT fail on project repos here.
45
+ repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
46
+ repositories {
47
+ google()
48
+ mavenCentral()
49
+ }
50
+ }
51
+
52
+ rootProject.name = 'dash0-mobile-react-native-android'
53
+
54
+ // react-android 0.76 + the OTel SDK pull androidx.core 1.17.x transitively,
55
+ // which requires compiling against android-36. The shipping build.gradle reads
56
+ // the compileSdk/targetSdk from rootProject.ext (defaulting to 34 for old host
57
+ // consumers); set them here for the standalone test build only, without
58
+ // touching that host-facing default.
59
+ gradle.rootProject {
60
+ ext.compileSdkVersion = 36
61
+ ext.targetSdkVersion = 36
62
+ }
@@ -64,4 +64,46 @@ data class StartConfig(
64
64
  * sink will translate them the same way the iOS sink does today.
65
65
  */
66
66
  val nativeAutoCapture: List<String> = emptyList(),
67
+ /**
68
+ * Trace sampling strategy from the JS caller, mapped onto the native
69
+ * [io.opentelemetry.android.mobile.sampling.SamplingConfig].
70
+ *
71
+ * The RN bridge defaults this to [SamplingStrategy.ALWAYS_ON] when the
72
+ * JS caller omits `sampling`, rather than inheriting the native SDK's
73
+ * `dynamic(0.1)` default. RN manual spans are root spans with arbitrary
74
+ * names, so a 10% baseline silently drops ~90% of a user's first span
75
+ * (Loper finding #4). Null here means "caller said nothing" — the sink
76
+ * leaves the native default untouched, but in practice the JS bridge
77
+ * always sends a value.
78
+ */
79
+ val sampling: BridgeSamplingConfig? = null,
67
80
  )
81
+
82
+ /**
83
+ * Bridge-side mirror of the JS `SamplingConfig`. Decoded from the RN
84
+ * `start()` payload and translated to the native SDK's `SamplingConfig` in
85
+ * [OTelMobileCallSink.start].
86
+ */
87
+ data class BridgeSamplingConfig(
88
+ val strategy: SamplingStrategy,
89
+ /** Baseline rate for [SamplingStrategy.DYNAMIC]. Null = native default. */
90
+ val normalRate: Double? = null,
91
+ /** High-priority rate for [SamplingStrategy.DYNAMIC]. Null = native default. */
92
+ val highPriorityRate: Double? = null,
93
+ )
94
+
95
+ enum class SamplingStrategy {
96
+ ALWAYS_ON,
97
+ ALWAYS_OFF,
98
+ DYNAMIC,
99
+ ;
100
+
101
+ companion object {
102
+ /** Maps the JS `strategy` string; unknown values fall back to ALWAYS_ON (RN default). */
103
+ fun fromToken(raw: String?): SamplingStrategy = when (raw) {
104
+ "always_off" -> ALWAYS_OFF
105
+ "dynamic" -> DYNAMIC
106
+ else -> ALWAYS_ON
107
+ }
108
+ }
109
+ }
@@ -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