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.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 ip-api.com free tier (no API key required, 45 requests/minute)
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
- // Call ip-api.com without IP parameter - it auto-detects user's IP
853
- // Using HTTPS endpoint for better security
854
- const response = await fetch('https://ip-api.com/json/?fields=status,message,country,countryCode,region,regionName,city,lat,lon,timezone,query', {
855
- method: 'GET',
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
- const data = await response.json();
866
- // ip-api.com returns status field
867
- if (data.status === 'fail') {
868
- console.warn(`[Location] IP API error: ${data.message}`);
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: data.lat || null,
879
- lon: data.lon || null,
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: data.query || null, // Public IP address
885
- country: data.country || undefined,
886
- countryCode: data.countryCode || undefined,
887
- city: data.city || undefined,
888
- region: data.regionName || data.region || undefined,
889
- timezone: data.timezone || undefined,
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
- * Analytics Service
1175
- * Sends analytics events to your backend API
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 AnalyticsService {
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
- * Configure the analytics API endpoint
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
- static configure(config) {
1202
- this.apiEndpoint = config.apiEndpoint;
1499
+ setFlushCallback(callback) {
1500
+ this.flushCallback = callback;
1203
1501
  }
1204
1502
  /**
1205
- * Generate a random event ID
1503
+ * Add an event to the queue
1206
1504
  */
1207
- static generateEventId() {
1208
- const arr = new Uint32Array(4);
1209
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1210
- crypto.getRandomValues(arr);
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
- // Fallback for environments without crypto
1216
- return Math.random().toString(36).substring(2) + Date.now().toString(36);
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
- * Track user journey/analytics event
1526
+ * Flush events from the queue
1220
1527
  */
1221
- static async trackEvent(event) {
1222
- const payload = {
1223
- ...event,
1224
- timestamp: new Date(),
1225
- eventId: this.generateEventId(),
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 res = await fetch(this.apiEndpoint, {
1229
- method: 'POST',
1230
- headers: { 'Content-Type': 'application/json' },
1231
- keepalive: true, // Allows sending during unload on some browsers
1232
- body: JSON.stringify(payload),
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 (err) {
1242
- // Don't break user experience - silently fail
1243
- console.warn('[Analytics] Failed to send event:', err);
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
- * Track user journey with full context
1557
+ * Get current queue size
1248
1558
  */
1249
- static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
1250
- await this.trackEvent({
1251
- sessionId,
1252
- pageUrl,
1253
- networkInfo,
1254
- deviceInfo,
1255
- location,
1256
- attribution,
1257
- ipLocation,
1258
- userId: userId ?? sessionId,
1259
- customData: {
1260
- ...customData,
1261
- ...(ipLocation && { ipLocation }),
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({ apiEndpoint: config.apiEndpoint });
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
- }, [config?.apiEndpoint]);
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