@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,341 @@
|
|
|
1
|
+
// RN-013 unit tests for Dash0MobileBridgeDispatcher.
|
|
2
|
+
//
|
|
3
|
+
// Uses Swift Testing (aligns with the rest of otel-ios-mobile). These tests
|
|
4
|
+
// drive the dispatcher through the same payload fixtures the Android module
|
|
5
|
+
// tests use, to keep the cross-platform bridge contract aligned.
|
|
6
|
+
|
|
7
|
+
import Testing
|
|
8
|
+
@testable import Dash0MobileReactNative
|
|
9
|
+
|
|
10
|
+
@Suite("Dash0MobileBridgeDispatcher")
|
|
11
|
+
struct Dash0MobileBridgeDispatcherTests {
|
|
12
|
+
|
|
13
|
+
// ── start ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
func start_forwards_all_fields() throws {
|
|
17
|
+
let sink = RecordingSink()
|
|
18
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
19
|
+
try d.start(config: [
|
|
20
|
+
"serviceName": "otel-rn-astronomy-shop",
|
|
21
|
+
"endpoint": "https://ingress/v1/logs",
|
|
22
|
+
"serviceVersion": "1.2.3",
|
|
23
|
+
"authToken": "tok",
|
|
24
|
+
"dataset": "otel-mobile",
|
|
25
|
+
])
|
|
26
|
+
#expect(sink.starts.count == 1)
|
|
27
|
+
let got = sink.starts[0]
|
|
28
|
+
#expect(got.serviceName == "otel-rn-astronomy-shop")
|
|
29
|
+
#expect(got.endpoint == "https://ingress/v1/logs")
|
|
30
|
+
#expect(got.serviceVersion == "1.2.3")
|
|
31
|
+
#expect(got.authToken == "tok")
|
|
32
|
+
#expect(got.dataset == "otel-mobile")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
func start_forwards_nativeAutoCapture_tokens() throws {
|
|
37
|
+
let sink = RecordingSink()
|
|
38
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
39
|
+
try d.start(config: [
|
|
40
|
+
"serviceName": "s",
|
|
41
|
+
"endpoint": "https://e",
|
|
42
|
+
"nativeAutoCapture": ["vitals", "deviceStats"],
|
|
43
|
+
])
|
|
44
|
+
#expect(sink.starts.count == 1)
|
|
45
|
+
#expect(sink.starts[0].nativeAutoCapture == ["vitals", "deviceStats"])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
func start_defaults_nativeAutoCapture_to_empty_when_absent() throws {
|
|
50
|
+
let sink = RecordingSink()
|
|
51
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
52
|
+
try d.start(config: [
|
|
53
|
+
"serviceName": "s",
|
|
54
|
+
"endpoint": "https://e",
|
|
55
|
+
])
|
|
56
|
+
#expect(sink.starts[0].nativeAutoCapture == [])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
func start_throws_when_serviceName_missing() {
|
|
61
|
+
let sink = RecordingSink()
|
|
62
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
63
|
+
#expect(throws: Dash0MobileBridgeError.self) {
|
|
64
|
+
try d.start(config: ["endpoint": "e"])
|
|
65
|
+
}
|
|
66
|
+
#expect(sink.starts.isEmpty)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── emitBatch: log ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
func emitBatch_dispatches_log() {
|
|
73
|
+
let sink = RecordingSink()
|
|
74
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
75
|
+
d.emitBatch([[
|
|
76
|
+
"kind": "log",
|
|
77
|
+
"name": "cart.add_item",
|
|
78
|
+
"severity": 9,
|
|
79
|
+
"timeUnixNano": "1713600000000000000",
|
|
80
|
+
"attributes": ["shop.item_id": "abc", "qty": 2, "urgent": true],
|
|
81
|
+
]])
|
|
82
|
+
#expect(sink.logs.count == 1)
|
|
83
|
+
let l = sink.logs[0]
|
|
84
|
+
#expect(l.name == "cart.add_item")
|
|
85
|
+
#expect(l.severity == 9)
|
|
86
|
+
#expect(l.timeUnixNano == 1_713_600_000_000_000_000)
|
|
87
|
+
#expect(l.attributes["shop.item_id"] as? String == "abc")
|
|
88
|
+
#expect(l.attributes["urgent"] as? Bool == true)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── emitBatch: span pair ──────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
func emitBatch_dispatches_spanStart_then_spanEnd() {
|
|
95
|
+
let sink = RecordingSink()
|
|
96
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
97
|
+
d.emitBatch([
|
|
98
|
+
[
|
|
99
|
+
"kind": "spanStart",
|
|
100
|
+
"spanId": "abc123def456789a",
|
|
101
|
+
"name": "checkout",
|
|
102
|
+
"spanKind": "INTERNAL",
|
|
103
|
+
"startTimeUnixNano": "0",
|
|
104
|
+
"attributes": ["shop.cart_size": 3],
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
"kind": "spanEnd",
|
|
108
|
+
"spanId": "abc123def456789a",
|
|
109
|
+
"status": "OK",
|
|
110
|
+
"endTimeUnixNano": "50",
|
|
111
|
+
"attributes": ["http.response.status_code": 200],
|
|
112
|
+
],
|
|
113
|
+
])
|
|
114
|
+
#expect(sink.spanStarts.count == 1)
|
|
115
|
+
#expect(sink.spanEnds.count == 1)
|
|
116
|
+
#expect(sink.spanStarts[0].spanId == sink.spanEnds[0].spanId)
|
|
117
|
+
#expect(sink.spanEnds[0].status == "OK")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Test
|
|
121
|
+
func emitBatch_dispatches_ERROR_with_statusMessage() {
|
|
122
|
+
let sink = RecordingSink()
|
|
123
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
124
|
+
d.emitBatch([[
|
|
125
|
+
"kind": "spanEnd",
|
|
126
|
+
"spanId": "aaaaaaaaaaaaaaaa",
|
|
127
|
+
"status": "ERROR",
|
|
128
|
+
"statusMessage": "nope",
|
|
129
|
+
"endTimeUnixNano": "0",
|
|
130
|
+
"attributes": [:],
|
|
131
|
+
]])
|
|
132
|
+
#expect(sink.spanEnds[0].status == "ERROR")
|
|
133
|
+
#expect(sink.spanEnds[0].statusMessage == "nope")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── emitBatch: metric ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
func emitBatch_dispatches_counter_histogram_gauge() {
|
|
140
|
+
let sink = RecordingSink()
|
|
141
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
142
|
+
for t in ["counter", "histogram", "gauge"] {
|
|
143
|
+
d.emitBatch([[
|
|
144
|
+
"kind": "metric",
|
|
145
|
+
"name": "shop.x",
|
|
146
|
+
"instrumentType": t,
|
|
147
|
+
"value": 42.5,
|
|
148
|
+
"timeUnixNano": "0",
|
|
149
|
+
"attributes": [:],
|
|
150
|
+
]])
|
|
151
|
+
}
|
|
152
|
+
#expect(sink.metrics.count == 3)
|
|
153
|
+
#expect(sink.metrics.map { $0.instrumentType } == ["counter", "histogram", "gauge"])
|
|
154
|
+
#expect(sink.metrics[0].value == 42.5)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── forward-compat ────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
@Test
|
|
160
|
+
func emitBatch_silently_drops_unknown_kind() {
|
|
161
|
+
let sink = RecordingSink()
|
|
162
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
163
|
+
d.emitBatch([["kind": "martian", "name": "x"]])
|
|
164
|
+
#expect(sink.logs.isEmpty)
|
|
165
|
+
#expect(sink.metrics.isEmpty)
|
|
166
|
+
#expect(sink.spanStarts.isEmpty)
|
|
167
|
+
#expect(sink.spanEnds.isEmpty)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── flush / shutdown ──────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
@Test
|
|
173
|
+
func flushWindow_forwards_rounded_minutes() {
|
|
174
|
+
let sink = RecordingSink()
|
|
175
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
176
|
+
d.flushWindow(minutes: 5.0)
|
|
177
|
+
#expect(sink.flushMinutes == [5])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@Test
|
|
181
|
+
func shutdown_forwards() {
|
|
182
|
+
let sink = RecordingSink()
|
|
183
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
184
|
+
d.shutdown()
|
|
185
|
+
#expect(sink.shutdowns == 1)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// MARK: - FATAL-severity forceFlush hook
|
|
189
|
+
|
|
190
|
+
@Test
|
|
191
|
+
func emitLog_fatalSeverity_triggersForceFlush() {
|
|
192
|
+
let sink = RecordingSink()
|
|
193
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
194
|
+
d.emitBatch([[
|
|
195
|
+
"kind": "log",
|
|
196
|
+
"name": "app.error",
|
|
197
|
+
"severity": 21,
|
|
198
|
+
"attributes": [:],
|
|
199
|
+
"timeUnixNano": "1700000000000000000"
|
|
200
|
+
]])
|
|
201
|
+
#expect(sink.logs.count == 1)
|
|
202
|
+
#expect(sink.forceFlushes == 1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@Test
|
|
206
|
+
func emitLog_belowFatal_doesNotTriggerForceFlush() {
|
|
207
|
+
let sink = RecordingSink()
|
|
208
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
209
|
+
// 17 = ERROR in OTel semconv. Should NOT flush.
|
|
210
|
+
d.emitBatch([[
|
|
211
|
+
"kind": "log",
|
|
212
|
+
"name": "app.error",
|
|
213
|
+
"severity": 17,
|
|
214
|
+
"attributes": [:],
|
|
215
|
+
"timeUnixNano": "1700000000000000000"
|
|
216
|
+
]])
|
|
217
|
+
#expect(sink.logs.count == 1)
|
|
218
|
+
#expect(sink.forceFlushes == 0)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@Test
|
|
222
|
+
func emitLog_fatalSeverity_flushOrderingIsPostEmitPrePeer() {
|
|
223
|
+
let sink = RecordingSink()
|
|
224
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
225
|
+
// FATAL log followed by another payload in the same batch. The
|
|
226
|
+
// dispatcher must emit the FATAL, force-flush, THEN dispatch
|
|
227
|
+
// the next payload — never the other way around. This preserves
|
|
228
|
+
// the invariant that the FATAL gets out of the RAM buffer before
|
|
229
|
+
// the next bridge call could clobber it (or the process dies).
|
|
230
|
+
d.emitBatch([
|
|
231
|
+
[
|
|
232
|
+
"kind": "log",
|
|
233
|
+
"name": "app.error",
|
|
234
|
+
"severity": 21,
|
|
235
|
+
"attributes": [:],
|
|
236
|
+
"timeUnixNano": "1700000000000000000"
|
|
237
|
+
],
|
|
238
|
+
[
|
|
239
|
+
"kind": "log",
|
|
240
|
+
"name": "app.error.context",
|
|
241
|
+
"severity": 9,
|
|
242
|
+
"attributes": [:],
|
|
243
|
+
"timeUnixNano": "1700000000000000001"
|
|
244
|
+
]
|
|
245
|
+
])
|
|
246
|
+
// Expected order: emit FATAL, forceFlush, emit context.
|
|
247
|
+
#expect(sink.actionLog == [
|
|
248
|
+
"emitLog(app.error,21)",
|
|
249
|
+
"forceFlush",
|
|
250
|
+
"emitLog(app.error.context,9)"
|
|
251
|
+
])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@Test
|
|
255
|
+
func emitLog_multipleFatalsInBatch_eachTriggersFlush() {
|
|
256
|
+
let sink = RecordingSink()
|
|
257
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
258
|
+
// Two FATALs in a row — each gets its own flush. Wasteful but
|
|
259
|
+
// safer than batching: a flush after the first FATAL might
|
|
260
|
+
// succeed-then-die before reaching the second; flushing after
|
|
261
|
+
// each ensures both have a chance to reach disk independently.
|
|
262
|
+
d.emitBatch([
|
|
263
|
+
["kind": "log", "name": "a", "severity": 21, "attributes": [:], "timeUnixNano": "1"],
|
|
264
|
+
["kind": "log", "name": "b", "severity": 22, "attributes": [:], "timeUnixNano": "2"]
|
|
265
|
+
])
|
|
266
|
+
#expect(sink.forceFlushes == 2)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@Test
|
|
270
|
+
func emitLog_fatalSeverityRangeBoundary() {
|
|
271
|
+
// OTel semconv: FATAL severity range is 21..24. Anything >= 21
|
|
272
|
+
// is FATAL. The dispatcher's threshold (>= 21) covers all.
|
|
273
|
+
let sink = RecordingSink()
|
|
274
|
+
let d = Dash0MobileBridgeDispatcher(sink: sink)
|
|
275
|
+
d.emitBatch([
|
|
276
|
+
["kind": "log", "name": "fatal2", "severity": 22, "attributes": [:], "timeUnixNano": "1"],
|
|
277
|
+
["kind": "log", "name": "fatal3", "severity": 23, "attributes": [:], "timeUnixNano": "2"],
|
|
278
|
+
["kind": "log", "name": "fatal4", "severity": 24, "attributes": [:], "timeUnixNano": "3"],
|
|
279
|
+
["kind": "log", "name": "warn", "severity": 13, "attributes": [:], "timeUnixNano": "4"]
|
|
280
|
+
])
|
|
281
|
+
#expect(sink.forceFlushes == 3)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── test double ──────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
private struct LogCall { let name: String; let severity: Int; let attributes: [String: Any]; let timeUnixNano: UInt64 }
|
|
288
|
+
private struct SpanStartCall { let spanId: String; let parentSpanId: String?; let name: String; let spanKind: String; let attributes: [String: Any]; let startTimeUnixNano: UInt64 }
|
|
289
|
+
private struct SpanEndCall { let spanId: String; let status: String; let statusMessage: String?; let attributes: [String: Any]; let endTimeUnixNano: UInt64 }
|
|
290
|
+
private struct MetricCall { let name: String; let instrumentType: String; let value: Double; let attributes: [String: Any]; let timeUnixNano: UInt64 }
|
|
291
|
+
|
|
292
|
+
private final class RecordingSink: BridgeCallSink {
|
|
293
|
+
var starts: [BridgeStartConfig] = []
|
|
294
|
+
var logs: [LogCall] = []
|
|
295
|
+
var spanStarts: [SpanStartCall] = []
|
|
296
|
+
var spanEnds: [SpanEndCall] = []
|
|
297
|
+
var metrics: [MetricCall] = []
|
|
298
|
+
var flushMinutes: [Int] = []
|
|
299
|
+
var shutdowns = 0
|
|
300
|
+
/// Records the order of `(action, payloadIndex)` so tests can assert
|
|
301
|
+
/// that `forceFlush` runs AFTER the FATAL emit but BEFORE the next
|
|
302
|
+
/// payload in the same batch. The dispatcher contract says: dispatch
|
|
303
|
+
/// the log first (so it lands in the buffer), then force-flush so
|
|
304
|
+
/// the buffer drains before the next payload (which might carry a
|
|
305
|
+
/// span end the FATAL log's trace context references).
|
|
306
|
+
var actionLog: [String] = []
|
|
307
|
+
var forceFlushes = 0
|
|
308
|
+
|
|
309
|
+
func start(_ config: BridgeStartConfig) {
|
|
310
|
+
starts.append(config)
|
|
311
|
+
actionLog.append("start")
|
|
312
|
+
}
|
|
313
|
+
func emitLog(name: String, severity: Int, attributes: [String: Any], timeUnixNano: UInt64) {
|
|
314
|
+
logs.append(LogCall(name: name, severity: severity, attributes: attributes, timeUnixNano: timeUnixNano))
|
|
315
|
+
actionLog.append("emitLog(\(name),\(severity))")
|
|
316
|
+
}
|
|
317
|
+
func startSpan(spanId: String, parentSpanId: String?, name: String, spanKind: String, attributes: [String: Any], startTimeUnixNano: UInt64) {
|
|
318
|
+
spanStarts.append(SpanStartCall(spanId: spanId, parentSpanId: parentSpanId, name: name, spanKind: spanKind, attributes: attributes, startTimeUnixNano: startTimeUnixNano))
|
|
319
|
+
actionLog.append("startSpan(\(name))")
|
|
320
|
+
}
|
|
321
|
+
func endSpan(spanId: String, status: String, statusMessage: String?, attributes: [String: Any], endTimeUnixNano: UInt64) {
|
|
322
|
+
spanEnds.append(SpanEndCall(spanId: spanId, status: status, statusMessage: statusMessage, attributes: attributes, endTimeUnixNano: endTimeUnixNano))
|
|
323
|
+
actionLog.append("endSpan(\(spanId))")
|
|
324
|
+
}
|
|
325
|
+
func recordMetric(name: String, instrumentType: String, value: Double, attributes: [String: Any], timeUnixNano: UInt64) {
|
|
326
|
+
metrics.append(MetricCall(name: name, instrumentType: instrumentType, value: value, attributes: attributes, timeUnixNano: timeUnixNano))
|
|
327
|
+
actionLog.append("recordMetric(\(name))")
|
|
328
|
+
}
|
|
329
|
+
func flushWindow(minutes: Int) {
|
|
330
|
+
flushMinutes.append(minutes)
|
|
331
|
+
actionLog.append("flushWindow(\(minutes))")
|
|
332
|
+
}
|
|
333
|
+
func shutdown() {
|
|
334
|
+
shutdowns += 1
|
|
335
|
+
actionLog.append("shutdown")
|
|
336
|
+
}
|
|
337
|
+
func forceFlush() {
|
|
338
|
+
forceFlushes += 1
|
|
339
|
+
actionLog.append("forceFlush")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TurboModule codegen spec for @dash0/mobile-react-native.
|
|
3
|
+
*
|
|
4
|
+
* This file is intentionally NOT imported by `src/index.ts` — it exists
|
|
5
|
+
* solely to drive RN codegen (0.68+ / Fabric). The runtime code path goes
|
|
6
|
+
* through `require('react-native').NativeModules.Dash0Mobile` so we keep
|
|
7
|
+
* working on both old-arch and new-arch.
|
|
8
|
+
*
|
|
9
|
+
* Codegen constraints (from React Native docs):
|
|
10
|
+
* - methods must return `Promise<T>` or `void`
|
|
11
|
+
* - parameters must be primitives, `Object`, `Array`, or `{ readonly ... }`
|
|
12
|
+
* - no union types as parameters (codegen rejects)
|
|
13
|
+
*
|
|
14
|
+
* The shapes below are deliberately looser than `bridge/types.ts` (Object
|
|
15
|
+
* instead of discriminated unions) because codegen can't narrow on `kind`.
|
|
16
|
+
* The native side runtime-dispatches on the `kind` string.
|
|
17
|
+
*/
|
|
18
|
+
import type { TurboModule } from 'react-native';
|
|
19
|
+
export interface Spec extends TurboModule {
|
|
20
|
+
start(config: Object): Promise<void>;
|
|
21
|
+
emitBatch(payloads: Object[]): Promise<void>;
|
|
22
|
+
flushWindow(minutes: number): Promise<void>;
|
|
23
|
+
shutdown(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
declare const _default: Spec;
|
|
26
|
+
export default _default;
|
|
27
|
+
//# sourceMappingURL=NativeDash0Mobile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NativeDash0Mobile.d.ts","sourceRoot":"","sources":["../../src/NativeDash0Mobile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;;AAED,wBAAqE"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TurboModule codegen spec for @dash0/mobile-react-native.
|
|
3
|
+
*
|
|
4
|
+
* This file is intentionally NOT imported by `src/index.ts` — it exists
|
|
5
|
+
* solely to drive RN codegen (0.68+ / Fabric). The runtime code path goes
|
|
6
|
+
* through `require('react-native').NativeModules.Dash0Mobile` so we keep
|
|
7
|
+
* working on both old-arch and new-arch.
|
|
8
|
+
*
|
|
9
|
+
* Codegen constraints (from React Native docs):
|
|
10
|
+
* - methods must return `Promise<T>` or `void`
|
|
11
|
+
* - parameters must be primitives, `Object`, `Array`, or `{ readonly ... }`
|
|
12
|
+
* - no union types as parameters (codegen rejects)
|
|
13
|
+
*
|
|
14
|
+
* The shapes below are deliberately looser than `bridge/types.ts` (Object
|
|
15
|
+
* instead of discriminated unions) because codegen can't narrow on `kind`.
|
|
16
|
+
* The native side runtime-dispatches on the `kind` string.
|
|
17
|
+
*/
|
|
18
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
19
|
+
export default TurboModuleRegistry.getEnforcing('Dash0Mobile');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BridgePayload, NativeDash0MobileModule } from './types';
|
|
2
|
+
export declare const DEBOUNCE_MS = 50;
|
|
3
|
+
export declare const MAX_QUEUE = 10000;
|
|
4
|
+
export declare class NativeBridge {
|
|
5
|
+
private readonly native;
|
|
6
|
+
private queue;
|
|
7
|
+
private timer;
|
|
8
|
+
private inFlight;
|
|
9
|
+
constructor(native: NativeDash0MobileModule);
|
|
10
|
+
emit(payload: BridgePayload): void;
|
|
11
|
+
/**
|
|
12
|
+
* Synchronous fast-path for FATAL-severity payloads. Cancels any pending
|
|
13
|
+
* debounce, drains the queue + the new payload in a single
|
|
14
|
+
* `native.emitBatch` call made SYNCHRONOUSLY on the current stack frame.
|
|
15
|
+
*
|
|
16
|
+
* Why not just `emit(); flush()`? Because `flush()` is `async`, and its
|
|
17
|
+
* `await this.drain()` places the call to `native.emitBatch` on the
|
|
18
|
+
* microtask queue. On a crash path (JS throw → ErrorUtils global handler
|
|
19
|
+
* → previous(error, isFatal) → RN fatal reporter → process exit) the
|
|
20
|
+
* handler's synchronous continuation wins the race against the
|
|
21
|
+
* microtask — the payload never crosses the bridge.
|
|
22
|
+
*
|
|
23
|
+
* This method is intentionally fire-and-forget: per the RN bridge
|
|
24
|
+
* contract, argument marshaling is synchronous, so the payload arrives
|
|
25
|
+
* on the native side by the time `native.emitBatch(...)` returns. The
|
|
26
|
+
* Promise resolution (native-side completion signal) is async and we
|
|
27
|
+
* don't await it — we only need the payload to cross the bridge before
|
|
28
|
+
* the process dies. `.catch(() => {})` prevents unhandledRejection
|
|
29
|
+
* warnings if the native side fails.
|
|
30
|
+
*
|
|
31
|
+
* See docs/superpowers/specs/2026-04-22-rn-fatal-bridge-bypass-design.md.
|
|
32
|
+
*/
|
|
33
|
+
emitSync(payload: BridgePayload): void;
|
|
34
|
+
flush(): Promise<void>;
|
|
35
|
+
private drain;
|
|
36
|
+
private sendWithRetry;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=NativeBridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NativeBridge.d.ts","sourceRoot":"","sources":["../../../src/bridge/NativeBridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEtE,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,SAAS,QAAS,CAAC;AAIhC,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,QAAQ,CAA8B;gBAElC,MAAM,EAAE,uBAAuB;IAI3C,IAAI,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI;IAalC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI;IAgBhC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAQd,KAAK;YAYL,aAAa;CAY5B"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const DEBOUNCE_MS = 50;
|
|
2
|
+
export const MAX_QUEUE = 10000;
|
|
3
|
+
const RETRY_BASE_MS = 100;
|
|
4
|
+
const RETRY_MAX_ATTEMPTS = 5;
|
|
5
|
+
export class NativeBridge {
|
|
6
|
+
constructor(native) {
|
|
7
|
+
this.queue = [];
|
|
8
|
+
this.timer = null;
|
|
9
|
+
this.inFlight = null;
|
|
10
|
+
this.native = native;
|
|
11
|
+
}
|
|
12
|
+
emit(payload) {
|
|
13
|
+
this.queue.push(payload);
|
|
14
|
+
if (this.queue.length > MAX_QUEUE) {
|
|
15
|
+
this.queue.splice(0, this.queue.length - MAX_QUEUE);
|
|
16
|
+
}
|
|
17
|
+
if (this.timer === null) {
|
|
18
|
+
this.timer = setTimeout(() => {
|
|
19
|
+
this.timer = null;
|
|
20
|
+
void this.drain();
|
|
21
|
+
}, DEBOUNCE_MS);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Synchronous fast-path for FATAL-severity payloads. Cancels any pending
|
|
26
|
+
* debounce, drains the queue + the new payload in a single
|
|
27
|
+
* `native.emitBatch` call made SYNCHRONOUSLY on the current stack frame.
|
|
28
|
+
*
|
|
29
|
+
* Why not just `emit(); flush()`? Because `flush()` is `async`, and its
|
|
30
|
+
* `await this.drain()` places the call to `native.emitBatch` on the
|
|
31
|
+
* microtask queue. On a crash path (JS throw → ErrorUtils global handler
|
|
32
|
+
* → previous(error, isFatal) → RN fatal reporter → process exit) the
|
|
33
|
+
* handler's synchronous continuation wins the race against the
|
|
34
|
+
* microtask — the payload never crosses the bridge.
|
|
35
|
+
*
|
|
36
|
+
* This method is intentionally fire-and-forget: per the RN bridge
|
|
37
|
+
* contract, argument marshaling is synchronous, so the payload arrives
|
|
38
|
+
* on the native side by the time `native.emitBatch(...)` returns. The
|
|
39
|
+
* Promise resolution (native-side completion signal) is async and we
|
|
40
|
+
* don't await it — we only need the payload to cross the bridge before
|
|
41
|
+
* the process dies. `.catch(() => {})` prevents unhandledRejection
|
|
42
|
+
* warnings if the native side fails.
|
|
43
|
+
*
|
|
44
|
+
* See docs/superpowers/specs/2026-04-22-rn-fatal-bridge-bypass-design.md.
|
|
45
|
+
*/
|
|
46
|
+
emitSync(payload) {
|
|
47
|
+
if (this.timer !== null) {
|
|
48
|
+
clearTimeout(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
const batch = this.queue.length > 0 ? [...this.queue, payload] : [payload];
|
|
52
|
+
this.queue = [];
|
|
53
|
+
this.native.emitBatch(batch).catch(() => {
|
|
54
|
+
// Swallow — on the crash path, the process is dying anyway and
|
|
55
|
+
// there's no caller alive to observe the error. On non-crash paths
|
|
56
|
+
// (if any caller uses emitSync for non-FATAL severity) the payload
|
|
57
|
+
// is lost on failure, which matches the implicit "best-effort"
|
|
58
|
+
// contract of the RN bridge under adverse conditions.
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async flush() {
|
|
62
|
+
if (this.timer !== null) {
|
|
63
|
+
clearTimeout(this.timer);
|
|
64
|
+
this.timer = null;
|
|
65
|
+
}
|
|
66
|
+
await this.drain();
|
|
67
|
+
}
|
|
68
|
+
async drain() {
|
|
69
|
+
if (this.queue.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
const batch = this.queue;
|
|
72
|
+
this.queue = [];
|
|
73
|
+
this.inFlight = this.sendWithRetry(batch);
|
|
74
|
+
try {
|
|
75
|
+
await this.inFlight;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
this.inFlight = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async sendWithRetry(batch) {
|
|
82
|
+
for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
await this.native.emitBatch(batch);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
if (attempt === RETRY_MAX_ATTEMPTS - 1)
|
|
89
|
+
return;
|
|
90
|
+
const delay = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|