@debugbundle/sdk-react-native 3.0.1 → 3.1.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/DebugBundleReactNative.podspec +2 -2
  3. package/MIGRATION-3.1.md +116 -0
  4. package/README.md +37 -1
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativeModule.java +34 -3
  7. package/app.plugin.js +1 -1
  8. package/dist/NativeDebugBundleReactNative.d.ts +1 -0
  9. package/dist/NativeDebugBundleReactNative.d.ts.map +1 -1
  10. package/dist/before-send.d.ts.map +1 -1
  11. package/dist/before-send.js +9 -131
  12. package/dist/canonical-schema.d.ts +15 -0
  13. package/dist/canonical-schema.d.ts.map +1 -0
  14. package/dist/canonical-schema.js +139 -0
  15. package/dist/client-values.d.ts +17 -0
  16. package/dist/client-values.d.ts.map +1 -0
  17. package/dist/client-values.js +185 -0
  18. package/dist/client.d.ts +7 -0
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +102 -205
  21. package/dist/diagnostic-budget.d.ts +16 -0
  22. package/dist/diagnostic-budget.d.ts.map +1 -0
  23. package/dist/diagnostic-budget.js +120 -0
  24. package/dist/event-schema-data.d.ts +2 -0
  25. package/dist/event-schema-data.d.ts.map +1 -0
  26. package/dist/event-schema-data.js +49 -0
  27. package/dist/evidence-capabilities.d.ts +5 -0
  28. package/dist/evidence-capabilities.d.ts.map +1 -0
  29. package/dist/evidence-capabilities.js +21 -0
  30. package/dist/evidence-controller.d.ts +24 -0
  31. package/dist/evidence-controller.d.ts.map +1 -0
  32. package/dist/evidence-controller.js +113 -0
  33. package/dist/evidence-types.d.ts +33 -0
  34. package/dist/evidence-types.d.ts.map +1 -0
  35. package/dist/evidence-types.js +1 -0
  36. package/dist/evidence-wire.d.ts +7 -0
  37. package/dist/evidence-wire.d.ts.map +1 -0
  38. package/dist/evidence-wire.js +132 -0
  39. package/dist/exception-evidence.d.ts +25 -0
  40. package/dist/exception-evidence.d.ts.map +1 -0
  41. package/dist/exception-evidence.js +175 -0
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -0
  45. package/dist/owned-json.d.ts +11 -0
  46. package/dist/owned-json.d.ts.map +1 -0
  47. package/dist/owned-json.js +85 -0
  48. package/dist/privacy-keys.d.ts +2 -0
  49. package/dist/privacy-keys.d.ts.map +1 -1
  50. package/dist/privacy-keys.js +10 -2
  51. package/dist/privacy-telemetry.js +7 -7
  52. package/dist/privacy-text.d.ts +2 -2
  53. package/dist/privacy-text.d.ts.map +1 -1
  54. package/dist/privacy-text.js +35 -33
  55. package/dist/redaction.d.ts.map +1 -1
  56. package/dist/redaction.js +9 -7
  57. package/dist/safe-error-fields.d.ts +4 -0
  58. package/dist/safe-error-fields.d.ts.map +1 -0
  59. package/dist/safe-error-fields.js +54 -0
  60. package/dist/types.d.ts +21 -6
  61. package/dist/types.d.ts.map +1 -1
  62. package/ios/DebugBundleReactNative.swift +58 -4
  63. package/ios/DebugBundleReactNativeBridge.mm +4 -0
  64. package/package.json +3 -2
  65. package/src/NativeDebugBundleReactNative.ts +1 -0
  66. package/src/before-send.ts +10 -174
  67. package/src/canonical-schema.ts +108 -0
  68. package/src/client-values.ts +196 -0
  69. package/src/client.ts +92 -213
  70. package/src/diagnostic-budget.ts +90 -0
  71. package/src/event-schema-data.ts +49 -0
  72. package/src/evidence-capabilities.ts +19 -0
  73. package/src/evidence-controller.ts +89 -0
  74. package/src/evidence-types.ts +14 -0
  75. package/src/evidence-wire.ts +100 -0
  76. package/src/exception-evidence.ts +112 -0
  77. package/src/index.ts +6 -0
  78. package/src/owned-json.ts +60 -0
  79. package/src/privacy-keys.ts +12 -2
  80. package/src/privacy-telemetry.ts +9 -9
  81. package/src/privacy-text.ts +32 -31
  82. package/src/redaction.ts +8 -7
  83. package/src/safe-error-fields.ts +46 -0
  84. package/src/types.ts +23 -5
