@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/Dash0Mobile.podspec +4 -0
  2. package/README.md +113 -28
  3. package/android/build.gradle +73 -6
  4. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  5. package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
  6. package/android/gradle.properties +27 -0
  7. package/android/gradlew +251 -0
  8. package/android/gradlew.bat +94 -0
  9. package/android/settings.gradle +62 -0
  10. package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
  11. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
  12. package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
  13. package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
  14. package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
  15. package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
  16. package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
  17. package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
  18. package/ios/BoundedLiveSpanStore.swift +138 -0
  19. package/ios/BridgeCallSink.swift +49 -1
  20. package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
  21. package/ios/OTelMobileCallSink.swift +205 -34
  22. package/ios/RCTDash0MobileModule.swift +10 -2
  23. package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
  24. package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
  25. package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
  26. package/lib/bridge/types.d.ts +35 -0
  27. package/lib/bridge/types.d.ts.map +1 -1
  28. package/lib/index.d.ts +1 -1
  29. package/lib/index.d.ts.map +1 -1
  30. package/lib/index.js +42 -2
  31. package/lib/instrumentation/errors.d.ts.map +1 -1
  32. package/lib/instrumentation/errors.js +13 -2
  33. package/lib/instrumentation/fetch.d.ts.map +1 -1
  34. package/lib/instrumentation/fetch.js +58 -22
  35. package/lib/instrumentation/navigation.d.ts.map +1 -1
  36. package/lib/instrumentation/navigation.js +32 -8
  37. package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
  38. package/lib/instrumentation/unhandledRejection.js +13 -3
  39. package/lib/instrumentation/xhr.d.ts.map +1 -1
  40. package/lib/instrumentation/xhr.js +63 -36
  41. package/lib/redact.d.ts +30 -0
  42. package/lib/redact.d.ts.map +1 -0
  43. package/lib/redact.js +67 -0
  44. package/package.json +1 -1
  45. package/src/bridge/types.ts +36 -0
  46. package/src/index.ts +44 -3
  47. package/src/instrumentation/errors.ts +12 -2
  48. package/src/instrumentation/fetch.ts +60 -27
  49. package/src/instrumentation/navigation.ts +40 -8
  50. package/src/instrumentation/unhandledRejection.ts +12 -3
  51. package/src/instrumentation/xhr.ts +65 -40
  52. package/src/redact.ts +71 -0
@@ -6,7 +6,10 @@
6
6
  * ErrorInstrumentation dedupe semantics.
7
7
  */
8
8
  import { Dash0Mobile } from '../index';
9
+ import { sanitizeMessage, sanitizeStacktrace } from '../redact';
9
10
  const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
11
+ /** Cap on the dedupe map so a high-cardinality rejection storm can't leak memory. */
12
+ const DEDUPE_MAX_ENTRIES = 256;
10
13
  const SEVERITY_ERROR = 17;
