@debugbundle/sdk-react-native 1.1.0 → 1.3.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/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getNativeModule, degradedNativeState, safeNativeCall } from "./native.js";
2
+ import { applyBeforeSend } from "./before-send.js";
2
3
  import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
3
4
  import type {
4
5
  DebugBundleCaptureContext,
@@ -11,12 +12,13 @@ import type {
11
12
  DebugBundleResponseInfo,
12
13
  DebugBundleStatus,
13
14
  NativeDebugBundleModule,
15
+ NativeDebugBundleConfig,
14
16
  NativeDebugBundleState,
15
17
  ResolvedDebugBundleConfig
16
18
  } from "./types.js";
17
19
 
18
20
  const SDK_NAME = "@debugbundle/sdk-react-native" as const;
19
- const DEFAULT_SDK_VERSION = "1.1.0";
21
+ const DEFAULT_SDK_VERSION = "1.3.0";
20
22
  const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
21
23
  debug: 10,
22
24
  info: 20,
@@ -33,8 +35,6 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
33
35
  private breadcrumbs: Array<Record<string, unknown>> = [];
34
36
  private probeBuffers = new Map<string, Array<Record<string, unknown>>>();
35
37
  private jsProbeActivationExpiresAt = 0;
36
- private sessionSampledIn = true;
37
- private sessionEventCount = 0;
38
38
 
39
39
  constructor(config: DebugBundleConfig = {}) {
40
40
  this.config = resolveConfig(config);
@@ -54,9 +54,6 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
54
54
  init(config: DebugBundleConfig): void {
55
55
  this.config = resolveConfig(config);
56
56
  this.nativeModule = getNativeModule();
57
- this.sessionSampledIn =
58
- this.config.enabled &&
59
- (this.config.sessionSampleRate >= 1 || Math.random() <= this.config.sessionSampleRate);
60
57
  if (!this.config.enabled) {
61
58
  this.nativeState = { status: "disconnected", lastEventAt: null, nativeModuleAvailable: Boolean(this.nativeModule) };
62
59
  return;
@@ -69,26 +66,33 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
69
66
  this.nativeState = degradedNativeState("native_module_unavailable");
70
67
  return;
71
68
  }
72
- void safeNativeCall(
73
- async () => {
74
- this.nativeState = await this.nativeModule!.initialize(this.config);
75
- if (!this.nativeState.status) {
76
- this.nativeState.status = "healthy";
77
- }
78
- },
79
- undefined
80
- );
81
- this.nativeState = { status: "healthy", lastEventAt: null, nativeModuleAvailable: true };
69
+ try {
70
+ const initialization = this.nativeModule.initialize(nativeConfig(this.config));
71
+ if (isPromiseLike(initialization)) {
72
+ this.nativeState = { status: "healthy", lastEventAt: null, nativeModuleAvailable: true };
73
+ void safeNativeCall(async () => {
74
+ this.nativeState = normalizeNativeState(await initialization);
75
+ }, undefined);
76
+ } else {
77
+ this.nativeState = normalizeNativeState(initialization);
78
+ }
79
+ } catch {
80
+ this.nativeState = degradedNativeState("native_initialize_failed");
81
+ }
82
82
  }
83
83
 
84
84
  captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
85
85
  const mergedContext = this.mergeContext(context);
86
+ const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
86
87
  this.enqueue("frontend_exception", {
87
- error: sanitizeValue(error, { redactFields: this.config.redactFields }),
88
- context: sanitizeValue(mergedContext, { redactFields: this.config.redactFields }),
88
+ name: stringValue(sanitizedError.name) ?? "Error",
89
+ message: stringValue(sanitizedError.message) ?? "Unknown error",
90
+ stack: stringValue(sanitizedError.stack) ?? `${stringValue(sanitizedError.name) ?? "Error"}: ${stringValue(sanitizedError.message) ?? "Unknown error"}`,
89
91
  breadcrumbs: this.breadcrumbs.slice(),
90
92
  probe_data: this.snapshotProbeData()
91
- }, stringValue(mergedContext.trace_id), false);
93
+ }, stringValue(mergedContext.trace_id), false, asRecord(sanitizeValue(mergedContext, {
94
+ redactFields: this.config.redactFields
95
+ })), this.config.captureErrors);
92
96
  }
