@debugbundle/sdk-react-native 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## Unreleased
5
+ ## [Unreleased]
6
+
7
+ ## [3.0.0] - 2026-09-25
8
+
9
+ ### Changed
10
+
11
+ - Bound pending native probe and context calls with the shared event bridge budgets, contain throwing capture context accessors, and sanitize context field names before retaining or handing off values. Context keeps at most 50 fields with 128-character keys; saturated probes and context updates are best-effort drops.
12
+
13
+ - Reject locally disabled or below-threshold logs and disabled request capture before JavaScript context redaction and `beforeSend`; filtered console records skip argument formatting when the SDK client exposes its eligibility check.
14
+ - Limit unresolved native bridge event calls to 256 and 4 MiB, with 32 calls and 1 MiB reserved for errors and exceptions. Individual retained events are capped at 64 KiB; calls above capacity are discarded so a stalled bridge cannot retain an unbounded promise backlog.
6
15
 
7
16
  ## [2.0.0] - 2026-09-21
8
17
 
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = "DebugBundleReactNative"
3
- s.version = "2.0.0"
3
+ s.version = "3.0.0"
4
4
  s.summary = "React Native wrapper for the DebugBundle mobile SDKs."
5
5
  s.license = { :type => "Apache-2.0", :file => "LICENSE" }
6
6
  s.author = { "DebugBundle" => "support@debugbundle.com" }
@@ -12,5 +12,5 @@ Pod::Spec.new do |s|
12
12
 
13
13
  s.dependency "React-Core"
14
14
  install_modules_dependencies(s) if defined?(install_modules_dependencies)
15
- s.dependency "DebugBundle", "~> 2.0"
15
+ s.dependency "DebugBundle", "~> 3.0"
16
16
  end
@@ -0,0 +1,9 @@
1
+ # React Native 3.0 migration
2
+
3
+ React Native 2.0 remains the published compatibility baseline. Version 3 uses the hardened Swift3 and Android3 native SDKs with the same installation flow and bridge APIs; no collector or extra service is needed. Install/rebuild the native binary when upgrading and verify both native dependency versions before publishing the wrapper.
4
+
5
+ Optional `beforeSend` callbacks run after capture returns on the JavaScript event loop. They must return promptly: closures retain application state and cannot be transparently moved to isolated workers. Hook side effects are no longer synchronous with capture. Valid replacements, null drops and protected-original fallback for thrown/invalid hooks remain supported. Final values receive privacy protection and level checks again before native handoff.
6
+
7
+ Privacy-safe pending preparation and native calls share a 256-call/4-MiB cap. Low-priority work may use224 calls/3MiB, reserving space for errors and exceptions. Both the protected original and final bridge snapshot stay charged until the bridge settles. Under pressure a pending hook or valid expanded replacement can be discarded; it never causes a return to pre-hook application content. SDK-owned queues stay bounded if the native bridge stalls. Explicit flush lets prior deferred preparations reach the bridge before requesting the native flush.
8
+
9
+ The existing canonical and legacy native bridge methods remain supported. Context, probes, JavaScript error identity and native offline/ACK behavior remain available. Swift3 and Android3 document background persistence and safe custom-exception projection separately.
package/README.md CHANGED
@@ -15,6 +15,7 @@ Published implementation:
15
15
  - React Navigation breadcrumb helpers.
16
16
  - `fetch` and `XMLHttpRequest` instrumentation with target-scoped `X-DebugBundle-Trace-Id`.
17
17
  - JS-side bounded serialization and redaction before native queue persistence.
18
+ - Local log level, log capture, and request capture switches are checked before event construction or `beforeSend`. A stalled native bridge accepts at most 224 lower-priority event calls / 3 MiB and reserves 32 more calls / 1 MiB for errors and exceptions. Each event is capped at 64 KiB; excess calls are discarded.
18
19
  - Android and iOS native wrappers that delegate queueing, config/status, request capture, crash/error capture, flushing, and probe trigger activation to the native SDK foundations.
19
20
  - Repository-local Android Java, Swift, and Objective-C++ wrapper tests with an
20
21
  enforced 80% line-coverage floor for every handwritten native bridge source.
@@ -142,3 +143,7 @@ checks.
142
143
  ## Release
143
144
 
144
145
  The package publishes to npm from `v*` tags through GitHub Actions. Configure an npm GitHub Actions trusted publisher for `debugbundle/debugbundle-react-native` and `release.yml`, with a blank environment and direct `npm publish` enabled. Publishing uses OIDC without a long-lived npm token. Make sure the tag matches `package.json` exactly. The next protected release requires a matching 2.x wrapper/Swift pod line and a published Android 2.x BOM/module pin. `make check-protected-native-pins` refuses the current 1.x source line; update the wrapper version, native pins, and embedded SDK versions together only after the exact Android and Swift artifacts are available to Maven Central and CocoaPods consumers. The release workflow then compiles and runs clean apps against those published artifacts on Android API 37 and Xcode 27 before npm publication.
146
+
147
+ Probe and context native calls share the event bridge's pending count and byte limits, including reserved exception capacity. Saturated auxiliary calls are discarded without evaluating probe suppliers. Probe labels are limited to 128 characters. Persistent JS context retains at most 50 fields with keys up to 128 characters, and field-sensitive privacy runs before local retention and native handoff. Throwing capture context accessors are withheld without escaping into application code. These bounds do not move native SDK persistence off its caller thread.
148
+
149
+ Version 3 defers optional `beforeSend` callbacks until capture returns. Callbacks run on the JavaScript event loop and must return promptly. Pending preparation and native delivery share bounded count/byte limits; valid expanded replacements may be dropped under pressure. See [the version3 migration guide](MIGRATION-3.0.md).
@@ -11,7 +11,7 @@ if (isReactNativeHostBuild) {
11
11
  def hostKotlinVersion = rootProject.ext.has("kotlinVersion")
12
12
  ? rootProject.ext.get("kotlinVersion")
13
13
  : "2.1.20"
14
- def debugBundleAndroidVersion = rootProject.findProperty("debugBundleAndroidVersion") ?: "2.0.0"
14
+ def debugBundleAndroidVersion = rootProject.findProperty("debugBundleAndroidVersion") ?: "3.0.0"
15
15
 
16
16
  android {
17
17
  namespace "com.debugbundle.reactnative"
@@ -199,12 +199,12 @@ public final class DebugBundleReactNativeModule extends ReactContextBaseJavaModu
199
199
  }
200
200
  }
201
201
 
202
- private String currentSdkVersion = "2.0.0";
202
+ private String currentSdkVersion = "3.0.0";
203
203
  private String currentService = "react-native-app";
204
204
  private String currentEnvironment = "production";