11
14
  function dedupeKey(reason) {
12
15
  if (reason instanceof Error) {
@@ -28,19 +31,26 @@ export function installUnhandledRejectionInstrumentation() {
28
31
  const lastAt = recentlySeen.get(key);
29
32
  if (lastAt !== undefined && now - lastAt < DEDUPE_WINDOW_MS)
30
33
  return;
34
+ // LRU-style insert + cap so a rejection storm can't grow the map forever.
35
+ recentlySeen.delete(key);
31
36
  recentlySeen.set(key, now);
37
+ if (recentlySeen.size > DEDUPE_MAX_ENTRIES) {
38
+ const oldest = recentlySeen.keys().next().value;
39
+ if (oldest !== undefined)
40
+ recentlySeen.delete(oldest);
41
+ }
32
42
  if (reason instanceof Error) {
33
43
  Dash0Mobile.log('app.error', {
34
44
  'exception.type': reason.name ?? 'Error',
35
- 'exception.message': reason.message ?? String(reason),
36
- 'exception.stacktrace': reason.stack ?? '',
45
+ 'exception.message': sanitizeMessage(reason.message ?? String(reason)),
46
+ 'exception.stacktrace': sanitizeStacktrace(reason.stack ?? ''),
37
47
  'exception.escaped': true,
38
48
  }, SEVERITY_ERROR);
39
49
  }
40
50
  else {
41
51
  Dash0Mobile.log('app.error', {
42
52
  'exception.type': 'UnhandledRejection',
43
- 'exception.message': String(reason),
53
+ 'exception.message': sanitizeMessage(String(reason)),
44
54
  'exception.escaped': true,
45
55
  }, SEVERITY_ERROR);
46
56
  }
@@ -1 +1 @@
1
- {"version":3,"file":"xhr.d.ts","sourceRoot":"","sources":["../../src/instrumentation/xhr.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,WAAW,wBAAwB;IACvC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAWD,wBAAgB,yBAAyB,CACvC,MAAM,GAAE,wBAA6B,GACpC,MAAM,IAAI,CAgGZ"}
1
+ {"version":3,"file":"xhr.d.ts","sourceRoot":"","sources":["../../src/instrumentation/xhr.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,MAAM,WAAW,wBAAwB;IACvC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAWD,wBAAgB,yBAAyB,CACvC,MAAM,GAAE,wBAA6B,GACpC,MAAM,IAAI,CAwHZ"}
@@ -8,6 +8,7 @@
8
8
  * Dash0 queries see a single consistent HTTP span schema.
9
9
  */
10
10
  import { Dash0Mobile } from '../index';
11
+ import { sanitizeUrl } from '../redact';
11
12
  const HOST_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/([^/:?#]+)/i;
12
13
  function hostFromUrl(url) {
13
14
  const match = url.match(HOST_PATTERN);
@@ -23,58 +24,82 @@ export function installXhrInstrumentation(config = {}) {
23
24
  const instance = Reflect.construct(target, args, newTarget);
24
25
  const originalOpen = instance.open.bind(instance);
25
26
  instance.open = function open(method, url, ...rest) {
26
- instance.__dash0_method = method.toUpperCase();
27
- instance.__dash0_url = typeof url === 'string' ? url : url.toString();
27
+ try {
28
+ instance.__dash0_method = method.toUpperCase();
29
+ instance.__dash0_url = typeof url === 'string' ? url : url.toString();
30
+ }
31
+ catch {
32
+ // Capturing request metadata must never block the real open().
33
+ }
28
34
  // @ts-expect-error — forwarding variadic
29
35
  return originalOpen(method, url, ...rest);
30
36
  };
31
37
  const originalSend = instance.send.bind(instance);
32
38
  instance.send = function send(body) {
33
- const url = instance.__dash0_url ?? '';
34
- const host = hostFromUrl(url);
35
- if (!host || !ignored.has(host)) {
36
- const method = instance.__dash0_method ?? 'GET';
37
- instance.__dash0_span = Dash0Mobile.startSpan(`${method} ${host ?? 'unknown'}`, {
38
- 'http.request.method': method,
39
- 'url.full': url,
40
- ...(host ? { 'server.address': host } : {}),
41
- }, 'CLIENT');
42
- const onError = () => {
43
- instance.__dash0_failed = true;
44
- };
45
- const onLoadEnd = () => {
46
- const span = instance.__dash0_span;
47
- if (!span)
48
- return;
49
- if (instance.__dash0_failed) {
50
- span.setStatus('ERROR', 'network error');
51
- }
52
- else {
53
- const status = instance.status;
54
- if (typeof status === 'number' && status > 0) {
55
- span.setAttribute('http.response.status_code', status);
56
- if (status >= 400) {
57
- span.setStatus('ERROR', `HTTP ${status}`);
39
+ // ALL telemetry setup is best-effort: a throw here must never stop
40
+ // the host's real request from going out.
41
+ try {
42
+ const url = instance.__dash0_url ?? '';
43
+ const host = hostFromUrl(url);
44
+ if (!host || !ignored.has(host)) {
45
+ const method = instance.__dash0_method ?? 'GET';
46
+ instance.__dash0_span = Dash0Mobile.startSpan(`${method} ${host ?? 'unknown'}`, {
47
+ 'http.request.method': method,
48
+ 'url.full': sanitizeUrl(url),
49
+ ...(host ? { 'server.address': host } : {}),
50
+ }, 'CLIENT');
51
+ const onError = () => {
52
+ instance.__dash0_failed = true;
53
+ };
54
+ const onLoadEnd = () => {
55
+ try {
56
+ const span = instance.__dash0_span;
57
+ if (!span)
58
+ return;
59
+ if (instance.__dash0_failed) {
60
+ span.setStatus('ERROR', 'network error');
58
61
  }
59
62
  else {
60
- span.setStatus('OK');
63
+ const status = instance.status;
64
+ if (typeof status === 'number' && status > 0) {
65
+ span.setAttribute('http.response.status_code', status);
66
+ if (status >= 400) {
67
+ span.setStatus('ERROR', `HTTP ${status}`);
68
+ }
69
+ else {
70
+ span.setStatus('OK');
71
+ }
72
+ }
73
+ else {
74
+ span.setStatus('OK');
75
+ }
61
76
  }
77
+ span.end();
78
+ instance.__dash0_span = undefined;
62
79
  }
63
- else {
64
- span.setStatus('OK');
80
+ catch {
81
+ // Telemetry finalization failure must not surface to the host.
65
82
  }
66
- }
67
- span.end();
68
- instance.__dash0_span = undefined;
69
- };
70
- instance.addEventListener('error', onError);
71
- instance.addEventListener('loadend', onLoadEnd);
83
+ };
84
+ instance.addEventListener('error', onError);
85
+ instance.addEventListener('loadend', onLoadEnd);
86
+ }
87
+ }
88
+ catch (telemetryErr) {
89
+ // eslint-disable-next-line no-console
90
+ console.warn?.('[@dash0/mobile] xhr instrumentation setup failed', telemetryErr);
72
91
  }
73
92
  return originalSend(body);
74
93
  };
75
94
  return instance;
76
95
  },
77
96
  });
97
+ // Double-install guard: Fast Refresh / repeated start() would otherwise
98
+ // stack Proxy wrappers and leak the original constructor.
99
+ if (OriginalXHR.__dash0_installed) {
100
+ return () => { };
101
+ }
102
+ OriginalXHR.__dash0_installed = true;
78
103
  globalThis.XMLHttpRequest =
79
104
  wrapped;
80
105
  return function uninstall() {
@@ -84,5 +109,7 @@ export function installXhrInstrumentation(config = {}) {
84
109
  globalThis.XMLHttpRequest =
85
110
  OriginalXHR;
86
111
  }
112
+ // Clear the guard so a later install() can re-instrument.
113
+ delete OriginalXHR.__dash0_installed;
87
114
  };
88
115
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared redaction + truncation helpers.
3
+ *
4
+ * Telemetry attributes can carry secrets (query-string tokens, bearer
5
+ * headers echoed into error messages) and unbounded payloads (multi-MB
6
+ * stack traces). These helpers strip the obvious leaks and cap sizes so a
7
+ * single pathological event can't exfiltrate credentials or blow the
8
+ * bridge/export budget.
9
+ */
10
+ /** Max length for an exception message. */
11
+ export declare const MAX_MESSAGE_LEN = 1024;
12
+ /** Max length for an exception stacktrace. */
13
+ export declare const MAX_STACKTRACE_LEN = 8192;
14
+ /**
15
+ * Strip query string + fragment from a URL (they routinely carry
16
+ * `?token=`, `?sig=`, session ids), keeping origin + path, and cap length.
17
+ * Best-effort: if the input isn't a parseable URL we fall back to a manual
18
+ * `?`/`#` split so we never leak a query string just because parsing failed.
19
+ */
20
+ export declare function sanitizeUrl(url: string): string;
21
+ /**
22
+ * Run a light secret scrubber over free-text telemetry (error messages,
23
+ * stack traces) and cap its length.
24
+ */
25
+ export declare function scrubText(value: string, max: number): string;
26
+ /** Scrub + cap an exception message (≤1KB). */
27
+ export declare function sanitizeMessage(message: string): string;
28
+ /** Scrub + cap an exception stacktrace (≤8KB). */
29
+ export declare function sanitizeStacktrace(stack: string): string;
30
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,2CAA2C;AAC3C,eAAO,MAAM,eAAe,OAAO,CAAC;AACpC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAOvC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ/C;AAaD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAO5D;AAED,+CAA+C;AAC/C,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,kDAAkD;AAClD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAExD"}
package/lib/redact.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared redaction + truncation helpers.
3
+ *
4
+ * Telemetry attributes can carry secrets (query-string tokens, bearer
5
+ * headers echoed into error messages) and unbounded payloads (multi-MB
6
+ * stack traces). These helpers strip the obvious leaks and cap sizes so a
7
+ * single pathological event can't exfiltrate credentials or blow the
8
+ * bridge/export budget.
9
+ */
10
+ /** Max length for a sanitized URL (origin + path). */
11
+ const MAX_URL_LEN = 2048;
12
+ /** Max length for an exception message. */
13
+ export const MAX_MESSAGE_LEN = 1024; // 1KB
14
+ /** Max length for an exception stacktrace. */
15
+ export const MAX_STACKTRACE_LEN = 8192; // 8KB
16
+ function truncate(value, max) {
17
+ if (value.length <= max)
18
+ return value;
19
+ return `${value.slice(0, max)}…[truncated]`;
20
+ }
21
+ /**
22
+ * Strip query string + fragment from a URL (they routinely carry
23
+ * `?token=`, `?sig=`, session ids), keeping origin + path, and cap length.
24
+ * Best-effort: if the input isn't a parseable URL we fall back to a manual
25
+ * `?`/`#` split so we never leak a query string just because parsing failed.
26
+ */
27
+ export function sanitizeUrl(url) {
28
+ if (typeof url !== 'string' || url.length === 0)
29
+ return '';
30
+ let cleaned = url;
31
+ const queryIdx = cleaned.search(/[?#]/);
32
+ if (queryIdx >= 0) {
33
+ cleaned = cleaned.slice(0, queryIdx);
34
+ }
35
+ return truncate(cleaned, MAX_URL_LEN);
36
+ }
37
+ // Obvious secret patterns. Intentionally conservative — false positives are
38
+ // cheaper than leaked credentials, but we don't try to be a full DLP engine.
39
+ const SECRET_PATTERNS = [
40
+ // token=... / access_token=... in query-string or body form
41
+ [/([?&#]|\b)((?:access_|refresh_|id_)?token|api[_-]?key|secret|password|pwd|sig|signature)=[^&\s"']+/gi, '$1$2=[REDACTED]'],
42
+ // Authorization: Bearer <jwt-ish>
43
+ [/\bbearer\s+[A-Za-z0-9._\-+/]+=*/gi, 'bearer [REDACTED]'],
44
+ // long base64-ish runs (≥32 chars) that look like raw credentials
45
+ [/\b[A-Za-z0-9_\-+/]{32,}={0,2}\b/g, '[REDACTED]'],
46
+ ];
47
+ /**
48
+ * Run a light secret scrubber over free-text telemetry (error messages,
49
+ * stack traces) and cap its length.
50
+ */
51
+ export function scrubText(value, max) {
52
+ if (typeof value !== 'string' || value.length === 0)
53
+ return value ?? '';
54
+ let scrubbed = value;
55
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
56
+ scrubbed = scrubbed.replace(pattern, replacement);
57
+ }
58
+ return truncate(scrubbed, max);
59
+ }
60
+ /** Scrub + cap an exception message (≤1KB). */
61
+ export function sanitizeMessage(message) {
62
+ return scrubText(message, MAX_MESSAGE_LEN);
63
+ }
64
+ /** Scrub + cap an exception stacktrace (≤8KB). */
65
+ export function sanitizeStacktrace(stack) {
66
+ return scrubText(stack, MAX_STACKTRACE_LEN);
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barrysolomon/mobile-react-native",
3
- "version": "0.1.1-alpha",
3
+ "version": "0.2.1-alpha",
4
4
  "description": "Dash0 Mobile Observability SDK for React Native (JS/TS bridge). For native iOS use the Swift Package at https://github.com/barrysolomon/mobile-otel. For native Android use io.opentelemetry.android:mobile on GitHub Packages.",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.js",
@@ -61,6 +61,36 @@ export interface WireframeAutoCapture {
61
61
  dedupeByContentHash?: boolean;
62
62
  }
63
63
 
64
+ /**
65
+ * Trace sampling strategy passed through the RN bridge into the native SDK's
66
+ * `SamplingConfig`. Field names and semantics mirror Android's
67
+ * `io.opentelemetry.android.mobile.sampling.SamplingConfig` and iOS's
68
+ * `SamplingConfig` factory constructors one-to-one.
69
+ *
70
+ * - `always_on` → `SamplingConfig.alwaysOn()` — sample 100% of spans.
71
+ * - `always_off` → `SamplingConfig.alwaysOff()` — drop all spans.
72
+ * - `dynamic` → `SamplingConfig.dynamic(normalRate, highPriorityRate)`
73
+ * — `normalRate` baseline with `highPriorityRate` for high-priority
74
+ * spans (page.* / app.startup).
75
+ *
76
+ * RN DEFAULT: when `sampling` is omitted the bridge defaults to `always_on`
77
+ * (NOT the native SDK's `dynamic(0.1)` default). RN manual spans
78
+ * (`Dash0Mobile.startSpan()`) are ROOT spans with arbitrary names; a 10%
79
+ * baseline silently drops ~90% of a user's very first span, which is a
80
+ * terrible first-run experience and historically read as "spans vanish."
81
+ * Sampling/rate-limiting for RN architectures belongs in the collector, not
82
+ * the on-device SDK. Pass an explicit `sampling` to opt back into on-device
83
+ * sampling. The native-only Android/iOS SDKs keep their `dynamic(0.1)`
84
+ * default — only the RN-bridged default changes.
85
+ */
86
+ export interface SamplingConfig {
87
+ strategy: 'always_on' | 'always_off' | 'dynamic';
88
+ /** Baseline rate for `dynamic` (0.0–1.0). Ignored for always_on/always_off. */
89
+ normalRate?: number;
90
+ /** Rate for high-priority spans under `dynamic` (0.0–1.0). Defaults to 1.0 natively. */
91
+ highPriorityRate?: number;
92
+ }
93
+
64
94
  export interface StartConfig {
65
95
  serviceName: string;
66
96
  serviceVersion?: string;
@@ -72,6 +102,12 @@ export interface StartConfig {
72
102
  diskBytes?: number;
73
103
  };
74
104
  enablePolicyPolling?: boolean;
105
+ /**
106
+ * Trace sampling strategy. Defaults to `always_on` on RN (see
107
+ * {@link SamplingConfig}) — unlike the native SDKs' `dynamic(0.1)` default.
108
+ * Omit to keep all spans on-device and rate-limit in the collector.
109
+ */
110
+ sampling?: SamplingConfig;
75
111
  /**
76
112
  * Toggles for JS-side auto-instrumentation (fetch/XHR spans, JS error +
77
113
  * unhandled rejection logs). Defaults to all-on. RN bridge uses the same
package/src/index.ts CHANGED
@@ -16,13 +16,14 @@ import type {
16
16
  Attributes,
17
17
  BridgePayload,
18
18
  NativeDash0MobileModule,
19
+ SamplingConfig,
19
20
  SeverityNumber,
20
21
  SpanKind,
21
22
  SpanStartPayload,
22
23
  StartConfig,
23
24
  } from './bridge/types';
24
25
 
25
- export type { Attributes, SeverityNumber, StartConfig } from './bridge/types';
26
+ export type { Attributes, SamplingConfig, SeverityNumber, StartConfig } from './bridge/types';
26
27
  export { installReactNavigationInstrumentation } from './instrumentation/navigation';
27
28
  export { withTapTelemetry } from './instrumentation/touch';
28
29
  export { otel } from './otel-compat';
@@ -141,7 +142,7 @@ function resolveNative(): NativeDash0MobileModule | null {
141
142
  // callers and correlate issues to a specific bridge release. Keep this in
142
143
  // sync with package.json on each release.
143
144
  const DISTRO_NAME = 'dash0-react-native';
144
- const DISTRO_VERSION = '0.1.0-alpha';
145
+ const DISTRO_VERSION = '0.2.1-alpha';
145
146
 
146
147
  function resolveReactNativeVersion(): string | undefined {
147
148
  try {
@@ -174,8 +175,16 @@ export const Dash0Mobile = {
174
175
  // iOS URLProtocol / NSException / signal-handler swizzles by default,
175
176
  // which collide with RN's new-arch JS event loop.
176
177
  const nativeAutoCapture = buildNativeAutoCaptureTokens(config.autoCapture);
178
+ // RN sampling default: always_on. RN manual spans are root spans with
179
+ // arbitrary names, so the native SDKs' dynamic(0.1) default would
180
+ // silently drop ~90% of a user's first span (Loper finding #4). Sampling
181
+ // for RN architectures belongs in the collector — callers can still opt
182
+ // into on-device sampling by passing an explicit `sampling`. See the
183
+ // SamplingConfig doc comment in bridge/types.ts.
184
+ const sampling: SamplingConfig = config.sampling ?? { strategy: 'always_on' };
177
185
  const mergedConfig: StartConfig & { nativeAutoCapture: string[] } = {
178
186
  ...config,
187
+ sampling,
179
188
  extraResourceAttributes: {
180
189
  'telemetry.distro.name': DISTRO_NAME,
181
190
  'telemetry.distro.version': DISTRO_VERSION,
@@ -202,7 +211,22 @@ export const Dash0Mobile = {
202
211
  if (!isReactNative()) {
203
212
  autoInstrUninstallers.push(installFetchInstrumentation({ ignoredHosts }));
204
213
  }
205
- autoInstrUninstallers.push(installXhrInstrumentation({ ignoredHosts }));
214
+ // Android network capture is owned by the native OkHttp interceptor
215
+ // (OTelNetworkInterceptor), installed pre-JS in Dash0MobilePackage. The
216
+ // native layer is the only one that sees traffic under Expo SDK 52+ —
217
+ // expo/fetch routes through OkHttp directly and never touches the JS
218
+ // `fetch`/`XMLHttpRequest` globals these shims wrap, so the JS XHR shim
219
+ // sees zero traffic there. The native interceptor also injects a
220
+ // W3C `traceparent` built from the real native span context, which the
221
+ // JS shim cannot do. Installing the JS XHR shim on Android on top of the
222
+ // native interceptor would (for non-Expo apps) double-count every
223
+ // request. So: gate the JS XHR shim OFF on Android. iOS keeps the JS XHR
224
+ // shim — its native URLProtocol is opt-in (off by default for RN), and
225
+ // RN iOS fetch is XHR-backed, so XHR remains the authoritative JS layer
226
+ // there.
227
+ if (!isAndroid()) {
228
+ autoInstrUninstallers.push(installXhrInstrumentation({ ignoredHosts }));
229
+ }
206
230
  }
207
231
  if (auto.errors !== false) {
208
232
  autoInstrUninstallers.push(installErrorInstrumentation());
@@ -402,6 +426,23 @@ function isReactNative(): boolean {
402
426
  return nav?.product === 'ReactNative';
403
427
  }
404
428
 
429
+ // True only on React Native running on Android. Reads `Platform.OS` from the
430
+ // react-native module rather than a global so it's accurate on both old and
431
+ // new arch. Used to gate OFF the JS `XMLHttpRequest` shim on Android, where
432
+ // the native OkHttp interceptor (OTelNetworkInterceptor) owns network capture
433
+ // and traceparent injection. Any failure to resolve `Platform` (Jest, SSR,
434
+ // non-RN) returns false so those environments keep the JS shim — exactly the
435
+ // behavior the dedup test pins.
436
+ function isAndroid(): boolean {
437
+ try {
438
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
439
+ const rn = require('react-native') as { Platform?: { OS?: string } };
440
+ return rn?.Platform?.OS === 'android';
441
+ } catch {
442
+ return false;
443
+ }
444
+ }
445
+
405
446
  // Map JS autoCapture flags onto native-capability tokens the bridge
406
447
  // translates to AutoCaptureOptions. Defaults are all-OFF: callers must
407
448
  // explicitly set `true` to enable a native suite.
@@ -11,8 +11,11 @@
11
11
  */
12
12
 
13
13
  import { Dash0Mobile } from '../index';
14
+ import { sanitizeMessage, sanitizeStacktrace } from '../redact';
14
15
 
15
16
  export const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
17
+ /** Cap on the dedupe map so a high-cardinality error storm can't leak memory. */
18
+ const DEDUPE_MAX_ENTRIES = 256;
16
19
  const SEVERITY_ERROR = 17 as const;
17
20
  const SEVERITY_FATAL = 21 as const;
18
21
 
@@ -50,13 +53,20 @@ export function installErrorInstrumentation(): () => void {
50
53
  const now = Date.now();
51
54
  const lastAt = recentlySeen.get(key);
52
55
  if (lastAt === undefined || now - lastAt >= DEDUPE_WINDOW_MS) {
56
+ // LRU-style insert: re-key so the freshest entries stay, then evict the
57
+ // oldest if we've blown the cap. Bounds memory under an error storm.
58
+ recentlySeen.delete(key);
53
59
  recentlySeen.set(key, now);
60
+ if (recentlySeen.size > DEDUPE_MAX_ENTRIES) {
61
+ const oldest = recentlySeen.keys().next().value;
62
+ if (oldest !== undefined) recentlySeen.delete(oldest);
63
+ }
54
64
  Dash0Mobile.log(
55
65
  'app.error',
56
66
  {
57
67
  'exception.type': error?.name ?? 'Error',
58
- 'exception.message': error?.message ?? String(error),
59
- 'exception.stacktrace': error?.stack ?? '',
68
+ 'exception.message': sanitizeMessage(error?.message ?? String(error)),
69
+ 'exception.stacktrace': sanitizeStacktrace(error?.stack ?? ''),
60
70
  },
61
71
  isFatal ? SEVERITY_FATAL : SEVERITY_ERROR,
62
72
  );
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { Dash0Mobile } from '../index';
14
+ import { sanitizeUrl } from '../redact';
14
15
 
15
16
  export interface FetchInstrumentationConfig {
16
17
  ignoredHosts?: readonly string[];
@@ -43,51 +44,83 @@ export function installFetchInstrumentation(
43
44
  );
44
45
 
45
46
  const wrapped: FetchFn = async (input, init) => {
46
- const url = resolveUrl(input);
47
- const host = hostFromUrl(url);
48
- if (host && ignored.has(host)) {
47
+ // Telemetry setup must never break the host's request. Build the span
48
+ // defensively; on ANY failure fall through to the raw call.
49
+ let handle: ReturnType<typeof Dash0Mobile.startSpan> | undefined;
50
+ try {
51
+ const url = resolveUrl(input);
52
+ const host = hostFromUrl(url);
53
+ if (host && ignored.has(host)) {
54
+ return original(input, init);
55
+ }
56
+ const method = resolveMethod(input, init);
57
+ handle = Dash0Mobile.startSpan(
58
+ method,
59
+ {
60
+ 'http.request.method': method,
61
+ 'url.full': sanitizeUrl(url),
62
+ ...(host ? { 'server.address': host } : {}),
63
+ },
64
+ 'CLIENT',
65
+ );
66
+ } catch (telemetryErr) {
67
+ // Span setup failed — host request must still proceed unobserved.
68
+ // eslint-disable-next-line no-console
69
+ console.warn?.('[@dash0/mobile] fetch instrumentation setup failed', telemetryErr);
49
70
  return original(input, init);
50
71
  }
51
72
 
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
73
  try {
64
74
  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}`);
75
+ try {
76
+ const status = (response as Response | undefined)?.status;
77
+ if (typeof status === 'number') {
78
+ handle.setAttribute('http.response.status_code', status);
79
+ if (status >= 400) {
80
+ handle.setStatus('ERROR', `HTTP ${status}`);
81
+ } else {
82
+ handle.setStatus('OK');
83
+ }
70
84
  } else {
71
85
  handle.setStatus('OK');
72
86
  }
73
- } else {
74
- handle.setStatus('OK');
87
+ } catch {
88
+ // Telemetry bookkeeping failure must not mask a successful response.
75
89
  }
76
90
  return response;
77
91
  } catch (err) {
78
- const message = err instanceof Error ? err.message : String(err);
79
- handle.setStatus('ERROR', message);
92
+ try {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ handle.setStatus('ERROR', message);
95
+ } catch {
96
+ // ignore telemetry failure on the error path
97
+ }
80
98
  throw err;
81
99
  } finally {
82
- handle.end();
100
+ try {
101
+ handle.end();
102
+ } catch {
103
+ // ignore
104
+ }
83
105
  }
84
106
  };
85
107
 
86
- globalThis.fetch = wrapped;
108
+ const g = globalThis as unknown as {
109
+ fetch: FetchFn & { __dash0_installed?: boolean };
110
+ };
111
+ // Double-install guard: Fast Refresh / repeated start() would otherwise
112
+ // stack wrappers and leak the captured `original`.
113
+ if (g.fetch && (g.fetch as { __dash0_installed?: boolean }).__dash0_installed) {
114
+ return () => {};
115
+ }
116
+ (wrapped as { __dash0_installed?: boolean }).__dash0_installed = true;
117
+ g.fetch = wrapped;
87
118
 
88
119
  return function uninstall() {
89
- if (globalThis.fetch === wrapped) {
90
- globalThis.fetch = original;
120
+ if (g.fetch === wrapped) {
121
+ g.fetch = original;
91
122
  }
123
+ // Clear the guard so a later install() can re-instrument.
124
+ delete (wrapped as { __dash0_installed?: boolean }).__dash0_installed;
92
125
  };
93
126
  }
@@ -25,15 +25,19 @@ export function installReactNavigationInstrumentation(
25
25
  let currentName: string | null = null;
26
26
  let currentSpan: SpanHandle | null = null;
27
27
 
28
+ const endCurrentSpan = () => {
29
+ if (currentSpan) {
30
+ currentSpan.end();
31
+ currentSpan = null;
32
+ }
33
+ };
34
+
28
35
  const onState = () => {
29
36
  const route = navRef.getCurrentRoute();
30
37
  if (!route) return;
31
38
  if (route.name === currentName) return;
32
39
 
33
- if (currentSpan) {
34
- currentSpan.end();
35
- currentSpan = null;
36
- }
40
+ endCurrentSpan();
37
41
 
38
42
  currentName = route.name;
39
43
  Dash0Mobile.log('ui.screen_view', { 'screen.name': route.name }, 9);
@@ -42,11 +46,39 @@ export function installReactNavigationInstrumentation(
42
46
 
43
47
  const unsub = navRef.addListener('state', onState);
44
48
 
49
+ // End the active route span when the app leaves the foreground. Without
50
+ // this, a span started on the visible screen stays open until the next
51
+ // route change or uninstall — which on background may be minutes/never,
52
+ // producing absurd durations. Re-entering 'active' lets the next route
53
+ // change open a fresh span. AppState is required lazily so non-RN
54
+ // (Jest/SSR) environments don't need the native module.
55
+ let appStateSub: { remove?: () => void } | undefined;
56
+ try {
57
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
58
+ const { AppState } = require('react-native') as {
59
+ AppState?: {
60
+ addEventListener: (
61
+ type: string,
62
+ listener: (state: string) => void,
63
+ ) => { remove?: () => void };
64
+ };
65
+ };
66
+ if (AppState && typeof AppState.addEventListener === 'function') {
67
+ appStateSub = AppState.addEventListener('change', (state: string) => {
68
+ if (state === 'background' || state === 'inactive') {
69
+ endCurrentSpan();
70
+ // Force the next foregrounded route to re-open a span.
71
+ currentName = null;
72
+ }
73
+ });
74
+ }
75
+ } catch {
76
+ // No AppState available (test/SSR) — background-end is best-effort.
77
+ }
78
+
45
79
  return function uninstall() {
46
80
  unsub();
47
- if (currentSpan) {
48
- currentSpan.end();
49
- currentSpan = null;
50
- }
81
+ appStateSub?.remove?.();
82
+ endCurrentSpan();
51
83
  };
52
84
  }