@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.
- package/README.md +77 -3
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
- package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +204 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +216 -0
- package/ios/Recording/ReplayOrchestrator.swift +207 -144
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +45 -2
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +79 -29
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/DataCompression.swift +2 -2
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/expoRouterTracking.js +137 -0
- package/lib/commonjs/index.js +204 -34
- package/lib/commonjs/sdk/autoTracking.js +262 -100
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/expoRouterTracking.js +135 -0
- package/lib/module/index.js +203 -28
- package/lib/module/sdk/autoTracking.js +260 -100
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/expoRouterTracking.d.ts +14 -0
- package/lib/typescript/index.d.ts +2 -2
- package/lib/typescript/sdk/autoTracking.d.ts +14 -1
- package/lib/typescript/types/index.d.ts +56 -5
- package/package.json +23 -3
- package/src/NativeRejourney.ts +8 -5
- package/src/expoRouterTracking.ts +167 -0
- package/src/index.ts +221 -35
- package/src/sdk/autoTracking.ts +286 -114
- package/src/sdk/networkInterceptor.ts +110 -1
- 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
|
-
|
|
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
|
package/lib/module/index.js
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1167
|
-
|
|
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,
|
|
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
|
|