@athena-tracker/tracker 1.2.0 → 1.3.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/dist/index.esm.js +374 -33
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +374 -33
- package/dist/index.js.map +1 -1
- package/dist/inference/auto-detect.d.ts +1 -1
- package/dist/inference/auto-detect.d.ts.map +1 -1
- package/dist/inference/on-device-web.d.ts +36 -0
- package/dist/inference/on-device-web.d.ts.map +1 -0
- package/dist/inference/server.d.ts.map +1 -1
- package/dist/tracker.d.ts +27 -1
- package/dist/tracker.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -1
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ var reactNative = require('react-native');
|
|
|
9
9
|
* Auto-Detection Logic
|
|
10
10
|
*
|
|
11
11
|
* Automatically detects whether to use on-device or server-side inference
|
|
12
|
-
* based on availability of onnxruntime-react-native
|
|
12
|
+
* based on availability of onnxruntime-web (browser) or onnxruntime-react-native (RN)
|
|
13
13
|
*/
|
|
14
14
|
async function detectInferenceMode(configMode) {
|
|
15
15
|
// Respect explicit configuration
|
|
@@ -21,14 +21,31 @@ async function detectInferenceMode(configMode) {
|
|
|
21
21
|
console.log('[ATHENA] Forcing server-side inference mode');
|
|
22
22
|
return 'server';
|
|
23
23
|
}
|
|
24
|
-
// Auto-detect based on
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
// Auto-detect based on platform and ONNX runtime availability
|
|
25
|
+
const platform = getPlatform();
|
|
26
|
+
if (platform === 'web') {
|
|
27
|
+
// Try to load onnxruntime-web for browsers
|
|
28
|
+
try {
|
|
29
|
+
await import('onnxruntime-web');
|
|
30
|
+
console.log('[ATHENA] On-device inference available (onnxruntime-web detected)');
|
|
31
|
+
return 'on-device';
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.log('[ATHENA] Falling back to server-side inference (onnxruntime-web not found)');
|
|
34
|
+
return 'server';
|
|
35
|
+
}
|
|
36
|
+
} else if (platform === 'react-native') {
|
|
37
|
+
// Try to load onnxruntime-react-native for React Native
|
|
38
|
+
try {
|
|
39
|
+
await import('onnxruntime-react-native');
|
|
40
|
+
console.log('[ATHENA] On-device inference available (onnxruntime-react-native detected)');
|
|
41
|
+
return 'on-device';
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.log('[ATHENA] Falling back to server-side inference (onnxruntime-react-native not found)');
|
|
44
|
+
return 'server';
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
// Unknown platform - use server-side inference
|
|
48
|
+
console.log('[ATHENA] Falling back to server-side inference (unknown platform)');
|
|
32
49
|
return 'server';
|
|
33
50
|
}
|
|
34
51
|
}
|
|
@@ -62,14 +79,14 @@ function getPlatform() {
|
|
|
62
79
|
* Uses onnxruntime-react-native for local ML inference
|
|
63
80
|
* Target latency: <10ms P95
|
|
64
81
|
*/
|
|
65
|
-
let InferenceSession;
|
|
66
|
-
let Tensor;
|
|
82
|
+
let InferenceSession$1;
|
|
83
|
+
let Tensor$1;
|
|
67
84
|
// Dynamically import ONNX Runtime (only available in React Native)
|
|
68
85
|
async function loadOnnxRuntime() {
|
|
69
86
|
try {
|
|
70
87
|
const onnx = await import('onnxruntime-react-native');
|
|
71
|
-
InferenceSession = onnx.InferenceSession;
|
|
72
|
-
Tensor = onnx.Tensor;
|
|
88
|
+
InferenceSession$1 = onnx.InferenceSession;
|
|
89
|
+
Tensor$1 = onnx.Tensor;
|
|
73
90
|
return true;
|
|
74
91
|
} catch (error) {
|
|
75
92
|
console.error('[ATHENA] Failed to load onnxruntime-react-native:', error);
|
|
@@ -93,7 +110,7 @@ class OnDeviceInference {
|
|
|
93
110
|
}
|
|
94
111
|
try {
|
|
95
112
|
const startTime = performance.now();
|
|
96
|
-
this.session = await InferenceSession.create(modelPath);
|
|
113
|
+
this.session = await InferenceSession$1.create(modelPath);
|
|
97
114
|
const loadTime = performance.now() - startTime;
|
|
98
115
|
this.modelLoaded = true;
|
|
99
116
|
console.log(`[ATHENA] ONNX model loaded successfully (${loadTime.toFixed(0)}ms)`);
|
|
@@ -112,7 +129,7 @@ class OnDeviceInference {
|
|
|
112
129
|
const startTime = performance.now();
|
|
113
130
|
try {
|
|
114
131
|
// Create input tensor
|
|
115
|
-
const inputTensor = new Tensor('float32', features, [1, features.length]);
|
|
132
|
+
const inputTensor = new Tensor$1('float32', features, [1, features.length]);
|
|
116
133
|
const feeds = {
|
|
117
134
|
input: inputTensor
|
|
118
135
|
};
|
|
@@ -238,6 +255,192 @@ class OnDeviceInference {
|
|
|
238
255
|
}
|
|
239
256
|
}
|
|
240
257
|
|
|
258
|
+
/**
|
|
259
|
+
* On-Device Inference Module (Web Browser)
|
|
260
|
+
*
|
|
261
|
+
* Uses onnxruntime-web for browser-based ML inference
|
|
262
|
+
* Target latency: <3ms P95
|
|
263
|
+
*/
|
|
264
|
+
let InferenceSession;
|
|
265
|
+
let Tensor;
|
|
266
|
+
// Dynamically import ONNX Runtime Web (only available in browsers)
|
|
267
|
+
async function loadOnnxRuntimeWeb() {
|
|
268
|
+
try {
|
|
269
|
+
const onnx = await import('onnxruntime-web');
|
|
270
|
+
InferenceSession = onnx.InferenceSession;
|
|
271
|
+
Tensor = onnx.Tensor;
|
|
272
|
+
return true;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('[ATHENA] Failed to load onnxruntime-web:', error);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
class OnDeviceInferenceWeb {
|
|
279
|
+
constructor() {
|
|
280
|
+
this.session = null;
|
|
281
|
+
this.modelLoaded = false;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Initialize ONNX session with model file
|
|
285
|
+
*/
|
|
286
|
+
async initialize(modelPath) {
|
|
287
|
+
console.log('[ATHENA] Loading ONNX model for browser inference...');
|
|
288
|
+
// Load ONNX Runtime Web
|
|
289
|
+
const loaded = await loadOnnxRuntimeWeb();
|
|
290
|
+
if (!loaded) {
|
|
291
|
+
throw new Error('onnxruntime-web not available');
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const startTime = performance.now();
|
|
295
|
+
this.session = await InferenceSession.create(modelPath, {
|
|
296
|
+
executionProviders: ['wasm'],
|
|
297
|
+
// Use WebAssembly backend
|
|
298
|
+
graphOptimizationLevel: 'all'
|
|
299
|
+
});
|
|
300
|
+
const loadTime = performance.now() - startTime;
|
|
301
|
+
this.modelLoaded = true;
|
|
302
|
+
console.log(`[ATHENA] ONNX model loaded successfully in browser (${loadTime.toFixed(0)}ms)`);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
console.error('[ATHENA] Failed to load ONNX model:', error);
|
|
305
|
+
throw new Error(`Model loading failed: ${error}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Run inference on feature vector
|
|
310
|
+
*/
|
|
311
|
+
async predict(features) {
|
|
312
|
+
if (!this.modelLoaded || !this.session) {
|
|
313
|
+
throw new Error('ONNX model not initialized. Call initialize() first.');
|
|
314
|
+
}
|
|
315
|
+
const startTime = performance.now();
|
|
316
|
+
try {
|
|
317
|
+
// Create input tensor
|
|
318
|
+
const inputTensor = new Tensor('float32', features, [1, features.length]);
|
|
319
|
+
const feeds = {
|
|
320
|
+
input: inputTensor
|
|
321
|
+
};
|
|
322
|
+
// Run inference
|
|
323
|
+
const results = await this.session.run(feeds);
|
|
324
|
+
const inferenceTime = performance.now() - startTime;
|
|
325
|
+
// Parse output
|
|
326
|
+
const prediction = this.parseOutput(results.output);
|
|
327
|
+
console.log(`[ATHENA] Browser inference complete: ${inferenceTime.toFixed(2)}ms ` + `(class: ${prediction.predicted_class}, confidence: ${prediction.confidence.toFixed(2)})`);
|
|
328
|
+
return {
|
|
329
|
+
...prediction,
|
|
330
|
+
inference_time_ms: Math.round(inferenceTime * 100) / 100,
|
|
331
|
+
inference_location: 'on-device',
|
|
332
|
+
timestamp: new Date().toISOString()
|
|
333
|
+
};
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error('[ATHENA] Browser inference failed:', error);
|
|
336
|
+
throw new Error(`Inference failed: ${error}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Parse ONNX output tensor to prediction result
|
|
341
|
+
*/
|
|
342
|
+
parseOutput(outputTensor) {
|
|
343
|
+
const data = outputTensor.data;
|
|
344
|
+
// Extract predictions from model output
|
|
345
|
+
// Model outputs: [predicted_class_idx, confidence, purchase_intent, cart_abandon_risk, checkout_abandon_risk]
|
|
346
|
+
const predictedClassIdx = data[0];
|
|
347
|
+
const confidence = data[1];
|
|
348
|
+
const purchaseIntent = data[2];
|
|
349
|
+
const cartAbandonRisk = data[3] || 0;
|
|
350
|
+
const checkoutAbandonRisk = data[4] || 0;
|
|
351
|
+
// Map class index to label
|
|
352
|
+
const classLabels = ['engaged_explorer', 'high_intent_buyer', 'cart_abandoner', 'checkout_abandoner', 'bounce_risk'];
|
|
353
|
+
const predictedClass = classLabels[predictedClassIdx] || 'unknown';
|
|
354
|
+
// Determine archetype based on purchase intent
|
|
355
|
+
let archetype;
|
|
356
|
+
if (purchaseIntent >= 0.85) {
|
|
357
|
+
archetype = 'fast_mover';
|
|
358
|
+
} else if (purchaseIntent >= 0.60) {
|
|
359
|
+
archetype = 'on_track';
|
|
360
|
+
} else if (purchaseIntent >= 0.40) {
|
|
361
|
+
archetype = 'slow_adopter';
|
|
362
|
+
} else if (purchaseIntent >= 0.20) {
|
|
363
|
+
archetype = 'at_risk';
|
|
364
|
+
} else {
|
|
365
|
+
archetype = 'different_path';
|
|
366
|
+
}
|
|
367
|
+
// Generate recommendations
|
|
368
|
+
const recommendation = this.generateRecommendation(predictedClass, purchaseIntent, cartAbandonRisk, checkoutAbandonRisk);
|
|
369
|
+
return {
|
|
370
|
+
predicted_class: predictedClass,
|
|
371
|
+
confidence,
|
|
372
|
+
archetype,
|
|
373
|
+
purchase_intent: purchaseIntent,
|
|
374
|
+
cart_abandonment_risk: cartAbandonRisk,
|
|
375
|
+
checkout_abandonment_risk: checkoutAbandonRisk,
|
|
376
|
+
...recommendation
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Generate action recommendations based on prediction
|
|
381
|
+
*/
|
|
382
|
+
generateRecommendation(predictedClass, purchaseIntent, cartAbandonRisk, checkoutAbandonRisk) {
|
|
383
|
+
// High cart abandonment risk
|
|
384
|
+
if (cartAbandonRisk > 0.7) {
|
|
385
|
+
return {
|
|
386
|
+
recommended_action: 'Show cart abandonment discount (10-15% off)',
|
|
387
|
+
urgency: 'high',
|
|
388
|
+
trigger_reason: `High cart abandonment risk (${(cartAbandonRisk * 100).toFixed(0)}%)`
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
// High checkout abandonment risk
|
|
392
|
+
if (checkoutAbandonRisk > 0.7) {
|
|
393
|
+
return {
|
|
394
|
+
recommended_action: 'Simplify checkout flow or offer free shipping',
|
|
395
|
+
urgency: 'critical',
|
|
396
|
+
trigger_reason: `High checkout abandonment risk (${(checkoutAbandonRisk * 100).toFixed(0)}%)`
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// High purchase intent
|
|
400
|
+
if (purchaseIntent > 0.8) {
|
|
401
|
+
return {
|
|
402
|
+
recommended_action: 'Show product recommendations or upsell',
|
|
403
|
+
urgency: 'medium',
|
|
404
|
+
trigger_reason: `High purchase intent (${(purchaseIntent * 100).toFixed(0)}%)`
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// Low purchase intent
|
|
408
|
+
if (purchaseIntent < 0.3) {
|
|
409
|
+
return {
|
|
410
|
+
recommended_action: 'Show value proposition or testimonials',
|
|
411
|
+
urgency: 'low',
|
|
412
|
+
trigger_reason: `Low purchase intent (${(purchaseIntent * 100).toFixed(0)}%)`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
recommended_action: 'Continue monitoring',
|
|
417
|
+
urgency: 'low'
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Check if model is loaded
|
|
422
|
+
*/
|
|
423
|
+
isReady() {
|
|
424
|
+
return this.modelLoaded;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Cleanup resources
|
|
428
|
+
*/
|
|
429
|
+
async dispose() {
|
|
430
|
+
if (this.session) {
|
|
431
|
+
try {
|
|
432
|
+
// ONNX Runtime sessions should be disposed
|
|
433
|
+
await this.session.release?.();
|
|
434
|
+
console.log('[ATHENA] Browser inference session disposed');
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.warn('[ATHENA] Failed to dispose session:', error);
|
|
437
|
+
}
|
|
438
|
+
this.session = null;
|
|
439
|
+
this.modelLoaded = false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
241
444
|
/**
|
|
242
445
|
* Server-Side Inference Module
|
|
243
446
|
*
|
|
@@ -318,12 +521,17 @@ class ServerInference {
|
|
|
318
521
|
try {
|
|
319
522
|
// Replace /v1/predict with /health for health check
|
|
320
523
|
const healthUrl = this.apiUrl.replace('/v1/predict', '/health');
|
|
524
|
+
console.log(`[ATHENA] Testing server connectivity: ${healthUrl}`);
|
|
321
525
|
const response = await this.fetchWithTimeout(healthUrl, {
|
|
322
526
|
method: 'GET'
|
|
323
|
-
},
|
|
527
|
+
}, Math.min(this.timeout, 5000)); // Use 5s max for health check
|
|
528
|
+
if (response.ok) {
|
|
529
|
+
console.log('[ATHENA] Server connectivity test passed');
|
|
530
|
+
}
|
|
324
531
|
return response.ok;
|
|
325
532
|
} catch (error) {
|
|
326
|
-
console.warn('[ATHENA] Server connectivity test failed:', error);
|
|
533
|
+
console.warn('[ATHENA] Server connectivity test failed:', error.message || error);
|
|
534
|
+
console.warn('[ATHENA] URL attempted:', this.apiUrl.replace('/v1/predict', '/health'));
|
|
327
535
|
return false;
|
|
328
536
|
}
|
|
329
537
|
}
|
|
@@ -360,6 +568,10 @@ class AthenaTracker {
|
|
|
360
568
|
this.onDeviceInference = null;
|
|
361
569
|
this.serverInference = null;
|
|
362
570
|
this.batchIntervalId = null;
|
|
571
|
+
this.failureCount = 0;
|
|
572
|
+
this.maxFailures = 3;
|
|
573
|
+
// External IDs for custom audience building (Meta, Google)
|
|
574
|
+
this.externalIds = {};
|
|
363
575
|
// Private constructor for singleton
|
|
364
576
|
}
|
|
365
577
|
/**
|
|
@@ -402,6 +614,10 @@ class AthenaTracker {
|
|
|
402
614
|
}
|
|
403
615
|
// Generate session ID
|
|
404
616
|
instance.state.sessionId = instance.generateSessionId();
|
|
617
|
+
// Generate anonymous user ID (will be replaced on identify())
|
|
618
|
+
instance.state.userId = instance.generateAnonymousId();
|
|
619
|
+
// Capture external IDs for custom audience building (browser only)
|
|
620
|
+
instance.captureExternalIds();
|
|
405
621
|
// Start event batching
|
|
406
622
|
instance.startBatching();
|
|
407
623
|
instance.state.initialized = true;
|
|
@@ -412,7 +628,15 @@ class AthenaTracker {
|
|
|
412
628
|
*/
|
|
413
629
|
async initializeOnDevice() {
|
|
414
630
|
try {
|
|
415
|
-
|
|
631
|
+
const platform = getPlatform();
|
|
632
|
+
// Use platform-specific ONNX runtime
|
|
633
|
+
if (platform === 'web') {
|
|
634
|
+
this.onDeviceInference = new OnDeviceInferenceWeb();
|
|
635
|
+
} else if (platform === 'react-native') {
|
|
636
|
+
this.onDeviceInference = new OnDeviceInference();
|
|
637
|
+
} else {
|
|
638
|
+
throw new Error('Unsupported platform for on-device inference');
|
|
639
|
+
}
|
|
416
640
|
await this.onDeviceInference.initialize(this.config.modelPath);
|
|
417
641
|
console.log('[ATHENA] On-device inference ready');
|
|
418
642
|
} catch (error) {
|
|
@@ -426,7 +650,8 @@ class AthenaTracker {
|
|
|
426
650
|
* Initialize server-side inference
|
|
427
651
|
*/
|
|
428
652
|
async initializeServer() {
|
|
429
|
-
this.serverInference = new ServerInference(this.config.serverInferenceUrl, this.config.appToken
|
|
653
|
+
this.serverInference = new ServerInference(this.config.serverInferenceUrl, this.config.appToken, this.config.timeout || 5000 // Use configured timeout or default to 5s
|
|
654
|
+
);
|
|
430
655
|
// Test connectivity
|
|
431
656
|
const connected = await this.serverInference.testConnection();
|
|
432
657
|
if (!connected) {
|
|
@@ -437,15 +662,37 @@ class AthenaTracker {
|
|
|
437
662
|
}
|
|
438
663
|
/**
|
|
439
664
|
* Identify a user
|
|
665
|
+
* Supports email/phone hashing for Meta/Google custom audiences
|
|
666
|
+
*
|
|
667
|
+
* @param userId - User identifier
|
|
668
|
+
* @param traits - User traits (email, phone, name, etc.)
|
|
669
|
+
* @param options - Optional configuration
|
|
440
670
|
*/
|
|
441
|
-
static identify(userId, traits) {
|
|
671
|
+
static async identify(userId, traits, options) {
|
|
442
672
|
const instance = AthenaTracker.getInstance();
|
|
673
|
+
const previousUserId = instance.state.userId; // Store anonymous ID for mapping
|
|
443
674
|
instance.state.userId = userId;
|
|
675
|
+
// Hash PII for custom audience building if requested
|
|
676
|
+
const hashedTraits = {
|
|
677
|
+
...traits
|
|
678
|
+
};
|
|
679
|
+
if (options?.hashPII && traits) {
|
|
680
|
+
if (traits.email) {
|
|
681
|
+
hashedTraits.email_sha256 = await instance.hashPII(traits.email);
|
|
682
|
+
}
|
|
683
|
+
if (traits.phone) {
|
|
684
|
+
hashedTraits.phone_sha256 = await instance.hashPII(traits.phone);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
444
687
|
AthenaTracker.track('identify', {
|
|
445
688
|
user_id: userId,
|
|
446
|
-
|
|
689
|
+
anonymous_id: previousUserId,
|
|
690
|
+
// Map anonymous → identified user
|
|
691
|
+
...hashedTraits,
|
|
692
|
+
// Include external IDs for audience sync
|
|
693
|
+
external_ids: Object.keys(instance.externalIds).length > 0 ? instance.externalIds : undefined
|
|
447
694
|
});
|
|
448
|
-
console.log(`[ATHENA] User identified: ${userId}`);
|
|
695
|
+
console.log(`[ATHENA] User identified: ${userId}` + (previousUserId?.startsWith('anon_') ? ` (was ${previousUserId})` : ''));
|
|
449
696
|
}
|
|
450
697
|
/**
|
|
451
698
|
* Track an event
|
|
@@ -477,10 +724,21 @@ class AthenaTracker {
|
|
|
477
724
|
*/
|
|
478
725
|
async processBatch() {
|
|
479
726
|
if (this.state.events.length === 0) return;
|
|
727
|
+
// Skip inference if too many consecutive failures
|
|
728
|
+
if (this.failureCount >= this.maxFailures) {
|
|
729
|
+
if (this.config?.debug) {
|
|
730
|
+
console.warn(`[ATHENA] Skipping inference after ${this.failureCount} consecutive failures. Will retry after ${this.maxFailures} successful events.`);
|
|
731
|
+
}
|
|
732
|
+
// Reset failure count after clearing queue
|
|
733
|
+
this.failureCount = Math.max(0, this.failureCount - 1);
|
|
734
|
+
this.state.events = []; // Drop events to prevent infinite buildup
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
480
737
|
const events = [...this.state.events];
|
|
481
738
|
this.state.events = [];
|
|
482
739
|
try {
|
|
483
740
|
const prediction = await this.runInference(events);
|
|
741
|
+
this.failureCount = 0; // Reset on success
|
|
484
742
|
if (this.config?.webhook?.enabled && this.config.webhook.url) {
|
|
485
743
|
await this.sendWebhook(prediction);
|
|
486
744
|
}
|
|
@@ -488,9 +746,14 @@ class AthenaTracker {
|
|
|
488
746
|
console.log('[ATHENA] Prediction:', prediction);
|
|
489
747
|
}
|
|
490
748
|
} catch (error) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
749
|
+
this.failureCount++;
|
|
750
|
+
console.error(`[ATHENA] Failed to process batch (${this.failureCount}/${this.maxFailures}):`, error.message || error);
|
|
751
|
+
// Only re-queue if below failure threshold
|
|
752
|
+
if (this.failureCount < this.maxFailures) {
|
|
753
|
+
this.state.events.unshift(...events);
|
|
754
|
+
} else {
|
|
755
|
+
console.warn('[ATHENA] Max failures reached. Dropping events and disabling inference temporarily.');
|
|
756
|
+
}
|
|
494
757
|
}
|
|
495
758
|
}
|
|
496
759
|
/**
|
|
@@ -553,7 +816,9 @@ class AthenaTracker {
|
|
|
553
816
|
body: JSON.stringify({
|
|
554
817
|
...prediction,
|
|
555
818
|
session_id: this.state.sessionId,
|
|
556
|
-
user_id: this.state.userId
|
|
819
|
+
user_id: this.state.userId,
|
|
820
|
+
// Include external IDs for custom audience building
|
|
821
|
+
external_ids: Object.keys(this.externalIds).length > 0 ? this.externalIds : undefined
|
|
557
822
|
})
|
|
558
823
|
});
|
|
559
824
|
if (response.ok) {
|
|
@@ -622,6 +887,82 @@ class AthenaTracker {
|
|
|
622
887
|
generateSessionId() {
|
|
623
888
|
return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
624
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Generate anonymous user ID compatible with Meta/Google standards
|
|
892
|
+
* Format: anon_{timestamp}_{random} for Pascal internal use
|
|
893
|
+
* Can be mapped to hashed PII for custom audience building
|
|
894
|
+
*/
|
|
895
|
+
generateAnonymousId() {
|
|
896
|
+
const timestamp = Date.now();
|
|
897
|
+
const random = Math.random().toString(36).substring(2, 11);
|
|
898
|
+
return `anon_${timestamp}_${random}`;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Capture external IDs from Meta and Google for custom audience building
|
|
902
|
+
* Only works in browser environment with cookies
|
|
903
|
+
*/
|
|
904
|
+
captureExternalIds() {
|
|
905
|
+
// Skip if not in browser environment
|
|
906
|
+
if (typeof document === 'undefined' || typeof document.cookie === 'undefined') {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
|
|
911
|
+
const [key, value] = cookie.trim().split('=');
|
|
912
|
+
acc[key] = value;
|
|
913
|
+
return acc;
|
|
914
|
+
}, {});
|
|
915
|
+
// Meta Pixel ID (_fbp) - Format: fb.{version}.{timestamp}.{random}
|
|
916
|
+
if (cookies._fbp) {
|
|
917
|
+
this.externalIds.fbp = cookies._fbp;
|
|
918
|
+
}
|
|
919
|
+
// Meta Click ID (_fbc) - Format: fb.{version}.{timestamp}.{click_id}
|
|
920
|
+
if (cookies._fbc) {
|
|
921
|
+
this.externalIds.fbc = cookies._fbc;
|
|
922
|
+
}
|
|
923
|
+
// Google Analytics Client ID (_ga) - Format: GA1.{version}.{random}.{timestamp}
|
|
924
|
+
if (cookies._ga) {
|
|
925
|
+
this.externalIds.ga = cookies._ga;
|
|
926
|
+
}
|
|
927
|
+
// Google Click ID (gcl_aw) - For Google Ads conversions
|
|
928
|
+
if (cookies.gcl_aw) {
|
|
929
|
+
this.externalIds.gclid = cookies.gcl_aw;
|
|
930
|
+
}
|
|
931
|
+
if (this.config?.debug && Object.keys(this.externalIds).length > 0) {
|
|
932
|
+
console.log('[ATHENA] External IDs captured for audience building:', this.externalIds);
|
|
933
|
+
}
|
|
934
|
+
} catch (error) {
|
|
935
|
+
console.warn('[ATHENA] Failed to capture external IDs:', error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Hash PII for custom audience matching (SHA-256)
|
|
940
|
+
* Use this when user identifies with email/phone
|
|
941
|
+
*/
|
|
942
|
+
async hashPII(value) {
|
|
943
|
+
// Normalize: lowercase, trim whitespace
|
|
944
|
+
const normalized = value.toLowerCase().trim();
|
|
945
|
+
// Use Web Crypto API for hashing (browser/modern Node)
|
|
946
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
947
|
+
const encoder = new TextEncoder();
|
|
948
|
+
const data = encoder.encode(normalized);
|
|
949
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
950
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
951
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
952
|
+
}
|
|
953
|
+
// Try expo-crypto for React Native (Expo)
|
|
954
|
+
try {
|
|
955
|
+
// @ts-expect-error - expo-crypto is an optional peer dependency
|
|
956
|
+
const Crypto = await import('expo-crypto');
|
|
957
|
+
const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, normalized);
|
|
958
|
+
return hash;
|
|
959
|
+
} catch (error) {
|
|
960
|
+
// expo-crypto not available
|
|
961
|
+
}
|
|
962
|
+
// Fallback: return unhashed (server should hash it)
|
|
963
|
+
console.warn('[ATHENA] SHA-256 not available - install expo-crypto for React Native: npx expo install expo-crypto');
|
|
964
|
+
return normalized;
|
|
965
|
+
}
|
|
625
966
|
/**
|
|
626
967
|
* Sleep utility
|
|
627
968
|
*/
|
|
@@ -638,13 +979,13 @@ AthenaTracker.instance = null;
|
|
|
638
979
|
* Per ATHENA Integration Plan Phase 4.1
|
|
639
980
|
*/
|
|
640
981
|
// Cross-platform crypto support
|
|
641
|
-
let crypto;
|
|
982
|
+
let crypto$1;
|
|
642
983
|
try {
|
|
643
984
|
// Node.js environment
|
|
644
|
-
crypto = require('crypto');
|
|
985
|
+
crypto$1 = require('crypto');
|
|
645
986
|
} catch {
|
|
646
987
|
// Browser/React Native - will use Web Crypto API
|
|
647
|
-
crypto = null;
|
|
988
|
+
crypto$1 = null;
|
|
648
989
|
}
|
|
649
990
|
/**
|
|
650
991
|
* Generate HMAC-SHA256 signature for webhook payload (Node.js version)
|
|
@@ -655,7 +996,7 @@ try {
|
|
|
655
996
|
*/
|
|
656
997
|
function generateWebhookSignatureNode(payload, secret) {
|
|
657
998
|
const payloadString = JSON.stringify(payload);
|
|
658
|
-
const hmac = crypto.createHmac('sha256', secret);
|
|
999
|
+
const hmac = crypto$1.createHmac('sha256', secret);
|
|
659
1000
|
hmac.update(payloadString);
|
|
660
1001
|
return hmac.digest('hex');
|
|
661
1002
|
}
|
|
@@ -689,7 +1030,7 @@ async function generateWebhookSignatureBrowser(payload, secret) {
|
|
|
689
1030
|
* @returns HMAC signature (hex-encoded)
|
|
690
1031
|
*/
|
|
691
1032
|
async function generateWebhookSignature(payload, secret) {
|
|
692
|
-
if (crypto) {
|
|
1033
|
+
if (crypto$1) {
|
|
693
1034
|
// Node.js environment
|
|
694
1035
|
return generateWebhookSignatureNode(payload, secret);
|
|
695
1036
|
} else if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
|
|
@@ -716,9 +1057,9 @@ async function verifyWebhookSignature(payload, signature, secret) {
|
|
|
716
1057
|
return true;
|
|
717
1058
|
}
|
|
718
1059
|
// Use timing-safe comparison to prevent timing attacks (Node.js only)
|
|
719
|
-
if (crypto && crypto.timingSafeEqual) {
|
|
1060
|
+
if (crypto$1 && crypto$1.timingSafeEqual) {
|
|
720
1061
|
try {
|
|
721
|
-
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
1062
|
+
return crypto$1.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
722
1063
|
} catch (error) {
|
|
723
1064
|
return false;
|
|
724
1065
|
}
|