@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,196 @@
1
+ import type { NativeDebugBundleState, DebugBundleEventEnvelope, ResolvedDebugBundleConfig } from "./types.js";
2
+
3
+ export function correlationFromContext(context: Record<string, unknown>, traceId: string | null = null): DebugBundleEventEnvelope["correlation"] {
4
+ const nested = Object.getOwnPropertyDescriptor(context, "correlation");
5
+ const source = nested && "value" in nested && nested.value && typeof nested.value === "object" ? nested.value : {};
6
+ const result: NonNullable<DebugBundleEventEnvelope["correlation"]> = {};
7
+ for (const key of ["request_id", "trace_id", "session_id", "user_id_hash"] as const) {
8
+ const descriptor = Object.getOwnPropertyDescriptor(source, key) ?? Object.getOwnPropertyDescriptor(context, key);
9
+ if (descriptor && "value" in descriptor && (descriptor.value === null || typeof descriptor.value === "string")) result[key] = descriptor.value;
10
+ }
11
+ if (traceId !== null) result.trace_id = traceId;
12
+ return Object.keys(result).length ? result : undefined;
13
+ }
14
+
15
+ /** Both inputs are validated, owned canonical device JSON. Key order is immaterial. */
16
+ export function sameMobileDevice(left: unknown, right: unknown): boolean {
17
+ if (left === right) return true;
18
+ if (!left || !right || typeof left !== "object" || typeof right !== "object" || Array.isArray(left) || Array.isArray(right)) return false;
19
+ const a = left as Record<string, unknown>, b = right as Record<string, unknown>;
20
+ const keys = Object.keys(a);
21
+ return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && sameMobileDevice(a[key], b[key]));
22
+ }
23
+
24
+ export function normalizeNativeState(state: NativeDebugBundleState): NativeDebugBundleState {
25
+ return {
26
+ ...state,
27
+ status: state.status || "healthy",
28
+ nativeModuleAvailable: state.nativeModuleAvailable ?? true
29
+ };
30
+ }
31
+
32
+ export function stringValue(value: unknown): string | null {
33
+ return typeof value === "string" && value.length > 0 ? value : null;
34
+ }
35
+
36
+ export function asRecord(value: unknown): Record<string, unknown> {
37
+ if (value && typeof value === "object" && !Array.isArray(value)) {
38
+ return value as Record<string, unknown>;
39
+ }
40
+ return {};
41
+ }
42
+
43
+ export function objectWrap(value: unknown): Record<string, unknown> {
44
+ return value && typeof value === "object" && !Array.isArray(value)
45
+ ? value as Record<string, unknown>
46
+ : { value };
47
+ }
48
+
49
+ export function parseRequestUrl(url: string): { path: string; query: Record<string, unknown> } {
50
+ try {
51
+ const parsed = new URL(url);
52
+ const query: Record<string, unknown> = {};
53
+ for (const [key, value] of parsed.searchParams) {
54
+ const existing = query[key];
55
+ if (existing === undefined) {
56
+ query[key] = value;
57
+ } else if (Array.isArray(existing)) {
58
+ existing.push(value);
59
+ } else {
60
+ query[key] = [existing, value];
61
+ }
62
+ }
63
+ return { path: parsed.pathname || "/", query };
64
+ } catch {
65
+ const [path = "/", rawQuery = ""] = url.split("?", 2);
66
+ const query: Record<string, unknown> = {};
67
+ for (const segment of rawQuery.split("&")) {
68
+ if (!segment) {
69
+ continue;
70
+ }
71
+ const [rawKey = "", rawValue = ""] = segment.split("=", 2);
72
+ const key = decodeQuerySegment(rawKey);
73
+ const value = decodeQuerySegment(rawValue);
74
+ const existing = query[key];
75
+ if (existing === undefined) {
76
+ query[key] = value;
77
+ } else if (Array.isArray(existing)) {
78
+ existing.push(value);
79
+ } else {
80
+ query[key] = [existing, value];
81
+ }
82
+ }
83
+ return { path: path || "/", query };
84
+ }
85
+ }
86
+
87
+ function decodeQuerySegment(value: string): string {
88
+ try {
89
+ return decodeURIComponent(value.replace(/\+/g, " "));
90
+ } catch {
91
+ return value;
92
+ }
93
+ }
94
+
95
+ export function sanitizeScreenName(screenName: string): string {
96
+ return screenName.replace(/[^a-zA-Z0-9_.:/-]/g, "_").slice(0, 120);
97
+ }
98
+
99
+ export function generateId(): string {
100
+ const cryptoLike = globalThis.crypto;
101
+ if (cryptoLike?.randomUUID) {
102
+ return cryptoLike.randomUUID();
103
+ }
104
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (marker) => {
105
+ const random = Math.trunc(Math.random() * 16);
106
+ const value = marker === "x" ? random : (random & 0x3) | 0x8;
107
+ return value.toString(16);
108
+ });
109
+ }
110
+
111
+ export function canonicalDevice(
112
+ nativeDevice: Record<string, unknown> | null | undefined,
113
+ config: ResolvedDebugBundleConfig
114
+ ): Record<string, unknown> {
115
+ const source = nativeDevice ?? {};
116
+ const sourceOs = asRecord(source.os);
117
+ const sourceScreen = asRecord(source.screen);
118
+ const sourceViewport = asRecord(source.viewport);
119
+ const width = nonNegativeInteger(sourceScreen.width ?? source.screen_width);
120
+ const height = nonNegativeInteger(sourceScreen.height ?? source.screen_height);
121
+ return {
122
+ user_agent: stringValue(source.user_agent),
123
+ os: {
124
+ name: stringValue(sourceOs.name ?? source.os_name),
125
+ version: stringValue(sourceOs.version ?? source.os_version)
126
+ },
127
+ device_type: canonicalDeviceType(source.device_type),
128
+ screen: { width, height },
129
+ viewport: {
130
+ width: nonNegativeInteger(sourceViewport.width ?? width),
131
+ height: nonNegativeInteger(sourceViewport.height ?? height)
132
+ },
133
+ device_pixel_ratio: positiveNumber(source.device_pixel_ratio),
134
+ touch_capable: typeof source.touch_capable === "boolean" ? source.touch_capable : null,
135
+ language: stringValue(source.language ?? source.locale),
136
+ connection_type: stringValue(source.connection_type),
137
+ color_scheme_preference: canonicalColorScheme(source.color_scheme_preference),
138
+ app_version: stringValue(source.app_version) ?? config.appVersion,
139
+ build_number: stringValue(source.build_number) ?? config.buildNumber,
140
+ release_channel: stringValue(source.release_channel) ?? config.releaseChannel,
141
+ api_level: nullableNonNegativeInteger(source.api_level),
142
+ manufacturer: stringValue(source.manufacturer),
143
+ model: stringValue(source.model),
144
+ timezone: stringValue(source.timezone),
145
+ battery_level: nonNegativeNumber(source.battery_level),
146
+ battery_charging: typeof source.battery_charging === "boolean"
147
+ ? source.battery_charging
148
+ : typeof source.charging === "boolean" ? source.charging : null,
149
+ free_disk_bytes: nullableNonNegativeInteger(source.free_disk_bytes),
150
+ free_memory_bytes: nullableNonNegativeInteger(source.free_memory_bytes),
151
+ jailbroken: typeof source.jailbroken === "boolean"
152
+ ? source.jailbroken
153
+ : typeof source.rooted === "boolean" ? source.rooted : null
154
+ };
155
+ }
156
+
157
+ export function toLegacyNativeEvent(event: DebugBundleEventEnvelope): DebugBundleEventEnvelope {
158
+ const context = event.context ?? {};
159
+ const payload: Record<string, unknown> = { ...event.payload, context };
160
+ if (event.event_type === "frontend_exception") {
161
+ payload.error = {
162
+ name: event.payload.name,
163
+ message: event.payload.message,
164
+ stack: event.payload.stack
165
+ };
166
+ } else if (event.event_type === "request_event") {
167
+ payload.url = event.payload.path;
168
+ }
169
+ return { ...event, payload };
170
+ }
171
+
172
+ function canonicalDeviceType(value: unknown): "desktop" | "mobile" | "tablet" | "unknown" {
173
+ return value === "desktop" || value === "mobile" || value === "tablet" ? value : "unknown";
174
+ }
175
+
176
+ function canonicalColorScheme(value: unknown): "light" | "dark" | "no-preference" | null {
177
+ return value === "light" || value === "dark" || value === "no-preference" ? value : null;
178
+ }
179
+
180
+ function nonNegativeInteger(value: unknown): number {
181
+ return nullableNonNegativeInteger(value) ?? 0;
182
+ }
183
+
184
+ function nullableNonNegativeInteger(value: unknown): number | null {
185
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
186
+ ? Math.trunc(value)
187
+ : null;
188
+ }
189
+
190
+ function positiveNumber(value: unknown): number | null {
191
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
192
+ }
193
+
194
+ function nonNegativeNumber(value: unknown): number | null {
195
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
196
+ }
package/src/client.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import { utf8Bytes as utf8ByteLength } from "./owned-json.js";
2
+ import { normalizeNativeState, stringValue, asRecord, objectWrap, parseRequestUrl, sanitizeScreenName, generateId, canonicalDevice, toLegacyNativeEvent, correlationFromContext, sameMobileDevice } from "./client-values.js";
3
+ import { MobileEvidenceController, fitMobileEvent } from "./evidence-controller.js";
4
+ import { normalizeCapabilities } from "./evidence-capabilities.js";
5
+ import { canonicalEvent } from "./evidence-wire.js";
6
+ import { ownJSON } from "./owned-json.js";
7
+ import { snapshotError, snapshotExceptionEvidence } from "./exception-evidence.js";
8
+ import type { EvidenceCapabilities, OperationScope, RecoveryObservation } from "./evidence-types.js";
1
9
  import { getNativeModule, degradedNativeState, safeNativeCall } from "./native.js";
