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