@rejourneyco/react-native 1.0.7

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 (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. package/src/types/index.ts +709 -0
package/src/index.ts ADDED
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * Rejourney - Session Recording and Replay SDK for React Native
3
+ *
4
+ * Captures user interactions, gestures, and screen states for replay and analysis.
5
+ *
6
+ * Just call initRejourney() - everything else is automatic!
7
+ *
8
+ * Copyright (c) 2026 Rejourney
9
+ *
10
+ * Licensed under the Apache License, Version 2.0 (the "License");
11
+ * you may not use this file except in compliance with the License.
12
+ * See LICENSE-APACHE for full terms.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { initRejourney } from 'rejourney';
17
+ *
18
+ * // Initialize the SDK with your public key - that's it!
19
+ * initRejourney('pk_live_xxxxxxxxxxxx');
20
+ *
21
+ * // Or with options:
22
+ * initRejourney('pk_live_xxxxxxxxxxxx', { debug: true });
23
+ * ```
24
+ */
25
+
26
+ // =============================================================================
27
+ // CRITICAL: Safe Module Loading for React Native 0.81+
28
+ // =============================================================================
29
+ //
30
+ // On React Native 0.81+ with New Architecture (Bridgeless), importing from
31
+ // 'react-native' at module load time can fail with "PlatformConstants could
32
+ // not be found" because the TurboModule runtime may not be fully initialized.
33
+ //
34
+ // This wrapper ensures that all react-native imports happen after the runtime
35
+ // is ready, by catching any initialization errors and deferring actual SDK
36
+ // operations until initRejourney() is called.
37
+ // =============================================================================
38
+
39
+ // Module load confirmation - this runs when the module is first imported
40
+
41
+ // SDK disabled flag - set to true if we detect runtime issues
42
+ let _sdkDisabled = false;
43
+
44
+ // Type-only imports are safe - they're erased at compile time
45
+ import type {
46
+ RejourneyConfig,
47
+ RejourneyAPI,
48
+ SessionSummary,
49
+ SessionData,
50
+ NetworkRequestParams,
51
+ SDKMetrics,
52
+ } from './types';
53
+ import type { Spec } from './NativeRejourney';
54
+
55
+ // SDK version is safe - no react-native imports
56
+ import { SDK_VERSION } from './sdk/constants';
57
+
58
+ // =============================================================================
59
+ // Lazy Module Loading
60
+ // =============================================================================
61
+ // All modules that import from 'react-native' are loaded lazily to avoid
62
+ // accessing TurboModuleRegistry before it's ready.
63
+
64
+ let _reactNativeLoaded = false;
65
+ let _RN: typeof import('react-native') | null = null;
66
+
67
+ function getReactNative(): typeof import('react-native') | null {
68
+ if (_sdkDisabled) return null;
69
+ if (_reactNativeLoaded) return _RN;
70
+
71
+ try {
72
+ _RN = require('react-native');
73
+ _reactNativeLoaded = true;
74
+ return _RN;
75
+ } catch (error: any) {
76
+ getLogger().warn('Failed to load react-native:', error?.message || error);
77
+ _sdkDisabled = true;
78
+ return null;
79
+ }
80
+ }
81
+
82
+ let _logger: typeof import('./sdk/utils').logger | null = null;
83
+
84
+ function getLogger() {
85
+ if (_logger) return _logger;
86
+ if (_sdkDisabled) {
87
+ return {
88
+ debug: () => { },
89
+ info: console.log.bind(console, '[Rejourney]'),
90
+ warn: console.warn.bind(console, '[Rejourney]'),
91
+ error: console.error.bind(console, '[Rejourney]'),
92
+ logSessionStart: () => { },
93
+ logSessionEnd: () => { },
94
+ logInitSuccess: () => { },
95
+ logInitFailure: () => { },
96
+ setLogLevel: () => { },
97
+ setDebugMode: () => { },
98
+ logObservabilityStart: () => { },
99
+ logRecordingStart: () => { },
100
+ logRecordingRemoteDisabled: () => { },
101
+ logInvalidProjectKey: () => { },
102
+ logPackageMismatch: () => { },
103
+ logNetworkRequest: () => { },
104
+ logFrustration: () => { },
105
+ logError: () => { },
106
+ logUploadStats: () => { },
107
+ logLifecycleEvent: () => { },
108
+ };
109
+ }
110
+
111
+ try {
112
+ const utils = require('./sdk/utils');
113
+ _logger = utils.logger;
114
+ return _logger!;
115
+ } catch (error: any) {
116
+ console.warn('[Rejourney] Failed to load logger:', error?.message || error);
117
+ // Return fallback logger with working info
118
+ return {
119
+ debug: () => { },
120
+ info: console.log.bind(console, '[Rejourney]'),
121
+ warn: console.warn.bind(console, '[Rejourney]'),
122
+ error: console.error.bind(console, '[Rejourney]'),
123
+ logSessionStart: () => { },
124
+ logSessionEnd: () => { },
125
+ logInitSuccess: () => { },
126
+ logInitFailure: () => { },
127
+ setLogLevel: () => { },
128
+ setDebugMode: () => { },
129
+ logObservabilityStart: () => { },
130
+ logRecordingStart: () => { },
131
+ logRecordingRemoteDisabled: () => { },
132
+ logInvalidProjectKey: () => { },
133
+ logPackageMismatch: () => { },
134
+ logNetworkRequest: () => { },
135
+ logFrustration: () => { },
136
+ logError: () => { },
137
+ logUploadStats: () => { },
138
+ logLifecycleEvent: () => { },
139
+ };
140
+ }
141
+ }
142
+
143
+ // Lazy-loaded network interceptor
144
+ let _networkInterceptor: {
145
+ initNetworkInterceptor: typeof import('./sdk/networkInterceptor').initNetworkInterceptor;
146
+ disableNetworkInterceptor: typeof import('./sdk/networkInterceptor').disableNetworkInterceptor;
147
+ } | null = null;
148
+
149
+ function getNetworkInterceptor() {
150
+ if (_sdkDisabled) return { initNetworkInterceptor: () => { }, disableNetworkInterceptor: () => { } };
151
+ if (_networkInterceptor) return _networkInterceptor;
152
+
153
+ try {
154
+ _networkInterceptor = require('./sdk/networkInterceptor');
155
+ return _networkInterceptor!;
156
+ } catch (error: any) {
157
+ getLogger().warn('Failed to load network interceptor:', error?.message || error);
158
+ return { initNetworkInterceptor: () => { }, disableNetworkInterceptor: () => { } };
159
+ }
160
+ }
161
+
162
+ // Lazy-loaded auto tracking module
163
+ let _autoTracking: {
164
+ initAutoTracking: typeof import('./sdk/autoTracking').initAutoTracking;
165
+ cleanupAutoTracking: typeof import('./sdk/autoTracking').cleanupAutoTracking;
166
+ trackScroll: typeof import('./sdk/autoTracking').trackScroll;
167
+ trackScreen: typeof import('./sdk/autoTracking').trackScreen;
168
+ trackAPIRequest: typeof import('./sdk/autoTracking').trackAPIRequest;
169
+ notifyStateChange: typeof import('./sdk/autoTracking').notifyStateChange;
170
+ getSessionMetrics: typeof import('./sdk/autoTracking').getSessionMetrics;
171
+ resetMetrics: typeof import('./sdk/autoTracking').resetMetrics;
172
+ collectDeviceInfo: typeof import('./sdk/autoTracking').collectDeviceInfo;
173
+ ensurePersistentAnonymousId: typeof import('./sdk/autoTracking').ensurePersistentAnonymousId;
174
+ } | null = null;
175
+
176
+ // No-op auto tracking for when SDK is disabled
177
+ const noopAutoTracking = {
178
+ initAutoTracking: () => { },
179
+ cleanupAutoTracking: () => { },
180
+ trackScroll: () => { },
181
+ trackScreen: () => { },
182
+ trackAPIRequest: () => { },
183
+ notifyStateChange: () => { },
184
+ getSessionMetrics: () => ({}),
185
+ resetMetrics: () => { },
186
+ collectDeviceInfo: async () => ({} as any),
187
+ ensurePersistentAnonymousId: async () => 'anonymous',
188
+ };
189
+
190
+ function getAutoTracking() {
191
+ if (_sdkDisabled) return noopAutoTracking;
192
+ if (_autoTracking) return _autoTracking;
193
+
194
+ try {
195
+ _autoTracking = require('./sdk/autoTracking');
196
+ return _autoTracking!;
197
+ } catch (error: any) {
198
+ getLogger().warn('Failed to load auto tracking:', error?.message || error);
199
+ return noopAutoTracking;
200
+ }
201
+ }
202
+
203
+ // State
204
+ let _isInitialized = false;
205
+ let _isRecording = false;
206
+ let _initializationFailed = false;
207
+ let _metricsInterval: ReturnType<typeof setInterval> | null = null;
208
+ let _appStateSubscription: { remove: () => void } | null = null;
209
+ let _authErrorSubscription: { remove: () => void } | null = null;
210
+ let _currentAppState: string = 'active'; // Default to active, will be updated on init
211
+ let _userIdentity: string | null = null;
212
+
213
+ // Scroll throttling - reduce native bridge calls from 60fps to at most 10/sec
214
+ let _lastScrollTime: number = 0;
215
+ let _lastScrollOffset: number = 0;
216
+ const SCROLL_THROTTLE_MS = 100;
217
+
218
+ // Helper to save/load user identity
219
+ // NOW HANDLED NATIVELY - No-op on JS side to avoid unnecessary bridge calls
220
+ async function persistUserIdentity(_identity: string | null): Promise<void> {
221
+ // Native module handles persistence automatically in setUserIdentity
222
+ }
223
+
224
+ async function loadPersistedUserIdentity(): Promise<string | null> {
225
+ try {
226
+ const nativeModule = getRejourneyNative();
227
+ if (!nativeModule) return null;
228
+
229
+ // NATIVE STORAGE: Read directly from SharedPreferences/NSUserDefaults
230
+ return await nativeModule.getUserIdentity();
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ let _storedConfig: RejourneyConfig | null = null;
237
+
238
+ // Remote config from backend - controls sample rate and enable/disable
239
+ interface RemoteConfig {
240
+ projectId: string;
241
+ rejourneyEnabled: boolean;
242
+ recordingEnabled: boolean;
243
+ sampleRate: number;
244
+ maxRecordingMinutes: number;
245
+ billingBlocked?: boolean;
246
+ billingReason?: string;
247
+ }
248
+
249
+ // Result type for fetchRemoteConfig - distinguishes network errors from access denial
250
+ type ConfigFetchResult =
251
+ | { status: 'success'; config: RemoteConfig }
252
+ | { status: 'network_error' } // Proceed with defaults (fail-open)
253
+ | { status: 'access_denied'; httpStatus: number }; // Abort recording (fail-closed)
254
+
255
+ let _remoteConfig: RemoteConfig | null = null;
256
+ let _sessionSampledOut: boolean = false; // True = telemetry only, no replay video
257
+
258
+ /**
259
+ * Fetch project configuration from backend
260
+ * This determines sample rate, enabled state, and other server-side settings
261
+ */
262
+ async function fetchRemoteConfig(apiUrl: string, publicKey: string): Promise<ConfigFetchResult> {
263
+ try {
264
+ const RN = getReactNative();
265
+ const platform = RN?.Platform?.OS || 'unknown';
266
+
267
+ // Read actual bundleId/packageName from native module instead of hardcoding
268
+ let bundleId: string | undefined;
269
+ let packageName: string | undefined;
270
+ try {
271
+ const nativeModule = getRejourneyNative();
272
+ if (nativeModule) {
273
+ const deviceInfo = await nativeModule.getDeviceInfo() as Record<string, any>;
274
+ if (platform === 'ios' && deviceInfo?.bundleId && deviceInfo.bundleId !== 'unknown') {
275
+ bundleId = deviceInfo.bundleId;
276
+ } else if (platform === 'android' && deviceInfo?.bundleId && deviceInfo.bundleId !== 'unknown') {
277
+ packageName = deviceInfo.bundleId; // Android returns packageName as bundleId
278
+ }
279
+ }
280
+ } catch {
281
+ // If we can't get device info, skip bundle validation headers
282
+ getLogger().debug('Could not read bundleId from native module');
283
+ }
284
+
285
+ const headers: Record<string, string> = {
286
+ 'x-public-key': publicKey,
287
+ 'x-platform': platform,
288
+ };
289
+ if (bundleId) headers['x-bundle-id'] = bundleId;
290
+ if (packageName) headers['x-package-name'] = packageName;
291
+
292
+ const response = await fetch(`${apiUrl}/api/sdk/config`, {
293
+ method: 'GET',
294
+ headers,
295
+ });
296
+
297
+ if (!response.ok) {
298
+ // 401/403/404 = access denied (invalid key, project disabled, deleted, etc) - STOP recording
299
+ // Other errors (500, etc) = server issue - treat as network error, proceed with defaults
300
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
301
+ getLogger().warn(`Access denied (${response.status}) - recording disabled`);
302
+ return { status: 'access_denied', httpStatus: response.status };
303
+ }
304
+ getLogger().warn(`Config fetch failed: ${response.status}`);
305
+ return { status: 'network_error' };
306
+ }
307
+
308
+ const config = await response.json();
309
+ getLogger().debug('Remote config:', JSON.stringify(config));
310
+ return { status: 'success', config: config as RemoteConfig };
311
+ } catch (error) {
312
+ // Network timeout, no connectivity, etc - proceed with defaults
313
+ getLogger().warn('Failed to fetch remote config:', error);
314
+ return { status: 'network_error' };
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Determine if this session should be sampled (recorded)
320
+ * Returns true if recording should proceed, false if sampled out
321
+ */
322
+ function shouldRecordSession(sampleRate: number): boolean {
323
+ // sampleRate is 0-100 (percentage)
324
+ if (sampleRate >= 100) return true;
325
+ if (sampleRate <= 0) return false;
326
+
327
+ const randomValue = Math.random() * 100;
328
+ return randomValue < sampleRate;
329
+ }
330
+
331
+ // Lazy-loaded native module reference
332
+ // We don't access TurboModuleRegistry at module load time to avoid
333
+ // "PlatformConstants could not be found" errors on RN 0.81+
334
+ let _rejourneyNative: Spec | null | undefined = undefined;
335
+ let _nativeModuleLogged = false;
336
+ let _runtimeReady = false;
337
+
338
+ /**
339
+ * Check if the React Native runtime is ready for native module access.
340
+ * This prevents crashes on RN 0.81+ where accessing modules too early fails.
341
+ */
342
+ function isRuntimeReady(): boolean {
343
+ if (_runtimeReady) return true;
344
+
345
+ try {
346
+ const RN = require('react-native');
347
+ if (RN.NativeModules) {
348
+ _runtimeReady = true;
349
+ return true;
350
+ }
351
+ } catch {
352
+ // Runtime not ready yet
353
+ }
354
+ return false;
355
+ }
356
+
357
+ /**
358
+ * Get the native Rejourney module lazily.
359
+ *
360
+ * This function defers access to TurboModuleRegistry/NativeModules until
361
+ * the first time it's actually needed. This is critical for React Native 0.81+
362
+ * where accessing TurboModuleRegistry at module load time can fail because
363
+ * PlatformConstants and other core modules aren't yet initialized.
364
+ *
365
+ * The function caches the result after the first call.
366
+ */
367
+ function getRejourneyNative(): Spec | null {
368
+ // Return cached result if already resolved
369
+ if (_rejourneyNative !== undefined) {
370
+ return _rejourneyNative;
371
+ }
372
+
373
+ // Check if runtime is ready before attempting to access native modules
374
+ if (!isRuntimeReady()) {
375
+ getLogger().debug('Rejourney: Runtime not ready, deferring native module access');
376
+ return null;
377
+ }
378
+
379
+ try {
380
+ const RN = require('react-native');
381
+ const { NativeModules, TurboModuleRegistry } = RN;
382
+
383
+ // Track how the module was loaded
384
+ let loadedVia: 'TurboModules' | 'NativeModules' | 'none' = 'none';
385
+ let nativeModule: Spec | null = null;
386
+
387
+ // Try TurboModuleRegistry first (New Architecture)
388
+ if (TurboModuleRegistry && typeof TurboModuleRegistry.get === 'function') {
389
+ try {
390
+ nativeModule = TurboModuleRegistry.get('Rejourney');
391
+ if (nativeModule) {
392
+ loadedVia = 'TurboModules';
393
+ }
394
+ } catch (turboError) {
395
+ // TurboModuleRegistry.get failed, will try NativeModules
396
+ getLogger().debug('TurboModuleRegistry.get failed:', turboError);
397
+ }
398
+ }
399
+
400
+ // Fall back to NativeModules (Old Architecture / Interop Layer)
401
+ if (!nativeModule && NativeModules) {
402
+ nativeModule = NativeModules.Rejourney ?? null;
403
+ if (nativeModule) {
404
+ loadedVia = 'NativeModules';
405
+ }
406
+ }
407
+
408
+ _rejourneyNative = nativeModule;
409
+
410
+ // Log which method was used to load the module
411
+ if (_rejourneyNative && !_nativeModuleLogged) {
412
+ _nativeModuleLogged = true;
413
+
414
+ // More accurate detection based on actual load method
415
+ if (loadedVia === 'TurboModules') {
416
+ getLogger().debug('Using New Architecture (TurboModules/JSI)');
417
+ } else if (loadedVia === 'NativeModules') {
418
+ // Check if we're in interop mode (New Arch with bridge fallback)
419
+ const hasTurboProxy = !!(global as any).__turboModuleProxy;
420
+ if (hasTurboProxy) {
421
+ getLogger().debug('Using New Architecture (Interop Layer)');
422
+ } else {
423
+ getLogger().debug('Using Old Architecture (Bridge)');
424
+ }
425
+ }
426
+ }
427
+ } catch (error) {
428
+ getLogger().warn('Rejourney: Failed to access native modules:', error);
429
+ _rejourneyNative = null;
430
+ }
431
+
432
+ if (_rejourneyNative === undefined) {
433
+ _rejourneyNative = null;
434
+ }
435
+
436
+ return _rejourneyNative;
437
+ }
438
+
439
+ /**
440
+ * Safely call a native method with error handling
441
+ * Never throws - logs errors and returns gracefully
442
+ */
443
+ async function safeNativeCall<T>(
444
+ methodName: string,
445
+ fn: () => Promise<T>,
446
+ defaultValue: T
447
+ ): Promise<T> {
448
+ const nativeModule = getRejourneyNative();
449
+ if (!nativeModule || _initializationFailed) {
450
+ return defaultValue;
451
+ }
452
+ try {
453
+ return await fn();
454
+ } catch (error) {
455
+ getLogger().error(`Rejourney.${methodName} failed:`, error);
456
+ return defaultValue;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Safely call a synchronous native method with error handling
462
+ * Never throws - logs errors and returns gracefully
463
+ */
464
+ function safeNativeCallSync<T>(
465
+ methodName: string,
466
+ fn: () => T,
467
+ defaultValue: T
468
+ ): T {
469
+ const nativeModule = getRejourneyNative();
470
+ if (!nativeModule || _initializationFailed) {
471
+ return defaultValue;
472
+ }
473
+ try {
474
+ return fn();
475
+ } catch (error) {
476
+ getLogger().error(`Rejourney.${methodName} failed:`, error);
477
+ return defaultValue;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Main Rejourney API (Internal)
483
+ */
484
+ const Rejourney: RejourneyAPI = {
485
+ /**
486
+ * SDK Version
487
+ */
488
+ version: SDK_VERSION,
489
+ /**
490
+ * Internal method to start recording session
491
+ * Called by startRejourney() after user consent
492
+ */
493
+ async _startSession(): Promise<boolean> {
494
+ getLogger().debug('_startSession() entered');
495
+
496
+ if (!_storedConfig) {
497
+ throw new Error('SDK not initialized. Call initRejourney() first.');
498
+ }
499
+
500
+ const nativeModule = getRejourneyNative();
501
+ if (!nativeModule) {
502
+ // Common causes:
503
+ // - startRejourney() called too early (RN runtime not ready yet)
504
+ // - native module not linked (pods/gradle/autolinking issue)
505
+ getLogger().warn('Native module not available - cannot start recording');
506
+ return false;
507
+ }
508
+
509
+ getLogger().debug('Native module found, checking if already recording...');
510
+
511
+ if (_isRecording) {
512
+ getLogger().warn('Recording already started');
513
+ return false;
514
+ }
515
+
516
+ try {
517
+ const apiUrl = _storedConfig.apiUrl || 'https://api.rejourney.co';
518
+ const publicKey = _storedConfig.publicRouteKey || '';
519
+
520
+ // =========================================================
521
+ // STEP 1: Fetch remote config from backend
522
+ // This determines if recording is enabled and at what rate
523
+ // =========================================================
524
+ const configResult = await fetchRemoteConfig(apiUrl, publicKey);
525
+
526
+ // =========================================================
527
+ // CASE 0: Access denied (401/403) - abort immediately
528
+ // This means project disabled, invalid key, etc - HARD STOP
529
+ // =========================================================
530
+ if (configResult.status === 'access_denied') {
531
+ getLogger().info(`Recording disabled - access denied (${configResult.httpStatus})`);
532
+ return false;
533
+ }
534
+
535
+ // For success, extract the config; for network_error, proceed with null
536
+ _remoteConfig = configResult.status === 'success' ? configResult.config : null;
537
+
538
+ if (_remoteConfig) {
539
+ // =========================================================
540
+ // CASE 1: Rejourney completely disabled - abort early, nothing captured
541
+ // This is the most performant case - no native calls made
542
+ // =========================================================
543
+ if (!_remoteConfig.rejourneyEnabled) {
544
+ getLogger().logRecordingRemoteDisabled();
545
+ getLogger().info('Rejourney disabled by project settings - no data captured');
546
+ return false;
547
+ }
548
+
549
+ // =========================================================
550
+ // CASE 2: Billing blocked - abort early, nothing captured
551
+ // =========================================================
552
+ if (_remoteConfig.billingBlocked) {
553
+ getLogger().warn(`Recording blocked: ${_remoteConfig.billingReason || 'billing issue'}`);
554
+ return false;
555
+ }
556
+
557
+ // =========================================================
558
+ // CASE 3: Session sampled out - CONTINUE but disable REPLAY only
559
+ // Telemetry, events, and crash tracking still work
560
+ // =========================================================
561
+ _sessionSampledOut = !shouldRecordSession(_remoteConfig.sampleRate ?? 100);
562
+ if (_sessionSampledOut) {
563
+ getLogger().info(`Session sampled out (rate: ${_remoteConfig.sampleRate}%) - telemetry only, no replay video`);
564
+ }
565
+
566
+ // =========================================================
567
+ // CASE 4: recordingEnabled=false in dashboard - telemetry only
568
+ // Effective recording = dashboard setting AND not sampled out
569
+ // =========================================================
570
+ const effectiveRecordingEnabled = _remoteConfig.recordingEnabled && !_sessionSampledOut;
571
+
572
+ // Pass config to native - this controls visual capture on/off
573
+ await nativeModule.setRemoteConfig(
574
+ _remoteConfig.rejourneyEnabled,
575
+ effectiveRecordingEnabled,
576
+ _remoteConfig.sampleRate,
577
+ _remoteConfig.maxRecordingMinutes
578
+ );
579
+ } else {
580
+ // Network error (not access denied) - proceed with defaults
581
+ // This is "fail-open" behavior for temporary network issues
582
+ getLogger().debug('Remote config unavailable (network issue), proceeding with defaults');
583
+ }
584
+
585
+ const deviceId = await getAutoTracking().ensurePersistentAnonymousId();
586
+
587
+ if (!_userIdentity) {
588
+ _userIdentity = await loadPersistedUserIdentity();
589
+ }
590
+
591
+ const userId = _userIdentity || deviceId;
592
+ getLogger().debug(`userId=${userId.substring(0, 8)}...`);
593
+
594
+ const result = await nativeModule.startSession(userId, apiUrl, publicKey);
595
+ getLogger().debug('Native startSession returned:', JSON.stringify(result));
596
+
597
+ if (!result?.success) {
598
+ const reason = result?.error || 'Native startSession returned success=false';
599
+ if (/disabled|blocked|not enabled/i.test(reason)) {
600
+ getLogger().logRecordingRemoteDisabled();
601
+ }
602
+ getLogger().error('Native startSession failed:', reason);
603
+ return false;
604
+ }
605
+
606
+ _isRecording = true;
607
+ getLogger().debug(`✅ Session started: ${result.sessionId}`);
608
+ getLogger().logSessionStart(result.sessionId);
609
+ // Start polling for upload stats in dev mode
610
+ if (__DEV__) {
611
+ _metricsInterval = setInterval(async () => {
612
+ if (!_isRecording) {
613
+ if (_metricsInterval) clearInterval(_metricsInterval);
614
+ return;
615
+ }
616
+ try {
617
+ const native = getRejourneyNative();
618
+ if (native) {
619
+ const metrics = await native.getSDKMetrics();
620
+ if (metrics) {
621
+ getLogger().logUploadStats(metrics);
622
+ }
623
+ }
624
+ } catch (e) {
625
+ getLogger().debug('Failed to fetch metrics:', e);
626
+ }
627
+ }, 10000); // Poll more frequently in dev (10s) for better feedback
628
+ }
629
+
630
+ getAutoTracking().initAutoTracking(
631
+ {
632
+ rageTapThreshold: _storedConfig?.rageTapThreshold ?? 3,
633
+ rageTapTimeWindow: _storedConfig?.rageTapTimeWindow ?? 500,
634
+ rageTapRadius: 50,
635
+ trackJSErrors: true,
636
+ trackPromiseRejections: true,
637
+ trackReactNativeErrors: true,
638
+ collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false,
639
+ },
640
+ {
641
+ // Rage tap callback - log as frustration event
642
+ onRageTap: (count: number, x: number, y: number) => {
643
+ this.logEvent('frustration', {
644
+ frustrationKind: 'rage_tap',
645
+ tapCount: count,
646
+ x,
647
+ y,
648
+ });
649
+ getLogger().logFrustration(`Rage tap (${count} taps)`);
650
+ },
651
+ // Error callback - log as error event
652
+ onError: (error: { message: string; stack?: string; name?: string }) => {
653
+ this.logEvent('error', {
654
+ message: error.message,
655
+ stack: error.stack,
656
+ name: error.name,
657
+ });
658
+ getLogger().logError(error.message);
659
+ },
660
+ onScreen: (_screenName: string, _previousScreen?: string) => {
661
+ },
662
+ }
663
+ );
664
+
665
+ if (_storedConfig?.collectDeviceInfo !== false) {
666
+ try {
667
+ const deviceInfo = await getAutoTracking().collectDeviceInfo();
668
+ this.logEvent('device_info', deviceInfo as unknown as Record<string, unknown>);
669
+ } catch (deviceError) {
670
+ getLogger().warn('Failed to collect device info:', deviceError);
671
+ }
672
+ }
673
+
674
+ if (_storedConfig?.autoTrackNetwork !== false) {
675
+ try {
676
+ const ignoreUrls: (string | RegExp)[] = [
677
+ apiUrl,
678
+ '/api/sdk/config',
679
+ '/api/ingest/presign',
680
+ '/api/ingest/batch/complete',
681
+ '/api/ingest/session/end',
682
+ ...(_storedConfig?.networkIgnoreUrls || []),
683
+ ];
684
+
685
+ getNetworkInterceptor().initNetworkInterceptor(
686
+ (request: NetworkRequestParams) => {
687
+ getAutoTracking().trackAPIRequest(
688
+ request.success || false,
689
+ request.statusCode,
690
+ request.duration || 0,
691
+ request.responseBodySize || 0
692
+ );
693
+ Rejourney.logNetworkRequest(request);
694
+ },
695
+ {
696
+ ignoreUrls,
697
+ captureSizes: _storedConfig?.networkCaptureSizes !== false,
698
+ }
699
+ );
700
+
701
+ } catch (networkError) {
702
+ getLogger().warn('Failed to setup network interception:', networkError);
703
+ }
704
+ }
705
+
706
+
707
+ return true;
708
+ } catch (error) {
709
+ getLogger().error('Failed to start recording:', error);
710
+ _isRecording = false;
711
+ return false;
712
+ }
713
+ },
714
+
715
+ /**
716
+ * Stop the current recording session
717
+ */
718
+ async _stopSession(): Promise<void> {
719
+ if (!_isRecording) {
720
+ getLogger().warn('No active recording to stop');
721
+ return;
722
+ }
723
+
724
+ try {
725
+ const metrics = getAutoTracking().getSessionMetrics();
726
+ this.logEvent('session_metrics', metrics as unknown as Record<string, unknown>);
727
+
728
+ getNetworkInterceptor().disableNetworkInterceptor();
729
+ getAutoTracking().cleanupAutoTracking();
730
+ getAutoTracking().resetMetrics();
731
+
732
+ await safeNativeCall('stopSession', () => getRejourneyNative()!.stopSession(), undefined);
733
+
734
+ if (_metricsInterval) {
735
+ clearInterval(_metricsInterval);
736
+ _metricsInterval = null;
737
+ }
738
+
739
+ _isRecording = false;
740
+ getLogger().logSessionEnd('current');
741
+ } catch (error) {
742
+ getLogger().error('Failed to stop recording:', error);
743
+ }
744
+ },
745
+
746
+ /**
747
+ * Log a custom event
748
+ *
749
+ * @param name - Event name
750
+ * @param properties - Optional event properties
751
+ * @example
752
+ * Rejourney.logEvent('button_click', { buttonId: 'submit' });
753
+ */
754
+ logEvent(name: string, properties?: Record<string, unknown>): void {
755
+ safeNativeCallSync(
756
+ 'logEvent',
757
+ () => {
758
+ getRejourneyNative()!.logEvent(name, properties || {}).catch(() => { });
759
+ },
760
+ undefined
761
+ );
762
+ },
763
+
764
+ /**
765
+ * Set user identity for session correlation
766
+ * Associates current and future sessions with a user ID
767
+ *
768
+ * @param userId - User identifier (e.g., email, username, or internal ID)
769
+ * @example
770
+ * Rejourney.setUserIdentity('user_12345');
771
+ * Rejourney.setUserIdentity('john@example.com');
772
+ */
773
+ setUserIdentity(userId: string): void {
774
+ _userIdentity = userId;
775
+ persistUserIdentity(userId).catch(() => { });
776
+
777
+ if (_isRecording && getRejourneyNative()) {
778
+ safeNativeCallSync(
779
+ 'setUserIdentity',
780
+ () => {
781
+ getRejourneyNative()!.setUserIdentity(userId).catch(() => { });
782
+ },
783
+ undefined
784
+ );
785
+ }
786
+ },
787
+
788
+ /**
789
+ * Clear user identity
790
+ * Removes user association from future sessions
791
+ */
792
+ clearUserIdentity(): void {
793
+ _userIdentity = null;
794
+ persistUserIdentity(null).catch(() => { });
795
+
796
+ if (_isRecording && getRejourneyNative()) {
797
+ safeNativeCallSync(
798
+ 'setUserIdentity',
799
+ () => {
800
+ getRejourneyNative()!.setUserIdentity('anonymous').catch(() => { });
801
+ },
802
+ undefined
803
+ );
804
+ }
805
+ },
806
+
807
+ /**
808
+ * Tag the current screen
809
+ *
810
+ * @param screenName - Screen name
811
+ * @param params - Optional screen parameters
812
+ */
813
+ tagScreen(screenName: string, _params?: Record<string, unknown>): void {
814
+ getAutoTracking().trackScreen(screenName);
815
+ getAutoTracking().notifyStateChange();
816
+
817
+ safeNativeCallSync(
818
+ 'tagScreen',
819
+ () => {
820
+ getRejourneyNative()!.screenChanged(screenName).catch(() => { });
821
+ },
822
+ undefined
823
+ );
824
+ },
825
+
826
+ /**
827
+ * Mark a view as sensitive (will be occluded in recordings)
828
+ *
829
+ * @param viewRef - React ref to the view
830
+ * @param occluded - Whether to occlude (default: true)
831
+ */
832
+ setOccluded(_viewRef: { current: any }, _occluded: boolean = true): void {
833
+ // No-op - occlusion handled automatically by native module
834
+ },
835
+
836
+ /**
837
+ * Add a tag to the current session
838
+ *
839
+ * @param tag - Tag string
840
+ */
841
+ addSessionTag(tag: string): void {
842
+ this.logEvent('session_tag', { tag });
843
+ },
844
+
845
+ /**
846
+ * Get all recorded sessions
847
+ *
848
+ * @returns Array of session summaries (always empty - sessions on dashboard server)
849
+ */
850
+ async getSessions(): Promise<SessionSummary[]> {
851
+ return [];
852
+ },
853
+
854
+ /**
855
+ * Get session data for replay
856
+ *
857
+ * @param sessionId - Session ID
858
+ * @returns Session data (not implemented - use dashboard server)
859
+ */
860
+ async getSessionData(_sessionId: string): Promise<SessionData> {
861
+ // Return empty session data - actual data should be fetched from dashboard server
862
+ getLogger().warn('getSessionData not implemented - fetch from dashboard server');
863
+ return {
864
+ metadata: {
865
+ sessionId: _sessionId,
866
+ startTime: 0,
867
+ endTime: 0,
868
+ duration: 0,
869
+ deviceInfo: { model: '', os: 'ios', osVersion: '', screenWidth: 0, screenHeight: 0, pixelRatio: 1 },
870
+ eventCount: 0,
871
+ videoSegmentCount: 0,
872
+ storageSize: 0,
873
+ sdkVersion: SDK_VERSION,
874
+ isComplete: false,
875
+ },
876
+ events: [],
877
+ };
878
+ },
879
+
880
+ /**
881
+ * Delete a session
882
+ *
883
+ * @param sessionId - Session ID
884
+ */
885
+ async deleteSession(_sessionId: string): Promise<void> {
886
+ // No-op - session deletion handled by dashboard server
887
+ },
888
+
889
+ /**
890
+ * Delete all sessions
891
+ */
892
+ async deleteAllSessions(): Promise<void> {
893
+ // No-op - session deletion handled by dashboard server
894
+ },
895
+
896
+ /**
897
+ * Export session for sharing
898
+ *
899
+ * @param sessionId - Session ID
900
+ * @returns Path to export file (not implemented)
901
+ */
902
+ async exportSession(_sessionId: string): Promise<string> {
903
+ getLogger().warn('exportSession not implemented - export from dashboard server');
904
+ return '';
905
+ },
906
+
907
+ /**
908
+ * Check if currently recording
909
+ *
910
+ * @returns Whether recording is active
911
+ */
912
+ async isRecording(): Promise<boolean> {
913
+ return _isRecording;
914
+ },
915
+
916
+ /**
917
+ * Get storage usage
918
+ *
919
+ * @returns Storage usage info (always 0 - storage on dashboard server)
920
+ */
921
+ async getStorageUsage(): Promise<{ used: number; max: number }> {
922
+ return { used: 0, max: 0 };
923
+ },
924
+
925
+
926
+
927
+ /**
928
+ * Mark a visual change that should be captured
929
+ *
930
+ * Use this when your app changes in a visually significant way that should be captured,
931
+ * like showing a success message, updating a cart badge, or displaying an error.
932
+ *
933
+ * @param reason - Description of what changed (e.g., 'cart_updated', 'error_shown')
934
+ * @param importance - How important is this change? 'low', 'medium', 'high', or 'critical'
935
+ *
936
+ * @example
937
+ * ```typescript
938
+ * // Mark that an error was shown (high importance)
939
+ * await Rejourney.markVisualChange('checkout_error', 'high');
940
+ *
941
+ * // Mark that a cart badge updated (medium importance)
942
+ * await Rejourney.markVisualChange('cart_badge_update', 'medium');
943
+ * ```
944
+ */
945
+ async markVisualChange(
946
+ reason: string,
947
+ importance: 'low' | 'medium' | 'high' | 'critical' = 'medium'
948
+ ): Promise<boolean> {
949
+ return safeNativeCall(
950
+ 'markVisualChange',
951
+ async () => {
952
+ await getRejourneyNative()!.markVisualChange(reason, importance);
953
+ return true;
954
+ },
955
+ false
956
+ );
957
+ },
958
+
959
+ /**
960
+ * Report a scroll event for video capture timing
961
+ *
962
+ * Call this from your ScrollView's onScroll handler to improve scroll capture.
963
+ * The SDK captures video at 2 FPS continuously, but this helps log scroll events
964
+ * for timeline correlation during replay.
965
+ *
966
+ * @param scrollOffset - Current scroll offset (vertical or horizontal)
967
+ *
968
+ * @example
969
+ * ```typescript
970
+ * <ScrollView
971
+ * onScroll={(e) => {
972
+ * Rejourney.onScroll(e.nativeEvent.contentOffset.y);
973
+ * }}
974
+ * scrollEventThrottle={16}
975
+ * >
976
+ * {content}
977
+ * </ScrollView>
978
+ * ```
979
+ */
980
+ async onScroll(scrollOffset: number): Promise<void> {
981
+ // Throttle scroll events to reduce native bridge traffic
982
+ // Scroll events can fire at 60fps, but we only need ~10/sec for smooth replay
983
+ const now = Date.now();
984
+ const offsetDelta = Math.abs(scrollOffset - _lastScrollOffset);
985
+
986
+ // Only forward to native if enough time passed OR significant scroll distance
987
+ if (now - _lastScrollTime < SCROLL_THROTTLE_MS && offsetDelta < 50) {
988
+ return;
989
+ }
990
+
991
+ _lastScrollTime = now;
992
+ _lastScrollOffset = scrollOffset;
993
+
994
+ // Track scroll for metrics
995
+ getAutoTracking().trackScroll();
996
+
997
+ await safeNativeCall(
998
+ 'onScroll',
999
+ () => getRejourneyNative()!.onScroll(scrollOffset),
1000
+ undefined
1001
+ );
1002
+ },
1003
+
1004
+ /**
1005
+ * Notify the SDK that an OAuth flow is starting
1006
+ *
1007
+ * Call this before opening an OAuth URL (e.g., before opening Safari for Google/Apple sign-in).
1008
+ * This captures the current screen and marks the session as entering an OAuth flow.
1009
+ *
1010
+ * @param provider - The OAuth provider name (e.g., 'google', 'apple', 'facebook')
1011
+ *
1012
+ * @example
1013
+ * ```typescript
1014
+ * // Before opening OAuth URL
1015
+ * await Rejourney.onOAuthStarted('google');
1016
+ * await WebBrowser.openAuthSessionAsync(authUrl);
1017
+ * ```
1018
+ */
1019
+ async onOAuthStarted(provider: string): Promise<boolean> {
1020
+ return safeNativeCall(
1021
+ 'onOAuthStarted',
1022
+ async () => {
1023
+ await getRejourneyNative()!.onOAuthStarted(provider);
1024
+ return true;
1025
+ },
1026
+ false
1027
+ );
1028
+ },
1029
+
1030
+ /**
1031
+ * Notify the SDK that an OAuth flow has completed
1032
+ *
1033
+ * Call this after the user returns from an OAuth flow (successful or not).
1034
+ * This captures the result screen and logs the OAuth outcome.
1035
+ *
1036
+ * @param provider - The OAuth provider name (e.g., 'google', 'apple', 'facebook')
1037
+ * @param success - Whether the OAuth flow was successful
1038
+ *
1039
+ * @example
1040
+ * ```typescript
1041
+ * // After OAuth returns
1042
+ * const result = await WebBrowser.openAuthSessionAsync(authUrl);
1043
+ * await Rejourney.onOAuthCompleted('google', result.type === 'success');
1044
+ * ```
1045
+ */
1046
+ async onOAuthCompleted(provider: string, success: boolean): Promise<boolean> {
1047
+ return safeNativeCall(
1048
+ 'onOAuthCompleted',
1049
+ async () => {
1050
+ await getRejourneyNative()!.onOAuthCompleted(provider, success);
1051
+ return true;
1052
+ },
1053
+ false
1054
+ );
1055
+ },
1056
+
1057
+ /**
1058
+ * Notify the SDK that an external URL is being opened
1059
+ *
1060
+ * Call this when your app opens an external URL (browser, maps, phone, etc.).
1061
+ * This is automatically detected for app lifecycle events, but you can use this
1062
+ * for more granular tracking.
1063
+ *
1064
+ * @param urlScheme - The URL scheme being opened (e.g., 'https', 'tel', 'maps')
1065
+ *
1066
+ * @example
1067
+ * ```typescript
1068
+ * // Before opening external URL
1069
+ * await Rejourney.onExternalURLOpened('https');
1070
+ * Linking.openURL('https://example.com');
1071
+ * ```
1072
+ */
1073
+ async onExternalURLOpened(urlScheme: string): Promise<boolean> {
1074
+ return safeNativeCall(
1075
+ 'onExternalURLOpened',
1076
+ async () => {
1077
+ await getRejourneyNative()!.onExternalURLOpened(urlScheme);
1078
+ return true;
1079
+ },
1080
+ false
1081
+ );
1082
+ },
1083
+
1084
+ /**
1085
+ * Log a network request for API call timeline tracking
1086
+ *
1087
+ * This is a low-priority, efficient way to track API calls during session replay.
1088
+ * Network requests are stored separately and displayed in a collapsible timeline
1089
+ * in the dashboard for easy correlation with user actions.
1090
+ *
1091
+ * @param request - Network request parameters
1092
+ *
1093
+ * @example
1094
+ * ```typescript
1095
+ * // After a fetch completes
1096
+ * const startTime = Date.now();
1097
+ * const response = await fetch('https://api.example.com/users', {
1098
+ * method: 'POST',
1099
+ * body: JSON.stringify(userData),
1100
+ * });
1101
+ *
1102
+ * Rejourney.logNetworkRequest({
1103
+ * method: 'POST',
1104
+ * url: 'https://api.example.com/users',
1105
+ * statusCode: response.status,
1106
+ * duration: Date.now() - startTime,
1107
+ * requestBodySize: JSON.stringify(userData).length,
1108
+ * responseBodySize: (await response.text()).length,
1109
+ * });
1110
+ * ```
1111
+ */
1112
+ logNetworkRequest(request: NetworkRequestParams): void {
1113
+ safeNativeCallSync(
1114
+ 'logNetworkRequest',
1115
+ () => {
1116
+ // Parse URL for efficient storage and grouping
1117
+ let urlPath = request.url;
1118
+ let urlHost = '';
1119
+ try {
1120
+ const parsedUrl = new URL(request.url);
1121
+ urlHost = parsedUrl.host;
1122
+ urlPath = parsedUrl.pathname + parsedUrl.search;
1123
+ } catch {
1124
+ // If URL parsing fails, use the full URL as path
1125
+ }
1126
+
1127
+ const endTimestamp = request.endTimestamp || Date.now();
1128
+ const startTimestamp = request.startTimestamp || (endTimestamp - request.duration);
1129
+ const success = request.statusCode >= 200 && request.statusCode < 400;
1130
+
1131
+ // Create the network request event
1132
+ const networkEvent = {
1133
+ type: 'network_request',
1134
+ requestId: request.requestId || `req_${startTimestamp}_${Math.random().toString(36).substr(2, 9)}`,
1135
+ timestamp: startTimestamp,
1136
+ method: request.method,
1137
+ url: request.url.length > 500 ? request.url.substring(0, 500) : request.url, // Truncate long URLs
1138
+ urlPath,
1139
+ urlHost,
1140
+ statusCode: request.statusCode,
1141
+ duration: request.duration,
1142
+ endTimestamp,
1143
+ success,
1144
+ requestBodySize: request.requestBodySize,
1145
+ responseBodySize: request.responseBodySize,
1146
+ requestContentType: request.requestContentType,
1147
+ responseContentType: request.responseContentType,
1148
+ errorMessage: request.errorMessage,
1149
+ cached: request.cached,
1150
+ };
1151
+
1152
+ getRejourneyNative()!.logEvent('network_request', networkEvent).catch(() => { });
1153
+ },
1154
+ undefined
1155
+ );
1156
+ },
1157
+
1158
+ /**
1159
+ * Get SDK telemetry metrics for observability
1160
+ *
1161
+ * Returns metrics about SDK health including upload success rates,
1162
+ * retry attempts, circuit breaker events, and memory pressure.
1163
+ *
1164
+ * @returns SDK telemetry metrics
1165
+ *
1166
+ * @example
1167
+ * ```typescript
1168
+ * const metrics = await Rejourney.getSDKMetrics();
1169
+ * console.log(`Upload success rate: ${(metrics.uploadSuccessRate * 100).toFixed(1)}%`);
1170
+ * console.log(`Circuit breaker opens: ${metrics.circuitBreakerOpenCount}`);
1171
+ * ```
1172
+ */
1173
+ async getSDKMetrics(): Promise<SDKMetrics> {
1174
+ return safeNativeCall(
1175
+ 'getSDKMetrics',
1176
+ () => getRejourneyNative()!.getSDKMetrics(),
1177
+ {
1178
+ uploadSuccessCount: 0,
1179
+ uploadFailureCount: 0,
1180
+ retryAttemptCount: 0,
1181
+ circuitBreakerOpenCount: 0,
1182
+ memoryEvictionCount: 0,
1183
+ offlinePersistCount: 0,
1184
+ sessionStartCount: 0,
1185
+ crashCount: 0,
1186
+ uploadSuccessRate: 1.0,
1187
+ avgUploadDurationMs: 0,
1188
+ currentQueueDepth: 0,
1189
+ lastUploadTime: null,
1190
+ lastRetryTime: null,
1191
+ totalBytesUploaded: 0,
1192
+ totalBytesEvicted: 0,
1193
+ }
1194
+ );
1195
+ },
1196
+
1197
+ /**
1198
+ * Trigger a debug ANR (Dev only)
1199
+ * Blocks the main thread for the specified duration
1200
+ */
1201
+ debugTriggerANR(durationMs: number): void {
1202
+ if (__DEV__) {
1203
+ safeNativeCallSync(
1204
+ 'debugTriggerANR',
1205
+ () => {
1206
+ getRejourneyNative()!.debugTriggerANR(durationMs);
1207
+ },
1208
+ undefined
1209
+ );
1210
+ } else {
1211
+ getLogger().warn('debugTriggerANR is only available in development mode');
1212
+ }
1213
+ },
1214
+
1215
+ /**
1216
+ * Mask a view by its nativeID prop (will be occluded in recordings)
1217
+ *
1218
+ * Use this to mask any sensitive content that isn't a text input.
1219
+ * The view must have a `nativeID` prop set.
1220
+ *
1221
+ * @param nativeID - The nativeID prop of the view to mask
1222
+ * @example
1223
+ * ```tsx
1224
+ * // In your component
1225
+ * <View nativeID="sensitiveCard">...</View>
1226
+ *
1227
+ * // To mask it
1228
+ * Rejourney.maskView('sensitiveCard');
1229
+ * ```
1230
+ */
1231
+ maskView(nativeID: string): void {
1232
+ safeNativeCallSync(
1233
+ 'maskView',
1234
+ () => {
1235
+ getRejourneyNative()!.maskViewByNativeID(nativeID).catch(() => { });
1236
+ },
1237
+ undefined
1238
+ );
1239
+ },
1240
+
1241
+ /**
1242
+ * Unmask a view by its nativeID prop
1243
+ *
1244
+ * Removes the mask from a view that was previously masked with maskView().
1245
+ *
1246
+ * @param nativeID - The nativeID prop of the view to unmask
1247
+ */
1248
+ unmaskView(nativeID: string): void {
1249
+ safeNativeCallSync(
1250
+ 'unmaskView',
1251
+ () => {
1252
+ getRejourneyNative()!.unmaskViewByNativeID(nativeID).catch(() => { });
1253
+ },
1254
+ undefined
1255
+ );
1256
+ },
1257
+ };
1258
+
1259
+ /**
1260
+ * Handle app state changes for automatic session management
1261
+ * - Pauses recording when app goes to background
1262
+ * - Resumes recording when app comes back to foreground
1263
+ * - Cleans up properly when app is terminated
1264
+ */
1265
+ function handleAppStateChange(nextAppState: string): void {
1266
+ if (!_isInitialized || _initializationFailed) return;
1267
+
1268
+ try {
1269
+ if (_currentAppState.match(/active/) && nextAppState === 'background') {
1270
+ // App going to background - native module handles this automatically
1271
+ getLogger().logLifecycleEvent('App moving to background');
1272
+ } else if (_currentAppState.match(/inactive|background/) && nextAppState === 'active') {
1273
+ // App coming back to foreground
1274
+ getLogger().logLifecycleEvent('App returning to foreground');
1275
+ }
1276
+ _currentAppState = nextAppState;
1277
+ } catch (error) {
1278
+ getLogger().warn('Error handling app state change:', error);
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Setup automatic lifecycle management
1284
+ * Handles cleanup when the app unmounts or goes to background
1285
+ */
1286
+ function setupLifecycleManagement(): void {
1287
+ if (_sdkDisabled) return;
1288
+
1289
+ const RN = getReactNative();
1290
+ if (!RN) return;
1291
+
1292
+ if (_appStateSubscription) {
1293
+ _appStateSubscription.remove();
1294
+ _appStateSubscription = null;
1295
+ }
1296
+
1297
+ try {
1298
+ _currentAppState = RN.AppState.currentState || 'active';
1299
+ _appStateSubscription = RN.AppState.addEventListener('change', handleAppStateChange);
1300
+ setupAuthErrorListener();
1301
+
1302
+ getLogger().debug('Lifecycle management enabled');
1303
+ } catch (error) {
1304
+ getLogger().warn('Failed to setup lifecycle management:', error);
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Setup listener for authentication errors from native module
1310
+ * This handles security errors like bundle ID mismatch
1311
+ */
1312
+ function setupAuthErrorListener(): void {
1313
+ if (_sdkDisabled) return;
1314
+
1315
+ const RN = getReactNative();
1316
+ if (!RN) return;
1317
+
1318
+ if (_authErrorSubscription) {
1319
+ _authErrorSubscription.remove();
1320
+ _authErrorSubscription = null;
1321
+ }
1322
+
1323
+ try {
1324
+ const nativeModule = getRejourneyNative();
1325
+ if (nativeModule) {
1326
+ const maybeAny = nativeModule as any;
1327
+ const hasEventEmitterHooks =
1328
+ typeof maybeAny?.addListener === 'function' && typeof maybeAny?.removeListeners === 'function';
1329
+
1330
+ const eventEmitter = hasEventEmitterHooks
1331
+ ? new RN.NativeEventEmitter(maybeAny)
1332
+ : new RN.NativeEventEmitter();
1333
+
1334
+ _authErrorSubscription = eventEmitter.addListener(
1335
+ 'rejourneyAuthError',
1336
+ (error: { code: number; message: string; domain: string }) => {
1337
+ getLogger().error('Authentication error from native:', error);
1338
+
1339
+ if (error?.code === 403) {
1340
+ getLogger().logPackageMismatch();
1341
+ } else if (error?.code === 404) {
1342
+ getLogger().logInvalidProjectKey();
1343
+ }
1344
+
1345
+ _isRecording = false;
1346
+
1347
+ if (_storedConfig?.onAuthError) {
1348
+ try {
1349
+ _storedConfig.onAuthError(error);
1350
+ } catch (callbackError) {
1351
+ getLogger().warn('Error in onAuthError callback:', callbackError);
1352
+ }
1353
+ }
1354
+ }
1355
+ );
1356
+ }
1357
+ } catch (error) {
1358
+ getLogger().debug('Auth error listener not available:', error);
1359
+ }
1360
+ }
1361
+
1362
+ /**
1363
+ * Cleanup lifecycle management
1364
+ */
1365
+ function cleanupLifecycleManagement(): void {
1366
+ if (_appStateSubscription) {
1367
+ _appStateSubscription.remove();
1368
+ _appStateSubscription = null;
1369
+ }
1370
+ if (_authErrorSubscription) {
1371
+ _authErrorSubscription.remove();
1372
+ _authErrorSubscription = null;
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * Initialize Rejourney SDK - STEP 1 of 3
1378
+ *
1379
+ * This sets up the SDK, handles attestation, and prepares for recording,
1380
+ * but does NOT start recording automatically. Call startRejourney() after
1381
+ * obtaining user consent to begin recording.
1382
+ *
1383
+ * @param publicRouteKey - Your public route key from the Rejourney dashboard
1384
+ * @param options - Optional configuration options
1385
+ *
1386
+ * @example
1387
+ * ```typescript
1388
+ * import { initRejourney, startRejourney } from 'rejourney';
1389
+ *
1390
+ * // Step 1: Initialize SDK (safe to call on app start)
1391
+ * initRejourney('pk_live_xxxxxxxxxxxx');
1392
+ *
1393
+ * // Step 2: After obtaining user consent
1394
+ * startRejourney();
1395
+ *
1396
+ * // With options
1397
+ * initRejourney('pk_live_xxxxxxxxxxxx', {
1398
+ * debug: true,
1399
+ * apiUrl: 'https://api.yourdomain.com',
1400
+ * projectId: 'your-project-id',
1401
+ * });
1402
+ * ```
1403
+ */
1404
+ export function initRejourney(
1405
+ publicRouteKey: string,
1406
+ options?: Omit<RejourneyConfig, 'publicRouteKey'>
1407
+ ): void {
1408
+ if (!publicRouteKey || typeof publicRouteKey !== 'string') {
1409
+ getLogger().warn('Rejourney: Invalid public route key provided. SDK will be disabled.');
1410
+ _initializationFailed = true;
1411
+ return;
1412
+ }
1413
+
1414
+ _storedConfig = {
1415
+ ...options,
1416
+ publicRouteKey,
1417
+ };
1418
+
1419
+ if (options?.debug) {
1420
+ getLogger().setDebugMode(true);
1421
+ const nativeModule = getRejourneyNative();
1422
+ if (nativeModule) {
1423
+ nativeModule.setDebugMode(true).catch(() => { });
1424
+ }
1425
+ }
1426
+
1427
+ _isInitialized = true;
1428
+
1429
+ (async () => {
1430
+ try {
1431
+ setupLifecycleManagement();
1432
+ getLogger().logObservabilityStart();
1433
+ getLogger().logInitSuccess(SDK_VERSION);
1434
+ } catch (error) {
1435
+ const reason = error instanceof Error ? error.message : String(error);
1436
+ getLogger().logInitFailure(reason);
1437
+ _initializationFailed = true;
1438
+ _isInitialized = false;
1439
+ }
1440
+ })();
1441
+ }
1442
+
1443
+ /**
1444
+ * Start recording - STEP 2 of 3 (call after user consent)
1445
+ *
1446
+ * Begins session recording. Call this after obtaining user consent for recording.
1447
+ *
1448
+ * @example
1449
+ * ```typescript
1450
+ * import { initRejourney, startRejourney } from 'rejourney';
1451
+ *
1452
+ * initRejourney('pk_live_xxxxxxxxxxxx');
1453
+ *
1454
+ * // After user accepts consent dialog
1455
+ * startRejourney();
1456
+ * ```
1457
+ */
1458
+ export function startRejourney(): void {
1459
+ getLogger().debug('startRejourney() called');
1460
+
1461
+ if (!_isInitialized) {
1462
+ getLogger().warn('Not initialized - call initRejourney() first');
1463
+ return;
1464
+ }
1465
+
1466
+ if (_initializationFailed) {
1467
+ getLogger().warn('Initialization failed - cannot start recording');
1468
+ return;
1469
+ }
1470
+
1471
+ getLogger().logRecordingStart();
1472
+ getLogger().debug('Starting session...');
1473
+
1474
+ (async () => {
1475
+ try {
1476
+ const started = await Rejourney._startSession();
1477
+ if (started) {
1478
+ getLogger().debug('✅ Recording started successfully');
1479
+ } else {
1480
+ getLogger().warn('Recording not started');
1481
+ }
1482
+ } catch (error) {
1483
+ getLogger().error('Failed to start recording:', error);
1484
+ }
1485
+ })();
1486
+ }
1487
+
1488
+ /**
1489
+ * Stop recording and cleanup all resources.
1490
+ *
1491
+ * Note: This is usually not needed as the SDK handles cleanup automatically.
1492
+ * Only call this if you want to explicitly stop recording.
1493
+ */
1494
+ export function stopRejourney(): void {
1495
+ try {
1496
+ cleanupLifecycleManagement();
1497
+ Rejourney._stopSession();
1498
+ _isRecording = false;
1499
+ getLogger().debug('Rejourney stopped');
1500
+ } catch (error) {
1501
+ getLogger().warn('Error stopping Rejourney:', error);
1502
+ }
1503
+ }
1504
+
1505
+ export default Rejourney;
1506
+
1507
+ export * from './types';
1508
+
1509
+ export {
1510
+ trackTap,
1511
+ trackScroll,
1512
+ trackGesture,
1513
+ trackInput,
1514
+ trackScreen,
1515
+ captureError,
1516
+ getSessionMetrics,
1517
+ } from './sdk/autoTracking';
1518
+
1519
+ export { trackNavigationState, useNavigationTracking } from './sdk/autoTracking';
1520
+
1521
+ export { LogLevel } from './sdk/utils';
1522
+
1523
+ /**
1524
+ * Configure SDK log verbosity.
1525
+ *
1526
+ * By default, the SDK logs minimally to avoid polluting your app's console:
1527
+ * - Production/Release: SILENT (no logs at all)
1528
+ * - Development/Debug: Only critical errors shown
1529
+ *
1530
+ * Essential lifecycle events (init success, session start/end) are automatically
1531
+ * logged in debug builds only - you don't need to configure anything.
1532
+ *
1533
+ * Use this function only if you need to troubleshoot SDK behavior.
1534
+ *
1535
+ * @param level - Minimum log level to display
1536
+ *
1537
+ * @example
1538
+ * ```typescript
1539
+ * import { setLogLevel, LogLevel } from 'rejourney';
1540
+ *
1541
+ * // Enable verbose logging for SDK debugging (not recommended for regular use)
1542
+ * setLogLevel(LogLevel.DEBUG);
1543
+ *
1544
+ * // Show warnings and errors (for troubleshooting)
1545
+ * setLogLevel(LogLevel.WARNING);
1546
+ *
1547
+ * // Silence all logs (default behavior in production)
1548
+ * setLogLevel(LogLevel.SILENT);
1549
+ * ```
1550
+ */
1551
+ export function setLogLevel(level: number): void {
1552
+ getLogger().setLogLevel(level);
1553
+ }
1554
+
1555
+ export { Mask } from './components/Mask';