2
10
  import { applyBeforeSend } from "./before-send.js";
3
11
  import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
@@ -19,7 +27,7 @@ import type {
19
27
  } from "./types.js";
20
28
 
21
29
  const SDK_NAME = "@debugbundle/sdk-react-native" as const;
22
- const DEFAULT_SDK_VERSION = "3.0.1";
30
+ const DEFAULT_SDK_VERSION = "3.1.0";
23
31
  const MAX_PENDING_NATIVE_CALLS = 256;
24
32
  const MAX_PENDING_LOW_PRIORITY_CALLS = 224;
25
33
  const MAX_PENDING_NATIVE_BYTES = 4 * 1024 * 1024;
@@ -78,9 +86,11 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
78
86
  private jsProbeActivationExpiresAt = 0;
79
87
  private pendingNativeCalls = 0;
80
88
  private pendingNativeBytes = 0;
89
+ private evidence: MobileEvidenceController;
81
90
 
82
91
  constructor(config: DebugBundleConfig = {}) {
83
92
  this.config = resolveConfig(config);
93
+ this.evidence = new MobileEvidenceController(this.config);
84
94
  if (Object.keys(config).length > 0) {
85
95
  this.init(config);
86
96
  }
@@ -94,8 +104,30 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
94
104
  return this.nativeState.lastEventAt ?? null;
95
105
  }
