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