@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.
Files changed (61) hide show
  1. package/Dash0Mobile.podspec +29 -0
  2. package/README.md +117 -0
  3. package/android/build.gradle +68 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +67 -0
  6. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +198 -0
  7. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +14 -0
  8. package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +208 -0
  9. package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +413 -0
  10. package/ios/BridgeCallSink.swift +83 -0
  11. package/ios/Dash0MobileBridgeDispatcher.swift +142 -0
  12. package/ios/OTelMobileCallSink.swift +262 -0
  13. package/ios/RCTDash0MobileModule.m +28 -0
  14. package/ios/RCTDash0MobileModule.swift +104 -0
  15. package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +341 -0
  16. package/lib/src/NativeDash0Mobile.d.ts +27 -0
  17. package/lib/src/NativeDash0Mobile.d.ts.map +1 -0
  18. package/lib/src/NativeDash0Mobile.js +19 -0
  19. package/lib/src/bridge/NativeBridge.d.ts +38 -0
  20. package/lib/src/bridge/NativeBridge.d.ts.map +1 -0
  21. package/lib/src/bridge/NativeBridge.js +95 -0
  22. package/lib/src/bridge/types.d.ts +166 -0
  23. package/lib/src/bridge/types.d.ts.map +1 -0
  24. package/lib/src/bridge/types.js +10 -0
  25. package/lib/src/index.d.ts +35 -0
  26. package/lib/src/index.d.ts.map +1 -0
  27. package/lib/src/index.js +408 -0
  28. package/lib/src/instrumentation/errors.d.ts +14 -0
  29. package/lib/src/instrumentation/errors.d.ts.map +1 -0
  30. package/lib/src/instrumentation/errors.js +65 -0
  31. package/lib/src/instrumentation/fetch.d.ts +16 -0
  32. package/lib/src/instrumentation/fetch.d.ts.map +1 -0
  33. package/lib/src/instrumentation/fetch.js +75 -0
  34. package/lib/src/instrumentation/navigation.d.ts +19 -0
  35. package/lib/src/instrumentation/navigation.d.ts.map +1 -0
  36. package/lib/src/instrumentation/navigation.js +39 -0
  37. package/lib/src/instrumentation/touch.d.ts +12 -0
  38. package/lib/src/instrumentation/touch.d.ts.map +1 -0
  39. package/lib/src/instrumentation/touch.js +18 -0
  40. package/lib/src/instrumentation/unhandledRejection.d.ts +9 -0
  41. package/lib/src/instrumentation/unhandledRejection.d.ts.map +1 -0
  42. package/lib/src/instrumentation/unhandledRejection.js +52 -0
  43. package/lib/src/instrumentation/xhr.d.ts +14 -0
  44. package/lib/src/instrumentation/xhr.d.ts.map +1 -0
  45. package/lib/src/instrumentation/xhr.js +88 -0
  46. package/lib/src/otel-compat.d.ts +67 -0
  47. package/lib/src/otel-compat.d.ts.map +1 -0
  48. package/lib/src/otel-compat.js +84 -0
  49. package/package.json +72 -0
  50. package/react-native.config.js +17 -0
  51. package/src/NativeDash0Mobile.ts +29 -0
  52. package/src/bridge/NativeBridge.ts +101 -0
  53. package/src/bridge/types.ts +188 -0
  54. package/src/index.ts +456 -0
  55. package/src/instrumentation/errors.ts +84 -0
  56. package/src/instrumentation/fetch.ts +93 -0
  57. package/src/instrumentation/navigation.ts +52 -0
  58. package/src/instrumentation/touch.ts +32 -0
  59. package/src/instrumentation/unhandledRejection.ts +75 -0
  60. package/src/instrumentation/xhr.ts +125 -0
  61. 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
+ }