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