@rejourneyco/react-native 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +709 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Rejourney
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rejourney Auto Tracking Module
|
|
19
|
+
*
|
|
20
|
+
* Automatic tracking features that work with just init() - no additional code needed.
|
|
21
|
+
* This module handles:
|
|
22
|
+
* - Rage tap detection
|
|
23
|
+
* - Error tracking (JS + React Native)
|
|
24
|
+
* - Session metrics aggregation
|
|
25
|
+
* - Device info collection
|
|
26
|
+
* - Anonymous ID generation
|
|
27
|
+
* - Funnel/screen tracking
|
|
28
|
+
* - Score calculations
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT: This file uses lazy loading for react-native imports to avoid
|
|
31
|
+
* "PlatformConstants could not be found" errors on RN 0.81+.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { logger } from './utils';
|
|
35
|
+
|
|
36
|
+
// Lazy-loaded React Native modules
|
|
37
|
+
let _RN = null;
|
|
38
|
+
function getRN() {
|
|
39
|
+
if (_RN) return _RN;
|
|
40
|
+
try {
|
|
41
|
+
_RN = require('react-native');
|
|
42
|
+
return _RN;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function getPlatform() {
|
|
48
|
+
return getRN()?.Platform;
|
|
49
|
+
}
|
|
50
|
+
function getDimensions() {
|
|
51
|
+
return getRN()?.Dimensions;
|
|
52
|
+
}
|
|
53
|
+
function getRejourneyNativeModule() {
|
|
54
|
+
const RN = getRN();
|
|
55
|
+
if (!RN) return null;
|
|
56
|
+
const {
|
|
57
|
+
TurboModuleRegistry,
|
|
58
|
+
NativeModules
|
|
59
|
+
} = RN;
|
|
60
|
+
let nativeModule = null;
|
|
61
|
+
if (TurboModuleRegistry && typeof TurboModuleRegistry.get === 'function') {
|
|
62
|
+
try {
|
|
63
|
+
nativeModule = TurboModuleRegistry.get('Rejourney');
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!nativeModule && NativeModules) {
|
|
69
|
+
nativeModule = NativeModules.Rejourney ?? null;
|
|
70
|
+
}
|
|
71
|
+
return nativeModule;
|
|
72
|
+
}
|
|
73
|
+
const _globalThis = globalThis;
|
|
74
|
+
let isInitialized = false;
|
|
75
|
+
let config = {};
|
|
76
|
+
const recentTaps = [];
|
|
77
|
+
let tapHead = 0;
|
|
78
|
+
let tapCount = 0;
|
|
79
|
+
const MAX_RECENT_TAPS = 10;
|
|
80
|
+
let metrics = createEmptyMetrics();
|
|
81
|
+
let sessionStartTime = 0;
|
|
82
|
+
let maxSessionDurationMs = 10 * 60 * 1000;
|
|
83
|
+
let currentScreen = '';
|
|
84
|
+
let screensVisited = [];
|
|
85
|
+
let anonymousId = null;
|
|
86
|
+
let anonymousIdPromise = null;
|
|
87
|
+
let onRageTapDetected = null;
|
|
88
|
+
let onErrorCaptured = null;
|
|
89
|
+
let onScreenChange = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mark a tap as handled.
|
|
93
|
+
* No-op — kept for API compatibility. Dead tap detection is now native-side.
|
|
94
|
+
*/
|
|
95
|
+
export function markTapHandled() {
|
|
96
|
+
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
97
|
+
}
|
|
98
|
+
// ========== End Dead Tap Detection ==========
|
|
99
|
+
|
|
100
|
+
let originalErrorHandler;
|
|
101
|
+
let originalOnError = null;
|
|
102
|
+
let originalOnUnhandledRejection = null;
|
|
103
|
+
let originalConsoleError = null;
|
|
104
|
+
let _promiseRejectionTrackingDisable = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initialize auto tracking features
|
|
108
|
+
* Called automatically by Rejourney.init() - no user action needed
|
|
109
|
+
*/
|
|
110
|
+
export function initAutoTracking(trackingConfig, callbacks = {}) {
|
|
111
|
+
if (isInitialized) return;
|
|
112
|
+
config = {
|
|
113
|
+
rageTapThreshold: 3,
|
|
114
|
+
rageTapTimeWindow: 500,
|
|
115
|
+
rageTapRadius: 50,
|
|
116
|
+
trackJSErrors: true,
|
|
117
|
+
trackPromiseRejections: true,
|
|
118
|
+
trackReactNativeErrors: true,
|
|
119
|
+
collectDeviceInfo: true,
|
|
120
|
+
maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
|
|
121
|
+
...trackingConfig
|
|
122
|
+
};
|
|
123
|
+
sessionStartTime = Date.now();
|
|
124
|
+
setMaxSessionDurationMinutes(trackingConfig.maxSessionDurationMs ? trackingConfig.maxSessionDurationMs / 60000 : undefined);
|
|
125
|
+
onRageTapDetected = callbacks.onRageTap || null;
|
|
126
|
+
onErrorCaptured = callbacks.onError || null;
|
|
127
|
+
onScreenChange = callbacks.onScreen || null;
|
|
128
|
+
setupErrorTracking();
|
|
129
|
+
setupNavigationTracking();
|
|
130
|
+
loadAnonymousId().then(id => {
|
|
131
|
+
anonymousId = id;
|
|
132
|
+
});
|
|
133
|
+
isInitialized = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cleanup auto tracking features
|
|
138
|
+
*/
|
|
139
|
+
export function cleanupAutoTracking() {
|
|
140
|
+
if (!isInitialized) return;
|
|
141
|
+
restoreErrorHandlers();
|
|
142
|
+
cleanupNavigationTracking();
|
|
143
|
+
|
|
144
|
+
// Reset state
|
|
145
|
+
tapHead = 0;
|
|
146
|
+
tapCount = 0;
|
|
147
|
+
metrics = createEmptyMetrics();
|
|
148
|
+
screensVisited = [];
|
|
149
|
+
currentScreen = '';
|
|
150
|
+
sessionStartTime = 0;
|
|
151
|
+
maxSessionDurationMs = 10 * 60 * 1000;
|
|
152
|
+
isInitialized = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Track a tap event for rage tap detection
|
|
157
|
+
* Called automatically from touch interceptor
|
|
158
|
+
*/
|
|
159
|
+
export function trackTap(tap) {
|
|
160
|
+
if (!isInitialized) return;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
const insertIndex = (tapHead + tapCount) % MAX_RECENT_TAPS;
|
|
163
|
+
if (tapCount < MAX_RECENT_TAPS) {
|
|
164
|
+
recentTaps[insertIndex] = {
|
|
165
|
+
...tap,
|
|
166
|
+
timestamp: now
|
|
167
|
+
};
|
|
168
|
+
tapCount++;
|
|
169
|
+
} else {
|
|
170
|
+
recentTaps[tapHead] = {
|
|
171
|
+
...tap,
|
|
172
|
+
timestamp: now
|
|
173
|
+
};
|
|
174
|
+
tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
|
|
175
|
+
}
|
|
176
|
+
const windowStart = now - (config.rageTapTimeWindow || 500);
|
|
177
|
+
while (tapCount > 0) {
|
|
178
|
+
const oldestTap = recentTaps[tapHead];
|
|
179
|
+
if (oldestTap && oldestTap.timestamp < windowStart) {
|
|
180
|
+
tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
|
|
181
|
+
tapCount--;
|
|
182
|
+
} else {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
detectRageTap();
|
|
187
|
+
metrics.touchCount++;
|
|
188
|
+
metrics.totalEvents++;
|
|
189
|
+
// Dead tap detection is now handled natively in TelemetryPipeline
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Detect if recent taps form a rage tap pattern
|
|
194
|
+
*/
|
|
195
|
+
function detectRageTap() {
|
|
196
|
+
const threshold = config.rageTapThreshold || 3;
|
|
197
|
+
const radius = config.rageTapRadius || 50;
|
|
198
|
+
if (tapCount < threshold) return;
|
|
199
|
+
const tapsToCheck = [];
|
|
200
|
+
for (let i = 0; i < threshold; i++) {
|
|
201
|
+
const idx = (tapHead + tapCount - threshold + i) % MAX_RECENT_TAPS;
|
|
202
|
+
tapsToCheck.push(recentTaps[idx]);
|
|
203
|
+
}
|
|
204
|
+
let centerX = 0;
|
|
205
|
+
let centerY = 0;
|
|
206
|
+
for (const tap of tapsToCheck) {
|
|
207
|
+
centerX += tap.x;
|
|
208
|
+
centerY += tap.y;
|
|
209
|
+
}
|
|
210
|
+
centerX /= tapsToCheck.length;
|
|
211
|
+
centerY /= tapsToCheck.length;
|
|
212
|
+
let allWithinRadius = true;
|
|
213
|
+
for (const tap of tapsToCheck) {
|
|
214
|
+
const dx = tap.x - centerX;
|
|
215
|
+
const dy = tap.y - centerY;
|
|
216
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
217
|
+
if (distance > radius) {
|
|
218
|
+
allWithinRadius = false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (allWithinRadius) {
|
|
223
|
+
metrics.rageTapCount++;
|
|
224
|
+
tapHead = 0;
|
|
225
|
+
tapCount = 0;
|
|
226
|
+
|
|
227
|
+
// Notify callback
|
|
228
|
+
if (onRageTapDetected) {
|
|
229
|
+
onRageTapDetected(threshold, centerX, centerY);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Notify that a state change occurred (navigation, modal, etc.)
|
|
236
|
+
* Kept for API compatibility
|
|
237
|
+
*/
|
|
238
|
+
export function notifyStateChange() {
|
|
239
|
+
// No-op: dead tap detection is handled natively in TelemetryPipeline
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Setup automatic error tracking
|
|
244
|
+
*/
|
|
245
|
+
function setupErrorTracking() {
|
|
246
|
+
if (config.trackReactNativeErrors !== false) {
|
|
247
|
+
setupReactNativeErrorHandler();
|
|
248
|
+
}
|
|
249
|
+
if (config.trackJSErrors !== false && typeof _globalThis !== 'undefined') {
|
|
250
|
+
setupJSErrorHandler();
|
|
251
|
+
}
|
|
252
|
+
if (config.trackPromiseRejections !== false && typeof _globalThis !== 'undefined') {
|
|
253
|
+
setupPromiseRejectionHandler();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Setup React Native ErrorUtils handler
|
|
259
|
+
*
|
|
260
|
+
* CRITICAL FIX: For fatal errors, we delay calling the original handler by 500ms
|
|
261
|
+
* to give the React Native bridge time to flush the logEvent('error') call to the
|
|
262
|
+
* native TelemetryPipeline. Without this delay, the error event is queued on the
|
|
263
|
+
* JS→native bridge but the app crashes (via originalErrorHandler) before the bridge
|
|
264
|
+
* flushes, so the error is lost. Crashes are captured separately by native crash
|
|
265
|
+
* handlers, but the corresponding JS error record was never making it to the backend.
|
|
266
|
+
*/
|
|
267
|
+
function setupReactNativeErrorHandler() {
|
|
268
|
+
try {
|
|
269
|
+
const ErrorUtils = _globalThis.ErrorUtils;
|
|
270
|
+
if (!ErrorUtils) return;
|
|
271
|
+
originalErrorHandler = ErrorUtils.getGlobalHandler();
|
|
272
|
+
ErrorUtils.setGlobalHandler((error, isFatal) => {
|
|
273
|
+
trackError({
|
|
274
|
+
type: 'error',
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
message: error.message || String(error),
|
|
277
|
+
stack: error.stack,
|
|
278
|
+
name: error.name || 'Error'
|
|
279
|
+
});
|
|
280
|
+
if (originalErrorHandler) {
|
|
281
|
+
if (isFatal) {
|
|
282
|
+
// For fatal errors, delay the original handler so the native bridge
|
|
283
|
+
// has time to deliver the error event to TelemetryPipeline before
|
|
284
|
+
// the app terminates. 500ms is enough for the bridge to flush.
|
|
285
|
+
setTimeout(() => {
|
|
286
|
+
originalErrorHandler(error, isFatal);
|
|
287
|
+
}, 500);
|
|
288
|
+
} else {
|
|
289
|
+
originalErrorHandler(error, isFatal);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
// Ignore
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Setup global JS error handler
|
|
300
|
+
*/
|
|
301
|
+
function setupJSErrorHandler() {
|
|
302
|
+
if (typeof _globalThis.onerror !== 'undefined') {
|
|
303
|
+
originalOnError = _globalThis.onerror;
|
|
304
|
+
_globalThis.onerror = (message, source, lineno, colno, error) => {
|
|
305
|
+
trackError({
|
|
306
|
+
type: 'error',
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
message: typeof message === 'string' ? message : 'Unknown error',
|
|
309
|
+
stack: error?.stack || `${source}:${lineno}:${colno}`,
|
|
310
|
+
name: error?.name || 'Error'
|
|
311
|
+
});
|
|
312
|
+
if (originalOnError) {
|
|
313
|
+
return originalOnError(message, source, lineno, colno, error);
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Setup unhandled promise rejection handler
|
|
322
|
+
*
|
|
323
|
+
* React Native's Hermes engine does NOT support the web-standard
|
|
324
|
+
* globalThis.addEventListener('unhandledrejection', ...) API.
|
|
325
|
+
* We use two complementary strategies:
|
|
326
|
+
*
|
|
327
|
+
* 1. React Native's built-in promise rejection tracking polyfill
|
|
328
|
+
* (promise/setimmediate/rejection-tracking) — fires for all
|
|
329
|
+
* unhandled rejections, including those that never hit ErrorUtils.
|
|
330
|
+
*
|
|
331
|
+
* 2. console.error interception — newer RN versions (0.73+) report
|
|
332
|
+
* unhandled promise rejections via console.error with a recognizable
|
|
333
|
+
* prefix. We intercept these as a fallback.
|
|
334
|
+
*
|
|
335
|
+
* 3. Web API fallback — for non-RN environments (e.g., testing in a browser).
|
|
336
|
+
*/
|
|
337
|
+
function setupPromiseRejectionHandler() {
|
|
338
|
+
let rnTrackingSetUp = false;
|
|
339
|
+
|
|
340
|
+
// Strategy 1: RN-specific promise rejection tracking polyfill
|
|
341
|
+
try {
|
|
342
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
343
|
+
const tracking = require('promise/setimmediate/rejection-tracking');
|
|
344
|
+
if (tracking && typeof tracking.enable === 'function') {
|
|
345
|
+
tracking.enable({
|
|
346
|
+
allRejections: true,
|
|
347
|
+
onUnhandled: (_id, error) => {
|
|
348
|
+
trackError({
|
|
349
|
+
type: 'error',
|
|
350
|
+
timestamp: Date.now(),
|
|
351
|
+
message: error?.message || String(error) || 'Unhandled Promise Rejection',
|
|
352
|
+
stack: error?.stack,
|
|
353
|
+
name: error?.name || 'UnhandledRejection'
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
onHandled: () => {/* no-op */}
|
|
357
|
+
});
|
|
358
|
+
_promiseRejectionTrackingDisable = () => {
|
|
359
|
+
try {
|
|
360
|
+
tracking.disable();
|
|
361
|
+
} catch {/* ignore */}
|
|
362
|
+
};
|
|
363
|
+
rnTrackingSetUp = true;
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Polyfill not available — fall through to other strategies
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Strategy 2: Intercept console.error for promise rejection messages
|
|
370
|
+
// Newer RN versions log "Possible Unhandled Promise Rejection" via console.error
|
|
371
|
+
if (!rnTrackingSetUp && typeof console !== 'undefined' && console.error) {
|
|
372
|
+
originalConsoleError = console.error;
|
|
373
|
+
console.error = (...args) => {
|
|
374
|
+
// Detect RN-style promise rejection messages
|
|
375
|
+
const firstArg = args[0];
|
|
376
|
+
if (typeof firstArg === 'string' && firstArg.includes('Possible Unhandled Promise Rejection')) {
|
|
377
|
+
const error = args[1];
|
|
378
|
+
trackError({
|
|
379
|
+
type: 'error',
|
|
380
|
+
timestamp: Date.now(),
|
|
381
|
+
message: error?.message || String(error) || firstArg,
|
|
382
|
+
stack: error?.stack,
|
|
383
|
+
name: error?.name || 'UnhandledRejection'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// Always call through to original console.error
|
|
387
|
+
if (originalConsoleError) {
|
|
388
|
+
originalConsoleError.apply(console, args);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Strategy 3: Web API fallback (works in browser-based testing, not in RN Hermes)
|
|
394
|
+
if (!rnTrackingSetUp && typeof _globalThis.addEventListener !== 'undefined') {
|
|
395
|
+
const handler = event => {
|
|
396
|
+
const reason = event.reason;
|
|
397
|
+
trackError({
|
|
398
|
+
type: 'error',
|
|
399
|
+
timestamp: Date.now(),
|
|
400
|
+
message: reason?.message || String(reason) || 'Unhandled Promise Rejection',
|
|
401
|
+
stack: reason?.stack,
|
|
402
|
+
name: reason?.name || 'UnhandledRejection'
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
originalOnUnhandledRejection = handler;
|
|
406
|
+
_globalThis.addEventListener('unhandledrejection', handler);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Restore original error handlers
|
|
412
|
+
*/
|
|
413
|
+
function restoreErrorHandlers() {
|
|
414
|
+
if (originalErrorHandler) {
|
|
415
|
+
try {
|
|
416
|
+
const ErrorUtils = _globalThis.ErrorUtils;
|
|
417
|
+
if (ErrorUtils) {
|
|
418
|
+
ErrorUtils.setGlobalHandler(originalErrorHandler);
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Ignore
|
|
422
|
+
}
|
|
423
|
+
originalErrorHandler = undefined;
|
|
424
|
+
}
|
|
425
|
+
if (originalOnError !== null) {
|
|
426
|
+
_globalThis.onerror = originalOnError;
|
|
427
|
+
originalOnError = null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Restore promise rejection tracking
|
|
431
|
+
if (_promiseRejectionTrackingDisable) {
|
|
432
|
+
_promiseRejectionTrackingDisable();
|
|
433
|
+
_promiseRejectionTrackingDisable = null;
|
|
434
|
+
}
|
|
435
|
+
if (originalConsoleError) {
|
|
436
|
+
console.error = originalConsoleError;
|
|
437
|
+
originalConsoleError = null;
|
|
438
|
+
}
|
|
439
|
+
if (originalOnUnhandledRejection && typeof _globalThis.removeEventListener !== 'undefined') {
|
|
440
|
+
_globalThis.removeEventListener('unhandledrejection', originalOnUnhandledRejection);
|
|
441
|
+
originalOnUnhandledRejection = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Track an error
|
|
447
|
+
*/
|
|
448
|
+
function trackError(error) {
|
|
449
|
+
metrics.errorCount++;
|
|
450
|
+
metrics.totalEvents++;
|
|
451
|
+
if (onErrorCaptured) {
|
|
452
|
+
onErrorCaptured(error);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Manually track an error (for API errors, etc.)
|
|
458
|
+
*/
|
|
459
|
+
export function captureError(message, stack, name) {
|
|
460
|
+
trackError({
|
|
461
|
+
type: 'error',
|
|
462
|
+
timestamp: Date.now(),
|
|
463
|
+
message,
|
|
464
|
+
stack,
|
|
465
|
+
name: name || 'Error'
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
let navigationPollingInterval = null;
|
|
469
|
+
let lastDetectedScreen = '';
|
|
470
|
+
let navigationSetupDone = false;
|
|
471
|
+
let navigationPollingErrors = 0;
|
|
472
|
+
const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Track a navigation state change from React Navigation.
|
|
476
|
+
*
|
|
477
|
+
* For bare React Native apps using @react-navigation/native.
|
|
478
|
+
* Just add this to your NavigationContainer's onStateChange prop.
|
|
479
|
+
*
|
|
480
|
+
* @example
|
|
481
|
+
* ```tsx
|
|
482
|
+
* import { trackNavigationState } from 'rejourney';
|
|
483
|
+
*
|
|
484
|
+
* <NavigationContainer onStateChange={trackNavigationState}>
|
|
485
|
+
* ...
|
|
486
|
+
* </NavigationContainer>
|
|
487
|
+
* ```
|
|
488
|
+
*/
|
|
489
|
+
export function trackNavigationState(state) {
|
|
490
|
+
if (!state?.routes) return;
|
|
491
|
+
try {
|
|
492
|
+
const {
|
|
493
|
+
normalizeScreenName
|
|
494
|
+
} = require('./navigation');
|
|
495
|
+
const findActiveScreen = navState => {
|
|
496
|
+
if (!navState?.routes) return null;
|
|
497
|
+
const index = navState.index ?? navState.routes.length - 1;
|
|
498
|
+
const route = navState.routes[index];
|
|
499
|
+
if (!route) return null;
|
|
500
|
+
if (route.state) return findActiveScreen(route.state);
|
|
501
|
+
return normalizeScreenName(route.name || 'Unknown');
|
|
502
|
+
};
|
|
503
|
+
const screenName = findActiveScreen(state);
|
|
504
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
505
|
+
lastDetectedScreen = screenName;
|
|
506
|
+
trackScreen(screenName);
|
|
507
|
+
}
|
|
508
|
+
} catch {
|
|
509
|
+
// Ignore
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* React hook for navigation tracking.
|
|
515
|
+
*
|
|
516
|
+
* Returns props to spread on NavigationContainer that will:
|
|
517
|
+
* 1. Track the initial screen on mount (via onReady)
|
|
518
|
+
* 2. Track all subsequent navigations (via onStateChange)
|
|
519
|
+
*
|
|
520
|
+
* This is the RECOMMENDED approach for bare React Native apps.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```tsx
|
|
524
|
+
* import { useNavigationTracking } from 'rejourney';
|
|
525
|
+
* import { NavigationContainer } from '@react-navigation/native';
|
|
526
|
+
*
|
|
527
|
+
* function App() {
|
|
528
|
+
* const navigationTracking = useNavigationTracking();
|
|
529
|
+
*
|
|
530
|
+
* return (
|
|
531
|
+
* <NavigationContainer {...navigationTracking}>
|
|
532
|
+
* <RootNavigator />
|
|
533
|
+
* </NavigationContainer>
|
|
534
|
+
* );
|
|
535
|
+
* }
|
|
536
|
+
* ```
|
|
537
|
+
*/
|
|
538
|
+
export function useNavigationTracking() {
|
|
539
|
+
const React = require('react');
|
|
540
|
+
const {
|
|
541
|
+
createNavigationContainerRef
|
|
542
|
+
} = require('@react-navigation/native');
|
|
543
|
+
const navigationRef = React.useRef(createNavigationContainerRef());
|
|
544
|
+
const onReady = React.useCallback(() => {
|
|
545
|
+
try {
|
|
546
|
+
const currentRoute = navigationRef.current?.getCurrentRoute?.();
|
|
547
|
+
if (currentRoute?.name) {
|
|
548
|
+
const {
|
|
549
|
+
normalizeScreenName
|
|
550
|
+
} = require('./navigation');
|
|
551
|
+
const screenName = normalizeScreenName(currentRoute.name);
|
|
552
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
553
|
+
lastDetectedScreen = screenName;
|
|
554
|
+
trackScreen(screenName);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
// Ignore
|
|
559
|
+
}
|
|
560
|
+
}, []);
|
|
561
|
+
return {
|
|
562
|
+
ref: navigationRef.current,
|
|
563
|
+
onReady,
|
|
564
|
+
onStateChange: trackNavigationState
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Setup automatic Expo Router tracking
|
|
570
|
+
*
|
|
571
|
+
* For Expo apps using expo-router - works automatically.
|
|
572
|
+
* For bare React Native apps - use trackNavigationState instead.
|
|
573
|
+
*/
|
|
574
|
+
function setupNavigationTracking() {
|
|
575
|
+
if (navigationSetupDone) return;
|
|
576
|
+
navigationSetupDone = true;
|
|
577
|
+
if (__DEV__) {
|
|
578
|
+
logger.debug('Setting up navigation tracking...');
|
|
579
|
+
}
|
|
580
|
+
let attempts = 0;
|
|
581
|
+
const maxAttempts = 5;
|
|
582
|
+
const trySetup = () => {
|
|
583
|
+
attempts++;
|
|
584
|
+
if (__DEV__) {
|
|
585
|
+
logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
|
|
586
|
+
}
|
|
587
|
+
const success = trySetupExpoRouter();
|
|
588
|
+
if (success) {
|
|
589
|
+
if (__DEV__) {
|
|
590
|
+
logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
|
|
591
|
+
}
|
|
592
|
+
} else if (attempts < maxAttempts) {
|
|
593
|
+
const delay = 200 * attempts;
|
|
594
|
+
if (__DEV__) {
|
|
595
|
+
logger.debug('Expo Router not ready, retrying in', delay, 'ms');
|
|
596
|
+
}
|
|
597
|
+
setTimeout(trySetup, delay);
|
|
598
|
+
} else {
|
|
599
|
+
if (__DEV__) {
|
|
600
|
+
logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
|
|
601
|
+
logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
setTimeout(trySetup, 200);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Set up Expo Router auto-tracking by polling the internal router store
|
|
610
|
+
*
|
|
611
|
+
* Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
|
|
612
|
+
*/
|
|
613
|
+
function trySetupExpoRouter() {
|
|
614
|
+
try {
|
|
615
|
+
const expoRouter = require('expo-router');
|
|
616
|
+
const router = expoRouter.router;
|
|
617
|
+
if (!router) {
|
|
618
|
+
if (__DEV__) {
|
|
619
|
+
logger.debug('Expo Router: router object not found');
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
if (__DEV__) {
|
|
624
|
+
logger.debug('Expo Router: Setting up navigation tracking');
|
|
625
|
+
}
|
|
626
|
+
const {
|
|
627
|
+
normalizeScreenName,
|
|
628
|
+
getScreenNameFromPath
|
|
629
|
+
} = require('./navigation');
|
|
630
|
+
navigationPollingInterval = setInterval(() => {
|
|
631
|
+
try {
|
|
632
|
+
let state = null;
|
|
633
|
+
let stateSource = '';
|
|
634
|
+
if (typeof router.getState === 'function') {
|
|
635
|
+
state = router.getState();
|
|
636
|
+
stateSource = 'router.getState()';
|
|
637
|
+
} else if (router.rootState) {
|
|
638
|
+
state = router.rootState;
|
|
639
|
+
stateSource = 'router.rootState';
|
|
640
|
+
}
|
|
641
|
+
if (!state) {
|
|
642
|
+
try {
|
|
643
|
+
const storeModule = require('expo-router/build/global-state/router-store');
|
|
644
|
+
if (storeModule?.store) {
|
|
645
|
+
state = storeModule.store.state;
|
|
646
|
+
if (state) stateSource = 'store.state';
|
|
647
|
+
if (!state && storeModule.store.navigationRef?.current) {
|
|
648
|
+
state = storeModule.store.navigationRef.current.getRootState?.();
|
|
649
|
+
if (state) stateSource = 'navigationRef.getRootState()';
|
|
650
|
+
}
|
|
651
|
+
if (!state) {
|
|
652
|
+
state = storeModule.store.rootState || storeModule.store.initialState;
|
|
653
|
+
if (state) stateSource = 'store.rootState/initialState';
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
// Ignore
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (!state) {
|
|
661
|
+
try {
|
|
662
|
+
const imperative = require('expo-router/build/imperative-api');
|
|
663
|
+
if (imperative?.router) {
|
|
664
|
+
state = imperative.router.getState?.();
|
|
665
|
+
if (state) stateSource = 'imperative-api';
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
// Ignore
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (state) {
|
|
672
|
+
navigationPollingErrors = 0;
|
|
673
|
+
navigationPollingErrors = 0;
|
|
674
|
+
const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
|
|
675
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
676
|
+
if (__DEV__) {
|
|
677
|
+
logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
|
|
678
|
+
}
|
|
679
|
+
lastDetectedScreen = screenName;
|
|
680
|
+
trackScreen(screenName);
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
navigationPollingErrors++;
|
|
684
|
+
if (__DEV__ && navigationPollingErrors === 1) {
|
|
685
|
+
logger.debug('Expo Router: Could not get navigation state');
|
|
686
|
+
}
|
|
687
|
+
if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
|
|
688
|
+
cleanupNavigationTracking();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch (e) {
|
|
692
|
+
navigationPollingErrors++;
|
|
693
|
+
if (__DEV__ && navigationPollingErrors === 1) {
|
|
694
|
+
logger.debug('Expo Router polling error:', e);
|
|
695
|
+
}
|
|
696
|
+
if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
|
|
697
|
+
cleanupNavigationTracking();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}, 500);
|
|
701
|
+
return true;
|
|
702
|
+
} catch (e) {
|
|
703
|
+
if (__DEV__) {
|
|
704
|
+
logger.debug('Expo Router not available:', e);
|
|
705
|
+
}
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Extract screen name from Expo Router navigation state
|
|
712
|
+
*
|
|
713
|
+
* Handles complex nested structures like Drawer → Tabs → Stack
|
|
714
|
+
* by recursively accumulating segments from each navigation level.
|
|
715
|
+
*/
|
|
716
|
+
function extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName, accumulatedSegments = []) {
|
|
717
|
+
if (!state?.routes) return null;
|
|
718
|
+
const route = state.routes[state.index ?? state.routes.length - 1];
|
|
719
|
+
if (!route) return null;
|
|
720
|
+
const newSegments = [...accumulatedSegments, route.name];
|
|
721
|
+
if (route.state) {
|
|
722
|
+
return extractScreenNameFromRouterState(route.state, getScreenNameFromPath, normalizeScreenName, newSegments);
|
|
723
|
+
}
|
|
724
|
+
const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
|
|
725
|
+
if (cleanSegments.length === 0) {
|
|
726
|
+
for (let i = newSegments.length - 1; i >= 0; i--) {
|
|
727
|
+
const seg = newSegments[i];
|
|
728
|
+
if (seg && !seg.startsWith('(') && !seg.endsWith(')')) {
|
|
729
|
+
cleanSegments.push(seg);
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const pathname = '/' + cleanSegments.join('/');
|
|
735
|
+
return getScreenNameFromPath(pathname, newSegments);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Cleanup navigation tracking
|
|
740
|
+
*/
|
|
741
|
+
function cleanupNavigationTracking() {
|
|
742
|
+
if (navigationPollingInterval) {
|
|
743
|
+
clearInterval(navigationPollingInterval);
|
|
744
|
+
navigationPollingInterval = null;
|
|
745
|
+
}
|
|
746
|
+
navigationSetupDone = false;
|
|
747
|
+
lastDetectedScreen = '';
|
|
748
|
+
navigationPollingErrors = 0;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Track a screen view
|
|
753
|
+
* This updates JS metrics AND notifies the native module to send to backend
|
|
754
|
+
*/
|
|
755
|
+
export function trackScreen(screenName) {
|
|
756
|
+
if (!isInitialized) {
|
|
757
|
+
if (__DEV__) {
|
|
758
|
+
logger.debug('trackScreen called but not initialized, screen:', screenName);
|
|
759
|
+
}
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const previousScreen = currentScreen;
|
|
763
|
+
currentScreen = screenName;
|
|
764
|
+
screensVisited.push(screenName);
|
|
765
|
+
const uniqueScreens = new Set(screensVisited);
|
|
766
|
+
metrics.uniqueScreensCount = uniqueScreens.size;
|
|
767
|
+
metrics.navigationCount++;
|
|
768
|
+
metrics.totalEvents++;
|
|
769
|
+
if (__DEV__) {
|
|
770
|
+
logger.debug('trackScreen:', screenName, '(total screens:', metrics.uniqueScreensCount, ')');
|
|
771
|
+
}
|
|
772
|
+
if (onScreenChange) {
|
|
773
|
+
onScreenChange(screenName, previousScreen);
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const RejourneyNative = getRejourneyNativeModule();
|
|
777
|
+
if (RejourneyNative?.screenChanged) {
|
|
778
|
+
if (__DEV__) {
|
|
779
|
+
logger.debug('Notifying native screenChanged:', screenName);
|
|
780
|
+
}
|
|
781
|
+
RejourneyNative.screenChanged(screenName).catch(e => {
|
|
782
|
+
if (__DEV__) {
|
|
783
|
+
logger.debug('Native screenChanged error:', e);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
} else if (__DEV__) {
|
|
787
|
+
logger.debug('Native screenChanged method not available');
|
|
788
|
+
}
|
|
789
|
+
} catch (e) {
|
|
790
|
+
if (__DEV__) {
|
|
791
|
+
logger.debug('trackScreen native call error:', e);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Track an API request with timing data
|
|
798
|
+
*/
|
|
799
|
+
export function trackAPIRequest(success, _statusCode, durationMs = 0, responseBytes = 0) {
|
|
800
|
+
if (!isInitialized) return;
|
|
801
|
+
metrics.apiTotalCount++;
|
|
802
|
+
if (durationMs > 0) {
|
|
803
|
+
metrics.netTotalDurationMs += durationMs;
|
|
804
|
+
}
|
|
805
|
+
if (responseBytes > 0) {
|
|
806
|
+
metrics.netTotalBytes += responseBytes;
|
|
807
|
+
}
|
|
808
|
+
if (success) {
|
|
809
|
+
metrics.apiSuccessCount++;
|
|
810
|
+
} else {
|
|
811
|
+
metrics.apiErrorCount++;
|
|
812
|
+
metrics.errorCount++;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Create empty metrics object
|
|
818
|
+
*/
|
|
819
|
+
function createEmptyMetrics() {
|
|
820
|
+
return {
|
|
821
|
+
totalEvents: 0,
|
|
822
|
+
touchCount: 0,
|
|
823
|
+
scrollCount: 0,
|
|
824
|
+
gestureCount: 0,
|
|
825
|
+
inputCount: 0,
|
|
826
|
+
navigationCount: 0,
|
|
827
|
+
errorCount: 0,
|
|
828
|
+
rageTapCount: 0,
|
|
829
|
+
deadTapCount: 0,
|
|
830
|
+
apiSuccessCount: 0,
|
|
831
|
+
apiErrorCount: 0,
|
|
832
|
+
apiTotalCount: 0,
|
|
833
|
+
netTotalDurationMs: 0,
|
|
834
|
+
netTotalBytes: 0,
|
|
835
|
+
screensVisited: [],
|
|
836
|
+
uniqueScreensCount: 0,
|
|
837
|
+
interactionScore: 100,
|
|
838
|
+
explorationScore: 100,
|
|
839
|
+
uxScore: 100
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Track a scroll event
|
|
845
|
+
*/
|
|
846
|
+
export function trackScroll() {
|
|
847
|
+
if (!isInitialized) return;
|
|
848
|
+
metrics.scrollCount++;
|
|
849
|
+
metrics.totalEvents++;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Track a gesture event
|
|
854
|
+
*/
|
|
855
|
+
export function trackGesture() {
|
|
856
|
+
if (!isInitialized) return;
|
|
857
|
+
metrics.gestureCount++;
|
|
858
|
+
metrics.totalEvents++;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Track an input event (keyboard)
|
|
863
|
+
*/
|
|
864
|
+
export function trackInput() {
|
|
865
|
+
if (!isInitialized) return;
|
|
866
|
+
metrics.inputCount++;
|
|
867
|
+
metrics.totalEvents++;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Get current session metrics
|
|
872
|
+
*/
|
|
873
|
+
export function getSessionMetrics() {
|
|
874
|
+
calculateScores();
|
|
875
|
+
const netAvgDurationMs = metrics.apiTotalCount > 0 ? Math.round(metrics.netTotalDurationMs / metrics.apiTotalCount) : 0;
|
|
876
|
+
return {
|
|
877
|
+
...metrics,
|
|
878
|
+
screensVisited: [...screensVisited],
|
|
879
|
+
netAvgDurationMs
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Calculate session scores
|
|
885
|
+
*/
|
|
886
|
+
function calculateScores() {
|
|
887
|
+
const totalInteractions = metrics.touchCount + metrics.scrollCount + metrics.gestureCount + metrics.inputCount;
|
|
888
|
+
const avgInteractions = 50;
|
|
889
|
+
metrics.interactionScore = Math.min(100, Math.round(totalInteractions / avgInteractions * 100));
|
|
890
|
+
const avgScreens = 5;
|
|
891
|
+
metrics.explorationScore = Math.min(100, Math.round(metrics.uniqueScreensCount / avgScreens * 100));
|
|
892
|
+
let uxScore = 100;
|
|
893
|
+
uxScore -= Math.min(30, metrics.errorCount * 15);
|
|
894
|
+
uxScore -= Math.min(24, metrics.rageTapCount * 8);
|
|
895
|
+
uxScore -= Math.min(16, metrics.deadTapCount * 4);
|
|
896
|
+
uxScore -= Math.min(20, metrics.apiErrorCount * 10);
|
|
897
|
+
if (metrics.uniqueScreensCount >= 3) {
|
|
898
|
+
uxScore += 5;
|
|
899
|
+
}
|
|
900
|
+
metrics.uxScore = Math.max(0, Math.min(100, uxScore));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Reset metrics for new session
|
|
905
|
+
*/
|
|
906
|
+
export function resetMetrics() {
|
|
907
|
+
metrics = createEmptyMetrics();
|
|
908
|
+
screensVisited = [];
|
|
909
|
+
currentScreen = '';
|
|
910
|
+
tapHead = 0;
|
|
911
|
+
tapCount = 0;
|
|
912
|
+
sessionStartTime = Date.now();
|
|
913
|
+
}
|
|
914
|
+
export function setMaxSessionDurationMinutes(minutes) {
|
|
915
|
+
const clampedMinutes = Math.min(10, Math.max(1, minutes ?? 10));
|
|
916
|
+
maxSessionDurationMs = clampedMinutes * 60 * 1000;
|
|
917
|
+
}
|
|
918
|
+
export function hasExceededMaxSessionDuration() {
|
|
919
|
+
if (!sessionStartTime) return false;
|
|
920
|
+
return Date.now() - sessionStartTime >= maxSessionDurationMs;
|
|
921
|
+
}
|
|
922
|
+
export function getRemainingSessionDurationMs() {
|
|
923
|
+
if (!sessionStartTime) return maxSessionDurationMs;
|
|
924
|
+
const remaining = maxSessionDurationMs - (Date.now() - sessionStartTime);
|
|
925
|
+
return Math.max(0, remaining);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Collect device information
|
|
930
|
+
*/
|
|
931
|
+
/**
|
|
932
|
+
* Collect device information
|
|
933
|
+
*/
|
|
934
|
+
export async function collectDeviceInfo() {
|
|
935
|
+
const Dimensions = getDimensions();
|
|
936
|
+
const Platform = getPlatform();
|
|
937
|
+
let width = 0,
|
|
938
|
+
height = 0,
|
|
939
|
+
scale = 1;
|
|
940
|
+
if (Dimensions) {
|
|
941
|
+
const windowDims = Dimensions.get('window');
|
|
942
|
+
const screenDims = Dimensions.get('screen');
|
|
943
|
+
width = windowDims?.width || 0;
|
|
944
|
+
height = windowDims?.height || 0;
|
|
945
|
+
scale = screenDims?.scale || 1;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Basic JS-side info
|
|
949
|
+
let locale;
|
|
950
|
+
let timezone;
|
|
951
|
+
try {
|
|
952
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
953
|
+
locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
954
|
+
} catch {
|
|
955
|
+
// Ignore
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Get native info
|
|
959
|
+
const nativeModule = getRejourneyNativeModule();
|
|
960
|
+
let nativeInfo = {};
|
|
961
|
+
if (nativeModule && nativeModule.getDeviceInfo) {
|
|
962
|
+
try {
|
|
963
|
+
nativeInfo = await nativeModule.getDeviceInfo();
|
|
964
|
+
} catch (e) {
|
|
965
|
+
if (__DEV__) {
|
|
966
|
+
console.warn('[Rejourney] Failed to get native device info:', e);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
model: nativeInfo.model || 'Unknown',
|
|
972
|
+
manufacturer: nativeInfo.brand,
|
|
973
|
+
os: Platform?.OS || 'ios',
|
|
974
|
+
osVersion: nativeInfo.systemVersion || Platform?.Version?.toString() || 'Unknown',
|
|
975
|
+
screenWidth: Math.round(width),
|
|
976
|
+
screenHeight: Math.round(height),
|
|
977
|
+
pixelRatio: scale,
|
|
978
|
+
appVersion: nativeInfo.appVersion,
|
|
979
|
+
appId: nativeInfo.bundleId,
|
|
980
|
+
locale: locale,
|
|
981
|
+
timezone: timezone
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Generate a persistent anonymous ID
|
|
987
|
+
*/
|
|
988
|
+
function generateAnonymousId() {
|
|
989
|
+
const timestamp = Date.now().toString(36);
|
|
990
|
+
const random = Math.random().toString(36).substring(2, 15);
|
|
991
|
+
return `anon_${timestamp}_${random}`;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Get the anonymous ID (synchronous - returns generated ID immediately)
|
|
996
|
+
*/
|
|
997
|
+
export function getAnonymousId() {
|
|
998
|
+
if (!anonymousId) {
|
|
999
|
+
anonymousId = generateAnonymousId();
|
|
1000
|
+
}
|
|
1001
|
+
return anonymousId;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Ensure a stable, persisted anonymous/device ID is available.
|
|
1006
|
+
* Returns the stored ID if present, otherwise generates and persists one.
|
|
1007
|
+
*/
|
|
1008
|
+
export async function ensurePersistentAnonymousId() {
|
|
1009
|
+
if (anonymousId) return anonymousId;
|
|
1010
|
+
if (!anonymousIdPromise) {
|
|
1011
|
+
anonymousIdPromise = (async () => {
|
|
1012
|
+
const id = await loadAnonymousId();
|
|
1013
|
+
anonymousId = id;
|
|
1014
|
+
return id;
|
|
1015
|
+
})();
|
|
1016
|
+
}
|
|
1017
|
+
return anonymousIdPromise;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Load anonymous ID from persistent storage
|
|
1022
|
+
* Call this at app startup for best results
|
|
1023
|
+
*/
|
|
1024
|
+
export async function loadAnonymousId() {
|
|
1025
|
+
const nativeModule = getRejourneyNativeModule();
|
|
1026
|
+
if (nativeModule && nativeModule.getUserIdentity) {
|
|
1027
|
+
try {
|
|
1028
|
+
return (await nativeModule.getUserIdentity()) || generateAnonymousId();
|
|
1029
|
+
} catch {
|
|
1030
|
+
return generateAnonymousId();
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return generateAnonymousId();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Set a custom anonymous ID
|
|
1038
|
+
*/
|
|
1039
|
+
export function setAnonymousId(id) {
|
|
1040
|
+
anonymousId = id;
|
|
1041
|
+
}
|
|
1042
|
+
export default {
|
|
1043
|
+
init: initAutoTracking,
|
|
1044
|
+
cleanup: cleanupAutoTracking,
|
|
1045
|
+
trackTap,
|
|
1046
|
+
trackScroll,
|
|
1047
|
+
trackGesture,
|
|
1048
|
+
trackInput,
|
|
1049
|
+
trackScreen,
|
|
1050
|
+
trackAPIRequest,
|
|
1051
|
+
captureError,
|
|
1052
|
+
getMetrics: getSessionMetrics,
|
|
1053
|
+
resetMetrics,
|
|
1054
|
+
collectDeviceInfo,
|
|
1055
|
+
getAnonymousId,
|
|
1056
|
+
setAnonymousId,
|
|
1057
|
+
markTapHandled
|
|
1058
|
+
};
|
|
1059
|
+
//# sourceMappingURL=autoTracking.js.map
|