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/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 ip-api.com free tier (no API key required, 45 requests/minute)
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
- // Call ip-api.com without IP parameter - it auto-detects user's IP
857
- // Using HTTPS endpoint for better security
858
- const response = await fetch('https://ip-api.com/json/?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,query', {
859
- method: 'GET',
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
- const data = await response.json();
870
- // ip-api.com returns status field
871
- if (data.status === 'fail') {
872
- console.warn(`[Location] IP API error: ${data.message}`);
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: data.lat || null,
883
- lon: data.lon || null,
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: data.query || null, // Public IP address
889
- country: data.country || undefined,
890
- countryCode: data.countryCode || undefined,
891
- city: data.city || undefined,
892
- region: data.regionName || data.region || undefined,
893
- timezone: data.timezone || undefined,
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
- * Analytics Service
1179
- * Sends analytics events to your backend API
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 AnalyticsService {
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
- * Configure the analytics API endpoint
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
- static configure(config) {
1206
- this.apiEndpoint = config.apiEndpoint;
1503
+ setFlushCallback(callback) {
1504
+ this.flushCallback = callback;
1207
1505
  }
1208
1506
  /**
1209
- * Generate a random event ID
1507
+ * Add an event to the queue
1210
1508
  */
1211
- static generateEventId() {
1212
- const arr = new Uint32Array(4);
1213
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1214
- crypto.getRandomValues(arr);
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
- // Fallback for environments without crypto
1220
- return Math.random().toString(36).substring(2) + Date.now().toString(36);
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
- * Track user journey/analytics event
1530
+ * Flush events from the queue
1224
1531
  */
1225
- static async trackEvent(event) {
1226
- const payload = {
1227
- ...event,
1228
- timestamp: new Date(),
1229
- eventId: this.generateEventId(),
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 res = await fetch(this.apiEndpoint, {
1233
- method: 'POST',
1234
- headers: { 'Content-Type': 'application/json' },
1235
- keepalive: true, // Allows sending during unload on some browsers
1236
- body: JSON.stringify(payload),
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 (err) {
1246
- // Don't break user experience - silently fail
1247
- console.warn('[Analytics] Failed to send event:', err);
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
- * Track user journey with full context
1561
+ * Get current queue size
1252
1562
  */
1253
- static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
1254
- await this.trackEvent({
1255
- sessionId,
1256
- pageUrl,
1257
- networkInfo,
1258
- deviceInfo,
1259
- location,
1260
- attribution,
1261
- ipLocation,
1262
- userId: userId ?? sessionId,
1263
- customData: {
1264
- ...customData,
1265
- ...(ipLocation && { ipLocation }),
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({ apiEndpoint: config.apiEndpoint });
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
- }, [config?.apiEndpoint]);
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