@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dash0Mobile.podspec +4 -0
- package/README.md +113 -28
- package/android/build.gradle +73 -6
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/android/gradle.properties +27 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +62 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
- package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
- package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
- package/ios/BoundedLiveSpanStore.swift +138 -0
- package/ios/BridgeCallSink.swift +49 -1
- package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
- package/ios/OTelMobileCallSink.swift +205 -34
- package/ios/RCTDash0MobileModule.swift +10 -2
- package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
- package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
- package/lib/bridge/types.d.ts +35 -0
- package/lib/bridge/types.d.ts.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +42 -2
- package/lib/instrumentation/errors.d.ts.map +1 -1
- package/lib/instrumentation/errors.js +13 -2
- package/lib/instrumentation/fetch.d.ts.map +1 -1
- package/lib/instrumentation/fetch.js +58 -22
- package/lib/instrumentation/navigation.d.ts.map +1 -1
- package/lib/instrumentation/navigation.js +32 -8
- package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
- package/lib/instrumentation/unhandledRejection.js +13 -3
- package/lib/instrumentation/xhr.d.ts.map +1 -1
- package/lib/instrumentation/xhr.js +63 -36
- package/lib/redact.d.ts +30 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +67 -0
- package/package.json +1 -1
- package/src/bridge/types.ts +36 -0
- package/src/index.ts +44 -3
- package/src/instrumentation/errors.ts +12 -2
- package/src/instrumentation/fetch.ts +60 -27
- package/src/instrumentation/navigation.ts +40 -8
- package/src/instrumentation/unhandledRejection.ts +12 -3
- package/src/instrumentation/xhr.ts +65 -40
- package/src/redact.ts +71 -0
|
@@ -0,0 +1,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
|
-
|
|
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
|
|