205
205
 
206
206
  private DebugBundleConfig createConfig(ReadableMap config) {
207
- currentSdkVersion = stringOrDefault(config, "sdkVersion", "2.0.0");
207
+ currentSdkVersion = stringOrDefault(config, "sdkVersion", "3.0.0");
208
208
  currentService = stringOrDefault(config, "service", "react-native-app");
209
209
  currentEnvironment = stringOrDefault(config, "environment", "production");
210
210
  return DebugBundleConfig.create(
package/app.plugin.js CHANGED
@@ -49,4 +49,4 @@ function withAndroidDesugaring(contents) {
49
49
  return next;
50
50
  }
51
51
 
52
- export default createRunOncePlugin(withDebugBundle, "@debugbundle/sdk-react-native", "2.0.0");
52
+ export default createRunOncePlugin(withDebugBundle, "@debugbundle/sdk-react-native", "3.0.0");
@@ -1 +1 @@
1
- {"version":3,"file":"before-send.d.ts","sourceRoot":"","sources":["../src/before-send.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AA2ClF,wBAAgB,eAAe,CAC7B,KAAK,EAAE,wBAAwB,EAC/B,IAAI,EAAE,qBAAqB,GAAG,IAAI,GACjC,wBAAwB,GAAG,IAAI,CAajC"}
1
+ {"version":3,"file":"before-send.d.ts","sourceRoot":"","sources":["../src/before-send.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AA2ClF,wBAAgB,eAAe,CAC7B,KAAK,EAAE,wBAAwB,EAC/B,IAAI,EAAE,qBAAqB,GAAG,IAAI,GACjC,wBAAwB,GAAG,IAAI,CAmBjC"}
@@ -42,6 +42,12 @@ export function applyBeforeSend(event, hook) {
42
42
  }
43
43
  try {
44
44
  const result = hook(cloneEvent(event));
45
+ // Hooks have synchronous return contracts. Contain mistaken async failures
46
+ // before treating their Promise as invalid and keeping the protected original.
47
+ if (result instanceof Promise) {
48
+ void result.catch(() => undefined);
49
+ return event;
50
+ }
45
51
  if (result === null) {
46
52
  return null;
47
53
  }
package/dist/client.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare class DebugBundleReactNativeClient implements DebugBundleClient {
7
7
  private breadcrumbs;
8
8
  private probeBuffers;
9
9
  private jsProbeActivationExpiresAt;
10
+ private pendingNativeCalls;
11
+ private pendingNativeBytes;
10
12
  constructor(config?: DebugBundleConfig);
11
13
  get status(): DebugBundleStatus;
12
14
  get lastEventAt(): number | null;
@@ -14,15 +16,21 @@ export declare class DebugBundleReactNativeClient implements DebugBundleClient {
14
16
  captureException(error: unknown, context?: DebugBundleCaptureContext): void;
15
17
  captureError(error: unknown, context?: DebugBundleCaptureContext): void;
16
18
  captureLog(message: string, level?: DebugBundleLogLevel, context?: DebugBundleCaptureContext): void;
19
+ isLogEnabled(level: DebugBundleLogLevel): boolean;
17
20
  captureRequest(request: DebugBundleRequestInfo, response: DebugBundleResponseInfo, context?: DebugBundleCaptureContext): void;
18
21
  captureMessage(message: string, level?: DebugBundleLogLevel, context?: DebugBundleCaptureContext): void;
19
22
  setContext(key: string, value: unknown): void;
20
23
  probe(label: string, data: unknown | (() => unknown), options?: DebugBundleProbeOptions): void;
24
+ private enqueueNativeAuxiliary;
21
25
  flush(): Promise<void>;
22
26
  activateProbeTriggerToken(token: string): Promise<boolean>;
23
27
  recordBreadcrumb(type: string, data?: Record<string, unknown>): void;
24
28
  recordScreen(screenName: string, previousScreen?: string | null, source?: string): void;
25
29
  private enqueue;
30
+ private sendAdmittedEvent;
31
+ private prepareAdmittedEvent;
32
+ private hasNativeCapacity;
33
+ private releaseNativeCapacity;
26
34
  private canCapture;
27
35
  private mergeContext;
28
36
  private snapshotProbeData;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,iBAAiB,EAEjB,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,EAIjB,yBAAyB,EAC1B,MAAM,YAAY,CAAC;AA6CpB,qBAAa,4BAA6B,YAAW,iBAAiB;IACpE,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,YAAY,CAAwC;IAC5D,OAAO,CAAC,WAAW,CAAkE;IACrF,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,WAAW,CAAsC;IACzD,OAAO,CAAC,YAAY,CAAqD;IACzE,OAAO,CAAC,0BAA0B,CAAK;gBAE3B,MAAM,GAAE,iBAAsB;IAO1C,IAAI,MAAM,IAAI,iBAAiB,CAE9B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,IAAI,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI;IA8BrC,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAc/E,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAI3E,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,GAAE,mBAA+B,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAYlH,cAAc,CACZ,OAAO,EAAE,sBAAsB,EAC/B,QAAQ,EAAE,uBAAuB,EACjC,OAAO,GAAE,yBAA8B,GACtC,IAAI;IA+BP,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,GAAE,mBAA+B,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAQtH,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAS7C,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,EAAE,OAAO,GAAE,uBAA4B,GAAG,IAAI;IA4B5F,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAWtB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWhE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI;IAcxE,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,cAAc,GAAE,MAAM,GAAG,IAAW,EAAE,MAAM,SAAqB,GAAG,IAAI;IAWzG,OAAO,CAAC,OAAO;IAiDf,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,0BAA0B;CAInC;AAED,wBAAgB,uBAAuB,CAAC,MAAM,GAAE,iBAAsB,GAAG,4BAA4B,CAEpG;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,iBAAiB,GAAG,yBAAyB,CAsClF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,iBAAiB,EAEjB,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,EAIjB,yBAAyB,EAC1B,MAAM,YAAY,CAAC;AAoDpB,qBAAa,4BAA6B,YAAW,iBAAiB;IACpE,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,YAAY,CAAwC;IAC5D,OAAO,CAAC,WAAW,CAAkE;IACrF,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,WAAW,CAAsC;IACzD,OAAO,CAAC,YAAY,CAAqD;IACzE,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,kBAAkB,CAAK;gBAEnB,MAAM,GAAE,iBAAsB;IAO1C,IAAI,MAAM,IAAI,iBAAiB,CAE9B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,IAAI,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI;IA8BrC,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAgB/E,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAI3E,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,GAAE,mBAA+B,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAalH,YAAY,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO;IAMjD,cAAc,CACZ,OAAO,EAAE,sBAAsB,EAC/B,QAAQ,EAAE,uBAAuB,EACjC,OAAO,GAAE,yBAA8B,GACtC,IAAI;IA6BP,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,GAAE,mBAA+B,EAAE,OAAO,GAAE,yBAA8B,GAAG,IAAI;IAQtH,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAqB7C,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,EAAE,OAAO,GAAE,uBAA4B,GAAG,IAAI;IAuBlG,OAAO,CAAC,sBAAsB;IASxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAatB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAWhE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI;IAcxE,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,cAAc,GAAE,MAAM,GAAG,IAAW,EAAE,MAAM,SAAqB,GAAG,IAAI;IAWzG,OAAO,CAAC,OAAO;IA4Df,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,oBAAoB;IAa5B,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,0BAA0B;CAInC;AAiBD,wBAAgB,uBAAuB,CAAC,MAAM,GAAE,iBAAsB,GAAG,4BAA4B,CAEpG;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,iBAAiB,GAAG,yBAAyB,CAsClF"}
package/dist/client.js CHANGED
@@ -3,7 +3,14 @@ import { applyBeforeSend } from "./before-send.js";
3
3
  import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
4
4
  import { sanitizeTelemetry } from "./privacy-telemetry.js";
5
5
  const SDK_NAME = "@debugbundle/sdk-react-native";
6
- const DEFAULT_SDK_VERSION = "2.0.0";
6
+ const DEFAULT_SDK_VERSION = "3.0.0";
7
+ const MAX_PENDING_NATIVE_CALLS = 256;
8
+ const MAX_PENDING_LOW_PRIORITY_CALLS = 224;
9
+ const MAX_PENDING_NATIVE_BYTES = 4 * 1024 * 1024;
10
+ const MAX_PENDING_LOW_PRIORITY_BYTES = 3 * 1024 * 1024;
11
+ const MAX_NATIVE_EVENT_BYTES = 64 * 1024;
12
+ const MAX_CONTEXT_FIELDS = 50;
13
+ const MAX_CONTEXT_KEY_LENGTH = 128;
7
14
  const LOG_LEVELS = {
8
15
  debug: 10,
9
16
  info: 20,
@@ -50,6 +57,8 @@ export class DebugBundleReactNativeClient {
50
57
  breadcrumbs = [];
51
58
  probeBuffers = new Map();
52
59
  jsProbeActivationExpiresAt = 0;
60
+ pendingNativeCalls = 0;
61
+ pendingNativeBytes = 0;
53
62
  constructor(config = {}) {
54
63
  this.config = resolveConfig(config);
55
64
  if (Object.keys(config).length > 0) {
@@ -94,6 +103,9 @@ export class DebugBundleReactNativeClient {
94
103
  }
95
104
  }
96
105
  captureException(error, context = {}) {
106
+ if (!this.config.captureErrors || !this.canCapture("frontend_exception", false) ||
107
+ !this.hasNativeCapacity(true))
108
+ return;
97
109
  const mergedContext = this.mergeContext(context);
98
110
  const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
99
111
  this.enqueue("frontend_exception", {
@@ -110,16 +122,26 @@ export class DebugBundleReactNativeClient {
110
122
  this.captureException(error, context);
111
123
  }
112
124
  captureLog(message, level = "warning", context = {}) {
125
+ if (!this.isLogEnabled(level))
126
+ return;
127
+ const mergedContext = this.mergeContext(context);
113
128
  this.enqueue("log_event", {
114
129
  level,
115
130
  message: sanitizeValue(message, { redactFields: this.config.redactFields }),
116
131
  attributes: {
117
- ...asRecord(sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields })),
132
+ ...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
118
133
  source: "javascript"
119
134
  }
120
- }, stringValue(context.trace_id), true, undefined, this.config.captureLogs && LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel]);
135
+ }, stringValue(mergedContext.trace_id), true);
136
+ }
137
+ isLogEnabled(level) {
138
+ const rank = LOG_LEVELS[level];
139
+ return rank !== undefined && this.config.captureLogs && rank >= LOG_LEVELS[this.config.logLevel] &&
140
+ this.canCapture("log_event", true) && this.hasNativeCapacity(rank >= LOG_LEVELS.error);
121
141
  }
122
142
  captureRequest(request, response, context = {}) {
143
+ if (!this.config.captureNetwork || !this.canCapture("request_event", true) || !this.hasNativeCapacity(false))
144
+ return;
123
145
  const mergedContext = this.mergeContext(context);
124
146
  const traceId = request.traceId ?? stringValue(mergedContext.trace_id);
125
147
  const parsedUrl = parseRequestUrl(request.url);
@@ -137,10 +159,8 @@ export class DebugBundleReactNativeClient {
137
159
  }),
138
160
  duration_ms: response.durationMillis ?? 0
139
161
  };
140
- if (this.config.captureNetwork) {
141
- this.recordBreadcrumb("network_request", payload);
142
- }
143
- this.enqueue("request_event", payload, traceId, true, asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })), this.config.captureNetwork);
162
+ this.recordBreadcrumb("network_request", payload);
163
+ this.enqueue("request_event", payload, traceId, true, asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })));
144
164
  }
