@athena-tracker/tracker 1.0.2 → 1.2.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/README.md +389 -163
- package/dist/events/capture-react-native.d.ts +77 -0
- package/dist/events/capture-react-native.d.ts.map +1 -0
- package/dist/index.d.ts +13 -106
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +1207 -146
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1223 -154
- package/dist/index.js.map +1 -1
- package/dist/inference/auto-detect.d.ts +21 -0
- package/dist/inference/auto-detect.d.ts.map +1 -0
- package/dist/inference/on-device.d.ts +36 -0
- package/dist/inference/on-device.d.ts.map +1 -0
- package/dist/inference/server.d.ts +30 -0
- package/dist/inference/server.d.ts.map +1 -0
- package/dist/react-native/ForcedReloadWrapper.d.ts +26 -0
- package/dist/react-native/ForcedReloadWrapper.d.ts.map +1 -0
- package/dist/tracker.d.ts +82 -0
- package/dist/tracker.d.ts.map +1 -0
- package/dist/types/index.d.ts +105 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/webhook.d.ts +80 -0
- package/dist/utils/webhook.d.ts.map +1 -0
- package/package.json +58 -14
package/dist/index.esm.js
CHANGED
|
@@ -1,175 +1,1236 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { StyleSheet, View, ActivityIndicator, Text, AppState, PanResponder } from 'react-native';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
* Auto-Detection Logic
|
|
6
|
+
*
|
|
7
|
+
* Automatically detects whether to use on-device or server-side inference
|
|
8
|
+
* based on availability of onnxruntime-react-native
|
|
9
|
+
*/
|
|
10
|
+
async function detectInferenceMode(configMode) {
|
|
11
|
+
// Respect explicit configuration
|
|
12
|
+
if (configMode === 'on-device') {
|
|
13
|
+
console.log('[ATHENA] Forcing on-device inference mode');
|
|
14
|
+
return 'on-device';
|
|
15
|
+
}
|
|
16
|
+
if (configMode === 'server') {
|
|
17
|
+
console.log('[ATHENA] Forcing server-side inference mode');
|
|
18
|
+
return 'server';
|
|
19
|
+
}
|
|
20
|
+
// Auto-detect based on onnxruntime-react-native availability
|
|
21
|
+
try {
|
|
22
|
+
// Try to dynamically import onnxruntime-react-native
|
|
23
|
+
await import('onnxruntime-react-native');
|
|
24
|
+
console.log('[ATHENA] On-device inference available (onnxruntime-react-native detected)');
|
|
25
|
+
return 'on-device';
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.log('[ATHENA] Falling back to server-side inference (onnxruntime-react-native not found)');
|
|
28
|
+
return 'server';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check if running in React Native environment
|
|
33
|
+
*/
|
|
34
|
+
function isReactNative() {
|
|
35
|
+
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Check if running in browser environment
|
|
39
|
+
*/
|
|
40
|
+
function isBrowser() {
|
|
41
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get platform identifier
|
|
45
|
+
*/
|
|
46
|
+
function getPlatform() {
|
|
47
|
+
if (isReactNative()) {
|
|
48
|
+
return 'react-native';
|
|
49
|
+
} else if (isBrowser()) {
|
|
50
|
+
return 'web';
|
|
51
|
+
}
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* On-Device Inference Module
|
|
57
|
+
*
|
|
58
|
+
* Uses onnxruntime-react-native for local ML inference
|
|
59
|
+
* Target latency: <10ms P95
|
|
60
|
+
*/
|
|
61
|
+
let InferenceSession;
|
|
62
|
+
let Tensor;
|
|
63
|
+
// Dynamically import ONNX Runtime (only available in React Native)
|
|
64
|
+
async function loadOnnxRuntime() {
|
|
65
|
+
try {
|
|
66
|
+
const onnx = await import('onnxruntime-react-native');
|
|
67
|
+
InferenceSession = onnx.InferenceSession;
|
|
68
|
+
Tensor = onnx.Tensor;
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('[ATHENA] Failed to load onnxruntime-react-native:', error);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
class OnDeviceInference {
|
|
76
|
+
constructor() {
|
|
77
|
+
this.session = null;
|
|
78
|
+
this.modelLoaded = false;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Initialize ONNX session with model file
|
|
82
|
+
*/
|
|
83
|
+
async initialize(modelPath) {
|
|
84
|
+
console.log('[ATHENA] Loading ONNX model for on-device inference...');
|
|
85
|
+
// Load ONNX Runtime
|
|
86
|
+
const loaded = await loadOnnxRuntime();
|
|
87
|
+
if (!loaded) {
|
|
88
|
+
throw new Error('onnxruntime-react-native not available');
|
|
14
89
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const script = document.createElement('script');
|
|
25
|
-
script.src = `${this.config.apiUrl}/v1/tracker.min.js`;
|
|
26
|
-
script.async = true;
|
|
27
|
-
script.onload = () => {
|
|
28
|
-
this.scriptLoaded = true;
|
|
29
|
-
this.initialize();
|
|
30
|
-
resolve();
|
|
31
|
-
};
|
|
32
|
-
script.onerror = () => {
|
|
33
|
-
reject(new Error('Failed to load ATHENA tracker script'));
|
|
34
|
-
};
|
|
35
|
-
document.head.appendChild(script);
|
|
36
|
-
});
|
|
37
|
-
return this.loadPromise;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Initialize the tracker with configuration
|
|
41
|
-
*/
|
|
42
|
-
initialize() {
|
|
43
|
-
if (typeof window !== 'undefined' && window.PascalTracker) {
|
|
44
|
-
window.athenaTracker = new window.PascalTracker(this.config);
|
|
45
|
-
}
|
|
90
|
+
try {
|
|
91
|
+
const startTime = performance.now();
|
|
92
|
+
this.session = await InferenceSession.create(modelPath);
|
|
93
|
+
const loadTime = performance.now() - startTime;
|
|
94
|
+
this.modelLoaded = true;
|
|
95
|
+
console.log(`[ATHENA] ONNX model loaded successfully (${loadTime.toFixed(0)}ms)`);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('[ATHENA] Failed to load ONNX model:', error);
|
|
98
|
+
throw new Error(`Model loading failed: ${error}`);
|
|
46
99
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
if (!window.athenaTracker) {
|
|
56
|
-
console.warn('ATHENA Tracker: not initialized. Call load() first.');
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
return window.athenaTracker;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Identify a user
|
|
63
|
-
*/
|
|
64
|
-
identify(userId, properties) {
|
|
65
|
-
const tracker = this.getTracker();
|
|
66
|
-
if (tracker && tracker.identify) {
|
|
67
|
-
tracker.identify({
|
|
68
|
-
userId,
|
|
69
|
-
...properties,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Run inference on feature vector
|
|
103
|
+
*/
|
|
104
|
+
async predict(features) {
|
|
105
|
+
if (!this.modelLoaded || !this.session) {
|
|
106
|
+
throw new Error('ONNX model not initialized. Call initialize() first.');
|
|
72
107
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
const startTime = performance.now();
|
|
109
|
+
try {
|
|
110
|
+
// Create input tensor
|
|
111
|
+
const inputTensor = new Tensor('float32', features, [1, features.length]);
|
|
112
|
+
const feeds = {
|
|
113
|
+
input: inputTensor
|
|
114
|
+
};
|
|
115
|
+
// Run inference
|
|
116
|
+
const results = await this.session.run(feeds);
|
|
117
|
+
const inferenceTime = performance.now() - startTime;
|
|
118
|
+
// Parse output
|
|
119
|
+
const prediction = this.parseOutput(results.output);
|
|
120
|
+
console.log(`[ATHENA] On-device inference complete: ${inferenceTime.toFixed(2)}ms ` + `(class: ${prediction.predicted_class}, confidence: ${prediction.confidence.toFixed(2)})`);
|
|
121
|
+
return {
|
|
122
|
+
...prediction,
|
|
123
|
+
inference_time_ms: Math.round(inferenceTime * 100) / 100,
|
|
124
|
+
inference_location: 'on-device',
|
|
125
|
+
timestamp: new Date().toISOString()
|
|
126
|
+
};
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('[ATHENA] On-device inference failed:', error);
|
|
129
|
+
throw new Error(`Inference failed: ${error}`);
|
|
81
130
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Parse ONNX output tensor to prediction result
|
|
134
|
+
*/
|
|
135
|
+
parseOutput(outputTensor) {
|
|
136
|
+
const data = outputTensor.data;
|
|
137
|
+
// Extract predictions from model output
|
|
138
|
+
// Model outputs: [predicted_class_idx, confidence, purchase_intent, cart_abandon_risk, checkout_abandon_risk]
|
|
139
|
+
const predictedClassIdx = data[0];
|
|
140
|
+
const confidence = data[1];
|
|
141
|
+
const purchaseIntent = data[2];
|
|
142
|
+
const cartAbandonRisk = data[3] || 0;
|
|
143
|
+
const checkoutAbandonRisk = data[4] || 0;
|
|
144
|
+
// Map class index to label
|
|
145
|
+
const classLabels = ['engaged_explorer', 'high_intent_buyer', 'cart_abandoner', 'checkout_abandoner', 'bounce_risk'];
|
|
146
|
+
const predictedClass = classLabels[predictedClassIdx] || 'unknown';
|
|
147
|
+
// Determine archetype based on purchase intent
|
|
148
|
+
let archetype;
|
|
149
|
+
if (purchaseIntent >= 0.85) {
|
|
150
|
+
archetype = 'fast_mover';
|
|
151
|
+
} else if (purchaseIntent >= 0.60) {
|
|
152
|
+
archetype = 'on_track';
|
|
153
|
+
} else if (purchaseIntent >= 0.40) {
|
|
154
|
+
archetype = 'slow_adopter';
|
|
155
|
+
} else if (purchaseIntent >= 0.20) {
|
|
156
|
+
archetype = 'at_risk';
|
|
157
|
+
} else {
|
|
158
|
+
archetype = 'different_path';
|
|
90
159
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
160
|
+
// Generate recommendations
|
|
161
|
+
const recommendation = this.generateRecommendation(predictedClass, purchaseIntent, cartAbandonRisk, checkoutAbandonRisk);
|
|
162
|
+
return {
|
|
163
|
+
predicted_class: predictedClass,
|
|
164
|
+
confidence,
|
|
165
|
+
archetype,
|
|
166
|
+
purchase_intent: purchaseIntent,
|
|
167
|
+
cart_abandonment_risk: cartAbandonRisk,
|
|
168
|
+
checkout_abandonment_risk: checkoutAbandonRisk,
|
|
169
|
+
...recommendation
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generate action recommendations based on prediction
|
|
174
|
+
*/
|
|
175
|
+
generateRecommendation(predictedClass, purchaseIntent, cartAbandonRisk, checkoutAbandonRisk) {
|
|
176
|
+
// High cart abandonment risk
|
|
177
|
+
if (cartAbandonRisk > 0.7) {
|
|
178
|
+
return {
|
|
179
|
+
recommended_action: 'Show cart abandonment discount (10-15% off)',
|
|
180
|
+
urgency: 'high',
|
|
181
|
+
trigger_reason: `High cart abandonment risk (${(cartAbandonRisk * 100).toFixed(0)}%)`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// High checkout abandonment risk
|
|
185
|
+
if (checkoutAbandonRisk > 0.7) {
|
|
186
|
+
return {
|
|
187
|
+
recommended_action: 'Simplify checkout flow or offer free shipping',
|
|
188
|
+
urgency: 'critical',
|
|
189
|
+
trigger_reason: `High checkout abandonment risk (${(checkoutAbandonRisk * 100).toFixed(0)}%)`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// High purchase intent
|
|
193
|
+
if (purchaseIntent > 0.8) {
|
|
194
|
+
return {
|
|
195
|
+
recommended_action: 'Show product recommendations or upsell',
|
|
196
|
+
urgency: 'medium',
|
|
197
|
+
trigger_reason: `High purchase intent (${(purchaseIntent * 100).toFixed(0)}%)`
|
|
198
|
+
};
|
|
99
199
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
200
|
+
// Low purchase intent
|
|
201
|
+
if (purchaseIntent < 0.3) {
|
|
202
|
+
return {
|
|
203
|
+
recommended_action: 'Show value proposition or testimonials',
|
|
204
|
+
urgency: 'low',
|
|
205
|
+
trigger_reason: `Low purchase intent (${(purchaseIntent * 100).toFixed(0)}%)`
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
recommended_action: 'Continue monitoring',
|
|
210
|
+
urgency: 'low'
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check if model is loaded
|
|
215
|
+
*/
|
|
216
|
+
isReady() {
|
|
217
|
+
return this.modelLoaded;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Cleanup resources
|
|
221
|
+
*/
|
|
222
|
+
async dispose() {
|
|
223
|
+
if (this.session) {
|
|
224
|
+
try {
|
|
225
|
+
// ONNX Runtime sessions should be disposed
|
|
226
|
+
await this.session.release?.();
|
|
227
|
+
console.log('[ATHENA] On-device inference session disposed');
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.warn('[ATHENA] Failed to dispose session:', error);
|
|
230
|
+
}
|
|
231
|
+
this.session = null;
|
|
232
|
+
this.modelLoaded = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Server-Side Inference Module
|
|
239
|
+
*
|
|
240
|
+
* Fallback inference via HTTP API when on-device inference is not available
|
|
241
|
+
* Target latency: <100ms P95
|
|
242
|
+
*/
|
|
243
|
+
class ServerInference {
|
|
244
|
+
constructor(apiUrl, appToken, timeout = 5000) {
|
|
245
|
+
this.apiUrl = apiUrl;
|
|
246
|
+
this.appToken = appToken;
|
|
247
|
+
this.timeout = timeout;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Make prediction request to server
|
|
251
|
+
*/
|
|
252
|
+
async predict(events, sessionId, userId) {
|
|
253
|
+
const startTime = Date.now();
|
|
254
|
+
try {
|
|
255
|
+
const requestBody = {
|
|
256
|
+
app_token: this.appToken,
|
|
257
|
+
events,
|
|
258
|
+
session_id: sessionId,
|
|
259
|
+
user_id: userId
|
|
260
|
+
};
|
|
261
|
+
console.log(`[ATHENA] Sending ${events.length} events to server for inference...`);
|
|
262
|
+
const response = await this.fetchWithTimeout(this.apiUrl, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': 'application/json',
|
|
266
|
+
'X-App-Token': this.appToken
|
|
267
|
+
},
|
|
268
|
+
body: JSON.stringify(requestBody)
|
|
269
|
+
}, this.timeout);
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const errorText = await response.text();
|
|
272
|
+
throw new Error(`Server inference failed: ${response.status} ${errorText}`);
|
|
273
|
+
}
|
|
274
|
+
const result = await response.json();
|
|
275
|
+
const latency = Date.now() - startTime;
|
|
276
|
+
console.log(`[ATHENA] Server-side inference complete: ${latency}ms ` + `(class: ${result.predicted_class}, confidence: ${result.confidence.toFixed(2)})`);
|
|
277
|
+
return {
|
|
278
|
+
...result,
|
|
279
|
+
inference_time_ms: latency,
|
|
280
|
+
inference_location: 'server',
|
|
281
|
+
timestamp: result.timestamp || new Date().toISOString()
|
|
282
|
+
};
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const latency = Date.now() - startTime;
|
|
285
|
+
console.error(`[ATHENA] Server inference failed after ${latency}ms:`, error.message);
|
|
286
|
+
throw new Error(`Server inference failed: ${error.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Fetch with timeout
|
|
291
|
+
*/
|
|
292
|
+
async fetchWithTimeout(url, options, timeout) {
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetch(url, {
|
|
297
|
+
...options,
|
|
298
|
+
signal: controller.signal
|
|
299
|
+
});
|
|
300
|
+
clearTimeout(timeoutId);
|
|
301
|
+
return response;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
clearTimeout(timeoutId);
|
|
304
|
+
if (error.name === 'AbortError') {
|
|
305
|
+
throw new Error(`Request timeout after ${timeout}ms`);
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Test server connectivity
|
|
312
|
+
*/
|
|
313
|
+
async testConnection() {
|
|
314
|
+
try {
|
|
315
|
+
// Replace /v1/predict with /health for health check
|
|
316
|
+
const healthUrl = this.apiUrl.replace('/v1/predict', '/health');
|
|
317
|
+
const response = await this.fetchWithTimeout(healthUrl, {
|
|
318
|
+
method: 'GET'
|
|
319
|
+
}, 10000); // Increased to 10s for mobile networks
|
|
320
|
+
return response.ok;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
console.warn('[ATHENA] Server connectivity test failed:', error);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Update configuration
|
|
328
|
+
*/
|
|
329
|
+
updateConfig(apiUrl, appToken, timeout) {
|
|
330
|
+
if (apiUrl) this.apiUrl = apiUrl;
|
|
331
|
+
if (appToken) this.appToken = appToken;
|
|
332
|
+
if (timeout) this.timeout = timeout;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* ATHENA Tracker - Main Class
|
|
338
|
+
*
|
|
339
|
+
* Unified interface for behavioral tracking with dual-mode ML inference
|
|
340
|
+
*/
|
|
341
|
+
const DEFAULT_API_URL = 'https://tracker.pascal.cx';
|
|
342
|
+
const DEFAULT_ML_API_URL = 'https://pascal-ml-api-344769701859.us-central1.run.app';
|
|
343
|
+
const DEFAULT_MODEL_PATH = 'https://tracker.pascal.cx/models/base_model_int8.onnx';
|
|
344
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
345
|
+
const DEFAULT_BATCH_INTERVAL_MS = 10000; // 10 seconds
|
|
346
|
+
class AthenaTracker {
|
|
347
|
+
constructor() {
|
|
348
|
+
this.config = null;
|
|
349
|
+
this.state = {
|
|
350
|
+
initialized: false,
|
|
351
|
+
inferenceMode: null,
|
|
352
|
+
sessionId: null,
|
|
353
|
+
userId: null,
|
|
354
|
+
events: []
|
|
355
|
+
};
|
|
356
|
+
this.onDeviceInference = null;
|
|
357
|
+
this.serverInference = null;
|
|
358
|
+
this.batchIntervalId = null;
|
|
359
|
+
// Private constructor for singleton
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Get singleton instance
|
|
363
|
+
*/
|
|
364
|
+
static getInstance() {
|
|
365
|
+
if (!AthenaTracker.instance) {
|
|
366
|
+
AthenaTracker.instance = new AthenaTracker();
|
|
367
|
+
}
|
|
368
|
+
return AthenaTracker.instance;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Initialize tracker
|
|
372
|
+
*/
|
|
373
|
+
static async init(config) {
|
|
374
|
+
const instance = AthenaTracker.getInstance();
|
|
375
|
+
if (instance.state.initialized) {
|
|
376
|
+
console.warn('[ATHENA] Already initialized');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
console.log('[ATHENA] Initializing tracker...');
|
|
380
|
+
instance.config = {
|
|
381
|
+
...config,
|
|
382
|
+
apiUrl: config.apiUrl || DEFAULT_API_URL,
|
|
383
|
+
modelPath: config.modelPath || DEFAULT_MODEL_PATH,
|
|
384
|
+
serverInferenceUrl: config.serverInferenceUrl || `${DEFAULT_ML_API_URL}/v1/predict`,
|
|
385
|
+
batching: {
|
|
386
|
+
size: config.batching?.size || DEFAULT_BATCH_SIZE,
|
|
387
|
+
intervalMs: config.batching?.intervalMs || DEFAULT_BATCH_INTERVAL_MS
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
// Detect inference mode
|
|
391
|
+
const detectedMode = await detectInferenceMode(config.inferenceMode);
|
|
392
|
+
instance.state.inferenceMode = detectedMode;
|
|
393
|
+
// Initialize appropriate inference engine
|
|
394
|
+
if (detectedMode === 'on-device') {
|
|
395
|
+
await instance.initializeOnDevice();
|
|
396
|
+
} else {
|
|
397
|
+
await instance.initializeServer();
|
|
398
|
+
}
|
|
399
|
+
// Generate session ID
|
|
400
|
+
instance.state.sessionId = instance.generateSessionId();
|
|
401
|
+
// Start event batching
|
|
402
|
+
instance.startBatching();
|
|
403
|
+
instance.state.initialized = true;
|
|
404
|
+
console.log(`[ATHENA] Tracker initialized successfully (mode: ${detectedMode}, platform: ${getPlatform()})`);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Initialize on-device inference
|
|
408
|
+
*/
|
|
409
|
+
async initializeOnDevice() {
|
|
410
|
+
try {
|
|
411
|
+
this.onDeviceInference = new OnDeviceInference();
|
|
412
|
+
await this.onDeviceInference.initialize(this.config.modelPath);
|
|
413
|
+
console.log('[ATHENA] On-device inference ready');
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error('[ATHENA] On-device inference initialization failed:', error);
|
|
416
|
+
console.log('[ATHENA] Falling back to server-side inference');
|
|
417
|
+
this.state.inferenceMode = 'server';
|
|
418
|
+
await this.initializeServer();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Initialize server-side inference
|
|
423
|
+
*/
|
|
424
|
+
async initializeServer() {
|
|
425
|
+
this.serverInference = new ServerInference(this.config.serverInferenceUrl, this.config.appToken);
|
|
426
|
+
// Test connectivity
|
|
427
|
+
const connected = await this.serverInference.testConnection();
|
|
428
|
+
if (!connected) {
|
|
429
|
+
console.warn('[ATHENA] Server connectivity test failed - predictions may fail');
|
|
430
|
+
} else {
|
|
431
|
+
console.log('[ATHENA] Server-side inference ready');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Identify a user
|
|
436
|
+
*/
|
|
437
|
+
static identify(userId, traits) {
|
|
438
|
+
const instance = AthenaTracker.getInstance();
|
|
439
|
+
instance.state.userId = userId;
|
|
440
|
+
AthenaTracker.track('identify', {
|
|
441
|
+
user_id: userId,
|
|
442
|
+
...traits
|
|
443
|
+
});
|
|
444
|
+
console.log(`[ATHENA] User identified: ${userId}`);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Track an event
|
|
448
|
+
*/
|
|
449
|
+
static track(eventType, properties) {
|
|
450
|
+
const instance = AthenaTracker.getInstance();
|
|
451
|
+
if (!instance.state.initialized) {
|
|
452
|
+
console.warn('[ATHENA] Tracker not initialized. Call init() first.');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const event = {
|
|
456
|
+
event_type: eventType,
|
|
457
|
+
timestamp: Date.now(),
|
|
458
|
+
properties: properties || {},
|
|
459
|
+
session_id: instance.state.sessionId || undefined,
|
|
460
|
+
user_id: instance.state.userId || undefined
|
|
461
|
+
};
|
|
462
|
+
instance.state.events.push(event);
|
|
463
|
+
if (instance.config?.debug) {
|
|
464
|
+
console.log('[ATHENA] Event tracked:', event);
|
|
465
|
+
}
|
|
466
|
+
// Trigger immediate inference if batch size reached
|
|
467
|
+
if (instance.state.events.length >= instance.config.batching.size) {
|
|
468
|
+
instance.processBatch();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Process event batch and run inference
|
|
473
|
+
*/
|
|
474
|
+
async processBatch() {
|
|
475
|
+
if (this.state.events.length === 0) return;
|
|
476
|
+
const events = [...this.state.events];
|
|
477
|
+
this.state.events = [];
|
|
478
|
+
try {
|
|
479
|
+
const prediction = await this.runInference(events);
|
|
480
|
+
if (this.config?.webhook?.enabled && this.config.webhook.url) {
|
|
481
|
+
await this.sendWebhook(prediction);
|
|
482
|
+
}
|
|
483
|
+
if (this.config?.debug) {
|
|
484
|
+
console.log('[ATHENA] Prediction:', prediction);
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error('[ATHENA] Failed to process batch:', error);
|
|
488
|
+
// Re-queue events on failure
|
|
489
|
+
this.state.events.unshift(...events);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Run inference (delegates to on-device or server)
|
|
494
|
+
*/
|
|
495
|
+
async runInference(events) {
|
|
496
|
+
if (this.state.inferenceMode === 'on-device' && this.onDeviceInference) {
|
|
497
|
+
// Extract features from events (simplified - would need proper feature extraction)
|
|
498
|
+
const features = this.extractFeatures(events);
|
|
499
|
+
return await this.onDeviceInference.predict(features);
|
|
500
|
+
} else if (this.state.inferenceMode === 'server' && this.serverInference) {
|
|
501
|
+
return await this.serverInference.predict(events, this.state.sessionId || undefined, this.state.userId || undefined);
|
|
502
|
+
} else {
|
|
503
|
+
throw new Error('No inference engine available');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Extract features from events (placeholder)
|
|
508
|
+
* In production, this would use proper feature engineering
|
|
509
|
+
*/
|
|
510
|
+
extractFeatures(events) {
|
|
511
|
+
// Simplified feature extraction - 20 features
|
|
512
|
+
const features = new Float32Array(20);
|
|
513
|
+
// Event count
|
|
514
|
+
features[0] = events.length;
|
|
515
|
+
// Unique event types
|
|
516
|
+
const uniqueTypes = new Set(events.map(e => e.event_type));
|
|
517
|
+
features[1] = uniqueTypes.size;
|
|
518
|
+
// Time span
|
|
519
|
+
if (events.length > 1) {
|
|
520
|
+
const timeSpan = events[events.length - 1].timestamp - events[0].timestamp;
|
|
521
|
+
features[2] = timeSpan / 1000; // seconds
|
|
522
|
+
}
|
|
523
|
+
// Fill remaining features with event type frequencies
|
|
524
|
+
const typeCounts = new Map();
|
|
525
|
+
events.forEach(e => {
|
|
526
|
+
typeCounts.set(e.event_type, (typeCounts.get(e.event_type) || 0) + 1);
|
|
527
|
+
});
|
|
528
|
+
let idx = 3;
|
|
529
|
+
['click', 'scroll', 'page_view', 'cart_add', 'checkout_start'].forEach(type => {
|
|
530
|
+
features[idx++] = typeCounts.get(type) || 0;
|
|
531
|
+
});
|
|
532
|
+
return features;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Send prediction to webhook
|
|
536
|
+
*/
|
|
537
|
+
async sendWebhook(prediction) {
|
|
538
|
+
if (!this.config?.webhook?.url) return;
|
|
539
|
+
const maxAttempts = this.config.webhook.retry?.maxAttempts || 3;
|
|
540
|
+
const backoffMs = this.config.webhook.retry?.backoffMs || 1000;
|
|
541
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
542
|
+
try {
|
|
543
|
+
const response = await fetch(this.config.webhook.url, {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: {
|
|
546
|
+
'Content-Type': 'application/json',
|
|
547
|
+
'X-App-Token': this.config.appToken
|
|
548
|
+
},
|
|
549
|
+
body: JSON.stringify({
|
|
550
|
+
...prediction,
|
|
551
|
+
session_id: this.state.sessionId,
|
|
552
|
+
user_id: this.state.userId
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
if (response.ok) {
|
|
556
|
+
if (this.config.debug) {
|
|
557
|
+
console.log('[ATHENA] Webhook delivered successfully');
|
|
558
|
+
}
|
|
559
|
+
return;
|
|
107
560
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
getUserId() {
|
|
114
|
-
const tracker = this.getTracker();
|
|
115
|
-
if (tracker && tracker.getUserId) {
|
|
116
|
-
return tracker.getUserId();
|
|
561
|
+
throw new Error(`Webhook failed: ${response.status}`);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
console.warn(`[ATHENA] Webhook attempt ${attempt}/${maxAttempts} failed:`, error);
|
|
564
|
+
if (attempt < maxAttempts) {
|
|
565
|
+
await this.sleep(backoffMs * Math.pow(2, attempt - 1));
|
|
117
566
|
}
|
|
118
|
-
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
console.error('[ATHENA] Webhook delivery failed after all retries');
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Start event batching interval
|
|
573
|
+
*/
|
|
574
|
+
startBatching() {
|
|
575
|
+
this.batchIntervalId = setInterval(() => {
|
|
576
|
+
this.processBatch();
|
|
577
|
+
}, this.config.batching.intervalMs);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Get inference mode
|
|
581
|
+
*/
|
|
582
|
+
static getInferenceMode() {
|
|
583
|
+
return AthenaTracker.getInstance().state.inferenceMode;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Get session ID
|
|
587
|
+
*/
|
|
588
|
+
static getSessionId() {
|
|
589
|
+
return AthenaTracker.getInstance().state.sessionId;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Cleanup resources
|
|
593
|
+
*/
|
|
594
|
+
static async dispose() {
|
|
595
|
+
const instance = AthenaTracker.getInstance();
|
|
596
|
+
if (instance.batchIntervalId) {
|
|
597
|
+
clearInterval(instance.batchIntervalId);
|
|
598
|
+
instance.batchIntervalId = null;
|
|
119
599
|
}
|
|
600
|
+
if (instance.onDeviceInference) {
|
|
601
|
+
await instance.onDeviceInference.dispose();
|
|
602
|
+
instance.onDeviceInference = null;
|
|
603
|
+
}
|
|
604
|
+
instance.serverInference = null;
|
|
605
|
+
instance.config = null;
|
|
606
|
+
instance.state = {
|
|
607
|
+
initialized: false,
|
|
608
|
+
inferenceMode: null,
|
|
609
|
+
sessionId: null,
|
|
610
|
+
userId: null,
|
|
611
|
+
events: []
|
|
612
|
+
};
|
|
613
|
+
console.log('[ATHENA] Tracker disposed');
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Generate session ID
|
|
617
|
+
*/
|
|
618
|
+
generateSessionId() {
|
|
619
|
+
return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Sleep utility
|
|
623
|
+
*/
|
|
624
|
+
sleep(ms) {
|
|
625
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
AthenaTracker.instance = null;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Webhook Utilities
|
|
632
|
+
*
|
|
633
|
+
* Production-ready webhook system with HMAC signature verification
|
|
634
|
+
* Per ATHENA Integration Plan Phase 4.1
|
|
635
|
+
*/
|
|
636
|
+
// Cross-platform crypto support
|
|
637
|
+
let crypto;
|
|
638
|
+
try {
|
|
639
|
+
// Node.js environment
|
|
640
|
+
crypto = require('crypto');
|
|
641
|
+
} catch {
|
|
642
|
+
// Browser/React Native - will use Web Crypto API
|
|
643
|
+
crypto = null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Generate HMAC-SHA256 signature for webhook payload (Node.js version)
|
|
647
|
+
*
|
|
648
|
+
* @param payload - Webhook payload object
|
|
649
|
+
* @param secret - Webhook secret key
|
|
650
|
+
* @returns HMAC signature (hex-encoded)
|
|
651
|
+
*/
|
|
652
|
+
function generateWebhookSignatureNode(payload, secret) {
|
|
653
|
+
const payloadString = JSON.stringify(payload);
|
|
654
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
655
|
+
hmac.update(payloadString);
|
|
656
|
+
return hmac.digest('hex');
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Generate HMAC-SHA256 signature for webhook payload (Web Crypto API version)
|
|
660
|
+
*
|
|
661
|
+
* @param payload - Webhook payload object
|
|
662
|
+
* @param secret - Webhook secret key
|
|
663
|
+
* @returns HMAC signature (hex-encoded)
|
|
664
|
+
*/
|
|
665
|
+
async function generateWebhookSignatureBrowser(payload, secret) {
|
|
666
|
+
const payloadString = JSON.stringify(payload);
|
|
667
|
+
const encoder = new TextEncoder();
|
|
668
|
+
const keyData = encoder.encode(secret);
|
|
669
|
+
const messageData = encoder.encode(payloadString);
|
|
670
|
+
// Import key for HMAC
|
|
671
|
+
const key = await globalThis.crypto.subtle.importKey('raw', keyData, {
|
|
672
|
+
name: 'HMAC',
|
|
673
|
+
hash: 'SHA-256'
|
|
674
|
+
}, false, ['sign']);
|
|
675
|
+
// Generate signature
|
|
676
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, messageData);
|
|
677
|
+
// Convert to hex
|
|
678
|
+
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Generate HMAC-SHA256 signature for webhook payload (cross-platform)
|
|
682
|
+
*
|
|
683
|
+
* @param payload - Webhook payload object
|
|
684
|
+
* @param secret - Webhook secret key
|
|
685
|
+
* @returns HMAC signature (hex-encoded)
|
|
686
|
+
*/
|
|
687
|
+
async function generateWebhookSignature(payload, secret) {
|
|
688
|
+
if (crypto) {
|
|
689
|
+
// Node.js environment
|
|
690
|
+
return generateWebhookSignatureNode(payload, secret);
|
|
691
|
+
} else if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
|
|
692
|
+
// Browser/Web Crypto API
|
|
693
|
+
return await generateWebhookSignatureBrowser(payload, secret);
|
|
694
|
+
} else {
|
|
695
|
+
// No crypto available - return empty signature
|
|
696
|
+
console.warn('[ATHENA] Crypto API not available - webhook signatures disabled');
|
|
697
|
+
return '';
|
|
698
|
+
}
|
|
120
699
|
}
|
|
121
|
-
// Singleton instance
|
|
122
|
-
let trackerInstance = null;
|
|
123
700
|
/**
|
|
124
|
-
*
|
|
701
|
+
* Verify webhook signature (for use on receiving end)
|
|
702
|
+
*
|
|
703
|
+
* @param payload - Webhook payload object
|
|
704
|
+
* @param signature - Received signature (from X-ATHENA-Signature header)
|
|
705
|
+
* @param secret - Webhook secret key
|
|
706
|
+
* @returns True if signature is valid
|
|
125
707
|
*/
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
708
|
+
async function verifyWebhookSignature(payload, signature, secret) {
|
|
709
|
+
const expectedSignature = await generateWebhookSignature(payload, secret);
|
|
710
|
+
if (!expectedSignature) {
|
|
711
|
+
// Crypto not available - skip verification
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
// Use timing-safe comparison to prevent timing attacks (Node.js only)
|
|
715
|
+
if (crypto && crypto.timingSafeEqual) {
|
|
716
|
+
try {
|
|
717
|
+
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return false;
|
|
129
720
|
}
|
|
130
|
-
|
|
721
|
+
}
|
|
722
|
+
// Browser fallback - simple comparison (not timing-safe)
|
|
723
|
+
return signature === expectedSignature;
|
|
131
724
|
}
|
|
132
725
|
/**
|
|
133
|
-
*
|
|
726
|
+
* Create webhook payload for prediction update
|
|
134
727
|
*/
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
728
|
+
function createPredictionPayload(appId, userId, sessionId, prediction) {
|
|
729
|
+
return {
|
|
730
|
+
event: 'prediction_update',
|
|
731
|
+
appId,
|
|
732
|
+
timestamp: new Date().toISOString(),
|
|
733
|
+
data: {
|
|
734
|
+
user_id: userId,
|
|
735
|
+
session_id: sessionId,
|
|
736
|
+
...prediction
|
|
138
737
|
}
|
|
139
|
-
|
|
738
|
+
};
|
|
140
739
|
}
|
|
141
740
|
/**
|
|
142
|
-
*
|
|
741
|
+
* Create webhook payload for segment update
|
|
143
742
|
*/
|
|
144
|
-
function
|
|
145
|
-
|
|
743
|
+
function createSegmentUpdatePayload(appId, segment, userCount, users) {
|
|
744
|
+
return {
|
|
745
|
+
event: 'segment_update',
|
|
746
|
+
appId,
|
|
747
|
+
timestamp: new Date().toISOString(),
|
|
748
|
+
data: {
|
|
749
|
+
segment,
|
|
750
|
+
user_count: userCount,
|
|
751
|
+
users
|
|
752
|
+
}
|
|
753
|
+
};
|
|
146
754
|
}
|
|
147
|
-
|
|
148
|
-
|
|
755
|
+
/**
|
|
756
|
+
* Create webhook payload for friction detection
|
|
757
|
+
*/
|
|
758
|
+
function createFrictionPayload(appId, frictionType, screen, selector, affectedUsers) {
|
|
759
|
+
return {
|
|
760
|
+
event: 'friction_detected',
|
|
761
|
+
appId,
|
|
762
|
+
timestamp: new Date().toISOString(),
|
|
763
|
+
data: {
|
|
764
|
+
type: frictionType,
|
|
765
|
+
screen,
|
|
766
|
+
selector,
|
|
767
|
+
affected_users: affectedUsers,
|
|
768
|
+
severity: affectedUsers > 100 ? 'high' : affectedUsers > 50 ? 'medium' : 'low'
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Create webhook payload for conversion event
|
|
774
|
+
*/
|
|
775
|
+
function createConversionPayload(appId, userId, sessionId, conversionType, value, metadata) {
|
|
776
|
+
return {
|
|
777
|
+
event: 'conversion',
|
|
778
|
+
appId,
|
|
779
|
+
timestamp: new Date().toISOString(),
|
|
780
|
+
data: {
|
|
781
|
+
user_id: userId,
|
|
782
|
+
session_id: sessionId,
|
|
783
|
+
conversion_type: conversionType,
|
|
784
|
+
value,
|
|
785
|
+
...metadata
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Webhook delivery with retry logic and exponential backoff
|
|
791
|
+
*
|
|
792
|
+
* @param url - Webhook URL
|
|
793
|
+
* @param payload - Webhook payload
|
|
794
|
+
* @param secret - Webhook secret for signature (optional)
|
|
795
|
+
* @param options - Delivery options
|
|
796
|
+
* @returns Delivery result
|
|
797
|
+
*/
|
|
798
|
+
async function deliverWebhook(url, payload, secret, options = {}) {
|
|
799
|
+
const maxAttempts = options.maxAttempts || 3;
|
|
800
|
+
const backoffMs = options.backoffMs || 1000;
|
|
801
|
+
const timeoutMs = options.timeoutMs || 5000;
|
|
802
|
+
const signature = secret ? await generateWebhookSignature(payload, secret) : '';
|
|
803
|
+
const startTime = Date.now();
|
|
804
|
+
let lastError;
|
|
805
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
806
|
+
try {
|
|
807
|
+
const controller = new AbortController();
|
|
808
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
809
|
+
const response = await fetch(url, {
|
|
810
|
+
method: 'POST',
|
|
811
|
+
headers: {
|
|
812
|
+
'Content-Type': 'application/json',
|
|
813
|
+
'X-ATHENA-Signature': signature,
|
|
814
|
+
'X-ATHENA-Event': payload.event,
|
|
815
|
+
'X-ATHENA-Timestamp': payload.timestamp
|
|
816
|
+
},
|
|
817
|
+
body: JSON.stringify(payload),
|
|
818
|
+
signal: controller.signal
|
|
819
|
+
});
|
|
820
|
+
clearTimeout(timeoutId);
|
|
821
|
+
if (response.ok) {
|
|
822
|
+
const latencyMs = Date.now() - startTime;
|
|
823
|
+
return {
|
|
824
|
+
success: true,
|
|
825
|
+
attempts: attempt,
|
|
826
|
+
latencyMs
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
lastError = `HTTP ${response.status}: ${response.statusText}`;
|
|
830
|
+
} catch (error) {
|
|
831
|
+
lastError = error.name === 'AbortError' ? `Timeout after ${timeoutMs}ms` : error.message || 'Unknown error';
|
|
832
|
+
}
|
|
833
|
+
// Wait before retry (exponential backoff)
|
|
834
|
+
if (attempt < maxAttempts) {
|
|
835
|
+
const delayMs = backoffMs * Math.pow(2, attempt - 1);
|
|
836
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const latencyMs = Date.now() - startTime;
|
|
840
|
+
return {
|
|
841
|
+
success: false,
|
|
842
|
+
attempts: maxAttempts,
|
|
843
|
+
latencyMs,
|
|
844
|
+
error: lastError
|
|
845
|
+
};
|
|
149
846
|
}
|
|
150
|
-
|
|
151
|
-
|
|
847
|
+
/**
|
|
848
|
+
* Validate webhook payload structure
|
|
849
|
+
*/
|
|
850
|
+
function isValidWebhookPayload(payload) {
|
|
851
|
+
if (typeof payload !== 'object' || payload === null) return false;
|
|
852
|
+
const validEvents = ['prediction_update', 'segment_update', 'friction_detected', 'conversion', 'session_end'];
|
|
853
|
+
return typeof payload.event === 'string' && validEvents.includes(payload.event) && typeof payload.appId === 'string' && typeof payload.timestamp === 'string' && typeof payload.data === 'object' && payload.data !== null;
|
|
152
854
|
}
|
|
153
|
-
|
|
154
|
-
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* ATHENA OTA Wrapper
|
|
858
|
+
*
|
|
859
|
+
* Wraps the React Native app and forces a reload on first launch after OTA update
|
|
860
|
+
* This ensures the ATHENA tracker is loaded fresh after deployment
|
|
861
|
+
*
|
|
862
|
+
* Usage:
|
|
863
|
+
* import { AthenaOTAWrapper } from '@athena/tracker';
|
|
864
|
+
*
|
|
865
|
+
* export default function App() {
|
|
866
|
+
* return (
|
|
867
|
+
* <AthenaOTAWrapper>
|
|
868
|
+
* <YourAppContent />
|
|
869
|
+
* </AthenaOTAWrapper>
|
|
870
|
+
* );
|
|
871
|
+
* }
|
|
872
|
+
*/
|
|
873
|
+
// Dynamic import of expo-updates (optional peer dependency)
|
|
874
|
+
let Updates = null;
|
|
875
|
+
async function loadExpoUpdates() {
|
|
876
|
+
try {
|
|
877
|
+
Updates = await import('expo-updates');
|
|
878
|
+
return true;
|
|
879
|
+
} catch (error) {
|
|
880
|
+
console.warn('[ATHENA] expo-updates not available - OTA updates disabled');
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
155
883
|
}
|
|
156
|
-
function
|
|
157
|
-
|
|
884
|
+
function AthenaOTAWrapper({
|
|
885
|
+
children,
|
|
886
|
+
loadingMessage = 'Loading...',
|
|
887
|
+
updateMessage = 'Updating...'
|
|
888
|
+
}) {
|
|
889
|
+
const [isCheckingUpdate, setIsCheckingUpdate] = useState(true);
|
|
890
|
+
const [updateAvailable, setUpdateAvailable] = useState(false);
|
|
891
|
+
const [error, setError] = useState(null);
|
|
892
|
+
useEffect(() => {
|
|
893
|
+
checkForUpdates();
|
|
894
|
+
}, []);
|
|
895
|
+
async function checkForUpdates() {
|
|
896
|
+
try {
|
|
897
|
+
// Load expo-updates module
|
|
898
|
+
const hasUpdates = await loadExpoUpdates();
|
|
899
|
+
if (!hasUpdates) {
|
|
900
|
+
// expo-updates not available, continue without OTA check
|
|
901
|
+
setIsCheckingUpdate(false);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// Check if running in development mode (skip update check)
|
|
905
|
+
if (__DEV__) {
|
|
906
|
+
console.log('[ATHENA] Development mode - skipping OTA update check');
|
|
907
|
+
setIsCheckingUpdate(false);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
console.log('[ATHENA] Checking for OTA updates...');
|
|
911
|
+
// Check for available updates
|
|
912
|
+
const update = await Updates.checkForUpdateAsync();
|
|
913
|
+
if (update.isAvailable) {
|
|
914
|
+
console.log('[ATHENA] Update available - fetching...');
|
|
915
|
+
setUpdateAvailable(true);
|
|
916
|
+
// Fetch the new update
|
|
917
|
+
await Updates.fetchUpdateAsync();
|
|
918
|
+
console.log('[ATHENA] Update fetched - reloading app...');
|
|
919
|
+
// Force reload to apply update
|
|
920
|
+
await Updates.reloadAsync();
|
|
921
|
+
// If reloadAsync fails, we'll never reach here
|
|
922
|
+
} else {
|
|
923
|
+
console.log('[ATHENA] No updates available - continuing with current version');
|
|
924
|
+
setIsCheckingUpdate(false);
|
|
925
|
+
}
|
|
926
|
+
} catch (error) {
|
|
927
|
+
// Don't block app if update check fails
|
|
928
|
+
console.warn('[ATHENA] Update check failed:', error.message);
|
|
929
|
+
setError(error.message);
|
|
930
|
+
setIsCheckingUpdate(false);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// Show loading screen while checking for updates
|
|
934
|
+
if (isCheckingUpdate || updateAvailable) {
|
|
935
|
+
return React.createElement(View, {
|
|
936
|
+
style: styles.container
|
|
937
|
+
}, React.createElement(ActivityIndicator, {
|
|
938
|
+
size: "large",
|
|
939
|
+
color: "#007AFF"
|
|
940
|
+
}), React.createElement(Text, {
|
|
941
|
+
style: styles.text
|
|
942
|
+
}, updateAvailable ? updateMessage : loadingMessage), error && React.createElement(Text, {
|
|
943
|
+
style: styles.errorText
|
|
944
|
+
}, "Update check failed: ", error));
|
|
945
|
+
}
|
|
946
|
+
// Render app content once update check is complete
|
|
947
|
+
return React.createElement(React.Fragment, null, children);
|
|
158
948
|
}
|
|
159
|
-
|
|
160
|
-
|
|
949
|
+
const styles = StyleSheet.create({
|
|
950
|
+
container: {
|
|
951
|
+
flex: 1,
|
|
952
|
+
justifyContent: 'center',
|
|
953
|
+
alignItems: 'center',
|
|
954
|
+
backgroundColor: '#FFFFFF'
|
|
955
|
+
},
|
|
956
|
+
text: {
|
|
957
|
+
marginTop: 16,
|
|
958
|
+
color: '#666666',
|
|
959
|
+
fontSize: 14,
|
|
960
|
+
fontWeight: '500'
|
|
961
|
+
},
|
|
962
|
+
errorText: {
|
|
963
|
+
marginTop: 8,
|
|
964
|
+
color: '#FF3B30',
|
|
965
|
+
fontSize: 12,
|
|
966
|
+
textAlign: 'center',
|
|
967
|
+
paddingHorizontal: 32
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* React Native Event Capture
|
|
973
|
+
*
|
|
974
|
+
* Captures behavioral events from React Native apps
|
|
975
|
+
* - Touch events (Tap, Swipe, LongPress)
|
|
976
|
+
* - Navigation events (Screen changes)
|
|
977
|
+
* - App lifecycle events (Open, Background, Foreground, Close)
|
|
978
|
+
* - Form interactions
|
|
979
|
+
* - Network errors
|
|
980
|
+
*/
|
|
981
|
+
class ReactNativeEventCapture {
|
|
982
|
+
constructor(config = {}) {
|
|
983
|
+
this.events = [];
|
|
984
|
+
this.appStateSubscription = null;
|
|
985
|
+
this.panResponder = null;
|
|
986
|
+
this.batchTimer = null;
|
|
987
|
+
this.currentScreen = 'Unknown';
|
|
988
|
+
this.sessionStartTime = Date.now();
|
|
989
|
+
this.config = {
|
|
990
|
+
captureTouch: config.captureTouch !== false,
|
|
991
|
+
captureNavigation: config.captureNavigation !== false,
|
|
992
|
+
captureLifecycle: config.captureLifecycle !== false,
|
|
993
|
+
captureNetworkErrors: config.captureNetworkErrors !== false,
|
|
994
|
+
batchSize: config.batchSize || 10,
|
|
995
|
+
batchIntervalMs: config.batchIntervalMs || 10000
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Start capturing events
|
|
1000
|
+
*/
|
|
1001
|
+
start() {
|
|
1002
|
+
console.log('[ATHENA] Starting React Native event capture');
|
|
1003
|
+
if (this.config.captureLifecycle) {
|
|
1004
|
+
this.setupLifecycleTracking();
|
|
1005
|
+
}
|
|
1006
|
+
if (this.config.captureTouch) {
|
|
1007
|
+
this.setupTouchTracking();
|
|
1008
|
+
}
|
|
1009
|
+
if (this.config.captureNetworkErrors) {
|
|
1010
|
+
this.setupNetworkErrorTracking();
|
|
1011
|
+
}
|
|
1012
|
+
// Start batch timer
|
|
1013
|
+
this.batchTimer = setInterval(() => {
|
|
1014
|
+
this.flushEvents();
|
|
1015
|
+
}, this.config.batchIntervalMs);
|
|
1016
|
+
// Capture initial AppOpen event
|
|
1017
|
+
this.captureEvent({
|
|
1018
|
+
event_type: 'AppOpen',
|
|
1019
|
+
timestamp: Date.now(),
|
|
1020
|
+
properties: {
|
|
1021
|
+
screen: this.currentScreen,
|
|
1022
|
+
session_start: true
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Stop capturing events
|
|
1028
|
+
*/
|
|
1029
|
+
stop() {
|
|
1030
|
+
console.log('[ATHENA] Stopping React Native event capture');
|
|
1031
|
+
if (this.appStateSubscription) {
|
|
1032
|
+
this.appStateSubscription.remove();
|
|
1033
|
+
}
|
|
1034
|
+
if (this.batchTimer) {
|
|
1035
|
+
clearInterval(this.batchTimer);
|
|
1036
|
+
}
|
|
1037
|
+
// Flush any remaining events
|
|
1038
|
+
this.flushEvents();
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Setup app lifecycle tracking (Open, Background, Foreground, Close)
|
|
1042
|
+
*/
|
|
1043
|
+
setupLifecycleTracking() {
|
|
1044
|
+
this.appStateSubscription = AppState.addEventListener('change', nextAppState => {
|
|
1045
|
+
if (nextAppState === 'active') {
|
|
1046
|
+
this.captureEvent({
|
|
1047
|
+
event_type: 'AppForeground',
|
|
1048
|
+
timestamp: Date.now(),
|
|
1049
|
+
properties: {
|
|
1050
|
+
screen: this.currentScreen
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
} else if (nextAppState === 'background') {
|
|
1054
|
+
this.captureEvent({
|
|
1055
|
+
event_type: 'AppBackground',
|
|
1056
|
+
timestamp: Date.now(),
|
|
1057
|
+
properties: {
|
|
1058
|
+
screen: this.currentScreen,
|
|
1059
|
+
session_duration: Date.now() - this.sessionStartTime
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
} else if (nextAppState === 'inactive') {
|
|
1063
|
+
this.captureEvent({
|
|
1064
|
+
event_type: 'AppInactive',
|
|
1065
|
+
timestamp: Date.now(),
|
|
1066
|
+
properties: {
|
|
1067
|
+
screen: this.currentScreen
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Setup touch event tracking
|
|
1075
|
+
*/
|
|
1076
|
+
setupTouchTracking() {
|
|
1077
|
+
let touchStartTime = 0;
|
|
1078
|
+
this.panResponder = PanResponder.create({
|
|
1079
|
+
onStartShouldSetPanResponder: () => true,
|
|
1080
|
+
onMoveShouldSetPanResponder: () => true,
|
|
1081
|
+
onPanResponderGrant: (_evt, _gestureState) => {
|
|
1082
|
+
touchStartTime = Date.now();
|
|
1083
|
+
},
|
|
1084
|
+
onPanResponderRelease: (evt, gestureState) => {
|
|
1085
|
+
const touchDuration = Date.now() - touchStartTime;
|
|
1086
|
+
const deltaX = Math.abs(gestureState.dx);
|
|
1087
|
+
const deltaY = Math.abs(gestureState.dy);
|
|
1088
|
+
// Detect gesture type
|
|
1089
|
+
if (touchDuration > 500 && deltaX < 10 && deltaY < 10) {
|
|
1090
|
+
// Long press
|
|
1091
|
+
this.captureEvent({
|
|
1092
|
+
event_type: 'LongPress',
|
|
1093
|
+
timestamp: Date.now(),
|
|
1094
|
+
properties: {
|
|
1095
|
+
screen: this.currentScreen,
|
|
1096
|
+
x: evt.nativeEvent.pageX,
|
|
1097
|
+
y: evt.nativeEvent.pageY,
|
|
1098
|
+
duration: touchDuration
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
} else if (deltaX > 50 || deltaY > 50) {
|
|
1102
|
+
// Swipe
|
|
1103
|
+
const direction = deltaX > deltaY ? gestureState.dx > 0 ? 'right' : 'left' : gestureState.dy > 0 ? 'down' : 'up';
|
|
1104
|
+
this.captureEvent({
|
|
1105
|
+
event_type: 'Swipe',
|
|
1106
|
+
timestamp: Date.now(),
|
|
1107
|
+
properties: {
|
|
1108
|
+
screen: this.currentScreen,
|
|
1109
|
+
direction,
|
|
1110
|
+
distance: Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
} else {
|
|
1114
|
+
// Tap
|
|
1115
|
+
this.captureEvent({
|
|
1116
|
+
event_type: 'Tap',
|
|
1117
|
+
timestamp: Date.now(),
|
|
1118
|
+
properties: {
|
|
1119
|
+
screen: this.currentScreen,
|
|
1120
|
+
x: evt.nativeEvent.pageX,
|
|
1121
|
+
y: evt.nativeEvent.pageY,
|
|
1122
|
+
duration: touchDuration
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Setup network error tracking
|
|
1131
|
+
*/
|
|
1132
|
+
setupNetworkErrorTracking() {
|
|
1133
|
+
// Monkey-patch fetch to track network errors
|
|
1134
|
+
const originalFetch = global.fetch;
|
|
1135
|
+
global.fetch = async (input, init) => {
|
|
1136
|
+
const startTime = Date.now();
|
|
1137
|
+
const url = typeof input === 'string' ? input : input.url || String(input);
|
|
1138
|
+
try {
|
|
1139
|
+
const response = await originalFetch(input, init);
|
|
1140
|
+
if (!response.ok) {
|
|
1141
|
+
this.captureEvent({
|
|
1142
|
+
event_type: 'NetworkError',
|
|
1143
|
+
timestamp: Date.now(),
|
|
1144
|
+
properties: {
|
|
1145
|
+
screen: this.currentScreen,
|
|
1146
|
+
url,
|
|
1147
|
+
status: response.status,
|
|
1148
|
+
statusText: response.statusText,
|
|
1149
|
+
duration: Date.now() - startTime
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return response;
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
this.captureEvent({
|
|
1156
|
+
event_type: 'NetworkError',
|
|
1157
|
+
timestamp: Date.now(),
|
|
1158
|
+
properties: {
|
|
1159
|
+
screen: this.currentScreen,
|
|
1160
|
+
url,
|
|
1161
|
+
error: error.message,
|
|
1162
|
+
duration: Date.now() - startTime
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Manually track screen navigation
|
|
1171
|
+
* Should be called by navigation library (React Navigation, etc.)
|
|
1172
|
+
*/
|
|
1173
|
+
trackScreenView(screenName, params) {
|
|
1174
|
+
this.currentScreen = screenName;
|
|
1175
|
+
this.captureEvent({
|
|
1176
|
+
event_type: 'ScreenView',
|
|
1177
|
+
timestamp: Date.now(),
|
|
1178
|
+
properties: {
|
|
1179
|
+
screen: screenName,
|
|
1180
|
+
params: params || {},
|
|
1181
|
+
previous_screen: this.currentScreen
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Manually track custom event
|
|
1187
|
+
*/
|
|
1188
|
+
track(eventType, properties) {
|
|
1189
|
+
this.captureEvent({
|
|
1190
|
+
event_type: eventType,
|
|
1191
|
+
timestamp: Date.now(),
|
|
1192
|
+
properties: {
|
|
1193
|
+
...properties,
|
|
1194
|
+
screen: this.currentScreen
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Capture an event and add to batch
|
|
1200
|
+
*/
|
|
1201
|
+
captureEvent(event) {
|
|
1202
|
+
this.events.push(event);
|
|
1203
|
+
// Flush immediately if batch size reached
|
|
1204
|
+
if (this.events.length >= this.config.batchSize) {
|
|
1205
|
+
this.flushEvents();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Flush accumulated events (to be sent to server)
|
|
1210
|
+
*/
|
|
1211
|
+
flushEvents() {
|
|
1212
|
+
if (this.events.length === 0) return;
|
|
1213
|
+
const batch = [...this.events];
|
|
1214
|
+
this.events = [];
|
|
1215
|
+
// Emit events for tracker to send
|
|
1216
|
+
this.onEventBatch?.(batch);
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Get PanResponder for manual integration
|
|
1220
|
+
* Usage: <View {...capture.getPanResponderProps()}>
|
|
1221
|
+
*/
|
|
1222
|
+
getPanResponderProps() {
|
|
1223
|
+
return this.panResponder?.panHandlers || {};
|
|
1224
|
+
}
|
|
161
1225
|
}
|
|
162
|
-
// Default export
|
|
163
|
-
var index = {
|
|
164
|
-
initTracker,
|
|
165
|
-
getTracker,
|
|
166
|
-
identify,
|
|
167
|
-
track,
|
|
168
|
-
page,
|
|
169
|
-
reset,
|
|
170
|
-
getSessionId,
|
|
171
|
-
getUserId,
|
|
172
|
-
};
|
|
173
1226
|
|
|
174
|
-
|
|
1227
|
+
/**
|
|
1228
|
+
* @athena/tracker
|
|
1229
|
+
*
|
|
1230
|
+
* Behavioral analytics tracker with edge AI
|
|
1231
|
+
*/
|
|
1232
|
+
// Version
|
|
1233
|
+
const VERSION = '1.0.0';
|
|
1234
|
+
|
|
1235
|
+
export { AthenaOTAWrapper, AthenaTracker, ReactNativeEventCapture, VERSION, createConversionPayload, createFrictionPayload, createPredictionPayload, createSegmentUpdatePayload, AthenaTracker as default, deliverWebhook, detectInferenceMode, generateWebhookSignature, getPlatform, isBrowser, isReactNative, isValidWebhookPayload, verifyWebhookSignature };
|
|
175
1236
|
//# sourceMappingURL=index.esm.js.map
|