@rejourneyco/react-native 1.0.8 → 1.0.10

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 +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. package/src/types/index.ts +58 -6
@@ -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);
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Optional Expo Router integration for @rejourneyco/react-native
3
+ *
4
+ * This file is only loaded when you import '@rejourneyco/react-native/expo-router'.
5
+ * It contains require('expo-router') and related subpaths. Metro bundles require()
6
+ * at build time, so keeping this in a separate entry ensures apps that use
7
+ * Expo with react-navigation (without expo-router) never pull in expo-router
8
+ * and avoid "Requiring unknown module" crashes.
9
+ *
10
+ * If you use expo-router, add this once (e.g. in your root _layout.tsx):
11
+ * import '@rejourneyco/react-native/expo-router';
12
+ */
13
+
14
+ import { trackScreen, setExpoRouterPollingInterval, isExpoRouterTrackingEnabled } from './sdk/autoTracking';
15
+ import { normalizeScreenName, getScreenNameFromPath } from './sdk/navigation';
16
+ const MAX_POLLING_ERRORS = 10;
17
+ function extractScreenNameFromRouterState(state, getScreenNameFromPathFn, normalizeScreenNameFn, accumulatedSegments = []) {
18
+ if (!state?.routes) return null;
19
+ const route = state.routes[state.index ?? state.routes.length - 1];
20
+ if (!route) return null;
21
+ const newSegments = [...accumulatedSegments, route.name];
22
+ if (route.state) {
23
+ return extractScreenNameFromRouterState(route.state, getScreenNameFromPathFn, normalizeScreenNameFn, newSegments);
24
+ }
25
+ const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
26
+ if (cleanSegments.length === 0) {
27
+ for (let i = newSegments.length - 1; i >= 0; i--) {
28
+ const seg = newSegments[i];
29
+ if (seg && !seg.startsWith('(') && !seg.endsWith(')')) {
30
+ cleanSegments.push(seg);
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ const pathname = '/' + cleanSegments.join('/');
36
+ return getScreenNameFromPathFn(pathname, newSegments);
37
+ }
38
+ function setupExpoRouterPolling() {
39
+ let lastDetectedScreen = '';
40
+ let pollingErrors = 0;
41
+ try {
42
+ const EXPO_ROUTER = 'expo-router';
43
+ const expoRouter = require(EXPO_ROUTER);
44
+ const router = expoRouter.router;
45
+ if (!router) {
46
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
47
+ console.debug('[Rejourney] Expo Router: router object not found');
48
+ }
49
+ return;
50
+ }
51
+ const intervalId = setInterval(() => {
52
+ try {
53
+ let state = null;
54
+ if (typeof router.getState === 'function') {
55
+ state = router.getState();
56
+ } else if (router.rootState) {
57
+ state = router.rootState;
58
+ }
59
+ if (!state) {
60
+ try {
61
+ const STORE_PATH = 'expo-router/build/global-state/router-store';
62
+ const storeModule = require(STORE_PATH);
63
+ if (storeModule?.store) {
64
+ state = storeModule.store.state;
65
+ if (!state && storeModule.store.navigationRef?.current) {
66
+ state = storeModule.store.navigationRef.current.getRootState?.();
67
+ }
68
+ if (!state) {
69
+ state = storeModule.store.rootState || storeModule.store.initialState;
70
+ }
71
+ }
72
+ } catch {
73
+ // Ignore
74
+ }
75
+ }
76
+ if (!state) {
77
+ try {
78
+ const IMPERATIVE_PATH = 'expo-router/build/imperative-api';
79
+ const imperative = require(IMPERATIVE_PATH);
80
+ if (imperative?.router) {
81
+ state = imperative.router.getState?.();
82
+ }
83
+ } catch {
84
+ // Ignore
85
+ }
86
+ }
87
+ if (state) {
88
+ pollingErrors = 0;
89
+ const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
90
+ if (screenName && screenName !== lastDetectedScreen) {
91
+ lastDetectedScreen = screenName;
92
+ trackScreen(screenName);
93
+ }
94
+ } else {
95
+ pollingErrors++;
96
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
97
+ clearInterval(intervalId);
98
+ setExpoRouterPollingInterval(null);
99
+ }
100
+ }
101
+ } catch {
102
+ pollingErrors++;
103
+ if (pollingErrors >= MAX_POLLING_ERRORS) {
104
+ clearInterval(intervalId);
105
+ setExpoRouterPollingInterval(null);
106
+ }
107
+ }
108
+ }, 500);
109
+ setExpoRouterPollingInterval(intervalId);
110
+ } catch (e) {
111
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
112
+ console.debug('[Rejourney] Expo Router not available:', e);
113
+ }
114
+ }
115
+ }
116
+ let attempts = 0;
117
+ const maxAttempts = 5;
118
+ function trySetup() {
119
+ attempts++;
120
+ try {
121
+ const EXPO_ROUTER = 'expo-router';
122
+ const expoRouter = require(EXPO_ROUTER);
123
+ if (expoRouter?.router && isExpoRouterTrackingEnabled()) {
124
+ setupExpoRouterPolling();
125
+ return;
126
+ }
127
+ } catch {
128
+ // Not ready or not installed
129
+ }
130
+ if (attempts < maxAttempts) {
131
+ setTimeout(trySetup, 200 * attempts);
132
+ }
133
+ }
134
+ setTimeout(trySetup, 200);
135
+ //# sourceMappingURL=expoRouterTracking.js.map
@@ -160,7 +160,12 @@ const noopAutoTracking = {
160
160
  getSessionMetrics: () => ({}),
161
161
  resetMetrics: () => {},
162
162
  collectDeviceInfo: async () => ({}),
163
- ensurePersistentAnonymousId: async () => 'anonymous'
163
+ ensurePersistentAnonymousId: async () => 'anonymous',
164
+ useNavigationTracking: () => ({
165
+ ref: null,
166
+ onReady: () => {},
167
+ onStateChange: () => {}
168
+ })
164
169
  };
