@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 +10 -1
- package/DebugBundleReactNative.podspec +2 -2
- package/MIGRATION-3.0.md +9 -0
- package/README.md +5 -0
- package/android/build.gradle +1 -1
- package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativeModule.java +2 -2
- package/app.plugin.js +1 -1
- package/dist/before-send.d.ts.map +1 -1
- package/dist/before-send.js +6 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +206 -67
- package/dist/console.d.ts +4 -2
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +5 -0
- package/ios/DebugBundleReactNative.swift +2 -2
- package/package.json +3 -2
- package/src/before-send.ts +6 -0
- package/src/client.ts +192 -74
- package/src/console.ts +6 -1
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 = "
|
|
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", "~>
|
|
15
|
+
s.dependency "DebugBundle", "~> 3.0"
|
|
16
16
|
end
|
package/MIGRATION-3.0.md
ADDED
|
@@ -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).
|
package/android/build.gradle
CHANGED
|
@@ -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") ?: "
|
|
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 = "
|
|
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", "
|
|
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
|
@@ -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,
|
|
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"}
|
package/dist/before-send.js
CHANGED
|
@@ -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;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -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;
|
|
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 = "
|
|
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(
|
|
132
|
+
...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
|
|
118
133
|
source: "javascript"
|
|
119
134
|
}
|
|
120
|
-
}, stringValue(
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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 ||
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 (!
|
|
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
|
-
|
|
255
|
-
if (!event || !localPolicyAllows) {
|
|
278
|
+
if (!this.canCapture(eventType, countTowardSession)) {
|
|
256
279
|
return;
|
|
257
280
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
266
|
-
|
|
326
|
+
catch {
|
|
327
|
+
this.nativeState = degradedNativeState("event_preparation_failed");
|
|
267
328
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
package/dist/console.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../src/console.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,
|
|
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 = "
|
|
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 ?? "
|
|
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": "
|
|
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",
|
package/src/before-send.ts
CHANGED
|
@@ -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 = "
|
|
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(
|
|
154
|
+
...asRecord(sanitizeValue(mergedContext, { redactFields: this.config.redactFields })),
|
|
142
155
|
source: "javascript"
|
|
143
156
|
}
|
|
144
|
-
}, stringValue(
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 ||
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
}
|