145
165
  captureMessage(message, level = "warning", context = {}) {
146
166
  this.recordBreadcrumb("message", {
@@ -150,39 +170,66 @@ export class DebugBundleReactNativeClient {
150
170
  });
151
171
  }
152
172
  setContext(key, value) {
153
- this.context[key] = sanitizeValue(value, { redactFields: this.config.redactFields });
154
- if (this.nativeModule?.setContextValue) {
155
- void safeNativeCall(() => this.nativeModule.setContextValue(key, { value: this.context[key] }), undefined);
173
+ if (typeof key !== "string" || !key || key.length > MAX_CONTEXT_KEY_LENGTH ||
174
+ !this.hasNativeCapacity(false) ||
175
+ !Object.hasOwn(this.context, key) && Object.keys(this.context).length >= MAX_CONTEXT_FIELDS)
176
+ return;
177
+ try {
178
+ // Include the field name in mandatory privacy before local or native ownership.
179
+ const fields = asRecord(sanitizeValue({ [key]: value }, { redactFields: this.config.redactFields }));
180
+ if (!Object.hasOwn(fields, key))
181
+ return;
182
+ const entry = { value: fields[key] };
183
+ this.context = { ...this.context, [key]: entry.value };
184
+ const nativeModule = this.nativeModule;
185
+ if (nativeModule?.setContextValue) {
186
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContextValue(key, entry));
187
+ }
188
+ else if (nativeModule?.setContext) {
189
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContext(key, entry.value));
190
+ }
156
191
  }
157
- else if (this.nativeModule?.setContext) {
158
- void safeNativeCall(() => this.nativeModule.setContext(key, this.context[key]), undefined);
192
+ catch {
193
+ // Context accessors and optional native integrations cannot escape into the host.
159
194
  }
160
195
  }
161
196
  probe(label, data, options = {}) {
162
- if (!label || this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) {
163
- return;
164
- }
165
- const nativeProbeActive = this.nativeModule?.isProbeActive?.(label) ?? false;
166
- if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) {
197
+ if (!label || label.length > MAX_CONTEXT_KEY_LENGTH || !this.hasNativeCapacity(false) ||
198
+ this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label))
167
199
  return;
200
+ try {
201
+ const nativeModule = this.nativeModule;
202
+ const nativeProbeActive = nativeModule?.isProbeActive?.(label) ?? false;
203
+ if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation())
204
+ return;
205
+ const value = typeof data === "function" ? safeInvoke(data) : data;
206
+ const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
207
+ const occurredAt = new Date().toISOString();
208
+ const entry = { label, data: sanitizedData, timestamp: occurredAt, activation_id: null };
209
+ const entries = this.probeBuffers.get(label) ?? [];
210
+ entries.push(entry);
211
+ this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
212
+ if (nativeModule?.captureProbe) {
213
+ this.enqueueNativeAuxiliary({ label, data: sanitizedData, occurredAt }, () => nativeModule.captureProbe(label, sanitizedData, occurredAt));
214
+ }
168
215
  }
