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/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ # [2.1.0](https://github.com/switch-org/analytics-tracker/compare/v2.0.0...v2.1.0) (2025-12-29)
2
+
3
+
4
+ ### Features
5
+
6
+ * migrate IP geolocation to ipwho.is API with dynamic key storage ([0605f72](https://github.com/switch-org/analytics-tracker/commit/0605f7207baa590b776a3b52cbc055e98a8a22e4))
7
+
8
+ # [2.1.0](https://github.com/switch-org/analytics-tracker/compare/v2.0.0...v2.1.0) (TBD)
9
+
10
+ ### Features
11
+
12
+ * **IP Geolocation API Migration**: Migrated from ip-api.com to ipwho.is API for IP-based location tracking
13
+ - More comprehensive location data including continent, flag, connection details, and timezone information
14
+ - Dynamic key storage: All API response keys are automatically stored, including nested objects
15
+ - Future-proof: New fields added by the API are automatically captured without code changes
16
+ - No API key required: Free tier with no authentication needed
17
+ * **Enhanced IP Location Data**: The `IPLocation` interface now includes all fields from the ipwho.is API response
18
+ - New fields: `continent`, `continent_code`, `flag`, `connection`, `timezone` (with full details), `is_eu`, `postal`, `calling_code`, `capital`, `borders`
19
+ - Full backward compatibility: All existing fields (`lat`, `lon`, `countryCode`, etc.) remain available
20
+ - Access to full IP location data in analytics events via `ipLocationData` field
21
+
22
+ ### Improvements
23
+
24
+ * Better IP location data coverage with additional metadata
25
+ * Automatic storage of all API response keys, including future additions
26
+ * Improved type definitions for IP location data
27
+
28
+ ### Migration Notes
29
+
30
+ This upgrade is **fully backward compatible**. No code changes are required. See the [Upgrade Guide](./docs/upgrade-guide.md) for details on accessing new fields.
31
+
1
32
  # [2.0.0](https://github.com/switch-org/analytics-tracker/compare/v1.7.0...v2.0.0) (2025-12-01)
2
33
 
3
34
 
package/README.md CHANGED
@@ -15,7 +15,8 @@ A comprehensive, lightweight analytics tracking library for React applications.
15
15
  - 📍 **Location Tracking**:
16
16
  - **IP-based location** - Requires user consent (privacy-compliant)
17
17
  - **GPS location** - Requires explicit user consent and browser permission
18
- - Includes public IP address, country, city, region, timezone
18
+ - Includes public IP address, country, city, region, timezone, continent, flag, connection details
19
+ - Dynamic key storage: All IP location API fields are automatically captured
19
20
  - Automatic fallback from GPS to IP when GPS unavailable
20
21
  - Consent management utilities included
21
22
  - 🎯 **Attribution Tracking**: UTM parameters, referrer tracking, first/last touch attribution
@@ -855,6 +856,14 @@ export async function POST(req: NextRequest) {
855
856
 
856
857
  Comprehensive documentation is available in the [`docs/`](./docs) directory:
857
858
 
859
+ - **[Upgrade Guide](./docs/upgrade-guide.md)** - Step-by-step migration instructions for upgrading between versions
860
+
861
+ - **[Upgrade Guide](./docs/upgrade-guide.md)** - Step-by-step migration instructions for upgrading between versions
862
+ - Breaking changes and compatibility notes
863
+ - New features and improvements
864
+ - Migration examples
865
+ - Troubleshooting upgrade issues
866
+
858
867
  - **[Usage Guide](./docs/usage-guide.md)** - Complete guide on how to use the package in your applications
859
868
  - Installation instructions
860
869
  - Basic and advanced usage examples
@@ -964,7 +973,7 @@ MIT © [Switch Org](https://github.com/switch-org)
964
973
 
965
974
  ## 🙏 Acknowledgments
966
975
 
967
- - Uses [ip-api.com](http://ip-api.com) for free IP geolocation
976
+ - Uses [ipwho.is](https://ipwho.is/) for free IP geolocation
968
977
  - Built with modern web APIs (User-Agent Client Hints, Network Information API, Geolocation API)
969
978
 
970
979
  <!-- ## 📞 Support
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;
@@ -2380,6 +2562,8 @@ function useAnalytics(options = {}) {
2380
2562
  if (autoSend) {
2381
2563
  // Send after idle to not block paint
2382
2564
  const send = async () => {
2565
+ // Extract IP location data if available (stored in ipLocationData field)
2566
+ const ipLocationData = loc?.ipLocationData;
2383
2567
  await AnalyticsService.trackUserJourney({
2384
2568
  sessionId: getOrCreateUserId(),
2385
2569
  pageUrl: typeof window !== 'undefined' ? window.location.href : '',
@@ -2387,6 +2571,7 @@ function useAnalytics(options = {}) {
2387
2571
  deviceInfo: dev,
2388
2572
  location: loc,
2389
2573
  attribution: attr,
2574
+ ipLocation: ipLocationData,
2390
2575
  customData: config?.enableLocation ? { locationEnabled: true } : undefined,
2391
2576
  });
2392
2577
  };
@@ -2402,6 +2587,8 @@ function useAnalytics(options = {}) {
2402
2587
  const logEvent = react.useCallback(async (customData) => {
2403
2588
  if (!sessionId || !networkInfo || !deviceInfo)
2404
2589
  return;
2590
+ // Extract IP location data if available (stored in ipLocationData field)
2591
+ const ipLocationData = location ? location?.ipLocationData : undefined;
2405
2592
  await AnalyticsService.trackUserJourney({
2406
2593
  sessionId,
2407
2594
  pageUrl: typeof window !== 'undefined' ? window.location.href : '',
@@ -2409,6 +2596,7 @@ function useAnalytics(options = {}) {
2409
2596
  deviceInfo,
2410
2597
  location: location ?? undefined,
2411
2598
  attribution: attribution ?? undefined,
2599
+ ipLocation: ipLocationData,
2412
2600
  userId: sessionId,
2413
2601
  customData,
2414
2602
  });
@@ -2546,167 +2734,6 @@ function useAnalytics(options = {}) {
2546
2734
  ]);
2547
2735
  }
2548
2736
 
2549
- /**
2550
- * IP Geolocation Service
2551
- * Fetches location data (country, region, city) from user's IP address
2552
- * Uses free tier of ip-api.com (no API key required, 45 requests/minute)
2553
- */
2554
- /**
2555
- * Get public IP address using ip-api.com
2556
- * Free tier: 45 requests/minute, no API key required
2557
- *
2558
- * @returns Promise<string | null> - The public IP address, or null if unavailable
2559
- *
2560
- * @example
2561
- * ```typescript
2562
- * const ip = await getPublicIP();
2563
- * console.log('Your IP:', ip); // e.g., "203.0.113.42"
2564
- * ```
2565
- */
2566
- async function getPublicIP() {
2567
- // Skip if we're in an environment without fetch (SSR)
2568
- if (typeof fetch === 'undefined') {
2569
- return null;
2570
- }
2571
- try {
2572
- // Call ip-api.com without IP parameter - it auto-detects user's IP
2573
- // Using HTTPS endpoint for better security
2574
- const response = await fetch('https://ip-api.com/json/?fields=status,message,query', {
2575
- method: 'GET',
2576
- headers: {
2577
- Accept: 'application/json',
2578
- },
2579
- // Add timeout to prevent hanging
2580
- signal: AbortSignal.timeout(5000),
2581
- });
2582
- if (!response.ok) {
2583
- return null;
2584
- }
2585
- const data = await response.json();
2586
- // ip-api.com returns status field
2587
- if (data.status === 'fail') {
2588
- return null;
2589
- }
2590
- return data.query || null;
2591
- }
2592
- catch (error) {
2593
- // Silently fail - don't break user experience
2594
- if (error.name !== 'AbortError') {
2595
- console.warn('[IP Geolocation] Error fetching public IP:', error.message);
2596
- }
2597
- return null;
2598
- }
2599
- }
2600
- /**
2601
- * Get location from IP address using ip-api.com
2602
- * Free tier: 45 requests/minute, no API key required
2603
- *
2604
- * Alternative services:
2605
- * - ipapi.co (requires API key for production)
2606
- * - ipgeolocation.io (requires API key)
2607
- * - ip-api.com (free tier available)
2608
- */
2609
- async function getIPLocation(ip) {
2610
- // Skip localhost/private IPs (these can't be geolocated)
2611
- if (!ip ||
2612
- ip === '0.0.0.0' ||
2613
- ip === '::1' ||
2614
- ip.startsWith('127.') ||
2615
- ip.startsWith('192.168.') ||
2616
- ip.startsWith('10.') ||
2617
- ip.startsWith('172.') ||
2618
- ip.startsWith('::ffff:127.')) {
2619
- console.log(`[IP Geolocation] Skipping localhost/private IP: ${ip} (geolocation not available for local IPs)`);
2620
- return null;
2621
- }
2622
- try {
2623
- // Using ip-api.com free tier (JSON format)
2624
- 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`, {
2625
- method: 'GET',
2626
- headers: {
2627
- Accept: 'application/json',
2628
- },
2629
- // Add timeout to prevent hanging
2630
- signal: AbortSignal.timeout(3000),
2631
- });
2632
- if (!response.ok) {
2633
- console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
2634
- return null;
2635
- }
2636
- const data = await response.json();
2637
- // ip-api.com returns status field
2638
- if (data.status === 'fail') {
2639
- console.warn(`[IP Geolocation] API error for IP ${ip}: ${data.message}`);
2640
- return null;
2641
- }
2642
- return {
2643
- ip: data.query || ip,
2644
- country: data.country || undefined,
2645
- countryCode: data.countryCode || undefined,
2646
- region: data.region || undefined,
2647
- regionName: data.regionName || undefined,
2648
- city: data.city || undefined,
2649
- lat: data.lat || undefined,
2650
- lon: data.lon || undefined,
2651
- timezone: data.timezone || undefined,
2652
- isp: data.isp || undefined,
2653
- org: data.org || undefined,
2654
- as: data.as || undefined,
2655
- query: data.query || ip,
2656
- };
2657
- }
2658
- catch (error) {
2659
- // Silently fail - don't break user experience
2660
- if (error.name !== 'AbortError') {
2661
- console.warn(`[IP Geolocation] Error fetching location for IP ${ip}:`, error.message);
2662
- }
2663
- return null;
2664
- }
2665
- }
2666
- /**
2667
- * Get IP address from request headers
2668
- * Handles various proxy headers (x-forwarded-for, x-real-ip, etc.)
2669
- */
2670
- function getIPFromRequest(req) {
2671
- // Try various headers that proxies/load balancers use
2672
- const forwardedFor = req.headers?.get?.('x-forwarded-for') ||
2673
- req.headers?.['x-forwarded-for'] ||
2674
- req.headers?.['X-Forwarded-For'];
2675
- if (forwardedFor) {
2676
- // x-forwarded-for can contain multiple IPs, take the first one
2677
- const ips = forwardedFor.split(',').map((ip) => ip.trim());
2678
- const ip = ips[0];
2679
- if (ip && ip !== '0.0.0.0') {
2680
- return ip;
2681
- }
2682
- }
2683
- const realIP = req.headers?.get?.('x-real-ip') ||
2684
- req.headers?.['x-real-ip'] ||
2685
- req.headers?.['X-Real-IP'];
2686
- if (realIP && realIP !== '0.0.0.0') {
2687
- return realIP.trim();
2688
- }
2689
- // Try req.ip (from Express/Next.js)
2690
- if (req.ip && req.ip !== '0.0.0.0') {
2691
- return req.ip;
2692
- }
2693
- // For localhost, detect if we're running locally
2694
- if (typeof window === 'undefined') {
2695
- const hostname = req.headers?.get?.('host') || req.headers?.['host'];
2696
- if (hostname &&
2697
- (hostname.includes('localhost') ||
2698
- hostname.includes('127.0.0.1') ||
2699
- hostname.startsWith('192.168.'))) {
2700
- return '127.0.0.1'; // Localhost IP
2701
- }
2702
- }
2703
- // If no IP found and we're in development, return localhost
2704
- if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') {
2705
- return '127.0.0.1'; // Localhost for development
2706
- }
2707
- return '0.0.0.0';
2708
- }
2709
-
2710
2737
  exports.AnalyticsService = AnalyticsService;
2711
2738
  exports.AttributionDetector = AttributionDetector;
2712
2739
  exports.DeviceDetector = DeviceDetector;