@debugbundle/sdk-react-native 0.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.
Files changed (67) hide show
  1. package/DebugBundleReactNative.podspec +15 -0
  2. package/README.md +124 -0
  3. package/android/build.gradle +37 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativeModule.java +281 -0
  6. package/android/src/main/java/com/debugbundle/reactnative/DebugBundleReactNativePackage.java +20 -0
  7. package/app.plugin.js +52 -0
  8. package/dist/NativeDebugBundleReactNative.d.ts +13 -0
  9. package/dist/NativeDebugBundleReactNative.d.ts.map +1 -0
  10. package/dist/NativeDebugBundleReactNative.js +2 -0
  11. package/dist/app-state.d.ts +9 -0
  12. package/dist/app-state.d.ts.map +1 -0
  13. package/dist/app-state.js +10 -0
  14. package/dist/client.d.ts +36 -0
  15. package/dist/client.d.ts.map +1 -0
  16. package/dist/client.js +328 -0
  17. package/dist/console.d.ts +3 -0
  18. package/dist/console.d.ts.map +1 -0
  19. package/dist/console.js +35 -0
  20. package/dist/errors.d.ts +6 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +42 -0
  23. package/dist/index.d.ts +25 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +59 -0
  26. package/dist/native.d.ts +7 -0
  27. package/dist/native.d.ts.map +1 -0
  28. package/dist/native.js +59 -0
  29. package/dist/navigation.d.ts +13 -0
  30. package/dist/navigation.d.ts.map +1 -0
  31. package/dist/navigation.js +26 -0
  32. package/dist/network.d.ts +14 -0
  33. package/dist/network.d.ts.map +1 -0
  34. package/dist/network.js +179 -0
  35. package/dist/probes.d.ts +4 -0
  36. package/dist/probes.d.ts.map +1 -0
  37. package/dist/probes.js +8 -0
  38. package/dist/react.d.ts +21 -0
  39. package/dist/react.d.ts.map +1 -0
  40. package/dist/react.js +36 -0
  41. package/dist/redaction.d.ts +12 -0
  42. package/dist/redaction.d.ts.map +1 -0
  43. package/dist/redaction.js +132 -0
  44. package/dist/testing.d.ts +9 -0
  45. package/dist/testing.d.ts.map +1 -0
  46. package/dist/testing.js +36 -0
  47. package/dist/types.d.ts +152 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +1 -0
  50. package/ios/DebugBundleReactNative.swift +125 -0
  51. package/ios/DebugBundleReactNativeBridge.m +28 -0
  52. package/package.json +99 -0
  53. package/src/NativeDebugBundleReactNative.ts +14 -0
  54. package/src/app-state.ts +17 -0
  55. package/src/client.ts +385 -0
  56. package/src/console.ts +38 -0
  57. package/src/errors.ts +64 -0
  58. package/src/index.ts +82 -0
  59. package/src/native.ts +79 -0
  60. package/src/navigation.tsx +37 -0
  61. package/src/network.ts +240 -0
  62. package/src/probes.ts +11 -0
  63. package/src/react-native-shim.d.ts +14 -0
  64. package/src/react.tsx +60 -0
  65. package/src/redaction.ts +165 -0
  66. package/src/testing.ts +45 -0
  67. package/src/types.ts +173 -0