169
- const value = typeof data === "function" ? safeInvoke(data) : data;
170
- const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
171
- const occurredAt = new Date().toISOString();
172
- const entry = {
173
- label,
174
- data: sanitizedData,
175
- timestamp: occurredAt,
176
- activation_id: null
177
- };
178
- const entries = this.probeBuffers.get(label) ?? [];
179
- entries.push(entry);
180
- this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
181
- if (this.nativeModule?.captureProbe) {
182
- void safeNativeCall(() => this.nativeModule.captureProbe(label, sanitizedData, occurredAt), false);
216
+ catch {
217
+ // Probe options, native activation readers and data access cannot throw into the host.
183
218
  }
184
219
  }
220
+ enqueueNativeAuxiliary(payload, operation) {
221
+ if (!this.hasNativeCapacity(false))
222
+ return;
223
+ const bytes = utf8ByteLength(JSON.stringify(payload));
224
+ if (bytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + bytes > MAX_PENDING_LOW_PRIORITY_BYTES)
225
+ return;
226
+ this.pendingNativeCalls += 1;
227
+ this.pendingNativeBytes += bytes;
228
+ void safeNativeCall(operation, undefined).finally(() => this.releaseNativeCapacity(bytes));
229
+ }
185
230
  async flush() {
231
+ // Hook preparation queued by earlier capture calls must reach the bridge first.
232
+ await Promise.resolve();
186
233
  if (!this.nativeModule) {
187
234
  return;
188
235
  }
@@ -226,49 +273,117 @@ export class DebugBundleReactNativeClient {
226
273
  });
227
274
  }
