@rejourneyco/react-native 1.0.0
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/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 +2981 -0
- package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
- package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
- package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
- package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
- package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
- package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
- package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
- package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
- package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
- package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
- package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
- package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
- package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
- package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
- package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
- package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
- package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
- package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
- package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
- package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
- package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
- package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
- package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
- package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
- package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
- package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
- package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
- package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
- package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Capture/RJANRHandler.h +42 -0
- package/ios/Capture/RJANRHandler.m +328 -0
- package/ios/Capture/RJCaptureEngine.h +275 -0
- package/ios/Capture/RJCaptureEngine.m +2062 -0
- package/ios/Capture/RJCaptureHeuristics.h +80 -0
- package/ios/Capture/RJCaptureHeuristics.m +903 -0
- package/ios/Capture/RJCrashHandler.h +46 -0
- package/ios/Capture/RJCrashHandler.m +313 -0
- package/ios/Capture/RJMotionEvent.h +183 -0
- package/ios/Capture/RJMotionEvent.m +183 -0
- package/ios/Capture/RJPerformanceManager.h +100 -0
- package/ios/Capture/RJPerformanceManager.m +373 -0
- package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
- package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
- package/ios/Capture/RJSegmentUploader.h +146 -0
- package/ios/Capture/RJSegmentUploader.m +778 -0
- package/ios/Capture/RJVideoEncoder.h +247 -0
- package/ios/Capture/RJVideoEncoder.m +1036 -0
- package/ios/Capture/RJViewControllerTracker.h +73 -0
- package/ios/Capture/RJViewControllerTracker.m +508 -0
- package/ios/Capture/RJViewHierarchyScanner.h +215 -0
- package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
- package/ios/Capture/RJViewSerializer.h +119 -0
- package/ios/Capture/RJViewSerializer.m +498 -0
- package/ios/Core/RJConstants.h +124 -0
- package/ios/Core/RJConstants.m +88 -0
- package/ios/Core/RJLifecycleManager.h +85 -0
- package/ios/Core/RJLifecycleManager.m +308 -0
- package/ios/Core/RJLogger.h +61 -0
- package/ios/Core/RJLogger.m +211 -0
- package/ios/Core/RJTypes.h +176 -0
- package/ios/Core/RJTypes.m +66 -0
- package/ios/Core/Rejourney.h +64 -0
- package/ios/Core/Rejourney.mm +2495 -0
- package/ios/Network/RJDeviceAuthManager.h +94 -0
- package/ios/Network/RJDeviceAuthManager.m +967 -0
- package/ios/Network/RJNetworkMonitor.h +68 -0
- package/ios/Network/RJNetworkMonitor.m +267 -0
- package/ios/Network/RJRetryManager.h +73 -0
- package/ios/Network/RJRetryManager.m +325 -0
- package/ios/Network/RJUploadManager.h +267 -0
- package/ios/Network/RJUploadManager.m +2296 -0
- package/ios/Privacy/RJPrivacyMask.h +163 -0
- package/ios/Privacy/RJPrivacyMask.m +922 -0
- package/ios/Rejourney.h +63 -0
- package/ios/Touch/RJGestureClassifier.h +130 -0
- package/ios/Touch/RJGestureClassifier.m +333 -0
- package/ios/Touch/RJTouchInterceptor.h +169 -0
- package/ios/Touch/RJTouchInterceptor.m +772 -0
- package/ios/Utils/RJEventBuffer.h +112 -0
- package/ios/Utils/RJEventBuffer.m +358 -0
- package/ios/Utils/RJGzipUtils.h +33 -0
- package/ios/Utils/RJGzipUtils.m +89 -0
- package/ios/Utils/RJKeychainManager.h +48 -0
- package/ios/Utils/RJKeychainManager.m +111 -0
- package/ios/Utils/RJPerfTiming.h +209 -0
- package/ios/Utils/RJPerfTiming.m +264 -0
- package/ios/Utils/RJTelemetry.h +92 -0
- package/ios/Utils/RJTelemetry.m +320 -0
- package/ios/Utils/RJWindowUtils.h +66 -0
- package/ios/Utils/RJWindowUtils.m +133 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +79 -0
- package/lib/commonjs/index.js +1381 -0
- package/lib/commonjs/sdk/autoTracking.js +1259 -0
- package/lib/commonjs/sdk/constants.js +151 -0
- package/lib/commonjs/sdk/errorTracking.js +199 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +204 -0
- package/lib/commonjs/sdk/navigation.js +151 -0
- package/lib/commonjs/sdk/networkInterceptor.js +412 -0
- package/lib/commonjs/sdk/utils.js +363 -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 +72 -0
- package/lib/module/index.js +1284 -0
- package/lib/module/sdk/autoTracking.js +1233 -0
- package/lib/module/sdk/constants.js +145 -0
- package/lib/module/sdk/errorTracking.js +189 -0
- package/lib/module/sdk/index.js +12 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +143 -0
- package/lib/module/sdk/networkInterceptor.js +401 -0
- package/lib/module/sdk/utils.js +342 -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 +147 -0
- package/lib/typescript/components/Mask.d.ts +39 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +204 -0
- package/lib/typescript/sdk/constants.d.ts +120 -0
- package/lib/typescript/sdk/errorTracking.d.ts +32 -0
- package/lib/typescript/sdk/index.d.ts +9 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
- package/lib/typescript/sdk/navigation.d.ts +33 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
- package/lib/typescript/sdk/utils.d.ts +148 -0
- package/lib/typescript/types/index.d.ts +624 -0
- package/package.json +102 -0
- package/rejourney.podspec +21 -0
- package/src/NativeRejourney.ts +165 -0
- package/src/components/Mask.tsx +80 -0
- package/src/index.ts +1459 -0
- package/src/sdk/autoTracking.ts +1373 -0
- package/src/sdk/constants.ts +134 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +11 -0
- package/src/sdk/metricsTracking.ts +232 -0
- package/src/sdk/navigation.ts +157 -0
- package/src/sdk/networkInterceptor.ts +440 -0
- package/src/sdk/utils.ts +369 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +739 -0
|
@@ -0,0 +1,1373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejourney Auto Tracking Module
|
|
3
|
+
*
|
|
4
|
+
* Automatic tracking features that work with just init() - no additional code needed.
|
|
5
|
+
* This module handles:
|
|
6
|
+
* - Rage tap detection
|
|
7
|
+
* - Error tracking (JS + React Native)
|
|
8
|
+
* - Session metrics aggregation
|
|
9
|
+
* - Device info collection
|
|
10
|
+
* - Anonymous ID generation
|
|
11
|
+
* - Funnel/screen tracking
|
|
12
|
+
* - Score calculations
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: This file uses lazy loading for react-native imports to avoid
|
|
15
|
+
* "PlatformConstants could not be found" errors on RN 0.81+.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
DeviceInfo,
|
|
20
|
+
ErrorEvent,
|
|
21
|
+
} from '../types';
|
|
22
|
+
import { logger } from './utils';
|
|
23
|
+
|
|
24
|
+
// Lazy-loaded React Native modules
|
|
25
|
+
let _RN: typeof import('react-native') | null = null;
|
|
26
|
+
|
|
27
|
+
function getRN(): typeof import('react-native') | null {
|
|
28
|
+
if (_RN) return _RN;
|
|
29
|
+
try {
|
|
30
|
+
_RN = require('react-native');
|
|
31
|
+
return _RN;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getPlatform() {
|
|
38
|
+
return getRN()?.Platform;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getDimensions() {
|
|
42
|
+
return getRN()?.Dimensions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getNativeModules() {
|
|
46
|
+
return getRN()?.NativeModules;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getRejourneyNativeModule() {
|
|
50
|
+
const RN = getRN();
|
|
51
|
+
if (!RN) return null;
|
|
52
|
+
|
|
53
|
+
const { TurboModuleRegistry, NativeModules } = RN;
|
|
54
|
+
let nativeModule = null;
|
|
55
|
+
|
|
56
|
+
if (TurboModuleRegistry && typeof TurboModuleRegistry.get === 'function') {
|
|
57
|
+
try {
|
|
58
|
+
nativeModule = TurboModuleRegistry.get('Rejourney');
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore and fall back to NativeModules
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!nativeModule && NativeModules) {
|
|
65
|
+
nativeModule = NativeModules.Rejourney ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return nativeModule;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Type declarations for browser globals (only used in hybrid apps where DOM is available)
|
|
72
|
+
// These don't exist in pure React Native but are needed for error tracking in hybrid scenarios
|
|
73
|
+
type OnErrorEventHandler = ((
|
|
74
|
+
event: Event | string,
|
|
75
|
+
source?: string,
|
|
76
|
+
lineno?: number,
|
|
77
|
+
colno?: number,
|
|
78
|
+
error?: Error
|
|
79
|
+
) => boolean | void) | null;
|
|
80
|
+
|
|
81
|
+
interface PromiseRejectionEvent {
|
|
82
|
+
reason?: any;
|
|
83
|
+
promise?: Promise<any>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cast globalThis to work with both RN and hybrid scenarios
|
|
87
|
+
const _globalThis = globalThis as typeof globalThis & {
|
|
88
|
+
onerror?: OnErrorEventHandler;
|
|
89
|
+
addEventListener?: (type: string, handler: (event: any) => void) => void;
|
|
90
|
+
removeEventListener?: (type: string, handler: (event: any) => void) => void;
|
|
91
|
+
ErrorUtils?: {
|
|
92
|
+
getGlobalHandler: () => ((error: Error, isFatal: boolean) => void) | undefined;
|
|
93
|
+
setGlobalHandler: (handler: (error: Error, isFatal: boolean) => void) => void;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Types
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
export interface TapEvent {
|
|
102
|
+
x: number;
|
|
103
|
+
y: number;
|
|
104
|
+
timestamp: number;
|
|
105
|
+
targetId?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SessionMetrics {
|
|
109
|
+
// Core counts
|
|
110
|
+
totalEvents: number;
|
|
111
|
+
touchCount: number;
|
|
112
|
+
scrollCount: number;
|
|
113
|
+
gestureCount: number;
|
|
114
|
+
inputCount: number;
|
|
115
|
+
navigationCount: number;
|
|
116
|
+
|
|
117
|
+
// Issue tracking
|
|
118
|
+
errorCount: number;
|
|
119
|
+
rageTapCount: number;
|
|
120
|
+
|
|
121
|
+
// API metrics
|
|
122
|
+
apiSuccessCount: number;
|
|
123
|
+
apiErrorCount: number;
|
|
124
|
+
apiTotalCount: number;
|
|
125
|
+
|
|
126
|
+
// Network timing (for avg calculation)
|
|
127
|
+
netTotalDurationMs: number; // Sum of all API durations
|
|
128
|
+
netTotalBytes: number; // Sum of all response bytes
|
|
129
|
+
|
|
130
|
+
// Screen tracking for funnels
|
|
131
|
+
screensVisited: string[];
|
|
132
|
+
uniqueScreensCount: number;
|
|
133
|
+
|
|
134
|
+
// Scores (0-100)
|
|
135
|
+
interactionScore: number;
|
|
136
|
+
explorationScore: number;
|
|
137
|
+
uxScore: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface AutoTrackingConfig {
|
|
141
|
+
// Rage tap detection
|
|
142
|
+
rageTapThreshold?: number; // Number of taps (default: 3)
|
|
143
|
+
rageTapTimeWindow?: number; // Time window in ms (default: 500)
|
|
144
|
+
rageTapRadius?: number; // Max radius in pixels (default: 50)
|
|
145
|
+
|
|
146
|
+
// Error tracking
|
|
147
|
+
trackJSErrors?: boolean; // Track window errors (default: true)
|
|
148
|
+
trackPromiseRejections?: boolean; // Track unhandled rejections (default: true)
|
|
149
|
+
trackReactNativeErrors?: boolean; // Track RN ErrorUtils (default: true)
|
|
150
|
+
|
|
151
|
+
// Session settings
|
|
152
|
+
collectDeviceInfo?: boolean; // Collect device info (default: true)
|
|
153
|
+
maxSessionDurationMs?: number; // Clamp to 1–10 minutes; default set from server/config
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// State
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
let isInitialized = false;
|
|
161
|
+
let config: AutoTrackingConfig = {};
|
|
162
|
+
|
|
163
|
+
// Rage tap tracking
|
|
164
|
+
const recentTaps: TapEvent[] = [];
|
|
165
|
+
let tapHead = 0; // Circular buffer head pointer
|
|
166
|
+
let tapCount = 0; // Actual count of taps in buffer
|
|
167
|
+
const MAX_RECENT_TAPS = 10;
|
|
168
|
+
|
|
169
|
+
// Session metrics
|
|
170
|
+
let metrics: SessionMetrics = createEmptyMetrics();
|
|
171
|
+
let sessionStartTime: number = 0;
|
|
172
|
+
let maxSessionDurationMs: number = 10 * 60 * 1000;
|
|
173
|
+
|
|
174
|
+
// Screen tracking
|
|
175
|
+
let currentScreen = '';
|
|
176
|
+
let screensVisited: string[] = [];
|
|
177
|
+
|
|
178
|
+
// Anonymous ID
|
|
179
|
+
let anonymousId: string | null = null;
|
|
180
|
+
let anonymousIdPromise: Promise<string> | null = null;
|
|
181
|
+
|
|
182
|
+
// Callbacks
|
|
183
|
+
let onRageTapDetected: ((count: number, x: number, y: number) => void) | null = null;
|
|
184
|
+
let onErrorCaptured: ((error: ErrorEvent) => void) | null = null;
|
|
185
|
+
let onScreenChange: ((screenName: string, previousScreen?: string) => void) | null = null;
|
|
186
|
+
|
|
187
|
+
// Original error handlers (for restoration)
|
|
188
|
+
let originalErrorHandler: ((error: Error, isFatal: boolean) => void) | undefined;
|
|
189
|
+
let originalOnError: OnErrorEventHandler | null = null;
|
|
190
|
+
let originalOnUnhandledRejection: ((event: PromiseRejectionEvent) => void) | null = null;
|
|
191
|
+
|
|
192
|
+
// =============================================================================
|
|
193
|
+
// Initialization
|
|
194
|
+
// =============================================================================
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Initialize auto tracking features
|
|
198
|
+
* Called automatically by Rejourney.init() - no user action needed
|
|
199
|
+
*/
|
|
200
|
+
export function initAutoTracking(
|
|
201
|
+
trackingConfig: AutoTrackingConfig,
|
|
202
|
+
callbacks: {
|
|
203
|
+
onRageTap?: (count: number, x: number, y: number) => void;
|
|
204
|
+
onError?: (error: ErrorEvent) => void;
|
|
205
|
+
onScreen?: (screenName: string, previousScreen?: string) => void;
|
|
206
|
+
} = {}
|
|
207
|
+
): void {
|
|
208
|
+
if (isInitialized) return;
|
|
209
|
+
|
|
210
|
+
config = {
|
|
211
|
+
rageTapThreshold: 3,
|
|
212
|
+
rageTapTimeWindow: 500,
|
|
213
|
+
rageTapRadius: 50,
|
|
214
|
+
trackJSErrors: true,
|
|
215
|
+
trackPromiseRejections: true,
|
|
216
|
+
trackReactNativeErrors: true,
|
|
217
|
+
collectDeviceInfo: true,
|
|
218
|
+
maxSessionDurationMs: trackingConfig.maxSessionDurationMs,
|
|
219
|
+
...trackingConfig,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Session timing
|
|
223
|
+
sessionStartTime = Date.now();
|
|
224
|
+
setMaxSessionDurationMinutes(
|
|
225
|
+
trackingConfig.maxSessionDurationMs
|
|
226
|
+
? trackingConfig.maxSessionDurationMs / 60000
|
|
227
|
+
: undefined
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Set callbacks
|
|
231
|
+
onRageTapDetected = callbacks.onRageTap || null;
|
|
232
|
+
onErrorCaptured = callbacks.onError || null;
|
|
233
|
+
onScreenChange = callbacks.onScreen || null;
|
|
234
|
+
|
|
235
|
+
// Initialize metrics
|
|
236
|
+
metrics = createEmptyMetrics();
|
|
237
|
+
sessionStartTime = Date.now();
|
|
238
|
+
anonymousId = generateAnonymousId();
|
|
239
|
+
|
|
240
|
+
// Setup error tracking
|
|
241
|
+
setupErrorTracking();
|
|
242
|
+
|
|
243
|
+
// Setup React Navigation tracking (if available)
|
|
244
|
+
setupNavigationTracking();
|
|
245
|
+
|
|
246
|
+
isInitialized = true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Cleanup auto tracking features
|
|
251
|
+
*/
|
|
252
|
+
export function cleanupAutoTracking(): void {
|
|
253
|
+
if (!isInitialized) return;
|
|
254
|
+
|
|
255
|
+
// Restore original error handlers
|
|
256
|
+
restoreErrorHandlers();
|
|
257
|
+
|
|
258
|
+
// Cleanup navigation tracking
|
|
259
|
+
cleanupNavigationTracking();
|
|
260
|
+
|
|
261
|
+
// Reset state
|
|
262
|
+
tapHead = 0;
|
|
263
|
+
tapCount = 0;
|
|
264
|
+
metrics = createEmptyMetrics();
|
|
265
|
+
screensVisited = [];
|
|
266
|
+
currentScreen = '';
|
|
267
|
+
sessionStartTime = 0;
|
|
268
|
+
maxSessionDurationMs = 10 * 60 * 1000;
|
|
269
|
+
isInitialized = false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// =============================================================================
|
|
273
|
+
// Rage Tap Detection
|
|
274
|
+
// =============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Track a tap event for rage tap detection
|
|
278
|
+
* Called automatically from touch interceptor
|
|
279
|
+
*/
|
|
280
|
+
export function trackTap(tap: TapEvent): void {
|
|
281
|
+
if (!isInitialized) return;
|
|
282
|
+
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
|
|
285
|
+
// Add to circular buffer (O(1) instead of shift() which is O(n))
|
|
286
|
+
const insertIndex = (tapHead + tapCount) % MAX_RECENT_TAPS;
|
|
287
|
+
if (tapCount < MAX_RECENT_TAPS) {
|
|
288
|
+
recentTaps[insertIndex] = { ...tap, timestamp: now };
|
|
289
|
+
tapCount++;
|
|
290
|
+
} else {
|
|
291
|
+
// Buffer full, overwrite oldest
|
|
292
|
+
recentTaps[tapHead] = { ...tap, timestamp: now };
|
|
293
|
+
tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Evict old taps outside time window
|
|
297
|
+
const windowStart = now - (config.rageTapTimeWindow || 500);
|
|
298
|
+
while (tapCount > 0) {
|
|
299
|
+
const oldestTap = recentTaps[tapHead];
|
|
300
|
+
if (oldestTap && oldestTap.timestamp < windowStart) {
|
|
301
|
+
tapHead = (tapHead + 1) % MAX_RECENT_TAPS;
|
|
302
|
+
tapCount--;
|
|
303
|
+
} else {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check for rage tap
|
|
309
|
+
detectRageTap();
|
|
310
|
+
|
|
311
|
+
// Update metrics
|
|
312
|
+
metrics.touchCount++;
|
|
313
|
+
metrics.totalEvents++;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Detect if recent taps form a rage tap pattern
|
|
318
|
+
*/
|
|
319
|
+
function detectRageTap(): void {
|
|
320
|
+
const threshold = config.rageTapThreshold || 3;
|
|
321
|
+
const radius = config.rageTapRadius || 50;
|
|
322
|
+
|
|
323
|
+
if (tapCount < threshold) return;
|
|
324
|
+
// Check last N taps from circular buffer
|
|
325
|
+
const tapsToCheck: TapEvent[] = [];
|
|
326
|
+
for (let i = 0; i < threshold; i++) {
|
|
327
|
+
const idx = (tapHead + tapCount - threshold + i) % MAX_RECENT_TAPS;
|
|
328
|
+
tapsToCheck.push(recentTaps[idx]!);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Calculate center point
|
|
332
|
+
let centerX = 0;
|
|
333
|
+
let centerY = 0;
|
|
334
|
+
for (const tap of tapsToCheck) {
|
|
335
|
+
centerX += tap.x;
|
|
336
|
+
centerY += tap.y;
|
|
337
|
+
}
|
|
338
|
+
centerX /= tapsToCheck.length;
|
|
339
|
+
centerY /= tapsToCheck.length;
|
|
340
|
+
|
|
341
|
+
// Check if all taps are within radius of center
|
|
342
|
+
let allWithinRadius = true;
|
|
343
|
+
for (const tap of tapsToCheck) {
|
|
344
|
+
const dx = tap.x - centerX;
|
|
345
|
+
const dy = tap.y - centerY;
|
|
346
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
347
|
+
if (distance > radius) {
|
|
348
|
+
allWithinRadius = false;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (allWithinRadius) {
|
|
354
|
+
// Rage tap detected!
|
|
355
|
+
metrics.rageTapCount++;
|
|
356
|
+
// Clear circular buffer to prevent duplicate detection
|
|
357
|
+
tapHead = 0;
|
|
358
|
+
tapCount = 0;
|
|
359
|
+
|
|
360
|
+
// Notify callback
|
|
361
|
+
if (onRageTapDetected) {
|
|
362
|
+
onRageTapDetected(threshold, centerX, centerY);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// =============================================================================
|
|
368
|
+
// State Change Notification
|
|
369
|
+
// =============================================================================
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Notify that a state change occurred (navigation, modal, etc.)
|
|
373
|
+
* Kept for API compatibility
|
|
374
|
+
*/
|
|
375
|
+
export function notifyStateChange(): void {
|
|
376
|
+
// No-op - kept for backward compatibility
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// =============================================================================
|
|
380
|
+
// Error Tracking
|
|
381
|
+
// =============================================================================
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Setup automatic error tracking
|
|
385
|
+
*/
|
|
386
|
+
function setupErrorTracking(): void {
|
|
387
|
+
// Track React Native errors
|
|
388
|
+
if (config.trackReactNativeErrors !== false) {
|
|
389
|
+
setupReactNativeErrorHandler();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Track JavaScript errors (only works in web/debug)
|
|
393
|
+
if (config.trackJSErrors !== false && typeof _globalThis !== 'undefined') {
|
|
394
|
+
setupJSErrorHandler();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Track unhandled promise rejections
|
|
398
|
+
if (config.trackPromiseRejections !== false && typeof _globalThis !== 'undefined') {
|
|
399
|
+
setupPromiseRejectionHandler();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Setup React Native ErrorUtils handler
|
|
405
|
+
*/
|
|
406
|
+
function setupReactNativeErrorHandler(): void {
|
|
407
|
+
try {
|
|
408
|
+
// Access ErrorUtils from global scope
|
|
409
|
+
const ErrorUtils = _globalThis.ErrorUtils;
|
|
410
|
+
if (!ErrorUtils) return;
|
|
411
|
+
|
|
412
|
+
// Store original handler
|
|
413
|
+
originalErrorHandler = ErrorUtils.getGlobalHandler();
|
|
414
|
+
|
|
415
|
+
// Set new handler
|
|
416
|
+
ErrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => {
|
|
417
|
+
// Track the error
|
|
418
|
+
trackError({
|
|
419
|
+
type: 'error',
|
|
420
|
+
timestamp: Date.now(),
|
|
421
|
+
message: error.message || String(error),
|
|
422
|
+
stack: error.stack,
|
|
423
|
+
name: error.name || 'Error',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Call original handler
|
|
427
|
+
if (originalErrorHandler) {
|
|
428
|
+
originalErrorHandler(error, isFatal);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
} catch {
|
|
432
|
+
// ErrorUtils not available
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Setup global JS error handler
|
|
438
|
+
*/
|
|
439
|
+
function setupJSErrorHandler(): void {
|
|
440
|
+
if (typeof _globalThis.onerror !== 'undefined') {
|
|
441
|
+
// Note: In React Native, this typically doesn't fire
|
|
442
|
+
// But we set it up anyway for hybrid apps
|
|
443
|
+
originalOnError = _globalThis.onerror;
|
|
444
|
+
|
|
445
|
+
_globalThis.onerror = (
|
|
446
|
+
message: string | Event,
|
|
447
|
+
source?: string,
|
|
448
|
+
lineno?: number,
|
|
449
|
+
colno?: number,
|
|
450
|
+
error?: Error
|
|
451
|
+
) => {
|
|
452
|
+
trackError({
|
|
453
|
+
type: 'error',
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
message: typeof message === 'string' ? message : 'Unknown error',
|
|
456
|
+
stack: error?.stack || `${source}:${lineno}:${colno}`,
|
|
457
|
+
name: error?.name || 'Error',
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Call original handler
|
|
461
|
+
if (originalOnError) {
|
|
462
|
+
return originalOnError(message, source, lineno, colno, error);
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Setup unhandled promise rejection handler
|
|
471
|
+
*/
|
|
472
|
+
function setupPromiseRejectionHandler(): void {
|
|
473
|
+
if (typeof _globalThis.addEventListener !== 'undefined') {
|
|
474
|
+
const handler = (event: PromiseRejectionEvent) => {
|
|
475
|
+
const reason = event.reason;
|
|
476
|
+
trackError({
|
|
477
|
+
type: 'error',
|
|
478
|
+
timestamp: Date.now(),
|
|
479
|
+
message: reason?.message || String(reason) || 'Unhandled Promise Rejection',
|
|
480
|
+
stack: reason?.stack,
|
|
481
|
+
name: reason?.name || 'UnhandledRejection',
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
originalOnUnhandledRejection = handler;
|
|
486
|
+
_globalThis.addEventListener!('unhandledrejection', handler);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Restore original error handlers
|
|
492
|
+
*/
|
|
493
|
+
function restoreErrorHandlers(): void {
|
|
494
|
+
// Restore React Native handler
|
|
495
|
+
if (originalErrorHandler) {
|
|
496
|
+
try {
|
|
497
|
+
const ErrorUtils = _globalThis.ErrorUtils;
|
|
498
|
+
if (ErrorUtils) {
|
|
499
|
+
ErrorUtils.setGlobalHandler(originalErrorHandler);
|
|
500
|
+
}
|
|
501
|
+
} catch {
|
|
502
|
+
// Ignore
|
|
503
|
+
}
|
|
504
|
+
originalErrorHandler = undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Restore global onerror
|
|
508
|
+
if (originalOnError !== null) {
|
|
509
|
+
_globalThis.onerror = originalOnError;
|
|
510
|
+
originalOnError = null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Remove promise rejection handler
|
|
514
|
+
if (originalOnUnhandledRejection && typeof _globalThis.removeEventListener !== 'undefined') {
|
|
515
|
+
_globalThis.removeEventListener!('unhandledrejection', originalOnUnhandledRejection);
|
|
516
|
+
originalOnUnhandledRejection = null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Track an error
|
|
522
|
+
*/
|
|
523
|
+
function trackError(error: ErrorEvent): void {
|
|
524
|
+
metrics.errorCount++;
|
|
525
|
+
metrics.totalEvents++;
|
|
526
|
+
|
|
527
|
+
if (onErrorCaptured) {
|
|
528
|
+
onErrorCaptured(error);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Manually track an error (for API errors, etc.)
|
|
534
|
+
*/
|
|
535
|
+
export function captureError(
|
|
536
|
+
message: string,
|
|
537
|
+
stack?: string,
|
|
538
|
+
name?: string
|
|
539
|
+
): void {
|
|
540
|
+
trackError({
|
|
541
|
+
type: 'error',
|
|
542
|
+
timestamp: Date.now(),
|
|
543
|
+
message,
|
|
544
|
+
stack,
|
|
545
|
+
name: name || 'Error',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// =============================================================================
|
|
550
|
+
// Screen/Funnel Tracking - Automatic Navigation Detection
|
|
551
|
+
// =============================================================================
|
|
552
|
+
|
|
553
|
+
// Navigation detection state
|
|
554
|
+
let navigationPollingInterval: ReturnType<typeof setInterval> | null = null;
|
|
555
|
+
let lastDetectedScreen = '';
|
|
556
|
+
let navigationSetupDone = false;
|
|
557
|
+
let navigationPollingErrors = 0; // Track consecutive errors
|
|
558
|
+
const MAX_POLLING_ERRORS = 10; // Stop polling after 10 consecutive errors
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Track a navigation state change from React Navigation.
|
|
562
|
+
*
|
|
563
|
+
* For bare React Native apps using @react-navigation/native.
|
|
564
|
+
* Just add this to your NavigationContainer's onStateChange prop.
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```tsx
|
|
568
|
+
* import { trackNavigationState } from 'rejourney';
|
|
569
|
+
*
|
|
570
|
+
* <NavigationContainer onStateChange={trackNavigationState}>
|
|
571
|
+
* ...
|
|
572
|
+
* </NavigationContainer>
|
|
573
|
+
* ```
|
|
574
|
+
*/
|
|
575
|
+
export function trackNavigationState(state: any): void {
|
|
576
|
+
if (!state?.routes) return;
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const { normalizeScreenName } = require('./navigation');
|
|
580
|
+
|
|
581
|
+
// Find the active screen recursively
|
|
582
|
+
const findActiveScreen = (navState: any): string | null => {
|
|
583
|
+
if (!navState?.routes) return null;
|
|
584
|
+
const index = navState.index ?? navState.routes.length - 1;
|
|
585
|
+
const route = navState.routes[index];
|
|
586
|
+
if (!route) return null;
|
|
587
|
+
if (route.state) return findActiveScreen(route.state);
|
|
588
|
+
return normalizeScreenName(route.name || 'Unknown');
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const screenName = findActiveScreen(state);
|
|
592
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
593
|
+
lastDetectedScreen = screenName;
|
|
594
|
+
trackScreen(screenName);
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
// Navigation tracking error
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* React hook for navigation tracking.
|
|
603
|
+
*
|
|
604
|
+
* Returns props to spread on NavigationContainer that will:
|
|
605
|
+
* 1. Track the initial screen on mount (via onReady)
|
|
606
|
+
* 2. Track all subsequent navigations (via onStateChange)
|
|
607
|
+
*
|
|
608
|
+
* This is the RECOMMENDED approach for bare React Native apps.
|
|
609
|
+
*
|
|
610
|
+
* @example
|
|
611
|
+
* ```tsx
|
|
612
|
+
* import { useNavigationTracking } from 'rejourney';
|
|
613
|
+
* import { NavigationContainer } from '@react-navigation/native';
|
|
614
|
+
*
|
|
615
|
+
* function App() {
|
|
616
|
+
* const navigationTracking = useNavigationTracking();
|
|
617
|
+
*
|
|
618
|
+
* return (
|
|
619
|
+
* <NavigationContainer {...navigationTracking}>
|
|
620
|
+
* <RootNavigator />
|
|
621
|
+
* </NavigationContainer>
|
|
622
|
+
* );
|
|
623
|
+
* }
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
export function useNavigationTracking() {
|
|
627
|
+
// Use React's useRef and useCallback to create stable references
|
|
628
|
+
const React = require('react');
|
|
629
|
+
const { createNavigationContainerRef } = require('@react-navigation/native');
|
|
630
|
+
|
|
631
|
+
// Create a stable navigation ref
|
|
632
|
+
const navigationRef = React.useRef(createNavigationContainerRef());
|
|
633
|
+
|
|
634
|
+
// Track initial screen when navigation is ready
|
|
635
|
+
const onReady = React.useCallback(() => {
|
|
636
|
+
try {
|
|
637
|
+
const currentRoute = navigationRef.current?.getCurrentRoute?.();
|
|
638
|
+
if (currentRoute?.name) {
|
|
639
|
+
const { normalizeScreenName } = require('./navigation');
|
|
640
|
+
const screenName = normalizeScreenName(currentRoute.name);
|
|
641
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
642
|
+
lastDetectedScreen = screenName;
|
|
643
|
+
trackScreen(screenName);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
// Navigation not ready yet
|
|
648
|
+
}
|
|
649
|
+
}, []);
|
|
650
|
+
|
|
651
|
+
// Return props to spread on NavigationContainer
|
|
652
|
+
return {
|
|
653
|
+
ref: navigationRef.current,
|
|
654
|
+
onReady,
|
|
655
|
+
onStateChange: trackNavigationState,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Setup automatic Expo Router tracking
|
|
661
|
+
*
|
|
662
|
+
* For Expo apps using expo-router - works automatically.
|
|
663
|
+
* For bare React Native apps - use trackNavigationState instead.
|
|
664
|
+
*/
|
|
665
|
+
function setupNavigationTracking(): void {
|
|
666
|
+
if (navigationSetupDone) return;
|
|
667
|
+
navigationSetupDone = true;
|
|
668
|
+
|
|
669
|
+
if (__DEV__) {
|
|
670
|
+
logger.debug('Setting up navigation tracking...');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Delay to ensure navigation is initialized - Expo Router needs more time
|
|
674
|
+
// We retry a few times with increasing delays
|
|
675
|
+
let attempts = 0;
|
|
676
|
+
const maxAttempts = 5;
|
|
677
|
+
|
|
678
|
+
const trySetup = () => {
|
|
679
|
+
attempts++;
|
|
680
|
+
if (__DEV__) {
|
|
681
|
+
logger.debug('Navigation setup attempt', attempts, 'of', maxAttempts);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const success = trySetupExpoRouter();
|
|
685
|
+
|
|
686
|
+
if (success) {
|
|
687
|
+
if (__DEV__) {
|
|
688
|
+
logger.debug('Expo Router setup: SUCCESS on attempt', attempts);
|
|
689
|
+
}
|
|
690
|
+
} else if (attempts < maxAttempts) {
|
|
691
|
+
// Retry with exponential backoff
|
|
692
|
+
const delay = 200 * attempts; // 200, 400, 600, 800ms
|
|
693
|
+
if (__DEV__) {
|
|
694
|
+
logger.debug('Expo Router not ready, retrying in', delay, 'ms');
|
|
695
|
+
}
|
|
696
|
+
setTimeout(trySetup, delay);
|
|
697
|
+
} else {
|
|
698
|
+
if (__DEV__) {
|
|
699
|
+
logger.debug('Expo Router setup: FAILED after', maxAttempts, 'attempts');
|
|
700
|
+
logger.debug('For manual navigation tracking, use trackNavigationState() on your NavigationContainer');
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// Start first attempt after 200ms
|
|
706
|
+
setTimeout(trySetup, 200);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Set up Expo Router auto-tracking by polling the internal router store
|
|
711
|
+
*
|
|
712
|
+
* Supports both expo-router v3 (store.rootState) and v6+ (store.state, store.navigationRef)
|
|
713
|
+
*/
|
|
714
|
+
function trySetupExpoRouter(): boolean {
|
|
715
|
+
try {
|
|
716
|
+
const expoRouter = require('expo-router');
|
|
717
|
+
const router = expoRouter.router;
|
|
718
|
+
|
|
719
|
+
if (!router) {
|
|
720
|
+
if (__DEV__) {
|
|
721
|
+
logger.debug('Expo Router: router object not found');
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (__DEV__) {
|
|
727
|
+
logger.debug('Expo Router: Setting up navigation tracking');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const { normalizeScreenName, getScreenNameFromPath } = require('./navigation');
|
|
731
|
+
|
|
732
|
+
// Poll for route changes (expo-router doesn't expose a listener API outside hooks)
|
|
733
|
+
navigationPollingInterval = setInterval(() => {
|
|
734
|
+
try {
|
|
735
|
+
let state = null;
|
|
736
|
+
let stateSource = '';
|
|
737
|
+
|
|
738
|
+
// Method 1: Public accessors on router object
|
|
739
|
+
if (typeof router.getState === 'function') {
|
|
740
|
+
state = router.getState();
|
|
741
|
+
stateSource = 'router.getState()';
|
|
742
|
+
} else if ((router as any).rootState) {
|
|
743
|
+
state = (router as any).rootState;
|
|
744
|
+
stateSource = 'router.rootState';
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Method 2: Internal store access (works for both v3 and v6+)
|
|
748
|
+
if (!state) {
|
|
749
|
+
try {
|
|
750
|
+
const storeModule = require('expo-router/build/global-state/router-store');
|
|
751
|
+
if (storeModule?.store) {
|
|
752
|
+
// v6+: store.state or store.navigationRef
|
|
753
|
+
state = storeModule.store.state;
|
|
754
|
+
if (state) stateSource = 'store.state';
|
|
755
|
+
|
|
756
|
+
// v6+: Try navigationRef if state is undefined
|
|
757
|
+
if (!state && storeModule.store.navigationRef?.current) {
|
|
758
|
+
state = storeModule.store.navigationRef.current.getRootState?.();
|
|
759
|
+
if (state) stateSource = 'navigationRef.getRootState()';
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// v3: store.rootState or store.initialState
|
|
763
|
+
if (!state) {
|
|
764
|
+
state = storeModule.store.rootState || storeModule.store.initialState;
|
|
765
|
+
if (state) stateSource = 'store.rootState/initialState';
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} catch {
|
|
769
|
+
// Store not available
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Method 3: Try accessing via a different export path for v6
|
|
774
|
+
if (!state) {
|
|
775
|
+
try {
|
|
776
|
+
const imperative = require('expo-router/build/imperative-api');
|
|
777
|
+
if (imperative?.router) {
|
|
778
|
+
state = imperative.router.getState?.();
|
|
779
|
+
if (state) stateSource = 'imperative-api';
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
// Imperative API not available
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (state) {
|
|
787
|
+
// Reset error count on success
|
|
788
|
+
navigationPollingErrors = 0;
|
|
789
|
+
const screenName = extractScreenNameFromRouterState(state, getScreenNameFromPath, normalizeScreenName);
|
|
790
|
+
if (screenName && screenName !== lastDetectedScreen) {
|
|
791
|
+
if (__DEV__) {
|
|
792
|
+
logger.debug('Screen changed:', lastDetectedScreen, '->', screenName, `(source: ${stateSource})`);
|
|
793
|
+
}
|
|
794
|
+
lastDetectedScreen = screenName;
|
|
795
|
+
trackScreen(screenName);
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
// Track consecutive failures to get state
|
|
799
|
+
navigationPollingErrors++;
|
|
800
|
+
if (__DEV__ && navigationPollingErrors === 1) {
|
|
801
|
+
logger.debug('Expo Router: Could not get navigation state');
|
|
802
|
+
}
|
|
803
|
+
if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
|
|
804
|
+
// Stop polling after too many errors to save CPU
|
|
805
|
+
if (__DEV__) {
|
|
806
|
+
logger.debug('Expo Router: Stopped polling after', MAX_POLLING_ERRORS, 'errors');
|
|
807
|
+
}
|
|
808
|
+
cleanupNavigationTracking();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch (e) {
|
|
812
|
+
// Error - track and potentially stop
|
|
813
|
+
navigationPollingErrors++;
|
|
814
|
+
if (__DEV__ && navigationPollingErrors === 1) {
|
|
815
|
+
logger.debug('Expo Router polling error:', e);
|
|
816
|
+
}
|
|
817
|
+
if (navigationPollingErrors >= MAX_POLLING_ERRORS) {
|
|
818
|
+
cleanupNavigationTracking();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}, 500); // 500ms polling (reduced from 300ms for lower CPU usage)
|
|
822
|
+
|
|
823
|
+
return true;
|
|
824
|
+
} catch (e) {
|
|
825
|
+
if (__DEV__) {
|
|
826
|
+
logger.debug('Expo Router not available:', e);
|
|
827
|
+
}
|
|
828
|
+
// expo-router not installed
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Extract screen name from Expo Router navigation state
|
|
835
|
+
*
|
|
836
|
+
* Handles complex nested structures like Drawer → Tabs → Stack
|
|
837
|
+
* by recursively accumulating segments from each navigation level.
|
|
838
|
+
*/
|
|
839
|
+
function extractScreenNameFromRouterState(
|
|
840
|
+
state: any,
|
|
841
|
+
getScreenNameFromPath: (path: string, segments: string[]) => string,
|
|
842
|
+
normalizeScreenName: (name: string) => string,
|
|
843
|
+
accumulatedSegments: string[] = []
|
|
844
|
+
): string | null {
|
|
845
|
+
if (!state?.routes) return null;
|
|
846
|
+
|
|
847
|
+
const route = state.routes[state.index ?? state.routes.length - 1];
|
|
848
|
+
if (!route) return null;
|
|
849
|
+
|
|
850
|
+
// Add current route name to accumulated segments
|
|
851
|
+
const newSegments = [...accumulatedSegments, route.name];
|
|
852
|
+
|
|
853
|
+
// If this route has nested state, recurse deeper
|
|
854
|
+
if (route.state) {
|
|
855
|
+
return extractScreenNameFromRouterState(
|
|
856
|
+
route.state,
|
|
857
|
+
getScreenNameFromPath,
|
|
858
|
+
normalizeScreenName,
|
|
859
|
+
newSegments
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// We've reached the deepest level - build the screen name
|
|
864
|
+
// Filter out group markers like (tabs), (main), (auth)
|
|
865
|
+
const cleanSegments = newSegments.filter(s => !s.startsWith('(') && !s.endsWith(')'));
|
|
866
|
+
|
|
867
|
+
// If after filtering we have no segments, use the last meaningful name
|
|
868
|
+
if (cleanSegments.length === 0) {
|
|
869
|
+
// Find the last non-group segment
|
|
870
|
+
for (let i = newSegments.length - 1; i >= 0; i--) {
|
|
871
|
+
const seg = newSegments[i];
|
|
872
|
+
if (seg && !seg.startsWith('(') && !seg.endsWith(')')) {
|
|
873
|
+
cleanSegments.push(seg);
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const pathname = '/' + cleanSegments.join('/');
|
|
880
|
+
return getScreenNameFromPath(pathname, newSegments);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Cleanup navigation tracking
|
|
885
|
+
*/
|
|
886
|
+
function cleanupNavigationTracking(): void {
|
|
887
|
+
if (navigationPollingInterval) {
|
|
888
|
+
clearInterval(navigationPollingInterval);
|
|
889
|
+
navigationPollingInterval = null;
|
|
890
|
+
}
|
|
891
|
+
navigationSetupDone = false;
|
|
892
|
+
lastDetectedScreen = '';
|
|
893
|
+
navigationPollingErrors = 0;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Track a screen view
|
|
898
|
+
* This updates JS metrics AND notifies the native module to send to backend
|
|
899
|
+
*/
|
|
900
|
+
export function trackScreen(screenName: string): void {
|
|
901
|
+
if (!isInitialized) {
|
|
902
|
+
if (__DEV__) {
|
|
903
|
+
logger.debug('trackScreen called but not initialized, screen:', screenName);
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const previousScreen = currentScreen;
|
|
909
|
+
currentScreen = screenName;
|
|
910
|
+
// Add to screens visited (only track for unique set, avoid large array copies)
|
|
911
|
+
screensVisited.push(screenName);
|
|
912
|
+
|
|
913
|
+
// Update unique screens count
|
|
914
|
+
const uniqueScreens = new Set(screensVisited);
|
|
915
|
+
metrics.uniqueScreensCount = uniqueScreens.size;
|
|
916
|
+
|
|
917
|
+
// Update navigation count
|
|
918
|
+
metrics.navigationCount++;
|
|
919
|
+
metrics.totalEvents++;
|
|
920
|
+
|
|
921
|
+
if (__DEV__) {
|
|
922
|
+
logger.debug('trackScreen:', screenName, '(total screens:', metrics.uniqueScreensCount, ')');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Notify callback
|
|
926
|
+
if (onScreenChange) {
|
|
927
|
+
onScreenChange(screenName, previousScreen);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// IMPORTANT: Also notify native module to send to backend
|
|
931
|
+
// This is the key fix - without this, screens don't get recorded!
|
|
932
|
+
try {
|
|
933
|
+
const RejourneyNative = getRejourneyNativeModule();
|
|
934
|
+
if (RejourneyNative?.screenChanged) {
|
|
935
|
+
if (__DEV__) {
|
|
936
|
+
logger.debug('Notifying native screenChanged:', screenName);
|
|
937
|
+
}
|
|
938
|
+
RejourneyNative.screenChanged(screenName).catch((e: Error) => {
|
|
939
|
+
if (__DEV__) {
|
|
940
|
+
logger.debug('Native screenChanged error:', e);
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
} else if (__DEV__) {
|
|
944
|
+
logger.debug('Native screenChanged method not available');
|
|
945
|
+
}
|
|
946
|
+
} catch (e) {
|
|
947
|
+
if (__DEV__) {
|
|
948
|
+
logger.debug('trackScreen native call error:', e);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// =============================================================================
|
|
954
|
+
// API Metrics Tracking
|
|
955
|
+
// =============================================================================
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Track an API request with timing data
|
|
959
|
+
*/
|
|
960
|
+
export function trackAPIRequest(
|
|
961
|
+
success: boolean,
|
|
962
|
+
_statusCode: number,
|
|
963
|
+
durationMs: number = 0,
|
|
964
|
+
responseBytes: number = 0
|
|
965
|
+
): void {
|
|
966
|
+
if (!isInitialized) return;
|
|
967
|
+
|
|
968
|
+
metrics.apiTotalCount++;
|
|
969
|
+
|
|
970
|
+
// Accumulate timing and size for avg calculation
|
|
971
|
+
if (durationMs > 0) {
|
|
972
|
+
metrics.netTotalDurationMs += durationMs;
|
|
973
|
+
}
|
|
974
|
+
if (responseBytes > 0) {
|
|
975
|
+
metrics.netTotalBytes += responseBytes;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (success) {
|
|
979
|
+
metrics.apiSuccessCount++;
|
|
980
|
+
} else {
|
|
981
|
+
metrics.apiErrorCount++;
|
|
982
|
+
|
|
983
|
+
// API errors also count toward error count for UX score
|
|
984
|
+
metrics.errorCount++;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// =============================================================================
|
|
989
|
+
// Session Metrics
|
|
990
|
+
// =============================================================================
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Create empty metrics object
|
|
994
|
+
*/
|
|
995
|
+
function createEmptyMetrics(): SessionMetrics {
|
|
996
|
+
return {
|
|
997
|
+
totalEvents: 0,
|
|
998
|
+
touchCount: 0,
|
|
999
|
+
scrollCount: 0,
|
|
1000
|
+
gestureCount: 0,
|
|
1001
|
+
inputCount: 0,
|
|
1002
|
+
navigationCount: 0,
|
|
1003
|
+
errorCount: 0,
|
|
1004
|
+
rageTapCount: 0,
|
|
1005
|
+
apiSuccessCount: 0,
|
|
1006
|
+
apiErrorCount: 0,
|
|
1007
|
+
apiTotalCount: 0,
|
|
1008
|
+
netTotalDurationMs: 0,
|
|
1009
|
+
netTotalBytes: 0,
|
|
1010
|
+
screensVisited: [],
|
|
1011
|
+
uniqueScreensCount: 0,
|
|
1012
|
+
interactionScore: 100,
|
|
1013
|
+
explorationScore: 100,
|
|
1014
|
+
uxScore: 100,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Track a scroll event
|
|
1020
|
+
*/
|
|
1021
|
+
export function trackScroll(): void {
|
|
1022
|
+
if (!isInitialized) return;
|
|
1023
|
+
metrics.scrollCount++;
|
|
1024
|
+
metrics.totalEvents++;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Track a gesture event
|
|
1029
|
+
*/
|
|
1030
|
+
export function trackGesture(): void {
|
|
1031
|
+
if (!isInitialized) return;
|
|
1032
|
+
metrics.gestureCount++;
|
|
1033
|
+
metrics.totalEvents++;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Track an input event (keyboard)
|
|
1038
|
+
*/
|
|
1039
|
+
export function trackInput(): void {
|
|
1040
|
+
if (!isInitialized) return;
|
|
1041
|
+
metrics.inputCount++;
|
|
1042
|
+
metrics.totalEvents++;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Get current session metrics
|
|
1047
|
+
*/
|
|
1048
|
+
export function getSessionMetrics(): SessionMetrics & { netAvgDurationMs: number } {
|
|
1049
|
+
// Calculate scores before returning
|
|
1050
|
+
calculateScores();
|
|
1051
|
+
|
|
1052
|
+
// Compute average API response time
|
|
1053
|
+
const netAvgDurationMs = metrics.apiTotalCount > 0
|
|
1054
|
+
? Math.round(metrics.netTotalDurationMs / metrics.apiTotalCount)
|
|
1055
|
+
: 0;
|
|
1056
|
+
|
|
1057
|
+
// Lazily populate screensVisited only when metrics are retrieved
|
|
1058
|
+
// This avoids expensive array copies on every screen change
|
|
1059
|
+
return {
|
|
1060
|
+
...metrics,
|
|
1061
|
+
screensVisited: [...screensVisited], // Only copy here when needed
|
|
1062
|
+
netAvgDurationMs,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Calculate session scores
|
|
1068
|
+
*/
|
|
1069
|
+
function calculateScores(): void {
|
|
1070
|
+
// Interaction Score (0-100)
|
|
1071
|
+
// Based on total interactions normalized to a baseline
|
|
1072
|
+
const totalInteractions =
|
|
1073
|
+
metrics.touchCount +
|
|
1074
|
+
metrics.scrollCount +
|
|
1075
|
+
metrics.gestureCount +
|
|
1076
|
+
metrics.inputCount;
|
|
1077
|
+
|
|
1078
|
+
// Assume 50 interactions is "average" for a session
|
|
1079
|
+
const avgInteractions = 50;
|
|
1080
|
+
metrics.interactionScore = Math.min(100, Math.round((totalInteractions / avgInteractions) * 100));
|
|
1081
|
+
|
|
1082
|
+
// Exploration Score (0-100)
|
|
1083
|
+
// Based on unique screens visited
|
|
1084
|
+
// Assume 5 screens is "average" exploration
|
|
1085
|
+
const avgScreens = 5;
|
|
1086
|
+
metrics.explorationScore = Math.min(100, Math.round((metrics.uniqueScreensCount / avgScreens) * 100));
|
|
1087
|
+
|
|
1088
|
+
// UX Score (0-100)
|
|
1089
|
+
// Starts at 100, deducts for issues
|
|
1090
|
+
let uxScore = 100;
|
|
1091
|
+
|
|
1092
|
+
// Deduct for errors
|
|
1093
|
+
uxScore -= Math.min(30, metrics.errorCount * 15); // Max 30 point deduction
|
|
1094
|
+
|
|
1095
|
+
// Deduct for rage taps
|
|
1096
|
+
uxScore -= Math.min(24, metrics.rageTapCount * 8); // Max 24 point deduction
|
|
1097
|
+
|
|
1098
|
+
// Deduct for API errors
|
|
1099
|
+
uxScore -= Math.min(20, metrics.apiErrorCount * 10); // Max 20 point deduction
|
|
1100
|
+
|
|
1101
|
+
// Bonus for completing funnel (if screens > 3)
|
|
1102
|
+
if (metrics.uniqueScreensCount >= 3) {
|
|
1103
|
+
uxScore += 5;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
metrics.uxScore = Math.max(0, Math.min(100, uxScore));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Reset metrics for new session
|
|
1111
|
+
*/
|
|
1112
|
+
export function resetMetrics(): void {
|
|
1113
|
+
metrics = createEmptyMetrics();
|
|
1114
|
+
screensVisited = [];
|
|
1115
|
+
currentScreen = '';
|
|
1116
|
+
tapHead = 0;
|
|
1117
|
+
tapCount = 0;
|
|
1118
|
+
sessionStartTime = Date.now();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// =============================================================================
|
|
1122
|
+
// Session duration helpers
|
|
1123
|
+
// =============================================================================
|
|
1124
|
+
|
|
1125
|
+
/** Clamp and set max session duration in minutes (1–10). Defaults to 10. */
|
|
1126
|
+
export function setMaxSessionDurationMinutes(minutes?: number): void {
|
|
1127
|
+
const clampedMinutes = Math.min(10, Math.max(1, minutes ?? 10));
|
|
1128
|
+
maxSessionDurationMs = clampedMinutes * 60 * 1000;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Returns true if the current session exceeded the configured max duration. */
|
|
1132
|
+
export function hasExceededMaxSessionDuration(): boolean {
|
|
1133
|
+
if (!sessionStartTime) return false;
|
|
1134
|
+
return Date.now() - sessionStartTime >= maxSessionDurationMs;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Returns remaining milliseconds until the session should stop. */
|
|
1138
|
+
export function getRemainingSessionDurationMs(): number {
|
|
1139
|
+
if (!sessionStartTime) return maxSessionDurationMs;
|
|
1140
|
+
const remaining = maxSessionDurationMs - (Date.now() - sessionStartTime);
|
|
1141
|
+
return Math.max(0, remaining);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// =============================================================================
|
|
1145
|
+
// Device Info Collection
|
|
1146
|
+
// =============================================================================
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Collect device information
|
|
1150
|
+
*/
|
|
1151
|
+
export function collectDeviceInfo(): DeviceInfo {
|
|
1152
|
+
const Dimensions = getDimensions();
|
|
1153
|
+
const Platform = getPlatform();
|
|
1154
|
+
const NativeModules = getNativeModules();
|
|
1155
|
+
|
|
1156
|
+
// Default values if react-native isn't available
|
|
1157
|
+
let width = 0, height = 0, scale = 1;
|
|
1158
|
+
|
|
1159
|
+
if (Dimensions) {
|
|
1160
|
+
const windowDims = Dimensions.get('window');
|
|
1161
|
+
const screenDims = Dimensions.get('screen');
|
|
1162
|
+
width = windowDims?.width || 0;
|
|
1163
|
+
height = windowDims?.height || 0;
|
|
1164
|
+
scale = screenDims?.scale || 1;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Get device model - this varies by platform
|
|
1168
|
+
let model = 'Unknown';
|
|
1169
|
+
let manufacturer: string | undefined;
|
|
1170
|
+
let osVersion = 'Unknown';
|
|
1171
|
+
let appVersion: string | undefined;
|
|
1172
|
+
let appId: string | undefined;
|
|
1173
|
+
let locale: string | undefined;
|
|
1174
|
+
let timezone: string | undefined;
|
|
1175
|
+
|
|
1176
|
+
try {
|
|
1177
|
+
// Try to get from react-native-device-info if available
|
|
1178
|
+
// This is optional - falls back to basic info if not installed
|
|
1179
|
+
const DeviceInfo = require('react-native-device-info');
|
|
1180
|
+
model = DeviceInfo.getModel?.() || model;
|
|
1181
|
+
manufacturer = DeviceInfo.getBrand?.() || undefined;
|
|
1182
|
+
osVersion = DeviceInfo.getSystemVersion?.() || osVersion;
|
|
1183
|
+
appVersion = DeviceInfo.getVersion?.() || undefined;
|
|
1184
|
+
appId = DeviceInfo.getBundleId?.() || undefined;
|
|
1185
|
+
locale = DeviceInfo.getDeviceLocale?.() || undefined;
|
|
1186
|
+
timezone = DeviceInfo.getTimezone?.() || undefined;
|
|
1187
|
+
} catch {
|
|
1188
|
+
// react-native-device-info not installed, try Expo packages
|
|
1189
|
+
try {
|
|
1190
|
+
// Try expo-application for app version/id
|
|
1191
|
+
const Application = require('expo-application');
|
|
1192
|
+
appVersion = Application.nativeApplicationVersion || Application.applicationVersion || undefined;
|
|
1193
|
+
appId = Application.applicationId || undefined;
|
|
1194
|
+
} catch {
|
|
1195
|
+
// expo-application not available
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
// Try expo-constants for additional info
|
|
1200
|
+
const Constants = require('expo-constants');
|
|
1201
|
+
const expoConfig = Constants.expoConfig || Constants.manifest2?.extra?.expoClient || Constants.manifest;
|
|
1202
|
+
if (!appVersion && expoConfig?.version) {
|
|
1203
|
+
appVersion = expoConfig.version;
|
|
1204
|
+
}
|
|
1205
|
+
if (!appId && (expoConfig?.ios?.bundleIdentifier || expoConfig?.android?.package)) {
|
|
1206
|
+
appId = Platform?.OS === 'ios'
|
|
1207
|
+
? expoConfig?.ios?.bundleIdentifier
|
|
1208
|
+
: expoConfig?.android?.package;
|
|
1209
|
+
}
|
|
1210
|
+
} catch {
|
|
1211
|
+
// expo-constants not available
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Fall back to basic platform info
|
|
1215
|
+
if (Platform?.OS === 'ios') {
|
|
1216
|
+
// Get basic info from constants
|
|
1217
|
+
const PlatformConstants = NativeModules?.PlatformConstants;
|
|
1218
|
+
osVersion = Platform.Version?.toString() || osVersion;
|
|
1219
|
+
model = PlatformConstants?.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone';
|
|
1220
|
+
} else if (Platform?.OS === 'android') {
|
|
1221
|
+
osVersion = Platform.Version?.toString() || osVersion;
|
|
1222
|
+
model = 'Android Device';
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Get timezone
|
|
1227
|
+
if (!timezone) {
|
|
1228
|
+
try {
|
|
1229
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1230
|
+
} catch {
|
|
1231
|
+
timezone = undefined;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Get locale
|
|
1236
|
+
if (!locale) {
|
|
1237
|
+
try {
|
|
1238
|
+
locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
1239
|
+
} catch {
|
|
1240
|
+
locale = undefined;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return {
|
|
1245
|
+
model,
|
|
1246
|
+
manufacturer,
|
|
1247
|
+
os: (Platform?.OS || 'ios') as 'ios' | 'android',
|
|
1248
|
+
osVersion,
|
|
1249
|
+
screenWidth: Math.round(width),
|
|
1250
|
+
screenHeight: Math.round(height),
|
|
1251
|
+
pixelRatio: scale,
|
|
1252
|
+
appVersion,
|
|
1253
|
+
appId,
|
|
1254
|
+
locale,
|
|
1255
|
+
timezone,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// =============================================================================
|
|
1260
|
+
// Anonymous ID Generation
|
|
1261
|
+
// =============================================================================
|
|
1262
|
+
|
|
1263
|
+
// Storage key for anonymous ID
|
|
1264
|
+
const ANONYMOUS_ID_KEY = '@rejourney_anonymous_id';
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Generate a persistent anonymous ID
|
|
1268
|
+
*/
|
|
1269
|
+
function generateAnonymousId(): string {
|
|
1270
|
+
const timestamp = Date.now().toString(36);
|
|
1271
|
+
const random = Math.random().toString(36).substring(2, 15);
|
|
1272
|
+
return `anon_${timestamp}_${random}`;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Initialize anonymous ID - tries to load from storage, generates new if not found
|
|
1277
|
+
* This is called internally and runs asynchronously
|
|
1278
|
+
*/
|
|
1279
|
+
async function initAnonymousId(): Promise<void> {
|
|
1280
|
+
try {
|
|
1281
|
+
// Try to load from AsyncStorage
|
|
1282
|
+
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
|
1283
|
+
const storedId = await AsyncStorage.getItem(ANONYMOUS_ID_KEY);
|
|
1284
|
+
|
|
1285
|
+
if (storedId) {
|
|
1286
|
+
anonymousId = storedId;
|
|
1287
|
+
} else {
|
|
1288
|
+
// Generate new ID and persist
|
|
1289
|
+
anonymousId = generateAnonymousId();
|
|
1290
|
+
await AsyncStorage.setItem(ANONYMOUS_ID_KEY, anonymousId);
|
|
1291
|
+
}
|
|
1292
|
+
} catch {
|
|
1293
|
+
// AsyncStorage not available or error - just generate without persistence
|
|
1294
|
+
if (!anonymousId) {
|
|
1295
|
+
anonymousId = generateAnonymousId();
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Get the anonymous ID (synchronous - returns generated ID immediately)
|
|
1302
|
+
* For persistent ID, call initAnonymousId() first
|
|
1303
|
+
*/
|
|
1304
|
+
export function getAnonymousId(): string {
|
|
1305
|
+
if (!anonymousId) {
|
|
1306
|
+
anonymousId = generateAnonymousId();
|
|
1307
|
+
// Try to persist asynchronously (fire and forget)
|
|
1308
|
+
initAnonymousId().catch(() => { });
|
|
1309
|
+
}
|
|
1310
|
+
return anonymousId;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Ensure a stable, persisted anonymous/device ID is available.
|
|
1315
|
+
* Returns the stored ID if present, otherwise generates and persists one.
|
|
1316
|
+
*/
|
|
1317
|
+
export async function ensurePersistentAnonymousId(): Promise<string> {
|
|
1318
|
+
if (anonymousId) return anonymousId;
|
|
1319
|
+
if (!anonymousIdPromise) {
|
|
1320
|
+
anonymousIdPromise = (async () => {
|
|
1321
|
+
await initAnonymousId();
|
|
1322
|
+
if (!anonymousId) {
|
|
1323
|
+
anonymousId = generateAnonymousId();
|
|
1324
|
+
}
|
|
1325
|
+
return anonymousId;
|
|
1326
|
+
})();
|
|
1327
|
+
}
|
|
1328
|
+
return anonymousIdPromise;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Load anonymous ID from persistent storage
|
|
1333
|
+
* Call this at app startup for best results
|
|
1334
|
+
*/
|
|
1335
|
+
export async function loadAnonymousId(): Promise<string> {
|
|
1336
|
+
await initAnonymousId();
|
|
1337
|
+
return anonymousId || generateAnonymousId();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Set a custom anonymous ID (e.g., from persistent storage)
|
|
1342
|
+
*/
|
|
1343
|
+
export function setAnonymousId(id: string): void {
|
|
1344
|
+
anonymousId = id;
|
|
1345
|
+
// Try to persist asynchronously
|
|
1346
|
+
try {
|
|
1347
|
+
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
|
1348
|
+
AsyncStorage.setItem(ANONYMOUS_ID_KEY, id).catch(() => { });
|
|
1349
|
+
} catch {
|
|
1350
|
+
// Ignore if AsyncStorage not available
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// =============================================================================
|
|
1355
|
+
// Exports
|
|
1356
|
+
// =============================================================================
|
|
1357
|
+
|
|
1358
|
+
export default {
|
|
1359
|
+
init: initAutoTracking,
|
|
1360
|
+
cleanup: cleanupAutoTracking,
|
|
1361
|
+
trackTap,
|
|
1362
|
+
trackScroll,
|
|
1363
|
+
trackGesture,
|
|
1364
|
+
trackInput,
|
|
1365
|
+
trackScreen,
|
|
1366
|
+
trackAPIRequest,
|
|
1367
|
+
captureError,
|
|
1368
|
+
getMetrics: getSessionMetrics,
|
|
1369
|
+
resetMetrics,
|
|
1370
|
+
collectDeviceInfo,
|
|
1371
|
+
getAnonymousId,
|
|
1372
|
+
setAnonymousId,
|
|
1373
|
+
};
|