user-analytics-tracker 2.0.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;
@@ -2376,6 +2558,8 @@ function useAnalytics(options = {}) {
2376
2558
  if (autoSend) {
2377
2559
  // Send after idle to not block paint
2378
2560
  const send = async () => {
2561
+ // Extract IP location data if available (stored in ipLocationData field)
2562
+ const ipLocationData = loc?.ipLocationData;
2379
2563
  await AnalyticsService.trackUserJourney({
2380
2564
  sessionId: getOrCreateUserId(),
2381
2565
  pageUrl: typeof window !== 'undefined' ? window.location.href : '',
@@ -2383,6 +2567,7 @@ function useAnalytics(options = {}) {
2383
2567
  deviceInfo: dev,
2384
2568
  location: loc,
2385
2569
  attribution: attr,
2570
+ ipLocation: ipLocationData,
2386
2571
  customData: config?.enableLocation ? { locationEnabled: true } : undefined,
2387
2572
  });
2388
2573
  };
@@ -2398,6 +2583,8 @@ function useAnalytics(options = {}) {
2398
2583
  const logEvent = useCallback(async (customData) => {
2399
2584
  if (!sessionId || !networkInfo || !deviceInfo)
2400
2585
  return;
2586
+ // Extract IP location data if available (stored in ipLocationData field)
2587
+ const ipLocationData = location ? location?.ipLocationData : undefined;
2401
2588
  await AnalyticsService.trackUserJourney({
2402
2589
  sessionId,
2403
2590
  pageUrl: typeof window !== 'undefined' ? window.location.href : '',
@@ -2405,6 +2592,7 @@ function useAnalytics(options = {}) {
2405
2592
  deviceInfo,
2406
2593
  location: location ?? undefined,
2407
2594
  attribution: attribution ?? undefined,
2595
+ ipLocation: ipLocationData,
2408
2596
  userId: sessionId,
2409
2597
  customData,
2410
2598
  });
@@ -2542,166 +2730,5 @@ function useAnalytics(options = {}) {
2542
2730
  ]);
2543
2731
  }
2544
2732
 
2545
- /**
2546
- * IP Geolocation Service
2547
- * Fetches location data (country, region, city) from user's IP address
2548
- * Uses free tier of ip-api.com (no API key required, 45 requests/minute)
2549
- */
2550
- /**
2551
- * Get public IP address using ip-api.com
2552
- * Free tier: 45 requests/minute, no API key required
2553
- *
2554
- * @returns Promise<string | null> - The public IP address, or null if unavailable
2555
- *
2556
- * @example
2557
- * ```typescript
2558
- * const ip = await getPublicIP();
2559
- * console.log('Your IP:', ip); // e.g., "203.0.113.42"
2560
- * ```
2561
- */
2562
- async function getPublicIP() {
2563
- // Skip if we're in an environment without fetch (SSR)
2564
- if (typeof fetch === 'undefined') {
2565
- return null;
2566
- }
2567
- try {
2568
- // Call ip-api.com without IP parameter - it auto-detects user's IP
2569
- // Using HTTPS endpoint for better security
2570
- const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
2571
- method: 'GET',
2572
- headers: {
2573
- Accept: 'application/json',
2574
- },
2575
- // Add timeout to prevent hanging
2576
- signal: AbortSignal.timeout(5000),
2577
- });
2578
- if (!response.ok) {
2579
- return null;
2580
- }
2581
- const data = await response.json();
2582
- // ip-api.com returns status field
2583
- if (data.status === 'fail') {
2584
- return null;
2585
- }
2586
- return data.query || null;
2587
- }
2588
- catch (error) {
2589
- // Silently fail - don't break user experience
2590
- if (error.name !== 'AbortError') {
2591
- console.warn('[IP Geolocation] Error fetching public IP:', error.message);
2592
- }
2593
- return null;
2594
- }
2595
- }
2596
- /**
2597
- * Get location from IP address using ip-api.com
2598
- * Free tier: 45 requests/minute, no API key required
2599
- *
2600
- * Alternative services:
2601
- * - ipapi.co (requires API key for production)
2602
- * - ipgeolocation.io (requires API key)
2603
- * - ip-api.com (free tier available)
2604
- */
2605
- async function getIPLocation(ip) {
2606
- // Skip localhost/private IPs (these can't be geolocated)
2607
- if (!ip ||
2608
- ip === '0.0.0.0' ||
2609
- ip === '::1' ||
2610
- ip.startsWith('127.') ||
2611
- ip.startsWith('192.168.') ||
2612
- ip.startsWith('10.') ||
2613
- ip.startsWith('172.') ||
2614
- ip.startsWith('::ffff:127.')) {
2615
- console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
2616
- return null;
2617
- }
2618
- try {
2619
- // Using ip-api.com free tier (JSON format)
2620
- 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`, {
2621
- method: 'GET',
2622
- headers: {
2623
- Accept: 'application/json',
2624
- },
2625
- // Add timeout to prevent hanging
2626
- signal: AbortSignal.timeout(3000),
2627
- });
2628
- if (!response.ok) {
2629
- console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
2630
- return null;
2631
- }
2632
- const data = await response.json();
2633
- // ip-api.com returns status field
2634
- if (data.status === 'fail') {
2635
- console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
2636
- return null;
2637
- }
2638
- return {
2639
- ip: data.query || ip,
2640
- country: data.country || undefined,
2641
- countryCode: data.countryCode || undefined,
2642
- region: data.region || undefined,
2643
- regionName: data.regionName || undefined,
2644
- city: data.city || undefined,
2645
- lat: data.lat || undefined,
2646
- lon: data.lon || undefined,
2647
- timezone: data.timezone || undefined,
2648
- isp: data.isp || undefined,
2649
- org: data.org || undefined,
2650
- as: data.as || undefined,
2651
- query: data.query || ip,
2652
- };
2653
- }
2654
- catch (error) {
2655
- // Silently fail - don't break user experience
2656
- if (error.name !== 'AbortError') {
2657
- console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
2658
- }
2659
- return null;
2660
- }
2661
- }
2662
- /**
2663
- * Get IP address from request headers
2664
- * Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
2665
- */
2666
- function getIPFromRequest(req) {
2667
- // Try various headers that proxies/load balancers use
2668
- const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
2669
- req.headers?.['x-forwarded-for'] ||
2670
- req.headers?.['X-Forwarded-For'];
2671
- if (forwardedFor) {
2672
- // x-forwarded-for can contain multiple IPs, take the first one
2673
- const ips = forwardedFor.split(',').map((ip) => ip.trim());
2674
- const ip = ips[0];
2675
- if (ip && ip !== '0.0.0.0') {
2676
- return ip;
2677
- }
2678
- }
2679
- const realIP = req.headers?.get?.('x-real-ip') ||
2680
- req.headers?.['x-real-ip'] ||
2681
- req.headers?.['X-Real-IP'];
2682
- if (realIP && realIP !== '0.0.0.0') {
2683
- return realIP.trim();
2684
- }
2685
- // Try req.ip (from Express/Next.js)
2686
- if (req.ip && req.ip !== '0.0.0.0') {
2687
- return req.ip;
2688
- }
2689
- // For localhost, detect if we're running locally
2690
- if (typeof window === 'undefined') {
2691
- const hostname = req.headers?.get?.('host') || req.headers?.['host'];
2692
- if (hostname &&
2693
- (hostname.includes('localhost') ||
2694
- hostname.includes('127.0.0.1') ||
2695
- hostname.startsWith('192.168.'))) {
2696
- return '127.0.0.1'; // Localhost IP
2697
- }
2698
- }
2699
- // If no IP found and we're in development, return localhost
2700
- if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
2701
- return '127.0.0.1'; // Localhost for development
2702
- }
2703
- return '0.0.0.0';
2704
- }
2705
-
2706
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 };
2707
2734
  //# sourceMappingURL=index.esm.js.map