96
106
 
107
+ get evidenceCapabilities(): EvidenceCapabilities {
108
+ return this.evidence.capabilities(this.nativeState, this.hasCanonicalBridge());
109
+ }
110
+
111
+ private hasCanonicalBridge(): boolean {
112
+ return this.nativeModule?.enqueueCanonicalEventJson !== undefined || this.nativeModule?.enqueueCanonicalEvent !== undefined;
113
+ }
114
+
115
+ beginOperation(): OperationScope { return this.evidence.begin(); }
116
+ withOperationContext<T>(operation: (scope: OperationScope) => T): T {
117
+ const scope = this.beginOperation();
118
+ try { return operation(scope); } finally { scope.close(); }
119
+ }
120
+ captureRecovery(observation: RecoveryObservation): void {
121
+ if (!this.canCapture("diagnostic_context", true) || !this.hasNativeCapacity(false) ||
122
+ !this.evidenceCapabilities.features.includes("recovery_v1")) return;
123
+ const prepared = this.evidence.recovery(observation);
124
+ if (prepared) this.enqueue("diagnostic_context", prepared.payload, null, true, undefined, true, true, prepared.correlation);
125
+ }
126
+
97
127
  init(config: DebugBundleConfig): void {
128
+ this.evidence.close();
98
129
  this.config = resolveConfig(config);
130
+ this.evidence = new MobileEvidenceController(this.config);
99
131
  this.nativeModule = getNativeModule();
100
132
  if (!this.config.enabled) {
101
133
  this.nativeState = { status: "disconnected", lastEventAt: null, nativeModuleAvailable: Boolean(this.nativeModule) };
@@ -110,11 +142,13 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
110
142
  return;
111
143
  }
112
144
  try {
145
+ const initializedConfig = this.config;
113
146
  const initialization = this.nativeModule.initialize(nativeConfig(this.config));
114
147
  if (isPromiseLike(initialization)) {
115
148
  this.nativeState = { status: "healthy", lastEventAt: null, nativeModuleAvailable: true };
116
149
  void safeNativeCall(async () => {
117
- this.nativeState = normalizeNativeState(await initialization);
150
+ const state = await initialization;
151
+ if (this.config === initializedConfig) this.nativeState = normalizeNativeState(state);
118
152
  }, undefined);
119
153
  } else {
120
154
  this.nativeState = normalizeNativeState(initialization);
@@ -127,17 +161,21 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
127
161
  captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
128
162
  if (!this.config.captureErrors || !this.canCapture("frontend_exception", false) ||
129
163
  !this.hasNativeCapacity(true)) return;
130
- const mergedContext = this.mergeContext(context);
131
- const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
164
+ const mergedContext = this.evidence.context(this.mergeContext(context), this.nativeState, this.hasCanonicalBridge());
165
+ const sanitizedError = snapshotError(error, this.config.redactFields);
166
+ const fatal = mergedContext.source === "react-native-global-error" && mergedContext.fatal === true;
167
+ const graph = this.evidenceCapabilities.features.includes("exception_evidence_v1")
168
+ ? snapshotExceptionEvidence(error, sanitizedError, this.config.redactFields, fatal) : undefined;
132
169
  this.enqueue("frontend_exception", {
133
170
  name: stringValue(sanitizedError.name) ?? "Error",
134
171
  message: stringValue(sanitizedError.message) ?? "Unknown error",
135
172
  stack: stringValue(sanitizedError.stack) ?? `${stringValue(sanitizedError.name) ?? "Error"}: ${stringValue(sanitizedError.message) ?? "Unknown error"}`,
136
173
  breadcrumbs: this.breadcrumbs.slice(),
137
- probe_data: this.snapshotProbeData()
174
+ probe_data: this.snapshotProbeData(),
175
+ ...(graph ? { exception_evidence: graph } : {})
138
176
  }, stringValue(mergedContext.trace_id), false, asRecord(sanitizeValue(mergedContext, {
139
177
  redactFields: this.config.redactFields
140
- })), this.config.captureErrors);
178
+ })), this.config.captureErrors, !fatal, correlationFromContext(mergedContext));
141
179
  }
