@barrysolomon/mobile-react-native 0.1.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 +29 -0
- package/README.md +117 -0
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +67 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +198 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +14 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +208 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +413 -0
- package/ios/BridgeCallSink.swift +83 -0
- package/ios/Dash0MobileBridgeDispatcher.swift +142 -0
- package/ios/OTelMobileCallSink.swift +262 -0
- package/ios/RCTDash0MobileModule.m +28 -0
- package/ios/RCTDash0MobileModule.swift +104 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +341 -0
- package/lib/src/NativeDash0Mobile.d.ts +27 -0
- package/lib/src/NativeDash0Mobile.d.ts.map +1 -0
- package/lib/src/NativeDash0Mobile.js +19 -0
- package/lib/src/bridge/NativeBridge.d.ts +38 -0
- package/lib/src/bridge/NativeBridge.d.ts.map +1 -0
- package/lib/src/bridge/NativeBridge.js +95 -0
- package/lib/src/bridge/types.d.ts +166 -0
- package/lib/src/bridge/types.d.ts.map +1 -0
- package/lib/src/bridge/types.js +10 -0
- package/lib/src/index.d.ts +35 -0
- package/lib/src/index.d.ts.map +1 -0
- package/lib/src/index.js +408 -0
- package/lib/src/instrumentation/errors.d.ts +14 -0
- package/lib/src/instrumentation/errors.d.ts.map +1 -0
- package/lib/src/instrumentation/errors.js +65 -0
- package/lib/src/instrumentation/fetch.d.ts +16 -0
- package/lib/src/instrumentation/fetch.d.ts.map +1 -0
- package/lib/src/instrumentation/fetch.js +75 -0
- package/lib/src/instrumentation/navigation.d.ts +19 -0
- package/lib/src/instrumentation/navigation.d.ts.map +1 -0
- package/lib/src/instrumentation/navigation.js +39 -0
- package/lib/src/instrumentation/touch.d.ts +12 -0
- package/lib/src/instrumentation/touch.d.ts.map +1 -0
- package/lib/src/instrumentation/touch.js +18 -0
- package/lib/src/instrumentation/unhandledRejection.d.ts +9 -0
- package/lib/src/instrumentation/unhandledRejection.d.ts.map +1 -0
- package/lib/src/instrumentation/unhandledRejection.js +52 -0
- package/lib/src/instrumentation/xhr.d.ts +14 -0
- package/lib/src/instrumentation/xhr.d.ts.map +1 -0
- package/lib/src/instrumentation/xhr.js +88 -0
- package/lib/src/otel-compat.d.ts +67 -0
- package/lib/src/otel-compat.d.ts.map +1 -0
- package/lib/src/otel-compat.js +84 -0
- package/package.json +72 -0
- package/react-native.config.js +17 -0
- package/src/NativeDash0Mobile.ts +29 -0
- package/src/bridge/NativeBridge.ts +101 -0
- package/src/bridge/types.ts +188 -0
- package/src/index.ts +456 -0
- package/src/instrumentation/errors.ts +84 -0
- package/src/instrumentation/fetch.ts +93 -0
- package/src/instrumentation/navigation.ts +52 -0
- package/src/instrumentation/touch.ts +32 -0
- package/src/instrumentation/unhandledRejection.ts +75 -0
- package/src/instrumentation/xhr.ts +125 -0
- package/src/otel-compat.ts +159 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Production BridgeCallSink. Forwards RN bridge calls into the existing
|
|
3
|
+
* io.opentelemetry.android.mobile.OTelMobile SDK.
|
|
4
|
+
*
|
|
5
|
+
* Kept in a separate file from Dash0MobileModule so the module's unit tests
|
|
6
|
+
* can run under pure JVM (JUnit) without needing to stand up OTelMobile.
|
|
7
|
+
*/
|
|
8
|
+
package com.dash0.mobile.reactnative
|
|
9
|
+
|
|
10
|
+
import android.content.Context
|
|
11
|
+
import io.opentelemetry.android.mobile.OTelMobile
|
|
12
|
+
import io.opentelemetry.android.mobile.config.MobileConfig
|
|
13
|
+
import io.opentelemetry.api.common.AttributeKey
|
|
14
|
+
import io.opentelemetry.api.common.Attributes
|
|
15
|
+
import io.opentelemetry.api.common.AttributesBuilder
|
|
16
|
+
import io.opentelemetry.api.logs.Severity
|
|
17
|
+
import io.opentelemetry.api.trace.SpanKind
|
|
18
|
+
import io.opentelemetry.api.trace.StatusCode
|
|
19
|
+
|
|
20
|
+
internal class OTelMobileCallSink(
|
|
21
|
+
private val appContext: Context,
|
|
22
|
+
) : BridgeCallSink {
|
|
23
|
+
|
|
24
|
+
private val liveSpans = HashMap<String, io.opentelemetry.api.trace.Span>()
|
|
25
|
+
|
|
26
|
+
override fun start(config: StartConfig) {
|
|
27
|
+
val app = appContext.applicationContext as android.app.Application
|
|
28
|
+
// Build optional auth + dataset headers. Dash0's OTLP/HTTP ingress
|
|
29
|
+
// requires Bearer auth + the Dash0-Dataset routing header.
|
|
30
|
+
val headers = buildMap<String, String> {
|
|
31
|
+
config.authToken?.takeIf { it.isNotBlank() }?.let {
|
|
32
|
+
put("Authorization", "Bearer $it")
|
|
33
|
+
}
|
|
34
|
+
config.dataset?.takeIf { it.isNotBlank() }?.let {
|
|
35
|
+
put("Dash0-Dataset", it)
|
|
36
|
+
}
|
|
37
|
+
}.takeIf { it.isNotEmpty() }
|
|
38
|
+
|
|
39
|
+
OTelMobile.start(
|
|
40
|
+
app,
|
|
41
|
+
MobileConfig(
|
|
42
|
+
serviceName = config.serviceName,
|
|
43
|
+
serviceVersion = config.serviceVersion ?: "unknown",
|
|
44
|
+
collectorEndpoint = config.endpoint,
|
|
45
|
+
headers = headers,
|
|
46
|
+
// RN consumers don't have the same battery concerns as native —
|
|
47
|
+
// default to CONTINUOUS so traces + metrics flow in reasonable
|
|
48
|
+
// time. CONDITIONAL buffers spans for up to 1 hour, which is
|
|
49
|
+
// surprising behavior for a JS dev who just called startSpan().
|
|
50
|
+
exportMode = io.opentelemetry.android.mobile.config.ExportMode.CONTINUOUS,
|
|
51
|
+
extraResourceAttributes = config.extraResourceAttributes,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override fun emitLog(
|
|
57
|
+
name: String,
|
|
58
|
+
severity: Int,
|
|
59
|
+
attributes: Map<String, Any?>,
|
|
60
|
+
timeUnixNano: Long,
|
|
61
|
+
) {
|
|
62
|
+
OTelMobile.getLogger(SCOPE)
|
|
63
|
+
.logRecordBuilder()
|
|
64
|
+
.setBody(name)
|
|
65
|
+
.setSeverity(severityOf(severity))
|
|
66
|
+
.setAllAttributes(attributes.toOtelAttributes())
|
|
67
|
+
.emit()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun startSpan(
|
|
71
|
+
spanId: String,
|
|
72
|
+
parentSpanId: String?,
|
|
73
|
+
name: String,
|
|
74
|
+
spanKind: String,
|
|
75
|
+
attributes: Map<String, Any?>,
|
|
76
|
+
startTimeUnixNano: Long,
|
|
77
|
+
) {
|
|
78
|
+
val parent = if (parentSpanId != null) synchronized(liveSpans) { liveSpans[parentSpanId] } else null
|
|
79
|
+
val builder = OTelMobile.getTracer(SCOPE)
|
|
80
|
+
.spanBuilder(name)
|
|
81
|
+
.setSpanKind(spanKindOf(spanKind))
|
|
82
|
+
.setAllAttributes(attributes.toOtelAttributes())
|
|
83
|
+
// Propagate parent context so Dash0 renders the waterfall instead
|
|
84
|
+
// of 14 orphan spans per checkout. `setParent` with an active span
|
|
85
|
+
// is Context.current()-free and works regardless of thread.
|
|
86
|
+
if (parent != null) {
|
|
87
|
+
builder.setParent(io.opentelemetry.context.Context.current().with(parent))
|
|
88
|
+
}
|
|
89
|
+
val span = builder.startSpan()
|
|
90
|
+
synchronized(liveSpans) { liveSpans[spanId] = span }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override fun endSpan(
|
|
94
|
+
spanId: String,
|
|
95
|
+
status: String,
|
|
96
|
+
statusMessage: String?,
|
|
97
|
+
attributes: Map<String, Any?>,
|
|
98
|
+
endTimeUnixNano: Long,
|
|
99
|
+
) {
|
|
100
|
+
val span = synchronized(liveSpans) { liveSpans.remove(spanId) } ?: return
|
|
101
|
+
span.setAllAttributes(attributes.toOtelAttributes())
|
|
102
|
+
when (status) {
|
|
103
|
+
"OK" -> span.setStatus(StatusCode.OK)
|
|
104
|
+
"ERROR" -> span.setStatus(StatusCode.ERROR, statusMessage ?: "")
|
|
105
|
+
else -> Unit
|
|
106
|
+
}
|
|
107
|
+
span.end()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override fun recordMetric(
|
|
111
|
+
name: String,
|
|
112
|
+
instrumentType: String,
|
|
113
|
+
value: Double,
|
|
114
|
+
attributes: Map<String, Any?>,
|
|
115
|
+
timeUnixNano: Long,
|
|
116
|
+
) {
|
|
117
|
+
val meter = OTelMobile.getMeter(SCOPE)
|
|
118
|
+
val otelAttrs = attributes.toOtelAttributes()
|
|
119
|
+
when (instrumentType) {
|
|
120
|
+
"counter" -> meter.counterBuilder(name).build().add(value.toLong(), otelAttrs)
|
|
121
|
+
"histogram" -> meter.histogramBuilder(name).build().record(value, otelAttrs)
|
|
122
|
+
"gauge" -> meter.gaugeBuilder(name).buildWithCallback { obs -> obs.record(value, otelAttrs) }
|
|
123
|
+
else -> Unit
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
override fun flushWindow(minutes: Int) {
|
|
128
|
+
try {
|
|
129
|
+
OTelMobile.getLoggerProvider().getMobileProcessor().flushWindow(minutes)
|
|
130
|
+
} catch (_: Throwable) {
|
|
131
|
+
// Bridge contract: flush failures must not surface as JS promise
|
|
132
|
+
// rejections. The SDK's own retry/disk-buffer path handles
|
|
133
|
+
// recovery; the RN caller just wants best-effort flush.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override fun shutdown() {
|
|
138
|
+
OTelMobile.stop()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Synchronous drain through the OTLP exporter, persisting any
|
|
143
|
+
* in-flight records to disk on export failure. Invoked by
|
|
144
|
+
* [Dash0MobileModule.dispatch] after a FATAL-severity (severity ≥ 21)
|
|
145
|
+
* log emit so the crash payload lands in Dash0 even when the JS
|
|
146
|
+
* fatal reporter terminates the process before the periodic flush
|
|
147
|
+
* timer fires.
|
|
148
|
+
*
|
|
149
|
+
* Mirrors iOS commit `39bd258` (library-level FATAL forceFlush).
|
|
150
|
+
*/
|
|
151
|
+
override fun forceFlush() {
|
|
152
|
+
try {
|
|
153
|
+
OTelMobile.getLoggerProvider().getMobileProcessor().forceFlush()
|
|
154
|
+
} catch (_: Throwable) {
|
|
155
|
+
// Best-effort: a forceFlush failure on the FATAL path must
|
|
156
|
+
// never throw out of the dispatcher (we'd lose the rest of
|
|
157
|
+
// the batch). The SDK's own retry/disk-buffer handles any
|
|
158
|
+
// export failure under the hood.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
companion object {
|
|
163
|
+
private const val SCOPE = "io.dash0.mobile.reactnative"
|
|
164
|
+
|
|
165
|
+
private fun severityOf(n: Int): Severity = when (n) {
|
|
166
|
+
1 -> Severity.TRACE
|
|
167
|
+
5 -> Severity.DEBUG
|
|
168
|
+
9 -> Severity.INFO
|
|
169
|
+
13 -> Severity.WARN
|
|
170
|
+
17 -> Severity.ERROR
|
|
171
|
+
21 -> Severity.FATAL
|
|
172
|
+
else -> Severity.INFO
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun spanKindOf(s: String): SpanKind = when (s) {
|
|
176
|
+
"CLIENT" -> SpanKind.CLIENT
|
|
177
|
+
"SERVER" -> SpanKind.SERVER
|
|
178
|
+
"PRODUCER" -> SpanKind.PRODUCER
|
|
179
|
+
"CONSUMER" -> SpanKind.CONSUMER
|
|
180
|
+
else -> SpanKind.INTERNAL
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private fun Map<String, Any?>.toOtelAttributes(): Attributes {
|
|
184
|
+
val b: AttributesBuilder = Attributes.builder()
|
|
185
|
+
for ((k, v) in this) {
|
|
186
|
+
when (v) {
|
|
187
|
+
is String -> b.put(AttributeKey.stringKey(k), v)
|
|
188
|
+
is Boolean -> b.put(AttributeKey.booleanKey(k), v)
|
|
189
|
+
is Double -> {
|
|
190
|
+
// Route integer-valued doubles to long keys so downstream
|
|
191
|
+
// consumers see `qty=2`, not `qty=2.0`. OTel attribute
|
|
192
|
+
// conventions use long for whole-number counts.
|
|
193
|
+
if (v == v.toLong().toDouble()) {
|
|
194
|
+
b.put(AttributeKey.longKey(k), v.toLong())
|
|
195
|
+
} else {
|
|
196
|
+
b.put(AttributeKey.doubleKey(k), v)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
is Long -> b.put(AttributeKey.longKey(k), v)
|
|
200
|
+
is Int -> b.put(AttributeKey.longKey(k), v.toLong())
|
|
201
|
+
null -> Unit
|
|
202
|
+
else -> b.put(AttributeKey.stringKey(k), v.toString())
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return b.build()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* RN-012 unit tests for Dash0MobileModule.
|
|
3
|
+
*
|
|
4
|
+
* These tests feed ReadableMap/ReadableArray fixtures into the module and
|
|
5
|
+
* assert the corresponding BridgeCallSink methods are called with the right
|
|
6
|
+
* arguments. A fake sink is used so we don't need the real OTelMobile SDK,
|
|
7
|
+
* an Android emulator, or any RN runtime.
|
|
8
|
+
*
|
|
9
|
+
* The cross-bridge contract is the only thing under test here — the OTel
|
|
10
|
+
* wiring is verified separately in the Android SDK's own test suite.
|
|
11
|
+
*/
|
|
12
|
+
package com.dash0.mobile.reactnative
|
|
13
|
+
|
|
14
|
+
import com.facebook.react.bridge.JavaOnlyArray
|
|
15
|
+
import com.facebook.react.bridge.JavaOnlyMap
|
|
16
|
+
import com.facebook.react.bridge.Promise
|
|
17
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
18
|
+
import org.junit.Assert.assertEquals
|
|
19
|
+
import org.junit.Assert.assertNull
|
|
20
|
+
import org.junit.Assert.assertTrue
|
|
21
|
+
import org.junit.Before
|
|
22
|
+
import org.junit.Test
|
|
23
|
+
import org.mockito.Mockito.mock
|
|
24
|
+
|
|
25
|
+
class Dash0MobileModuleTest {
|
|
26
|
+
|
|
27
|
+
private lateinit var sink: RecordingSink
|
|
28
|
+
private lateinit var module: Dash0MobileModule
|
|
29
|
+
|
|
30
|
+
@Before
|
|
31
|
+
fun setUp() {
|
|
32
|
+
sink = RecordingSink()
|
|
33
|
+
val ctx = mock(ReactApplicationContext::class.java)
|
|
34
|
+
module = Dash0MobileModule(ctx, sink)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── start ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun start_forwards_required_and_optional_fields() {
|
|
41
|
+
val cfg = JavaOnlyMap.of(
|
|
42
|
+
"serviceName", "otel-rn-astronomy-shop",
|
|
43
|
+
"endpoint", "https://ingress/v1/logs",
|
|
44
|
+
"serviceVersion", "1.2.3",
|
|
45
|
+
"authToken", "tok",
|
|
46
|
+
"dataset", "otel-mobile",
|
|
47
|
+
)
|
|
48
|
+
val promise = RecordingPromise()
|
|
49
|
+
|
|
50
|
+
module.start(cfg, promise)
|
|
51
|
+
|
|
52
|
+
assertEquals(1, sink.starts.size)
|
|
53
|
+
val got = sink.starts[0]
|
|
54
|
+
assertEquals("otel-rn-astronomy-shop", got.serviceName)
|
|
55
|
+
assertEquals("https://ingress/v1/logs", got.endpoint)
|
|
56
|
+
assertEquals("1.2.3", got.serviceVersion)
|
|
57
|
+
assertEquals("tok", got.authToken)
|
|
58
|
+
assertEquals("otel-mobile", got.dataset)
|
|
59
|
+
assertTrue(promise.resolved)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun start_forwards_nativeAutoCapture_tokens() {
|
|
64
|
+
val cfg = JavaOnlyMap.of(
|
|
65
|
+
"serviceName", "s",
|
|
66
|
+
"endpoint", "https://e",
|
|
67
|
+
"nativeAutoCapture", JavaOnlyArray.of("vitals", "deviceStats"),
|
|
68
|
+
)
|
|
69
|
+
val promise = RecordingPromise()
|
|
70
|
+
module.start(cfg, promise)
|
|
71
|
+
assertEquals(listOf("vitals", "deviceStats"), sink.starts[0].nativeAutoCapture)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
fun start_defaults_nativeAutoCapture_to_empty_when_absent() {
|
|
76
|
+
val cfg = JavaOnlyMap.of(
|
|
77
|
+
"serviceName", "s",
|
|
78
|
+
"endpoint", "https://e",
|
|
79
|
+
)
|
|
80
|
+
val promise = RecordingPromise()
|
|
81
|
+
module.start(cfg, promise)
|
|
82
|
+
assertTrue(sink.starts[0].nativeAutoCapture.isEmpty())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun start_rejects_when_serviceName_missing() {
|
|
87
|
+
val cfg = JavaOnlyMap.of("endpoint", "e")
|
|
88
|
+
val promise = RecordingPromise()
|
|
89
|
+
module.start(cfg, promise)
|
|
90
|
+
assertTrue(promise.rejected)
|
|
91
|
+
assertEquals(0, sink.starts.size)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── emitBatch: log ───────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun emitBatch_dispatches_log_payload() {
|
|
98
|
+
val payload = JavaOnlyMap.of(
|
|
99
|
+
"kind", "log",
|
|
100
|
+
"name", "cart.add_item",
|
|
101
|
+
"severity", 9.0,
|
|
102
|
+
"timeUnixNano", "1713600000000000000",
|
|
103
|
+
"attributes", JavaOnlyMap.of(
|
|
104
|
+
"shop.item_id", "abc",
|
|
105
|
+
"qty", 2.0,
|
|
106
|
+
"urgent", true,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
val batch = JavaOnlyArray.of(payload)
|
|
110
|
+
val promise = RecordingPromise()
|
|
111
|
+
|
|
112
|
+
module.emitBatch(batch, promise)
|
|
113
|
+
|
|
114
|
+
assertEquals(1, sink.logs.size)
|
|
115
|
+
val log = sink.logs[0]
|
|
116
|
+
assertEquals("cart.add_item", log.name)
|
|
117
|
+
assertEquals(9, log.severity)
|
|
118
|
+
assertEquals(1713600000000000000L, log.timeUnixNano)
|
|
119
|
+
assertEquals("abc", log.attributes["shop.item_id"])
|
|
120
|
+
assertEquals(2.0, log.attributes["qty"] as Double, 0.0)
|
|
121
|
+
assertEquals(true, log.attributes["urgent"])
|
|
122
|
+
assertTrue(promise.resolved)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── emitBatch: span pair ─────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
@Test
|
|
128
|
+
fun emitBatch_dispatches_spanStart_and_spanEnd_with_matching_spanId() {
|
|
129
|
+
val spanStart = JavaOnlyMap.of(
|
|
130
|
+
"kind", "spanStart",
|
|
131
|
+
"spanId", "abc123def456789a",
|
|
132
|
+
"name", "checkout",
|
|
133
|
+
"spanKind", "INTERNAL",
|
|
134
|
+
"startTimeUnixNano", "1713600000000000000",
|
|
135
|
+
"attributes", JavaOnlyMap.of("shop.cart_size", 3.0),
|
|
136
|
+
)
|
|
137
|
+
val spanEnd = JavaOnlyMap.of(
|
|
138
|
+
"kind", "spanEnd",
|
|
139
|
+
"spanId", "abc123def456789a",
|
|
140
|
+
"status", "OK",
|
|
141
|
+
"endTimeUnixNano", "1713600000050000000",
|
|
142
|
+
"attributes", JavaOnlyMap.of("http.response.status_code", 200.0),
|
|
143
|
+
)
|
|
144
|
+
val batch = JavaOnlyArray.of(spanStart, spanEnd)
|
|
145
|
+
|
|
146
|
+
module.emitBatch(batch, RecordingPromise())
|
|
147
|
+
|
|
148
|
+
assertEquals(1, sink.spanStarts.size)
|
|
149
|
+
assertEquals(1, sink.spanEnds.size)
|
|
150
|
+
assertEquals(sink.spanStarts[0].spanId, sink.spanEnds[0].spanId)
|
|
151
|
+
assertEquals("checkout", sink.spanStarts[0].name)
|
|
152
|
+
assertEquals("OK", sink.spanEnds[0].status)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun emitBatch_spanEnd_with_ERROR_carries_statusMessage() {
|
|
157
|
+
val spanEnd = JavaOnlyMap.of(
|
|
158
|
+
"kind", "spanEnd",
|
|
159
|
+
"spanId", "aaaaaaaaaaaaaaaa",
|
|
160
|
+
"status", "ERROR",
|
|
161
|
+
"statusMessage", "nope",
|
|
162
|
+
"endTimeUnixNano", "0",
|
|
163
|
+
"attributes", JavaOnlyMap.of(),
|
|
164
|
+
)
|
|
165
|
+
module.emitBatch(JavaOnlyArray.of(spanEnd), RecordingPromise())
|
|
166
|
+
assertEquals("ERROR", sink.spanEnds[0].status)
|
|
167
|
+
assertEquals("nope", sink.spanEnds[0].statusMessage)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── emitBatch: metric ────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
@Test
|
|
173
|
+
fun emitBatch_dispatches_metric_for_counter_histogram_gauge() {
|
|
174
|
+
val types = listOf("counter", "histogram", "gauge")
|
|
175
|
+
val items = types.map { t ->
|
|
176
|
+
JavaOnlyMap.of(
|
|
177
|
+
"kind", "metric",
|
|
178
|
+
"name", "shop.x",
|
|
179
|
+
"instrumentType", t,
|
|
180
|
+
"value", 42.5,
|
|
181
|
+
"timeUnixNano", "0",
|
|
182
|
+
"attributes", JavaOnlyMap.of(),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
val batch = JavaOnlyArray.of(*items.toTypedArray())
|
|
186
|
+
|
|
187
|
+
module.emitBatch(batch, RecordingPromise())
|
|
188
|
+
|
|
189
|
+
assertEquals(3, sink.metrics.size)
|
|
190
|
+
assertEquals(types, sink.metrics.map { it.instrumentType })
|
|
191
|
+
assertEquals(42.5, sink.metrics[0].value, 0.0)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── emitBatch: forward-compat ────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
fun emitBatch_silently_drops_unknown_kind() {
|
|
198
|
+
val weird = JavaOnlyMap.of("kind", "martian", "name", "x")
|
|
199
|
+
val promise = RecordingPromise()
|
|
200
|
+
module.emitBatch(JavaOnlyArray.of(weird), promise)
|
|
201
|
+
assertEquals(0, sink.logs.size + sink.spanStarts.size + sink.spanEnds.size + sink.metrics.size)
|
|
202
|
+
assertTrue(promise.resolved)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── flushWindow / shutdown ───────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
@Test
|
|
208
|
+
fun flushWindow_forwards_minutes_as_int() {
|
|
209
|
+
val promise = RecordingPromise()
|
|
210
|
+
module.flushWindow(5.0, promise)
|
|
211
|
+
assertEquals(listOf(5), sink.flushMinutes)
|
|
212
|
+
assertTrue(promise.resolved)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@Test
|
|
216
|
+
fun shutdown_forwards_and_resolves() {
|
|
217
|
+
val promise = RecordingPromise()
|
|
218
|
+
module.shutdown(promise)
|
|
219
|
+
assertEquals(1, sink.shutdowns)
|
|
220
|
+
assertTrue(promise.resolved)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@Test
|
|
224
|
+
fun getName_returns_Dash0Mobile() {
|
|
225
|
+
assertEquals("Dash0Mobile", module.name)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── FATAL-severity forceFlush hook ───────────────────────────────────
|
|
229
|
+
|
|
230
|
+
@Test
|
|
231
|
+
fun emitLog_fatalSeverity_triggersForceFlush() {
|
|
232
|
+
val payload = JavaOnlyMap.of(
|
|
233
|
+
"kind", "log",
|
|
234
|
+
"name", "app.error",
|
|
235
|
+
"severity", 21.0,
|
|
236
|
+
"attributes", JavaOnlyMap.of(),
|
|
237
|
+
"timeUnixNano", "1700000000000000000",
|
|
238
|
+
)
|
|
239
|
+
module.emitBatch(JavaOnlyArray.of(payload), RecordingPromise())
|
|
240
|
+
|
|
241
|
+
assertEquals(1, sink.logs.size)
|
|
242
|
+
assertEquals(1, sink.forceFlushes)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@Test
|
|
246
|
+
fun emitLog_belowFatal_doesNotTriggerForceFlush() {
|
|
247
|
+
// 17 = ERROR in OTel semconv. Should NOT flush.
|
|
248
|
+
val payload = JavaOnlyMap.of(
|
|
249
|
+
"kind", "log",
|
|
250
|
+
"name", "app.error",
|
|
251
|
+
"severity", 17.0,
|
|
252
|
+
"attributes", JavaOnlyMap.of(),
|
|
253
|
+
"timeUnixNano", "1700000000000000000",
|
|
254
|
+
)
|
|
255
|
+
module.emitBatch(JavaOnlyArray.of(payload), RecordingPromise())
|
|
256
|
+
|
|
257
|
+
assertEquals(1, sink.logs.size)
|
|
258
|
+
assertEquals(0, sink.forceFlushes)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@Test
|
|
262
|
+
fun emitLog_fatalSeverity_flushOrderingIsPostEmitPrePeer() {
|
|
263
|
+
// FATAL log followed by another payload in the same batch. The
|
|
264
|
+
// dispatcher must emit the FATAL, force-flush, THEN dispatch
|
|
265
|
+
// the next payload — never the other way around.
|
|
266
|
+
val fatal = JavaOnlyMap.of(
|
|
267
|
+
"kind", "log",
|
|
268
|
+
"name", "app.error",
|
|
269
|
+
"severity", 21.0,
|
|
270
|
+
"attributes", JavaOnlyMap.of(),
|
|
271
|
+
"timeUnixNano", "1700000000000000000",
|
|
272
|
+
)
|
|
273
|
+
val context = JavaOnlyMap.of(
|
|
274
|
+
"kind", "log",
|
|
275
|
+
"name", "app.error.context",
|
|
276
|
+
"severity", 9.0,
|
|
277
|
+
"attributes", JavaOnlyMap.of(),
|
|
278
|
+
"timeUnixNano", "1700000000000000001",
|
|
279
|
+
)
|
|
280
|
+
module.emitBatch(JavaOnlyArray.of(fatal, context), RecordingPromise())
|
|
281
|
+
|
|
282
|
+
assertEquals(
|
|
283
|
+
listOf(
|
|
284
|
+
"emitLog(app.error,21)",
|
|
285
|
+
"forceFlush",
|
|
286
|
+
"emitLog(app.error.context,9)",
|
|
287
|
+
),
|
|
288
|
+
sink.actionLog,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@Test
|
|
293
|
+
fun emitLog_multipleFatalsInBatch_eachTriggersFlush() {
|
|
294
|
+
// Two FATALs in a row — each gets its own flush. Wasteful but
|
|
295
|
+
// safer than batching: a flush after the first FATAL might
|
|
296
|
+
// succeed-then-die before reaching the second; flushing after
|
|
297
|
+
// each ensures both have a chance to reach disk independently.
|
|
298
|
+
val a = JavaOnlyMap.of(
|
|
299
|
+
"kind", "log", "name", "a", "severity", 21.0,
|
|
300
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "1",
|
|
301
|
+
)
|
|
302
|
+
val b = JavaOnlyMap.of(
|
|
303
|
+
"kind", "log", "name", "b", "severity", 22.0,
|
|
304
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "2",
|
|
305
|
+
)
|
|
306
|
+
module.emitBatch(JavaOnlyArray.of(a, b), RecordingPromise())
|
|
307
|
+
|
|
308
|
+
assertEquals(2, sink.forceFlushes)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@Test
|
|
312
|
+
fun emitLog_fatalSeverityRangeBoundary() {
|
|
313
|
+
// OTel semconv: FATAL severity range is 21..24. Anything ≥ 21
|
|
314
|
+
// is FATAL. The dispatcher's threshold (≥ 21) covers all.
|
|
315
|
+
val fatal22 = JavaOnlyMap.of(
|
|
316
|
+
"kind", "log", "name", "fatal2", "severity", 22.0,
|
|
317
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "1",
|
|
318
|
+
)
|
|
319
|
+
val fatal23 = JavaOnlyMap.of(
|
|
320
|
+
"kind", "log", "name", "fatal3", "severity", 23.0,
|
|
321
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "2",
|
|
322
|
+
)
|
|
323
|
+
val fatal24 = JavaOnlyMap.of(
|
|
324
|
+
"kind", "log", "name", "fatal4", "severity", 24.0,
|
|
325
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "3",
|
|
326
|
+
)
|
|
327
|
+
val warn = JavaOnlyMap.of(
|
|
328
|
+
"kind", "log", "name", "warn", "severity", 13.0,
|
|
329
|
+
"attributes", JavaOnlyMap.of(), "timeUnixNano", "4",
|
|
330
|
+
)
|
|
331
|
+
module.emitBatch(
|
|
332
|
+
JavaOnlyArray.of(fatal22, fatal23, fatal24, warn),
|
|
333
|
+
RecordingPromise(),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
assertEquals(3, sink.forceFlushes)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── test doubles ────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
private data class LogCall(val name: String, val severity: Int, val attributes: Map<String, Any?>, val timeUnixNano: Long)
|
|
343
|
+
private data class SpanStartCall(val spanId: String, val parentSpanId: String?, val name: String, val spanKind: String, val attributes: Map<String, Any?>, val startTimeUnixNano: Long)
|
|
344
|
+
private data class SpanEndCall(val spanId: String, val status: String, val statusMessage: String?, val attributes: Map<String, Any?>, val endTimeUnixNano: Long)
|
|
345
|
+
private data class MetricCall(val name: String, val instrumentType: String, val value: Double, val attributes: Map<String, Any?>, val timeUnixNano: Long)
|
|
346
|
+
|
|
347
|
+
private class RecordingSink : BridgeCallSink {
|
|
348
|
+
val starts = mutableListOf<StartConfig>()
|
|
349
|
+
val logs = mutableListOf<LogCall>()
|
|
350
|
+
val spanStarts = mutableListOf<SpanStartCall>()
|
|
351
|
+
val spanEnds = mutableListOf<SpanEndCall>()
|
|
352
|
+
val metrics = mutableListOf<MetricCall>()
|
|
353
|
+
val flushMinutes = mutableListOf<Int>()
|
|
354
|
+
var shutdowns = 0
|
|
355
|
+
/**
|
|
356
|
+
* Order of `(action, payloadId)` so the ordering tests can assert
|
|
357
|
+
* forceFlush runs AFTER the FATAL emit but BEFORE the next payload
|
|
358
|
+
* — the dispatcher contract is "drain the buffer for the FATAL
|
|
359
|
+
* before the next payload could clobber it or the process dies."
|
|
360
|
+
*/
|
|
361
|
+
val actionLog = mutableListOf<String>()
|
|
362
|
+
var forceFlushes = 0
|
|
363
|
+
|
|
364
|
+
override fun start(config: StartConfig) {
|
|
365
|
+
starts += config
|
|
366
|
+
actionLog += "start"
|
|
367
|
+
}
|
|
368
|
+
override fun emitLog(name: String, severity: Int, attributes: Map<String, Any?>, timeUnixNano: Long) {
|
|
369
|
+
logs += LogCall(name, severity, attributes, timeUnixNano)
|
|
370
|
+
actionLog += "emitLog($name,$severity)"
|
|
371
|
+
}
|
|
372
|
+
override fun startSpan(spanId: String, parentSpanId: String?, name: String, spanKind: String, attributes: Map<String, Any?>, startTimeUnixNano: Long) {
|
|
373
|
+
spanStarts += SpanStartCall(spanId, parentSpanId, name, spanKind, attributes, startTimeUnixNano)
|
|
374
|
+
actionLog += "startSpan($name)"
|
|
375
|
+
}
|
|
376
|
+
override fun endSpan(spanId: String, status: String, statusMessage: String?, attributes: Map<String, Any?>, endTimeUnixNano: Long) {
|
|
377
|
+
spanEnds += SpanEndCall(spanId, status, statusMessage, attributes, endTimeUnixNano)
|
|
378
|
+
actionLog += "endSpan($spanId)"
|
|
379
|
+
}
|
|
380
|
+
override fun recordMetric(name: String, instrumentType: String, value: Double, attributes: Map<String, Any?>, timeUnixNano: Long) {
|
|
381
|
+
metrics += MetricCall(name, instrumentType, value, attributes, timeUnixNano)
|
|
382
|
+
actionLog += "recordMetric($name)"
|
|
383
|
+
}
|
|
384
|
+
override fun flushWindow(minutes: Int) {
|
|
385
|
+
flushMinutes += minutes
|
|
386
|
+
actionLog += "flushWindow($minutes)"
|
|
387
|
+
}
|
|
388
|
+
override fun shutdown() {
|
|
389
|
+
shutdowns += 1
|
|
390
|
+
actionLog += "shutdown"
|
|
391
|
+
}
|
|
392
|
+
override fun forceFlush() {
|
|
393
|
+
forceFlushes += 1
|
|
394
|
+
actionLog += "forceFlush"
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private class RecordingPromise : Promise {
|
|
399
|
+
var resolved = false
|
|
400
|
+
var rejected = false
|
|
401
|
+
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 }
|
|
405
|
+
override fun reject(throwable: Throwable) { rejected = true }
|
|
406
|
+
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 }
|
|
410
|
+
override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: com.facebook.react.bridge.WritableMap?) { rejected = true }
|
|
411
|
+
@Suppress("DEPRECATION")
|
|
412
|
+
override fun reject(message: String) { rejected = true }
|
|
413
|
+
}
|