@rejourneyco/react-native 1.0.8 → 1.0.9

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 (42) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
  2. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  3. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  4. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  5. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
  9. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  10. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
  11. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  12. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  13. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  14. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  15. package/ios/Engine/DeviceRegistrar.swift +13 -3
  16. package/ios/Engine/RejourneyImpl.swift +199 -115
  17. package/ios/Recording/AnrSentinel.swift +58 -25
  18. package/ios/Recording/InteractionRecorder.swift +1 -0
  19. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  20. package/ios/Recording/ReplayOrchestrator.swift +204 -143
  21. package/ios/Recording/SegmentDispatcher.swift +8 -0
  22. package/ios/Recording/StabilityMonitor.swift +40 -32
  23. package/ios/Recording/TelemetryPipeline.swift +17 -0
  24. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  25. package/ios/Recording/VisualCapture.swift +54 -8
  26. package/ios/Rejourney.mm +27 -8
  27. package/ios/Utility/ImageBlur.swift +0 -1
  28. package/lib/commonjs/index.js +28 -15
  29. package/lib/commonjs/sdk/autoTracking.js +162 -11
  30. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  31. package/lib/module/index.js +28 -15
  32. package/lib/module/sdk/autoTracking.js +162 -11
  33. package/lib/module/sdk/networkInterceptor.js +84 -4
  34. package/lib/typescript/NativeRejourney.d.ts +5 -2
  35. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  36. package/lib/typescript/types/index.d.ts +14 -2
  37. package/package.json +4 -4
  38. package/src/NativeRejourney.ts +8 -5
  39. package/src/index.ts +37 -19
  40. package/src/sdk/autoTracking.ts +176 -11
  41. package/src/sdk/networkInterceptor.ts +110 -1
  42. package/src/types/index.ts +15 -3
