@athena-tracker/tracker 1.0.2 → 1.2.1
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 +1337 -146
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1353 -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 +108 -0
- package/dist/tracker.d.ts.map +1 -0
- package/dist/types/index.d.ts +107 -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 +61 -14
package/dist/index.esm.js
CHANGED
|
@@ -1,175 +1,1366 @@
|
|
|
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
|
+
};
|
|
199
|
+
}
|
|
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
|
+
};
|
|
99
207
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
console.log(`[ATHENA] Testing server connectivity: ${healthUrl}`);
|
|
318
|
+
const response = await this.fetchWithTimeout(healthUrl, {
|
|
319
|
+
method: 'GET'
|
|
320
|
+
}, Math.min(this.timeout, 5000)); // Use 5s max for health check
|
|
321
|
+
if (response.ok) {
|
|
322
|
+
console.log('[ATHENA] Server connectivity test passed');
|
|
323
|
+
}
|
|
324
|
+
return response.ok;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.warn('[ATHENA] Server connectivity test failed:', error.message || error);
|
|
327
|
+
console.warn('[ATHENA] URL attempted:', this.apiUrl.replace('/v1/predict', '/health'));
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Update configuration
|
|
333
|
+
*/
|
|
334
|
+
updateConfig(apiUrl, appToken, timeout) {
|
|
335
|
+
if (apiUrl) this.apiUrl = apiUrl;
|
|
336
|
+
if (appToken) this.appToken = appToken;
|
|
337
|
+
if (timeout) this.timeout = timeout;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* ATHENA Tracker - Main Class
|
|
343
|
+
*
|
|
344
|
+
* Unified interface for behavioral tracking with dual-mode ML inference
|
|
345
|
+
*/
|
|
346
|
+
const DEFAULT_API_URL = 'https://tracker.pascal.cx';
|
|
347
|
+
const DEFAULT_ML_API_URL = 'https://pascal-ml-api-344769701859.us-central1.run.app';
|
|
348
|
+
const DEFAULT_MODEL_PATH = 'https://tracker.pascal.cx/models/base_model_int8.onnx';
|
|
349
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
350
|
+
const DEFAULT_BATCH_INTERVAL_MS = 10000; // 10 seconds
|
|
351
|
+
class AthenaTracker {
|
|
352
|
+
constructor() {
|
|
353
|
+
this.config = null;
|
|
354
|
+
this.state = {
|
|
355
|
+
initialized: false,
|
|
356
|
+
inferenceMode: null,
|
|
357
|
+
sessionId: null,
|
|
358
|
+
userId: null,
|
|
359
|
+
events: []
|
|
360
|
+
};
|
|
361
|
+
this.onDeviceInference = null;
|
|
362
|
+
this.serverInference = null;
|
|
363
|
+
this.batchIntervalId = null;
|
|
364
|
+
this.failureCount = 0;
|
|
365
|
+
this.maxFailures = 3;
|
|
366
|
+
// External IDs for custom audience building (Meta, Google)
|
|
367
|
+
this.externalIds = {};
|
|
368
|
+
// Private constructor for singleton
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Get singleton instance
|
|
372
|
+
*/
|
|
373
|
+
static getInstance() {
|
|
374
|
+
if (!AthenaTracker.instance) {
|
|
375
|
+
AthenaTracker.instance = new AthenaTracker();
|
|
376
|
+
}
|
|
377
|
+
return AthenaTracker.instance;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Initialize tracker
|
|
381
|
+
*/
|
|
382
|
+
static async init(config) {
|
|
383
|
+
const instance = AthenaTracker.getInstance();
|
|
384
|
+
if (instance.state.initialized) {
|
|
385
|
+
console.warn('[ATHENA] Already initialized');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
console.log('[ATHENA] Initializing tracker...');
|
|
389
|
+
instance.config = {
|
|
390
|
+
...config,
|
|
391
|
+
apiUrl: config.apiUrl || DEFAULT_API_URL,
|
|
392
|
+
modelPath: config.modelPath || DEFAULT_MODEL_PATH,
|
|
393
|
+
serverInferenceUrl: config.serverInferenceUrl || `${DEFAULT_ML_API_URL}/v1/predict`,
|
|
394
|
+
batching: {
|
|
395
|
+
size: config.batching?.size || DEFAULT_BATCH_SIZE,
|
|
396
|
+
intervalMs: config.batching?.intervalMs || DEFAULT_BATCH_INTERVAL_MS
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
// Detect inference mode
|
|
400
|
+
const detectedMode = await detectInferenceMode(config.inferenceMode);
|
|
401
|
+
instance.state.inferenceMode = detectedMode;
|
|
402
|
+
// Initialize appropriate inference engine
|
|
403
|
+
if (detectedMode === 'on-device') {
|
|
404
|
+
await instance.initializeOnDevice();
|
|
405
|
+
} else {
|
|
406
|
+
await instance.initializeServer();
|
|
407
|
+
}
|
|
408
|
+
// Generate session ID
|
|
409
|
+
instance.state.sessionId = instance.generateSessionId();
|
|
410
|
+
// Generate anonymous user ID (will be replaced on identify())
|
|
411
|
+
instance.state.userId = instance.generateAnonymousId();
|
|
412
|
+
// Capture external IDs for custom audience building (browser only)
|
|
413
|
+
instance.captureExternalIds();
|
|
414
|
+
// Start event batching
|
|
415
|
+
instance.startBatching();
|
|
416
|
+
instance.state.initialized = true;
|
|
417
|
+
console.log(`[ATHENA] Tracker initialized successfully (mode: ${detectedMode}, platform: ${getPlatform()})`);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Initialize on-device inference
|
|
421
|
+
*/
|
|
422
|
+
async initializeOnDevice() {
|
|
423
|
+
try {
|
|
424
|
+
this.onDeviceInference = new OnDeviceInference();
|
|
425
|
+
await this.onDeviceInference.initialize(this.config.modelPath);
|
|
426
|
+
console.log('[ATHENA] On-device inference ready');
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error('[ATHENA] On-device inference initialization failed:', error);
|
|
429
|
+
console.log('[ATHENA] Falling back to server-side inference');
|
|
430
|
+
this.state.inferenceMode = 'server';
|
|
431
|
+
await this.initializeServer();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Initialize server-side inference
|
|
436
|
+
*/
|
|
437
|
+
async initializeServer() {
|
|
438
|
+
this.serverInference = new ServerInference(this.config.serverInferenceUrl, this.config.appToken, this.config.timeout || 5000 // Use configured timeout or default to 5s
|
|
439
|
+
);
|
|
440
|
+
// Test connectivity
|
|
441
|
+
const connected = await this.serverInference.testConnection();
|
|
442
|
+
if (!connected) {
|
|
443
|
+
console.warn('[ATHENA] Server connectivity test failed - predictions may fail');
|
|
444
|
+
} else {
|
|
445
|
+
console.log('[ATHENA] Server-side inference ready');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Identify a user
|
|
450
|
+
* Supports email/phone hashing for Meta/Google custom audiences
|
|
451
|
+
*
|
|
452
|
+
* @param userId - User identifier
|
|
453
|
+
* @param traits - User traits (email, phone, name, etc.)
|
|
454
|
+
* @param options - Optional configuration
|
|
455
|
+
*/
|
|
456
|
+
static async identify(userId, traits, options) {
|
|
457
|
+
const instance = AthenaTracker.getInstance();
|
|
458
|
+
const previousUserId = instance.state.userId; // Store anonymous ID for mapping
|
|
459
|
+
instance.state.userId = userId;
|
|
460
|
+
// Hash PII for custom audience building if requested
|
|
461
|
+
const hashedTraits = {
|
|
462
|
+
...traits
|
|
463
|
+
};
|
|
464
|
+
if (options?.hashPII && traits) {
|
|
465
|
+
if (traits.email) {
|
|
466
|
+
hashedTraits.email_sha256 = await instance.hashPII(traits.email);
|
|
467
|
+
}
|
|
468
|
+
if (traits.phone) {
|
|
469
|
+
hashedTraits.phone_sha256 = await instance.hashPII(traits.phone);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
AthenaTracker.track('identify', {
|
|
473
|
+
user_id: userId,
|
|
474
|
+
anonymous_id: previousUserId,
|
|
475
|
+
// Map anonymous → identified user
|
|
476
|
+
...hashedTraits,
|
|
477
|
+
// Include external IDs for audience sync
|
|
478
|
+
external_ids: Object.keys(instance.externalIds).length > 0 ? instance.externalIds : undefined
|
|
479
|
+
});
|
|
480
|
+
console.log(`[ATHENA] User identified: ${userId}` + (previousUserId?.startsWith('anon_') ? ` (was ${previousUserId})` : ''));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Track an event
|
|
484
|
+
*/
|
|
485
|
+
static track(eventType, properties) {
|
|
486
|
+
const instance = AthenaTracker.getInstance();
|
|
487
|
+
if (!instance.state.initialized) {
|
|
488
|
+
console.warn('[ATHENA] Tracker not initialized. Call init() first.');
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const event = {
|
|
492
|
+
event_type: eventType,
|
|
493
|
+
timestamp: Date.now(),
|
|
494
|
+
properties: properties || {},
|
|
495
|
+
session_id: instance.state.sessionId || undefined,
|
|
496
|
+
user_id: instance.state.userId || undefined
|
|
497
|
+
};
|
|
498
|
+
instance.state.events.push(event);
|
|
499
|
+
if (instance.config?.debug) {
|
|
500
|
+
console.log('[ATHENA] Event tracked:', event);
|
|
501
|
+
}
|
|
502
|
+
// Trigger immediate inference if batch size reached
|
|
503
|
+
if (instance.state.events.length >= instance.config.batching.size) {
|
|
504
|
+
instance.processBatch();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Process event batch and run inference
|
|
509
|
+
*/
|
|
510
|
+
async processBatch() {
|
|
511
|
+
if (this.state.events.length === 0) return;
|
|
512
|
+
// Skip inference if too many consecutive failures
|
|
513
|
+
if (this.failureCount >= this.maxFailures) {
|
|
514
|
+
if (this.config?.debug) {
|
|
515
|
+
console.warn(`[ATHENA] Skipping inference after ${this.failureCount} consecutive failures. Will retry after ${this.maxFailures} successful events.`);
|
|
516
|
+
}
|
|
517
|
+
// Reset failure count after clearing queue
|
|
518
|
+
this.failureCount = Math.max(0, this.failureCount - 1);
|
|
519
|
+
this.state.events = []; // Drop events to prevent infinite buildup
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const events = [...this.state.events];
|
|
523
|
+
this.state.events = [];
|
|
524
|
+
try {
|
|
525
|
+
const prediction = await this.runInference(events);
|
|
526
|
+
this.failureCount = 0; // Reset on success
|
|
527
|
+
if (this.config?.webhook?.enabled && this.config.webhook.url) {
|
|
528
|
+
await this.sendWebhook(prediction);
|
|
529
|
+
}
|
|
530
|
+
if (this.config?.debug) {
|
|
531
|
+
console.log('[ATHENA] Prediction:', prediction);
|
|
532
|
+
}
|
|
533
|
+
} catch (error) {
|
|
534
|
+
this.failureCount++;
|
|
535
|
+
console.error(`[ATHENA] Failed to process batch (${this.failureCount}/${this.maxFailures}):`, error.message || error);
|
|
536
|
+
// Only re-queue if below failure threshold
|
|
537
|
+
if (this.failureCount < this.maxFailures) {
|
|
538
|
+
this.state.events.unshift(...events);
|
|
539
|
+
} else {
|
|
540
|
+
console.warn('[ATHENA] Max failures reached. Dropping events and disabling inference temporarily.');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Run inference (delegates to on-device or server)
|
|
546
|
+
*/
|
|
547
|
+
async runInference(events) {
|
|
548
|
+
if (this.state.inferenceMode === 'on-device' && this.onDeviceInference) {
|
|
549
|
+
// Extract features from events (simplified - would need proper feature extraction)
|
|
550
|
+
const features = this.extractFeatures(events);
|
|
551
|
+
return await this.onDeviceInference.predict(features);
|
|
552
|
+
} else if (this.state.inferenceMode === 'server' && this.serverInference) {
|
|
553
|
+
return await this.serverInference.predict(events, this.state.sessionId || undefined, this.state.userId || undefined);
|
|
554
|
+
} else {
|
|
555
|
+
throw new Error('No inference engine available');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Extract features from events (placeholder)
|
|
560
|
+
* In production, this would use proper feature engineering
|
|
561
|
+
*/
|
|
562
|
+
extractFeatures(events) {
|
|
563
|
+
// Simplified feature extraction - 20 features
|
|
564
|
+
const features = new Float32Array(20);
|
|
565
|
+
// Event count
|
|
566
|
+
features[0] = events.length;
|
|
567
|
+
// Unique event types
|
|
568
|
+
const uniqueTypes = new Set(events.map(e => e.event_type));
|
|
569
|
+
features[1] = uniqueTypes.size;
|
|
570
|
+
// Time span
|
|
571
|
+
if (events.length > 1) {
|
|
572
|
+
const timeSpan = events[events.length - 1].timestamp - events[0].timestamp;
|
|
573
|
+
features[2] = timeSpan / 1000; // seconds
|
|
574
|
+
}
|
|
575
|
+
// Fill remaining features with event type frequencies
|
|
576
|
+
const typeCounts = new Map();
|
|
577
|
+
events.forEach(e => {
|
|
578
|
+
typeCounts.set(e.event_type, (typeCounts.get(e.event_type) || 0) + 1);
|
|
579
|
+
});
|
|
580
|
+
let idx = 3;
|
|
581
|
+
['click', 'scroll', 'page_view', 'cart_add', 'checkout_start'].forEach(type => {
|
|
582
|
+
features[idx++] = typeCounts.get(type) || 0;
|
|
583
|
+
});
|
|
584
|
+
return features;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Send prediction to webhook
|
|
588
|
+
*/
|
|
589
|
+
async sendWebhook(prediction) {
|
|
590
|
+
if (!this.config?.webhook?.url) return;
|
|
591
|
+
const maxAttempts = this.config.webhook.retry?.maxAttempts || 3;
|
|
592
|
+
const backoffMs = this.config.webhook.retry?.backoffMs || 1000;
|
|
593
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
594
|
+
try {
|
|
595
|
+
const response = await fetch(this.config.webhook.url, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: {
|
|
598
|
+
'Content-Type': 'application/json',
|
|
599
|
+
'X-App-Token': this.config.appToken
|
|
600
|
+
},
|
|
601
|
+
body: JSON.stringify({
|
|
602
|
+
...prediction,
|
|
603
|
+
session_id: this.state.sessionId,
|
|
604
|
+
user_id: this.state.userId,
|
|
605
|
+
// Include external IDs for custom audience building
|
|
606
|
+
external_ids: Object.keys(this.externalIds).length > 0 ? this.externalIds : undefined
|
|
607
|
+
})
|
|
608
|
+
});
|
|
609
|
+
if (response.ok) {
|
|
610
|
+
if (this.config.debug) {
|
|
611
|
+
console.log('[ATHENA] Webhook delivered successfully');
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
107
614
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
getUserId() {
|
|
114
|
-
const tracker = this.getTracker();
|
|
115
|
-
if (tracker && tracker.getUserId) {
|
|
116
|
-
return tracker.getUserId();
|
|
615
|
+
throw new Error(`Webhook failed: ${response.status}`);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
console.warn(`[ATHENA] Webhook attempt ${attempt}/${maxAttempts} failed:`, error);
|
|
618
|
+
if (attempt < maxAttempts) {
|
|
619
|
+
await this.sleep(backoffMs * Math.pow(2, attempt - 1));
|
|
117
620
|
}
|
|
118
|
-
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
console.error('[ATHENA] Webhook delivery failed after all retries');
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Start event batching interval
|
|
627
|
+
*/
|
|
628
|
+
startBatching() {
|
|
629
|
+
this.batchIntervalId = setInterval(() => {
|
|
630
|
+
this.processBatch();
|
|
631
|
+
}, this.config.batching.intervalMs);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get inference mode
|
|
635
|
+
*/
|
|
636
|
+
static getInferenceMode() {
|
|
637
|
+
return AthenaTracker.getInstance().state.inferenceMode;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Get session ID
|
|
641
|
+
*/
|
|
642
|
+
static getSessionId() {
|
|
643
|
+
return AthenaTracker.getInstance().state.sessionId;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Cleanup resources
|
|
647
|
+
*/
|
|
648
|
+
static async dispose() {
|
|
649
|
+
const instance = AthenaTracker.getInstance();
|
|
650
|
+
if (instance.batchIntervalId) {
|
|
651
|
+
clearInterval(instance.batchIntervalId);
|
|
652
|
+
instance.batchIntervalId = null;
|
|
653
|
+
}
|
|
654
|
+
if (instance.onDeviceInference) {
|
|
655
|
+
await instance.onDeviceInference.dispose();
|
|
656
|
+
instance.onDeviceInference = null;
|
|
119
657
|
}
|
|
658
|
+
instance.serverInference = null;
|
|
659
|
+
instance.config = null;
|
|
660
|
+
instance.state = {
|
|
661
|
+
initialized: false,
|
|
662
|
+
inferenceMode: null,
|
|
663
|
+
sessionId: null,
|
|
664
|
+
userId: null,
|
|
665
|
+
events: []
|
|
666
|
+
};
|
|
667
|
+
console.log('[ATHENA] Tracker disposed');
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Generate session ID
|
|
671
|
+
*/
|
|
672
|
+
generateSessionId() {
|
|
673
|
+
return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Generate anonymous user ID compatible with Meta/Google standards
|
|
677
|
+
* Format: anon_{timestamp}_{random} for Pascal internal use
|
|
678
|
+
* Can be mapped to hashed PII for custom audience building
|
|
679
|
+
*/
|
|
680
|
+
generateAnonymousId() {
|
|
681
|
+
const timestamp = Date.now();
|
|
682
|
+
const random = Math.random().toString(36).substring(2, 11);
|
|
683
|
+
return `anon_${timestamp}_${random}`;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Capture external IDs from Meta and Google for custom audience building
|
|
687
|
+
* Only works in browser environment with cookies
|
|
688
|
+
*/
|
|
689
|
+
captureExternalIds() {
|
|
690
|
+
// Skip if not in browser environment
|
|
691
|
+
if (typeof document === 'undefined' || typeof document.cookie === 'undefined') {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
|
|
696
|
+
const [key, value] = cookie.trim().split('=');
|
|
697
|
+
acc[key] = value;
|
|
698
|
+
return acc;
|
|
699
|
+
}, {});
|
|
700
|
+
// Meta Pixel ID (_fbp) - Format: fb.{version}.{timestamp}.{random}
|
|
701
|
+
if (cookies._fbp) {
|
|
702
|
+
this.externalIds.fbp = cookies._fbp;
|
|
703
|
+
}
|
|
704
|
+
// Meta Click ID (_fbc) - Format: fb.{version}.{timestamp}.{click_id}
|
|
705
|
+
if (cookies._fbc) {
|
|
706
|
+
this.externalIds.fbc = cookies._fbc;
|
|
707
|
+
}
|
|
708
|
+
// Google Analytics Client ID (_ga) - Format: GA1.{version}.{random}.{timestamp}
|
|
709
|
+
if (cookies._ga) {
|
|
710
|
+
this.externalIds.ga = cookies._ga;
|
|
711
|
+
}
|
|
712
|
+
// Google Click ID (gcl_aw) - For Google Ads conversions
|
|
713
|
+
if (cookies.gcl_aw) {
|
|
714
|
+
this.externalIds.gclid = cookies.gcl_aw;
|
|
715
|
+
}
|
|
716
|
+
if (this.config?.debug && Object.keys(this.externalIds).length > 0) {
|
|
717
|
+
console.log('[ATHENA] External IDs captured for audience building:', this.externalIds);
|
|
718
|
+
}
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.warn('[ATHENA] Failed to capture external IDs:', error);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Hash PII for custom audience matching (SHA-256)
|
|
725
|
+
* Use this when user identifies with email/phone
|
|
726
|
+
*/
|
|
727
|
+
async hashPII(value) {
|
|
728
|
+
// Normalize: lowercase, trim whitespace
|
|
729
|
+
const normalized = value.toLowerCase().trim();
|
|
730
|
+
// Use Web Crypto API for hashing (browser/modern Node)
|
|
731
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
732
|
+
const encoder = new TextEncoder();
|
|
733
|
+
const data = encoder.encode(normalized);
|
|
734
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
735
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
736
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
737
|
+
}
|
|
738
|
+
// Try expo-crypto for React Native (Expo)
|
|
739
|
+
try {
|
|
740
|
+
// @ts-expect-error - expo-crypto is an optional peer dependency
|
|
741
|
+
const Crypto = await import('expo-crypto');
|
|
742
|
+
const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, normalized);
|
|
743
|
+
return hash;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
// expo-crypto not available
|
|
746
|
+
}
|
|
747
|
+
// Fallback: return unhashed (server should hash it)
|
|
748
|
+
console.warn('[ATHENA] SHA-256 not available - install expo-crypto for React Native: npx expo install expo-crypto');
|
|
749
|
+
return normalized;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Sleep utility
|
|
753
|
+
*/
|
|
754
|
+
sleep(ms) {
|
|
755
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
AthenaTracker.instance = null;
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Webhook Utilities
|
|
762
|
+
*
|
|
763
|
+
* Production-ready webhook system with HMAC signature verification
|
|
764
|
+
* Per ATHENA Integration Plan Phase 4.1
|
|
765
|
+
*/
|
|
766
|
+
// Cross-platform crypto support
|
|
767
|
+
let crypto$1;
|
|
768
|
+
try {
|
|
769
|
+
// Node.js environment
|
|
770
|
+
crypto$1 = require('crypto');
|
|
771
|
+
} catch {
|
|
772
|
+
// Browser/React Native - will use Web Crypto API
|
|
773
|
+
crypto$1 = null;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Generate HMAC-SHA256 signature for webhook payload (Node.js version)
|
|
777
|
+
*
|
|
778
|
+
* @param payload - Webhook payload object
|
|
779
|
+
* @param secret - Webhook secret key
|
|
780
|
+
* @returns HMAC signature (hex-encoded)
|
|
781
|
+
*/
|
|
782
|
+
function generateWebhookSignatureNode(payload, secret) {
|
|
783
|
+
const payloadString = JSON.stringify(payload);
|
|
784
|
+
const hmac = crypto$1.createHmac('sha256', secret);
|
|
785
|
+
hmac.update(payloadString);
|
|
786
|
+
return hmac.digest('hex');
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Generate HMAC-SHA256 signature for webhook payload (Web Crypto API version)
|
|
790
|
+
*
|
|
791
|
+
* @param payload - Webhook payload object
|
|
792
|
+
* @param secret - Webhook secret key
|
|
793
|
+
* @returns HMAC signature (hex-encoded)
|
|
794
|
+
*/
|
|
795
|
+
async function generateWebhookSignatureBrowser(payload, secret) {
|
|
796
|
+
const payloadString = JSON.stringify(payload);
|
|
797
|
+
const encoder = new TextEncoder();
|
|
798
|
+
const keyData = encoder.encode(secret);
|
|
799
|
+
const messageData = encoder.encode(payloadString);
|
|
800
|
+
// Import key for HMAC
|
|
801
|
+
const key = await globalThis.crypto.subtle.importKey('raw', keyData, {
|
|
802
|
+
name: 'HMAC',
|
|
803
|
+
hash: 'SHA-256'
|
|
804
|
+
}, false, ['sign']);
|
|
805
|
+
// Generate signature
|
|
806
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, messageData);
|
|
807
|
+
// Convert to hex
|
|
808
|
+
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
120
809
|
}
|
|
121
|
-
// Singleton instance
|
|
122
|
-
let trackerInstance = null;
|
|
123
810
|
/**
|
|
124
|
-
*
|
|
811
|
+
* Generate HMAC-SHA256 signature for webhook payload (cross-platform)
|
|
812
|
+
*
|
|
813
|
+
* @param payload - Webhook payload object
|
|
814
|
+
* @param secret - Webhook secret key
|
|
815
|
+
* @returns HMAC signature (hex-encoded)
|
|
125
816
|
*/
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
817
|
+
async function generateWebhookSignature(payload, secret) {
|
|
818
|
+
if (crypto$1) {
|
|
819
|
+
// Node.js environment
|
|
820
|
+
return generateWebhookSignatureNode(payload, secret);
|
|
821
|
+
} else if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
|
|
822
|
+
// Browser/Web Crypto API
|
|
823
|
+
return await generateWebhookSignatureBrowser(payload, secret);
|
|
824
|
+
} else {
|
|
825
|
+
// No crypto available - return empty signature
|
|
826
|
+
console.warn('[ATHENA] Crypto API not available - webhook signatures disabled');
|
|
827
|
+
return '';
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Verify webhook signature (for use on receiving end)
|
|
832
|
+
*
|
|
833
|
+
* @param payload - Webhook payload object
|
|
834
|
+
* @param signature - Received signature (from X-ATHENA-Signature header)
|
|
835
|
+
* @param secret - Webhook secret key
|
|
836
|
+
* @returns True if signature is valid
|
|
837
|
+
*/
|
|
838
|
+
async function verifyWebhookSignature(payload, signature, secret) {
|
|
839
|
+
const expectedSignature = await generateWebhookSignature(payload, secret);
|
|
840
|
+
if (!expectedSignature) {
|
|
841
|
+
// Crypto not available - skip verification
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
// Use timing-safe comparison to prevent timing attacks (Node.js only)
|
|
845
|
+
if (crypto$1 && crypto$1.timingSafeEqual) {
|
|
846
|
+
try {
|
|
847
|
+
return crypto$1.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
848
|
+
} catch (error) {
|
|
849
|
+
return false;
|
|
129
850
|
}
|
|
130
|
-
|
|
851
|
+
}
|
|
852
|
+
// Browser fallback - simple comparison (not timing-safe)
|
|
853
|
+
return signature === expectedSignature;
|
|
131
854
|
}
|
|
132
855
|
/**
|
|
133
|
-
*
|
|
856
|
+
* Create webhook payload for prediction update
|
|
134
857
|
*/
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
858
|
+
function createPredictionPayload(appId, userId, sessionId, prediction) {
|
|
859
|
+
return {
|
|
860
|
+
event: 'prediction_update',
|
|
861
|
+
appId,
|
|
862
|
+
timestamp: new Date().toISOString(),
|
|
863
|
+
data: {
|
|
864
|
+
user_id: userId,
|
|
865
|
+
session_id: sessionId,
|
|
866
|
+
...prediction
|
|
138
867
|
}
|
|
139
|
-
|
|
868
|
+
};
|
|
140
869
|
}
|
|
141
870
|
/**
|
|
142
|
-
*
|
|
871
|
+
* Create webhook payload for segment update
|
|
143
872
|
*/
|
|
144
|
-
function
|
|
145
|
-
|
|
873
|
+
function createSegmentUpdatePayload(appId, segment, userCount, users) {
|
|
874
|
+
return {
|
|
875
|
+
event: 'segment_update',
|
|
876
|
+
appId,
|
|
877
|
+
timestamp: new Date().toISOString(),
|
|
878
|
+
data: {
|
|
879
|
+
segment,
|
|
880
|
+
user_count: userCount,
|
|
881
|
+
users
|
|
882
|
+
}
|
|
883
|
+
};
|
|
146
884
|
}
|
|
147
|
-
|
|
148
|
-
|
|
885
|
+
/**
|
|
886
|
+
* Create webhook payload for friction detection
|
|
887
|
+
*/
|
|
888
|
+
function createFrictionPayload(appId, frictionType, screen, selector, affectedUsers) {
|
|
889
|
+
return {
|
|
890
|
+
event: 'friction_detected',
|
|
891
|
+
appId,
|
|
892
|
+
timestamp: new Date().toISOString(),
|
|
893
|
+
data: {
|
|
894
|
+
type: frictionType,
|
|
895
|
+
screen,
|
|
896
|
+
selector,
|
|
897
|
+
affected_users: affectedUsers,
|
|
898
|
+
severity: affectedUsers > 100 ? 'high' : affectedUsers > 50 ? 'medium' : 'low'
|
|
899
|
+
}
|
|
900
|
+
};
|
|
149
901
|
}
|
|
150
|
-
|
|
151
|
-
|
|
902
|
+
/**
|
|
903
|
+
* Create webhook payload for conversion event
|
|
904
|
+
*/
|
|
905
|
+
function createConversionPayload(appId, userId, sessionId, conversionType, value, metadata) {
|
|
906
|
+
return {
|
|
907
|
+
event: 'conversion',
|
|
908
|
+
appId,
|
|
909
|
+
timestamp: new Date().toISOString(),
|
|
910
|
+
data: {
|
|
911
|
+
user_id: userId,
|
|
912
|
+
session_id: sessionId,
|
|
913
|
+
conversion_type: conversionType,
|
|
914
|
+
value,
|
|
915
|
+
...metadata
|
|
916
|
+
}
|
|
917
|
+
};
|
|
152
918
|
}
|
|
153
|
-
|
|
154
|
-
|
|
919
|
+
/**
|
|
920
|
+
* Webhook delivery with retry logic and exponential backoff
|
|
921
|
+
*
|
|
922
|
+
* @param url - Webhook URL
|
|
923
|
+
* @param payload - Webhook payload
|
|
924
|
+
* @param secret - Webhook secret for signature (optional)
|
|
925
|
+
* @param options - Delivery options
|
|
926
|
+
* @returns Delivery result
|
|
927
|
+
*/
|
|
928
|
+
async function deliverWebhook(url, payload, secret, options = {}) {
|
|
929
|
+
const maxAttempts = options.maxAttempts || 3;
|
|
930
|
+
const backoffMs = options.backoffMs || 1000;
|
|
931
|
+
const timeoutMs = options.timeoutMs || 5000;
|
|
932
|
+
const signature = secret ? await generateWebhookSignature(payload, secret) : '';
|
|
933
|
+
const startTime = Date.now();
|
|
934
|
+
let lastError;
|
|
935
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
936
|
+
try {
|
|
937
|
+
const controller = new AbortController();
|
|
938
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
939
|
+
const response = await fetch(url, {
|
|
940
|
+
method: 'POST',
|
|
941
|
+
headers: {
|
|
942
|
+
'Content-Type': 'application/json',
|
|
943
|
+
'X-ATHENA-Signature': signature,
|
|
944
|
+
'X-ATHENA-Event': payload.event,
|
|
945
|
+
'X-ATHENA-Timestamp': payload.timestamp
|
|
946
|
+
},
|
|
947
|
+
body: JSON.stringify(payload),
|
|
948
|
+
signal: controller.signal
|
|
949
|
+
});
|
|
950
|
+
clearTimeout(timeoutId);
|
|
951
|
+
if (response.ok) {
|
|
952
|
+
const latencyMs = Date.now() - startTime;
|
|
953
|
+
return {
|
|
954
|
+
success: true,
|
|
955
|
+
attempts: attempt,
|
|
956
|
+
latencyMs
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
lastError = `HTTP ${response.status}: ${response.statusText}`;
|
|
960
|
+
} catch (error) {
|
|
961
|
+
lastError = error.name === 'AbortError' ? `Timeout after ${timeoutMs}ms` : error.message || 'Unknown error';
|
|
962
|
+
}
|
|
963
|
+
// Wait before retry (exponential backoff)
|
|
964
|
+
if (attempt < maxAttempts) {
|
|
965
|
+
const delayMs = backoffMs * Math.pow(2, attempt - 1);
|
|
966
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const latencyMs = Date.now() - startTime;
|
|
970
|
+
return {
|
|
971
|
+
success: false,
|
|
972
|
+
attempts: maxAttempts,
|
|
973
|
+
latencyMs,
|
|
974
|
+
error: lastError
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Validate webhook payload structure
|
|
979
|
+
*/
|
|
980
|
+
function isValidWebhookPayload(payload) {
|
|
981
|
+
if (typeof payload !== 'object' || payload === null) return false;
|
|
982
|
+
const validEvents = ['prediction_update', 'segment_update', 'friction_detected', 'conversion', 'session_end'];
|
|
983
|
+
return typeof payload.event === 'string' && validEvents.includes(payload.event) && typeof payload.appId === 'string' && typeof payload.timestamp === 'string' && typeof payload.data === 'object' && payload.data !== null;
|
|
155
984
|
}
|
|
156
|
-
|
|
157
|
-
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* ATHENA OTA Wrapper
|
|
988
|
+
*
|
|
989
|
+
* Wraps the React Native app and forces a reload on first launch after OTA update
|
|
990
|
+
* This ensures the ATHENA tracker is loaded fresh after deployment
|
|
991
|
+
*
|
|
992
|
+
* Usage:
|
|
993
|
+
* import { AthenaOTAWrapper } from '@athena/tracker';
|
|
994
|
+
*
|
|
995
|
+
* export default function App() {
|
|
996
|
+
* return (
|
|
997
|
+
* <AthenaOTAWrapper>
|
|
998
|
+
* <YourAppContent />
|
|
999
|
+
* </AthenaOTAWrapper>
|
|
1000
|
+
* );
|
|
1001
|
+
* }
|
|
1002
|
+
*/
|
|
1003
|
+
// Dynamic import of expo-updates (optional peer dependency)
|
|
1004
|
+
let Updates = null;
|
|
1005
|
+
async function loadExpoUpdates() {
|
|
1006
|
+
try {
|
|
1007
|
+
Updates = await import('expo-updates');
|
|
1008
|
+
return true;
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
console.warn('[ATHENA] expo-updates not available - OTA updates disabled');
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
158
1013
|
}
|
|
159
|
-
function
|
|
160
|
-
|
|
1014
|
+
function AthenaOTAWrapper({
|
|
1015
|
+
children,
|
|
1016
|
+
loadingMessage = 'Loading...',
|
|
1017
|
+
updateMessage = 'Updating...'
|
|
1018
|
+
}) {
|
|
1019
|
+
const [isCheckingUpdate, setIsCheckingUpdate] = useState(true);
|
|
1020
|
+
const [updateAvailable, setUpdateAvailable] = useState(false);
|
|
1021
|
+
const [error, setError] = useState(null);
|
|
1022
|
+
useEffect(() => {
|
|
1023
|
+
checkForUpdates();
|
|
1024
|
+
}, []);
|
|
1025
|
+
async function checkForUpdates() {
|
|
1026
|
+
try {
|
|
1027
|
+
// Load expo-updates module
|
|
1028
|
+
const hasUpdates = await loadExpoUpdates();
|
|
1029
|
+
if (!hasUpdates) {
|
|
1030
|
+
// expo-updates not available, continue without OTA check
|
|
1031
|
+
setIsCheckingUpdate(false);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// Check if running in development mode (skip update check)
|
|
1035
|
+
if (__DEV__) {
|
|
1036
|
+
console.log('[ATHENA] Development mode - skipping OTA update check');
|
|
1037
|
+
setIsCheckingUpdate(false);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
console.log('[ATHENA] Checking for OTA updates...');
|
|
1041
|
+
// Check for available updates
|
|
1042
|
+
const update = await Updates.checkForUpdateAsync();
|
|
1043
|
+
if (update.isAvailable) {
|
|
1044
|
+
console.log('[ATHENA] Update available - fetching...');
|
|
1045
|
+
setUpdateAvailable(true);
|
|
1046
|
+
// Fetch the new update
|
|
1047
|
+
await Updates.fetchUpdateAsync();
|
|
1048
|
+
console.log('[ATHENA] Update fetched - reloading app...');
|
|
1049
|
+
// Force reload to apply update
|
|
1050
|
+
await Updates.reloadAsync();
|
|
1051
|
+
// If reloadAsync fails, we'll never reach here
|
|
1052
|
+
} else {
|
|
1053
|
+
console.log('[ATHENA] No updates available - continuing with current version');
|
|
1054
|
+
setIsCheckingUpdate(false);
|
|
1055
|
+
}
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
// Don't block app if update check fails
|
|
1058
|
+
console.warn('[ATHENA] Update check failed:', error.message);
|
|
1059
|
+
setError(error.message);
|
|
1060
|
+
setIsCheckingUpdate(false);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// Show loading screen while checking for updates
|
|
1064
|
+
if (isCheckingUpdate || updateAvailable) {
|
|
1065
|
+
return React.createElement(View, {
|
|
1066
|
+
style: styles.container
|
|
1067
|
+
}, React.createElement(ActivityIndicator, {
|
|
1068
|
+
size: "large",
|
|
1069
|
+
color: "#007AFF"
|
|
1070
|
+
}), React.createElement(Text, {
|
|
1071
|
+
style: styles.text
|
|
1072
|
+
}, updateAvailable ? updateMessage : loadingMessage), error && React.createElement(Text, {
|
|
1073
|
+
style: styles.errorText
|
|
1074
|
+
}, "Update check failed: ", error));
|
|
1075
|
+
}
|
|
1076
|
+
// Render app content once update check is complete
|
|
1077
|
+
return React.createElement(React.Fragment, null, children);
|
|
161
1078
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
1079
|
+
const styles = StyleSheet.create({
|
|
1080
|
+
container: {
|
|
1081
|
+
flex: 1,
|
|
1082
|
+
justifyContent: 'center',
|
|
1083
|
+
alignItems: 'center',
|
|
1084
|
+
backgroundColor: '#FFFFFF'
|
|
1085
|
+
},
|
|
1086
|
+
text: {
|
|
1087
|
+
marginTop: 16,
|
|
1088
|
+
color: '#666666',
|
|
1089
|
+
fontSize: 14,
|
|
1090
|
+
fontWeight: '500'
|
|
1091
|
+
},
|
|
1092
|
+
errorText: {
|
|
1093
|
+
marginTop: 8,
|
|
1094
|
+
color: '#FF3B30',
|
|
1095
|
+
fontSize: 12,
|
|
1096
|
+
textAlign: 'center',
|
|
1097
|
+
paddingHorizontal: 32
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* React Native Event Capture
|
|
1103
|
+
*
|
|
1104
|
+
* Captures behavioral events from React Native apps
|
|
1105
|
+
* - Touch events (Tap, Swipe, LongPress)
|
|
1106
|
+
* - Navigation events (Screen changes)
|
|
1107
|
+
* - App lifecycle events (Open, Background, Foreground, Close)
|
|
1108
|
+
* - Form interactions
|
|
1109
|
+
* - Network errors
|
|
1110
|
+
*/
|
|
1111
|
+
class ReactNativeEventCapture {
|
|
1112
|
+
constructor(config = {}) {
|
|
1113
|
+
this.events = [];
|
|
1114
|
+
this.appStateSubscription = null;
|
|
1115
|
+
this.panResponder = null;
|
|
1116
|
+
this.batchTimer = null;
|
|
1117
|
+
this.currentScreen = 'Unknown';
|
|
1118
|
+
this.sessionStartTime = Date.now();
|
|
1119
|
+
this.config = {
|
|
1120
|
+
captureTouch: config.captureTouch !== false,
|
|
1121
|
+
captureNavigation: config.captureNavigation !== false,
|
|
1122
|
+
captureLifecycle: config.captureLifecycle !== false,
|
|
1123
|
+
captureNetworkErrors: config.captureNetworkErrors !== false,
|
|
1124
|
+
batchSize: config.batchSize || 10,
|
|
1125
|
+
batchIntervalMs: config.batchIntervalMs || 10000
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Start capturing events
|
|
1130
|
+
*/
|
|
1131
|
+
start() {
|
|
1132
|
+
console.log('[ATHENA] Starting React Native event capture');
|
|
1133
|
+
if (this.config.captureLifecycle) {
|
|
1134
|
+
this.setupLifecycleTracking();
|
|
1135
|
+
}
|
|
1136
|
+
if (this.config.captureTouch) {
|
|
1137
|
+
this.setupTouchTracking();
|
|
1138
|
+
}
|
|
1139
|
+
if (this.config.captureNetworkErrors) {
|
|
1140
|
+
this.setupNetworkErrorTracking();
|
|
1141
|
+
}
|
|
1142
|
+
// Start batch timer
|
|
1143
|
+
this.batchTimer = setInterval(() => {
|
|
1144
|
+
this.flushEvents();
|
|
1145
|
+
}, this.config.batchIntervalMs);
|
|
1146
|
+
// Capture initial AppOpen event
|
|
1147
|
+
this.captureEvent({
|
|
1148
|
+
event_type: 'AppOpen',
|
|
1149
|
+
timestamp: Date.now(),
|
|
1150
|
+
properties: {
|
|
1151
|
+
screen: this.currentScreen,
|
|
1152
|
+
session_start: true
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Stop capturing events
|
|
1158
|
+
*/
|
|
1159
|
+
stop() {
|
|
1160
|
+
console.log('[ATHENA] Stopping React Native event capture');
|
|
1161
|
+
if (this.appStateSubscription) {
|
|
1162
|
+
this.appStateSubscription.remove();
|
|
1163
|
+
}
|
|
1164
|
+
if (this.batchTimer) {
|
|
1165
|
+
clearInterval(this.batchTimer);
|
|
1166
|
+
}
|
|
1167
|
+
// Flush any remaining events
|
|
1168
|
+
this.flushEvents();
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Setup app lifecycle tracking (Open, Background, Foreground, Close)
|
|
1172
|
+
*/
|
|
1173
|
+
setupLifecycleTracking() {
|
|
1174
|
+
this.appStateSubscription = AppState.addEventListener('change', nextAppState => {
|
|
1175
|
+
if (nextAppState === 'active') {
|
|
1176
|
+
this.captureEvent({
|
|
1177
|
+
event_type: 'AppForeground',
|
|
1178
|
+
timestamp: Date.now(),
|
|
1179
|
+
properties: {
|
|
1180
|
+
screen: this.currentScreen
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
} else if (nextAppState === 'background') {
|
|
1184
|
+
this.captureEvent({
|
|
1185
|
+
event_type: 'AppBackground',
|
|
1186
|
+
timestamp: Date.now(),
|
|
1187
|
+
properties: {
|
|
1188
|
+
screen: this.currentScreen,
|
|
1189
|
+
session_duration: Date.now() - this.sessionStartTime
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
} else if (nextAppState === 'inactive') {
|
|
1193
|
+
this.captureEvent({
|
|
1194
|
+
event_type: 'AppInactive',
|
|
1195
|
+
timestamp: Date.now(),
|
|
1196
|
+
properties: {
|
|
1197
|
+
screen: this.currentScreen
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Setup touch event tracking
|
|
1205
|
+
*/
|
|
1206
|
+
setupTouchTracking() {
|
|
1207
|
+
let touchStartTime = 0;
|
|
1208
|
+
this.panResponder = PanResponder.create({
|
|
1209
|
+
onStartShouldSetPanResponder: () => true,
|
|
1210
|
+
onMoveShouldSetPanResponder: () => true,
|
|
1211
|
+
onPanResponderGrant: (_evt, _gestureState) => {
|
|
1212
|
+
touchStartTime = Date.now();
|
|
1213
|
+
},
|
|
1214
|
+
onPanResponderRelease: (evt, gestureState) => {
|
|
1215
|
+
const touchDuration = Date.now() - touchStartTime;
|
|
1216
|
+
const deltaX = Math.abs(gestureState.dx);
|
|
1217
|
+
const deltaY = Math.abs(gestureState.dy);
|
|
1218
|
+
// Detect gesture type
|
|
1219
|
+
if (touchDuration > 500 && deltaX < 10 && deltaY < 10) {
|
|
1220
|
+
// Long press
|
|
1221
|
+
this.captureEvent({
|
|
1222
|
+
event_type: 'LongPress',
|
|
1223
|
+
timestamp: Date.now(),
|
|
1224
|
+
properties: {
|
|
1225
|
+
screen: this.currentScreen,
|
|
1226
|
+
x: evt.nativeEvent.pageX,
|
|
1227
|
+
y: evt.nativeEvent.pageY,
|
|
1228
|
+
duration: touchDuration
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
} else if (deltaX > 50 || deltaY > 50) {
|
|
1232
|
+
// Swipe
|
|
1233
|
+
const direction = deltaX > deltaY ? gestureState.dx > 0 ? 'right' : 'left' : gestureState.dy > 0 ? 'down' : 'up';
|
|
1234
|
+
this.captureEvent({
|
|
1235
|
+
event_type: 'Swipe',
|
|
1236
|
+
timestamp: Date.now(),
|
|
1237
|
+
properties: {
|
|
1238
|
+
screen: this.currentScreen,
|
|
1239
|
+
direction,
|
|
1240
|
+
distance: Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
} else {
|
|
1244
|
+
// Tap
|
|
1245
|
+
this.captureEvent({
|
|
1246
|
+
event_type: 'Tap',
|
|
1247
|
+
timestamp: Date.now(),
|
|
1248
|
+
properties: {
|
|
1249
|
+
screen: this.currentScreen,
|
|
1250
|
+
x: evt.nativeEvent.pageX,
|
|
1251
|
+
y: evt.nativeEvent.pageY,
|
|
1252
|
+
duration: touchDuration
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Setup network error tracking
|
|
1261
|
+
*/
|
|
1262
|
+
setupNetworkErrorTracking() {
|
|
1263
|
+
// Monkey-patch fetch to track network errors
|
|
1264
|
+
const originalFetch = global.fetch;
|
|
1265
|
+
global.fetch = async (input, init) => {
|
|
1266
|
+
const startTime = Date.now();
|
|
1267
|
+
const url = typeof input === 'string' ? input : input.url || String(input);
|
|
1268
|
+
try {
|
|
1269
|
+
const response = await originalFetch(input, init);
|
|
1270
|
+
if (!response.ok) {
|
|
1271
|
+
this.captureEvent({
|
|
1272
|
+
event_type: 'NetworkError',
|
|
1273
|
+
timestamp: Date.now(),
|
|
1274
|
+
properties: {
|
|
1275
|
+
screen: this.currentScreen,
|
|
1276
|
+
url,
|
|
1277
|
+
status: response.status,
|
|
1278
|
+
statusText: response.statusText,
|
|
1279
|
+
duration: Date.now() - startTime
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
return response;
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
this.captureEvent({
|
|
1286
|
+
event_type: 'NetworkError',
|
|
1287
|
+
timestamp: Date.now(),
|
|
1288
|
+
properties: {
|
|
1289
|
+
screen: this.currentScreen,
|
|
1290
|
+
url,
|
|
1291
|
+
error: error.message,
|
|
1292
|
+
duration: Date.now() - startTime
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Manually track screen navigation
|
|
1301
|
+
* Should be called by navigation library (React Navigation, etc.)
|
|
1302
|
+
*/
|
|
1303
|
+
trackScreenView(screenName, params) {
|
|
1304
|
+
this.currentScreen = screenName;
|
|
1305
|
+
this.captureEvent({
|
|
1306
|
+
event_type: 'ScreenView',
|
|
1307
|
+
timestamp: Date.now(),
|
|
1308
|
+
properties: {
|
|
1309
|
+
screen: screenName,
|
|
1310
|
+
params: params || {},
|
|
1311
|
+
previous_screen: this.currentScreen
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Manually track custom event
|
|
1317
|
+
*/
|
|
1318
|
+
track(eventType, properties) {
|
|
1319
|
+
this.captureEvent({
|
|
1320
|
+
event_type: eventType,
|
|
1321
|
+
timestamp: Date.now(),
|
|
1322
|
+
properties: {
|
|
1323
|
+
...properties,
|
|
1324
|
+
screen: this.currentScreen
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Capture an event and add to batch
|
|
1330
|
+
*/
|
|
1331
|
+
captureEvent(event) {
|
|
1332
|
+
this.events.push(event);
|
|
1333
|
+
// Flush immediately if batch size reached
|
|
1334
|
+
if (this.events.length >= this.config.batchSize) {
|
|
1335
|
+
this.flushEvents();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Flush accumulated events (to be sent to server)
|
|
1340
|
+
*/
|
|
1341
|
+
flushEvents() {
|
|
1342
|
+
if (this.events.length === 0) return;
|
|
1343
|
+
const batch = [...this.events];
|
|
1344
|
+
this.events = [];
|
|
1345
|
+
// Emit events for tracker to send
|
|
1346
|
+
this.onEventBatch?.(batch);
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Get PanResponder for manual integration
|
|
1350
|
+
* Usage: <View {...capture.getPanResponderProps()}>
|
|
1351
|
+
*/
|
|
1352
|
+
getPanResponderProps() {
|
|
1353
|
+
return this.panResponder?.panHandlers || {};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* @athena/tracker
|
|
1359
|
+
*
|
|
1360
|
+
* Behavioral analytics tracker with edge AI
|
|
1361
|
+
*/
|
|
1362
|
+
// Version
|
|
1363
|
+
const VERSION = '1.0.0';
|
|
173
1364
|
|
|
174
|
-
export {
|
|
1365
|
+
export { AthenaOTAWrapper, AthenaTracker, ReactNativeEventCapture, VERSION, createConversionPayload, createFrictionPayload, createPredictionPayload, createSegmentUpdatePayload, AthenaTracker as default, deliverWebhook, detectInferenceMode, generateWebhookSignature, getPlatform, isBrowser, isReactNative, isValidWebhookPayload, verifyWebhookSignature };
|
|
175
1366
|
//# sourceMappingURL=index.esm.js.map
|