165
170
  function getAutoTracking() {
166
171
  if (_sdkDisabled) return noopAutoTracking;
@@ -183,6 +188,11 @@ let _appStateSubscription = null;
183
188
  let _authErrorSubscription = null;
184
189
  let _currentAppState = 'active'; // Default to active, will be updated on init
185
190
  let _userIdentity = null;
191
+ let _backgroundEntryTime = null; // Track when app went to background
192
+ let _storedMetadata = {}; // Accumulate metadata for session rollover
193
+
194
+ // Session timeout - must match native side (60 seconds)
195
+ const SESSION_TIMEOUT_MS = 60_000;
186
196
 
187
197
  // Scroll throttling - reduce native bridge calls from 60fps to at most 10/sec
188
198
  let _lastScrollTime = 0;
@@ -437,7 +447,7 @@ function safeNativeCallSync(methodName, fn, defaultValue) {
437
447
  /**
438
448
  * Main Rejourney API (Internal)
439
449
  */
440
- const Rejourney = {
450
+ export const Rejourney = {
441
451
  /**
442
452
  * SDK Version
443
453
  */
@@ -572,7 +582,9 @@ const Rejourney = {
572
582
  trackJSErrors: true,
573
583
  trackPromiseRejections: true,
574
584
  trackReactNativeErrors: true,
575
- collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false
585
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
586
+ collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false,
587
+ autoTrackExpoRouter: _storedConfig?.autoTrackExpoRouter !== false
576
588
  }, {
577
589
  // Rage tap callback - log as frustration event
578
590
  onRageTap: (count, x, y) => {
@@ -584,13 +596,8 @@ const Rejourney = {
584
596
  });
585
597
  getLogger().logFrustration(`Rage tap (${count} taps)`);
586
598
  },
587
- // Error callback - log as error event
599
+ // Error callback - SDK forwarding is handled in autoTracking.trackError
588
600
  onError: error => {
589
- this.logEvent('error', {
590
- message: error.message,
591
- stack: error.stack,
592
- name: error.name
593
- });
594
601
  getLogger().logError(error.message);
595
602
  },
596
603
  onScreen: (_screenName, _previousScreen) => {}
@@ -605,7 +612,12 @@ const Rejourney = {
605
612
  }
606
613
  if (_storedConfig?.autoTrackNetwork !== false) {
607
614
  try {
608
- const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', ...(_storedConfig?.networkIgnoreUrls || [])];
615
+ // JS-level fetch/XHR patching is the primary mechanism for capturing network
616
+ // calls within React Native. Native interceptors (RejourneyURLProtocol on iOS,
617
+ // RejourneyNetworkInterceptor on Android) are supplementary — they capture
618
+ // native-originated HTTP calls that bypass JS fetch(), but cannot intercept
619
+ // RN's own networking since it creates its NSURLSession/OkHttpClient at init time.
620
+ const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', '/api/ingest/segment/presign', '/api/ingest/segment/complete', ...(_storedConfig?.networkIgnoreUrls || [])];
609
621
  getNetworkInterceptor().initNetworkInterceptor(request => {
610
622
  getAutoTracking().trackAPIRequest(request.success || false, request.statusCode, request.duration || 0, request.responseBodySize || 0);
611
623
  Rejourney.logNetworkRequest(request);
@@ -694,17 +706,57 @@ const Rejourney = {
694
706
  }
695
707
  },
696
708
  /**
697
- * Tag the current screen
709
+ /**
710
+ * Set custom session metadata.
711
+ * Can be called with a single key-value pair or an object of properties.
712
+ * Useful for filtering sessions later (e.g., plan: 'premium', role: 'admin').
713
+ * Caps at 100 properties per session.
714
+ *
715
+ * @param keyOrProperties Property name string, or an object containing key-value pairs
716
+ * @param value Property value (if first argument is a string)
717
+ */
718
+ setMetadata(keyOrProperties, value) {
719
+ if (typeof keyOrProperties === 'string') {
720
+ const key = keyOrProperties;
721
+ if (!key) {
722
+ getLogger().warn('setMetadata requires a non-empty string key');
723
+ return;
724
+ }
725
+ if (value !== undefined && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
726
+ getLogger().warn('setMetadata value must be a string, number, or boolean when using a key string');
727
+ return;
728
+ }
729
+ this.logEvent('$user_property', {
730
+ key,
731
+ value
732
+ });
733
+ // Track for session rollover restoration
734
+ _storedMetadata[key] = value;
735
+ } else if (keyOrProperties && typeof keyOrProperties === 'object') {
736
+ const properties = keyOrProperties;
737
+ const validProps = {};
738
+ for (const [k, v] of Object.entries(properties)) {
739
+ if (typeof k === 'string' && k && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
740
+ validProps[k] = v;
741
+ }
742
+ }
743
+ if (Object.keys(validProps).length > 0) {
744
+ this.logEvent('$user_property', validProps);
745
+ // Track for session rollover restoration
746
+ Object.assign(_storedMetadata, validProps);
747
+ }
748
+ } else {
749
+ getLogger().warn('setMetadata requires a string key and value, or a properties object');
750
+ }
751
+ },
752
+ /**
753
+ * Track current screen (manual)
698
754
  *
699
755
  * @param screenName - Screen name
700
756
  * @param params - Optional screen parameters
701
757
  */
702
- tagScreen(screenName, _params) {
758
+ trackScreen(screenName, _params) {
703
759
  getAutoTracking().trackScreen(screenName);
704
- getAutoTracking().notifyStateChange();
705
- safeNativeCallSync('tagScreen', () => {
706
- getRejourneyNative().screenChanged(screenName).catch(() => {});
707
- }, undefined);
708
760
  },
709
761
  /**
710
762
  * Mark a view as sensitive (will be occluded in recordings)
@@ -1002,6 +1054,23 @@ const Rejourney = {
1002
1054
  getRejourneyNative().logEvent('network_request', networkEvent).catch(() => {});
1003
1055
  }, undefined);
1004
1056
  },
1057
+ /**
1058
+ * Log customer feedback (e.g. from an in-app survey or NPS widget).
1059
+ *
1060
+ * @param rating - Numeric rating (e.g. 1 to 5)
1061
+ * @param message - Associated feedback text or comment
1062
+ */
1063
+ logFeedback(rating, message) {
1064
+ safeNativeCallSync('logFeedback', () => {
1065
+ const feedbackEvent = {
1066
+ type: 'feedback',
1067
+ timestamp: Date.now(),
1068
+ rating,
1069
+ message
1070
+ };
1071
+ getRejourneyNative().logEvent('feedback', feedbackEvent).catch(() => {});
1072
+ }, undefined);
1073
+ },
1005
1074
  /**
1006
1075
  * Get SDK telemetry metrics for observability
1007
1076
  *
@@ -1037,17 +1106,12 @@ const Rejourney = {
1037
1106
  });
1038
1107
  },
1039
1108
  /**
1040
- * Trigger a debug ANR (Dev only)
1041
- * Blocks the main thread for the specified duration
1109
+ * Trigger an ANR test by blocking the main thread for the specified duration.
1042
1110
  */
1043
1111
  debugTriggerANR(durationMs) {
1044
- if (__DEV__) {
1045
- safeNativeCallSync('debugTriggerANR', () => {
1046
- getRejourneyNative().debugTriggerANR(durationMs);
1047
- }, undefined);
1048
- } else {
1049
- getLogger().warn('debugTriggerANR is only available in development mode');
1050
- }
1112
+ safeNativeCallSync('debugTriggerANR', () => {
1113
+ getRejourneyNative().debugTriggerANR(durationMs);
1114
+ }, undefined);
1051
1115
  },
1052
1116
  /**
1053
1117
  * Mask a view by its nativeID prop (will be occluded in recordings)
@@ -1081,13 +1145,109 @@ const Rejourney = {
1081
1145
  safeNativeCallSync('unmaskView', () => {
1082
1146
  getRejourneyNative().unmaskViewByNativeID(nativeID).catch(() => {});
1083
1147
  }, undefined);
1148
+ },
1149
+ /**
1150
+ * Initialize Rejourney SDK
1151
+ */
1152
+ init(publicRouteKey, options) {
1153
+ initRejourney(publicRouteKey, options);
1154
+ },
1155
+ /**
1156
+ * Start recording
1157
+ */
1158
+ start() {
1159
+ startRejourney();
1160
+ },
1161
+ /**
1162
+ * Stop recording
1163
+ */
1164
+ stop() {
1165
+ stopRejourney();
1166
+ },
1167
+ /**
1168
+ * Hook for automatic React Navigation tracking.
1169
+ */
1170
+ useNavigationTracking() {
1171
+ return getAutoTracking().useNavigationTracking();
1084
1172
  }
1085
1173
  };
1086
1174
 
1175
+ /**
1176
+ * Reinitialize JS-side auto-tracking for a new session after background timeout.
1177
+ *
1178
+ * When the app was in background for >60s the native layer rolls over to a
1179
+ * fresh session automatically. The JS side must tear down stale tracking
1180
+ * state (metrics, console-log counter, screen history, error handlers) and
1181
+ * re-initialize so that trackScreen, logEvent, setMetadata, etc. work
1182
+ * correctly against the new native session.
1183
+ */
1184
+ function _reinitAutoTrackingForNewSession() {
1185
+ try {
1186
+ // 1. Tear down old session's auto-tracking state
1187
+ getAutoTracking().cleanupAutoTracking();
1188
+
1189
+ // 2. Re-initialize auto-tracking with the same config
1190
+ getAutoTracking().initAutoTracking({
1191
+ rageTapThreshold: _storedConfig?.rageTapThreshold ?? 3,
1192
+ rageTapTimeWindow: _storedConfig?.rageTapTimeWindow ?? 500,
1193
+ rageTapRadius: 50,
1194
+ trackJSErrors: true,
1195
+ trackPromiseRejections: true,
1196
+ trackReactNativeErrors: true,
1197
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
1198
+ collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false,
1199
+ autoTrackExpoRouter: _storedConfig?.autoTrackExpoRouter !== false
1200
+ }, {
1201
+ onRageTap: (count, x, y) => {
1202
+ Rejourney.logEvent('frustration', {
1203
+ frustrationKind: 'rage_tap',
1204
+ tapCount: count,
1205
+ x,
1206
+ y
1207
+ });
1208
+ getLogger().logFrustration(`Rage tap (${count} taps)`);
1209
+ },
1210
+ onError: error => {
1211
+ getLogger().logError(error.message);
1212
+ },
1213
+ onScreen: (_screenName, _previousScreen) => {}
1214
+ });
1215
+
1216
+ // 3. Re-collect device info for the new session
1217
+ if (_storedConfig?.collectDeviceInfo !== false) {
1218
+ getAutoTracking().collectDeviceInfo().then(deviceInfo => {
1219
+ Rejourney.logEvent('device_info', deviceInfo);
1220
+ }).catch(() => {});
1221
+ }
1222
+
1223
+ // 4. Re-send user identity to the new native session
1224
+ if (_userIdentity) {
1225
+ safeNativeCallSync('setUserIdentity', () => {
1226
+ getRejourneyNative().setUserIdentity(_userIdentity).catch(() => {});
1227
+ }, undefined);
1228
+ getLogger().debug(`Restored user identity '${_userIdentity}' to new session`);
1229
+ }
1230
+
1231
+ // 5. Re-send any stored metadata to the new native session
1232
+ if (Object.keys(_storedMetadata).length > 0) {
1233
+ for (const [key, value] of Object.entries(_storedMetadata)) {
1234
+ if (value !== undefined && value !== null) {
1235
+ Rejourney.setMetadata(key, value);
1236
+ }
1237
+ }
1238
+ getLogger().debug('Restored metadata to new session');
1239
+ }
1240
+ getLogger().logLifecycleEvent('JS auto-tracking reinitialized for new session');
1241
+ } catch (error) {
1242
+ getLogger().warn('Failed to reinitialize auto-tracking after session rollover:', error);
1243
+ }
1244
+ }
1245
+
1087
1246
  /**
1088
1247
  * Handle app state changes for automatic session management
1089
1248
  * - Pauses recording when app goes to background
1090
1249
  * - Resumes recording when app comes back to foreground
1250
+ * - Reinitializes JS-side auto-tracking when native rolls over to a new session
1091
1251
  * - Cleans up properly when app is terminated
1092
1252
  */
1093
1253
  function handleAppStateChange(nextAppState) {
@@ -1096,9 +1256,22 @@ function handleAppStateChange(nextAppState) {
1096
1256
  if (_currentAppState.match(/active/) && nextAppState === 'background') {
1097
1257
  // App going to background - native module handles this automatically
1098
1258
  getLogger().logLifecycleEvent('App moving to background');
1259
+ _backgroundEntryTime = Date.now();
1099
1260
  } else if (_currentAppState.match(/inactive|background/) && nextAppState === 'active') {
1100
1261
  // App coming back to foreground
1101
1262
  getLogger().logLifecycleEvent('App returning to foreground');
1263
+
1264
+ // Check if we exceeded the session timeout (60s).
1265
+ // Native side will have already ended the old session and started a new
1266
+ // one — we need to reset JS-side auto-tracking state to match.
1267
+ if (_backgroundEntryTime && _isRecording) {
1268
+ const backgroundDurationMs = Date.now() - _backgroundEntryTime;
1269
+ if (backgroundDurationMs > SESSION_TIMEOUT_MS) {
1270
+ getLogger().debug(`Session rollover: background ${Math.round(backgroundDurationMs / 1000)}s > ${SESSION_TIMEOUT_MS / 1000}s timeout`);
1271
+ _reinitAutoTrackingForNewSession();
1272
+ }
1273
+ }
1274
+ _backgroundEntryTime = null;
1102
1275
  }
1103
1276
  _currentAppState = nextAppState;
1104
1277
  } catch (error) {
@@ -1163,8 +1336,10 @@ function setupAuthErrorListener() {
1163
1336
  }
1164
1337
  });
1165
1338
  }
1166
- } catch (error) {
1167
- getLogger().debug('Auth error listener not available:', error);
1339
+ } catch {
1340
+ // Expected on some architectures where NativeEventEmitter isn't fully supported.
1341
+ // Auth errors are still handled synchronously via native callback — this listener
1342
+ // is purely supplementary. No need to log.
1168
1343
  }
1169
1344
  }
1170
1345
 
@@ -1301,7 +1476,7 @@ export function stopRejourney() {
1301
1476
  }
1302
1477
  export default Rejourney;
1303
1478
  export * from './types';
1304
- export { trackTap, trackScroll, trackGesture, trackInput, trackScreen, captureError, getSessionMetrics } from './sdk/autoTracking';
1479
+ export { trackTap, trackScroll, trackGesture, trackInput, captureError, getSessionMetrics } from './sdk/autoTracking';
1305
1480
  export { trackNavigationState, useNavigationTracking } from './sdk/autoTracking';
1306
1481
  export { LogLevel } from './sdk/utils';
1307
1482