142
180
 
143
181
  captureError(error: unknown, context: DebugBundleCaptureContext = {}): void {
@@ -266,7 +304,7 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
266
304
  await safeNativeCall(async () => {
267
305
  await this.nativeModule!.flush();
268
306
  const state = await this.nativeModule!.getStatus();
269
- this.nativeState = state;
307
+ this.nativeState = normalizeNativeState(state);
270
308
  }, undefined);
271
309
  }
272
310
 
@@ -290,7 +328,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
290
328
  this.breadcrumbs.push(breadcrumb);
291
329
  this.breadcrumbs = this.breadcrumbs.slice(-this.config.maxBreadcrumbs);
292
330
  this.enqueue("frontend_breadcrumb", {
293
- breadcrumb_type: type,
331
+ breadcrumb_type: ["route_change", "click", "form_submit", "console_log", "network_request"].includes(type) ? type
332
+ : type === "screen" ? "route_change" : type === "action" ? "click" : "console_log",
294
333
  data: breadcrumb.data
295
334
  }, null, true);
296
335
  }
@@ -312,7 +351,9 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
312
351
  traceId: string | null,
313
352
  countTowardSession: boolean,
314
353
  context?: Record<string, unknown>,
315
- localPolicyAllows = true
354
+ localPolicyAllows = true,
355
+ runBeforeSend = true,
356
+ correlation?: DebugBundleEventEnvelope["correlation"]
316
357
  ): void {
317
358
  if (!localPolicyAllows) return;
318
359
  if (!this.canCapture(eventType, countTowardSession)) {
@@ -324,6 +365,7 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
324
365
  this.pendingNativeCalls += 1;
325
366
  let handedOff = false;
326
367
  let reservedBytes = 0;
368
+ let owner: string | null = null;
327
369
  try {
328
370
  const authoredEvent: DebugBundleEventEnvelope = {
329
371
  schema_version: "2026-03-01",
@@ -338,14 +380,14 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
338
380
  framework: "react-native"
339
381
  },
340
382
  occurred_at: new Date().toISOString(),
341
- ...(traceId ? { correlation: { trace_id: traceId } } : {}),
383
+ ...(correlation ? { correlation } : traceId ? { correlation: { trace_id: traceId } } : {}),
342
384
  ...(context && Object.keys(context).length > 0 ? { context } : {}),
343
- payload: this.nativeState.device
344
- ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
345
- : payload
385
+ payload: eventType === "diagnostic_context" ? payload : { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
346
386
  };
347
387
  const initial = protectEventFields(authoredEvent, this.config.redactFields);
348
388
  if (!initial) return;
389
+ owner = initial.event_id;
390
+ if (initial.event_type === "diagnostic_context" && (!canonicalEvent(ownJSON(initial)) || !this.evidence.budget.reserve(initial, owner))) return;
349
391
  const config = this.config;
350
392
  const nativeModule = this.nativeModule;
351
393
  const initialBytes = utf8ByteLength(JSON.stringify(initial));
@@ -358,11 +400,11 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
358
400
  return;
359
401
  }
360
402
  handedOff = true;
361
- this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes);
403
+ this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes, runBeforeSend, !this.nativeState.device);
362
404
  } catch {
363
405
  this.nativeState = degradedNativeState("event_preparation_failed");
364
406
  } finally {
365
- if (!handedOff) this.releaseNativeCapacity(reservedBytes);
407
+ if (!handedOff) { this.releaseNativeCapacity(reservedBytes); if (owner) this.evidence.budget.release(owner); }
366
408
  }