228
275
  enqueue(eventType, payload, traceId, countTowardSession, context, localPolicyAllows = true) {
229
- if (!this.canCapture(eventType, countTowardSession)) {
230
- return;
231
- }
232
- const authoredEvent = {
233
- schema_version: "2026-03-01",
234
- event_id: generateId(),
235
- event_type: eventType,
236
- sdk_name: SDK_NAME,
237
- sdk_version: this.config.sdkVersion,
238
- service: {
239
- name: this.config.service,
240
- environment: this.config.environment,
241
- runtime: "react-native",
242
- framework: "react-native"
243
- },
244
- occurred_at: new Date().toISOString(),
245
- ...(traceId ? { correlation: { trace_id: traceId } } : {}),
246
- ...(context && Object.keys(context).length > 0 ? { context } : {}),
247
- payload: this.nativeState.device
248
- ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
249
- : payload
250
- };
251
- const initial = protectEventFields(authoredEvent, this.config.redactFields);
252
- if (!initial)
276
+ if (!localPolicyAllows)
253
277
  return;
254
- const event = applyBeforeSend(initial, this.config.beforeSend);
255
- if (!event || !localPolicyAllows) {
278
+ if (!this.canCapture(eventType, countTowardSession)) {
256
279
  return;
257
280
  }
258
- const safeEvent = protectEventFields(event, this.config.redactFields);
259
- if (!safeEvent)
260
- return;
261
- if (!this.nativeModule) {
262
- this.nativeState = degradedNativeState("native_module_unavailable");
281
+ const highPriority = eventType === "frontend_exception" ||
282
+ eventType === "log_event" && (payload.level === "error" || payload.level === "critical");
283
+ if (!this.hasNativeCapacity(highPriority))
263
284
  return;
285
+ this.pendingNativeCalls += 1;
286
+ let handedOff = false;
287
+ let reservedBytes = 0;
288
+ try {
289
+ const authoredEvent = {
290
+ schema_version: "2026-03-01",
291
+ event_id: generateId(),
292
+ event_type: eventType,
293
+ sdk_name: SDK_NAME,
294
+ sdk_version: this.config.sdkVersion,
295
+ service: {
296
+ name: this.config.service,
297
+ environment: this.config.environment,
298
+ runtime: "react-native",
299
+ framework: "react-native"
300
+ },
301
+ occurred_at: new Date().toISOString(),
302
+ ...(traceId ? { correlation: { trace_id: traceId } } : {}),
303
+ ...(context && Object.keys(context).length > 0 ? { context } : {}),
304
+ payload: this.nativeState.device
305
+ ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
306
+ : payload
307
+ };
308
+ const initial = protectEventFields(authoredEvent, this.config.redactFields);
309
+ if (!initial)
310
+ return;
311
+ const config = this.config;
312
+ const nativeModule = this.nativeModule;
313
+ const initialBytes = utf8ByteLength(JSON.stringify(initial));
314
+ const byteLimit = highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
315
+ if (initialBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + initialBytes > byteLimit)
316
+ return;
317
+ this.pendingNativeBytes += initialBytes;
318
+ reservedBytes = initialBytes;
319
+ if (!nativeModule) {
320
+ this.nativeState = degradedNativeState("native_module_unavailable");
321
+ return;
322
+ }
323
+ handedOff = true;
324
+ this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes);
264
325
  }
265
- if (this.nativeModule.enqueueCanonicalEvent) {
266
- void safeNativeCall(() => this.nativeModule.enqueueCanonicalEvent(safeEvent), undefined);
326
+ catch {
327
+ this.nativeState = degradedNativeState("event_preparation_failed");
267
328
  }
268
- else {
269
- void safeNativeCall(() => this.nativeModule.enqueueEvent(toLegacyNativeEvent(safeEvent)), undefined);
329
+ finally {
330
+ if (!handedOff)
331
+ this.releaseNativeCapacity(reservedBytes);
270
332
  }
271
333
  }
334
+ sendAdmittedEvent(initial, config, nativeModule, reservedBytes) {
335
+ // This activation receives only a protected snapshot: deferred closures must
336
+ // not retain the raw capture payload/context through their lexical scope.
337
+ const prepareAndSend = async () => {
338
+ if (this.config !== config)
339
+ return;
340
+ const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEvent !== undefined);
341
+ if (safeEvent === null)
342
+ return;
343
+ const finalPriority = safeEvent.event_type === "frontend_exception" ||
344
+ safeEvent.event_type === "log_event" && ["error", "critical"].includes(String(safeEvent.payload.level));
345
+ const eventBytes = utf8ByteLength(JSON.stringify(safeEvent));
346
+ const finalLimit = finalPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
347
+ // The callback closure can retain the protected original until the bridge
348
+ // settles. Charge both it and the final snapshot instead of forgetting it.
349
+ if (eventBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + eventBytes > finalLimit)
350
+ return;
351
+ this.pendingNativeBytes += eventBytes;
352
+ reservedBytes += eventBytes;
353
+ if (nativeModule.enqueueCanonicalEvent)
354
+ await nativeModule.enqueueCanonicalEvent(safeEvent);
355
+ else
356
+ await nativeModule.enqueueEvent(safeEvent);
357
+ };
358
+ const operation = config.beforeSend === null
359
+ ? safeNativeCall(prepareAndSend, undefined)
360
+ : Promise.resolve().then(() => safeNativeCall(prepareAndSend, undefined));
361
+ void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); });
362
+ }
363
+ prepareAdmittedEvent(initial, config, canonical) {
364
+ // Exit this synchronous activation before awaiting the bridge. An application
365
+ // replacement may be huge before projection and must not survive suspension.
366
+ const event = applyBeforeSend(initial, config.beforeSend);
367
+ if (!event || this.config !== config)
368
+ return null;
369
+ const safeEvent = protectEventFields(event, config.redactFields);
370
+ if (!safeEvent)
371
+ return null;
372
+ if (safeEvent.event_type === "log_event" &&
373
+ (!config.captureLogs || !(LOG_LEVELS[safeEvent.payload.level] >= LOG_LEVELS[config.logLevel])))
374
+ return null;
375
+ if (safeEvent.event_type === "frontend_exception" && !config.captureErrors)
376
+ return null;
377
+ return canonical ? safeEvent : toLegacyNativeEvent(safeEvent);
378
+ }
379
+ hasNativeCapacity(highPriority) {
380
+ return this.pendingNativeCalls < (highPriority ? MAX_PENDING_NATIVE_CALLS : MAX_PENDING_LOW_PRIORITY_CALLS) &&
381
+ this.pendingNativeBytes < (highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES);
382
+ }
383
+ releaseNativeCapacity(bytes) {
384
+ this.pendingNativeCalls -= 1;
385
+ this.pendingNativeBytes -= bytes;
386
+ }
272
387
  canCapture(eventType, countTowardSession) {
273
388
  if (!this.config.enabled || !this.config.projectToken) {
274
389
  return false;
@@ -278,7 +393,13 @@ export class DebugBundleReactNativeClient {
278
393
  return true;
279
394
  }
280
395
  mergeContext(context) {
281
- return { ...this.context, ...context };
396
+ try {
397
+ return { ...this.context, ...context };
398
+ }
399
+ catch {
400
+ // A hostile caller accessor cannot interrupt error reporting or application work.
401
+ return { ...this.context };
402
+ }
282
403
  }
283
404
  snapshotProbeData() {
284
405
  if (!this.config.probeFlushOnError) {
@@ -293,6 +414,24 @@ export class DebugBundleReactNativeClient {
293
414
  return this.jsProbeActivationExpiresAt > Date.now();
294
415
  }
295
416
  }
417
+ function utf8ByteLength(value) {
418
+ let bytes = 0;
419
+ for (let index = 0; index < value.length; index += 1) {
420
+ const code = value.charCodeAt(index);
421
+ if (code < 0x80)
422
+ bytes += 1;
423
+ else if (code < 0x800)
424
+ bytes += 2;
425
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
426
+ value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
427
+ bytes += 4;
428
+ index += 1;
429
+ }
430
+ else
431
+ bytes += 3;
432
+ }
433
+ return bytes;
434
+ }
296
435
  export function createDebugBundleClient(config = {}) {
297
436
  return new DebugBundleReactNativeClient(config);
298
437
  }
package/dist/console.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- import type { DebugBundleClient } from "./types.js";
2
- export declare function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog">): () => void;
1
+ import type { DebugBundleClient, DebugBundleLogLevel } from "./types.js";
2
+ export declare function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog"> & {
3
+ isLogEnabled?: (level: DebugBundleLogLevel) => boolean;
4
+ }): () => void;
3
5
  //# sourceMappingURL=console.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../src/console.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAuB,MAAM,YAAY,CAAC;AAIzE,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,GAAG,MAAM,IAAI,CAiCnG"}
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../src/console.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAIzE,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,GAAG;IACxF,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,OAAO,CAAC;CACxD,GAAG,MAAM,IAAI,CAoCb"}
package/dist/console.js CHANGED
@@ -26,8 +26,13 @@ export function captureDebugBundleConsole(client) {
26
26
  }
27
27
  inCapture = true;
28
28
  try {
29
+ if (client.isLogEnabled?.(level) === false)
30
+ return;
29
31
  client.captureLog(args.map(String).join(" "), level, { source: "console" });
30
32
  }
33
+ catch {
34
+ // Console instrumentation must not change host logging behavior.
35
+ }
31
36
  finally {
32
37
  inCapture = false;
33
38
  }
@@ -4,7 +4,7 @@ import DebugBundle
4
4
 
5
5
  @objc(DebugBundleReactNative)