@@ -0,0 +1,112 @@
1
+ import { errorField, safeErrorStack, nativeBrand } from "./safe-error-fields.js";
2
+ import { sanitizeValue } from "./redaction.js";
3
+ import { utf8Bytes } from "./owned-json.js";
4
+
5
+ type LegacyError = { name: string; message: string; stack: string; traceAvailable?: boolean };
6
+ type Frame = { function: string | null; file: string | null; line: number | null; column: number | null; classification: "unknown" | "framework" | "sdk" };
7
+ type Node = { id: number; name: string; message: string; stack: string | null; trace_origin: string; parsing_status: string;
8
+ frames: Frame[]; relationships: Array<{ kind: "cause"; target_id: number }> };
9
+ const text = (value: unknown, fallback: string): string => typeof value === "string" && value ? value : fallback;
10
+ export function snapshotError(error: unknown, fields: string[]): LegacyError {
11
+ let name = "Error", message = "Unknown error", stack: unknown;
12
+ try {
13
+ if (typeof error === "string") message = error;
14
+ else if (error !== null && typeof error === "object") {
15
+ name = text(errorField(error, "name"), name); message = text(errorField(error, "message"), message); stack = safeErrorStack(error);
16
+ }
17
+ } catch { /* Legacy outer evidence is best effort; no application accessor is required. */ }
18
+ return {
19
+ name: text(sanitizeValue(name, { redactFields: fields }), "Error"),
20
+ message: text(sanitizeValue(message, { redactFields: fields }), "Unknown error"),
21
+ stack: text(sanitizeValue(text(stack, `${name}: ${message}`), { redactFields: fields, maxStringLength: 8192 }), `${name}: ${message}`),
22
+ traceAvailable: typeof stack === "string" && stack.length > 0
23
+ };
24
+ }
25
+
26
+ function clip(text: string, maximum: number): string {
27
+ let result = "", bytes = 0;
28
+ for (const character of text.slice(0, maximum)) {
29
+ const cost = utf8Bytes(character); if (bytes + cost > maximum) break;
30
+ result += character; bytes += cost;
31
+ }
32
+ return result;
33
+ }
34
+ export function parseMobileStack(stack: string, maximum = 32): { frames: Frame[]; status: string; truncated: boolean } {
35
+ const frames: Frame[] = [];
36
+ const lines = stack.slice(0, 16_384).split(/\r?\n/, 129);
37
+ let partial = false, truncated = stack.length > 16_384 || lines.length > 128;
38
+ for (const input of lines.slice(0, 128)) {
39
+ if (frames.length >= maximum) { truncated = true; break; }
40
+ if (input.length > 2048) truncated = true;
41
+ const line = input.trim().slice(0, 2048);
42
+ const named = /^at\s+(.+?)\s*\((?:address at )?(.+?):(\d+)(?::(\d+))?\)$/.exec(line);
43
+ const anonymous = named ? null : /^at\s+(.+?):(\d+)(?::(\d+))?$/.exec(line);
44
+ const webkit = named || anonymous ? null : /^(.*?)@(.+?):(\d+)(?::(\d+))?$/.exec(line);
45
+ const match = named ?? anonymous ?? webkit;
46
+ if (!match) { if (/^at\s|@\S+:\d/.test(line)) partial = true; continue; }
47
+ let file = named || webkit ? match[2]! : match[1]!;
48
+ if (/^https?:\/\//i.test(file)) {
49
+ try { const url = new URL(file); file = `${url.protocol}//${url.host}${url.pathname}`; } catch { partial = true; continue; }
50
+ } else file = file.split(/[?#]/, 1)[0]!;
51
+ const row = Number(named || webkit ? match[3] : match[2]);
52
+ const rawColumn = named || webkit ? match[4] : match[3];
53
+ const column = rawColumn === undefined ? null : Number(rawColumn);
54
+ if (!Number.isSafeInteger(row) || row <= 0 || column !== null && (!Number.isSafeInteger(column) || column < 0)) { partial = true; continue; }
55
+ const fn = named || webkit ? clip(match[1]!.trim(), 256) || null : null;
56
+ frames.push({ function: fn, file: clip(file, 2048), line: row, column,
57
+ classification: /node_modules[/\\]@debugbundle[/\\]/.test(file) ? "sdk" : /node_modules[/\\]/.test(file) ? "framework" : "unknown" });
58
+ }
59
+ return { frames, status: truncated ? "truncated" : !frames.length ? "unparsed" : partial ? "partial" : "parsed", truncated };
60
+ }
61
+
62
+ function stackLinks(stack: string): string {
63
+ return stack.replace(/https?:\/\/[^\s)]+/gi, source => {
64
+ const coordinate = /(:\d+(?::\d+)?)$/.exec(source)?.[1] ?? "";
65
+ const location = coordinate ? source.slice(0, -coordinate.length) : source;
66
+ try { const url = new URL(location); return `${url.protocol}//${url.host}${url.pathname}${coordinate}`; } catch { return "[unavailable URL]"; }
67
+ });
68
+ }
69
+
70
+ /** New cause traversal requires the engine's native Error brand. Hermes/JSC
71
+ * without it retain the outer error and explicitly report unsupported traversal.
72
+ * Portable JS cannot prove aggregate arrays are not proxies: no iterator is run. */
73
+ export function snapshotExceptionEvidence(error: unknown, legacy: LegacyError, fields: string[], fatal = false,
74
+ brand = nativeBrand): Record<string, unknown> | undefined {
75
+ const nodes: Node[] = [], seen = new Map<object, number>(), active = new Set<object>();
76
+ let unsupported = brand === undefined, unsafe = false, truncated = false, totalFrames = 0;
77
+ const visit = (value: unknown, depth: number, root = false): number | null => {
78
+ if (!root && (!brand || !brand(value))) { unsupported = true; return null; }
79
+ if (typeof value === "object" && value !== null && active.has(value)) { truncated = true; return null; }
80
+ if (depth > (fatal ? 1 : 4) || nodes.length >= (fatal ? 2 : 8)) { truncated = true; return null; }
81
+ if (!root && seen.has(value as object)) return seen.get(value as object)!;
82
+ const snapshot = root ? legacy : snapshotError(value, fields);
83
+ const name = clip(snapshot.name, 256), message = clip(snapshot.message, fatal ? 512 : 2048);
84
+ const stack = stackLinks(clip(snapshot.stack, fatal ? 512 : 16_384));
85
+ const parsed = parseMobileStack(stack, fatal ? Math.min(4, 8 - totalFrames) : 32), frames = parsed.frames;
86
+ totalFrames += frames.length; truncated ||= parsed.truncated;
87
+ const trace = snapshot.traceAvailable !== false;
88
+ const id = nodes.length;
89
+ const node: Node = { id, name, message, stack: trace ? stack : null, frames: trace ? frames : [],
90
+ trace_origin: trace ? "runtime_reported" : "unavailable", parsing_status: trace ? parsed.status : "unavailable", relationships: [] };
91
+ nodes.push(node);
92
+ if (!brand || !brand(value)) { unsupported = true; return id; }
93
+ seen.set(value as object, id); active.add(value as object);
94
+ const cause = Object.getOwnPropertyDescriptor(value, "cause");
95
+ if (cause && !("value" in cause)) unsafe = true;
96
+ else if (cause?.value !== undefined) { const target = visit(cause.value, depth + 1); if (target !== null) node.relationships.push({ kind: "cause", target_id: target }); }
97
+ const aggregate = Object.getOwnPropertyDescriptor(value, "errors");
98
+ if (aggregate) { if (!("value" in aggregate)) unsafe = true; else unsupported = true; }
99
+ active.delete(value as object); return id;
100
+ };
101
+ try { visit(error, 0, true); } catch { unsafe = true; }
102
+ if (!nodes.length) return undefined;
103
+ const graph = { version: 1, runtime: "react_native", root_id: 0, nodes, truncated,
104
+ unavailable_reason: unsafe ? "unsafe" : truncated ? "budget_exceeded" : unsupported ? "unsupported" : null };
105
+ const maximum = fatal ? 3584 : 31_000;
106
+ if (utf8Bytes(JSON.stringify(graph)) > maximum) {
107
+ graph.truncated = true; graph.unavailable_reason = "budget_exceeded";
108
+ for (const node of nodes) { node.stack = null; node.frames = node.frames.slice(0, fatal ? 2 : 4); node.parsing_status = "truncated"; }
109
+ }
110
+ if (utf8Bytes(JSON.stringify(graph)) > maximum) for (const node of nodes) node.frames = [];
111
+ return utf8Bytes(JSON.stringify(graph)) <= maximum ? graph : undefined;
112
+ }
package/src/index.ts CHANGED
@@ -12,10 +12,15 @@ import type {
12
12
  DebugBundleResponseInfo,
13
13
  DebugBundleStatus
14
14
  } from "./types.js";
15
+ import type { EvidenceCapabilities, OperationScope, RecoveryObservation } from "./evidence-types.js";
15
16
 
16
17
  const singleton = new DebugBundleReactNativeClient();
17
18
 
18
19
  export const DebugBundle = {
20
+ beginOperation(): OperationScope { return singleton.beginOperation(); },
21
+ withOperationContext<T>(operation: (scope: OperationScope) => T): T { return singleton.withOperationContext(operation); },
22
+ captureRecovery(observation: RecoveryObservation): void { singleton.captureRecovery(observation); },
23
+ get evidenceCapabilities(): EvidenceCapabilities { return singleton.evidenceCapabilities; },
19
24
  init(config: DebugBundleConfig): void {
20
25
  singleton.init(config);
21
26
  if (config.captureErrors !== false) {
@@ -71,6 +76,7 @@ export const DebugBundle = {
71
76
  export { createDebugBundleClient, DebugBundleReactNativeClient };
72
77
  export { captureDebugBundleConsole };
73
78
  export { installDebugBundleErrorHandlers };
79
+ export type { EvidenceCapabilities, EvidenceFeature, EvidenceScope, OperationScope, RecoveryObservation } from "./evidence-types.js";
74
80
  export type {
75
81
  DebugBundleCaptureContext,
76
82
  DebugBundleBeforeSend,
@@ -0,0 +1,60 @@
1
+ export type OwnedJSON = null | boolean | number | string | OwnedJSON[] | { [key: string]: OwnedJSON };
2
+
3
+ /** Exhausted ownership cannot safely fall back to pre-hook application data. */
4
+ export class EventValidationUnavailable extends Error {}
5
+
6
+ export function utf8Bytes(value: string): number {
7
+ let bytes = 0;
8
+ for (let index = 0; index < value.length; index++) {
9
+ const code = value.charCodeAt(index);
10
+ if (code < 0x80) bytes++;
11
+ else if (code < 0x800) bytes += 2;
12
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
13
+ value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) { bytes += 4; index++; }
14
+ else bytes += 3;
15
+ }
16
+ return bytes;
17
+ }
18
+
19
+ /** Bounded plain JSON copy; no toJSON, property getters, foreign iterators or formatters. */
20
+ export function ownJSON(input: unknown): OwnedJSON {
21
+ let nodes = 4096, remaining = 8 * 1024 * 1024;
22
+ const seen = new Set<object>();
23
+ const use = (amount: number): void => { remaining -= amount; if (remaining < 0) throw new EventValidationUnavailable(); };
24
+ const visit = (value: unknown, depth: number): OwnedJSON => {
25
+ if (--nodes < 0 || depth > 16) throw new EventValidationUnavailable();
26
+ if (value === null || typeof value === "boolean") { use(5); return value; }
27
+ if (typeof value === "number" && Number.isFinite(value)) { use(24); return value; }
28
+ if (typeof value === "string") { if (value.length > remaining) throw new EventValidationUnavailable(); use(utf8Bytes(value) + 2); return value; }
29
+ if (typeof value !== "object" || value === null || seen.has(value)) throw new TypeError("invalid JSON");
30
+ seen.add(value);
31
+ try {
32
+ if (Array.isArray(value)) {
33
+ if (value.length > 256) throw new EventValidationUnavailable();
34
+ use(value.length + 2);
35
+ return Array.from({ length: value.length }, (_, index) => {
36
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
37
+ if (!descriptor || !("value" in descriptor)) throw new TypeError("invalid JSON");
38
+ return visit(descriptor.value, depth + 1);
39
+ });
40
+ }
41
+ if (![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError("invalid JSON");
42
+ const keys = Object.keys(value);
43
+ if (keys.length > 256) throw new EventValidationUnavailable();
44
+ const output: Record<string, OwnedJSON> = Object.create(null);
45
+ for (const key of keys) {
46
+ if (key.length > remaining) throw new EventValidationUnavailable();
47
+ use(utf8Bytes(key) + 4);
48
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
49
+ if (!descriptor || !("value" in descriptor)) throw new TypeError("invalid JSON");
50
+ output[key] = visit(descriptor.value, depth + 1);
51
+ }
52
+ return output;
53
+ } finally { seen.delete(value); }
54
+ };
55
+ return visit(input, 0);
56
+ }
57
+
58
+ export function jsonObject(value: unknown): Record<string, OwnedJSON> | null {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, OwnedJSON> : null;
60
+ }
@@ -19,12 +19,22 @@ function splitKeyIntoSegments(key: string): string[] {
19
19
  }
20
20
 
21
21
  export function isSensitiveKey(key: string, sensitiveKeys: readonly string[]): boolean {
22
+ return createSensitiveKeyMatcher(sensitiveKeys)(key);
23
+ }
24
+
25
+ /** Operation-owned lookup: bounded caller policy never enters a global cache. */
26
+ export function createSensitiveKeyMatcher(sensitiveKeys: readonly string[]): (key: string) => boolean {
27
+ const exact = new Set(sensitiveKeys);
28
+ const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
29
+ return key => matchesSensitiveKey(key, exact, canonicalSensitiveKeys);
30
+ }
31
+
32
+ function matchesSensitiveKey(key: string, exact: ReadonlySet<string>, canonicalSensitiveKeys: ReadonlySet<string>): boolean {
22
33
  const normalized = key.trim().toLowerCase();
23
- if (sensitiveKeys.includes(normalized)) {
34
+ if (exact.has(normalized)) {
24
35
  return true;
25
36
  }
26
37
 
27
- const canonicalSensitiveKeys = new Set(sensitiveKeys.map(canonicalizeSensitiveKey));
28
38
  const segments = splitKeyIntoSegments(key);
29
39
  for (let start = 0; start < segments.length; start += 1) {
30
40
  let combined = "";
@@ -1,6 +1,6 @@
1
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";
2
+ import { DEFAULT_SENSITIVE_KEYS, createSensitiveKeyMatcher } from "./privacy-keys.js";
3
+ import { createCredentialTextScrubber } from "./privacy-text.js";
4
4
 
5
5
  const REDACTED = "[REDACTED]";
6
6
  const MAX_DEPTH = 16;
@@ -23,8 +23,8 @@ interface WorkState {
23
23
  textBytes: number;
24
24
  redactionCount: number;
25
25
  seen: WeakSet<object>;
26
- keys: string[];
27
- extra: readonly string[];
26
+ sensitiveKey: (key: string) => boolean;
27
+ scrubText: (value: string) => string;
28
28
  maxTotalBytes: number;
29
29
  }
30
30
 
@@ -72,7 +72,7 @@ function sanitizeString(value: string, state: WorkState, allowStructured: boolea
72
72
  }
73
73
  }
74
74
 
75
- const result = scrubCredentialText(value, state.extra);
75
+ const result = state.scrubText(value);
76
76
  if (result !== value) state.redactionCount += 1;
77
77
  if ((value.startsWith("{") || value.startsWith("[")) && result === value &&
78
78
  /(?:password|token|secret|authorization|cookie)["']?\s*[:=]/i.test(value)) {
@@ -114,14 +114,14 @@ function sanitizeValue(value: unknown, state: WorkState, depth: number, allowStr
114
114
  }
115
115
  state.textBytes += byteLength(key);
116
116
  if (state.textBytes > state.maxTotalBytes) throw new BudgetExceeded();
117
- const safeKey = scrubCredentialText(key, state.extra);
117
+ const safeKey = state.scrubText(key);
118
118
  if (safeKey !== key) {
119
119
  state.redactionCount += 1;
120
120
  continue;
121
121
  }
122
122
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
123
123
  if (descriptor === undefined || !("value" in descriptor)) throw new TypeError("unsafe_input");
124
- const safeValue = isSensitiveKey(key, state.keys)
124
+ const safeValue = state.sensitiveKey(key)
125
125
  ? mask(state)
126
126
  : sanitizeValue(descriptor.value, state, depth + 1, allowStructured);
127
127
  Object.defineProperty(result, key, {
@@ -153,8 +153,8 @@ export function sanitizeTelemetry(value: unknown, options: TelemetrySanitization
153
153
  textBytes: 0,
154
154
  redactionCount: 0,
155
155
  seen: new WeakSet<object>(),
156
- keys: [...DEFAULT_SENSITIVE_KEYS, ...extra],
157
- extra,
156
+ sensitiveKey: createSensitiveKeyMatcher([...DEFAULT_SENSITIVE_KEYS, ...extra]),
157
+ scrubText: createCredentialTextScrubber(extra),
158
158
  maxTotalBytes
159
159
  };
160
160
  const sanitized = sanitizeValue(value, state, 0, true);
@@ -1,4 +1,4 @@
1
- import { DEFAULT_SENSITIVE_KEYS, isSensitiveKey } from "./privacy-keys.js";
1
+ import { DEFAULT_SENSITIVE_KEYS, createSensitiveKeyMatcher } from "./privacy-keys.js";
2
2
 
3
3
  const REDACTED = "[REDACTED]";
4
4
  const BUILTIN_LABELS = [...DEFAULT_SENSITIVE_KEYS, "client_secret", "x_api_key",
@@ -34,7 +34,7 @@ function isValidCard(value: string): boolean {
34
34
  return sum % 10 === 0;
35
35
  }
36
36
 
37
- function scrubUrl(raw: string, keys: readonly string[]): string {
37
+ function scrubUrl(raw: string, scrub: (value: string, scanUrls?: boolean) => string, sensitive: (key: string) => boolean): string {
38
38
  try {
39
39
  const url = new URL(raw);
40
40
  if (url.username || url.password) {
@@ -43,8 +43,8 @@ function scrubUrl(raw: string, keys: readonly string[]): string {
43
43
  }
44
44
  const nextQuery = new URLSearchParams();
45
45
  for (const [key, value] of url.searchParams) {
46
- if (key.length > 128 || scrubCredentialText(key, keys, false) !== key) continue;
47
- nextQuery.append(key, isSensitiveKey(key, keys) || scrubCredentialText(value, keys, false) !== value ? REDACTED : value);
46
+ if (key.length > 128 || scrub(key, false) !== key) continue;
47
+ nextQuery.append(key, sensitive(key) || scrub(value, false) !== value ? REDACTED : value);
48
48
  }
49
49
  url.search = nextQuery.toString();
50
50
  url.hash = "";
@@ -55,32 +55,33 @@ function scrubUrl(raw: string, keys: readonly string[]): string {
55
55
  }
56
56
  }
57
57
 
58
- /** Bounded caller input only. Returns stable replacement text, never the matching secret. */
59
- export function scrubCredentialText(input: string, additionalKeys: readonly string[], scanUrls = true): string {
60
- const keys = [...DEFAULT_SENSITIVE_KEYS, ...additionalKeys];
61
- if (PEM_START.test(input) && !PEM_COMPLETE.test(input)) return REDACTED;
62
- let output = input;
63
- if (/(?:password|token|secret|authorization|cookie)%3[ad]/i.test(output)) {
64
- try {
65
- output = decodeURIComponent(output);
66
- } catch {
67
- return REDACTED;
68
- }
69
- }
70
- output = output.replace(PEM, REDACTED).replace(HEADER, "$1: [REDACTED]");
71
- output = output.replace(BEARER, "$1 [REDACTED]").replace(DEBUGBUNDLE_TOKEN, REDACTED);
72
- output = output.replace(ASSIGNMENT, "$1$2[REDACTED]");
73
- if (additionalKeys.length > 0) {
74
- for (const key of additionalKeys) {
75
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
- const named = new RegExp(`\\b(${escaped})\\b(["']?\\s*[:=]\\s*)(?:"[^"]*"|'[^']*'|[^\\s&,;]+)`, "gi");
77
- output = output.replace(named, "$1$2[REDACTED]");
78
- }
79
- }
80
- output = output.replace(CARD, (candidate) => isValidCard(candidate) ? REDACTED : candidate);
81
- if (!scanUrls) return output;
82
- return output.replace(URL_TEXT, (matched) => {
83
- const trailing = matched.match(/[).,;]+$/)?.[0] ?? "";
84
- return scrubUrl(matched.slice(0, matched.length - trailing.length), keys) + trailing;
58
+ /** Compile each caller rule once per bounded protection operation, not per scalar. */
59
+ export function createCredentialTextScrubber(additionalKeys: readonly string[]): (input: string, scanUrls?: boolean) => string {
60
+ const sensitive = createSensitiveKeyMatcher([...DEFAULT_SENSITIVE_KEYS, ...additionalKeys]);
61
+ const patterns = additionalKeys.map(key => {
62
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ return new RegExp(`\\b(${escaped})\\b(["']?\\s*[:=]\\s*)(?:"[^"]*"|'[^']*'|[^\\s&,;]+)`, "gi");
85
64
  });
65
+ const scrub = (input: string, scanUrls = true): string => {
66
+ if (PEM_START.test(input) && !PEM_COMPLETE.test(input)) return REDACTED;
67
+ let output = input;
68
+ if (/(?:password|token|secret|authorization|cookie)%3[ad]/i.test(output)) {
69
+ try {
70
+ output = decodeURIComponent(output);
71
+ } catch {
72
+ return REDACTED;
73
+ }
74
+ }
75
+ output = output.replace(PEM, REDACTED).replace(HEADER, "$1: [REDACTED]");
76
+ output = output.replace(BEARER, "$1 [REDACTED]").replace(DEBUGBUNDLE_TOKEN, REDACTED);
77
+ output = output.replace(ASSIGNMENT, "$1$2[REDACTED]");
78
+ for (const pattern of patterns) output = output.replace(pattern, "$1$2[REDACTED]");
79
+ output = output.replace(CARD, (candidate) => isValidCard(candidate) ? REDACTED : candidate);
80
+ if (!scanUrls) return output;
81
+ return output.replace(URL_TEXT, (matched) => {
82
+ const trailing = matched.match(/[).,;]+$/)?.[0] ?? "";
83
+ return scrubUrl(matched.slice(0, matched.length - trailing.length), scrub, sensitive) + trailing;
84
+ });
85
+ };
86
+ return scrub;
86
87
  }
package/src/redaction.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_SENSITIVE_KEYS, isSensitiveKey as privacySensitiveKey } from "./privacy-keys.js";
2
2
  import { sanitizeTelemetry } from "./privacy-telemetry.js";
3
+ import { errorField, safeErrorStack } from "./safe-error-fields.js";
3
4
 
4
5
  const DEFAULT_REDACT_FIELDS = [
5
6
  ...DEFAULT_SENSITIVE_KEYS
@@ -93,9 +94,6 @@ function sanitize(
93
94
  if (typeof value === "function" || typeof value === "symbol") {
94
95
  return `[${typeof value}]`;
95
96
  }
96
- if (value instanceof Error) {
97
- return sanitizeError(value, options, depth, visited);
98
- }
99
97
  if (depth >= options.maxDepth) {
100
98
  return "[Truncated: depth]";
101
99
  }
@@ -104,6 +102,7 @@ function sanitize(
104
102
  return "[Circular]";
105
103
  }
106
104
  visited.add(value);
105
+ if (value instanceof Error) return sanitizeError(value, options, depth, visited);
107
106
  if (Array.isArray(value)) {
108
107
  return value
109
108
  .slice(0, options.maxArrayLength)
@@ -124,11 +123,13 @@ function sanitizeError(
124
123
  depth: number,
125
124
  visited: WeakSet<object>
126
125
  ): Record<string, unknown> {
126
+ const name = errorField(error, "name"), message = errorField(error, "message");
127
+ const stack = safeErrorStack(error), cause = Object.getOwnPropertyDescriptor(error, "cause");
127
128
  return {
128
- name: truncate(error.name || "Error", options.maxStringLength, options.redactFields),
129
- message: truncate(error.message || "", options.maxStringLength, options.redactFields),
130
- stack: typeof error.stack === "string" ? truncate(error.stack, options.maxStringLength * 4, options.redactFields) : null,
131
- cause: "cause" in error ? sanitize(error.cause, options, depth + 1, visited, "cause") : null
129
+ name: truncate(typeof name === "string" && name ? name : "Error", options.maxStringLength, options.redactFields),
130
+ message: truncate(typeof message === "string" ? message : "", options.maxStringLength, options.redactFields),
131
+ stack: typeof stack === "string" ? truncate(stack, options.maxStringLength * 4, options.redactFields) : null,
132
+ cause: cause && "value" in cause ? sanitize(cause.value, options, depth + 1, visited, "cause") : null
132
133
  };
133
134
  }
134
135
 
@@ -0,0 +1,46 @@
1
+ /** Read primitive native error fields without calling application accessors. */
2
+ const nativePrototypes = new Set([Error.prototype, TypeError.prototype, RangeError.prototype, ReferenceError.prototype,
3
+ SyntaxError.prototype, URIError.prototype, EvalError.prototype, ...(typeof AggregateError === "function" ? [AggregateError.prototype] : [])]);
4
+ const brandDescriptor = Object.getOwnPropertyDescriptor(Error, "isError");
5
+ export const nativeBrand = brandDescriptor && "value" in brandDescriptor && typeof brandDescriptor.value === "function"
6
+ ? brandDescriptor.value as (value: unknown) => boolean : undefined;
7
+ let nativeStackGetter: (() => unknown) | undefined;
8
+
9
+ export function errorField(value: object, key: string): unknown {
10
+ const own = Object.getOwnPropertyDescriptor(value, key);
11
+ if (own) return "value" in own ? own.value : undefined;
12
+ let prototype = Object.getPrototypeOf(value);
13
+ for (let depth = 0; prototype && depth < 4; depth++) {
14
+ if (!nativePrototypes.has(prototype)) return undefined;
15
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
16
+ if (descriptor) return "value" in descriptor ? descriptor.value : undefined;
17
+ prototype = Object.getPrototypeOf(prototype);
18
+ }
19
+ return undefined;
20
+ }
21
+ export function safeErrorStack(value: object): unknown {
22
+ let prototype = Object.getPrototypeOf(value);
23
+ for (let depth = 0; prototype && depth < 4; depth++) {
24
+ if (!nativePrototypes.has(prototype) && prototype !== Object.prototype) return undefined;
25
+ for (const key of ["name", "message"]) {
26
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
27
+ if (descriptor && (!("value" in descriptor) || typeof descriptor.value !== "string" || descriptor.value.length > 8192)) return undefined;
28
+ }
29
+ prototype = Object.getPrototypeOf(prototype);
30
+ }
31
+ for (const key of ["name", "message"]) {
32
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
33
+ if (descriptor && (!("value" in descriptor) || typeof descriptor.value !== "string" || descriptor.value.length > 8192)) return undefined;
34
+ }
35
+ const formatter = Object.getOwnPropertyDescriptor(Error, "prepareStackTrace");
36
+ const limit = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit");
37
+ if (formatter && (!("value" in formatter) || formatter.value !== undefined) ||
38
+ limit && (!("value" in limit) || typeof limit.value !== "number" || limit.value < 0 || limit.value > 64)) return undefined;
39
+ const descriptor = Object.getOwnPropertyDescriptor(value, "stack");
40
+ if (descriptor && "value" in descriptor) return descriptor.value;
41
+ if (descriptor?.get && nativeBrand?.(value)) {
42
+ nativeStackGetter ??= Object.getOwnPropertyDescriptor(new Error(), "stack")?.get;
43
+ if (descriptor.get === nativeStackGetter) return descriptor.get.call(value);
44
+ }
45
+ return undefined;
46
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,9 @@ export interface DebugBundleConfig {
22
22
  maxEventsPerSession?: number;
23
23
  maxBreadcrumbs?: number;
24
24
  captureErrors?: boolean;
25
+ captureExceptionEvidence?: boolean;
26
+ captureRecoveryObservations?: boolean;
27
+ evidenceCapabilities?: EvidenceCapabilities;
25
28
  captureUnhandledRejections?: boolean;
26
29
  captureScreens?: boolean;
27
30
  captureActions?: boolean;
@@ -60,6 +63,9 @@ export interface ResolvedDebugBundleConfig {
60
63
  maxEventsPerSession: number;
61
64
  maxBreadcrumbs: number;
62
65
  captureErrors: boolean;
66
+ captureExceptionEvidence: boolean;
67
+ captureRecoveryObservations: boolean;
68
+ evidenceCapabilities: EvidenceCapabilities | null;
63
69
  captureUnhandledRejections: boolean;
64
70
  captureScreens: boolean;
65
71
  captureActions: boolean;
@@ -104,6 +110,8 @@ export interface DebugBundleResponseInfo {
104
110
  }
105
111
 
106
112
  export interface DebugBundleCaptureContext {
113
+ failureRef?: string;
114
+ evidenceScope?: EvidenceScope;
107
115
  [key: string]: unknown;
108
116
  }
109
117
 
@@ -112,26 +120,29 @@ export interface DebugBundleProbeOptions {
112
120
  }
113
121
 
114
122
  export interface DebugBundleEventEnvelope {
115
- schema_version: "2026-03-01";
123
+ schema_version: string;
116
124
  event_id: string;
117
125
  event_type:
118
126
  | "frontend_exception"
127
+ | "backend_exception"
128
+ | "diagnostic_context"
129
+ | "deploy_metadata"
119
130
  | "frontend_breadcrumb"
120
131
  | "log_event"
121
132
  | "request_event"
122
133
  | "error_suppressed"
123
134
  | "probe_event";
124
- sdk_name: "@debugbundle/sdk-react-native";
135
+ sdk_name: string;
125
136
  sdk_version: string;
126
137
  service: {
127
138
  name: string;
128
139
  environment: string;
129
- runtime: "react-native";
130
- framework: "react-native";
140
+ runtime?: string | null;
141
+ framework?: string | null;
131
142
  };
132
143
  occurred_at: string;
133
144
  correlation?: {
134
- trace_id: string | null;
145
+ trace_id?: string | null;
135
146
  request_id?: string | null;
136
147
  session_id?: string | null;
137
148
  user_id_hash?: string | null;
@@ -143,6 +154,10 @@ export interface DebugBundleEventEnvelope {
143
154
  export interface DebugBundleClient {
144
155
  readonly status: DebugBundleStatus;
145
156
  readonly lastEventAt: number | null;
157
+ readonly evidenceCapabilities?: EvidenceCapabilities;
158
+ beginOperation?(): OperationScope;
159
+ withOperationContext?<T>(operation: (scope: OperationScope) => T): T;
160
+ captureRecovery?(observation: RecoveryObservation): void;
146
161
  init(config: DebugBundleConfig): void;
147
162
  captureException(error: unknown, context?: DebugBundleCaptureContext): void;
148
163
  captureError(error: unknown, context?: DebugBundleCaptureContext): void;
@@ -164,6 +179,7 @@ export interface DebugBundleClient {
164
179
  export interface NativeDebugBundleModule {
165
180
  initialize(config: NativeDebugBundleConfig): Promise<NativeDebugBundleState> | NativeDebugBundleState;
166
181
  enqueueCanonicalEvent?(event: DebugBundleEventEnvelope): Promise<boolean | void> | boolean | void;
182
+ enqueueCanonicalEventJson?(event: string): Promise<boolean | void> | boolean | void;
167
183
  /** Compatibility fallback implemented by native binaries released before the canonical bridge. */
168
184
  enqueueEvent(event: DebugBundleEventEnvelope): Promise<void> | void;
169
185
  captureProbe?(
@@ -185,6 +201,8 @@ export interface NativeDebugBundleState {
185
201
  status: DebugBundleStatus;
186
202
  lastEventAt?: number | null;
187
203
  device?: Record<string, unknown> | null;
204
+ evidenceCapabilities?: unknown;
188
205
  nativeModuleAvailable?: boolean;
189
206
  degradedReason?: string | null;
190
207
  }
208
+ import type { EvidenceCapabilities, EvidenceScope, OperationScope, RecoveryObservation } from "./evidence-types.js";