367
409
  }
368
410
 
@@ -370,15 +412,18 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
370
412
  initial: DebugBundleEventEnvelope,
371
413
  config: ResolvedDebugBundleConfig,
372
414
  nativeModule: NativeDebugBundleModule,
373
- reservedBytes: number
415
+ reservedBytes: number,
416
+ runBeforeSend: boolean,
417
+ hydrateNativeDevice: boolean
374
418
  ): void {
375
419
  // This activation receives only a protected snapshot: deferred closures must
376
420
  // not retain the raw capture payload/context through their lexical scope.
421
+ const evidence = this.evidence;
377
422
  const prepareAndSend = async (): Promise<void> => {
378
423
  if (this.config !== config) return;
379
- const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEvent !== undefined);
424
+ const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEventJson !== undefined || nativeModule.enqueueCanonicalEvent !== undefined, runBeforeSend, hydrateNativeDevice);
380
425
  if (safeEvent === null) return;
381
- const finalPriority = safeEvent.event_type === "frontend_exception" ||
426
+ const finalPriority = ["frontend_exception", "backend_exception"].includes(safeEvent.event_type) ||
382
427
  safeEvent.event_type === "log_event" && ["error", "critical"].includes(String(safeEvent.payload.level));
383
428
  const eventBytes = utf8ByteLength(JSON.stringify(safeEvent));
