@rejourneyco/react-native 1.0.7 → 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 (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -322,7 +322,7 @@ let _storedConfig = null;
322
322
  // Abort recording (fail-closed)
323
323
 
324
324
  let _remoteConfig = null;
325
- let _sessionSampledOut = false; // True = telemetry only, no replay video
325
+ let _sessionSampledOut = false; // True = telemetry only, no visual replay capture
326
326
 
327
327
  /**
328
328
  * Fetch project configuration from backend
@@ -618,7 +618,7 @@ const Rejourney = {
618
618
  // =========================================================
619
619
  _sessionSampledOut = !shouldRecordSession(_remoteConfig.sampleRate ?? 100);
620
620
  if (_sessionSampledOut) {
621
- getLogger().info(`Session sampled out (rate: ${_remoteConfig.sampleRate}%) - telemetry only, no replay video`);
621
+ getLogger().info(`Session sampled out (rate: ${_remoteConfig.sampleRate}%) - telemetry only, no visual replay capture`);
622
622
  }
623
623
 
624
624
  // =========================================================
@@ -680,6 +680,7 @@ const Rejourney = {
680
680
  trackJSErrors: true,
681
681
  trackPromiseRejections: true,
682
682
  trackReactNativeErrors: true,
683
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
683
684
  collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false
684
685
  }, {
685
686
  // Rage tap callback - log as frustration event
@@ -692,13 +693,8 @@ const Rejourney = {
692
693
  });
693
694
  getLogger().logFrustration(`Rage tap (${count} taps)`);
694
695
  },
695
- // Error callback - log as error event
696
+ // Error callback - SDK forwarding is handled in autoTracking.trackError
696
697
  onError: error => {
697
- this.logEvent('error', {
698
- message: error.message,
699
- stack: error.stack,
700
- name: error.name
701
- });
702
698
  getLogger().logError(error.message);
703
699
  },
704
700
  onScreen: (_screenName, _previousScreen) => {}
@@ -713,6 +709,11 @@ const Rejourney = {
713
709
  }
714
710
  if (_storedConfig?.autoTrackNetwork !== false) {
715
711
  try {
712
+ // JS-level fetch/XHR patching is the primary mechanism for capturing network
713
+ // calls within React Native. Native interceptors (RejourneyURLProtocol on iOS,
714
+ // RejourneyNetworkInterceptor on Android) are supplementary — they capture
715
+ // native-originated HTTP calls that bypass JS fetch(), but cannot intercept
716
+ // RN's own networking since it creates its NSURLSession/OkHttpClient at init time.
716
717
  const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', ...(_storedConfig?.networkIgnoreUrls || [])];
717
718
  getNetworkInterceptor().initNetworkInterceptor(request => {
718
719
  getAutoTracking().trackAPIRequest(request.success || false, request.statusCode, request.duration || 0, request.responseBodySize || 0);
@@ -865,7 +866,6 @@ const Rejourney = {
865
866
  pixelRatio: 1
866
867
  },
867
868
  eventCount: 0,
868
- videoSegmentCount: 0,
869
869
  storageSize: 0,
870
870
  sdkVersion: _constants.SDK_VERSION,
871
871
  isComplete: false
@@ -941,10 +941,10 @@ const Rejourney = {
941
941
  }, false);
942
942
  },
943
943
  /**
944
- * Report a scroll event for video capture timing
944
+ * Report a scroll event for visual replay timing
945
945
  *
946
946
  * Call this from your ScrollView's onScroll handler to improve scroll capture.
947
- * The SDK captures video at 2 FPS continuously, but this helps log scroll events
947
+ * The SDK captures visual replay frames continuously, and this helps log scroll events
948
948
  * for timeline correlation during replay.
949
949
  *
950
950
  * @param scrollOffset - Current scroll offset (vertical or horizontal)
@@ -1111,6 +1111,23 @@ const Rejourney = {
1111
1111
  getRejourneyNative().logEvent('network_request', networkEvent).catch(() => {});
1112
1112
  }, undefined);
1113
1113
  },
1114
+ /**
1115
+ * Log customer feedback (e.g. from an in-app survey or NPS widget).
1116
+ *
1117
+ * @param rating - Numeric rating (e.g. 1 to 5)
1118
+ * @param message - Associated feedback text or comment
1119
+ */
1120
+ logFeedback(rating, message) {
1121
+ safeNativeCallSync('logFeedback', () => {
1122
+ const feedbackEvent = {
1123
+ type: 'feedback',
1124
+ timestamp: Date.now(),
1125
+ rating,
1126
+ message
1127
+ };
1128
+ getRejourneyNative().logEvent('feedback', feedbackEvent).catch(() => {});
1129
+ }, undefined);
1130
+ },
1114
1131
  /**
1115
1132
  * Get SDK telemetry metrics for observability
1116
1133
  *
@@ -1146,17 +1163,12 @@ const Rejourney = {
1146
1163
  });
1147
1164
  },
1148
1165
  /**
1149
- * Trigger a debug ANR (Dev only)
1150
- * Blocks the main thread for the specified duration
1166
+ * Trigger an ANR test by blocking the main thread for the specified duration.
1151
1167
  */
1152
1168
  debugTriggerANR(durationMs) {
1153
- if (__DEV__) {
1154
- safeNativeCallSync('debugTriggerANR', () => {
1155
- getRejourneyNative().debugTriggerANR(durationMs);
1156
- }, undefined);
1157
- } else {
1158
- getLogger().warn('debugTriggerANR is only available in development mode');
1159
- }
1169
+ safeNativeCallSync('debugTriggerANR', () => {
1170
+ getRejourneyNative().debugTriggerANR(durationMs);
1171
+ }, undefined);
1160
1172
  },
1161
1173
  /**
1162
1174
  * Mask a view by its nativeID prop (will be occluded in recordings)
@@ -130,6 +130,7 @@ let originalOnError = null;
130
130
  let originalOnUnhandledRejection = null;
131
131
  let originalConsoleError = null;
132
132
  let _promiseRejectionTrackingDisable = null;
133
+ const FATAL_ERROR_FLUSH_DELAY_MS = 1200;
133
134
 
134
135
  /**
135
136
  * Initialize auto tracking features
@@ -144,6 +145,7 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
144
145
  trackJSErrors: true,
145
146
  trackPromiseRejections: true,
146
147
  trackReactNativeErrors: true,
148
+ trackConsoleLogs: true,
147
149
  collectDeviceInfo: true,
148
150
  maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
149
151
  ...trackingConfig
@@ -154,6 +156,9 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
154
156
  onErrorCaptured = callbacks.onError || null;
155
157
  onScreenChange = callbacks.onScreen || null;
156
158
  setupErrorTracking();
159
+ if (config.trackConsoleLogs) {
160
+ setupConsoleTracking();
161
+ }
157
162
  setupNavigationTracking();
158
163
  loadAnonymousId().then(id => {
159
164
  anonymousId = id;
@@ -167,11 +172,13 @@ function initAutoTracking(trackingConfig, callbacks = {}) {
167
172
  function cleanupAutoTracking() {
168
173
  if (!isInitialized) return;
169
174
  restoreErrorHandlers();
175
+ restoreConsoleHandlers();
170
176
  cleanupNavigationTracking();
171
177
 
172
178
  // Reset state
173
179
  tapHead = 0;
174
180
  tapCount = 0;
181
+ consoleLogCount = 0;
175
182
  metrics = createEmptyMetrics();
176
183
  screensVisited = [];
177
184
  currentScreen = '';
@@ -285,7 +292,7 @@ function setupErrorTracking() {
285
292
  /**
286
293
  * Setup React Native ErrorUtils handler
287
294
  *
288
- * CRITICAL FIX: For fatal errors, we delay calling the original handler by 500ms
295
+ * CRITICAL FIX: For fatal errors, we delay calling the original handler briefly
289
296
  * to give the React Native bridge time to flush the logEvent('error') call to the
290
297
  * native TelemetryPipeline. Without this delay, the error event is queued on the
291
298
  * JS→native bridge but the app crashes (via originalErrorHandler) before the bridge
@@ -309,10 +316,10 @@ function setupReactNativeErrorHandler() {
309
316
  if (isFatal) {
310
317
  // For fatal errors, delay the original handler so the native bridge
311
318
  // has time to deliver the error event to TelemetryPipeline before
312
- // the app terminates. 500ms is enough for the bridge to flush.
319
+ // the app terminates.
313
320
  setTimeout(() => {
314
321
  originalErrorHandler(error, isFatal);
315
- }, 500);
322
+ }, FATAL_ERROR_FLUSH_DELAY_MS);
316
323
  } else {
317
324
  originalErrorHandler(error, isFatal);
318
325
  }
@@ -367,7 +374,6 @@ function setupPromiseRejectionHandler() {
367
374
 
368
375
  // Strategy 1: RN-specific promise rejection tracking polyfill
369
376
  try {
370
- // eslint-disable-next-line @typescript-eslint/no-var-requires
371
377
  const tracking = require('promise/setimmediate/rejection-tracking');
372
378
  if (tracking && typeof tracking.enable === 'function') {
373
379
  tracking.enable({
@@ -476,8 +482,27 @@ function restoreErrorHandlers() {
476
482
  function trackError(error) {
477
483
  metrics.errorCount++;
478
484
  metrics.totalEvents++;
485
+ forwardErrorToNative(error);
479
486
  if (onErrorCaptured) {
480
- onErrorCaptured(error);
487
+ try {
488
+ onErrorCaptured(error);
489
+ } catch {
490
+ // Ignore callback exceptions so SDK error forwarding keeps working.
491
+ }
492
+ }
493
+ }
494
+ function forwardErrorToNative(error) {
495
+ try {
496
+ const nativeModule = getRejourneyNativeModule();
497
+ if (!nativeModule || typeof nativeModule.logEvent !== 'function') return;
498
+ nativeModule.logEvent('error', {
499
+ message: error.message,
500
+ stack: error.stack,
501
+ name: error.name || 'Error',
502
+ timestamp: error.timestamp
503
+ }).catch(() => {});
504
+ } catch {
505
+ // Ignore native forwarding failures; SDK should never crash app code.
481
506
  }
482
507
  }
483
508
 
@@ -493,6 +518,83 @@ function captureError(message, stack, name) {
493
518
  name: name || 'Error'
494
519
  });
495
520
  }
521
+ let originalConsoleLog = null;
522
+ let originalConsoleInfo = null;
523
+ let originalConsoleWarn = null;
524
+
525
+ // Cap console logs to prevent flooding the event pipeline
526
+ const MAX_CONSOLE_LOGS_PER_SESSION = 1000;
527
+ let consoleLogCount = 0;
528
+
529
+ /**
530
+ * Setup console tracking to capture log statements
531
+ */
532
+ function setupConsoleTracking() {
533
+ if (typeof console === 'undefined') return;
534
+ if (!originalConsoleLog) originalConsoleLog = console.log;
535
+ if (!originalConsoleInfo) originalConsoleInfo = console.info;
536
+ if (!originalConsoleWarn) originalConsoleWarn = console.warn;
537
+ const createConsoleInterceptor = (level, originalFn) => {
538
+ return (...args) => {
539
+ try {
540
+ const message = args.map(arg => {
541
+ if (typeof arg === 'string') return arg;
542
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}${arg.stack ? `\n...` : ''}`;
543
+ try {
544
+ return JSON.stringify(arg);
545
+ } catch {
546
+ return String(arg);
547
+ }
548
+ }).join(' ');
549
+
550
+ // Enforce per-session cap and skip React Native unhandled-rejection noise.
551
+ if (consoleLogCount < MAX_CONSOLE_LOGS_PER_SESSION && !message.includes('Possible Unhandled Promise Rejection')) {
552
+ consoleLogCount++;
553
+ const nativeModule = getRejourneyNativeModule();
554
+ if (nativeModule) {
555
+ const logEvent = {
556
+ type: 'log',
557
+ timestamp: Date.now(),
558
+ level,
559
+ message: message.length > 2000 ? message.substring(0, 2000) + '...' : message
560
+ };
561
+ nativeModule.logEvent('log', logEvent).catch(() => {});
562
+ }
563
+ }
564
+ } catch {
565
+ // Ignore any errors during interception
566
+ }
567
+ if (originalFn) {
568
+ originalFn.apply(console, args);
569
+ }
570
+ };
571
+ };
572
+ console.log = createConsoleInterceptor('log', originalConsoleLog);
573
+ console.info = createConsoleInterceptor('info', originalConsoleInfo);
574
+ console.warn = createConsoleInterceptor('warn', originalConsoleWarn);
575
+ const currentConsoleError = console.error;
576
+ if (!originalConsoleError) originalConsoleError = currentConsoleError;
577
+ console.error = createConsoleInterceptor('error', currentConsoleError);
578
+ }
579
+
580
+ /**
581
+ * Restore console standard functions
582
+ */
583
+ function restoreConsoleHandlers() {
584
+ if (originalConsoleLog) {
585
+ console.log = originalConsoleLog;
586
+ originalConsoleLog = null;
587
+ }
588
+ if (originalConsoleInfo) {
589
+ console.info = originalConsoleInfo;
590
+ originalConsoleInfo = null;
591
+ }
592
+ if (originalConsoleWarn) {
593
+ console.warn = originalConsoleWarn;
594
+ originalConsoleWarn = null;
595
+ }
596
+ // Note: console.error is restored in restoreErrorHandlers via originalConsoleError
597
+ }
496
598
  let navigationPollingInterval = null;
497
599
  let lastDetectedScreen = '';
498
600
  let navigationSetupDone = false;
@@ -1016,7 +1118,26 @@ async function collectDeviceInfo() {
1016
1118
  function generateAnonymousId() {
1017
1119
  const timestamp = Date.now().toString(36);
1018
1120
  const random = Math.random().toString(36).substring(2, 15);
1019
- return `anon_${timestamp}_${random}`;
1121
+ const id = `anon_${timestamp}_${random}`;
1122
+ // Persist so the same ID survives app restarts
1123
+ _persistAnonymousId(id);
1124
+ return id;
1125
+ }
1126
+
1127
+ /**
1128
+ * Best-effort async persist of anonymous ID to native storage
1129
+ */
1130
+ function _persistAnonymousId(id) {
1131
+ const nativeModule = getRejourneyNativeModule();
1132
+ if (!nativeModule?.setAnonymousId) return;
1133
+ try {
1134
+ const result = nativeModule.setAnonymousId(id);
1135
+ if (result && typeof result.catch === 'function') {
1136
+ result.catch(() => {});
1137
+ }
1138
+ } catch {
1139
+ // Native storage unavailable — ID will still be stable for this session
1140
+ }
1020
1141
  }
1021
1142
 
1022
1143
  /**
@@ -1047,17 +1168,41 @@ async function ensurePersistentAnonymousId() {
1047
1168
 
1048
1169
  /**
1049
1170
  * Load anonymous ID from persistent storage
1050
- * Call this at app startup for best results
1171
+ * Checks native anonymous storage first, then falls back to native getUserIdentity,
1172
+ * and finally generates a new ID if nothing is persisted.
1051
1173
  */
1052
1174
  async function loadAnonymousId() {
1053
1175
  const nativeModule = getRejourneyNativeModule();
1054
- if (nativeModule && nativeModule.getUserIdentity) {
1176
+
1177
+ // 1. Try native anonymous ID storage
1178
+ if (nativeModule?.getAnonymousId) {
1055
1179
  try {
1056
- return (await nativeModule.getUserIdentity()) || generateAnonymousId();
1180
+ const stored = await nativeModule.getAnonymousId();
1181
+ if (stored && typeof stored === 'string') return stored;
1057
1182
  } catch {
1058
- return generateAnonymousId();
1183
+ // Continue to fallbacks
1059
1184
  }
1060
1185
  }
1186
+
1187
+ // 2. Backward compatibility fallback for older native modules
1188
+ if (nativeModule?.getUserIdentity) {
1189
+ try {
1190
+ const nativeId = await nativeModule.getUserIdentity();
1191
+ if (nativeId && typeof nativeId === 'string') {
1192
+ const normalized = nativeId.trim();
1193
+ // Only migrate legacy anonymous identifiers. Never treat explicit user identities
1194
+ // as anonymous fingerprints, or session correlation becomes unstable.
1195
+ if (normalized.startsWith('anon_')) {
1196
+ _persistAnonymousId(normalized);
1197
+ return normalized;
1198
+ }
1199
+ }
1200
+ } catch {
1201
+ // Continue to fallback
1202
+ }
1203
+ }
1204
+
1205
+ // 3. Generate and persist new ID
1061
1206
  return generateAnonymousId();
1062
1207
  }
1063
1208
 
@@ -1065,7 +1210,13 @@ async function loadAnonymousId() {
1065
1210
  * Set a custom anonymous ID
1066
1211
  */
1067
1212
  function setAnonymousId(id) {
1068
- anonymousId = id;
1213
+ const normalized = (id || '').trim();
1214
+ if (!normalized) {
1215
+ anonymousId = generateAnonymousId();
1216
+ return;
1217
+ }
1218
+ anonymousId = normalized;
1219
+ _persistAnonymousId(normalized);
1069
1220
  }
1070
1221
  var _default = exports.default = {
1071
1222
  init: initAutoTracking,
@@ -37,7 +37,7 @@ var _version = require("./version");
37
37
  /** Default configuration values */
38
38
  const DEFAULT_CONFIG = exports.DEFAULT_CONFIG = {
39
39
  enabled: true,
40
- captureFPS: 0.5,
40
+ captureFPS: 1.0,
41
41
  captureOnEvents: true,
42
42
  maxSessionDuration: 10 * 60 * 1000,
43
43
  maxStorageSize: 50 * 1024 * 1024,
@@ -90,7 +90,7 @@ const PLAYBACK_SPEEDS = exports.PLAYBACK_SPEEDS = [0.5, 1, 2, 4];
90
90
 
91
91
  /** Capture settings */
92
92
  const CAPTURE_SETTINGS = exports.CAPTURE_SETTINGS = {
93
- DEFAULT_FPS: 0.5,
93
+ DEFAULT_FPS: 1.0,
94
94
  MIN_FPS: 0.1,
95
95
  MAX_FPS: 2,
96
96
  CAPTURE_SCALE: 0.25,
@@ -61,6 +61,73 @@ const config = {
61
61
  captureSizes: false
62
62
  };
63
63
  const SENSITIVE_KEYS = ['token', 'key', 'secret', 'password', 'auth', 'access_token', 'api_key'];
64
+ function getUtf8Size(text) {
65
+ if (!text) return 0;
66
+ if (typeof TextEncoder !== 'undefined') {
67
+ return new TextEncoder().encode(text).length;
68
+ }
69
+ return text.length;
70
+ }
71
+ function getBodySize(body) {
72
+ if (body == null) return 0;
73
+ if (typeof body === 'string') return getUtf8Size(body);
74
+ if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) {
75
+ return body.byteLength;
76
+ }
77
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(body)) {
78
+ return body.byteLength;
79
+ }
80
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
81
+ return body.size;
82
+ }
83
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
84
+ return getUtf8Size(body.toString());
85
+ }
86
+ return 0;
87
+ }
88
+ async function getFetchResponseSize(response) {
89
+ const contentLength = response.headers?.get?.('content-length');
90
+ if (contentLength) {
91
+ const parsed = parseInt(contentLength, 10);
92
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
93
+ }
94
+ try {
95
+ const cloned = response.clone();
96
+ const buffer = await cloned.arrayBuffer();
97
+ return buffer.byteLength;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+ function getXhrResponseSize(xhr) {
103
+ try {
104
+ const contentLength = xhr.getResponseHeader('content-length');
105
+ if (contentLength) {
106
+ const parsed = parseInt(contentLength, 10);
107
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
108
+ }
109
+ } catch {
110
+ // Ignore header access errors and fall through to body inspection.
111
+ }
112
+ const responseType = xhr.responseType;
113
+ if (responseType === '' || responseType === 'text') {
114
+ return getUtf8Size(xhr.responseText || '');
115
+ }
116
+ if (responseType === 'arraybuffer') {
117
+ return typeof ArrayBuffer !== 'undefined' && xhr.response instanceof ArrayBuffer ? xhr.response.byteLength : 0;
118
+ }
119
+ if (responseType === 'blob') {
120
+ return typeof Blob !== 'undefined' && xhr.response instanceof Blob ? xhr.response.size : 0;
121
+ }
122
+ if (responseType === 'json') {
123
+ try {
124
+ return getUtf8Size(JSON.stringify(xhr.response ?? ''));
125
+ } catch {
126
+ return 0;
127
+ }
128
+ }
129
+ return 0;
130
+ }
64
131
 
65
132
  /**
66
133
  * Scrub sensitive data from URL
@@ -211,7 +278,9 @@ function interceptFetch() {
211
278
  }
212
279
  const startTime = Date.now();
213
280
  const method = (init?.method || 'GET').toUpperCase();
214
- return originalFetch(input, init).then(response => {
281
+ const requestBodySize = config.captureSizes ? getBodySize(init?.body) : 0;
282
+ return originalFetch(input, init).then(async response => {
283
+ const responseBodySize = config.captureSizes ? await getFetchResponseSize(response) : 0;
215
284
  queueRequest({
216
285
  requestId: `f${startTime}`,
217
286
  method,
@@ -220,7 +289,9 @@ function interceptFetch() {
220
289
  duration: Date.now() - startTime,
221
290
  startTimestamp: startTime,
222
291
  endTimestamp: Date.now(),
223
- success: response.ok
292
+ success: response.ok,
293
+ requestBodySize,
294
+ responseBodySize
224
295
  });
225
296
  return response;
226
297
  }, error => {
@@ -233,7 +304,8 @@ function interceptFetch() {
233
304
  startTimestamp: startTime,
234
305
  endTimestamp: Date.now(),
235
306
  success: false,
236
- errorMessage: error?.message || 'Network error'
307
+ errorMessage: error?.message || 'Network error',
308
+ requestBodySize
237
309
  });
238
310
  throw error;
239
311
  });
@@ -268,9 +340,15 @@ function interceptXHR() {
268
340
  if (!shouldSampleRequest(path)) {
269
341
  return originalXHRSend.call(this, body);
270
342
  }
343
+ if (config.captureSizes && body) {
344
+ data.reqSize = getBodySize(body);
345
+ } else {
346
+ data.reqSize = 0;
347
+ }
271
348
  data.t = Date.now();
272
349
  const onComplete = () => {
273
350
  const endTime = Date.now();
351
+ const responseBodySize = config.captureSizes ? getXhrResponseSize(this) : 0;
274
352
  queueRequest({
275
353
  requestId: `x${data.t}`,
276
354
  method: data.m,
@@ -280,7 +358,9 @@ function interceptXHR() {
280
358
  startTimestamp: data.t,
281
359
  endTimestamp: endTime,
282
360
  success: this.status >= 200 && this.status < 400,
283
- errorMessage: this.status === 0 ? 'Network error' : undefined
361
+ errorMessage: this.status === 0 ? 'Network error' : undefined,
362
+ requestBodySize: data.reqSize,
363
+ responseBodySize
284
364
  });
285
365
  };
286
366
  this.addEventListener('load', onComplete);
@@ -322,7 +322,7 @@ class Logger {
322
322
  }
323
323
  }
324
324
  notice(...args) {
325
- if (typeof __DEV__ !== 'undefined' && __DEV__) {
325
+ if (this.minimumLogLevel <= LogLevel.INFO) {
326
326
  console.info(this.prefix, ...args);
327
327
  }
328
328
  }
@@ -214,7 +214,7 @@ let _storedConfig = null;
214
214
  // Abort recording (fail-closed)
215
215
 
216
216
  let _remoteConfig = null;
217
- let _sessionSampledOut = false; // True = telemetry only, no replay video
217
+ let _sessionSampledOut = false; // True = telemetry only, no visual replay capture
218
218
 
219
219
  /**
220
220
  * Fetch project configuration from backend
@@ -510,7 +510,7 @@ const Rejourney = {
510
510
  // =========================================================
511
511
  _sessionSampledOut = !shouldRecordSession(_remoteConfig.sampleRate ?? 100);
512
512
  if (_sessionSampledOut) {
513
- getLogger().info(`Session sampled out (rate: ${_remoteConfig.sampleRate}%) - telemetry only, no replay video`);
513
+ getLogger().info(`Session sampled out (rate: ${_remoteConfig.sampleRate}%) - telemetry only, no visual replay capture`);
514
514
  }
515
515
 
516
516
  // =========================================================
@@ -572,6 +572,7 @@ const Rejourney = {
572
572
  trackJSErrors: true,
573
573
  trackPromiseRejections: true,
574
574
  trackReactNativeErrors: true,
575
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
575
576
  collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false
576
577
  }, {
577
578
  // Rage tap callback - log as frustration event
@@ -584,13 +585,8 @@ const Rejourney = {
584
585
  });
585
586
  getLogger().logFrustration(`Rage tap (${count} taps)`);
586
587
  },
587
- // Error callback - log as error event
588
+ // Error callback - SDK forwarding is handled in autoTracking.trackError
588
589
  onError: error => {
589
- this.logEvent('error', {
590
- message: error.message,
591
- stack: error.stack,
592
- name: error.name
593
- });
594
590
  getLogger().logError(error.message);
595
591
  },
596
592
  onScreen: (_screenName, _previousScreen) => {}
@@ -605,6 +601,11 @@ const Rejourney = {
605
601
  }
606
602
  if (_storedConfig?.autoTrackNetwork !== false) {
607
603
  try {
604
+ // JS-level fetch/XHR patching is the primary mechanism for capturing network
605
+ // calls within React Native. Native interceptors (RejourneyURLProtocol on iOS,
606
+ // RejourneyNetworkInterceptor on Android) are supplementary — they capture
607
+ // native-originated HTTP calls that bypass JS fetch(), but cannot intercept
608
+ // RN's own networking since it creates its NSURLSession/OkHttpClient at init time.
608
609
  const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', ...(_storedConfig?.networkIgnoreUrls || [])];
609
610
  getNetworkInterceptor().initNetworkInterceptor(request => {
610
611
  getAutoTracking().trackAPIRequest(request.success || false, request.statusCode, request.duration || 0, request.responseBodySize || 0);
@@ -757,7 +758,6 @@ const Rejourney = {
757
758
  pixelRatio: 1
758
759
  },
759
760
  eventCount: 0,
760
- videoSegmentCount: 0,
761
761
  storageSize: 0,
762
762
  sdkVersion: SDK_VERSION,
763
763
  isComplete: false
@@ -833,10 +833,10 @@ const Rejourney = {
833
833
  }, false);
834
834
  },
835
835
  /**
836
- * Report a scroll event for video capture timing
836
+ * Report a scroll event for visual replay timing
837
837
  *
838
838
  * Call this from your ScrollView's onScroll handler to improve scroll capture.
839
- * The SDK captures video at 2 FPS continuously, but this helps log scroll events
839
+ * The SDK captures visual replay frames continuously, and this helps log scroll events
840
840
  * for timeline correlation during replay.
841
841
  *
842
842
  * @param scrollOffset - Current scroll offset (vertical or horizontal)
@@ -1003,6 +1003,23 @@ const Rejourney = {
1003
1003
  getRejourneyNative().logEvent('network_request', networkEvent).catch(() => {});
1004
1004
  }, undefined);
1005
1005
  },
1006
+ /**
1007
+ * Log customer feedback (e.g. from an in-app survey or NPS widget).
1008
+ *
1009
+ * @param rating - Numeric rating (e.g. 1 to 5)
1010
+ * @param message - Associated feedback text or comment
1011
+ */
1012
+ logFeedback(rating, message) {
1013
+ safeNativeCallSync('logFeedback', () => {
1014
+ const feedbackEvent = {
1015
+ type: 'feedback',
1016
+ timestamp: Date.now(),
1017
+ rating,
1018
+ message
1019
+ };
1020
+ getRejourneyNative().logEvent('feedback', feedbackEvent).catch(() => {});
1021
+ }, undefined);
1022
+ },
1006
1023
  /**
1007
1024
  * Get SDK telemetry metrics for observability
1008
1025
  *
@@ -1038,17 +1055,12 @@ const Rejourney = {
1038
1055
  });
1039
1056
  },
1040
1057
  /**
1041
- * Trigger a debug ANR (Dev only)
1042
- * Blocks the main thread for the specified duration
1058
+ * Trigger an ANR test by blocking the main thread for the specified duration.
1043
1059
  */
1044
1060
  debugTriggerANR(durationMs) {
1045
- if (__DEV__) {
1046
- safeNativeCallSync('debugTriggerANR', () => {
1047
- getRejourneyNative().debugTriggerANR(durationMs);
1048
- }, undefined);
1049
- } else {
1050
- getLogger().warn('debugTriggerANR is only available in development mode');
1051
- }
1061
+ safeNativeCallSync('debugTriggerANR', () => {
1062
+ getRejourneyNative().debugTriggerANR(durationMs);
1063
+ }, undefined);
1052
1064
  },
1053
1065
  /**
1054
1066
  * Mask a view by its nativeID prop (will be occluded in recordings)