@@ -0,0 +1,125 @@
1
+ import Foundation
2
+ import React
3
+ import DebugBundle
4
+
5
+ @objc(DebugBundleReactNative)
6
+ final class DebugBundleReactNative: NSObject {
7
+ @objc(initialize:resolver:rejecter:)
8
+ func initialize(
9
+ _ config: NSDictionary,
10
+ resolver resolve: @escaping RCTPromiseResolveBlock,
11
+ rejecter reject: @escaping RCTPromiseRejectBlock
12
+ ) {
13
+ let sdkConfig = DebugBundleConfig(
14
+ projectToken: config["projectToken"] as? String ?? "",
15
+ enabled: config["enabled"] as? Bool ?? true,
16
+ environment: config["environment"] as? String ?? "production",
17
+ service: config["service"] as? String ?? "react-native-app",
18
+ endpoint: URL(string: config["endpoint"] as? String ?? DebugBundleConfig.defaultEndpoint.absoluteString) ?? DebugBundleConfig.defaultEndpoint,
19
+ releaseChannel: config["releaseChannel"] as? String ?? "production",
20
+ appVersion: config["appVersion"] as? String,
21
+ buildNumber: config["buildNumber"] as? String,
22
+ sdkVersion: config["sdkVersion"] as? String ?? "0.1.0"
23
+ )
24
+ DebugBundle.initialize(sdkConfig, transport: DebugBundleHTTPTransport())
25
+ resolve(state())
26
+ }
27
+
28
+ @objc(enqueueEvent:resolver:rejecter:)
29
+ func enqueueEvent(
30
+ _ event: NSDictionary,
31
+ resolver resolve: @escaping RCTPromiseResolveBlock,
32
+ rejecter reject: @escaping RCTPromiseRejectBlock
33
+ ) {
34
+ let eventType = event["event_type"] as? String ?? "unknown"
35
+ let payload = event["payload"] as? [String: Any?] ?? [:]
36
+ switch eventType {
37
+ case "frontend_exception":
38
+ DebugBundle.captureException(NSError(domain: "DebugBundleReactNative", code: 1, userInfo: payload.compactMapValues { $0 }))
39
+ case "frontend_breadcrumb":
40
+ DebugBundle.recordBreadcrumb(payload["breadcrumb_type"] as? String ?? "react_native", data: payload)
41
+ case "log_event":
42
+ DebugBundle.captureLog(payload["message"] as? String ?? "", level: logLevel(payload["level"] as? String), context: payload)
43
+ case "request_event":
44
+ let correlation = event["correlation"] as? [String: Any?]
45
+ DebugBundle.captureRequest(
46
+ DebugBundleRequestInfo(
47
+ method: payload["method"] as? String ?? "GET",
48
+ url: payload["url"] as? String ?? "",
49
+ routeTemplate: payload["route_template"] as? String,
50
+ traceId: correlation?["trace_id"] as? String
51
+ ),
52
+ response: DebugBundleResponseInfo(
53
+ statusCode: payload["response_status"] as? Int ?? 0,
54
+ durationMillis: payload["duration_ms"] as? Int
55
+ ),
56
+ context: payload
57
+ )
58
+ default:
59
+ DebugBundle.captureMessage("react_native_event:\(eventType)", context: payload)
60
+ }
61
+ resolve(nil)
62
+ }
63
+
64
+ @objc(flush:rejecter:)
65
+ func flush(resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
66
+ Task {
67
+ await DebugBundle.flush()
68
+ resolve(nil)
69
+ }
70
+ }
71
+
72
+ @objc(getStatus:rejecter:)
73
+ func getStatus(resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
74
+ resolve(state())
75
+ }
76
+
77
+ @objc(setContext:value:resolver:rejecter:)
78
+ func setContext(
79
+ _ key: String,
80
+ value: Any?,
81
+ resolver resolve: @escaping RCTPromiseResolveBlock,
82
+ rejecter reject: @escaping RCTPromiseRejectBlock
83
+ ) {
84
+ DebugBundle.setContext(key, value: value)
85
+ resolve(nil)
86
+ }
87
+
88
+ @objc(activateProbeTriggerToken:resolver:rejecter:)
89
+ func activateProbeTriggerToken(
90
+ _ token: String,
91
+ resolver resolve: @escaping RCTPromiseResolveBlock,
92
+ rejecter reject: @escaping RCTPromiseRejectBlock
93
+ ) {
94
+ resolve(DebugBundle.activateProbeTriggerToken(token))
95
+ }
96
+
97
+ @objc
98
+ static func requiresMainQueueSetup() -> Bool {
99
+ false
100
+ }
101
+
102
+ private func state() -> [String: Any?] {
103
+ [
104
+ "status": DebugBundle.status.rawValue,
105
+ "lastEventAt": DebugBundle.lastEventAt.map { $0.timeIntervalSince1970 * 1000 },
106
+ "nativeModuleAvailable": true,
107
+ "degradedReason": nil
108
+ ]
109
+ }
110
+ }
111
+
112
+ private func logLevel(_ value: String?) -> DebugBundleLogLevel {
113
+ switch value?.lowercased() {
114
+ case "debug":
115
+ return .debug
116
+ case "info":
117
+ return .info
118
+ case "error":
119
+ return .error
120
+ case "critical":
121
+ return .critical
122
+ default:
123
+ return .warning
124
+ }
125
+ }
@@ -0,0 +1,28 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(DebugBundleReactNative, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(initialize:(NSDictionary *)config
6
+ resolver:(RCTPromiseResolveBlock)resolve
7
+ rejecter:(RCTPromiseRejectBlock)reject)
8
+
9
+ RCT_EXTERN_METHOD(enqueueEvent:(NSDictionary *)event
10
+ resolver:(RCTPromiseResolveBlock)resolve
11
+ rejecter:(RCTPromiseRejectBlock)reject)
12
+
13
+ RCT_EXTERN_METHOD(flush:(RCTPromiseResolveBlock)resolve
14
+ rejecter:(RCTPromiseRejectBlock)reject)
15
+
16
+ RCT_EXTERN_METHOD(getStatus:(RCTPromiseResolveBlock)resolve
17
+ rejecter:(RCTPromiseRejectBlock)reject)
18
+
19
+ RCT_EXTERN_METHOD(setContext:(NSString *)key
20
+ value:(id)value
21
+ resolver:(RCTPromiseResolveBlock)resolve
22
+ rejecter:(RCTPromiseRejectBlock)reject)
23
+
24
+ RCT_EXTERN_METHOD(activateProbeTriggerToken:(NSString *)token
25
+ resolver:(RCTPromiseResolveBlock)resolve
26
+ rejecter:(RCTPromiseRejectBlock)reject)
27
+
28
+ @end
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@debugbundle/sdk-react-native",
3
+ "version": "0.1.0",
4
+ "description": "React Native SDK for DebugBundle mobile event capture.",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "homepage": "https://github.com/debugbundle/debugbundle-react-native#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/debugbundle/debugbundle-react-native.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/debugbundle/debugbundle-react-native/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "react-native": "./src/index.ts",
21
+ "codegenConfig": {
22
+ "name": "DebugBundleReactNativeSpec",
23
+ "type": "modules",
24
+ "jsSrcsDir": "src",
25
+ "android": {
26
+ "javaPackageName": "com.debugbundle.reactnative"
27
+ },
28
+ "ios": {
29
+ "modulesProvider": {
30
+ "DebugBundleReactNative": "DebugBundleReactNative"
31
+ }
32
+ }
33
+ },
34
+ "files": [
35
+ "android/build.gradle",
36
+ "android/src",
37
+ "ios",
38
+ "src",
39
+ "dist",
40
+ "app.plugin.js",
41
+ "DebugBundleReactNative.podspec",
42
+ "README.md"
43
+ ],
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/index.d.ts",
47
+ "default": "./dist/index.js"
48
+ },
49
+ "./network": {
50
+ "types": "./dist/network.d.ts",
51
+ "default": "./dist/network.js"
52
+ },
53
+ "./navigation": {
54
+ "types": "./dist/navigation.d.ts",
55
+ "default": "./dist/navigation.js"
56
+ },
57
+ "./react": {
58
+ "types": "./dist/react.d.ts",
59
+ "default": "./dist/react.js"
60
+ },
61
+ "./testing": {
62
+ "types": "./dist/testing.d.ts",
63
+ "default": "./dist/testing.js"
64
+ },
65
+ "./errors": {
66
+ "types": "./dist/errors.d.ts",
67
+ "default": "./dist/errors.js"
68
+ },
69
+ "./console": {
70
+ "types": "./dist/console.d.ts",
71
+ "default": "./dist/console.js"
72
+ }
73
+ },
74
+ "scripts": {
75
+ "build": "tsc -p tsconfig.json",
76
+ "typecheck": "tsc --noEmit -p tsconfig.json",
77
+ "test": "vitest run",
78
+ "pack:check": "npm pack --dry-run"
79
+ },
80
+ "peerDependencies": {
81
+ "@expo/config-plugins": ">=9",
82
+ "@react-navigation/native": ">=6",
83
+ "react": ">=18",
84
+ "react-native": ">=0.84"
85
+ },
86
+ "peerDependenciesMeta": {
87
+ "@expo/config-plugins": {
88
+ "optional": true
89
+ },
90
+ "@react-navigation/native": {
91
+ "optional": true
92
+ }
93
+ },
94
+ "devDependencies": {
95
+ "@types/react": "^19.1.2",
96
+ "typescript": "^6.0.2",
97
+ "vitest": "^4.1.2"
98
+ }
99
+ }
@@ -0,0 +1,14 @@
1
+ import type { TurboModule } from "react-native";
2
+ import type { UnsafeObject } from "react-native/Libraries/Types/CodegenTypes";
3
+ import { TurboModuleRegistry } from "react-native";
4
+
5
+ export interface Spec extends TurboModule {
6
+ initialize(config: UnsafeObject): Promise<UnsafeObject>;
7
+ enqueueEvent(event: UnsafeObject): Promise<void>;
8
+ flush(): Promise<void>;
9
+ getStatus(): Promise<UnsafeObject>;
10
+ setContext(key: string, value: unknown): Promise<void>;
11
+ activateProbeTriggerToken?(token: string): Promise<boolean>;
12
+ }
13
+
14
+ export default TurboModuleRegistry.getEnforcing<Spec>("DebugBundleReactNative");
@@ -0,0 +1,17 @@
1
+ import { DebugBundle } from "./index.js";
2
+ import type { DebugBundleClient } from "./types.js";
3
+
4
+ export interface AppStateLike {
5
+ currentState?: string;
6
+ addEventListener(event: "change", listener: (state: string) => void): { remove: () => void };
7
+ }
8
+
9
+ export function instrumentDebugBundleAppState(appState: AppStateLike, client: DebugBundleClient = DebugBundle): () => void {
10
+ const subscription = appState.addEventListener("change", (state) => {
11
+ client.recordBreadcrumb("app_state", { state });
12
+ if (state === "background" || state === "inactive") {
13
+ void client.flush();
14
+ }
15
+ });
16
+ return () => subscription.remove();
17
+ }
package/src/client.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { getNativeModule, degradedNativeState, safeNativeCall } from "./native.js";
2
+ import { defaultRedactFields, DEFAULT_HEADER_ALLOWLIST, sanitizeHeaders, sanitizeValue } from "./redaction.js";
3
+ import type {
4
+ DebugBundleCaptureContext,
5
+ DebugBundleClient,
6
+ DebugBundleConfig,
7
+ DebugBundleEventEnvelope,
8
+ DebugBundleLogLevel,
9
+ DebugBundleProbeOptions,
10
+ DebugBundleRequestInfo,
11
+ DebugBundleResponseInfo,
12
+ DebugBundleStatus,
13
+ NativeDebugBundleModule,
14
+ NativeDebugBundleState,
15
+ ResolvedDebugBundleConfig
16
+ } from "./types.js";
17
+
18
+ const SDK_NAME = "@debugbundle/sdk-react-native" as const;
19
+ const DEFAULT_SDK_VERSION = "0.1.0";
20
+ const LOG_LEVELS: Record<DebugBundleLogLevel, number> = {
21
+ debug: 10,
22
+ info: 20,
23
+ warning: 30,
24
+ error: 40,
25
+ critical: 50
26
+ };
27
+
28
+ export class DebugBundleReactNativeClient implements DebugBundleClient {
29
+ private config: ResolvedDebugBundleConfig;
30
+ private nativeModule: NativeDebugBundleModule | null = null;
31
+ private nativeState: NativeDebugBundleState = degradedNativeState("not_initialized");
32
+ private context: Record<string, unknown> = {};
33
+ private breadcrumbs: Array<Record<string, unknown>> = [];
34
+ private probeBuffers = new Map<string, Array<Record<string, unknown>>>();
35
+ private jsProbeActivationExpiresAt = 0;
36
+ private sessionSampledIn = true;
37
+ private sessionEventCount = 0;
38
+
39
+ constructor(config: DebugBundleConfig = {}) {
40
+ this.config = resolveConfig(config);
41
+ if (Object.keys(config).length > 0) {
42
+ this.init(config);
43
+ }
44
+ }
45
+
46
+ get status(): DebugBundleStatus {
47
+ return this.nativeState.status;
48
+ }
49
+
50
+ get lastEventAt(): number | null {
51
+ return this.nativeState.lastEventAt ?? null;
52
+ }
53
+
54
+ init(config: DebugBundleConfig): void {
55
+ this.config = resolveConfig(config);
56
+ this.nativeModule = getNativeModule();
57
+ this.sessionSampledIn =
58
+ this.config.enabled &&
59
+ (this.config.sessionSampleRate >= 1 || Math.random() <= this.config.sessionSampleRate);
60
+ if (!this.config.enabled) {
61
+ this.nativeState = { status: "disconnected", lastEventAt: null, nativeModuleAvailable: Boolean(this.nativeModule) };
62
+ return;
63
+ }
64
+ if (!this.config.projectToken || !this.config.endpoint) {
65
+ this.nativeState = degradedNativeState("missing_project_token_or_endpoint");
66
+ return;
67
+ }
68
+ if (!this.nativeModule) {
69
+ this.nativeState = degradedNativeState("native_module_unavailable");
70
+ return;
71
+ }
72
+ void safeNativeCall(
73
+ async () => {
74
+ this.nativeState = await this.nativeModule!.initialize(this.config);
75
+ if (!this.nativeState.status) {
76
+ this.nativeState.status = "healthy";
77
+ }
78
+ },
79
+ undefined
80
+ );
81
+ this.nativeState = { status: "healthy", lastEventAt: null, nativeModuleAvailable: true };
82
+ }
83
+
84
+ captureException(error: unknown, context: DebugBundleCaptureContext = {}): void {
85
+ const mergedContext = this.mergeContext(context);
86
+ this.enqueue("frontend_exception", {
87
+ error: sanitizeValue(error, { redactFields: this.config.redactFields }),
88
+ context: sanitizeValue(mergedContext, { redactFields: this.config.redactFields }),
89
+ breadcrumbs: this.breadcrumbs.slice(),
90
+ probe_data: this.snapshotProbeData()
91
+ }, stringValue(mergedContext.trace_id), false);
92
+ }
93
+
94
+ captureError(error: unknown, context: DebugBundleCaptureContext = {}): void {
95
+ this.captureException(error, context);
96
+ }
97
+
98
+ 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
+ this.enqueue("log_event", {
103
+ level,
104
+ message: sanitizeValue(message, { redactFields: this.config.redactFields }),
105
+ context: sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields }),
106
+ source: "javascript"
107
+ }, stringValue(context.trace_id), true);
108
+ }
109
+
110
+ captureRequest(
111
+ request: DebugBundleRequestInfo,
112
+ response: DebugBundleResponseInfo,
113
+ context: DebugBundleCaptureContext = {}
114
+ ): void {
115
+ const traceId = request.traceId ?? stringValue(context.trace_id);
116
+ const payload = {
117
+ method: request.method,
118
+ url: sanitizeUrl(request.url),
119
+ route_template: request.routeTemplate ?? null,
120
+ request_headers: sanitizeHeaders(request.headers, this.config.headerAllowlist, {
121
+ redactFields: this.config.redactFields
122
+ }),
123
+ response_status: response.statusCode,
124
+ response_headers: sanitizeHeaders(response.headers, this.config.headerAllowlist, {
125
+ redactFields: this.config.redactFields
126
+ }),
127
+ duration_ms: response.durationMillis ?? null,
128
+ source: "react-native"
129
+ };
130
+ this.recordBreadcrumb("network", payload);
131
+ if (this.shouldCaptureRequestEvent(response.statusCode)) {
132
+ this.enqueue("request_event", payload, traceId, true);
133
+ }
134
+ }
135
+
136
+ captureMessage(message: string, level: DebugBundleLogLevel = "warning", context: DebugBundleCaptureContext = {}): void {
137
+ this.recordBreadcrumb("message", {
138
+ level,
139
+ message: sanitizeValue(message, { redactFields: this.config.redactFields }),
140
+ context: sanitizeValue(this.mergeContext(context), { redactFields: this.config.redactFields })
141
+ });
142
+ }
143
+
144
+ setContext(key: string, value: unknown): void {
145
+ this.context[key] = sanitizeValue(value, { redactFields: this.config.redactFields });
146
+ if (this.nativeModule?.setContext) {
147
+ void safeNativeCall(() => this.nativeModule!.setContext!(key, this.context[key]), undefined);
148
+ }
149
+ }
150
+
151
+ probe(label: string, data: unknown | (() => unknown), options: DebugBundleProbeOptions = {}): void {
152
+ if (!label || this.probeBuffers.size >= this.config.maxProbeLabels && !this.probeBuffers.has(label)) {
153
+ return;
154
+ }
155
+ if (options.heavy && !this.hasActiveJsProbeActivation()) {
156
+ return;
157
+ }
158
+ const value = typeof data === "function" ? safeInvoke(data as () => unknown) : data;
159
+ const entry = {
160
+ label,
161
+ data: sanitizeValue(value, { redactFields: this.config.redactFields }),
162
+ timestamp: new Date().toISOString(),
163
+ activation_id: null
164
+ };
165
+ const entries = this.probeBuffers.get(label) ?? [];
166
+ entries.push(entry);
167
+ this.probeBuffers.set(label, entries.slice(-this.config.maxProbeEntriesPerLabel));
168
+ }
169
+
170
+ async flush(): Promise<void> {
171
+ if (!this.nativeModule) {
172
+ return;
173
+ }
174
+ await safeNativeCall(async () => {
175
+ await this.nativeModule!.flush();
176
+ const state = await this.nativeModule!.getStatus();
177
+ this.nativeState = state;
178
+ }, undefined);
179
+ }
180
+
181
+ async activateProbeTriggerToken(token: string): Promise<boolean> {
182
+ if (!this.nativeModule?.activateProbeTriggerToken) {
183
+ return false;
184
+ }
185
+ const activated = await safeNativeCall(() => this.nativeModule!.activateProbeTriggerToken!(token), false);
186
+ if (activated) {
187
+ this.jsProbeActivationExpiresAt = Date.now() + 60 * 60 * 1000;
188
+ }
189
+ return activated;
190
+ }
191
+
192
+ recordBreadcrumb(type: string, data: Record<string, unknown> = {}): void {
193
+ this.breadcrumbs.push({
194
+ breadcrumb_type: type,
195
+ occurred_at: new Date().toISOString(),
196
+ data: sanitizeValue(data, { redactFields: this.config.redactFields })
197
+ });
198
+ this.breadcrumbs = this.breadcrumbs.slice(-this.config.maxBreadcrumbs);
199
+ }
200
+
201
+ recordScreen(screenName: string, previousScreen: string | null = null, source = "react-navigation"): void {
202
+ if (!this.config.captureScreens) {
203
+ return;
204
+ }
205
+ this.recordBreadcrumb("screen", {
206
+ screen_name: sanitizeScreenName(screenName),
207
+ previous_screen: previousScreen ? sanitizeScreenName(previousScreen) : null,
208
+ source
209
+ });
210
+ }
211
+
212
+ private enqueue(
213
+ eventType: DebugBundleEventEnvelope["event_type"],
214
+ payload: Record<string, unknown>,
215
+ traceId: string | null,
216
+ countTowardSession: boolean
217
+ ): void {
218
+ if (!this.canCapture(eventType, countTowardSession)) {
219
+ return;
220
+ }
221
+ const event: DebugBundleEventEnvelope = {
222
+ schema_version: "1",
223
+ event_id: generateId(),
224
+ event_type: eventType,
225
+ sdk_name: SDK_NAME,
226
+ sdk_version: this.config.sdkVersion,
227
+ service: {
228
+ name: this.config.service,
229
+ environment: this.config.environment,
230
+ runtime: "react-native",
231
+ framework: "react-native"
232
+ },
233
+ occurred_at: new Date().toISOString(),
234
+ correlation: traceId ? { trace_id: traceId } : null,
235
+ payload,
236
+ device: this.nativeState.device ?? deviceFromConfig(this.config)
237
+ };
238
+ if (countTowardSession) {
239
+ this.sessionEventCount += 1;
240
+ }
241
+ if (!this.nativeModule) {
242
+ this.nativeState = degradedNativeState("native_module_unavailable");
243
+ return;
244
+ }
245
+ void safeNativeCall(() => this.nativeModule!.enqueueEvent(event), undefined);
246
+ }
247
+
248
+ private canCapture(eventType: DebugBundleEventEnvelope["event_type"], countTowardSession: boolean): boolean {
249
+ if (!this.config.enabled || !this.config.projectToken || !this.sessionSampledIn) {
250
+ return false;
251
+ }
252
+ if (Math.random() > this.config.sampleRate) {
253
+ return false;
254
+ }
255
+ if (countTowardSession && this.sessionEventCount >= this.config.maxEventsPerSession) {
256
+ return eventType === "frontend_exception" || eventType === "probe_event";
257
+ }
258
+ return true;
259
+ }
260
+
261
+ private mergeContext(context: DebugBundleCaptureContext): Record<string, unknown> {
262
+ return { ...this.context, ...context };
263
+ }
264
+
265
+ private snapshotProbeData(): Record<string, unknown> {
266
+ if (!this.config.probeFlushOnError) {
267
+ return { version: 1, items: [] };
268
+ }
269
+ return {
270
+ version: 1,
271
+ items: [...this.probeBuffers.values()].flat()
272
+ };
273
+ }
274
+
275
+ private hasActiveJsProbeActivation(): boolean {
276
+ return this.jsProbeActivationExpiresAt > Date.now();
277
+ }
278
+
279
+ private shouldCaptureRequestEvent(statusCode: number): boolean {
280
+ return statusCode >= 500 || [408, 423, 424, 425, 429].includes(statusCode);
281
+ }
282
+ }
283
+
284
+ export function createDebugBundleClient(config: DebugBundleConfig = {}): DebugBundleReactNativeClient {
285
+ return new DebugBundleReactNativeClient(config);
286
+ }
287
+
288
+ export function resolveConfig(config: DebugBundleConfig): ResolvedDebugBundleConfig {
289
+ return {
290
+ projectToken: config.projectToken ?? "",
291
+ enabled: config.enabled ?? true,
292
+ environment: config.environment ?? "production",
293
+ service: config.service ?? "react-native-app",
294
+ endpoint: config.endpoint ?? "https://api.debugbundle.com/v1/events",
295
+ releaseChannel: config.releaseChannel ?? "production",
296
+ appVersion: config.appVersion ?? null,
297
+ buildNumber: config.buildNumber ?? null,
298
+ batchSize: clampInteger(config.batchSize, 10, 1, 100),
299
+ flushInterval: clampInteger(config.flushInterval, 3000, 100, 60000),
300
+ sampleRate: clampRate(config.sampleRate),
301
+ sessionSampleRate: clampRate(config.sessionSampleRate),
302
+ maxEventsPerSession: clampInteger(config.maxEventsPerSession, 100, 1, 10000),
303
+ maxBreadcrumbs: clampInteger(config.maxBreadcrumbs, 20, 1, 200),
304
+ captureErrors: config.captureErrors ?? true,
305
+ captureUnhandledRejections: config.captureUnhandledRejections ?? true,
306
+ captureScreens: config.captureScreens ?? true,
307
+ captureActions: config.captureActions ?? false,
308
+ captureNetwork: config.captureNetwork ?? true,
309
+ captureConsole: config.captureConsole ?? false,
310
+ captureLogs: config.captureLogs ?? true,
311
+ logLevel: config.logLevel ?? "warning",
312
+ tracePropagationTargets: config.tracePropagationTargets ?? [],
313
+ networkFilter: config.networkFilter ?? {},
314
+ offlineQueueMaxEvents: clampInteger(config.offlineQueueMaxEvents, 500, 1, 10000),
315
+ offlineQueueMaxBytes: clampInteger(config.offlineQueueMaxBytes, 5 * 1024 * 1024, 1024, 100 * 1024 * 1024),
316
+ offlineQueueTtl: clampInteger(config.offlineQueueTtl, 72 * 60 * 60 * 1000, 60000, 30 * 24 * 60 * 60 * 1000),
317
+ requestTimeout: clampInteger(config.requestTimeout, 5000, 100, 60000),
318
+ maxProbeLabels: clampInteger(config.maxProbeLabels, 50, 1, 500),
319
+ maxProbeEntriesPerLabel: clampInteger(config.maxProbeEntriesPerLabel, 10, 1, 200),
320
+ probeFlushOnError: config.probeFlushOnError ?? true,
321
+ redactFields: config.redactFields ?? defaultRedactFields(),
322
+ headerAllowlist: config.headerAllowlist ?? DEFAULT_HEADER_ALLOWLIST,
323
+ sdkVersion: config.sdkVersion ?? DEFAULT_SDK_VERSION
324
+ };
325
+ }
326
+
327
+ function clampInteger(value: number | undefined, fallback: number, min: number, max: number): number {
328
+ if (!Number.isFinite(value)) {
329
+ return fallback;
330
+ }
331
+ return Math.min(max, Math.max(min, Math.trunc(value!)));
332
+ }
333
+
334
+ function clampRate(value: number | undefined): number {
335
+ if (!Number.isFinite(value)) {
336
+ return 1;
337
+ }
338
+ return Math.min(1, Math.max(0, value!));
339
+ }
340
+
341
+ function safeInvoke(producer: () => unknown): unknown {
342
+ try {
343
+ return producer();
344
+ } catch (error) {
345
+ return { error: sanitizeValue(error) };
346
+ }
347
+ }
348
+
349
+ function stringValue(value: unknown): string | null {
350
+ return typeof value === "string" && value.length > 0 ? value : null;
351
+ }
352
+
353
+ function sanitizeUrl(url: string): string {
354
+ try {
355
+ const parsed = new URL(url);
356
+ parsed.search = "";
357
+ return parsed.toString();
358
+ } catch {
359
+ return url.split("?")[0] ?? url;
360
+ }
361
+ }
362
+
363
+ function sanitizeScreenName(screenName: string): string {
364
+ return screenName.replace(/[^a-zA-Z0-9_.:/-]/g, "_").slice(0, 120);
365
+ }
366
+
367
+ function generateId(): string {
368
+ const cryptoLike = globalThis.crypto;
369
+ if (cryptoLike?.randomUUID) {
370
+ return cryptoLike.randomUUID();
371
+ }
372
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (marker) => {
373
+ const random = Math.trunc(Math.random() * 16);
374
+ const value = marker === "x" ? random : (random & 0x3) | 0x8;
375
+ return value.toString(16);
376
+ });
377
+ }
378
+
379
+ function deviceFromConfig(config: ResolvedDebugBundleConfig): Record<string, unknown> {
380
+ return {
381
+ app_version: config.appVersion,
382
+ build_number: config.buildNumber,
383
+ release_channel: config.releaseChannel
384
+ };
385
+ }