384
429
  const finalLimit = finalPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
@@ -387,26 +432,45 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
387
432
  if (eventBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + eventBytes > finalLimit) return;
388
433
  this.pendingNativeBytes += eventBytes;
389
434
  reservedBytes += eventBytes;
390
- if (nativeModule.enqueueCanonicalEvent) await nativeModule.enqueueCanonicalEvent(safeEvent);
435
+ if (safeEvent.event_type === "diagnostic_context") {
436
+ if (!evidence.budget.replace(initial.event_id, safeEvent)) return;
437
+ } else {
438
+ evidence.budget.release(initial.event_id);
439
+ if (["frontend_exception", "backend_exception"].includes(safeEvent.event_type)) evidence.budget.register(safeEvent);
440
+ }
441
+ // iOS TurboModule object conversion removes nested nulls. A string carrier
442
+ // preserves the validated JSON shape; older binaries keep their existing API.
443
+ if (nativeModule.enqueueCanonicalEventJson) await nativeModule.enqueueCanonicalEventJson(JSON.stringify(safeEvent));
444
+ else if (nativeModule.enqueueCanonicalEvent) await nativeModule.enqueueCanonicalEvent(safeEvent);
391
445
  else await nativeModule.enqueueEvent(safeEvent);
392
446
  };
393
- const operation = config.beforeSend === null
447
+ const operation = config.beforeSend === null || !runBeforeSend
394
448
  ? safeNativeCall(prepareAndSend, undefined)
395
449
  : Promise.resolve().then(() => safeNativeCall(prepareAndSend, undefined));
396
- void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); });
450
+ void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); evidence.budget.release(initial.event_id); });
397
451
  }
398
452
 