@@ -142,6 +142,7 @@ export interface AutoTrackingConfig {
142
142
  trackJSErrors?: boolean;
143
143
  trackPromiseRejections?: boolean;
144
144
  trackReactNativeErrors?: boolean;
145
+ trackConsoleLogs?: boolean;
145
146
  collectDeviceInfo?: boolean;
146
147
  maxSessionDurationMs?: number;
147
148
  detectDeadTaps?: boolean;
@@ -183,6 +184,7 @@ let originalOnError: OnErrorEventHandler | null = null;
183
184
  let originalOnUnhandledRejection: ((event: PromiseRejectionEvent) => void) | null = null;
184
185
  let originalConsoleError: ((...args: any[]) => void) | null = null;
185
186
  let _promiseRejectionTrackingDisable: (() => void) | null = null;
187
+ const FATAL_ERROR_FLUSH_DELAY_MS = 1200;
186
188
 
187
189
  /**
188
190
  * Initialize auto tracking features
@@ -205,6 +207,7 @@ export function initAutoTracking(
205
207
  trackJSErrors: true,
206
208
  trackPromiseRejections: true,
207
209
  trackReactNativeErrors: true,
210
+ trackConsoleLogs: true,
208
211
  collectDeviceInfo: true,
209
212
  maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
210
213
  ...trackingConfig,
@@ -221,6 +224,9 @@ export function initAutoTracking(
221
224
  onErrorCaptured = callbacks.onError || null;
222
225
  onScreenChange = callbacks.onScreen || null;
223
226
  setupErrorTracking();
227
+ if (config.trackConsoleLogs) {
228
+ setupConsoleTracking();
229
+ }
224
230
  setupNavigationTracking();
225
231
  loadAnonymousId().then(id => {
226
232
  anonymousId = id;
@@ -236,11 +242,13 @@ export function cleanupAutoTracking(): void {
236
242
  if (!isInitialized) return;
237
243
 
238
244
  restoreErrorHandlers();
245
+ restoreConsoleHandlers();
239
246
  cleanupNavigationTracking();
240
247
 
241
248
  // Reset state
242
249
  tapHead = 0;
243
250
  tapCount = 0;
251
+ consoleLogCount = 0;
244
252
  metrics = createEmptyMetrics();
245
253
  screensVisited = [];
246
254
  currentScreen = '';
@@ -357,7 +365,7 @@ function setupErrorTracking(): void {
357
365
  /**
358
366
  * Setup React Native ErrorUtils handler
359
367
  *
360
- * CRITICAL FIX: For fatal errors, we delay calling the original handler by 500ms
368
+ * CRITICAL FIX: For fatal errors, we delay calling the original handler briefly
361
369
  * to give the React Native bridge time to flush the logEvent('error') call to the
362
370
  * native TelemetryPipeline. Without this delay, the error event is queued on the
363
371
  * JS→native bridge but the app crashes (via originalErrorHandler) before the bridge
@@ -384,10 +392,10 @@ function setupReactNativeErrorHandler(): void {
384
392
  if (isFatal) {
385
393
  // For fatal errors, delay the original handler so the native bridge
386
394
  // has time to deliver the error event to TelemetryPipeline before
387
- // the app terminates. 500ms is enough for the bridge to flush.
395
+ // the app terminates.
388
396
  setTimeout(() => {
389
397
  originalErrorHandler!(error, isFatal);
390
- }, 500);
398
+ }, FATAL_ERROR_FLUSH_DELAY_MS);
391
399
  } else {
392
400
  originalErrorHandler(error, isFatal);
393
401
  }
@@ -450,7 +458,6 @@ function setupPromiseRejectionHandler(): void {
450
458
 
451
459
  // Strategy 1: RN-specific promise rejection tracking polyfill
452
460
  try {
453
- // eslint-disable-next-line @typescript-eslint/no-var-requires
454
461
  const tracking = require('promise/setimmediate/rejection-tracking');
455
462
  if (tracking && typeof tracking.enable === 'function') {
456
463
  tracking.enable({
@@ -565,8 +572,30 @@ function trackError(error: ErrorEvent): void {
565
572
  metrics.errorCount++;
566
573
  metrics.totalEvents++;
567
574
 
575
+ forwardErrorToNative(error);
576
+
568
577
  if (onErrorCaptured) {
569
- onErrorCaptured(error);
578
+ try {
579
+ onErrorCaptured(error);
580
+ } catch {
581
+ // Ignore callback exceptions so SDK error forwarding keeps working.
582
+ }
583
+ }
584
+ }
585
+
586
+ function forwardErrorToNative(error: ErrorEvent): void {
587
+ try {
588
+ const nativeModule = getRejourneyNativeModule();
589
+ if (!nativeModule || typeof nativeModule.logEvent !== 'function') return;
590
+
591
+ nativeModule.logEvent('error', {
592
+ message: error.message,
593
+ stack: error.stack,
594
+ name: error.name || 'Error',
595
+ timestamp: error.timestamp,
596
+ }).catch(() => { });
597
+ } catch {
598
+ // Ignore native forwarding failures; SDK should never crash app code.
570
599
  }
571
600
  }
572
601
 
@@ -587,6 +616,92 @@ export function captureError(
587
616
  });
588
617
  }
589
618
 
619
+ let originalConsoleLog: ((...args: any[]) => void) | null = null;
620
+ let originalConsoleInfo: ((...args: any[]) => void) | null = null;
621
+ let originalConsoleWarn: ((...args: any[]) => void) | null = null;
622
+
623
+ // Cap console logs to prevent flooding the event pipeline
624
+ const MAX_CONSOLE_LOGS_PER_SESSION = 1000;
625
+ let consoleLogCount = 0;
626
+
627
+ /**
628
+ * Setup console tracking to capture log statements
629
+ */
630
+ function setupConsoleTracking(): void {
631
+ if (typeof console === 'undefined') return;
632
+
633
+ if (!originalConsoleLog) originalConsoleLog = console.log;
634
+ if (!originalConsoleInfo) originalConsoleInfo = console.info;
635
+ if (!originalConsoleWarn) originalConsoleWarn = console.warn;
636
+
637
+ const createConsoleInterceptor = (level: 'log' | 'info' | 'warn' | 'error', originalFn: (...args: any[]) => void) => {
638
+ return (...args: any[]) => {
639
+ try {
640
+ const message = args.map(arg => {
641
+ if (typeof arg === 'string') return arg;
642
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}${arg.stack ? `\n...` : ''}`;
643
+ try {
644
+ return JSON.stringify(arg);
645
+ } catch {
646
+ return String(arg);
647
+ }
648
+ }).join(' ');
649
+
650
+ // Enforce per-session cap and skip React Native unhandled-rejection noise.
651
+ if (
652
+ consoleLogCount < MAX_CONSOLE_LOGS_PER_SESSION &&
653
+ !message.includes('Possible Unhandled Promise Rejection')
654
+ ) {
655
+ consoleLogCount++;
656
+ const nativeModule = getRejourneyNativeModule();
657
+ if (nativeModule) {
658
+ const logEvent = {
659
+ type: 'log',
660
+ timestamp: Date.now(),
661
+ level,
662
+ message: message.length > 2000 ? message.substring(0, 2000) + '...' : message,
663
+ };
664
+ nativeModule.logEvent('log', logEvent).catch(() => { });
665
+ }
666
+ }
667
+ } catch {
668
+ // Ignore any errors during interception
669
+ }
670
+
671
+ if (originalFn) {
672
+ originalFn.apply(console, args);
673
+ }
674
+ };
675
+ };
676
+
677
+ console.log = createConsoleInterceptor('log', originalConsoleLog!);
678
+ console.info = createConsoleInterceptor('info', originalConsoleInfo!);
679
+ console.warn = createConsoleInterceptor('warn', originalConsoleWarn!);
680
+
681
+ const currentConsoleError = console.error;
682
+ if (!originalConsoleError) originalConsoleError = currentConsoleError;
683
+ console.error = createConsoleInterceptor('error', currentConsoleError);
684
+ }
685
+
686
+ /**
687
+ * Restore console standard functions
688
+ */
689
+ function restoreConsoleHandlers(): void {
690
+ if (originalConsoleLog) {
691
+ console.log = originalConsoleLog;
692
+ originalConsoleLog = null;
693
+ }
694
+ if (originalConsoleInfo) {
695
+ console.info = originalConsoleInfo;
696
+ originalConsoleInfo = null;
697
+ }
698
+ if (originalConsoleWarn) {
699
+ console.warn = originalConsoleWarn;
700
+ originalConsoleWarn = null;
701
+ }
702
+ // Note: console.error is restored in restoreErrorHandlers via originalConsoleError
703
+ }
704
+
590
705
  let navigationPollingInterval: ReturnType<typeof setInterval> | null = null;
591
706
  let lastDetectedScreen = '';
592
707
  let navigationSetupDone = false;
@@ -1174,7 +1289,27 @@ export async function collectDeviceInfo(): Promise<DeviceInfo> {
1174
1289
  function generateAnonymousId(): string {
1175
1290
  const timestamp = Date.now().toString(36);
1176
1291
  const random = Math.random().toString(36).substring(2, 15);
1177
- return `anon_${timestamp}_${random}`;
1292
+ const id = `anon_${timestamp}_${random}`;
1293
+ // Persist so the same ID survives app restarts
1294
+ _persistAnonymousId(id);
1295
+ return id;
1296
+ }
1297
+
1298
+ /**
1299
+ * Best-effort async persist of anonymous ID to native storage
1300
+ */
1301
+ function _persistAnonymousId(id: string): void {
1302
+ const nativeModule = getRejourneyNativeModule();
1303
+ if (!nativeModule?.setAnonymousId) return;
1304
+
1305
+ try {
1306
+ const result = nativeModule.setAnonymousId(id);
1307
+ if (result && typeof result.catch === 'function') {
1308
+ result.catch(() => { });
1309
+ }
1310
+ } catch {
1311
+ // Native storage unavailable — ID will still be stable for this session
1312
+ }
1178
1313
  }
1179
1314
 
1180
1315
  /**
@@ -1205,17 +1340,41 @@ export async function ensurePersistentAnonymousId(): Promise<string> {
1205
1340
 
1206
1341
  /**
1207
1342
  * Load anonymous ID from persistent storage
1208
- * Call this at app startup for best results
1343
+ * Checks native anonymous storage first, then falls back to native getUserIdentity,
1344
+ * and finally generates a new ID if nothing is persisted.
1209
1345
  */
1210
1346
  export async function loadAnonymousId(): Promise<string> {
1211
1347
  const nativeModule = getRejourneyNativeModule();
1212
- if (nativeModule && nativeModule.getUserIdentity) {
1348
+
1349
+ // 1. Try native anonymous ID storage
1350
+ if (nativeModule?.getAnonymousId) {
1213
1351
  try {
1214
- return await nativeModule.getUserIdentity() || generateAnonymousId();
1352
+ const stored = await nativeModule.getAnonymousId();
1353
+ if (stored && typeof stored === 'string') return stored;
1215
1354
  } catch {
1216
- return generateAnonymousId();
1355
+ // Continue to fallbacks
1217
1356
  }
1218
1357
  }
1358
+
1359
+ // 2. Backward compatibility fallback for older native modules
1360
+ if (nativeModule?.getUserIdentity) {
1361
+ try {
1362
+ const nativeId = await nativeModule.getUserIdentity();
1363
+ if (nativeId && typeof nativeId === 'string') {
1364
+ const normalized = nativeId.trim();
1365
+ // Only migrate legacy anonymous identifiers. Never treat explicit user identities
1366
+ // as anonymous fingerprints, or session correlation becomes unstable.
1367
+ if (normalized.startsWith('anon_')) {
1368
+ _persistAnonymousId(normalized);
1369
+ return normalized;
1370
+ }
1371
+ }
1372
+ } catch {
1373
+ // Continue to fallback
1374
+ }
1375
+ }
1376
+
1377
+ // 3. Generate and persist new ID
1219
1378
  return generateAnonymousId();
1220
1379
  }
1221
1380
 
@@ -1223,7 +1382,13 @@ export async function loadAnonymousId(): Promise<string> {
1223
1382
  * Set a custom anonymous ID
1224
1383
  */
1225
1384
  export function setAnonymousId(id: string): void {
1226
- anonymousId = id;
1385
+ const normalized = (id || '').trim();
1386
+ if (!normalized) {
1387
+ anonymousId = generateAnonymousId();
1388
+ return;
1389
+ }
1390
+ anonymousId = normalized;
1391
+ _persistAnonymousId(normalized);
1227
1392
  }
1228
1393
 
1229
1394
  export default {
@@ -59,6 +59,94 @@ const config = {
59
59
 
60
60
  const SENSITIVE_KEYS = ['token', 'key', 'secret', 'password', 'auth', 'access_token', 'api_key'];
61
61
 
62
+ function getUtf8Size(text: string): number {
63
+ if (!text) return 0;
64
+ if (typeof TextEncoder !== 'undefined') {
65
+ return new TextEncoder().encode(text).length;
66
+ }
67
+ return text.length;
68
+ }
69
+
70
+ function getBodySize(body: unknown): number {
71
+ if (body == null) return 0;
72
+
73
+ if (typeof body === 'string') return getUtf8Size(body);
74
+
75
+ if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) {
76
+ return body.byteLength;
77
+ }
78
+
79
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body as any)) {
80
+ return (body as ArrayBufferView).byteLength;
81
+ }
82
+
83
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
84
+ return body.size;
85
+ }
86
+
87
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
88
+ return getUtf8Size(body.toString());
89
+ }
90
+
91
+ return 0;
92
+ }
93
+
94
+ async function getFetchResponseSize(response: Response): Promise<number> {
95
+ const contentLength = response.headers?.get?.('content-length');
96
+ if (contentLength) {
97
+ const parsed = parseInt(contentLength, 10);
98
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
99
+ }
100
+
101
+ try {
102
+ const cloned = response.clone();
103
+ const buffer = await cloned.arrayBuffer();
104
+ return buffer.byteLength;
105
+ } catch {
106
+ return 0;
107
+ }
108
+ }
109
+
110
+ function getXhrResponseSize(xhr: XMLHttpRequest): number {
111
+ try {
112
+ const contentLength = xhr.getResponseHeader('content-length');
113
+ if (contentLength) {
114
+ const parsed = parseInt(contentLength, 10);
115
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
116
+ }
117
+ } catch {
118
+ // Ignore header access errors and fall through to body inspection.
119
+ }
120
+
121
+ const responseType = xhr.responseType;
122
+
123
+ if (responseType === '' || responseType === 'text') {
124
+ return getUtf8Size(xhr.responseText || '');
125
+ }
126
+
127
+ if (responseType === 'arraybuffer') {
128
+ return typeof ArrayBuffer !== 'undefined' && xhr.response instanceof ArrayBuffer
129
+ ? xhr.response.byteLength
130
+ : 0;
131
+ }
132
+
133
+ if (responseType === 'blob') {
134
+ return typeof Blob !== 'undefined' && xhr.response instanceof Blob
135
+ ? xhr.response.size
136
+ : 0;
137
+ }
138
+
139
+ if (responseType === 'json') {
140
+ try {
141
+ return getUtf8Size(JSON.stringify(xhr.response ?? ''));
142
+ } catch {
143
+ return 0;
144
+ }
145
+ }
146
+
147
+ return 0;
148
+ }
149
+
62
150
  /**
63
151
  * Scrub sensitive data from URL
64
152
  */
@@ -225,8 +313,15 @@ function interceptFetch(): void {
225
313
 
226
314
  const startTime = Date.now();
227
315
  const method = ((init?.method || 'GET').toUpperCase()) as NetworkRequestParams['method'];
316
+
317
+ const requestBodySize = config.captureSizes ? getBodySize(init?.body) : 0;
318
+
228
319
  return originalFetch!(input, init).then(
229
- (response) => {
320
+ async (response) => {
321
+ const responseBodySize = config.captureSizes
322
+ ? await getFetchResponseSize(response)
323
+ : 0;
324
+
230
325
  queueRequest({
231
326
  requestId: `f${startTime}`,
232
327
  method,
@@ -236,6 +331,8 @@ function interceptFetch(): void {
236
331
  startTimestamp: startTime,
237
332
  endTimestamp: Date.now(),
238
333
  success: response.ok,
334
+ requestBodySize,
335
+ responseBodySize,
239
336
  });
240
337
  return response;
241
338
  },
@@ -250,6 +347,7 @@ function interceptFetch(): void {
250
347
  endTimestamp: Date.now(),
251
348
  success: false,
252
349
  errorMessage: error?.message || 'Network error',
350
+ requestBodySize,
253
351
  });
254
352
  throw error;
255
353
  }
@@ -296,10 +394,19 @@ function interceptXHR(): void {
296
394
  return originalXHRSend!.call(this, body);
297
395
  }
298
396
 
397
+ if (config.captureSizes && body) {
398
+ data.reqSize = getBodySize(body);
399
+ } else {
400
+ data.reqSize = 0;
401
+ }
402
+
299
403
  data.t = Date.now();
300
404
 
301
405
  const onComplete = () => {
302
406
  const endTime = Date.now();
407
+
408
+ const responseBodySize = config.captureSizes ? getXhrResponseSize(this) : 0;
409
+
303
410
  queueRequest({
304
411
  requestId: `x${data.t}`,
305
412
  method: data.m as NetworkRequestParams['method'],
@@ -310,6 +417,8 @@ function interceptXHR(): void {
310
417
  endTimestamp: endTime,
311
418
  success: this.status >= 200 && this.status < 400,
312
419
  errorMessage: this.status === 0 ? 'Network error' : undefined,
420
+ requestBodySize: data.reqSize,
421
+ responseBodySize,
313
422
  });
314
423
  };
315
424
 
@@ -102,8 +102,12 @@ export interface RejourneyConfig {
102
102
  * Disable if you want minimal network tracking overhead.
103
103
  */
104
104
  networkCaptureSizes?: boolean;
105
+ /**
106
+ * Automatically intercept console.log/info/warn/error and include them in session recordings.
107
+ * Useful for debugging sessions. Capped at 1,000 logs per session. (default: true)
108
+ */
109
+ trackConsoleLogs?: boolean;
105
110
  }
106
-
107
111
  export type GestureType =
108
112
  | 'tap'
109
113
  | 'double_tap'
@@ -577,16 +581,24 @@ export interface RejourneyAPI {
577
581
  /** Get storage usage */
578
582
  getStorageUsage(): Promise<{ used: number; max: number }>;
579
583
 
584
+ /**
585
+ * Log customer feedback (e.g. from an in-app survey or NPS widget).
586
+ *
587
+ * @param rating - Numeric rating (e.g. 1 to 5)
588
+ * @param message - Associated feedback text or comment
589
+ */
590
+ logFeedback(rating: number, message: string): void;
591
+
580
592
  /**
581
593
  * Get SDK telemetry metrics for observability
594
+
582
595
  * Returns metrics about SDK health including upload success rates,
583
596
  * retry attempts, circuit breaker events, and memory pressure.
584
597
  */
585
598
  getSDKMetrics(): Promise<SDKMetrics>;
586
599
 
587
600
  /**
588
- * Trigger a debug ANR (Dev only)
589
- * Blocks the main thread for the specified duration
601
+ * Trigger an ANR test by blocking the main thread for the specified duration.
590
602
  */
591
603
  debugTriggerANR(durationMs: number): void;
592
604