user-analytics-tracker 1.7.0 → 2.1.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/CHANGELOG.md +65 -0
- package/README.md +236 -2
- package/dist/index.cjs.js +1198 -268
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +262 -23
- package/dist/index.d.ts +262 -23
- package/dist/index.esm.js +1192 -269
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs.js
CHANGED
|
@@ -553,6 +553,191 @@ function checkAndSetLocationConsent(msisdn) {
|
|
|
553
553
|
return false;
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/**
|
|
557
|
+
* IP Geolocation Service
|
|
558
|
+
* Fetches location data (country, region, city) from user's IP address
|
|
559
|
+
* Uses ipwho.is API (no API key required)
|
|
560
|
+
*
|
|
561
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
562
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
565
|
+
* Get public IP address using ipwho.is API
|
|
566
|
+
* No API key required
|
|
567
|
+
*
|
|
568
|
+
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
569
|
+
*
|
|
570
|
+
* @example
|
|
571
|
+
* ```typescript
|
|
572
|
+
* const ip = await getPublicIP();
|
|
573
|
+
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
async function getPublicIP() {
|
|
577
|
+
// Skip if we're in an environment without fetch (SSR)
|
|
578
|
+
if (typeof fetch === 'undefined') {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
// Call ipwho.is without IP parameter - it auto-detects user's IP
|
|
583
|
+
// Using HTTPS endpoint for better security
|
|
584
|
+
const response = await fetch('https://ipwho.is/', {
|
|
585
|
+
method: 'GET',
|
|
586
|
+
headers: {
|
|
587
|
+
Accept: 'application/json',
|
|
588
|
+
},
|
|
589
|
+
// Add timeout to prevent hanging
|
|
590
|
+
signal: AbortSignal.timeout(5000),
|
|
591
|
+
});
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
const data = await response.json();
|
|
596
|
+
// ipwho.is returns success field
|
|
597
|
+
if (data.success === false) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
return data.ip || null;
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
// Silently fail - don't break user experience
|
|
604
|
+
if (error.name !== 'AbortError') {
|
|
605
|
+
console.warn('[IP Geolocation] Error fetching public IP:', error.message);
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Get location from IP address using ipwho.is API
|
|
612
|
+
* Free tier: No API key required
|
|
613
|
+
*
|
|
614
|
+
* Stores all keys dynamically from the API response, including nested objects
|
|
615
|
+
* This ensures we capture all available data and any new fields added by the API
|
|
616
|
+
*/
|
|
617
|
+
async function getIPLocation(ip) {
|
|
618
|
+
// Skip localhost/private IPs (these can't be geolocated)
|
|
619
|
+
if (!ip ||
|
|
620
|
+
ip === '0.0.0.0' ||
|
|
621
|
+
ip === '::1' ||
|
|
622
|
+
ip.startsWith('127.') ||
|
|
623
|
+
ip.startsWith('192.168.') ||
|
|
624
|
+
ip.startsWith('10.') ||
|
|
625
|
+
ip.startsWith('172.') ||
|
|
626
|
+
ip.startsWith('::ffff:127.')) {
|
|
627
|
+
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
// Using ipwho.is API (no API key required)
|
|
632
|
+
const response = await fetch(`https://ipwho.is/${ip}`, {
|
|
633
|
+
method: 'GET',
|
|
634
|
+
headers: {
|
|
635
|
+
Accept: 'application/json',
|
|
636
|
+
},
|
|
637
|
+
// Add timeout to prevent hanging
|
|
638
|
+
signal: AbortSignal.timeout(5000),
|
|
639
|
+
});
|
|
640
|
+
if (!response.ok) {
|
|
641
|
+
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const data = await response.json();
|
|
645
|
+
// ipwho.is returns success field
|
|
646
|
+
if (data.success === false) {
|
|
647
|
+
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message || 'Unknown error'}`);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
// Store all keys dynamically from the response
|
|
651
|
+
// This ensures we capture all fields, including nested objects and any new fields
|
|
652
|
+
const locationData = {
|
|
653
|
+
ip: data.ip || ip,
|
|
654
|
+
// Map all fields from the API response dynamically
|
|
655
|
+
...Object.keys(data).reduce((acc, key) => {
|
|
656
|
+
// Store all keys and their values, preserving nested objects
|
|
657
|
+
acc[key] = data[key];
|
|
658
|
+
return acc;
|
|
659
|
+
}, {}),
|
|
660
|
+
};
|
|
661
|
+
// Add backward compatibility mappings for existing code
|
|
662
|
+
if (data.latitude !== undefined) {
|
|
663
|
+
locationData.lat = data.latitude;
|
|
664
|
+
}
|
|
665
|
+
if (data.longitude !== undefined) {
|
|
666
|
+
locationData.lon = data.longitude;
|
|
667
|
+
}
|
|
668
|
+
if (data.country_code !== undefined) {
|
|
669
|
+
locationData.countryCode = data.country_code;
|
|
670
|
+
}
|
|
671
|
+
if (data.region !== undefined) {
|
|
672
|
+
locationData.regionName = data.region;
|
|
673
|
+
}
|
|
674
|
+
if (data.connection?.isp !== undefined) {
|
|
675
|
+
locationData.isp = data.connection.isp;
|
|
676
|
+
}
|
|
677
|
+
if (data.connection?.org !== undefined) {
|
|
678
|
+
locationData.org = data.connection.org;
|
|
679
|
+
}
|
|
680
|
+
if (data.connection?.asn !== undefined) {
|
|
681
|
+
locationData.as = `AS${data.connection.asn}`;
|
|
682
|
+
}
|
|
683
|
+
if (data.timezone?.id !== undefined) {
|
|
684
|
+
locationData.timezone = data.timezone.id;
|
|
685
|
+
}
|
|
686
|
+
locationData.query = data.ip || ip;
|
|
687
|
+
return locationData;
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
// Silently fail - don't break user experience
|
|
691
|
+
if (error.name !== 'AbortError') {
|
|
692
|
+
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Get IP address from request headers
|
|
699
|
+
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
700
|
+
*/
|
|
701
|
+
function getIPFromRequest(req) {
|
|
702
|
+
// Try various headers that proxies/load balancers use
|
|
703
|
+
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
704
|
+
req.headers?.['x-forwarded-for'] ||
|
|
705
|
+
req.headers?.['X-Forwarded-For'];
|
|
706
|
+
if (forwardedFor) {
|
|
707
|
+
// x-forwarded-for can contain multiple IPs, take the first one
|
|
708
|
+
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
709
|
+
const ip = ips[0];
|
|
710
|
+
if (ip && ip !== '0.0.0.0') {
|
|
711
|
+
return ip;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
715
|
+
req.headers?.['x-real-ip'] ||
|
|
716
|
+
req.headers?.['X-Real-IP'];
|
|
717
|
+
if (realIP && realIP !== '0.0.0.0') {
|
|
718
|
+
return realIP.trim();
|
|
719
|
+
}
|
|
720
|
+
// Try req.ip (from Express/Next.js)
|
|
721
|
+
if (req.ip && req.ip !== '0.0.0.0') {
|
|
722
|
+
return req.ip;
|
|
723
|
+
}
|
|
724
|
+
// For localhost, detect if we're running locally
|
|
725
|
+
if (typeof window === 'undefined') {
|
|
726
|
+
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
727
|
+
if (hostname &&
|
|
728
|
+
(hostname.includes('localhost') ||
|
|
729
|
+
hostname.includes('127.0.0.1') ||
|
|
730
|
+
hostname.startsWith('192.168.'))) {
|
|
731
|
+
return '127.0.0.1'; // Localhost IP
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// If no IP found and we're in development, return localhost
|
|
735
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
736
|
+
return '127.0.0.1'; // Localhost for development
|
|
737
|
+
}
|
|
738
|
+
return '0.0.0.0';
|
|
739
|
+
}
|
|
740
|
+
|
|
556
741
|
/**
|
|
557
742
|
* Location Detector
|
|
558
743
|
* Detects GPS location with consent management, falls back to IP-based location API
|
|
@@ -817,7 +1002,8 @@ class LocationDetector {
|
|
|
817
1002
|
/**
|
|
818
1003
|
* Get location from IP-based public API (client-side)
|
|
819
1004
|
* Works without user permission, good fallback when GPS is unavailable
|
|
820
|
-
* Uses
|
|
1005
|
+
* Uses ipwho.is API (no API key required)
|
|
1006
|
+
* Stores all keys dynamically from the API response
|
|
821
1007
|
*/
|
|
822
1008
|
static async getIPBasedLocation() {
|
|
823
1009
|
// Return cached IP location if available
|
|
@@ -853,51 +1039,47 @@ class LocationDetector {
|
|
|
853
1039
|
}
|
|
854
1040
|
this.ipLocationFetchingRef.current = true;
|
|
855
1041
|
try {
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
headers: {
|
|
861
|
-
Accept: 'application/json',
|
|
862
|
-
},
|
|
863
|
-
// Add timeout to prevent hanging
|
|
864
|
-
signal: AbortSignal.timeout(5000),
|
|
865
|
-
});
|
|
866
|
-
if (!response.ok) {
|
|
867
|
-
throw new Error(`HTTP ${response.status}`);
|
|
1042
|
+
// Get public IP first, then get location
|
|
1043
|
+
const publicIP = await getPublicIP();
|
|
1044
|
+
if (!publicIP) {
|
|
1045
|
+
throw new Error('Could not determine public IP address');
|
|
868
1046
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
const fallback = {
|
|
874
|
-
source: 'unknown',
|
|
875
|
-
permission: 'granted',
|
|
876
|
-
};
|
|
877
|
-
this.lastIPLocationRef.current = fallback;
|
|
878
|
-
return fallback;
|
|
1047
|
+
// Get location from IP using ipwho.is API
|
|
1048
|
+
const ipLocation = await getIPLocation(publicIP);
|
|
1049
|
+
if (!ipLocation) {
|
|
1050
|
+
throw new Error('Could not fetch location data');
|
|
879
1051
|
}
|
|
880
1052
|
// Convert IP location to LocationInfo format
|
|
1053
|
+
// Map all available fields from the IP location response
|
|
1054
|
+
// Handle timezone which can be either a string or an object
|
|
1055
|
+
const timezoneValue = typeof ipLocation.timezone === 'string'
|
|
1056
|
+
? ipLocation.timezone
|
|
1057
|
+
: ipLocation.timezone?.id || undefined;
|
|
881
1058
|
const locationResult = {
|
|
882
|
-
lat:
|
|
883
|
-
lon:
|
|
1059
|
+
lat: ipLocation.latitude ?? ipLocation.lat ?? null,
|
|
1060
|
+
lon: ipLocation.longitude ?? ipLocation.lon ?? null,
|
|
884
1061
|
accuracy: null, // IP-based location has no accuracy metric
|
|
885
1062
|
permission: 'granted', // IP location doesn't require permission
|
|
886
1063
|
source: 'ip',
|
|
887
1064
|
ts: new Date().toISOString(),
|
|
888
|
-
ip:
|
|
889
|
-
country:
|
|
890
|
-
countryCode:
|
|
891
|
-
city:
|
|
892
|
-
region:
|
|
893
|
-
timezone:
|
|
1065
|
+
ip: ipLocation.ip || publicIP,
|
|
1066
|
+
country: ipLocation.country || undefined,
|
|
1067
|
+
countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
|
|
1068
|
+
city: ipLocation.city || undefined,
|
|
1069
|
+
region: ipLocation.region || ipLocation.regionName || undefined,
|
|
1070
|
+
timezone: timezoneValue,
|
|
894
1071
|
};
|
|
1072
|
+
// Store the full IP location data in a custom field for access to all keys
|
|
1073
|
+
// This preserves all dynamic keys from the API response
|
|
1074
|
+
locationResult.ipLocationData = ipLocation;
|
|
895
1075
|
console.log('[Location] IP-based location obtained:', {
|
|
896
1076
|
ip: locationResult.ip,
|
|
897
1077
|
lat: locationResult.lat,
|
|
898
1078
|
lon: locationResult.lon,
|
|
899
1079
|
city: locationResult.city,
|
|
900
1080
|
country: locationResult.country,
|
|
1081
|
+
continent: ipLocation.continent,
|
|
1082
|
+
timezone: locationResult.timezone,
|
|
901
1083
|
});
|
|
902
1084
|
this.lastIPLocationRef.current = locationResult;
|
|
903
1085
|
return locationResult;
|
|
@@ -1017,15 +1199,91 @@ function trackPageVisit() {
|
|
|
1017
1199
|
localStorage.setItem('analytics:pageVisits', newCount.toString());
|
|
1018
1200
|
return newCount;
|
|
1019
1201
|
}
|
|
1202
|
+
const SESSION_STORAGE_KEY = 'analytics:session';
|
|
1203
|
+
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
1204
|
+
/**
|
|
1205
|
+
* Get or create a session
|
|
1206
|
+
*/
|
|
1207
|
+
function getOrCreateSession(timeout = DEFAULT_SESSION_TIMEOUT) {
|
|
1208
|
+
if (typeof window === 'undefined') {
|
|
1209
|
+
return {
|
|
1210
|
+
sessionId: `server-${Date.now()}`,
|
|
1211
|
+
startTime: Date.now(),
|
|
1212
|
+
lastActivity: Date.now(),
|
|
1213
|
+
pageViews: 1,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
const stored = loadJSON(SESSION_STORAGE_KEY);
|
|
1217
|
+
const now = Date.now();
|
|
1218
|
+
// Check if session expired
|
|
1219
|
+
if (stored && now - stored.lastActivity < timeout) {
|
|
1220
|
+
// Update last activity
|
|
1221
|
+
const updated = {
|
|
1222
|
+
...stored,
|
|
1223
|
+
lastActivity: now,
|
|
1224
|
+
pageViews: stored.pageViews + 1,
|
|
1225
|
+
};
|
|
1226
|
+
saveJSON(SESSION_STORAGE_KEY, updated);
|
|
1227
|
+
return updated;
|
|
1228
|
+
}
|
|
1229
|
+
// Create new session
|
|
1230
|
+
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
1231
|
+
const newSession = {
|
|
1232
|
+
sessionId,
|
|
1233
|
+
startTime: now,
|
|
1234
|
+
lastActivity: now,
|
|
1235
|
+
pageViews: 1,
|
|
1236
|
+
};
|
|
1237
|
+
saveJSON(SESSION_STORAGE_KEY, newSession);
|
|
1238
|
+
return newSession;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Update session activity
|
|
1242
|
+
*/
|
|
1243
|
+
function updateSessionActivity() {
|
|
1244
|
+
if (typeof window === 'undefined')
|
|
1245
|
+
return;
|
|
1246
|
+
const stored = loadJSON(SESSION_STORAGE_KEY);
|
|
1247
|
+
if (stored) {
|
|
1248
|
+
const updated = {
|
|
1249
|
+
...stored,
|
|
1250
|
+
lastActivity: Date.now(),
|
|
1251
|
+
};
|
|
1252
|
+
saveJSON(SESSION_STORAGE_KEY, updated);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Get current session info
|
|
1257
|
+
*/
|
|
1258
|
+
function getSession() {
|
|
1259
|
+
return loadJSON(SESSION_STORAGE_KEY);
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Clear session
|
|
1263
|
+
*/
|
|
1264
|
+
function clearSession() {
|
|
1265
|
+
if (typeof window === 'undefined')
|
|
1266
|
+
return;
|
|
1267
|
+
try {
|
|
1268
|
+
localStorage.removeItem(SESSION_STORAGE_KEY);
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
// Silently fail
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1020
1274
|
|
|
1021
1275
|
var storage = /*#__PURE__*/Object.freeze({
|
|
1022
1276
|
__proto__: null,
|
|
1277
|
+
clearSession: clearSession,
|
|
1278
|
+
getOrCreateSession: getOrCreateSession,
|
|
1023
1279
|
getOrCreateUserId: getOrCreateUserId,
|
|
1280
|
+
getSession: getSession,
|
|
1024
1281
|
loadJSON: loadJSON,
|
|
1025
1282
|
loadSessionJSON: loadSessionJSON,
|
|
1026
1283
|
saveJSON: saveJSON,
|
|
1027
1284
|
saveSessionJSON: saveSessionJSON,
|
|
1028
|
-
trackPageVisit: trackPageVisit
|
|
1285
|
+
trackPageVisit: trackPageVisit,
|
|
1286
|
+
updateSessionActivity: updateSessionActivity
|
|
1029
1287
|
});
|
|
1030
1288
|
|
|
1031
1289
|
const UTM_KEYS = [
|
|
@@ -1175,95 +1433,765 @@ var attributionDetector = /*#__PURE__*/Object.freeze({
|
|
|
1175
1433
|
});
|
|
1176
1434
|
|
|
1177
1435
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
* Supports both relative paths (e.g., '/api/analytics') and full URLs (e.g., 'https://your-server.com/api/analytics')
|
|
1436
|
+
* Logger utility for analytics tracker
|
|
1437
|
+
* Provides configurable logging levels for development and production
|
|
1182
1438
|
*/
|
|
1183
|
-
class
|
|
1439
|
+
class Logger {
|
|
1440
|
+
constructor() {
|
|
1441
|
+
this.level = 'warn';
|
|
1442
|
+
this.isDevelopment =
|
|
1443
|
+
typeof process !== 'undefined' &&
|
|
1444
|
+
process.env?.NODE_ENV === 'development';
|
|
1445
|
+
// Default to 'info' in development, 'warn' in production
|
|
1446
|
+
if (this.isDevelopment && this.level === 'warn') {
|
|
1447
|
+
this.level = 'info';
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
setLevel(level) {
|
|
1451
|
+
this.level = level;
|
|
1452
|
+
}
|
|
1453
|
+
getLevel() {
|
|
1454
|
+
return this.level;
|
|
1455
|
+
}
|
|
1456
|
+
shouldLog(level) {
|
|
1457
|
+
const levels = ['silent', 'error', 'warn', 'info', 'debug'];
|
|
1458
|
+
const currentIndex = levels.indexOf(this.level);
|
|
1459
|
+
const messageIndex = levels.indexOf(level);
|
|
1460
|
+
return messageIndex >= 0 && messageIndex <= currentIndex;
|
|
1461
|
+
}
|
|
1462
|
+
error(message, ...args) {
|
|
1463
|
+
if (this.shouldLog('error')) {
|
|
1464
|
+
console.error(`[Analytics] ${message}`, ...args);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
warn(message, ...args) {
|
|
1468
|
+
if (this.shouldLog('warn')) {
|
|
1469
|
+
console.warn(`[Analytics] ${message}`, ...args);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
info(message, ...args) {
|
|
1473
|
+
if (this.shouldLog('info')) {
|
|
1474
|
+
console.log(`[Analytics] ${message}`, ...args);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
debug(message, ...args) {
|
|
1478
|
+
if (this.shouldLog('debug')) {
|
|
1479
|
+
console.log(`[Analytics] [DEBUG] ${message}`, ...args);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
const logger = new Logger();
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Queue Manager for Analytics Events
|
|
1487
|
+
* Handles batching, persistence, and offline support
|
|
1488
|
+
*/
|
|
1489
|
+
class QueueManager {
|
|
1490
|
+
constructor(config) {
|
|
1491
|
+
this.queue = [];
|
|
1492
|
+
this.flushTimer = null;
|
|
1493
|
+
this.isFlushing = false;
|
|
1494
|
+
this.flushCallback = null;
|
|
1495
|
+
this.config = config;
|
|
1496
|
+
this.loadFromStorage();
|
|
1497
|
+
this.startAutoFlush();
|
|
1498
|
+
this.setupPageUnloadHandler();
|
|
1499
|
+
}
|
|
1184
1500
|
/**
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
* @param config - Configuration object
|
|
1188
|
-
* @param config.apiEndpoint - Your backend API endpoint URL
|
|
1189
|
-
* - Relative path: '/api/analytics' (sends to same domain)
|
|
1190
|
-
* - Full URL: 'https://your-server.com/api/analytics' (sends to your server)
|
|
1191
|
-
*
|
|
1192
|
-
* @example
|
|
1193
|
-
* ```typescript
|
|
1194
|
-
* // Use your own server
|
|
1195
|
-
* AnalyticsService.configure({
|
|
1196
|
-
* apiEndpoint: 'https://api.yourcompany.com/analytics'
|
|
1197
|
-
* });
|
|
1198
|
-
*
|
|
1199
|
-
* // Or use relative path (same domain)
|
|
1200
|
-
* AnalyticsService.configure({
|
|
1201
|
-
* apiEndpoint: '/api/analytics'
|
|
1202
|
-
* });
|
|
1203
|
-
* ```
|
|
1501
|
+
* Set the callback function to flush events
|
|
1204
1502
|
*/
|
|
1205
|
-
|
|
1206
|
-
this.
|
|
1503
|
+
setFlushCallback(callback) {
|
|
1504
|
+
this.flushCallback = callback;
|
|
1207
1505
|
}
|
|
1208
1506
|
/**
|
|
1209
|
-
*
|
|
1507
|
+
* Add an event to the queue
|
|
1210
1508
|
*/
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
return Array.from(arr)
|
|
1216
|
-
.map((n) => n.toString(16))
|
|
1217
|
-
.join('');
|
|
1509
|
+
enqueue(event) {
|
|
1510
|
+
if (this.queue.length >= this.config.maxQueueSize) {
|
|
1511
|
+
logger.warn(`Queue full (${this.config.maxQueueSize} events). Dropping oldest event.`);
|
|
1512
|
+
this.queue.shift(); // Remove oldest event
|
|
1218
1513
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1514
|
+
const queuedEvent = {
|
|
1515
|
+
event,
|
|
1516
|
+
retries: 0,
|
|
1517
|
+
timestamp: Date.now(),
|
|
1518
|
+
id: `${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
1519
|
+
};
|
|
1520
|
+
this.queue.push(queuedEvent);
|
|
1521
|
+
this.saveToStorage();
|
|
1522
|
+
logger.debug(`Event queued. Queue size: ${this.queue.length}`);
|
|
1523
|
+
// Auto-flush if batch size reached
|
|
1524
|
+
if (this.queue.length >= this.config.batchSize) {
|
|
1525
|
+
this.flush();
|
|
1526
|
+
}
|
|
1527
|
+
return true;
|
|
1221
1528
|
}
|
|
1222
1529
|
/**
|
|
1223
|
-
*
|
|
1530
|
+
* Flush events from the queue
|
|
1224
1531
|
*/
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1532
|
+
async flush() {
|
|
1533
|
+
if (this.isFlushing || this.queue.length === 0 || !this.flushCallback) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
this.isFlushing = true;
|
|
1537
|
+
const eventsToFlush = this.queue.splice(0, this.config.batchSize);
|
|
1538
|
+
if (eventsToFlush.length === 0) {
|
|
1539
|
+
this.isFlushing = false;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1231
1542
|
try {
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
});
|
|
1238
|
-
if (!res.ok) {
|
|
1239
|
-
console.warn('[Analytics] Send failed:', await res.text());
|
|
1240
|
-
}
|
|
1241
|
-
else if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
1242
|
-
console.log('[Analytics] Event sent successfully');
|
|
1243
|
-
}
|
|
1543
|
+
const events = eventsToFlush.map((q) => q.event);
|
|
1544
|
+
await this.flushCallback(events);
|
|
1545
|
+
// Remove successfully flushed events from storage
|
|
1546
|
+
this.saveToStorage();
|
|
1547
|
+
logger.debug(`Flushed ${events.length} events. Queue size: ${this.queue.length}`);
|
|
1244
1548
|
}
|
|
1245
|
-
catch (
|
|
1246
|
-
//
|
|
1247
|
-
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
// On failure, put events back in queue (they'll be retried)
|
|
1551
|
+
this.queue.unshift(...eventsToFlush);
|
|
1552
|
+
this.saveToStorage();
|
|
1553
|
+
logger.warn(`Failed to flush events. Re-queued ${eventsToFlush.length} events.`, error);
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
finally {
|
|
1557
|
+
this.isFlushing = false;
|
|
1248
1558
|
}
|
|
1249
1559
|
}
|
|
1250
1560
|
/**
|
|
1251
|
-
*
|
|
1561
|
+
* Get current queue size
|
|
1252
1562
|
*/
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1563
|
+
getQueueSize() {
|
|
1564
|
+
return this.queue.length;
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Get all queued events (for debugging)
|
|
1568
|
+
*/
|
|
1569
|
+
getQueue() {
|
|
1570
|
+
return [...this.queue];
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Clear the queue
|
|
1574
|
+
*/
|
|
1575
|
+
clear() {
|
|
1576
|
+
this.queue = [];
|
|
1577
|
+
this.saveToStorage();
|
|
1578
|
+
logger.debug('Queue cleared');
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Load queue from localStorage
|
|
1582
|
+
*/
|
|
1583
|
+
loadFromStorage() {
|
|
1584
|
+
if (typeof window === 'undefined')
|
|
1585
|
+
return;
|
|
1586
|
+
try {
|
|
1587
|
+
const stored = loadJSON(this.config.storageKey);
|
|
1588
|
+
if (stored && Array.isArray(stored)) {
|
|
1589
|
+
// Only load events from last 24 hours to prevent stale data
|
|
1590
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
1591
|
+
this.queue = stored.filter((q) => q.timestamp > oneDayAgo);
|
|
1592
|
+
if (this.queue.length > 0) {
|
|
1593
|
+
logger.debug(`Loaded ${this.queue.length} events from storage`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
catch (error) {
|
|
1598
|
+
logger.warn('Failed to load queue from storage', error);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Save queue to localStorage
|
|
1603
|
+
*/
|
|
1604
|
+
saveToStorage() {
|
|
1605
|
+
if (typeof window === 'undefined')
|
|
1606
|
+
return;
|
|
1607
|
+
try {
|
|
1608
|
+
saveJSON(this.config.storageKey, this.queue);
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
logger.warn('Failed to save queue to storage', error);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Start auto-flush timer
|
|
1616
|
+
*/
|
|
1617
|
+
startAutoFlush() {
|
|
1618
|
+
if (typeof window === 'undefined')
|
|
1619
|
+
return;
|
|
1620
|
+
if (this.flushTimer) {
|
|
1621
|
+
clearInterval(this.flushTimer);
|
|
1622
|
+
}
|
|
1623
|
+
this.flushTimer = setInterval(() => {
|
|
1624
|
+
if (this.queue.length > 0) {
|
|
1625
|
+
this.flush().catch((error) => {
|
|
1626
|
+
logger.warn('Auto-flush failed', error);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}, this.config.batchInterval);
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Setup page unload handler to flush events
|
|
1633
|
+
*/
|
|
1634
|
+
setupPageUnloadHandler() {
|
|
1635
|
+
if (typeof window === 'undefined')
|
|
1636
|
+
return;
|
|
1637
|
+
// Use sendBeacon for reliable delivery on page unload
|
|
1638
|
+
window.addEventListener('beforeunload', () => {
|
|
1639
|
+
if (this.queue.length > 0 && this.flushCallback) {
|
|
1640
|
+
const events = this.queue.splice(0, this.config.batchSize).map((q) => q.event);
|
|
1641
|
+
// Try to send via sendBeacon (more reliable on unload)
|
|
1642
|
+
if (navigator.sendBeacon) {
|
|
1643
|
+
try {
|
|
1644
|
+
const blob = new Blob([JSON.stringify(events)], {
|
|
1645
|
+
type: 'application/json',
|
|
1646
|
+
});
|
|
1647
|
+
navigator.sendBeacon(this.getEndpointFromCallback(), blob);
|
|
1648
|
+
this.saveToStorage();
|
|
1649
|
+
}
|
|
1650
|
+
catch (error) {
|
|
1651
|
+
// Fallback: put events back in queue
|
|
1652
|
+
this.queue.unshift(...events.map((e) => ({
|
|
1653
|
+
event: e,
|
|
1654
|
+
retries: 0,
|
|
1655
|
+
timestamp: Date.now(),
|
|
1656
|
+
id: `${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
1657
|
+
})));
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
// Also use visibilitychange for better mobile support
|
|
1663
|
+
document.addEventListener('visibilitychange', () => {
|
|
1664
|
+
if (document.visibilityState === 'hidden' && this.queue.length > 0) {
|
|
1665
|
+
this.flush().catch(() => {
|
|
1666
|
+
// Silently fail on visibility change
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Get endpoint for sendBeacon
|
|
1673
|
+
*/
|
|
1674
|
+
getEndpointFromCallback() {
|
|
1675
|
+
// Try to get from window or return default
|
|
1676
|
+
if (typeof window !== 'undefined' && window.__analyticsEndpoint) {
|
|
1677
|
+
return window.__analyticsEndpoint;
|
|
1678
|
+
}
|
|
1679
|
+
return '/api/analytics';
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Update storage key (for configuration changes)
|
|
1683
|
+
*/
|
|
1684
|
+
updateConfig(config) {
|
|
1685
|
+
this.config = { ...this.config, ...config };
|
|
1686
|
+
if (config.batchInterval) {
|
|
1687
|
+
this.startAutoFlush();
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Cleanup resources
|
|
1692
|
+
*/
|
|
1693
|
+
destroy() {
|
|
1694
|
+
if (this.flushTimer) {
|
|
1695
|
+
clearInterval(this.flushTimer);
|
|
1696
|
+
this.flushTimer = null;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Plugin Manager for Analytics Tracker
|
|
1703
|
+
* Manages plugin registration and execution
|
|
1704
|
+
*/
|
|
1705
|
+
class PluginManager {
|
|
1706
|
+
constructor() {
|
|
1707
|
+
this.plugins = [];
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Register a plugin
|
|
1711
|
+
*/
|
|
1712
|
+
register(plugin) {
|
|
1713
|
+
if (!plugin.name) {
|
|
1714
|
+
throw new Error('Plugin must have a name');
|
|
1715
|
+
}
|
|
1716
|
+
this.plugins.push(plugin);
|
|
1717
|
+
logger.debug(`Plugin registered: ${plugin.name}`);
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Unregister a plugin
|
|
1721
|
+
*/
|
|
1722
|
+
unregister(pluginName) {
|
|
1723
|
+
const index = this.plugins.findIndex((p) => p.name === pluginName);
|
|
1724
|
+
if (index !== -1) {
|
|
1725
|
+
this.plugins.splice(index, 1);
|
|
1726
|
+
logger.debug(`Plugin unregistered: ${pluginName}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Get all registered plugins
|
|
1731
|
+
*/
|
|
1732
|
+
getPlugins() {
|
|
1733
|
+
return [...this.plugins];
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Execute beforeSend hooks for all plugins
|
|
1737
|
+
* Returns the transformed event, or null if filtered out
|
|
1738
|
+
*/
|
|
1739
|
+
async executeBeforeSend(event) {
|
|
1740
|
+
let transformedEvent = event;
|
|
1741
|
+
for (const plugin of this.plugins) {
|
|
1742
|
+
if (!plugin.beforeSend)
|
|
1743
|
+
continue;
|
|
1744
|
+
try {
|
|
1745
|
+
const result = await plugin.beforeSend(transformedEvent);
|
|
1746
|
+
// If plugin returns null/undefined, filter out the event
|
|
1747
|
+
if (result === null || result === undefined) {
|
|
1748
|
+
logger.debug(`Event filtered out by plugin: ${plugin.name}`);
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
transformedEvent = result;
|
|
1752
|
+
}
|
|
1753
|
+
catch (error) {
|
|
1754
|
+
logger.warn(`Plugin ${plugin.name} beforeSend hook failed:`, error);
|
|
1755
|
+
// Continue with other plugins even if one fails
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
return transformedEvent;
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Execute afterSend hooks for all plugins
|
|
1762
|
+
*/
|
|
1763
|
+
async executeAfterSend(event) {
|
|
1764
|
+
for (const plugin of this.plugins) {
|
|
1765
|
+
if (!plugin.afterSend)
|
|
1766
|
+
continue;
|
|
1767
|
+
try {
|
|
1768
|
+
await plugin.afterSend(event);
|
|
1769
|
+
}
|
|
1770
|
+
catch (error) {
|
|
1771
|
+
logger.warn(`Plugin ${plugin.name} afterSend hook failed:`, error);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Execute onError hooks for all plugins
|
|
1777
|
+
*/
|
|
1778
|
+
async executeOnError(event, error) {
|
|
1779
|
+
for (const plugin of this.plugins) {
|
|
1780
|
+
if (!plugin.onError)
|
|
1781
|
+
continue;
|
|
1782
|
+
try {
|
|
1783
|
+
await plugin.onError(event, error);
|
|
1784
|
+
}
|
|
1785
|
+
catch (err) {
|
|
1786
|
+
logger.warn(`Plugin ${plugin.name} onError hook failed:`, err);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Clear all plugins
|
|
1792
|
+
*/
|
|
1793
|
+
clear() {
|
|
1794
|
+
this.plugins = [];
|
|
1795
|
+
logger.debug('All plugins cleared');
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
// Global plugin manager instance
|
|
1799
|
+
const pluginManager = new PluginManager();
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Metrics collection for analytics tracker
|
|
1803
|
+
* Tracks performance and usage statistics
|
|
1804
|
+
*/
|
|
1805
|
+
class MetricsCollector {
|
|
1806
|
+
constructor() {
|
|
1807
|
+
this.metrics = {
|
|
1808
|
+
eventsSent: 0,
|
|
1809
|
+
eventsQueued: 0,
|
|
1810
|
+
eventsFailed: 0,
|
|
1811
|
+
eventsFiltered: 0,
|
|
1812
|
+
averageSendTime: 0,
|
|
1813
|
+
retryCount: 0,
|
|
1814
|
+
queueSize: 0,
|
|
1815
|
+
lastFlushTime: null,
|
|
1816
|
+
errors: [],
|
|
1817
|
+
};
|
|
1818
|
+
this.sendTimes = [];
|
|
1819
|
+
this.maxErrors = 100; // Keep last 100 errors
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Record an event being queued
|
|
1823
|
+
*/
|
|
1824
|
+
recordQueued() {
|
|
1825
|
+
this.metrics.eventsQueued++;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Record an event being sent
|
|
1829
|
+
*/
|
|
1830
|
+
recordSent(sendTime) {
|
|
1831
|
+
this.metrics.eventsSent++;
|
|
1832
|
+
this.sendTimes.push(sendTime);
|
|
1833
|
+
// Keep only last 100 send times for average calculation
|
|
1834
|
+
if (this.sendTimes.length > 100) {
|
|
1835
|
+
this.sendTimes.shift();
|
|
1836
|
+
}
|
|
1837
|
+
// Calculate average
|
|
1838
|
+
const sum = this.sendTimes.reduce((a, b) => a + b, 0);
|
|
1839
|
+
this.metrics.averageSendTime = sum / this.sendTimes.length;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Record a failed event
|
|
1843
|
+
*/
|
|
1844
|
+
recordFailed(error) {
|
|
1845
|
+
this.metrics.eventsFailed++;
|
|
1846
|
+
if (error) {
|
|
1847
|
+
this.metrics.errors.push({
|
|
1848
|
+
timestamp: Date.now(),
|
|
1849
|
+
error: error.message || String(error),
|
|
1850
|
+
});
|
|
1851
|
+
// Keep only last N errors
|
|
1852
|
+
if (this.metrics.errors.length > this.maxErrors) {
|
|
1853
|
+
this.metrics.errors.shift();
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Record a filtered event
|
|
1859
|
+
*/
|
|
1860
|
+
recordFiltered() {
|
|
1861
|
+
this.metrics.eventsFiltered++;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Record a retry
|
|
1865
|
+
*/
|
|
1866
|
+
recordRetry() {
|
|
1867
|
+
this.metrics.retryCount++;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Update queue size
|
|
1871
|
+
*/
|
|
1872
|
+
updateQueueSize(size) {
|
|
1873
|
+
this.metrics.queueSize = size;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Record flush time
|
|
1877
|
+
*/
|
|
1878
|
+
recordFlush() {
|
|
1879
|
+
this.metrics.lastFlushTime = Date.now();
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Get current metrics
|
|
1883
|
+
*/
|
|
1884
|
+
getMetrics() {
|
|
1885
|
+
return {
|
|
1886
|
+
...this.metrics,
|
|
1887
|
+
errors: [...this.metrics.errors], // Return copy
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Reset metrics
|
|
1892
|
+
*/
|
|
1893
|
+
reset() {
|
|
1894
|
+
this.metrics = {
|
|
1895
|
+
eventsSent: 0,
|
|
1896
|
+
eventsQueued: 0,
|
|
1897
|
+
eventsFailed: 0,
|
|
1898
|
+
eventsFiltered: 0,
|
|
1899
|
+
averageSendTime: 0,
|
|
1900
|
+
retryCount: 0,
|
|
1901
|
+
queueSize: 0,
|
|
1902
|
+
lastFlushTime: null,
|
|
1903
|
+
errors: [],
|
|
1904
|
+
};
|
|
1905
|
+
this.sendTimes = [];
|
|
1906
|
+
logger.debug('Metrics reset');
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Get metrics summary (for logging)
|
|
1910
|
+
*/
|
|
1911
|
+
getSummary() {
|
|
1912
|
+
const m = this.metrics;
|
|
1913
|
+
return `Metrics: ${m.eventsSent} sent, ${m.eventsQueued} queued, ${m.eventsFailed} failed, ${m.eventsFiltered} filtered, ${m.retryCount} retries, avg send time: ${m.averageSendTime.toFixed(2)}ms`;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// Global metrics collector instance
|
|
1917
|
+
const metricsCollector = new MetricsCollector();
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Analytics Service
|
|
1921
|
+
* Sends analytics events to your backend API
|
|
1922
|
+
*
|
|
1923
|
+
* Supports both relative paths (e.g., '/api/analytics') and full URLs (e.g., 'https://your-server.com/api/analytics')
|
|
1924
|
+
*
|
|
1925
|
+
* Features:
|
|
1926
|
+
* - Event batching and queueing
|
|
1927
|
+
* - Automatic retry with exponential backoff
|
|
1928
|
+
* - Offline support with localStorage persistence
|
|
1929
|
+
* - Configurable logging levels
|
|
1930
|
+
*/
|
|
1931
|
+
class AnalyticsService {
|
|
1932
|
+
/**
|
|
1933
|
+
* Configure the analytics service
|
|
1934
|
+
*
|
|
1935
|
+
* @param config - Configuration object
|
|
1936
|
+
* @param config.apiEndpoint - Your backend API endpoint URL
|
|
1937
|
+
* @param config.batchSize - Events per batch (default: 10)
|
|
1938
|
+
* @param config.batchInterval - Flush interval in ms (default: 5000)
|
|
1939
|
+
* @param config.maxQueueSize - Max queued events (default: 100)
|
|
1940
|
+
* @param config.maxRetries - Max retry attempts (default: 3)
|
|
1941
|
+
* @param config.retryDelay - Initial retry delay in ms (default: 1000)
|
|
1942
|
+
* @param config.logLevel - Logging verbosity (default: 'warn')
|
|
1943
|
+
*
|
|
1944
|
+
* @example
|
|
1945
|
+
* ```typescript
|
|
1946
|
+
* // Basic configuration
|
|
1947
|
+
* AnalyticsService.configure({
|
|
1948
|
+
* apiEndpoint: 'https://api.yourcompany.com/analytics'
|
|
1949
|
+
* });
|
|
1950
|
+
*
|
|
1951
|
+
* // Advanced configuration
|
|
1952
|
+
* AnalyticsService.configure({
|
|
1953
|
+
* apiEndpoint: '/api/analytics',
|
|
1954
|
+
* batchSize: 20,
|
|
1955
|
+
* batchInterval: 10000,
|
|
1956
|
+
* maxRetries: 5,
|
|
1957
|
+
* logLevel: 'info'
|
|
1958
|
+
* });
|
|
1959
|
+
* ```
|
|
1960
|
+
*/
|
|
1961
|
+
static configure(config) {
|
|
1962
|
+
this.apiEndpoint = config.apiEndpoint;
|
|
1963
|
+
this.config = {
|
|
1964
|
+
batchSize: 10,
|
|
1965
|
+
batchInterval: 5000,
|
|
1966
|
+
maxQueueSize: 100,
|
|
1967
|
+
maxRetries: 3,
|
|
1968
|
+
retryDelay: 1000,
|
|
1969
|
+
logLevel: 'warn',
|
|
1970
|
+
...config,
|
|
1971
|
+
};
|
|
1972
|
+
// Set log level
|
|
1973
|
+
if (this.config.logLevel) {
|
|
1974
|
+
logger.setLevel(this.config.logLevel);
|
|
1975
|
+
}
|
|
1976
|
+
// Initialize queue manager
|
|
1977
|
+
this.initializeQueue();
|
|
1978
|
+
// Store endpoint for sendBeacon
|
|
1979
|
+
if (typeof window !== 'undefined') {
|
|
1980
|
+
window.__analyticsEndpoint = this.apiEndpoint;
|
|
1981
|
+
}
|
|
1982
|
+
// Reset metrics if enabled
|
|
1983
|
+
if (this.config.enableMetrics) {
|
|
1984
|
+
metricsCollector.reset();
|
|
1985
|
+
}
|
|
1986
|
+
this.isInitialized = true;
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Initialize the queue manager
|
|
1990
|
+
*/
|
|
1991
|
+
static initializeQueue() {
|
|
1992
|
+
if (typeof window === 'undefined')
|
|
1993
|
+
return;
|
|
1994
|
+
const batchSize = this.config.batchSize ?? 10;
|
|
1995
|
+
const batchInterval = this.config.batchInterval ?? 5000;
|
|
1996
|
+
const maxQueueSize = this.config.maxQueueSize ?? 100;
|
|
1997
|
+
this.queueManager = new QueueManager({
|
|
1998
|
+
batchSize,
|
|
1999
|
+
batchInterval,
|
|
2000
|
+
maxQueueSize,
|
|
2001
|
+
storageKey: 'analytics:eventQueue',
|
|
2002
|
+
});
|
|
2003
|
+
// Set flush callback
|
|
2004
|
+
this.queueManager.setFlushCallback(async (events) => {
|
|
2005
|
+
await this.sendBatch(events);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Get queue manager instance
|
|
2010
|
+
*/
|
|
2011
|
+
static getQueueManager() {
|
|
2012
|
+
return this.queueManager;
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Generate a random event ID
|
|
2016
|
+
*/
|
|
2017
|
+
static generateEventId() {
|
|
2018
|
+
const arr = new Uint32Array(4);
|
|
2019
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
2020
|
+
crypto.getRandomValues(arr);
|
|
2021
|
+
return Array.from(arr)
|
|
2022
|
+
.map((n) => n.toString(16))
|
|
2023
|
+
.join('');
|
|
2024
|
+
}
|
|
2025
|
+
// Fallback for environments without crypto
|
|
2026
|
+
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Send a batch of events with retry logic
|
|
2030
|
+
*/
|
|
2031
|
+
static async sendBatch(events) {
|
|
2032
|
+
if (events.length === 0)
|
|
2033
|
+
return;
|
|
2034
|
+
// Apply plugin transformations
|
|
2035
|
+
const transformedEvents = [];
|
|
2036
|
+
for (const event of events) {
|
|
2037
|
+
const transformed = await pluginManager.executeBeforeSend(event);
|
|
2038
|
+
if (transformed) {
|
|
2039
|
+
transformedEvents.push(transformed);
|
|
2040
|
+
}
|
|
2041
|
+
else {
|
|
2042
|
+
if (this.config.enableMetrics) {
|
|
2043
|
+
metricsCollector.recordFiltered();
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (transformedEvents.length === 0) {
|
|
2048
|
+
logger.debug('All events filtered out by plugins');
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
const maxRetries = this.config.maxRetries ?? 3;
|
|
2052
|
+
const retryDelay = this.config.retryDelay ?? 1000;
|
|
2053
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2054
|
+
try {
|
|
2055
|
+
const res = await fetch(this.apiEndpoint, {
|
|
2056
|
+
method: 'POST',
|
|
2057
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2058
|
+
keepalive: true,
|
|
2059
|
+
body: JSON.stringify(transformedEvents),
|
|
2060
|
+
});
|
|
2061
|
+
if (res.ok) {
|
|
2062
|
+
const sendTime = Date.now(); // Approximate send time
|
|
2063
|
+
logger.debug(`Successfully sent batch of ${transformedEvents.length} events`);
|
|
2064
|
+
// Record metrics
|
|
2065
|
+
if (this.config.enableMetrics) {
|
|
2066
|
+
for (let i = 0; i < transformedEvents.length; i++) {
|
|
2067
|
+
metricsCollector.recordSent(sendTime);
|
|
2068
|
+
}
|
|
2069
|
+
metricsCollector.recordFlush();
|
|
2070
|
+
}
|
|
2071
|
+
// Execute afterSend hooks
|
|
2072
|
+
for (const event of transformedEvents) {
|
|
2073
|
+
await pluginManager.executeAfterSend(event);
|
|
2074
|
+
}
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
// Don't retry on client errors (4xx)
|
|
2078
|
+
if (res.status >= 400 && res.status < 500) {
|
|
2079
|
+
const errorText = await res.text().catch(() => 'Unknown error');
|
|
2080
|
+
logger.warn(`Client error (${res.status}): ${errorText}`);
|
|
2081
|
+
// Record metrics
|
|
2082
|
+
if (this.config.enableMetrics) {
|
|
2083
|
+
const error = new Error(`Client error: ${errorText}`);
|
|
2084
|
+
for (let i = 0; i < transformedEvents.length; i++) {
|
|
2085
|
+
metricsCollector.recordFailed(error);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
// Retry on server errors (5xx) or network errors
|
|
2091
|
+
if (attempt < maxRetries) {
|
|
2092
|
+
const delay = retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
2093
|
+
logger.debug(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
|
|
2094
|
+
if (this.config.enableMetrics) {
|
|
2095
|
+
metricsCollector.recordRetry();
|
|
2096
|
+
}
|
|
2097
|
+
await this.sleep(delay);
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
const errorText = await res.text().catch(() => 'Unknown error');
|
|
2101
|
+
const error = new Error(`Failed after ${maxRetries} retries: ${errorText}`);
|
|
2102
|
+
// Record metrics
|
|
2103
|
+
if (this.config.enableMetrics) {
|
|
2104
|
+
for (let i = 0; i < transformedEvents.length; i++) {
|
|
2105
|
+
metricsCollector.recordFailed(error);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
// Execute onError hooks
|
|
2109
|
+
for (const event of transformedEvents) {
|
|
2110
|
+
await pluginManager.executeOnError(event, error);
|
|
2111
|
+
}
|
|
2112
|
+
throw error;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
catch (err) {
|
|
2116
|
+
// Network error - retry if attempts remaining
|
|
2117
|
+
if (attempt < maxRetries) {
|
|
2118
|
+
const delay = retryDelay * Math.pow(2, attempt);
|
|
2119
|
+
logger.debug(`Network error, retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
|
|
2120
|
+
if (this.config.enableMetrics) {
|
|
2121
|
+
metricsCollector.recordRetry();
|
|
2122
|
+
}
|
|
2123
|
+
await this.sleep(delay);
|
|
2124
|
+
}
|
|
2125
|
+
else {
|
|
2126
|
+
logger.error(`Failed to send batch after ${maxRetries} retries:`, err);
|
|
2127
|
+
// Record metrics
|
|
2128
|
+
if (this.config.enableMetrics) {
|
|
2129
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2130
|
+
for (let i = 0; i < transformedEvents.length; i++) {
|
|
2131
|
+
metricsCollector.recordFailed(error);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
// Execute onError hooks
|
|
2135
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2136
|
+
for (const event of transformedEvents) {
|
|
2137
|
+
await pluginManager.executeOnError(event, error);
|
|
2138
|
+
}
|
|
2139
|
+
throw err;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Sleep utility for retry delays
|
|
2146
|
+
*/
|
|
2147
|
+
static sleep(ms) {
|
|
2148
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Track user journey/analytics event
|
|
2152
|
+
* Events are automatically queued and batched
|
|
2153
|
+
*/
|
|
2154
|
+
static async trackEvent(event) {
|
|
2155
|
+
const payload = {
|
|
2156
|
+
...event,
|
|
2157
|
+
timestamp: new Date(),
|
|
2158
|
+
eventId: this.generateEventId(),
|
|
2159
|
+
};
|
|
2160
|
+
// If queue is available, use it (browser environment)
|
|
2161
|
+
if (this.queueManager && typeof window !== 'undefined') {
|
|
2162
|
+
this.queueManager.enqueue(payload);
|
|
2163
|
+
// Record metrics
|
|
2164
|
+
if (this.config.enableMetrics) {
|
|
2165
|
+
metricsCollector.recordQueued();
|
|
2166
|
+
metricsCollector.updateQueueSize(this.queueManager.getQueueSize());
|
|
2167
|
+
}
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
// Fallback: send immediately (SSR or queue not initialized)
|
|
2171
|
+
try {
|
|
2172
|
+
await this.sendBatch([payload]);
|
|
2173
|
+
}
|
|
2174
|
+
catch (err) {
|
|
2175
|
+
logger.warn('Failed to send event:', err);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Track user journey with full context
|
|
2180
|
+
*/
|
|
2181
|
+
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
|
|
2182
|
+
await this.trackEvent({
|
|
2183
|
+
sessionId,
|
|
2184
|
+
pageUrl,
|
|
2185
|
+
networkInfo,
|
|
2186
|
+
deviceInfo,
|
|
2187
|
+
location,
|
|
2188
|
+
attribution,
|
|
2189
|
+
ipLocation,
|
|
2190
|
+
userId: userId ?? sessionId,
|
|
2191
|
+
customData: {
|
|
2192
|
+
...customData,
|
|
2193
|
+
...(ipLocation && { ipLocation }),
|
|
2194
|
+
},
|
|
1267
2195
|
eventName: 'page_view', // Auto-tracked as page view
|
|
1268
2196
|
});
|
|
1269
2197
|
}
|
|
@@ -1373,8 +2301,123 @@ class AnalyticsService {
|
|
|
1373
2301
|
...parameters,
|
|
1374
2302
|
});
|
|
1375
2303
|
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Manually flush the event queue
|
|
2306
|
+
* Useful for ensuring events are sent before page unload
|
|
2307
|
+
*/
|
|
2308
|
+
static async flushQueue() {
|
|
2309
|
+
if (this.queueManager) {
|
|
2310
|
+
await this.queueManager.flush();
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Get current queue size
|
|
2315
|
+
*/
|
|
2316
|
+
static getQueueSize() {
|
|
2317
|
+
return this.queueManager?.getQueueSize() ?? 0;
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Clear the event queue
|
|
2321
|
+
*/
|
|
2322
|
+
static clearQueue() {
|
|
2323
|
+
this.queueManager?.clear();
|
|
2324
|
+
if (this.config.enableMetrics) {
|
|
2325
|
+
metricsCollector.updateQueueSize(0);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Get metrics (if enabled)
|
|
2330
|
+
*/
|
|
2331
|
+
static getMetrics() {
|
|
2332
|
+
if (!this.config.enableMetrics) {
|
|
2333
|
+
logger.warn('Metrics collection is not enabled. Set enableMetrics: true in config.');
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
return metricsCollector.getMetrics();
|
|
2337
|
+
}
|
|
1376
2338
|
}
|
|
1377
2339
|
AnalyticsService.apiEndpoint = '/api/analytics';
|
|
2340
|
+
AnalyticsService.queueManager = null;
|
|
2341
|
+
AnalyticsService.config = {};
|
|
2342
|
+
AnalyticsService.isInitialized = false;
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Debug utilities for analytics tracker
|
|
2346
|
+
* Provides debugging tools in development mode
|
|
2347
|
+
*/
|
|
2348
|
+
/**
|
|
2349
|
+
* Initialize debug utilities (only in development)
|
|
2350
|
+
*/
|
|
2351
|
+
function initDebug() {
|
|
2352
|
+
if (typeof window === 'undefined')
|
|
2353
|
+
return;
|
|
2354
|
+
const isDevelopment = typeof process !== 'undefined' &&
|
|
2355
|
+
process.env?.NODE_ENV === 'development';
|
|
2356
|
+
if (!isDevelopment) {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
const debug = {
|
|
2360
|
+
/**
|
|
2361
|
+
* Get current queue state
|
|
2362
|
+
*/
|
|
2363
|
+
getQueue: () => {
|
|
2364
|
+
const queueManager = AnalyticsService.getQueueManager();
|
|
2365
|
+
if (!queueManager) {
|
|
2366
|
+
logger.warn('Queue manager not initialized');
|
|
2367
|
+
return [];
|
|
2368
|
+
}
|
|
2369
|
+
return queueManager.getQueue();
|
|
2370
|
+
},
|
|
2371
|
+
/**
|
|
2372
|
+
* Get queue size
|
|
2373
|
+
*/
|
|
2374
|
+
getQueueSize: () => {
|
|
2375
|
+
return AnalyticsService.getQueueSize();
|
|
2376
|
+
},
|
|
2377
|
+
/**
|
|
2378
|
+
* Manually flush the queue
|
|
2379
|
+
*/
|
|
2380
|
+
flushQueue: async () => {
|
|
2381
|
+
logger.info('Manually flushing queue...');
|
|
2382
|
+
await AnalyticsService.flushQueue();
|
|
2383
|
+
logger.info('Queue flushed');
|
|
2384
|
+
},
|
|
2385
|
+
/**
|
|
2386
|
+
* Clear the queue
|
|
2387
|
+
*/
|
|
2388
|
+
clearQueue: () => {
|
|
2389
|
+
logger.info('Clearing queue...');
|
|
2390
|
+
AnalyticsService.clearQueue();
|
|
2391
|
+
logger.info('Queue cleared');
|
|
2392
|
+
},
|
|
2393
|
+
/**
|
|
2394
|
+
* Get debug statistics
|
|
2395
|
+
*/
|
|
2396
|
+
getStats: () => {
|
|
2397
|
+
const queueManager = AnalyticsService.getQueueManager();
|
|
2398
|
+
const metrics = AnalyticsService.getMetrics();
|
|
2399
|
+
return {
|
|
2400
|
+
queueSize: AnalyticsService.getQueueSize(),
|
|
2401
|
+
queue: queueManager?.getQueue() ?? [],
|
|
2402
|
+
config: {
|
|
2403
|
+
metrics: metrics,
|
|
2404
|
+
metricsSummary: metrics ? metricsCollector.getSummary() : 'Metrics disabled',
|
|
2405
|
+
},
|
|
2406
|
+
};
|
|
2407
|
+
},
|
|
2408
|
+
/**
|
|
2409
|
+
* Set log level
|
|
2410
|
+
*/
|
|
2411
|
+
setLogLevel: (level) => {
|
|
2412
|
+
logger.setLevel(level);
|
|
2413
|
+
logger.info(`Log level set to: ${level}`);
|
|
2414
|
+
},
|
|
2415
|
+
};
|
|
2416
|
+
// Expose to window for console access
|
|
2417
|
+
window.__analyticsDebug = debug;
|
|
2418
|
+
logger.info('Analytics debug tools available at window.__analyticsDebug');
|
|
2419
|
+
logger.info('Available methods: getQueue(), getQueueSize(), flushQueue(), clearQueue(), getStats(), setLogLevel()');
|
|
2420
|
+
}
|
|
1378
2421
|
|
|
1379
2422
|
/**
|
|
1380
2423
|
* React Hook for Analytics Tracking
|
|
@@ -1396,9 +2439,29 @@ function useAnalytics(options = {}) {
|
|
|
1396
2439
|
// Configure analytics service if endpoint provided
|
|
1397
2440
|
react.useEffect(() => {
|
|
1398
2441
|
if (config?.apiEndpoint) {
|
|
1399
|
-
AnalyticsService.configure({
|
|
2442
|
+
AnalyticsService.configure({
|
|
2443
|
+
apiEndpoint: config.apiEndpoint,
|
|
2444
|
+
batchSize: config.batchSize,
|
|
2445
|
+
batchInterval: config.batchInterval,
|
|
2446
|
+
maxQueueSize: config.maxQueueSize,
|
|
2447
|
+
maxRetries: config.maxRetries,
|
|
2448
|
+
retryDelay: config.retryDelay,
|
|
2449
|
+
logLevel: config.logLevel,
|
|
2450
|
+
enableMetrics: config.enableMetrics,
|
|
2451
|
+
sessionTimeout: config.sessionTimeout,
|
|
2452
|
+
});
|
|
1400
2453
|
}
|
|
1401
|
-
}, [
|
|
2454
|
+
}, [
|
|
2455
|
+
config?.apiEndpoint,
|
|
2456
|
+
config?.batchSize,
|
|
2457
|
+
config?.batchInterval,
|
|
2458
|
+
config?.maxQueueSize,
|
|
2459
|
+
config?.maxRetries,
|
|
2460
|
+
config?.retryDelay,
|
|
2461
|
+
config?.logLevel,
|
|
2462
|
+
config?.enableMetrics,
|
|
2463
|
+
config?.sessionTimeout,
|
|
2464
|
+
]);
|
|
1402
2465
|
const [networkInfo, setNetworkInfo] = react.useState(null);
|
|
1403
2466
|
const [deviceInfo, setDeviceInfo] = react.useState(null);
|
|
1404
2467
|
const [attribution, setAttribution] = react.useState(null);
|
|
@@ -1423,6 +2486,10 @@ function useAnalytics(options = {}) {
|
|
|
1423
2486
|
};
|
|
1424
2487
|
}
|
|
1425
2488
|
}, []);
|
|
2489
|
+
// Initialize debug tools in development
|
|
2490
|
+
react.useEffect(() => {
|
|
2491
|
+
initDebug();
|
|
2492
|
+
}, []);
|
|
1426
2493
|
const refresh = react.useCallback(async () => {
|
|
1427
2494
|
const net = NetworkDetector.detect();
|
|
1428
2495
|
const dev = await DeviceDetector.detect();
|
|
@@ -1495,6 +2562,8 @@ function useAnalytics(options = {}) {
|
|
|
1495
2562
|
if (autoSend) {
|
|
1496
2563
|
// Send after idle to not block paint
|
|
1497
2564
|
const send = async () => {
|
|
2565
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2566
|
+
const ipLocationData = loc?.ipLocationData;
|
|
1498
2567
|
await AnalyticsService.trackUserJourney({
|
|
1499
2568
|
sessionId: getOrCreateUserId(),
|
|
1500
2569
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -1502,6 +2571,7 @@ function useAnalytics(options = {}) {
|
|
|
1502
2571
|
deviceInfo: dev,
|
|
1503
2572
|
location: loc,
|
|
1504
2573
|
attribution: attr,
|
|
2574
|
+
ipLocation: ipLocationData,
|
|
1505
2575
|
customData: config?.enableLocation ? { locationEnabled: true } : undefined,
|
|
1506
2576
|
});
|
|
1507
2577
|
};
|
|
@@ -1517,6 +2587,8 @@ function useAnalytics(options = {}) {
|
|
|
1517
2587
|
const logEvent = react.useCallback(async (customData) => {
|
|
1518
2588
|
if (!sessionId || !networkInfo || !deviceInfo)
|
|
1519
2589
|
return;
|
|
2590
|
+
// Extract IP location data if available (stored in ipLocationData field)
|
|
2591
|
+
const ipLocationData = location ? location?.ipLocationData : undefined;
|
|
1520
2592
|
await AnalyticsService.trackUserJourney({
|
|
1521
2593
|
sessionId,
|
|
1522
2594
|
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
|
@@ -1524,6 +2596,7 @@ function useAnalytics(options = {}) {
|
|
|
1524
2596
|
deviceInfo,
|
|
1525
2597
|
location: location ?? undefined,
|
|
1526
2598
|
attribution: attribution ?? undefined,
|
|
2599
|
+
ipLocation: ipLocationData,
|
|
1527
2600
|
userId: sessionId,
|
|
1528
2601
|
customData,
|
|
1529
2602
|
});
|
|
@@ -1621,6 +2694,17 @@ function useAnalytics(options = {}) {
|
|
|
1621
2694
|
const incrementInteraction = react.useCallback(() => {
|
|
1622
2695
|
setInteractions((n) => n + 1);
|
|
1623
2696
|
}, []);
|
|
2697
|
+
// Session management
|
|
2698
|
+
react.useEffect(() => {
|
|
2699
|
+
if (config?.sessionTimeout) {
|
|
2700
|
+
getOrCreateSession(config.sessionTimeout);
|
|
2701
|
+
// Update session activity on user interactions
|
|
2702
|
+
const activityInterval = setInterval(() => {
|
|
2703
|
+
updateSessionActivity();
|
|
2704
|
+
}, 60000); // Update every minute
|
|
2705
|
+
return () => clearInterval(activityInterval);
|
|
2706
|
+
}
|
|
2707
|
+
}, [config?.sessionTimeout]);
|
|
1624
2708
|
return react.useMemo(() => ({
|
|
1625
2709
|
sessionId,
|
|
1626
2710
|
networkInfo,
|
|
@@ -1650,186 +2734,32 @@ function useAnalytics(options = {}) {
|
|
|
1650
2734
|
]);
|
|
1651
2735
|
}
|
|
1652
2736
|
|
|
1653
|
-
/**
|
|
1654
|
-
* IP Geolocation Service
|
|
1655
|
-
* Fetches location data (country, region, city) from user's IP address
|
|
1656
|
-
* Uses free tier of ip-api.com (no API key required, 45 requests/minute)
|
|
1657
|
-
*/
|
|
1658
|
-
/**
|
|
1659
|
-
* Get public IP address using ip-api.com
|
|
1660
|
-
* Free tier: 45 requests/minute, no API key required
|
|
1661
|
-
*
|
|
1662
|
-
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
1663
|
-
*
|
|
1664
|
-
* @example
|
|
1665
|
-
* ```typescript
|
|
1666
|
-
* const ip = await getPublicIP();
|
|
1667
|
-
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
1668
|
-
* ```
|
|
1669
|
-
*/
|
|
1670
|
-
async function getPublicIP() {
|
|
1671
|
-
// Skip if we're in an environment without fetch (SSR)
|
|
1672
|
-
if (typeof fetch === 'undefined') {
|
|
1673
|
-
return null;
|
|
1674
|
-
}
|
|
1675
|
-
try {
|
|
1676
|
-
// Call ip-api.com without IP parameter - it auto-detects user's IP
|
|
1677
|
-
// Using HTTPS endpoint for better security
|
|
1678
|
-
const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
|
|
1679
|
-
method: 'GET',
|
|
1680
|
-
headers: {
|
|
1681
|
-
Accept: 'application/json',
|
|
1682
|
-
},
|
|
1683
|
-
// Add timeout to prevent hanging
|
|
1684
|
-
signal: AbortSignal.timeout(5000),
|
|
1685
|
-
});
|
|
1686
|
-
if (!response.ok) {
|
|
1687
|
-
return null;
|
|
1688
|
-
}
|
|
1689
|
-
const data = await response.json();
|
|
1690
|
-
// ip-api.com returns status field
|
|
1691
|
-
if (data.status === 'fail') {
|
|
1692
|
-
return null;
|
|
1693
|
-
}
|
|
1694
|
-
return data.query || null;
|
|
1695
|
-
}
|
|
1696
|
-
catch (error) {
|
|
1697
|
-
// Silently fail - don't break user experience
|
|
1698
|
-
if (error.name !== 'AbortError') {
|
|
1699
|
-
console.warn('[IP Geolocation] Error fetching public IP:', error.message);
|
|
1700
|
-
}
|
|
1701
|
-
return null;
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
/**
|
|
1705
|
-
* Get location from IP address using ip-api.com
|
|
1706
|
-
* Free tier: 45 requests/minute, no API key required
|
|
1707
|
-
*
|
|
1708
|
-
* Alternative services:
|
|
1709
|
-
* - ipapi.co (requires API key for production)
|
|
1710
|
-
* - ipgeolocation.io (requires API key)
|
|
1711
|
-
* - ip-api.com (free tier available)
|
|
1712
|
-
*/
|
|
1713
|
-
async function getIPLocation(ip) {
|
|
1714
|
-
// Skip localhost/private IPs (these can't be geolocated)
|
|
1715
|
-
if (!ip ||
|
|
1716
|
-
ip === '0.0.0.0' ||
|
|
1717
|
-
ip === '::1' ||
|
|
1718
|
-
ip.startsWith('127.') ||
|
|
1719
|
-
ip.startsWith('192.168.') ||
|
|
1720
|
-
ip.startsWith('10.') ||
|
|
1721
|
-
ip.startsWith('172.') ||
|
|
1722
|
-
ip.startsWith('::ffff:127.')) {
|
|
1723
|
-
console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
|
|
1724
|
-
return null;
|
|
1725
|
-
}
|
|
1726
|
-
try {
|
|
1727
|
-
// Using ip-api.com free tier (JSON format)
|
|
1728
|
-
const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,isp,org,as,query`, {
|
|
1729
|
-
method: 'GET',
|
|
1730
|
-
headers: {
|
|
1731
|
-
Accept: 'application/json',
|
|
1732
|
-
},
|
|
1733
|
-
// Add timeout to prevent hanging
|
|
1734
|
-
signal: AbortSignal.timeout(3000),
|
|
1735
|
-
});
|
|
1736
|
-
if (!response.ok) {
|
|
1737
|
-
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
1738
|
-
return null;
|
|
1739
|
-
}
|
|
1740
|
-
const data = await response.json();
|
|
1741
|
-
// ip-api.com returns status field
|
|
1742
|
-
if (data.status === 'fail') {
|
|
1743
|
-
console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
|
|
1744
|
-
return null;
|
|
1745
|
-
}
|
|
1746
|
-
return {
|
|
1747
|
-
ip: data.query || ip,
|
|
1748
|
-
country: data.country || undefined,
|
|
1749
|
-
countryCode: data.countryCode || undefined,
|
|
1750
|
-
region: data.region || undefined,
|
|
1751
|
-
regionName: data.regionName || undefined,
|
|
1752
|
-
city: data.city || undefined,
|
|
1753
|
-
lat: data.lat || undefined,
|
|
1754
|
-
lon: data.lon || undefined,
|
|
1755
|
-
timezone: data.timezone || undefined,
|
|
1756
|
-
isp: data.isp || undefined,
|
|
1757
|
-
org: data.org || undefined,
|
|
1758
|
-
as: data.as || undefined,
|
|
1759
|
-
query: data.query || ip,
|
|
1760
|
-
};
|
|
1761
|
-
}
|
|
1762
|
-
catch (error) {
|
|
1763
|
-
// Silently fail - don't break user experience
|
|
1764
|
-
if (error.name !== 'AbortError') {
|
|
1765
|
-
console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
|
|
1766
|
-
}
|
|
1767
|
-
return null;
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Get IP address from request headers
|
|
1772
|
-
* Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
|
|
1773
|
-
*/
|
|
1774
|
-
function getIPFromRequest(req) {
|
|
1775
|
-
// Try various headers that proxies/load balancers use
|
|
1776
|
-
const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
|
|
1777
|
-
req.headers?.['x-forwarded-for'] ||
|
|
1778
|
-
req.headers?.['X-Forwarded-For'];
|
|
1779
|
-
if (forwardedFor) {
|
|
1780
|
-
// x-forwarded-for can contain multiple IPs, take the first one
|
|
1781
|
-
const ips = forwardedFor.split(',').map((ip) => ip.trim());
|
|
1782
|
-
const ip = ips[0];
|
|
1783
|
-
if (ip && ip !== '0.0.0.0') {
|
|
1784
|
-
return ip;
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
const realIP = req.headers?.get?.('x-real-ip') ||
|
|
1788
|
-
req.headers?.['x-real-ip'] ||
|
|
1789
|
-
req.headers?.['X-Real-IP'];
|
|
1790
|
-
if (realIP && realIP !== '0.0.0.0') {
|
|
1791
|
-
return realIP.trim();
|
|
1792
|
-
}
|
|
1793
|
-
// Try req.ip (from Express/Next.js)
|
|
1794
|
-
if (req.ip && req.ip !== '0.0.0.0') {
|
|
1795
|
-
return req.ip;
|
|
1796
|
-
}
|
|
1797
|
-
// For localhost, detect if we're running locally
|
|
1798
|
-
if (typeof window === 'undefined') {
|
|
1799
|
-
const hostname = req.headers?.get?.('host') || req.headers?.['host'];
|
|
1800
|
-
if (hostname &&
|
|
1801
|
-
(hostname.includes('localhost') ||
|
|
1802
|
-
hostname.includes('127.0.0.1') ||
|
|
1803
|
-
hostname.startsWith('192.168.'))) {
|
|
1804
|
-
return '127.0.0.1'; // Localhost IP
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
// If no IP found and we're in development, return localhost
|
|
1808
|
-
if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
|
|
1809
|
-
return '127.0.0.1'; // Localhost for development
|
|
1810
|
-
}
|
|
1811
|
-
return '0.0.0.0';
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
2737
|
exports.AnalyticsService = AnalyticsService;
|
|
1815
2738
|
exports.AttributionDetector = AttributionDetector;
|
|
1816
2739
|
exports.DeviceDetector = DeviceDetector;
|
|
1817
2740
|
exports.LocationDetector = LocationDetector;
|
|
1818
2741
|
exports.NetworkDetector = NetworkDetector;
|
|
2742
|
+
exports.QueueManager = QueueManager;
|
|
1819
2743
|
exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
|
|
1820
2744
|
exports.clearLocationConsent = clearLocationConsent;
|
|
2745
|
+
exports.clearSession = clearSession;
|
|
1821
2746
|
exports.default = useAnalytics;
|
|
1822
2747
|
exports.getIPFromRequest = getIPFromRequest;
|
|
1823
2748
|
exports.getIPLocation = getIPLocation;
|
|
1824
2749
|
exports.getLocationConsentTimestamp = getLocationConsentTimestamp;
|
|
2750
|
+
exports.getOrCreateSession = getOrCreateSession;
|
|
1825
2751
|
exports.getOrCreateUserId = getOrCreateUserId;
|
|
1826
2752
|
exports.getPublicIP = getPublicIP;
|
|
2753
|
+
exports.getSession = getSession;
|
|
1827
2754
|
exports.hasLocationConsent = hasLocationConsent;
|
|
2755
|
+
exports.initDebug = initDebug;
|
|
1828
2756
|
exports.loadJSON = loadJSON;
|
|
1829
2757
|
exports.loadSessionJSON = loadSessionJSON;
|
|
2758
|
+
exports.logger = logger;
|
|
1830
2759
|
exports.saveJSON = saveJSON;
|
|
1831
2760
|
exports.saveSessionJSON = saveSessionJSON;
|
|
1832
2761
|
exports.setLocationConsentGranted = setLocationConsentGranted;
|
|
1833
2762
|
exports.trackPageVisit = trackPageVisit;
|
|
2763
|
+
exports.updateSessionActivity = updateSessionActivity;
|
|
1834
2764
|
exports.useAnalytics = useAnalytics;
|
|
1835
2765
|
//# sourceMappingURL=index.cjs.js.map
|