399
- private prepareAdmittedEvent(initial: DebugBundleEventEnvelope, config: ResolvedDebugBundleConfig, canonical: boolean): DebugBundleEventEnvelope | null {
453
+ private prepareAdmittedEvent(initial: DebugBundleEventEnvelope, config: ResolvedDebugBundleConfig, canonical: boolean, runBeforeSend: boolean, hydrateNativeDevice: boolean): DebugBundleEventEnvelope | null {
400
454
  // Exit this synchronous activation before awaiting the bridge. An application
401
455
  // replacement may be huge before projection and must not survive suspension.
402
- const event = applyBeforeSend(initial, config.beforeSend);
456
+ const event = applyBeforeSend(initial, runBeforeSend ? config.beforeSend : null);
403
457
  if (!event || this.config !== config) return null;
404
458
  const safeEvent = protectEventFields(event, config.redactFields);
405
459
  if (!safeEvent) return null;
406
460
  if (safeEvent.event_type === "log_event" &&
407
461
  (!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);
462
+ if (["frontend_exception", "backend_exception"].includes(safeEvent.event_type) && !config.captureErrors) return null;
463
+ if (!canonicalEvent(ownJSON(safeEvent))) return null;
464
+ const filtered = this.evidence.filter(safeEvent, this.nativeState, canonical);
465
+ if (!filtered) return null;
466
+ const fitted = fitMobileEvent(filtered);
467
+ if (fitted && hydrateNativeDevice && fitted.sdk_name === SDK_NAME && fitted.event_type === "frontend_exception" &&
468
+ sameMobileDevice(initial.payload.device, fitted.payload.device)) {
469
+ // This is the private native carrier, whose existing worker supplies device
470
+ // metadata before wire delivery. Preserve an explicitly changed hook device.
471
+ delete fitted.payload.device;
472
+ }
473
+ return fitted ? canonical ? fitted : toLegacyNativeEvent(fitted) : null;
410
474
  }
411
475
 
412
476
  private hasNativeCapacity(highPriority: boolean): boolean {
@@ -453,20 +517,6 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
453
517
 
454
518
  }
455
519
 
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
520
 
471
521
  export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
472
522
  return new DebugBundleReactNativeClient(config);
@@ -489,6 +539,9 @@ export function resolveConfig(config: DebugBundleConfig): ResolvedDebugBundleCon
489
539
  maxEventsPerSession: clampInteger(config.maxEventsPerSession, 100, 1, 10000),
490
540
  maxBreadcrumbs: clampInteger(config.maxBreadcrumbs, 20, 1, 200),
491
541
  captureErrors: config.captureErrors ?? true,
542
+ captureExceptionEvidence: config.captureExceptionEvidence ?? true,
543
+ captureRecoveryObservations: config.captureRecoveryObservations ?? false,
544
+ evidenceCapabilities: config.evidenceCapabilities ? normalizeCapabilities(config.evidenceCapabilities) : null,
492
545
  captureUnhandledRejections: config.captureUnhandledRejections ?? true,
493
546
  captureScreens: config.captureScreens ?? true,
494
547
  captureActions: config.captureActions ?? false,
@@ -542,177 +595,3 @@ function safeInvoke(producer: () => unknown): unknown {
542
595
  function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
543
596
  return Boolean(value && typeof (value as Promise<T>).then === "function");
544
597
  }
545
-
546
- function normalizeNativeState(state: NativeDebugBundleState): NativeDebugBundleState {
547
- return {
548
- ...state,
549
- status: state.status || "healthy",
550
- nativeModuleAvailable: state.nativeModuleAvailable ?? true
551
- };
552
- }
553
-
554
- function stringValue(value: unknown): string | null {
555
- return typeof value === "string" && value.length > 0 ? value : null;
556
- }
557
-
558
- function asRecord(value: unknown): Record<string, unknown> {
559
- if (value && typeof value === "object" && !Array.isArray(value)) {
560
- return value as Record<string, unknown>;
561
- }
562
- return {};
563
- }
564
-
565
- function objectWrap(value: unknown): Record<string, unknown> {
566
- return value && typeof value === "object" && !Array.isArray(value)
567
- ? value as Record<string, unknown>
568
- : { value };
569
- }
570
-
571
- function parseRequestUrl(url: string): { path: string; query: Record<string, unknown> } {
572
- try {
573
- const parsed = new URL(url);
574
- const query: Record<string, unknown> = {};
575
- for (const [key, value] of parsed.searchParams) {
576
- const existing = query[key];
577
- if (existing === undefined) {
578
- query[key] = value;
579
- } else if (Array.isArray(existing)) {
580
- existing.push(value);
581
- } else {
582
- query[key] = [existing, value];
583
- }
584
- }
585
- return { path: parsed.pathname || "/", query };
586
- } catch {
587
- const [path = "/", rawQuery = ""] = url.split("?", 2);
588
- const query: Record<string, unknown> = {};
589
- for (const segment of rawQuery.split("&")) {
590
- if (!segment) {
591
- continue;
592
- }
593
- const [rawKey = "", rawValue = ""] = segment.split("=", 2);
594
- const key = decodeQuerySegment(rawKey);
595
- const value = decodeQuerySegment(rawValue);
596
- const existing = query[key];
597
- if (existing === undefined) {
598
- query[key] = value;
599
- } else if (Array.isArray(existing)) {
600
- existing.push(value);
601
- } else {
602
- query[key] = [existing, value];
603
- }
604
- }
605
- return { path: path || "/", query };
606
- }
607
- }
608
-
609
- function decodeQuerySegment(value: string): string {
610
- try {
611
- return decodeURIComponent(value.replace(/\+/g, " "));
612
- } catch {
613
- return value;
614
- }
615
- }
616
-
617
- function sanitizeScreenName(screenName: string): string {
618
- return screenName.replace(/[^a-zA-Z0-9_.:/-]/g, "_").slice(0, 120);
619
- }
620
-
621
- function generateId(): string {
622
- const cryptoLike = globalThis.crypto;
623
- if (cryptoLike?.randomUUID) {
624
- return cryptoLike.randomUUID();
625
- }
626
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (marker) => {
627
- const random = Math.trunc(Math.random() * 16);
628
- const value = marker === "x" ? random : (random & 0x3) | 0x8;
629
- return value.toString(16);
630
- });
631
- }
632
-
633
- function canonicalDevice(
634
- nativeDevice: Record<string, unknown> | null | undefined,
635
- config: ResolvedDebugBundleConfig
636
- ): Record<string, unknown> {
637
- const source = nativeDevice ?? {};
638
- const sourceOs = asRecord(source.os);
639
- const sourceScreen = asRecord(source.screen);
640
- const sourceViewport = asRecord(source.viewport);
641
- const width = nonNegativeInteger(sourceScreen.width ?? source.screen_width);
642
- const height = nonNegativeInteger(sourceScreen.height ?? source.screen_height);
643
- return {
644
- user_agent: stringValue(source.user_agent),
645
- os: {
646
- name: stringValue(sourceOs.name ?? source.os_name),
647
- version: stringValue(sourceOs.version ?? source.os_version)
648
- },
649
- device_type: canonicalDeviceType(source.device_type),
650
- screen: { width, height },
651
- viewport: {
652
- width: nonNegativeInteger(sourceViewport.width ?? width),
653
- height: nonNegativeInteger(sourceViewport.height ?? height)
654
- },
655
- device_pixel_ratio: positiveNumber(source.device_pixel_ratio),
656
- touch_capable: typeof source.touch_capable === "boolean" ? source.touch_capable : null,
657
- language: stringValue(source.language ?? source.locale),
658
- connection_type: stringValue(source.connection_type),
659
- color_scheme_preference: canonicalColorScheme(source.color_scheme_preference),
660
- app_version: stringValue(source.app_version) ?? config.appVersion,
661
- build_number: stringValue(source.build_number) ?? config.buildNumber,
662
- release_channel: stringValue(source.release_channel) ?? config.releaseChannel,
663
- api_level: nullableNonNegativeInteger(source.api_level),
664
- manufacturer: stringValue(source.manufacturer),
665
- model: stringValue(source.model),
666
- timezone: stringValue(source.timezone),
667
- battery_level: nonNegativeNumber(source.battery_level),
668
- battery_charging: typeof source.battery_charging === "boolean"
669
- ? source.battery_charging
670
- : typeof source.charging === "boolean" ? source.charging : null,
671
- free_disk_bytes: nullableNonNegativeInteger(source.free_disk_bytes),
672
- free_memory_bytes: nullableNonNegativeInteger(source.free_memory_bytes),
673
- jailbroken: typeof source.jailbroken === "boolean"
674
- ? source.jailbroken
675
- : typeof source.rooted === "boolean" ? source.rooted : null
676
- };
677
- }
678
-
679
- function toLegacyNativeEvent(event: DebugBundleEventEnvelope): DebugBundleEventEnvelope {
680
- const context = event.context ?? {};
681
- const payload: Record<string, unknown> = { ...event.payload, context };
682
- if (event.event_type === "frontend_exception") {
683
- payload.error = {
684
- name: event.payload.name,
685
- message: event.payload.message,
686
- stack: event.payload.stack
687
- };
688
- } else if (event.event_type === "request_event") {
689
- payload.url = event.payload.path;
690
- }
691
- return { ...event, payload };
692
- }
693
-
694
- function canonicalDeviceType(value: unknown): "desktop" | "mobile" | "tablet" | "unknown" {
695
- return value === "desktop" || value === "mobile" || value === "tablet" ? value : "unknown";
696
- }
697
-
698
- function canonicalColorScheme(value: unknown): "light" | "dark" | "no-preference" | null {
699
- return value === "light" || value === "dark" || value === "no-preference" ? value : null;
700
- }
701
-
702
- function nonNegativeInteger(value: unknown): number {
703
- return nullableNonNegativeInteger(value) ?? 0;
704
- }
705
-
706
- function nullableNonNegativeInteger(value: unknown): number | null {
707
- return typeof value === "number" && Number.isFinite(value) && value >= 0
708
- ? Math.trunc(value)
709
- : null;
710
- }
711
-
712
- function positiveNumber(value: unknown): number | null {
713
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
714
- }
715
-
716
- function nonNegativeNumber(value: unknown): number | null {
717
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
718
- }