6
6
  final class DebugBundleReactNative: NSObject {
7
- private var currentSDKVersion = "2.0.0"
7
+ private var currentSDKVersion = "3.0.0"
8
8
  private var currentService = "react-native-app"
9
9
  private var currentEnvironment = "production"
10
10
 
@@ -14,7 +14,7 @@ final class DebugBundleReactNative: NSObject {
14
14
  resolver resolve: @escaping RCTPromiseResolveBlock,
15
15
  rejecter reject: @escaping RCTPromiseRejectBlock
16
16
  ) {
17
- currentSDKVersion = config["sdkVersion"] as? String ?? "2.0.0"
17
+ currentSDKVersion = config["sdkVersion"] as? String ?? "3.0.0"
18
18
  currentService = config["service"] as? String ?? "react-native-app"
19
19
  currentEnvironment = config["environment"] as? String ?? "production"
20
20
  let sdkConfig = DebugBundleConfig(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugbundle/sdk-react-native",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "React Native SDK for DebugBundle mobile event capture.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -48,7 +48,8 @@
48
48
  "app.plugin.js",
49
49
  "DebugBundleReactNative.podspec",
50
50
  "CHANGELOG.md",
51
- "README.md"
51
+ "README.md",
52
+ "MIGRATION-3.0.md"
52
53
  ],
53
54
  "exports": {
54
55
  "./package.json": "./package.json",
@@ -50,6 +50,12 @@ export function applyBeforeSend(
50
50
  }
51
51
  try {
52
52
  const result = hook(cloneEvent(event));
53
+ // Hooks have synchronous return contracts. Contain mistaken async failures
54
+ // before treating their Promise as invalid and keeping the protected original.
55
+ if (result instanceof Promise) {
56
+ void result.catch(() => undefined);
57
+ return event;
58
+ }
53
59
  if (result === null) {
54
60
  return null;
55
61
  }
package/src/client.ts CHANGED
@@ -19,7 +19,14 @@ import type {
19
19
  } from "./types.js";
20
20
 
21
21
  const SDK_NAME = "@debugbundle/sdk-react-native" as const;
22
- const DEFAULT_SDK_VERSION = "2.0.0";
22
+ const DEFAULT_SDK_VERSION = "3.0.0";
23
+ const MAX_PENDING_NATIVE_CALLS = 256;
24
+ const MAX_PENDING_LOW_PRIORITY_CALLS = 224;
25
+ const MAX_PENDING_NATIVE_BYTES = 4 * 1024 * 1024;
26
+ const MAX_PENDING_LOW_PRIORITY_BYTES = 3 * 1024 * 1024;
27
+ const MAX_NATIVE_EVENT_BYTES = 64 * 1024;
28
+ const MAX_CONTEXT_FIELDS = 50;
29
+ const MAX_CONTEXT_KEY_LENGTH = 128;
23
30
  const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
24
31
  debug: 10,
25
32
  info: 20,
@@ -69,6 +76,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
69
76
  private breadcrumbs: Array<Record<string, unknown>> = [];
70
77
  private probeBuffers = new Map<string, Array<Record<string, unknown>>>();
71
78
  private jsProbeActivationExpiresAt = 0;
79
+ private pendingNativeCalls = 0;
80
+ private pendingNativeBytes = 0;
72
81
 
73
82
  constructor(config: DebugBundleConfig = {}) {
74
83
  this.config = resolveConfig(config);
@@ -116,6 +125,8 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
116
125
  }
117
126
 
118
127
  captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
128
+ if (!this.config.captureErrors || !this.canCapture("frontend_exception", false) ||
129
+ !this.hasNativeCapacity(true)) return;
119
130
  const mergedContext = this.mergeContext(context);
120
131
  const sanitizedError = asRecord(sanitizeValue(error, { redactFields: this.config.redactFields }));
121
132
  this.enqueue("frontend_exception", {
@@ -134,15 +145,22 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
134
145
  }
135
146
 
136
147
  captureLog(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
148
+ if (!this.isLogEnabled(level)) return;
149
+ const mergedContext = this.mergeContext(context);
137
150
  this.enqueue("log_event", {
138
151
  level,
139
152
  message: sanitizeValue(message, { redactFields: this.config.redactFields }),
140
153
  attributes: {
141
- ...asRecord(sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields })),
154
+ ...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
142
155
  source: "javascript"
143
156
  }
144
- }, stringValue(context.trace_id), true, undefined,
145
- this.config.captureLogs && LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel]);
157
+ }, stringValue(mergedContext.trace_id), true);
158
+ }
159
+
160
+ isLogEnabled(level: DebugBundleLogLevel): boolean {
161
+ const rank = LOG_LEVELS[level];
162
+ return rank !== undefined && this.config.captureLogs && rank >= LOG_LEVELS[this.config.logLevel] &&
163
+ this.canCapture("log_event", true) && this.hasNativeCapacity(rank >= LOG_LEVELS.error);
146
164
  }
147
165
 
148
166
  captureRequest(
@@ -150,6 +168,7 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
150
168
  response: DebugBundleResponseInfo,
151
169
  context: DebugBundleCaptureContext = {}
152
170
  ): void {
171
+ if (!this.config.captureNetwork || !this.canCapture("request_event", true) || !this.hasNativeCapacity(false)) return;
153
172
  const mergedContext = this.mergeContext(context);
154
173
  const traceId = request.traceId ?? stringValue(mergedContext.trace_id);
155
174
  const parsedUrl = parseRequestUrl(request.url);
@@ -167,16 +186,13 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
167
186
  }),
168
187
  duration_ms: response.durationMillis ?? 0
169
188
  };
170
- if (this.config.captureNetwork) {
171
- this.recordBreadcrumb("network_request", payload);
172
- }
189
+ this.recordBreadcrumb("network_request", payload);
173
190
  this.enqueue(
174
191
  "request_event",
175
192
  payload,
176
193
  traceId,
177
194
  true,
178
- asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
179
- this.config.captureNetwork
195
+ asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields }))
180
196
  );
181
197
  }
182
198
 
@@ -189,43 +205,61 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
189
205
  }
190
206
 
191
207
  setContext(key: string, value: unknown): void {
192
- this.context[key] = sanitizeValue(value, { redactFields: this.config.redactFields });
193
- if (this.nativeModule?.setContextValue) {
194
- void safeNativeCall(() => this.nativeModule!.setContextValue!(key, { value: this.context[key] }), undefined);
195
- } else if (this.nativeModule?.setContext) {
196
- void safeNativeCall(() => this.nativeModule!.setContext!(key, this.context[key]), undefined);
208
+ if (typeof key !== "string" || !key || key.length > MAX_CONTEXT_KEY_LENGTH ||
209
+ !this.hasNativeCapacity(false) ||
210
+ !Object.hasOwn(this.context, key) && Object.keys(this.context).length >= MAX_CONTEXT_FIELDS) return;
211
+ try {
212
+ // Include the field name in mandatory privacy before local or native ownership.
213
+ const fields = asRecord(sanitizeValue({ [key]: value }, { redactFields: this.config.redactFields }));
214
+ if (!Object.hasOwn(fields, key)) return;
215
+ const entry = { value: fields[key] };
216
+ this.context = { ...this.context, [key]: entry.value };
217
+ const nativeModule = this.nativeModule;
218
+ if (nativeModule?.setContextValue) {
219
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContextValue!(key, entry));
220
+ } else if (nativeModule?.setContext) {
221
+ this.enqueueNativeAuxiliary({ key, entry }, () => nativeModule.setContext!(key, entry.value));
222
+ }
223
+ } catch {
224
+ // Context accessors and optional native integrations cannot escape into the host.
197
225
  }
198
226
  }
