@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dash0/mobile-react-native — public API
|
|
3
|
+
*
|
|
4
|
+
* Thin JS facade. All buffering, policy, OTLP export, crash recovery happen
|
|
5
|
+
* in the native modules. This file only marshals calls onto a NativeBridge.
|
|
6
|
+
*
|
|
7
|
+
* See docs/epics/REACT_NATIVE_EPIC.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NativeBridge } from './bridge/NativeBridge';
|
|
11
|
+
import { installFetchInstrumentation } from './instrumentation/fetch';
|
|
12
|
+
import { installXhrInstrumentation } from './instrumentation/xhr';
|
|
13
|
+
import { installErrorInstrumentation } from './instrumentation/errors';
|
|
14
|
+
import { installUnhandledRejectionInstrumentation } from './instrumentation/unhandledRejection';
|
|
15
|
+
import type {
|
|
16
|
+
Attributes,
|
|
17
|
+
BridgePayload,
|
|
18
|
+
NativeDash0MobileModule,
|
|
19
|
+
SeverityNumber,
|
|
20
|
+
SpanKind,
|
|
21
|
+
SpanStartPayload,
|
|
22
|
+
StartConfig,
|
|
23
|
+
} from './bridge/types';
|
|
24
|
+
|
|
25
|
+
export type { Attributes, SeverityNumber, StartConfig } from './bridge/types';
|
|
26
|
+
export { installReactNavigationInstrumentation } from './instrumentation/navigation';
|
|
27
|
+
export { withTapTelemetry } from './instrumentation/touch';
|
|
28
|
+
export { otel } from './otel-compat';
|
|
29
|
+
|
|
30
|
+
export interface SpanHandle {
|
|
31
|
+
setAttribute(key: string, value: string | number | boolean): void;
|
|
32
|
+
setStatus(status: 'OK' | 'ERROR', message?: string): void;
|
|
33
|
+
end(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveSpan {
|
|
37
|
+
payload: SpanStartPayload;
|
|
38
|
+
attrs: Attributes;
|
|
39
|
+
status: 'UNSET' | 'OK' | 'ERROR';
|
|
40
|
+
statusMessage?: string;
|
|
41
|
+
ended: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let injectedNative: NativeDash0MobileModule | null = null;
|
|
45
|
+
let bridge: NativeBridge | null = null;
|
|
46
|
+
let started = false;
|
|
47
|
+
let autoInstrUninstallers: Array<() => void> = [];
|
|
48
|
+
|
|
49
|
+
// Ambient parent-span stack. Each `startSpan` pushes the new spanId; each
|
|
50
|
+
// `.end()` pops. Children emitted while an outer span is live pick up the
|
|
51
|
+
// top of the stack as their `parentSpanId`, so Dash0 renders proper
|
|
52
|
+
// waterfalls without callers threading parent handles manually.
|
|
53
|
+
//
|
|
54
|
+
// Limitation: a single global stack is only correct for strictly-nested
|
|
55
|
+
// (LIFO) span work — sufficient for `Dash0Mobile.span('x', async () => { ... })`
|
|
56
|
+
// and sequential `startSpan/.end()` pairs. Concurrent async span trees would
|
|
57
|
+
// need AsyncLocalStorage / Hermes async-hooks; RN doesn't expose those
|
|
58
|
+
// cleanly, so we accept the limitation and document it on the public API.
|
|
59
|
+
const spanStack: string[] = [];
|
|
60
|
+
|
|
61
|
+
/// Anchor pair used by `nowUnixNano` to combine an absolute-epoch
|
|
62
|
+
/// base (from `Date.now()`) with a sub-ms monotonic delta (from
|
|
63
|
+
/// `performance.now()`). Initialized lazily on the first timestamp
|
|
64
|
+
/// request so we don't pay the cost for consumers who never emit.
|
|
65
|
+
///
|
|
66
|
+
/// Why the anchor pattern: `performance.now()` gives sub-ms precision
|
|
67
|
+
/// but returns time-since-an-arbitrary-origin, not wall-clock epoch.
|
|
68
|
+
/// `Date.now()` gives wall-clock but only ms resolution. Capturing
|
|
69
|
+
/// both once, then deriving each timestamp as `epochAnchor + perfDelta`
|
|
70
|
+
/// yields sub-ms wall-clock nanoseconds without per-call drift.
|
|
71
|
+
let _epochAnchorMs: number | null = null;
|
|
72
|
+
let _perfAnchorMs: number | null = null;
|
|
73
|
+
|
|
74
|
+
function perfNow(): number {
|
|
75
|
+
// RN 0.85 new-arch ships `global.performance.now` via JSI. Older
|
|
76
|
+
// runtimes (Hermes < 0.12, some test harnesses) may not. Fall back
|
|
77
|
+
// gracefully — callers still get correct ms-resolution nanoseconds
|
|
78
|
+
// in that case, just not the sub-ms fidelity.
|
|
79
|
+
const g = globalThis as { performance?: { now?: () => number } };
|
|
80
|
+
if (typeof g.performance?.now === 'function') {
|
|
81
|
+
return g.performance.now();
|
|
82
|
+
}
|
|
83
|
+
return Date.now();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function nowUnixNano(): string {
|
|
87
|
+
// Initialize anchor lazily. The offset between the two reads is at
|
|
88
|
+
// most one JS tick — negligible for observability purposes.
|
|
89
|
+
if (_epochAnchorMs === null || _perfAnchorMs === null) {
|
|
90
|
+
_epochAnchorMs = Date.now();
|
|
91
|
+
_perfAnchorMs = perfNow();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const elapsedMs = perfNow() - _perfAnchorMs;
|
|
95
|
+
// Sub-ms fraction; convert to nanoseconds as an integer.
|
|
96
|
+
const extraNanos = Math.floor(elapsedMs * 1_000_000);
|
|
97
|
+
|
|
98
|
+
// BigInt is required for correctness. Unix nanoseconds in 2026
|
|
99
|
+
// are ~1.77e18, well past JS `Number.MAX_SAFE_INTEGER` (2^53 ≈
|
|
100
|
+
// 9.01e15). The previous `Date.now() * 1_000_000` implementation
|
|
101
|
+
// lost precision on every call (rounding to ~128-nanosecond bands),
|
|
102
|
+
// which collapsed nested child spans' start+end timestamps to
|
|
103
|
+
// identical values and surfaced as `duration=0ms` in Dash0.
|
|
104
|
+
const totalNanos =
|
|
105
|
+
BigInt(_epochAnchorMs) * 1_000_000n + BigInt(extraNanos);
|
|
106
|
+
return totalNanos.toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Test-only: reset the anchors so each test case starts fresh.
|
|
110
|
+
/// Not exported from the public entry point — accessed in Jest via
|
|
111
|
+
/// the internal require path.
|
|
112
|
+
export function __resetTimestampAnchorForTests__(): void {
|
|
113
|
+
_epochAnchorMs = null;
|
|
114
|
+
_perfAnchorMs = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function randomSpanId(): string {
|
|
118
|
+
let id = '';
|
|
119
|
+
for (let i = 0; i < 16; i++) {
|
|
120
|
+
id += Math.floor(Math.random() * 16).toString(16);
|
|
121
|
+
}
|
|
122
|
+
return id;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveNative(): NativeDash0MobileModule | null {
|
|
126
|
+
if (injectedNative) return injectedNative;
|
|
127
|
+
// Production path: lazily require react-native to avoid pulling it into
|
|
128
|
+
// tests that stub at the unit level. If RN isn't present we return null
|
|
129
|
+
// and pre-start calls become no-ops.
|
|
130
|
+
try {
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
132
|
+
const rn = require('react-native');
|
|
133
|
+
return (rn?.NativeModules?.Dash0Mobile as NativeDash0MobileModule) ?? null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Version of this RN bridge package. Sent as `telemetry.distro.version` so
|
|
140
|
+
// Dash0 can distinguish RN-originated telemetry from direct native SDK
|
|
141
|
+
// callers and correlate issues to a specific bridge release. Keep this in
|
|
142
|
+
// sync with package.json on each release.
|
|
143
|
+
const DISTRO_NAME = 'dash0-react-native';
|
|
144
|
+
const DISTRO_VERSION = '0.1.0-alpha';
|
|
145
|
+
|
|
146
|
+
function resolveReactNativeVersion(): string | undefined {
|
|
147
|
+
try {
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
149
|
+
const rn = require('react-native/package.json');
|
|
150
|
+
return typeof rn?.version === 'string' ? rn.version : undefined;
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const Dash0Mobile = {
|
|
157
|
+
async start(config: StartConfig): Promise<void> {
|
|
158
|
+
const native = resolveNative();
|
|
159
|
+
if (!native) {
|
|
160
|
+
// No native module wired. Stay quiet — caller is likely in a non-RN
|
|
161
|
+
// environment (Jest, SSR) and shouldn't crash.
|
|
162
|
+
started = false;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
bridge = new NativeBridge(native);
|
|
166
|
+
|
|
167
|
+
// Inject RN distribution attributes as OTel-spec resource attributes.
|
|
168
|
+
// Caller-provided extras win (lets apps override for testing/tagging).
|
|
169
|
+
const rnVersion = resolveReactNativeVersion();
|
|
170
|
+
// Translate the `autoCapture` flags into a native-capability token list the
|
|
171
|
+
// bridge (iOS/Android) can use to build `AutoCaptureOptions`. Native
|
|
172
|
+
// default is OFF for everything — callers must explicitly set a flag to
|
|
173
|
+
// `true` to enable the native suite on that capability. This avoids the
|
|
174
|
+
// iOS URLProtocol / NSException / signal-handler swizzles by default,
|
|
175
|
+
// which collide with RN's new-arch JS event loop.
|
|
176
|
+
const nativeAutoCapture = buildNativeAutoCaptureTokens(config.autoCapture);
|
|
177
|
+
const mergedConfig: StartConfig & { nativeAutoCapture: string[] } = {
|
|
178
|
+
...config,
|
|
179
|
+
extraResourceAttributes: {
|
|
180
|
+
'telemetry.distro.name': DISTRO_NAME,
|
|
181
|
+
'telemetry.distro.version': DISTRO_VERSION,
|
|
182
|
+
...(rnVersion ? { 'app.framework': 'react-native', 'app.framework.version': rnVersion } : {}),
|
|
183
|
+
...(config.extraResourceAttributes ?? {}),
|
|
184
|
+
},
|
|
185
|
+
nativeAutoCapture,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await native.start(mergedConfig);
|
|
189
|
+
started = true;
|
|
190
|
+
|
|
191
|
+
const auto = config.autoCapture ?? {};
|
|
192
|
+
if (auto.network !== false) {
|
|
193
|
+
const collectorHost = hostFromEndpoint(config.endpoint);
|
|
194
|
+
const ignoredHosts = collectorHost ? [collectorHost] : [];
|
|
195
|
+
// On React Native, `fetch` is implemented on top of XHR, so every
|
|
196
|
+
// fetch() call also fires the XHR instrumentation — installing both
|
|
197
|
+
// produces two spans per request. XHR is the authoritative layer
|
|
198
|
+
// because it also captures direct XHR callers (axios, Apollo HTTP
|
|
199
|
+
// link, legacy code) that don't go through fetch. In non-RN JS
|
|
200
|
+
// environments (future web/SSR use) fetch is native and independent
|
|
201
|
+
// of XHR, so both shims are needed.
|
|
202
|
+
if (!isReactNative()) {
|
|
203
|
+
autoInstrUninstallers.push(installFetchInstrumentation({ ignoredHosts }));
|
|
204
|
+
}
|
|
205
|
+
autoInstrUninstallers.push(installXhrInstrumentation({ ignoredHosts }));
|
|
206
|
+
}
|
|
207
|
+
if (auto.errors !== false) {
|
|
208
|
+
autoInstrUninstallers.push(installErrorInstrumentation());
|
|
209
|
+
autoInstrUninstallers.push(installUnhandledRejectionInstrumentation());
|
|
210
|
+
}
|
|
211
|
+
// Lifecycle (`app.foreground` / `app.background` / `app.start`) is now
|
|
212
|
+
// emitted by native instrumentation on both platforms — Android via
|
|
213
|
+
// ProcessLifecycleOwner, iOS via NotificationCenter with applicationState
|
|
214
|
+
// late-init synthesis. The previous JS-side AppState shim was deleted
|
|
215
|
+
// because RN 0.85 new-arch made it unreliable (TurboModule init race).
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
log(name: string, attributes: Attributes = {}, severity: SeverityNumber = 9): void {
|
|
219
|
+
if (!started || !bridge) return;
|
|
220
|
+
const payload: BridgePayload = {
|
|
221
|
+
kind: 'log',
|
|
222
|
+
name,
|
|
223
|
+
severity,
|
|
224
|
+
attributes,
|
|
225
|
+
timeUnixNano: nowUnixNano(),
|
|
226
|
+
};
|
|
227
|
+
// FATAL-severity logs bypass the 50ms debounce via `emitSync`, which
|
|
228
|
+
// calls `native.emitBatch` without any `await`. The payload crosses
|
|
229
|
+
// the RN bridge in the current stack frame — critical on crash paths
|
|
230
|
+
// because any microtask boundary (including the `await` inside
|
|
231
|
+
// `flush()`) loses the race against the handler's continuation into
|
|
232
|
+
// RN's fatal reporter. Once the payload is on the native side, the
|
|
233
|
+
// iOS SDK's willTerminate auto-forceFlush (commit 1a69c7e) persists
|
|
234
|
+
// it to disk and attempts OTLP export before process exit.
|
|
235
|
+
if (severity >= 21) {
|
|
236
|
+
bridge.emitSync(payload);
|
|
237
|
+
} else {
|
|
238
|
+
bridge.emit(payload);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
startSpan(name: string, attributes: Attributes = {}, spanKind: SpanKind = 'INTERNAL'): SpanHandle {
|
|
243
|
+
const spanId = randomSpanId();
|
|
244
|
+
const parentSpanId = spanStack.length > 0 ? spanStack[spanStack.length - 1] : undefined;
|
|
245
|
+
const startPayload: SpanStartPayload = {
|
|
246
|
+
kind: 'spanStart',
|
|
247
|
+
spanId,
|
|
248
|
+
parentSpanId,
|
|
249
|
+
name,
|
|
250
|
+
spanKind,
|
|
251
|
+
attributes: { ...attributes },
|
|
252
|
+
startTimeUnixNano: nowUnixNano(),
|
|
253
|
+
};
|
|
254
|
+
const active: ActiveSpan = {
|
|
255
|
+
payload: startPayload,
|
|
256
|
+
attrs: {},
|
|
257
|
+
status: 'UNSET',
|
|
258
|
+
ended: false,
|
|
259
|
+
};
|
|
260
|
+
spanStack.push(spanId);
|
|
261
|
+
|
|
262
|
+
if (started && bridge) {
|
|
263
|
+
bridge.emit(startPayload);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
setAttribute(key, value) {
|
|
268
|
+
active.attrs[key] = value;
|
|
269
|
+
},
|
|
270
|
+
setStatus(status, message) {
|
|
271
|
+
active.status = status;
|
|
272
|
+
active.statusMessage = message;
|
|
273
|
+
},
|
|
274
|
+
end() {
|
|
275
|
+
if (active.ended) return;
|
|
276
|
+
active.ended = true;
|
|
277
|
+
// Pop this spanId off the stack. Tolerate out-of-order ends by
|
|
278
|
+
// scanning from the top — the common case is LIFO, but mis-ordered
|
|
279
|
+
// ends (e.g. a long-lived outer span that ends after an inner
|
|
280
|
+
// one that was itself popped correctly) shouldn't corrupt the stack.
|
|
281
|
+
const idx = spanStack.lastIndexOf(spanId);
|
|
282
|
+
if (idx >= 0) spanStack.splice(idx, 1);
|
|
283
|
+
if (!started || !bridge) return;
|
|
284
|
+
bridge.emit({
|
|
285
|
+
kind: 'spanEnd',
|
|
286
|
+
spanId,
|
|
287
|
+
status: active.status === 'UNSET' ? 'OK' : active.status,
|
|
288
|
+
statusMessage: active.statusMessage,
|
|
289
|
+
attributes: active.attrs,
|
|
290
|
+
endTimeUnixNano: nowUnixNano(),
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async span<T>(
|
|
297
|
+
name: string,
|
|
298
|
+
fn: (handle: SpanHandle) => Promise<T> | T,
|
|
299
|
+
attributes?: Attributes,
|
|
300
|
+
): Promise<T> {
|
|
301
|
+
const handle = this.startSpan(name, attributes);
|
|
302
|
+
try {
|
|
303
|
+
const result = await fn(handle);
|
|
304
|
+
handle.setStatus('OK');
|
|
305
|
+
return result;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
handle.setStatus('ERROR', err instanceof Error ? err.message : String(err));
|
|
308
|
+
throw err;
|
|
309
|
+
} finally {
|
|
310
|
+
handle.end();
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
recordMetric(
|
|
315
|
+
name: string,
|
|
316
|
+
value: number,
|
|
317
|
+
instrumentType: 'counter' | 'histogram' | 'gauge' = 'counter',
|
|
318
|
+
attributes: Attributes = {},
|
|
319
|
+
): void {
|
|
320
|
+
if (!started || !bridge) return;
|
|
321
|
+
bridge.emit({
|
|
322
|
+
kind: 'metric',
|
|
323
|
+
name,
|
|
324
|
+
instrumentType,
|
|
325
|
+
value,
|
|
326
|
+
attributes,
|
|
327
|
+
timeUnixNano: nowUnixNano(),
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
async flushWindow(minutes: number): Promise<void> {
|
|
332
|
+
if (!started || !bridge) return;
|
|
333
|
+
await bridge.flush();
|
|
334
|
+
const native = resolveNative();
|
|
335
|
+
if (native) await native.flushWindow(minutes);
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// ─── User Journey API (UJ-020) ──────────────────────────────────────────
|
|
339
|
+
// Thin passthrough to native `OTelMobile.startJourney/endJourney`. The
|
|
340
|
+
// native side creates the journey span and triggers screenshot + wireframe
|
|
341
|
+
// captures at start/end boundaries.
|
|
342
|
+
|
|
343
|
+
async startJourney(name: string): Promise<string | null> {
|
|
344
|
+
if (!started) return null;
|
|
345
|
+
const native = resolveNative();
|
|
346
|
+
if (!native) return null;
|
|
347
|
+
return native.startJourney(name);
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async endJourney(journeyId: string): Promise<void> {
|
|
351
|
+
if (!started) return;
|
|
352
|
+
const native = resolveNative();
|
|
353
|
+
if (!native) return;
|
|
354
|
+
await native.endJourney(journeyId);
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async captureScreenshot(trigger: string = 'manual'): Promise<void> {
|
|
358
|
+
if (!started) return;
|
|
359
|
+
const native = resolveNative();
|
|
360
|
+
if (!native) return;
|
|
361
|
+
await native.captureScreenshot(trigger);
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async captureWireframe(trigger: string = 'manual'): Promise<void> {
|
|
365
|
+
if (!started) return;
|
|
366
|
+
const native = resolveNative();
|
|
367
|
+
if (!native) return;
|
|
368
|
+
await native.captureWireframe(trigger);
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
async shutdown(): Promise<void> {
|
|
372
|
+
for (const uninstall of autoInstrUninstallers) {
|
|
373
|
+
try {
|
|
374
|
+
uninstall();
|
|
375
|
+
} catch {
|
|
376
|
+
// Swallow — shutdown should never throw.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
autoInstrUninstallers = [];
|
|
380
|
+
if (!bridge) return;
|
|
381
|
+
await bridge.flush();
|
|
382
|
+
const native = resolveNative();
|
|
383
|
+
if (native) await native.shutdown();
|
|
384
|
+
started = false;
|
|
385
|
+
bridge = null;
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const ENDPOINT_HOST_RE = /^[a-z][a-z0-9+.-]*:\/\/([^/:?#]+)/i;
|
|
390
|
+
function hostFromEndpoint(endpoint: string): string | null {
|
|
391
|
+
const match = ENDPOINT_HOST_RE.exec(endpoint);
|
|
392
|
+
return match ? match[1].toLowerCase() : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// React Native sets `navigator.product === 'ReactNative'` — the standard
|
|
396
|
+
// public signal for code that wants to differ by JS environment. This is
|
|
397
|
+
// the same check used by Sentry, Datadog, and RN itself internally. Jest
|
|
398
|
+
// / Node / SSR won't match, so test and non-RN environments still
|
|
399
|
+
// exercise both network shims.
|
|
400
|
+
function isReactNative(): boolean {
|
|
401
|
+
const nav = (globalThis as unknown as { navigator?: { product?: string } }).navigator;
|
|
402
|
+
return nav?.product === 'ReactNative';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Map JS autoCapture flags onto native-capability tokens the bridge
|
|
406
|
+
// translates to AutoCaptureOptions. Defaults are all-OFF: callers must
|
|
407
|
+
// explicitly set `true` to enable a native suite.
|
|
408
|
+
type AutoCaptureFlag = keyof NonNullable<StartConfig['autoCapture']>;
|
|
409
|
+
const NATIVE_AUTO_CAPTURE_FLAGS: ReadonlyArray<[AutoCaptureFlag, string]> = [
|
|
410
|
+
['network', 'network'],
|
|
411
|
+
['errors', 'errors'],
|
|
412
|
+
['tap', 'tap'],
|
|
413
|
+
['scroll', 'scroll'],
|
|
414
|
+
['textInput', 'textInput'],
|
|
415
|
+
['screen', 'screen'],
|
|
416
|
+
['freeze', 'freeze'],
|
|
417
|
+
['vitals', 'vitals'],
|
|
418
|
+
['deviceStats', 'deviceStats'],
|
|
419
|
+
['screenshot', 'screenshot'],
|
|
420
|
+
['wireframe', 'wireframe'],
|
|
421
|
+
];
|
|
422
|
+
function buildNativeAutoCaptureTokens(ac: StartConfig['autoCapture']): string[] {
|
|
423
|
+
if (!ac) return [];
|
|
424
|
+
const out: string[] = [];
|
|
425
|
+
for (const [flag, token] of NATIVE_AUTO_CAPTURE_FLAGS) {
|
|
426
|
+
const value = ac[flag];
|
|
427
|
+
// Object form (screenshot / wireframe per-trigger): treat as enabled
|
|
428
|
+
// unless `enabled` is explicitly `false`. The bridge protocol carries
|
|
429
|
+
// only this on/off bit today — per-trigger fields are forward-compatible
|
|
430
|
+
// and currently must be configured natively. See ScreenshotAutoCapture /
|
|
431
|
+
// WireframeAutoCapture doc comments in bridge/types.ts.
|
|
432
|
+
if (value === true) {
|
|
433
|
+
out.push(token);
|
|
434
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
435
|
+
const enabled = (value as { enabled?: boolean }).enabled;
|
|
436
|
+
if (enabled !== false) out.push(token);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ─── test hooks ──────────────────────────────────────────────────────────────
|
|
443
|
+
// These are intentionally exported but prefixed __ to signal "not for app use."
|
|
444
|
+
// Native-module tests inject a mock here; a CI lint rule should ban callers
|
|
445
|
+
// outside __tests__/ from importing them.
|
|
446
|
+
|
|
447
|
+
export function __setNativeForTesting(native: NativeDash0MobileModule | null): void {
|
|
448
|
+
injectedNative = native;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function __resetForTesting(): void {
|
|
452
|
+
injectedNative = null;
|
|
453
|
+
bridge = null;
|
|
454
|
+
started = false;
|
|
455
|
+
spanStack.length = 0;
|
|
456
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error auto-instrumentation for React Native.
|
|
3
|
+
*
|
|
4
|
+
* Chains our handler into RN's `ErrorUtils.setGlobalHandler`, emits an
|
|
5
|
+
* `app.error` log with OTel `exception.*` semconv attributes, then delegates
|
|
6
|
+
* to the previous handler so Sentry/Bugsnag/redbox keep working.
|
|
7
|
+
*
|
|
8
|
+
* Deduplication: identical (type + message) errors inside a 5-minute window
|
|
9
|
+
* collapse to a single log, matching the Android ErrorInstrumentation
|
|
10
|
+
* policy (loops that throw on every frame shouldn't DoS the collector).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Dash0Mobile } from '../index';
|
|
14
|
+
|
|
15
|
+
export const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
|
16
|
+
const SEVERITY_ERROR = 17 as const;
|
|
17
|
+
const SEVERITY_FATAL = 21 as const;
|
|
18
|
+
|
|
19
|
+
type ErrorHandler = (error: Error, isFatal: boolean) => void;
|
|
20
|
+
|
|
21
|
+
interface ErrorUtilsLike {
|
|
22
|
+
getGlobalHandler(): ErrorHandler;
|
|
23
|
+
setGlobalHandler(handler: ErrorHandler): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveErrorUtils(): ErrorUtilsLike | null {
|
|
27
|
+
const g = globalThis as unknown as { ErrorUtils?: ErrorUtilsLike };
|
|
28
|
+
return g.ErrorUtils ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dedupeKey(err: Error): string {
|
|
32
|
+
const type = err?.name ?? 'Error';
|
|
33
|
+
const message = err?.message ?? '';
|
|
34
|
+
return `${type}::${message}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installErrorInstrumentation(): () => void {
|
|
38
|
+
const errorUtils = resolveErrorUtils();
|
|
39
|
+
if (!errorUtils) {
|
|
40
|
+
// Non-RN environment — caller is probably in Jest/SSR. Return a no-op
|
|
41
|
+
// uninstaller so callers don't need to gate on platform.
|
|
42
|
+
return () => {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const previous = errorUtils.getGlobalHandler();
|
|
46
|
+
const recentlySeen = new Map<string, number>();
|
|
47
|
+
|
|
48
|
+
const handler: ErrorHandler = (error, isFatal) => {
|
|
49
|
+
const key = dedupeKey(error);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const lastAt = recentlySeen.get(key);
|
|
52
|
+
if (lastAt === undefined || now - lastAt >= DEDUPE_WINDOW_MS) {
|
|
53
|
+
recentlySeen.set(key, now);
|
|
54
|
+
Dash0Mobile.log(
|
|
55
|
+
'app.error',
|
|
56
|
+
{
|
|
57
|
+
'exception.type': error?.name ?? 'Error',
|
|
58
|
+
'exception.message': error?.message ?? String(error),
|
|
59
|
+
'exception.stacktrace': error?.stack ?? '',
|
|
60
|
+
},
|
|
61
|
+
isFatal ? SEVERITY_FATAL : SEVERITY_ERROR,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Always chain — our capture must never swallow crashes from other
|
|
65
|
+
// reporters or the default redbox.
|
|
66
|
+
try {
|
|
67
|
+
previous(error, isFatal);
|
|
68
|
+
} catch {
|
|
69
|
+
// The previous handler throwing would normally be catastrophic, but
|
|
70
|
+
// we've already emitted the log — nothing safe to do here.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
errorUtils.setGlobalHandler(handler);
|
|
75
|
+
|
|
76
|
+
return function uninstall() {
|
|
77
|
+
// Best-effort restore. If someone chained on top of us we can't cleanly
|
|
78
|
+
// unwind — restore `previous` and accept the break (matches how Sentry
|
|
79
|
+
// handles the same race).
|
|
80
|
+
if (errorUtils.getGlobalHandler() === handler) {
|
|
81
|
+
errorUtils.setGlobalHandler(previous);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch() auto-instrumentation for React Native.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the global fetch so every HTTP call produces a CLIENT span with
|
|
5
|
+
* OTel HTTP semconv v1.23+ attributes. Matches attribute names emitted by
|
|
6
|
+
* the iOS URLProtocol interceptor and the Android OkHttp interceptor so a
|
|
7
|
+
* single Dash0 filter surfaces network spans across all three platforms.
|
|
8
|
+
*
|
|
9
|
+
* Self-capture avoidance: pass the collector host via `ignoredHosts` so
|
|
10
|
+
* OTLP export requests don't generate spans that then need to be exported.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Dash0Mobile } from '../index';
|
|
14
|
+
|
|
15
|
+
export interface FetchInstrumentationConfig {
|
|
16
|
+
ignoredHosts?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type FetchFn = typeof globalThis.fetch;
|
|
20
|
+
|
|
21
|
+
function hostFromUrl(url: string): string | null {
|
|
22
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\/([^/:?#]+)/i.exec(url);
|
|
23
|
+
return match ? match[1].toLowerCase() : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveUrl(input: RequestInfo | URL): string {
|
|
27
|
+
if (typeof input === 'string') return input;
|
|
28
|
+
if (input instanceof URL) return input.toString();
|
|
29
|
+
return (input as Request).url;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
33
|
+
const m = init?.method ?? (input as Request)?.method ?? 'GET';
|
|
34
|
+
return m.toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installFetchInstrumentation(
|
|
38
|
+
config: FetchInstrumentationConfig = {},
|
|
39
|
+
): () => void {
|
|
40
|
+
const original: FetchFn = globalThis.fetch;
|
|
41
|
+
const ignored = new Set(
|
|
42
|
+
(config.ignoredHosts ?? []).map(h => h.toLowerCase()),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const wrapped: FetchFn = async (input, init) => {
|
|
46
|
+
const url = resolveUrl(input);
|
|
47
|
+
const host = hostFromUrl(url);
|
|
48
|
+
if (host && ignored.has(host)) {
|
|
49
|
+
return original(input, init);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const method = resolveMethod(input, init);
|
|
53
|
+
const handle = Dash0Mobile.startSpan(
|
|
54
|
+
method,
|
|
55
|
+
{
|
|
56
|
+
'http.request.method': method,
|
|
57
|
+
'url.full': url,
|
|
58
|
+
...(host ? { 'server.address': host } : {}),
|
|
59
|
+
},
|
|
60
|
+
'CLIENT',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await original(input, init);
|
|
65
|
+
const status = (response as Response | undefined)?.status;
|
|
66
|
+
if (typeof status === 'number') {
|
|
67
|
+
handle.setAttribute('http.response.status_code', status);
|
|
68
|
+
if (status >= 400) {
|
|
69
|
+
handle.setStatus('ERROR', `HTTP ${status}`);
|
|
70
|
+
} else {
|
|
71
|
+
handle.setStatus('OK');
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
handle.setStatus('OK');
|
|
75
|
+
}
|
|
76
|
+
return response;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
handle.setStatus('ERROR', message);
|
|
80
|
+
throw err;
|
|
81
|
+
} finally {
|
|
82
|
+
handle.end();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
globalThis.fetch = wrapped;
|
|
87
|
+
|
|
88
|
+
return function uninstall() {
|
|
89
|
+
if (globalThis.fetch === wrapped) {
|
|
90
|
+
globalThis.fetch = original;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Navigation auto-instrumentation (opt-in).
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const navRef = useNavigationContainerRef();
|
|
6
|
+
* useEffect(() => installReactNavigationInstrumentation(navRef), [navRef]);
|
|
7
|
+
*
|
|
8
|
+
* Emits `ui.screen_view` logs and `page.<name>` spans keyed by the current
|
|
9
|
+
* route. Consecutive emissions for the same route are suppressed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Dash0Mobile } from '../index';
|
|
13
|
+
import type { SpanHandle } from '../index';
|
|
14
|
+
|
|
15
|
+
interface NavRefLike {
|
|
16
|
+
addListener(type: string, listener: () => void): () => void;
|
|
17
|
+
getCurrentRoute(): { name: string } | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function installReactNavigationInstrumentation(
|
|
21
|
+
navRef: NavRefLike | null | undefined,
|
|
22
|
+
): () => void {
|
|
23
|
+
if (!navRef) return () => {};
|
|
24
|
+
|
|
25
|
+
let currentName: string | null = null;
|
|
26
|
+
let currentSpan: SpanHandle | null = null;
|
|
27
|
+
|
|
28
|
+
const onState = () => {
|
|
29
|
+
const route = navRef.getCurrentRoute();
|
|
30
|
+
if (!route) return;
|
|
31
|
+
if (route.name === currentName) return;
|
|
32
|
+
|
|
33
|
+
if (currentSpan) {
|
|
34
|
+
currentSpan.end();
|
|
35
|
+
currentSpan = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
currentName = route.name;
|
|
39
|
+
Dash0Mobile.log('ui.screen_view', { 'screen.name': route.name }, 9);
|
|
40
|
+
currentSpan = Dash0Mobile.startSpan(`page.${route.name}`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const unsub = navRef.addListener('state', onState);
|
|
44
|
+
|
|
45
|
+
return function uninstall() {
|
|
46
|
+
unsub();
|
|
47
|
+
if (currentSpan) {
|
|
48
|
+
currentSpan.end();
|
|
49
|
+
currentSpan = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|