@debugbundle/sdk-react-native 1.3.0 → 3.0.0
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/CHANGELOG.md +22 -2
- package/DebugBundleReactNative.podspec +2 -2
- package/MIGRATION-3.0.md +9 -0
- package/README.md +8 -3
- package/android/build.gradle +3 -3
- package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativeModule.java +2 -2
- package/app.plugin.js +1 -1
- package/dist/before-send.d.ts.map +1 -1
- package/dist/before-send.js +6 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +238 -61
- package/dist/console.d.ts +4 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +5 -0
- package/dist/privacy-keys.d.ts +3 -0
- package/dist/privacy-keys.d.ts.map +1 -0
- package/dist/privacy-keys.js +35 -0
- package/dist/privacy-telemetry.d.ts +19 -0
- package/dist/privacy-telemetry.d.ts.map +1 -0
- package/dist/privacy-telemetry.js +164 -0
- package/dist/privacy-text.d.ts +3 -0
- package/dist/privacy-text.d.ts.map +1 -0
- package/dist/privacy-text.js +85 -0
- package/dist/redaction.d.ts.map +1 -1
- package/dist/redaction.js +29 -45
- package/ios/DebugBundleReactNative.swift +2 -2
- package/package.json +6 -5
- package/src/before-send.ts +6 -0
- package/src/client.ts +226 -70
- package/src/console.ts +6 -1
- package/src/privacy-keys.ts +39 -0
- package/src/privacy-telemetry.ts +168 -0
- package/src/privacy-text.ts +86 -0
- package/src/redaction.ts +26 -46
- package/android/src/test/java/com/debugbundle/reactnative/DebugBundleReactNativeModuleTest.java +0 -474
package/src/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getNativeModule, degradedNativeState, safeNativeCall } from "./native.js";
|
|
2
2
|
import { applyBeforeSend } from "./before-send.js";
|
|
3
3
|
import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
|
|
4
|
+
import { sanitizeTelemetry } from "./privacy-telemetry.js";
|
|
4
5
|
import type {
|
|
5
6
|
DebugBundleCaptureContext,
|
|
6
7
|
DebugBundleClient,
|
|
@@ -18,7 +19,14 @@ import type {
|
|
|
18
19
|
} from "./types.js";
|
|
19
20
|
|
|
20
21
|
const SDK_NAME = "@debugbundle/sdk-react-native" as const;
|
|
21
|
-
const DEFAULT_SDK_VERSION = "
|
|
22
|
+
const DEFAULT_SDK_VERSION = "3.0.0";
|
|
23
|
+
const MAX_PENDING_NATIVE_CALLS = 256;
|
|
24
|
+
const MAX_PENDING_LOW_PRIORITY_CALLS = 224;
|
|
25
|
+
const MAX_PENDING_NATIVE_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
const MAX_PENDING_LOW_PRIORITY_BYTES = 3 * 1024 * 1024;
|
|
27
|
+
const MAX_NATIVE_EVENT_BYTES = 64 * 1024;
|
|
28
|
+
const MAX_CONTEXT_FIELDS = 50;
|
|
29
|
+
const MAX_CONTEXT_KEY_LENGTH = 128;
|
|
22
30
|
const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
|
|
23
31
|
debug: 10,
|
|
24
32
|
info: 20,
|
|
@@ -27,6 +35,39 @@ const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
|
|
|
27
35
|
critical: 50
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
function protectEventFields(
|
|
39
|
+
event: DebugBundleEventEnvelope,
|
|
40
|
+
additionalKeys: string[]
|
|
41
|
+
): DebugBundleEventEnvelope | null {
|
|
42
|
+
// Preserve typed identities only when their scalar values pass the mandatory policy.
|
|
43
|
+
for (const value of [event.schema_version, event.sdk_name, event.sdk_version,
|
|
44
|
+
...Object.values(event.correlation ?? {})]) {
|
|
45
|
+
if (typeof value !== "string") continue;
|
|
46
|
+
const checked = sanitizeTelemetry(value, { additionalKeys });
|
|
47
|
+
if (!checked.ok || checked.value !== value) return null;
|
|
48
|
+
}
|
|
49
|
+
const result = sanitizeTelemetry({
|
|
50
|
+
service: event.service,
|
|
51
|
+
payload: event.payload,
|
|
52
|
+
...(event.context ? { context: event.context } : {})
|
|
53
|
+
}, { additionalKeys });
|
|
54
|
+
if (!result.ok || !result.value || typeof result.value !== "object" || Array.isArray(result.value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const safeFields = result.value as Record<string, unknown>;
|
|
58
|
+
if (!safeFields.service || !safeFields.payload ||
|
|
59
|
+
typeof safeFields.service !== "object" || Array.isArray(safeFields.service) ||
|
|
60
|
+
typeof safeFields.payload !== "object" || Array.isArray(safeFields.payload)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...event,
|
|
65
|
+
service: safeFields.service as DebugBundleEventEnvelope["service"],
|
|
66
|
+
payload: safeFields.payload as Record<string, unknown>,
|
|
67
|
+
...(event.context ? { context: safeFields.context as Record<string, unknown> } : {})
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
30
71
|
export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
31
72
|
private config: ResolvedDebugBundleConfig;
|
|
32
73
|
private nativeModule: NativeDebugBundleModule | null = null;
|
|
@@ -35,6 +76,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
35
76
|
private breadcrumbs: Array<Record<string, unknown>> = [];
|
|
36
77
|
private probeBuffers = new Map<string, Array<Record<string, unknown>>>();
|
|
37
78
|
private jsProbeActivationExpiresAt = 0;
|
|
79
|
+
private pendingNativeCalls = 0;
|
|
80
|
+
private pendingNativeBytes = 0;
|
|
38
81
|
|
|
39
82
|
constructor(config: DebugBundleConfig = {}) {
|
|
40
83
|
this.config = resolveConfig(config);
|
|
@@ -82,6 +125,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
82
125
|
}
|
|
83
126
|
|
|
84
127
|
captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
|
|
128
|
+
if (!this.config.captureErrors || !this.canCapture("frontend_exception", false) ||
|
|
129
|
+
!this.hasNativeCapacity(true)) return;
|
|
85
130
|
const mergedContext = this.mergeContext(context);
|
|
86
131
|
const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
|
|
87
132
|
this.enqueue("frontend_exception", {
|
|
@@ -100,15 +145,22 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
100
145
|
}
|
|
101
146
|
|
|
102
147
|
captureLog(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
|
|
148
|
+
if (!this.isLogEnabled(level)) return;
|
|
149
|
+
const mergedContext = this.mergeContext(context);
|
|
103
150
|
this.enqueue("log_event", {
|
|
104
151
|
level,
|
|
105
152
|
message: sanitizeValue(message, { redactFields: this.config.redactFields }),
|
|
106
153
|
attributes: {
|
|
107
|
-
...asRecord(sanitizeValue(
|
|
154
|
+
...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
|
|
108
155
|
source: "javascript"
|
|
109
156
|
}
|
|
110
|
-
}, stringValue(
|
|
111
|
-
|
|
157
|
+
}, stringValue(mergedContext.trace_id), true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isLogEnabled(level: DebugBundleLogLevel): boolean {
|
|
161
|
+
const rank = LOG_LEVELS[level];
|
|
162
|
+
return rank !== undefined && this.config.captureLogs && rank >= LOG_LEVELS[this.config.logLevel] &&
|
|
163
|
+
this.canCapture("log_event", true) && this.hasNativeCapacity(rank >= LOG_LEVELS.error);
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
captureRequest(
|
|
@@ -116,6 +168,7 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
116
168
|
response: DebugBundleResponseInfo,
|
|
117
169
|
context: DebugBundleCaptureContext = {}
|
|
118
170
|
): void {
|
|
171
|
+
if (!this.config.captureNetwork || !this.canCapture("request_event", true) || !this.hasNativeCapacity(false)) return;
|
|
119
172
|
const mergedContext = this.mergeContext(context);
|
|
120
173
|
const traceId = request.traceId ?? stringValue(mergedContext.trace_id);
|
|
121
174
|
const parsedUrl = parseRequestUrl(request.url);
|
|
@@ -133,16 +186,13 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
133
186
|
}),
|
|
134
187
|
duration_ms: response.durationMillis ?? 0
|
|
135
188
|
};
|
|
136
|
-
|
|
137
|
-
this.recordBreadcrumb("network_request", payload);
|
|
138
|
-
}
|
|
189
|
+
this.recordBreadcrumb("network_request", payload);
|
|
139
190
|
this.enqueue(
|
|
140
191
|
"request_event",
|
|
141
192
|
payload,
|
|
142
193
|
traceId,
|
|
143
194
|
true,
|
|
144
|
-
asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields }))
|
|
145
|
-
this.config.captureNetwork
|
|
195
|
+
asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields }))
|
|
146
196
|
);
|
|
147
197
|
}
|
|
148
198
|
|
|
@@ -155,43 +205,61 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
155
205
|
}
|
|
156
206
|
|
|
157
207
|
setContext(key: string, value: unknown): void {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
if (typeof key !== "string" || !key || key.length > MAX_CONTEXT_KEY_LENGTH ||
|
|
209
|
+
!this.hasNativeCapacity(false) ||
|
|
210
|
+
!Object.hasOwn(this.context, key) && Object.keys(this.context).length >= MAX_CONTEXT_FIELDS) return;
|
|
211
|
+
try {
|
|
212
|
+
// Include the field name in mandatory privacy before local or native ownership.
|
|
213
|
+
const fields = asRecord(sanitizeValue({ [key]: value }, { redactFields: this.config.redactFields }));
|
|
214
|
+
if (!Object.hasOwn(fields, key)) return;
|
|
215
|
+
const entry = { value: fields[key] };
|
|
216
|
+
this.context = { ...this.context, [key]: entry.value };
|
|
217
|
+
const nativeModule = this.nativeModule;
|
|
218
|
+
if (nativeModule?.setContextValue) {
|
|
219
|
+
this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContextValue!(key, entry));
|
|
220
|
+
} else if (nativeModule?.setContext) {
|
|
221
|
+
this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContext!(key, entry.value));
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// Context accessors and optional native integrations cannot escape into the host.
|
|
163
225
|
}
|
|
164
226
|
}
|
|
165
227
|
|
|
166
228
|
probe(label: string, data: unknown | (() => unknown), options: DebugBundleProbeOptions = {}): void {
|
|
167
|
-
if (!label ||
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (this.nativeModule?.captureProbe) {
|
|
187
|
-
void safeNativeCall(
|
|
188
|
-
() => this.nativeModule!.captureProbe!(label, sanitizedData, occurredAt),
|
|
189
|
-
false
|
|
190
|
-
);
|
|
229
|
+
if (!label || label.length > MAX_CONTEXT_KEY_LENGTH || !this.hasNativeCapacity(false) ||
|
|
230
|
+
this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) return;
|
|
231
|
+
try {
|
|
232
|
+
const nativeModule = this.nativeModule;
|
|
233
|
+
const nativeProbeActive = nativeModule?.isProbeActive?.(label) ?? false;
|
|
234
|
+
if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) return;
|
|
235
|
+
const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
|
|
236
|
+
const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
|
|
237
|
+
const occurredAt = new Date().toISOString();
|
|
238
|
+
const entry = { label, data: sanitizedData, timestamp: occurredAt, activation_id: null };
|
|
239
|
+
const entries = this.probeBuffers.get(label) ?? [];
|
|
240
|
+
entries.push(entry);
|
|
241
|
+
this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
|
|
242
|
+
if (nativeModule?.captureProbe) {
|
|
243
|
+
this.enqueueNativeAuxiliary({ label, data: sanitizedData, occurredAt },
|
|
244
|
+
() => nativeModule.captureProbe!(label, sanitizedData, occurredAt));
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// Probe options, native activation readers and data access cannot throw into the host.
|
|
191
248
|
}
|
|
192
249
|
}
|
|
193
250
|
|
|
251
|
+
private enqueueNativeAuxiliary(payload: unknown, operation: () => unknown): void {
|
|
252
|
+
if (!this.hasNativeCapacity(false)) return;
|
|
253
|
+
const bytes = utf8ByteLength(JSON.stringify(payload));
|
|
254
|
+
if (bytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + bytes > MAX_PENDING_LOW_PRIORITY_BYTES) return;
|
|
255
|
+
this.pendingNativeCalls += 1;
|
|
256
|
+
this.pendingNativeBytes += bytes;
|
|
257
|
+
void safeNativeCall(operation, undefined).finally(() => this.releaseNativeCapacity(bytes));
|
|
258
|
+
}
|
|
259
|
+
|
|
194
260
|
async flush(): Promise<void> {
|
|
261
|
+
// Hook preparation queued by earlier capture calls must reach the bridge first.
|
|
262
|
+
await Promise.resolve();
|
|
195
263
|
if (!this.nativeModule) {
|
|
196
264
|
return;
|
|
197
265
|
}
|
|
@@ -246,43 +314,111 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
246
314
|
context?: Record<string, unknown>,
|
|
247
315
|
localPolicyAllows = true
|
|
248
316
|
): void {
|
|
317
|
+
if (!localPolicyAllows) return;
|
|
249
318
|
if (!this.canCapture(eventType, countTowardSession)) {
|
|
250
319
|
return;
|
|
251
320
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
321
|
+
const highPriority = eventType === "frontend_exception" ||
|
|
322
|
+
eventType === "log_event" && (payload.level === "error" || payload.level === "critical");
|
|
323
|
+
if (!this.hasNativeCapacity(highPriority)) return;
|
|
324
|
+
this.pendingNativeCalls += 1;
|
|
325
|
+
let handedOff = false;
|
|
326
|
+
let reservedBytes = 0;
|
|
327
|
+
try {
|
|
328
|
+
const authoredEvent: DebugBundleEventEnvelope = {
|
|
329
|
+
schema_version: "2026-03-01",
|
|
330
|
+
event_id: generateId(),
|
|
331
|
+
event_type: eventType,
|
|
332
|
+
sdk_name: SDK_NAME,
|
|
333
|
+
sdk_version: this.config.sdkVersion,
|
|
334
|
+
service: {
|
|
335
|
+
name: this.config.service,
|
|
336
|
+
environment: this.config.environment,
|
|
337
|
+
runtime: "react-native",
|
|
338
|
+
framework: "react-native"
|
|
339
|
+
},
|
|
340
|
+
occurred_at: new Date().toISOString(),
|
|
341
|
+
...(traceId ? { correlation: { trace_id: traceId } } : {}),
|
|
342
|
+
...(context && Object.keys(context).length > 0 ? { context } : {}),
|
|
343
|
+
payload: this.nativeState.device
|
|
344
|
+
? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
|
|
345
|
+
: payload
|
|
346
|
+
};
|
|
347
|
+
const initial = protectEventFields(authoredEvent, this.config.redactFields);
|
|
348
|
+
if (!initial) return;
|
|
349
|
+
const config = this.config;
|
|
350
|
+
const nativeModule = this.nativeModule;
|
|
351
|
+
const initialBytes = utf8ByteLength(JSON.stringify(initial));
|
|
352
|
+
const byteLimit = highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
|
|
353
|
+
if (initialBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + initialBytes > byteLimit) return;
|
|
354
|
+
this.pendingNativeBytes += initialBytes;
|
|
355
|
+
reservedBytes = initialBytes;
|
|
356
|
+
if (!nativeModule) {
|
|
357
|
+
this.nativeState = degradedNativeState("native_module_unavailable");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
handedOff = true;
|
|
361
|
+
this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes);
|
|
362
|
+
} catch {
|
|
363
|
+
this.nativeState = degradedNativeState("event_preparation_failed");
|
|
364
|
+
} finally {
|
|
365
|
+
if (!handedOff) this.releaseNativeCapacity(reservedBytes);
|
|
283
366
|
}
|
|
284
367
|
}
|
|
285
368
|
|
|
369
|
+
private sendAdmittedEvent(
|
|
370
|
+
initial: DebugBundleEventEnvelope,
|
|
371
|
+
config: ResolvedDebugBundleConfig,
|
|
372
|
+
nativeModule: NativeDebugBundleModule,
|
|
373
|
+
reservedBytes: number
|
|
374
|
+
): void {
|
|
375
|
+
// This activation receives only a protected snapshot: deferred closures must
|
|
376
|
+
// not retain the raw capture payload/context through their lexical scope.
|
|
377
|
+
const prepareAndSend = async (): Promise<void> => {
|
|
378
|
+
if (this.config !== config) return;
|
|
379
|
+
const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEvent !== undefined);
|
|
380
|
+
if (safeEvent === null) return;
|
|
381
|
+
const finalPriority = safeEvent.event_type === "frontend_exception" ||
|
|
382
|
+
safeEvent.event_type === "log_event" && ["error", "critical"].includes(String(safeEvent.payload.level));
|
|
383
|
+
const eventBytes = utf8ByteLength(JSON.stringify(safeEvent));
|
|
384
|
+
const finalLimit = finalPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
|
|
385
|
+
// The callback closure can retain the protected original until the bridge
|
|
386
|
+
// settles. Charge both it and the final snapshot instead of forgetting it.
|
|
387
|
+
if (eventBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + eventBytes > finalLimit) return;
|
|
388
|
+
this.pendingNativeBytes += eventBytes;
|
|
389
|
+
reservedBytes += eventBytes;
|
|
390
|
+
if (nativeModule.enqueueCanonicalEvent) await nativeModule.enqueueCanonicalEvent(safeEvent);
|
|
391
|
+
else await nativeModule.enqueueEvent(safeEvent);
|
|
392
|
+
};
|
|
393
|
+
const operation = config.beforeSend === null
|
|
394
|
+
? safeNativeCall(prepareAndSend, undefined)
|
|
395
|
+
: Promise.resolve().then(() => safeNativeCall(prepareAndSend, undefined));
|
|
396
|
+
void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private prepareAdmittedEvent(initial: DebugBundleEventEnvelope, config: ResolvedDebugBundleConfig, canonical: boolean): DebugBundleEventEnvelope | null {
|
|
400
|
+
// Exit this synchronous activation before awaiting the bridge. An application
|
|
401
|
+
// replacement may be huge before projection and must not survive suspension.
|
|
402
|
+
const event = applyBeforeSend(initial, config.beforeSend);
|
|
403
|
+
if (!event || this.config !== config) return null;
|
|
404
|
+
const safeEvent = protectEventFields(event, config.redactFields);
|
|
405
|
+
if (!safeEvent) return null;
|
|
406
|
+
if (safeEvent.event_type === "log_event" &&
|
|
407
|
+
(!config.captureLogs || !(LOG_LEVELS[safeEvent.payload.level as DebugBundleLogLevel] >= LOG_LEVELS[config.logLevel]))) return null;
|
|
408
|
+
if (safeEvent.event_type === "frontend_exception" && !config.captureErrors) return null;
|
|
409
|
+
return canonical ? safeEvent : toLegacyNativeEvent(safeEvent);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private hasNativeCapacity(highPriority: boolean): boolean {
|
|
413
|
+
return this.pendingNativeCalls < (highPriority ? MAX_PENDING_NATIVE_CALLS : MAX_PENDING_LOW_PRIORITY_CALLS) &&
|
|
414
|
+
this.pendingNativeBytes < (highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private releaseNativeCapacity(bytes: number): void {
|
|
418
|
+
this.pendingNativeCalls -= 1;
|
|
419
|
+
this.pendingNativeBytes -= bytes;
|
|
420
|
+
}
|
|
421
|
+
|
|
286
422
|
private canCapture(eventType: DebugBundleEventEnvelope["event_type"], countTowardSession: boolean): boolean {
|
|
287
423
|
if (!this.config.enabled || !this.config.projectToken) {
|
|
288
424
|
return false;
|
|
@@ -293,7 +429,12 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
293
429
|
}
|
|
294
430
|
|
|
295
431
|
private mergeContext(context: DebugBundleCaptureContext): Record<string, unknown> {
|
|
296
|
-
|
|
432
|
+
try {
|
|
433
|
+
return { ...this.context, ...context };
|
|
434
|
+
} catch {
|
|
435
|
+
// A hostile caller accessor cannot interrupt error reporting or application work.
|
|
436
|
+
return { ...this.context };
|
|
437
|
+
}
|
|
297
438
|
}
|
|
298
439
|
|
|
299
440
|
private snapshotProbeData(): Record<string, unknown> {
|
|
@@ -312,6 +453,21 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
|
|
|
312
453
|
|
|
313
454
|
}
|
|
314
455
|
|
|
456
|
+
function utf8ByteLength(value: string): number {
|
|
457
|
+
let bytes = 0;
|
|
458
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
459
|
+
const code = value.charCodeAt(index);
|
|
460
|
+
if (code < 0x80) bytes += 1;
|
|
461
|
+
else if (code < 0x800) bytes += 2;
|
|
462
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
|
|
463
|
+
value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
|
|
464
|
+
bytes += 4;
|
|
465
|
+
index += 1;
|
|
466
|
+
} else bytes += 3;
|
|
467
|
+
}
|
|
468
|
+
return bytes;
|
|
469
|
+
}
|
|
470
|
+
|
|
315
471
|
export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
|
|
316
472
|
return new DebugBundleReactNativeClient(config);
|
|
317
473
|
}
|
package/src/console.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { DebugBundleClient, DebugBundleLogLevel } from "./types.js";
|
|
|
2
2
|
|
|
3
3
|
let restoreConsole: (() => void) | null = null;
|
|
4
4
|
|
|
5
|
-
export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog">
|
|
5
|
+
export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog"> & {
|
|
6
|
+
isLogEnabled?: (level: DebugBundleLogLevel) => boolean;
|
|
7
|
+
}): () => void {
|
|
6
8
|
if (restoreConsole) {
|
|
7
9
|
return restoreConsole;
|
|
8
10
|
}
|
|
@@ -30,7 +32,10 @@ export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captu
|
|
|
30
32
|
}
|
|
31
33
|
inCapture = true;
|
|
32
34
|
try {
|
|
35
|
+
if (client.isLogEnabled?.(level) === false) return;
|
|
33
36
|
client.captureLog(args.map(String).join(" "), level, { source: "console" });
|
|
37
|
+
} catch {
|
|
38
|
+
// Console instrumentation must not change host logging behavior.
|
|
34
39
|
} finally {
|
|
35
40
|
inCapture = false;
|
|
36
41
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const DEFAULT_SENSITIVE_KEYS = [
|
|
2
|
+
"password", "secret", "token", "api_key", "apikey", "access_token", "refresh_token",
|
|
3
|
+
"private_key", "authorization", "bearer", "cookie", "session_id", "passwd", "ssn",
|
|
4
|
+
"credit_card", "card_number", "cvv", "cvc", "pin", "expiry", "phone", "otp",
|
|
5
|
+
"verification_code"
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
function canonicalizeSensitiveKey(value: string): string {
|
|
9
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function splitKeyIntoSegments(key: string): string[] {
|
|
13
|
+
return key
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.split(/[^a-z0-9]+/)
|
|
18
|
+
.filter((segment) => segment.length > 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSensitiveKey(key: string, sensitiveKeys: readonly string[]): boolean {
|
|
22
|
+
const normalized = key.trim().toLowerCase();
|
|
23
|
+
if (sensitiveKeys.includes(normalized)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
|
|
28
|
+
const segments = splitKeyIntoSegments(key);
|
|
29
|
+
for (let start = 0; start < segments.length; start += 1) {
|
|
30
|
+
let combined = "";
|
|
31
|
+
for (let end = start; end < segments.length; end += 1) {
|
|
32
|
+
combined += segments[end];
|
|
33
|
+
if (canonicalSensitiveKeys.has(combined)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
2
|
+
import { DEFAULT_SENSITIVE_KEYS, isSensitiveKey } from "./privacy-keys.js";
|
|
3
|
+
import { scrubCredentialText } from "./privacy-text.js";
|
|
4
|
+
|
|
5
|
+
const REDACTED = "[REDACTED]";
|
|
6
|
+
const MAX_DEPTH = 16;
|
|
7
|
+
const MAX_NODES = 4096;
|
|
8
|
+
const MAX_ENTRIES = 256;
|
|
9
|
+
const MAX_STRING_BYTES = 16 * 1024;
|
|
10
|
+
const MAX_TOTAL_BYTES = 256 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface TelemetrySanitizationOptions {
|
|
13
|
+
additionalKeys?: readonly string[];
|
|
14
|
+
maxTotalBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TelemetrySanitizationResult =
|
|
18
|
+
| { ok: true; value: JsonValue; redactionCount: number }
|
|
19
|
+
| { ok: false; reason: "unsafe_input" | "budget_exceeded" };
|
|
20
|
+
|
|
21
|
+
interface WorkState {
|
|
22
|
+
nodes: number;
|
|
23
|
+
textBytes: number;
|
|
24
|
+
redactionCount: number;
|
|
25
|
+
seen: WeakSet<object>;
|
|
26
|
+
keys: string[];
|
|
27
|
+
extra: readonly string[];
|
|
28
|
+
maxTotalBytes: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class BudgetExceeded extends Error {}
|
|
32
|
+
|
|
33
|
+
function byteLength(value: string): number {
|
|
34
|
+
let bytes = 0;
|
|
35
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
36
|
+
const code = value.charCodeAt(index);
|
|
37
|
+
if (code < 0x80) bytes += 1;
|
|
38
|
+
else if (code < 0x800) bytes += 2;
|
|
39
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
|
|
40
|
+
value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
|
|
41
|
+
bytes += 4;
|
|
42
|
+
index += 1;
|
|
43
|
+
} else bytes += 3;
|
|
44
|
+
}
|
|
45
|
+
return bytes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mask(state: WorkState): string {
|
|
49
|
+
state.redactionCount += 1;
|
|
50
|
+
return REDACTED;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sanitizeString(value: string, state: WorkState, allowStructured: boolean): JsonValue {
|
|
54
|
+
if (value.length > MAX_STRING_BYTES) return mask(state);
|
|
55
|
+
const length = byteLength(value);
|
|
56
|
+
if (length > MAX_STRING_BYTES) return mask(state);
|
|
57
|
+
state.textBytes += length;
|
|
58
|
+
if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
|
|
59
|
+
|
|
60
|
+
if (allowStructured && (value.startsWith("{") || value.startsWith("["))) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed: unknown = JSON.parse(value);
|
|
63
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
64
|
+
const nested = sanitizeValue(parsed, state, 0, false);
|
|
65
|
+
const encoded = JSON.stringify(nested);
|
|
66
|
+
if (encoded !== value) state.redactionCount += 1;
|
|
67
|
+
return encoded;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof BudgetExceeded) throw error;
|
|
71
|
+
// Malformed structured content with a recognizable sensitive label is withheld below.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = scrubCredentialText(value, state.extra);
|
|
76
|
+
if (result !== value) state.redactionCount += 1;
|
|
77
|
+
if ((value.startsWith("{") || value.startsWith("[")) && result === value &&
|
|
78
|
+
/(?:password|token|secret|authorization|cookie)["']?\s*[:=]/i.test(value)) {
|
|
79
|
+
return mask(state);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sanitizeValue(value: unknown, state: WorkState, depth: number, allowStructured: boolean): JsonValue {
|
|
85
|
+
state.nodes += 1;
|
|
86
|
+
if (state.nodes > MAX_NODES) throw new BudgetExceeded();
|
|
87
|
+
if (depth > MAX_DEPTH) return mask(state);
|
|
88
|
+
if (typeof value === "string") return sanitizeString(value, state, allowStructured);
|
|
89
|
+
if (value === null || typeof value === "boolean") return value;
|
|
90
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
91
|
+
if (typeof value !== "object") throw new TypeError("unsafe_input");
|
|
92
|
+
|
|
93
|
+
if (state.seen.has(value)) return "[Circular]";
|
|
94
|
+
state.seen.add(value);
|
|
95
|
+
try {
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
if (value.length > MAX_ENTRIES) return mask(state);
|
|
98
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
99
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
100
|
+
if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
|
|
101
|
+
return sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
105
|
+
throw new TypeError("unsafe_input");
|
|
106
|
+
}
|
|
107
|
+
const keys = Object.keys(value);
|
|
108
|
+
if (keys.length > MAX_ENTRIES) return mask(state);
|
|
109
|
+
const result: Record<string, JsonValue> = {};
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
if (key.length > 128) {
|
|
112
|
+
state.redactionCount += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
state.textBytes += byteLength(key);
|
|
116
|
+
if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
|
|
117
|
+
const safeKey = scrubCredentialText(key, state.extra);
|
|
118
|
+
if (safeKey !== key) {
|
|
119
|
+
state.redactionCount += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
123
|
+
if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
|
|
124
|
+
const safeValue = isSensitiveKey(key, state.keys)
|
|
125
|
+
? mask(state)
|
|
126
|
+
: sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
|
|
127
|
+
Object.defineProperty(result, key, {
|
|
128
|
+
value: safeValue,
|
|
129
|
+
enumerable: true,
|
|
130
|
+
configurable: true,
|
|
131
|
+
writable: true
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
} finally {
|
|
136
|
+
state.seen.delete(value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mandatory telemetry policy; unlike the legacy `redact` helper, custom keys are additive. */
|
|
141
|
+
export function sanitizeTelemetry(value: unknown, options: TelemetrySanitizationOptions = {}): TelemetrySanitizationResult {
|
|
142
|
+
try {
|
|
143
|
+
const extra = options.additionalKeys ?? [];
|
|
144
|
+
if (extra.length > 128 || extra.some((key) => typeof key !== "string" || key.length > 64 || !key.trim())) {
|
|
145
|
+
return { ok: false, reason: "unsafe_input" };
|
|
146
|
+
}
|
|
147
|
+
const maxTotalBytes = options.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
148
|
+
if (!Number.isInteger(maxTotalBytes) || maxTotalBytes < 1 || maxTotalBytes > 512 * 1024) {
|
|
149
|
+
return { ok: false, reason: "unsafe_input" };
|
|
150
|
+
}
|
|
151
|
+
const state: WorkState = {
|
|
152
|
+
nodes: 0,
|
|
153
|
+
textBytes: 0,
|
|
154
|
+
redactionCount: 0,
|
|
155
|
+
seen: new WeakSet<object>(),
|
|
156
|
+
keys: [...DEFAULT_SENSITIVE_KEYS, ...extra],
|
|
157
|
+
extra,
|
|
158
|
+
maxTotalBytes
|
|
159
|
+
};
|
|
160
|
+
const sanitized = sanitizeValue(value, state, 0, true);
|
|
161
|
+
if (byteLength(JSON.stringify(sanitized)) > maxTotalBytes) {
|
|
162
|
+
return { ok: false, reason: "budget_exceeded" };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, value: sanitized, redactionCount: state.redactionCount };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { ok: false, reason: error instanceof BudgetExceeded ? "budget_exceeded" : "unsafe_input" };
|
|
167
|
+
}
|
|
168
|
+
}
|