93
97
 
94
98
  captureError(error: unknown, context: DebugBundleCaptureContext = {}): void {
@@ -96,15 +100,15 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
96
100
  }
97
101
 
98
102
  captureLog(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
99
- if (!this.config.captureLogs || LOG_LEVELS[level] < LOG_LEVELS[this.config.logLevel]) {
100
- return;
101
- }
102
103
  this.enqueue("log_event", {
103
104
  level,
104
105
  message: sanitizeValue(message, { redactFields: this.config.redactFields }),
105
- context: sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields }),
106
- source: "javascript"
107
- }, stringValue(context.trace_id), true);
106
+ attributes: {
107
+ ...asRecord(sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields })),
108
+ source: "javascript"
109
+ }
110
+ }, stringValue(context.trace_id), true, undefined,
111
+ this.config.captureLogs && LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel]);
108
112
  }
109
113
 
110
114
  captureRequest(
@@ -112,25 +116,34 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
112
116
  response: DebugBundleResponseInfo,
113
117
  context: DebugBundleCaptureContext = {}
114
118
  ): void {
115
- const traceId = request.traceId ?? stringValue(context.trace_id);
119
+ const mergedContext = this.mergeContext(context);
120
+ const traceId = request.traceId ?? stringValue(mergedContext.trace_id);
121
+ const parsedUrl = parseRequestUrl(request.url);
116
122
  const payload = {
117
123
  method: request.method,
118
- url: sanitizeUrl(request.url),
124
+ path: parsedUrl.path,
125
+ query: parsedUrl.query,
119
126
  route_template: request.routeTemplate ?? null,
120
- request_headers: sanitizeHeaders(request.headers, this.config.headerAllowlist, {
127
+ headers: sanitizeHeaders(request.headers, this.config.headerAllowlist, {
121
128
  redactFields: this.config.redactFields
122
129
  }),
123
130
  response_status: response.statusCode,
124
131
  response_headers: sanitizeHeaders(response.headers, this.config.headerAllowlist, {
125
132
  redactFields: this.config.redactFields
126
133
  }),
127
- duration_ms: response.durationMillis ?? null,
128
- source: "react-native"
134
+ duration_ms: response.durationMillis ?? 0
129
135
  };
130
- this.recordBreadcrumb("network", payload);
131
- if (this.shouldCaptureRequestEvent(response.statusCode)) {
132
- this.enqueue("request_event", payload, traceId, true);
136
+ if (this.config.captureNetwork) {
137
+ this.recordBreadcrumb("network_request", payload);
133
138
  }
139
+ this.enqueue(
140
+ "request_event",
141
+ payload,
142
+ traceId,
143
+ true,
144
+ asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
145
+ this.config.captureNetwork
146
+ );
134
147
  }
135
148
 
136
149
  captureMessage(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
@@ -143,7 +156,9 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
143
156
 
144
157
  setContext(key: string, value: unknown): void {
145
158
  this.context[key] = sanitizeValue(value, { redactFields: this.config.redactFields });
146
- if (this.nativeModule?.setContext) {
159
+ if (this.nativeModule?.setContextValue) {
160
+ void safeNativeCall(() => this.nativeModule!.setContextValue!(key, { value: this.context[key] }), undefined);
161
+ } else if (this.nativeModule?.setContext) {
147
162
  void safeNativeCall(() => this.nativeModule!.setContext!(key, this.context[key]), undefined);
148
163
  }
149
164
  }
@@ -152,19 +167,28 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
152
167
  if (!label || this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) {
153
168
  return;
154
169
  }
155
- if (options.heavy && !this.hasActiveJsProbeActivation()) {
170
+ const nativeProbeActive = this.nativeModule?.isProbeActive?.(label) ?? false;
171
+ if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) {
156
172
  return;
157
173
  }
158
174
  const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
175
+ const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
176
+ const occurredAt = new Date().toISOString();
159
177
  const entry = {
160
178
  label,
161
- data: sanitizeValue(value, { redactFields: this.config.redactFields }),
162
- timestamp: new Date().toISOString(),
179
+ data: sanitizedData,
180
+ timestamp: occurredAt,
163
181
  activation_id: null
164
182
  };
165
183
  const entries = this.probeBuffers.get(label) ?? [];
166
184
  entries.push(entry);
167
185
  this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
186
+ if (this.nativeModule?.captureProbe) {
187
+ void safeNativeCall(
188
+ () => this.nativeModule!.captureProbe!(label, sanitizedData, occurredAt),
189
+ false
190
+ );
191
+ }
168
192
  }
169
193
 
170
194
  async flush(): Promise<void> {
@@ -190,12 +214,17 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
190
214
  }
191
215
 
192
216
  recordBreadcrumb(type: string, data: Record<string, unknown> = {}): void {
193
- this.breadcrumbs.push({
217
+ const breadcrumb = {
194
218
  breadcrumb_type: type,
195
- occurred_at: new Date().toISOString(),
219
+ ts: new Date().toISOString(),
196
220
  data: sanitizeValue(data, { redactFields: this.config.redactFields })
197
- });
221
+ };
222
+ this.breadcrumbs.push(breadcrumb);
198
223
  this.breadcrumbs = this.breadcrumbs.slice(-this.config.maxBreadcrumbs);
224
+ this.enqueue("frontend_breadcrumb", {
225
+ breadcrumb_type: type,
226
+ data: breadcrumb.data
227
+ }, null, true);
199
228
  }
200
229
 
201
230
  recordScreen(screenName: string, previousScreen: string | null = null, source = "react-navigation"): void {
@@ -213,13 +242,15 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
213
242
  eventType: DebugBundleEventEnvelope["event_type"],
214
243
  payload: Record<string, unknown>,
215
244
  traceId: string | null,
216
- countTowardSession: boolean
245
+ countTowardSession: boolean,
246
+ context?: Record<string, unknown>,
247
+ localPolicyAllows = true
217
248
  ): void {
218
249
  if (!this.canCapture(eventType, countTowardSession)) {
219
250
  return;
220
251
  }
221
- const event: DebugBundleEventEnvelope = {
222
- schema_version: "1",
252
+ const authoredEvent: DebugBundleEventEnvelope = {
253
+ schema_version: "2026-03-01",
223
254
  event_id: generateId(),
224
255
  event_type: eventType,
225
256
  sdk_name: SDK_NAME,
@@ -231,30 +262,33 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
231
262
  framework: "react-native"
232
263
  },
233
264
  occurred_at: new Date().toISOString(),
234
- correlation: traceId ? { trace_id: traceId } : null,
235
- payload,
236
- device: this.nativeState.device ?? deviceFromConfig(this.config)
265
+ ...(traceId ? { correlation: { trace_id: traceId } } : {}),
266
+ ...(context && Object.keys(context).length > 0 ? { context } : {}),
267
+ payload: this.nativeState.device
268
+ ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
269
+ : payload
237
270
  };
238
- if (countTowardSession) {
239
- this.sessionEventCount += 1;
271
+ const event = applyBeforeSend(authoredEvent, this.config.beforeSend);
272
+ if (!event || !localPolicyAllows) {
273
+ return;
240
274
  }
241
275
  if (!this.nativeModule) {
242
276
  this.nativeState = degradedNativeState("native_module_unavailable");
243
277
  return;
244
278
  }
245
- void safeNativeCall(() => this.nativeModule!.enqueueEvent(event), undefined);
279
+ if (this.nativeModule.enqueueCanonicalEvent) {
280
+ void safeNativeCall(() => this.nativeModule!.enqueueCanonicalEvent!(event), undefined);
281
+ } else {
282
+ void safeNativeCall(() => this.nativeModule!.enqueueEvent(toLegacyNativeEvent(event)), undefined);
283
+ }
246
284
  }
247
285
 
248
286
  private canCapture(eventType: DebugBundleEventEnvelope["event_type"], countTowardSession: boolean): boolean {
249
- if (!this.config.enabled || !this.config.projectToken || !this.sessionSampledIn) {
250
- return false;
251
- }
252
- if (Math.random() > this.config.sampleRate) {
287
+ if (!this.config.enabled || !this.config.projectToken) {
253
288
  return false;
254
289
  }
255
- if (countTowardSession && this.sessionEventCount >= this.config.maxEventsPerSession) {
256
- return eventType === "frontend_exception" || eventType === "probe_event";
257
- }
290
+ void eventType;
291
+ void countTowardSession;
258
292
  return true;
259
293
  }
260
294
 
@@ -276,9 +310,6 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
276
310
  return this.jsProbeActivationExpiresAt > Date.now();
277
311
  }
278
312
 
279
- private shouldCaptureRequestEvent(statusCode: number): boolean {
280
- return statusCode >= 500 || [408, 423, 424, 425, 429].includes(statusCode);
281
- }
282
313
  }
283
314
 
284
315
  export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
@@ -320,10 +351,16 @@ export function resolveConfig(config: DebugBundleConfig): ResolvedDebugBundleCon
320
351
  probeFlushOnError: config.probeFlushOnError ?? true,
321
352
  redactFields: config.redactFields ?? defaultRedactFields(),
322
353
  headerAllowlist: config.headerAllowlist ?? DEFAULT_HEADER_ALLOWLIST,
323
- sdkVersion: config.sdkVersion ?? DEFAULT_SDK_VERSION
354
+ sdkVersion: config.sdkVersion ?? DEFAULT_SDK_VERSION,
355
+ beforeSend: config.beforeSend ?? null
324
356
  };
325
357
  }
326
358
 
359
+ function nativeConfig(config: ResolvedDebugBundleConfig): NativeDebugBundleConfig {
360
+ const { beforeSend: _beforeSend, ...native } = config;
361
+ return native;
362
+ }
363
+
327
364
  function clampInteger(value: number | undefined, fallback: number, min: number, max: number): number {
328
365
  if (!Number.isFinite(value)) {
329
366
  return fallback;
@@ -346,17 +383,78 @@ function safeInvoke(producer: () => unknown): unknown {
346
383
  }
347
384
  }
348
385
 
386
+ function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
387
+ return Boolean(value && typeof (value as Promise<T>).then === "function");
388
+ }
389
+
390
+ function normalizeNativeState(state: NativeDebugBundleState): NativeDebugBundleState {
391
+ return {
392
+ ...state,
393
+ status: state.status || "healthy",
394
+ nativeModuleAvailable: state.nativeModuleAvailable ?? true
395
+ };
396
+ }
397
+
349
398
  function stringValue(value: unknown): string | null {
350
399
  return typeof value === "string" && value.length > 0 ? value : null;
351
400
  }
352
401
 
353
- function sanitizeUrl(url: string): string {
402
+ function asRecord(value: unknown): Record<string, unknown> {
403
+ if (value && typeof value === "object" && !Array.isArray(value)) {
404
+ return value as Record<string, unknown>;
405
+ }
406
+ return {};
407
+ }
408
+
409
+ function objectWrap(value: unknown): Record<string, unknown> {
410
+ return value && typeof value === "object" && !Array.isArray(value)
411
+ ? value as Record<string, unknown>
412
+ : { value };
413
+ }
414
+
415
+ function parseRequestUrl(url: string): { path: string; query: Record<string, unknown> } {
354
416
  try {
355
417
  const parsed = new URL(url);
356
- parsed.search = "";
357
- return parsed.toString();
418
+ const query: Record<string, unknown> = {};
419
+ for (const [key, value] of parsed.searchParams) {
420
+ const existing = query[key];
421
+ if (existing === undefined) {
422
+ query[key] = value;
423
+ } else if (Array.isArray(existing)) {
424
+ existing.push(value);
425
+ } else {
426
+ query[key] = [existing, value];
427
+ }
428
+ }
429
+ return { path: parsed.pathname || "/", query };
430
+ } catch {
431
+ const [path = "/", rawQuery = ""] = url.split("?", 2);
432
+ const query: Record<string, unknown> = {};
433
+ for (const segment of rawQuery.split("&")) {
434
+ if (!segment) {
435
+ continue;
436
+ }
437
+ const [rawKey = "", rawValue = ""] = segment.split("=", 2);
438
+ const key = decodeQuerySegment(rawKey);
439
+ const value = decodeQuerySegment(rawValue);
440
+ const existing = query[key];
441
+ if (existing === undefined) {
442
+ query[key] = value;
443
+ } else if (Array.isArray(existing)) {
444
+ existing.push(value);
445
+ } else {
446
+ query[key] = [existing, value];
447
+ }
448
+ }
449
+ return { path: path || "/", query };
450
+ }
451
+ }
452
+
453
+ function decodeQuerySegment(value: string): string {
454
+ try {
455
+ return decodeURIComponent(value.replace(/\+/g, " "));
358
456
  } catch {
359
- return url.split("?")[0] ?? url;
457
+ return value;
360
458
  }
361
459
  }
362
460
 
@@ -376,10 +474,89 @@ function generateId(): string {
376
474
  });
377
475
  }
378
476
 
379
- function deviceFromConfig(config: ResolvedDebugBundleConfig): Record<string, unknown> {
477
+ function canonicalDevice(
478
+ nativeDevice: Record<string, unknown> | null | undefined,
479
+ config: ResolvedDebugBundleConfig
480
+ ): Record<string, unknown> {
481
+ const source = nativeDevice ?? {};
482
+ const sourceOs = asRecord(source.os);
483
+ const sourceScreen = asRecord(source.screen);
484
+ const sourceViewport = asRecord(source.viewport);
485
+ const width = nonNegativeInteger(sourceScreen.width ?? source.screen_width);
486
+ const height = nonNegativeInteger(sourceScreen.height ?? source.screen_height);
380
487
  return {
381
- app_version: config.appVersion,
382
- build_number: config.buildNumber,
383
- release_channel: config.releaseChannel
488
+ user_agent: stringValue(source.user_agent),
489
+ os: {
490
+ name: stringValue(sourceOs.name ?? source.os_name),
491
+ version: stringValue(sourceOs.version ?? source.os_version)
492
+ },
493
+ device_type: canonicalDeviceType(source.device_type),
494
+ screen: { width, height },
495
+ viewport: {
496
+ width: nonNegativeInteger(sourceViewport.width ?? width),
497
+ height: nonNegativeInteger(sourceViewport.height ?? height)
498
+ },
499
+ device_pixel_ratio: positiveNumber(source.device_pixel_ratio),
500
+ touch_capable: typeof source.touch_capable === "boolean" ? source.touch_capable : null,
501
+ language: stringValue(source.language ?? source.locale),
502
+ connection_type: stringValue(source.connection_type),
503
+ color_scheme_preference: canonicalColorScheme(source.color_scheme_preference),
504
+ app_version: stringValue(source.app_version) ?? config.appVersion,
505
+ build_number: stringValue(source.build_number) ?? config.buildNumber,
506
+ release_channel: stringValue(source.release_channel) ?? config.releaseChannel,
507
+ api_level: nullableNonNegativeInteger(source.api_level),
508
+ manufacturer: stringValue(source.manufacturer),
509
+ model: stringValue(source.model),
510
+ timezone: stringValue(source.timezone),
511
+ battery_level: nonNegativeNumber(source.battery_level),
512
+ battery_charging: typeof source.battery_charging === "boolean"
513
+ ? source.battery_charging
514
+ : typeof source.charging === "boolean" ? source.charging : null,
515
+ free_disk_bytes: nullableNonNegativeInteger(source.free_disk_bytes),
516
+ free_memory_bytes: nullableNonNegativeInteger(source.free_memory_bytes),
517
+ jailbroken: typeof source.jailbroken === "boolean"
518
+ ? source.jailbroken
519
+ : typeof source.rooted === "boolean" ? source.rooted : null
384
520
  };
385
521
  }
522
+
523
+ function toLegacyNativeEvent(event: DebugBundleEventEnvelope): DebugBundleEventEnvelope {
524
+ const context = event.context ?? {};
525
+ const payload: Record<string, unknown> = { ...event.payload, context };
526
+ if (event.event_type === "frontend_exception") {
527
+ payload.error = {
528
+ name: event.payload.name,
529
+ message: event.payload.message,
530
+ stack: event.payload.stack
531
+ };
532
+ } else if (event.event_type === "request_event") {
533
+ payload.url = event.payload.path;
534
+ }
535
+ return { ...event, payload };
536
+ }
537
+
538
+ function canonicalDeviceType(value: unknown): "desktop" | "mobile" | "tablet" | "unknown" {
539
+ return value === "desktop" || value === "mobile" || value === "tablet" ? value : "unknown";
540
+ }
541
+
542
+ function canonicalColorScheme(value: unknown): "light" | "dark" | "no-preference" | null {
543
+ return value === "light" || value === "dark" || value === "no-preference" ? value : null;
544
+ }
545
+
546
+ function nonNegativeInteger(value: unknown): number {
547
+ return nullableNonNegativeInteger(value) ?? 0;
548
+ }
549
+
550
+ function nullableNonNegativeInteger(value: unknown): number | null {
551
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
552
+ ? Math.trunc(value)
553
+ : null;
554
+ }
555
+
556
+ function positiveNumber(value: unknown): number | null {
557
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
558
+ }
559
+
560
+ function nonNegativeNumber(value: unknown): number | null {
561
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
562
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { captureDebugBundleConsole } from "./console.js";
3
3
  import { installDebugBundleErrorHandlers } from "./errors.js";
4
4
  import type {
5
5
  DebugBundleCaptureContext,
6
+ DebugBundleBeforeSend,
6
7
  DebugBundleClient,
7
8
  DebugBundleConfig,
8
9
  DebugBundleLogLevel,
@@ -72,6 +73,7 @@ export { captureDebugBundleConsole };
72
73
  export { installDebugBundleErrorHandlers };
73
74
  export type {
74
75
  DebugBundleCaptureContext,
76
+ DebugBundleBeforeSend,
75
77
  DebugBundleClient,
76
78
  DebugBundleConfig,
77
79
  DebugBundleLogLevel,
package/src/native.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { NativeModules, TurboModuleRegistry } from "react-native";
1
2
  import type { NativeDebugBundleModule, NativeDebugBundleState } from "./types.js";
2
3
 
3
4
  let overrideModule: NativeDebugBundleModule | null | undefined;
@@ -48,32 +49,9 @@ export function resetNativeModuleCacheForTesting(): void {
48
49
  }
49
50
 
50
51
  function resolveReactNativeModule(): NativeDebugBundleModule | null {
51
- const required = requireReactNative();
52
- const turboModule = required?.TurboModuleRegistry?.get?.("DebugBundleReactNative") as NativeDebugBundleModule | null | undefined;
52
+ const turboModule = TurboModuleRegistry.get<NativeDebugBundleModule>("DebugBundleReactNative");
53
53
  if (turboModule) {
54
54
  return turboModule;
55
55
  }
56
- return (required?.NativeModules?.DebugBundleReactNative as NativeDebugBundleModule | undefined) ?? null;
57
- }
58
-
59
- function requireReactNative():
60
- | {
61
- NativeModules?: Record<string, unknown>;
62
- TurboModuleRegistry?: { get?: (name: string) => unknown };
63
- }
64
- | null {
65
- try {
66
- const maybeRequire = Function("return typeof require === 'function' ? require : null")() as
67
- | ((specifier: string) => unknown)
68
- | null;
69
- if (!maybeRequire) {
70
- return null;
71
- }
72
- return maybeRequire("react-native") as {
73
- NativeModules?: Record<string, unknown>;
74
- TurboModuleRegistry?: { get?: (name: string) => unknown };
75
- };
76
- } catch {
77
- return null;
78
- }
56
+ return (NativeModules.DebugBundleReactNative as NativeDebugBundleModule | undefined) ?? null;
79
57
  }
package/src/testing.ts CHANGED
@@ -1,24 +1,47 @@
1
1
  import { resetNativeModuleCacheForTesting, setNativeModuleForTesting } from "./native.js";
2
- import type { DebugBundleEventEnvelope, NativeDebugBundleModule, NativeDebugBundleState, ResolvedDebugBundleConfig } from "./types.js";
2
+ import type {
3
+ DebugBundleEventEnvelope,
4
+ NativeDebugBundleConfig,
5
+ NativeDebugBundleModule,
6
+ NativeDebugBundleState
7
+ } from "./types.js";
3
8
 
4
9
  export interface RecordingNativeModule extends NativeDebugBundleModule {
5
10
  events: DebugBundleEventEnvelope[];
6
- config: ResolvedDebugBundleConfig | null;
11
+ probes: Array<{ label: string; data: unknown; occurredAt: string }>;
12
+ context: Record<string, unknown>;
13
+ config: NativeDebugBundleConfig | null;
14
+ activeProbeLabels: Set<string>;
7
15
  }
8
16
 
9
17
  export function createRecordingNativeModule(initialState: NativeDebugBundleState = { status: "healthy", lastEventAt: null }): RecordingNativeModule {
10
18
  const state = { ...initialState };
11
19
  return {
12
20
  events: [],
21
+ probes: [],
22
+ context: {},
13
23
  config: null,
24
+ activeProbeLabels: new Set(),
14
25
  initialize(config) {
15
26
  this.config = config;
16
27
  return state;
17
28
  },
29
+ enqueueCanonicalEvent(event) {
30
+ this.events.push(event);
31
+ state.lastEventAt = Date.now();
32
+ return true;
33
+ },
18
34
  enqueueEvent(event) {
19
35
  this.events.push(event);
20
36
  state.lastEventAt = Date.now();
21
37
  },
38
+ captureProbe(label, data, occurredAt) {
39
+ this.probes.push({ label, data, occurredAt });
40
+ return this.activeProbeLabels.has(label);
41
+ },
42
+ isProbeActive(label) {
43
+ return this.activeProbeLabels.has(label);
44
+ },
22
45
  flush() {
23
46
  state.status = "healthy";
24
47
  },
@@ -28,6 +51,9 @@ export function createRecordingNativeModule(initialState: NativeDebugBundleState
28
51
  setContext() {
29
52
  return undefined;
30
53
  },
54
+ setContextValue(key, entry) {
55
+ this.context[key] = entry.value;
56
+ },
31
57
  activateProbeTriggerToken() {
32
58
  return true;
33
59
  }
package/src/types.ts CHANGED
@@ -2,6 +2,10 @@ export type DebugBundleStatus = "healthy" | "degraded" | "disconnected";
2
2
 
3
3
  export type DebugBundleLogLevel = "debug" | "info" | "warning" | "error" | "critical";
4
4
 
5
+ export type DebugBundleBeforeSend = (
6
+ event: DebugBundleEventEnvelope
7
+ ) => DebugBundleEventEnvelope | null;
8
+
5
9
  export interface DebugBundleConfig {
6
10
  projectToken?: string;
7
11
  enabled?: boolean;
@@ -37,6 +41,7 @@ export interface DebugBundleConfig {
37
41
  redactFields?: string[];
38
42
  headerAllowlist?: string[];
39
43
  sdkVersion?: string;
44
+ beforeSend?: DebugBundleBeforeSend;
40
45
  }
41
46
 
42
47
  export interface ResolvedDebugBundleConfig {
@@ -74,6 +79,7 @@ export interface ResolvedDebugBundleConfig {
74
79
  redactFields: string[];
75
80
  headerAllowlist: string[];
76
81
  sdkVersion: string;
82
+ beforeSend: DebugBundleBeforeSend | null;
77
83
  }
78
84
 
79
85
  export interface DebugBundleNetworkFilter {
@@ -106,7 +112,7 @@ export interface DebugBundleProbeOptions {
106
112
  }
107
113
 
108
114
  export interface DebugBundleEventEnvelope {
109
- schema_version: "1";
115
+ schema_version: "2026-03-01";
110
116
  event_id: string;
111
117
  event_type:
112
118
  | "frontend_exception"
@@ -124,14 +130,14 @@ export interface DebugBundleEventEnvelope {
124
130
  framework: "react-native";
125
131
  };
126
132
  occurred_at: string;
127
- correlation: {
133
+ correlation?: {
128
134
  trace_id: string | null;
129
135
  request_id?: string | null;
130
136
  session_id?: string | null;
131
137
  user_id_hash?: string | null;
132
- } | null;
138
+ };
139
+ context?: Record<string, unknown>;
133
140
  payload: Record<string, unknown>;
134
- device: Record<string, unknown> | null;
135
141
  }
136
142
 
137
143
  export interface DebugBundleClient {
@@ -156,14 +162,25 @@ export interface DebugBundleClient {
156
162
  }
157
163
 
158
164
  export interface NativeDebugBundleModule {
159
- initialize(config: ResolvedDebugBundleConfig): Promise<NativeDebugBundleState> | NativeDebugBundleState;
165
+ initialize(config: NativeDebugBundleConfig): Promise<NativeDebugBundleState> | NativeDebugBundleState;
166
+ enqueueCanonicalEvent?(event: DebugBundleEventEnvelope): Promise<boolean | void> | boolean | void;
167
+ /** Compatibility fallback implemented by native binaries released before the canonical bridge. */
160
168
  enqueueEvent(event: DebugBundleEventEnvelope): Promise<void> | void;
169
+ captureProbe?(
170
+ label: string,
171
+ data: unknown,
172
+ occurredAt: string
173
+ ): Promise<boolean> | boolean;
174
+ isProbeActive?(label: string): boolean;
161
175
  flush(): Promise<void> | void;
162
176
  getStatus(): Promise<NativeDebugBundleState> | NativeDebugBundleState;
163
177
  setContext?(key: string, value: unknown): Promise<void> | void;
178
+ setContextValue?(key: string, entry: { value: unknown }): Promise<void> | void;
164
179
  activateProbeTriggerToken?(token: string): Promise<boolean> | boolean;
165
180
  }
166
181
 
182
+ export type NativeDebugBundleConfig = Omit<ResolvedDebugBundleConfig, "beforeSend">;
183
+
167
184
  export interface NativeDebugBundleState {
168
185
  status: DebugBundleStatus;
169
186
  lastEventAt?: number | null;