@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
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Dash0Mobile } from '../index';
|
|
10
|
+
import { sanitizeMessage, sanitizeStacktrace } from '../redact';
|
|
10
11
|
|
|
11
12
|
const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
|
13
|
+
/** Cap on the dedupe map so a high-cardinality rejection storm can't leak memory. */
|
|
14
|
+
const DEDUPE_MAX_ENTRIES = 256;
|
|
12
15
|
const SEVERITY_ERROR = 17 as const;
|
|
13
16
|
|
|
14
17
|
type RejectionEvent = { reason: unknown };
|
|
@@ -41,15 +44,21 @@ export function installUnhandledRejectionInstrumentation(): () => void {
|
|
|
41
44
|
const now = Date.now();
|
|
42
45
|
const lastAt = recentlySeen.get(key);
|
|
43
46
|
if (lastAt !== undefined && now - lastAt < DEDUPE_WINDOW_MS) return;
|
|
47
|
+
// LRU-style insert + cap so a rejection storm can't grow the map forever.
|
|
48
|
+
recentlySeen.delete(key);
|
|
44
49
|
recentlySeen.set(key, now);
|
|
50
|
+
if (recentlySeen.size > DEDUPE_MAX_ENTRIES) {
|
|
51
|
+
const oldest = recentlySeen.keys().next().value;
|
|
52
|
+
if (oldest !== undefined) recentlySeen.delete(oldest);
|
|
53
|
+
}
|
|
45
54
|
|
|
46
55
|
if (reason instanceof Error) {
|
|
47
56
|
Dash0Mobile.log(
|
|
48
57
|
'app.error',
|
|
49
58
|
{
|
|
50
59
|
'exception.type': reason.name ?? 'Error',
|
|
51
|
-
'exception.message': reason.message ?? String(reason),
|
|
52
|
-
'exception.stacktrace': reason.stack ?? '',
|
|
60
|
+
'exception.message': sanitizeMessage(reason.message ?? String(reason)),
|
|
61
|
+
'exception.stacktrace': sanitizeStacktrace(reason.stack ?? ''),
|
|
53
62
|
'exception.escaped': true,
|
|
54
63
|
},
|
|
55
64
|
SEVERITY_ERROR,
|
|
@@ -59,7 +68,7 @@ export function installUnhandledRejectionInstrumentation(): () => void {
|
|
|
59
68
|
'app.error',
|
|
60
69
|
{
|
|
61
70
|
'exception.type': 'UnhandledRejection',
|
|
62
|
-
'exception.message': String(reason),
|
|
71
|
+
'exception.message': sanitizeMessage(String(reason)),
|
|
63
72
|
'exception.escaped': true,
|
|
64
73
|
},
|
|
65
74
|
SEVERITY_ERROR,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { Dash0Mobile } from '../index';
|
|
12
12
|
import type { SpanHandle } from '../index';
|
|
13
|
+
import { sanitizeUrl } from '../redact';
|
|
13
14
|
|
|
14
15
|
export interface XhrInstrumentationConfig {
|
|
15
16
|
ignoredHosts?: readonly string[];
|
|
@@ -52,8 +53,12 @@ export function installXhrInstrumentation(
|
|
|
52
53
|
url: string | URL,
|
|
53
54
|
...rest: unknown[]
|
|
54
55
|
) {
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
try {
|
|
57
|
+
instance.__dash0_method = method.toUpperCase();
|
|
58
|
+
instance.__dash0_url = typeof url === 'string' ? url : url.toString();
|
|
59
|
+
} catch {
|
|
60
|
+
// Capturing request metadata must never block the real open().
|
|
61
|
+
}
|
|
57
62
|
// @ts-expect-error — forwarding variadic
|
|
58
63
|
return originalOpen(method, url, ...rest);
|
|
59
64
|
};
|
|
@@ -62,47 +67,58 @@ export function installXhrInstrumentation(
|
|
|
62
67
|
(instance as unknown as { send: XMLHttpRequest['send'] }).send = function send(
|
|
63
68
|
body?: Document | XMLHttpRequestBodyInit | null,
|
|
64
69
|
) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
'
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (status >= 400) {
|
|
92
|
-
span.setStatus('ERROR', `HTTP ${status}`);
|
|
70
|
+
// ALL telemetry setup is best-effort: a throw here must never stop
|
|
71
|
+
// the host's real request from going out.
|
|
72
|
+
try {
|
|
73
|
+
const url = instance.__dash0_url ?? '';
|
|
74
|
+
const host = hostFromUrl(url);
|
|
75
|
+
if (!host || !ignored.has(host)) {
|
|
76
|
+
const method = instance.__dash0_method ?? 'GET';
|
|
77
|
+
instance.__dash0_span = Dash0Mobile.startSpan(
|
|
78
|
+
`${method} ${host ?? 'unknown'}`,
|
|
79
|
+
{
|
|
80
|
+
'http.request.method': method,
|
|
81
|
+
'url.full': sanitizeUrl(url),
|
|
82
|
+
...(host ? { 'server.address': host } : {}),
|
|
83
|
+
},
|
|
84
|
+
'CLIENT',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const onError = () => {
|
|
88
|
+
instance.__dash0_failed = true;
|
|
89
|
+
};
|
|
90
|
+
const onLoadEnd = () => {
|
|
91
|
+
try {
|
|
92
|
+
const span = instance.__dash0_span;
|
|
93
|
+
if (!span) return;
|
|
94
|
+
if (instance.__dash0_failed) {
|
|
95
|
+
span.setStatus('ERROR', 'network error');
|
|
93
96
|
} else {
|
|
94
|
-
|
|
97
|
+
const status = instance.status;
|
|
98
|
+
if (typeof status === 'number' && status > 0) {
|
|
99
|
+
span.setAttribute('http.response.status_code', status);
|
|
100
|
+
if (status >= 400) {
|
|
101
|
+
span.setStatus('ERROR', `HTTP ${status}`);
|
|
102
|
+
} else {
|
|
103
|
+
span.setStatus('OK');
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
span.setStatus('OK');
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
span.end();
|
|
110
|
+
instance.__dash0_span = undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
// Telemetry finalization failure must not surface to the host.
|
|
98
113
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
instance.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
instance.addEventListener('error', onError);
|
|
117
|
+
instance.addEventListener('loadend', onLoadEnd);
|
|
118
|
+
}
|
|
119
|
+
} catch (telemetryErr) {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.warn?.('[@dash0/mobile] xhr instrumentation setup failed', telemetryErr);
|
|
106
122
|
}
|
|
107
123
|
return originalSend(body);
|
|
108
124
|
};
|
|
@@ -111,6 +127,13 @@ export function installXhrInstrumentation(
|
|
|
111
127
|
},
|
|
112
128
|
});
|
|
113
129
|
|
|
130
|
+
// Double-install guard: Fast Refresh / repeated start() would otherwise
|
|
131
|
+
// stack Proxy wrappers and leak the original constructor.
|
|
132
|
+
if ((OriginalXHR as { __dash0_installed?: boolean }).__dash0_installed) {
|
|
133
|
+
return () => {};
|
|
134
|
+
}
|
|
135
|
+
(OriginalXHR as { __dash0_installed?: boolean }).__dash0_installed = true;
|
|
136
|
+
|
|
114
137
|
(globalThis as unknown as { XMLHttpRequest: XhrCtor }).XMLHttpRequest =
|
|
115
138
|
wrapped as XhrCtor;
|
|
116
139
|
|
|
@@ -121,5 +144,7 @@ export function installXhrInstrumentation(
|
|
|
121
144
|
(globalThis as unknown as { XMLHttpRequest?: XhrCtor }).XMLHttpRequest =
|
|
122
145
|
OriginalXHR;
|
|
123
146
|
}
|
|
147
|
+
// Clear the guard so a later install() can re-instrument.
|
|
148
|
+
delete (OriginalXHR as { __dash0_installed?: boolean }).__dash0_installed;
|
|
124
149
|
};
|
|
125
150
|
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
|
|
11
|
+
/** Max length for a sanitized URL (origin + path). */
|
|
12
|
+
const MAX_URL_LEN = 2048;
|
|
13
|
+
/** Max length for an exception message. */
|
|
14
|
+
export const MAX_MESSAGE_LEN = 1024; // 1KB
|
|
15
|
+
/** Max length for an exception stacktrace. */
|
|
16
|
+
export const MAX_STACKTRACE_LEN = 8192; // 8KB
|
|
17
|
+
|
|
18
|
+
function truncate(value: string, max: number): string {
|
|
19
|
+
if (value.length <= max) return value;
|
|
20
|
+
return `${value.slice(0, max)}…[truncated]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip query string + fragment from a URL (they routinely carry
|
|
25
|
+
* `?token=`, `?sig=`, session ids), keeping origin + path, and cap length.
|
|
26
|
+
* Best-effort: if the input isn't a parseable URL we fall back to a manual
|
|
27
|
+
* `?`/`#` split so we never leak a query string just because parsing failed.
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeUrl(url: string): string {
|
|
30
|
+
if (typeof url !== 'string' || url.length === 0) return '';
|
|
31
|
+
let cleaned = url;
|
|
32
|
+
const queryIdx = cleaned.search(/[?#]/);
|
|
33
|
+
if (queryIdx >= 0) {
|
|
34
|
+
cleaned = cleaned.slice(0, queryIdx);
|
|
35
|
+
}
|
|
36
|
+
return truncate(cleaned, MAX_URL_LEN);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Obvious secret patterns. Intentionally conservative — false positives are
|
|
40
|
+
// cheaper than leaked credentials, but we don't try to be a full DLP engine.
|
|
41
|
+
const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
42
|
+
// token=... / access_token=... in query-string or body form
|
|
43
|
+
[/([?&#]|\b)((?:access_|refresh_|id_)?token|api[_-]?key|secret|password|pwd|sig|signature)=[^&\s"']+/gi, '$1$2=[REDACTED]'],
|
|
44
|
+
// Authorization: Bearer <jwt-ish>
|
|
45
|
+
[/\bbearer\s+[A-Za-z0-9._\-+/]+=*/gi, 'bearer [REDACTED]'],
|
|
46
|
+
// long base64-ish runs (≥32 chars) that look like raw credentials
|
|
47
|
+
[/\b[A-Za-z0-9_\-+/]{32,}={0,2}\b/g, '[REDACTED]'],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run a light secret scrubber over free-text telemetry (error messages,
|
|
52
|
+
* stack traces) and cap its length.
|
|
53
|
+
*/
|
|
54
|
+
export function scrubText(value: string, max: number): string {
|
|
55
|
+
if (typeof value !== 'string' || value.length === 0) return value ?? '';
|
|
56
|
+
let scrubbed = value;
|
|
57
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
58
|
+
scrubbed = scrubbed.replace(pattern, replacement);
|
|
59
|
+
}
|
|
60
|
+
return truncate(scrubbed, max);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scrub + cap an exception message (≤1KB). */
|
|
64
|
+
export function sanitizeMessage(message: string): string {
|
|
65
|
+
return scrubText(message, MAX_MESSAGE_LEN);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Scrub + cap an exception stacktrace (≤8KB). */
|
|
69
|
+
export function sanitizeStacktrace(stack: string): string {
|
|
70
|
+
return scrubText(stack, MAX_STACKTRACE_LEN);
|
|
71
|
+
}
|