199
227
 
200
228
  probe(label: string, data: unknown | (() => unknown), options: DebugBundleProbeOptions = {}): void {
201
- if (!label || this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) {
202
- return;
203
- }
204
- const nativeProbeActive = this.nativeModule?.isProbeActive?.(label) ?? false;
205
- if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) {
206
- return;
207
- }
208
- const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
209
- const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
210
- const occurredAt = new Date().toISOString();
211
- const entry = {
212
- label,
213
- data: sanitizedData,
214
- timestamp: occurredAt,
215
- activation_id: null
216
- };
217
- const entries = this.probeBuffers.get(label) ?? [];
218
- entries.push(entry);
219
- this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
220
- if (this.nativeModule?.captureProbe) {
221
- void safeNativeCall(
222
- () => this.nativeModule!.captureProbe!(label, sanitizedData, occurredAt),
223
- false
224
- );
229
+ if (!label || label.length > MAX_CONTEXT_KEY_LENGTH || !this.hasNativeCapacity(false) ||
230
+ this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) return;
231
+ try {
232
+ const nativeModule = this.nativeModule;
233
+ const nativeProbeActive = nativeModule?.isProbeActive?.(label) ?? false;
234
+ if (options.heavy && !nativeProbeActive && !this.hasActiveJsProbeActivation()) return;
235
+ const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
236
+ const sanitizedData = objectWrap(sanitizeValue(value, { redactFields: this.config.redactFields }));
237
+ const occurredAt = new Date().toISOString();
238
+ const entry = { label, data: sanitizedData, timestamp: occurredAt, activation_id: null };
239
+ const entries = this.probeBuffers.get(label) ?? [];
240
+ entries.push(entry);
241
+ this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
242
+ if (nativeModule?.captureProbe) {
243
+ this.enqueueNativeAuxiliary({ label, data: sanitizedData, occurredAt },
244
+ () => nativeModule.captureProbe!(label, sanitizedData, occurredAt));
245
+ }
246
+ } catch {
247
+ // Probe options, native activation readers and data access cannot throw into the host.
225
248
  }
226
249
  }
227
250
 
