@barrysolomon/mobile-react-native 0.1.1-alpha → 0.2.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 +4 -0
- package/README.md +27 -0
- package/android/build.gradle +73 -6
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/android/gradle.properties +27 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +62 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/BridgeCallSink.kt +42 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobileModule.kt +35 -7
- package/android/src/main/java/com/dash0/mobile/reactnative/Dash0MobilePackage.kt +71 -2
- package/android/src/main/java/com/dash0/mobile/reactnative/NetworkInstrumentation.kt +28 -0
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelMobileCallSink.kt +61 -1
- package/android/src/main/java/com/dash0/mobile/reactnative/OTelNetworkInterceptor.kt +206 -0
- package/android/src/test/java/com/dash0/mobile/reactnative/Dash0MobileModuleTest.kt +81 -6
- package/android/src/test/java/com/dash0/mobile/reactnative/OTelNetworkInterceptorTest.kt +381 -0
- package/ios/BoundedLiveSpanStore.swift +138 -0
- package/ios/BridgeCallSink.swift +49 -1
- package/ios/Dash0MobileBridgeDispatcher.swift +23 -1
- package/ios/OTelMobileCallSink.swift +205 -34
- package/ios/RCTDash0MobileModule.swift +10 -2
- package/ios/Tests/BoundedLiveSpanStoreTests.swift +143 -0
- package/ios/Tests/Dash0MobileBridgeDispatcherTests.swift +63 -0
- package/ios/Tests/OTelMobileCallSinkTests.swift +243 -0
- package/lib/bridge/types.d.ts +35 -0
- package/lib/bridge/types.d.ts.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +41 -1
- package/lib/instrumentation/errors.d.ts.map +1 -1
- package/lib/instrumentation/errors.js +13 -2
- package/lib/instrumentation/fetch.d.ts.map +1 -1
- package/lib/instrumentation/fetch.js +58 -22
- package/lib/instrumentation/navigation.d.ts.map +1 -1
- package/lib/instrumentation/navigation.js +32 -8
- package/lib/instrumentation/unhandledRejection.d.ts.map +1 -1
- package/lib/instrumentation/unhandledRejection.js +13 -3
- package/lib/instrumentation/xhr.d.ts.map +1 -1
- package/lib/instrumentation/xhr.js +63 -36
- package/lib/redact.d.ts +30 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +67 -0
- package/package.json +1 -1
- package/src/bridge/types.ts +36 -0
- package/src/index.ts +43 -2
- package/src/instrumentation/errors.ts +12 -2
- package/src/instrumentation/fetch.ts +60 -27
- package/src/instrumentation/navigation.ts +40 -8
- package/src/instrumentation/unhandledRejection.ts +12 -3
- package/src/instrumentation/xhr.ts +65 -40
- 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;
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
80
|
+
catch {
|
|
81
|
+
// Telemetry finalization failure must not surface to the host.
|
|
65
82
|
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
instance.
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
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
|
}
|
package/lib/redact.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.2.0-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",
|
package/src/bridge/types.ts
CHANGED
|
@@ -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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
}
|
|
74
|
-
|
|
87
|
+
} catch {
|
|
88
|
+
// Telemetry bookkeeping failure must not mask a successful response.
|
|
75
89
|
}
|
|
76
90
|
return response;
|
|
77
91
|
} catch (err) {
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
100
|
+
try {
|
|
101
|
+
handle.end();
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
83
105
|
}
|
|
84
106
|
};
|
|
85
107
|
|
|
86
|
-
|
|
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 (
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
currentSpan = null;
|
|
50
|
-
}
|
|
81
|
+
appStateSub?.remove?.();
|
|
82
|
+
endCurrentSpan();
|
|
51
83
|
};
|
|
52
84
|
}
|