251
+ private enqueueNativeAuxiliary(payload: unknown, operation: () => unknown): void {
252
+ if (!this.hasNativeCapacity(false)) return;
253
+ const bytes = utf8ByteLength(JSON.stringify(payload));
254
+ if (bytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + bytes > MAX_PENDING_LOW_PRIORITY_BYTES) return;
255
+ this.pendingNativeCalls += 1;
256
+ this.pendingNativeBytes += bytes;
257
+ void safeNativeCall(operation, undefined).finally(() => this.releaseNativeCapacity(bytes));
258
+ }
259
+
228
260
  async flush(): Promise<void> {
261
+ // Hook preparation queued by earlier capture calls must reach the bridge first.
262
+ await Promise.resolve();
229
263
  if (!this.nativeModule) {
230
264
  return;
231
265
  }
@@ -280,47 +314,111 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
280
314
  context?: Record<string, unknown>,
281
315
  localPolicyAllows = true
282
316
  ): void {
317
+ if (!localPolicyAllows) return;
283
318
  if (!this.canCapture(eventType, countTowardSession)) {
284
319
  return;
285
320
  }
286
- const authoredEvent: DebugBundleEventEnvelope = {
287
- schema_version: "2026-03-01",
288
- event_id: generateId(),
289
- event_type: eventType,
290
- sdk_name: SDK_NAME,
291
- sdk_version: this.config.sdkVersion,
292
- service: {
293
- name: this.config.service,
294
- environment: this.config.environment,
295
- runtime: "react-native",
296
- framework: "react-native"
297
- },
298
- occurred_at: new Date().toISOString(),
299
- ...(traceId ? { correlation: { trace_id: traceId } } : {}),
300
- ...(context && Object.keys(context).length > 0 ? { context } : {}),
301
- payload: this.nativeState.device
302
- ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
303
- : payload
304
- };
305
- const initial = protectEventFields(authoredEvent, this.config.redactFields);
306
- if (!initial) return;
307
- const event = applyBeforeSend(initial, this.config.beforeSend);
308
- if (!event || !localPolicyAllows) {
309
- return;
310
- }
311
- const safeEvent = protectEventFields(event, this.config.redactFields);
312
- if (!safeEvent) return;
313
- if (!this.nativeModule) {
314
- this.nativeState = degradedNativeState("native_module_unavailable");
315
- return;
316
- }
317
- if (this.nativeModule.enqueueCanonicalEvent) {
318
- void safeNativeCall(() => this.nativeModule!.enqueueCanonicalEvent!(safeEvent), undefined);
319
- } else {
320
- void safeNativeCall(() => this.nativeModule!.enqueueEvent(toLegacyNativeEvent(safeEvent)), undefined);
321
+ const highPriority = eventType === "frontend_exception" ||
322
+ eventType === "log_event" && (payload.level === "error" || payload.level === "critical");
323
+ if (!this.hasNativeCapacity(highPriority)) return;
324
+ this.pendingNativeCalls += 1;
325
+ let handedOff = false;
326
+ let reservedBytes = 0;
327
+ try {
328
+ const authoredEvent: DebugBundleEventEnvelope = {
329
+ schema_version: "2026-03-01",
330
+ event_id: generateId(),
331
+ event_type: eventType,
332
+ sdk_name: SDK_NAME,
333
+ sdk_version: this.config.sdkVersion,
334
+ service: {
335
+ name: this.config.service,
336
+ environment: this.config.environment,
337
+ runtime: "react-native",
338
+ framework: "react-native"
339
+ },
340
+ occurred_at: new Date().toISOString(),
341
+ ...(traceId ? { correlation: { trace_id: traceId } } : {}),
342
+ ...(context && Object.keys(context).length > 0 ? { context } : {}),
343
+ payload: this.nativeState.device
344
+ ? { ...payload, device: canonicalDevice(this.nativeState.device, this.config) }
345
+ : payload
346
+ };
347
+ const initial = protectEventFields(authoredEvent, this.config.redactFields);
348
+ if (!initial) return;
349
+ const config = this.config;
350
+ const nativeModule = this.nativeModule;
351
+ const initialBytes = utf8ByteLength(JSON.stringify(initial));
352
+ const byteLimit = highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
353
+ if (initialBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + initialBytes > byteLimit) return;
354
+ this.pendingNativeBytes += initialBytes;
355
+ reservedBytes = initialBytes;
356
+ if (!nativeModule) {
357
+ this.nativeState = degradedNativeState("native_module_unavailable");
358
+ return;
359
+ }
360
+ handedOff = true;
361
+ this.sendAdmittedEvent(initial, config, nativeModule, reservedBytes);
362
+ } catch {
363
+ this.nativeState = degradedNativeState("event_preparation_failed");
364
+ } finally {
365
+ if (!handedOff) this.releaseNativeCapacity(reservedBytes);
321
366
  }
322
367
  }
323
368
 
369
+ private sendAdmittedEvent(
370
+ initial: DebugBundleEventEnvelope,
371
+ config: ResolvedDebugBundleConfig,
372
+ nativeModule: NativeDebugBundleModule,
373
+ reservedBytes: number
374
+ ): void {
375
+ // This activation receives only a protected snapshot: deferred closures must
376
+ // not retain the raw capture payload/context through their lexical scope.
377
+ const prepareAndSend = async (): Promise<void> => {
378
+ if (this.config !== config) return;
379
+ const safeEvent = this.prepareAdmittedEvent(initial, config, nativeModule.enqueueCanonicalEvent !== undefined);
380
+ if (safeEvent === null) return;
381
+ const finalPriority = safeEvent.event_type === "frontend_exception" ||
382
+ safeEvent.event_type === "log_event" && ["error", "critical"].includes(String(safeEvent.payload.level));
383
+ const eventBytes = utf8ByteLength(JSON.stringify(safeEvent));
384
+ const finalLimit = finalPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES;
385
+ // The callback closure can retain the protected original until the bridge
386
+ // settles. Charge both it and the final snapshot instead of forgetting it.
387
+ if (eventBytes > MAX_NATIVE_EVENT_BYTES || this.pendingNativeBytes + eventBytes > finalLimit) return;
388
+ this.pendingNativeBytes += eventBytes;
389
+ reservedBytes += eventBytes;
390
+ if (nativeModule.enqueueCanonicalEvent) await nativeModule.enqueueCanonicalEvent(safeEvent);
391
+ else await nativeModule.enqueueEvent(safeEvent);
392
+ };
393
+ const operation = config.beforeSend === null
394
+ ? safeNativeCall(prepareAndSend, undefined)
395
+ : Promise.resolve().then(() => safeNativeCall(prepareAndSend, undefined));
396
+ void operation.finally(() => { this.releaseNativeCapacity(reservedBytes); });
397
+ }
398
+
399
+ private prepareAdmittedEvent(initial: DebugBundleEventEnvelope, config: ResolvedDebugBundleConfig, canonical: boolean): DebugBundleEventEnvelope | null {
400
+ // Exit this synchronous activation before awaiting the bridge. An application
401
+ // replacement may be huge before projection and must not survive suspension.
402
+ const event = applyBeforeSend(initial, config.beforeSend);
403
+ if (!event || this.config !== config) return null;
404
+ const safeEvent = protectEventFields(event, config.redactFields);
405
+ if (!safeEvent) return null;
406
+ if (safeEvent.event_type === "log_event" &&
407
+ (!config.captureLogs || !(LOG_LEVELS[safeEvent.payload.level as DebugBundleLogLevel] >= LOG_LEVELS[config.logLevel]))) return null;
408
+ if (safeEvent.event_type === "frontend_exception" && !config.captureErrors) return null;
409
+ return canonical ? safeEvent : toLegacyNativeEvent(safeEvent);
410
+ }
411
+
412
+ private hasNativeCapacity(highPriority: boolean): boolean {
413
+ return this.pendingNativeCalls < (highPriority ? MAX_PENDING_NATIVE_CALLS : MAX_PENDING_LOW_PRIORITY_CALLS) &&
414
+ this.pendingNativeBytes < (highPriority ? MAX_PENDING_NATIVE_BYTES : MAX_PENDING_LOW_PRIORITY_BYTES);
415
+ }
416
+
417
+ private releaseNativeCapacity(bytes: number): void {
418
+ this.pendingNativeCalls -= 1;
419
+ this.pendingNativeBytes -= bytes;
420
+ }
421
+
324
422
  private canCapture(eventType: DebugBundleEventEnvelope["event_type"], countTowardSession: boolean): boolean {
325
423
  if (!this.config.enabled || !this.config.projectToken) {
326
424
  return false;
@@ -331,7 +429,12 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
331
429
  }
332
430
 
333
431
  private mergeContext(context: DebugBundleCaptureContext): Record<string, unknown> {
334
- return { ...this.context, ...context };
432
+ try {
433
+ return { ...this.context, ...context };
434
+ } catch {
435
+ // A hostile caller accessor cannot interrupt error reporting or application work.
436
+ return { ...this.context };
437
+ }
335
438
  }
336
439
 
337
440
  private snapshotProbeData(): Record<string, unknown> {
@@ -350,6 +453,21 @@ export class DebugBundleReactNativeClient implements DebugBundleClient {
350
453
 
351
454
  }
352
455
 
456
+ function utf8ByteLength(value: string): number {
457
+ let bytes = 0;
458
+ for (let index = 0; index < value.length; index += 1) {
459
+ const code = value.charCodeAt(index);
460
+ if (code < 0x80) bytes += 1;
461
+ else if (code < 0x800) bytes += 2;
462
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
463
+ value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
464
+ bytes += 4;
465
+ index += 1;
466
+ } else bytes += 3;
467
+ }
468
+ return bytes;
469
+ }
470
+
353
471
  export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
354
472
  return new DebugBundleReactNativeClient(config);
355
473
  }
package/src/console.ts CHANGED
@@ -2,7 +2,9 @@ import type { DebugBundleClient, DebugBundleLogLevel } from "./types.js";
2
2
 
3
3
  let restoreConsole: (() => void) | null = null;
4
4
 
5
- export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog">): () => void {
5
+ export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captureLog"> & {
6
+ isLogEnabled?: (level: DebugBundleLogLevel) => boolean;
7
+ }): () => void {
6
8
  if (restoreConsole) {
7
9
  return restoreConsole;
8
10
  }
@@ -30,7 +32,10 @@ export function captureDebugBundleConsole(client: Pick<DebugBundleClient, "captu
30
32
  }
31
33
  inCapture = true;
32
34
  try {
35
+ if (client.isLogEnabled?.(level) === false) return;
33
36
  client.captureLog(args.map(String).join(" "), level, { source: "console" });
37
+ } catch {
38
+ // Console instrumentation must not change host logging behavior.
34
39
  } finally {
35
40
  inCapture = false;
36
41
  }