user-analytics-tracker 2.1.0 → 2.2.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,10 @@
1
+ # [2.2.0](https://github.com/switch-org/analytics-tracker/compare/v2.1.0...v2.2.0) (2025-12-30)
2
+
3
+
4
+ ### Features
5
+
6
+ * ip-location, connection update with ipwho-is api ([fa1b369](https://github.com/switch-org/analytics-tracker/commit/fa1b36997ecad6f71c758f738c768090529bb2b0))
7
+
1
8
  # [2.1.0](https://github.com/switch-org/analytics-tracker/compare/v2.0.0...v2.1.0) (2025-12-29)
2
9
 
3
10
 
package/dist/index.cjs.js CHANGED
@@ -562,25 +562,28 @@ function checkAndSetLocationConsent(msisdn) {
562
562
  * This ensures we capture all available data and any new fields added by the API
563
563
  */
564
564
  /**
565
- * Get public IP address using ipwho.is API
565
+ * Get complete IP location data from ipwho.is API (HIGH PRIORITY)
566
+ * This is the primary method - gets IP, location, connection, and all data in one call
566
567
  * No API key required
567
568
  *
568
- * @returns Promise<string | null> - The public IP address, or null if unavailable
569
+ * @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
569
570
  *
570
571
  * @example
571
572
  * ```typescript
572
- * const ip = await getPublicIP();
573
- * console.log('Your IP:', ip); // e.g., "203.0.113.42"
573
+ * const location = await getCompleteIPLocation();
574
+ * console.log('IP:', location?.ip);
575
+ * console.log('Country:', location?.country);
576
+ * console.log('ISP:', location?.connection?.isp);
574
577
  * ```
575
578
  */
576
- async function getPublicIP() {
579
+ async function getCompleteIPLocation() {
577
580
  // Skip if we're in an environment without fetch (SSR)
578
581
  if (typeof fetch === 'undefined') {
579
582
  return null;
580
583
  }
581
584
  try {
582
- // Call ipwho.is without IP parameter - it auto-detects user's IP
583
- // Using HTTPS endpoint for better security
585
+ // Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
586
+ // This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
584
587
  const response = await fetch('https://ipwho.is/', {
585
588
  method: 'GET',
586
589
  headers: {
@@ -597,22 +600,105 @@ async function getPublicIP() {
597
600
  if (data.success === false) {
598
601
  return null;
599
602
  }
600
- return data.ip || null;
603
+ // Store all keys dynamically from the response
604
+ // This ensures we capture all fields, including nested objects and any new fields
605
+ const locationData = {
606
+ ip: data.ip,
607
+ // Map all fields from the API response dynamically
608
+ ...Object.keys(data).reduce((acc, key) => {
609
+ // Store all keys and their values, preserving nested objects
610
+ acc[key] = data[key];
611
+ return acc;
612
+ }, {}),
613
+ };
614
+ // Add backward compatibility mappings for existing code
615
+ if (data.latitude !== undefined) {
616
+ locationData.lat = data.latitude;
617
+ }
618
+ if (data.longitude !== undefined) {
619
+ locationData.lon = data.longitude;
620
+ }
621
+ if (data.country_code !== undefined) {
622
+ locationData.countryCode = data.country_code;
623
+ }
624
+ if (data.region !== undefined) {
625
+ locationData.regionName = data.region;
626
+ }
627
+ if (data.connection?.isp !== undefined) {
628
+ locationData.isp = data.connection.isp;
629
+ }
630
+ if (data.connection?.org !== undefined) {
631
+ locationData.org = data.connection.org;
632
+ }
633
+ if (data.connection?.asn !== undefined) {
634
+ locationData.as = `AS${data.connection.asn}`;
635
+ }
636
+ if (data.timezone?.id !== undefined) {
637
+ locationData.timezone = data.timezone.id;
638
+ }
639
+ locationData.query = data.ip;
640
+ return locationData;
601
641
  }
602
642
  catch (error) {
603
643
  // Silently fail - don't break user experience
604
644
  if (error.name !== 'AbortError') {
605
- console.warn('[IP Geolocation] Error fetching public IP:', error.message);
645
+ console.warn('[IP Geolocation] Error fetching complete IP location from ipwho.is:', error.message);
606
646
  }
607
647
  return null;
608
648
  }
609
649
  }
610
650
  /**
611
- * Get location from IP address using ipwho.is API
651
+ * Get public IP address using ipwho.is API (FALLBACK - lower priority)
652
+ * This is kept for backward compatibility and as a fallback
653
+ * Prefer getCompleteIPLocation() which gets everything in one call
654
+ *
655
+ * @returns Promise<string | null> - The public IP address, or null if unavailable
656
+ *
657
+ * @example
658
+ * ```typescript
659
+ * const ip = await getPublicIP();
660
+ * console.log('Your IP:', ip); // e.g., "203.0.113.42"
661
+ * ```
662
+ */
663
+ async function getPublicIP() {
664
+ // Try to get complete location first (includes IP)
665
+ const completeLocation = await getCompleteIPLocation();
666
+ if (completeLocation?.ip) {
667
+ return completeLocation.ip;
668
+ }
669
+ // Fallback: try direct IP fetch (less efficient, lower priority)
670
+ try {
671
+ const response = await fetch('https://ipwho.is/', {
672
+ method: 'GET',
673
+ headers: {
674
+ Accept: 'application/json',
675
+ },
676
+ signal: AbortSignal.timeout(5000),
677
+ });
678
+ if (!response.ok) {
679
+ return null;
680
+ }
681
+ const data = await response.json();
682
+ if (data.success === false) {
683
+ return null;
684
+ }
685
+ return data.ip || null;
686
+ }
687
+ catch (error) {
688
+ if (error.name !== 'AbortError') {
689
+ console.warn('[IP Geolocation] Error fetching public IP (fallback):', error.message);
690
+ }
691
+ return null;
692
+ }
693
+ }
694
+ /**
695
+ * Get location from IP address using ipwho.is API (HIGH PRIORITY)
612
696
  * Free tier: No API key required
613
697
  *
614
698
  * Stores all keys dynamically from the API response, including nested objects
615
699
  * This ensures we capture all available data and any new fields added by the API
700
+ *
701
+ * Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
616
702
  */
617
703
  async function getIPLocation(ip) {
618
704
  // Skip localhost/private IPs (these can't be geolocated)
@@ -1039,15 +1125,21 @@ class LocationDetector {
1039
1125
  }
1040
1126
  this.ipLocationFetchingRef.current = true;
1041
1127
  try {
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');
1128
+ // HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
1129
+ // This gets IP, location, connection, timezone, flag, and all other data at once
1130
+ // More efficient than making separate calls
1131
+ let ipLocation = await getCompleteIPLocation();
1132
+ // If complete location fetch failed, try fallback: get IP first, then location
1133
+ if (!ipLocation) {
1134
+ console.log('[Location] Primary ipwho.is call failed, trying fallback...');
1135
+ const publicIP = await getPublicIP();
1136
+ if (publicIP) {
1137
+ // Fallback: Get location from IP using ipwho.is API
1138
+ ipLocation = await getIPLocation(publicIP);
1139
+ }
1046
1140
  }
1047
- // Get location from IP using ipwho.is API
1048
- const ipLocation = await getIPLocation(publicIP);
1049
1141
  if (!ipLocation) {
1050
- throw new Error('Could not fetch location data');
1142
+ throw new Error('Could not fetch location data from ipwho.is');
1051
1143
  }
1052
1144
  // Convert IP location to LocationInfo format
1053
1145
  // Map all available fields from the IP location response
@@ -1062,7 +1154,7 @@ class LocationDetector {
1062
1154
  permission: 'granted', // IP location doesn't require permission
1063
1155
  source: 'ip',
1064
1156
  ts: new Date().toISOString(),
1065
- ip: ipLocation.ip || publicIP,
1157
+ ip: ipLocation.ip || undefined,
1066
1158
  country: ipLocation.country || undefined,
1067
1159
  countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
1068
1160
  city: ipLocation.city || undefined,
@@ -1072,7 +1164,7 @@ class LocationDetector {
1072
1164
  // Store the full IP location data in a custom field for access to all keys
1073
1165
  // This preserves all dynamic keys from the API response
1074
1166
  locationResult.ipLocationData = ipLocation;
1075
- console.log('[Location] IP-based location obtained:', {
1167
+ console.log('[Location] IP-based location obtained from ipwho.is:', {
1076
1168
  ip: locationResult.ip,
1077
1169
  lat: locationResult.lat,
1078
1170
  lon: locationResult.lon,
@@ -1080,6 +1172,8 @@ class LocationDetector {
1080
1172
  country: locationResult.country,
1081
1173
  continent: ipLocation.continent,
1082
1174
  timezone: locationResult.timezone,
1175
+ isp: ipLocation.connection?.isp,
1176
+ connection: ipLocation.connection,
1083
1177
  });
1084
1178
  this.lastIPLocationRef.current = locationResult;
1085
1179
  return locationResult;
@@ -1916,6 +2010,73 @@ class MetricsCollector {
1916
2010
  // Global metrics collector instance
1917
2011
  const metricsCollector = new MetricsCollector();
1918
2012
 
2013
+ /**
2014
+ * Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
2015
+ * This ensures compatibility with the analytics backend integration
2016
+ *
2017
+ * @param ipLocation - Raw IP location data from ipwho.is API
2018
+ * @returns Transformed IP location data matching backend schema
2019
+ */
2020
+ function transformIPLocationForBackend(ipLocation) {
2021
+ if (!ipLocation) {
2022
+ return null;
2023
+ }
2024
+ // Transform to match backend expected format (camelCase)
2025
+ const transformed = {
2026
+ // Basic fields
2027
+ ip: ipLocation.ip,
2028
+ country: ipLocation.country,
2029
+ countryCode: ipLocation.country_code || ipLocation.countryCode,
2030
+ region: ipLocation.region || ipLocation.regionName,
2031
+ city: ipLocation.city,
2032
+ postal: ipLocation.postal,
2033
+ capital: ipLocation.capital,
2034
+ callingCode: ipLocation.calling_code || ipLocation.callingCode,
2035
+ // Geographic fields
2036
+ continent: ipLocation.continent,
2037
+ continentCode: ipLocation.continent_code || ipLocation.continentCode,
2038
+ lat: ipLocation.latitude ?? ipLocation.lat,
2039
+ lon: ipLocation.longitude ?? ipLocation.lon,
2040
+ borders: ipLocation.borders,
2041
+ // Network fields
2042
+ type: ipLocation.type,
2043
+ isEu: ipLocation.is_eu ?? ipLocation.isEu,
2044
+ // ISP/Connection - preserve connection object and also add top-level isp
2045
+ isp: ipLocation.connection?.isp || ipLocation.isp,
2046
+ connection: ipLocation.connection ? {
2047
+ asn: ipLocation.connection.asn,
2048
+ org: ipLocation.connection.org,
2049
+ isp: ipLocation.connection.isp,
2050
+ domain: ipLocation.connection.domain,
2051
+ } : undefined,
2052
+ // Timezone - store both simple string and full details object
2053
+ timezone: typeof ipLocation.timezone === 'string'
2054
+ ? ipLocation.timezone
2055
+ : ipLocation.timezone?.id,
2056
+ timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
2057
+ id: ipLocation.timezone.id,
2058
+ abbr: ipLocation.timezone.abbr,
2059
+ isDst: ipLocation.timezone.is_dst,
2060
+ offset: ipLocation.timezone.offset,
2061
+ utc: ipLocation.timezone.utc,
2062
+ currentTime: ipLocation.timezone.current_time,
2063
+ } : undefined,
2064
+ // Flag - transform to camelCase
2065
+ flag: ipLocation.flag ? {
2066
+ img: ipLocation.flag.img,
2067
+ emoji: ipLocation.flag.emoji,
2068
+ emojiUnicode: ipLocation.flag.emoji_unicode,
2069
+ } : undefined,
2070
+ };
2071
+ // Remove undefined values to keep the payload clean
2072
+ Object.keys(transformed).forEach(key => {
2073
+ if (transformed[key] === undefined) {
2074
+ delete transformed[key];
2075
+ }
2076
+ });
2077
+ return transformed;
2078
+ }
2079
+
1919
2080
  /**
1920
2081
  * Analytics Service
1921
2082
  * Sends analytics events to your backend API
@@ -2179,6 +2340,8 @@ class AnalyticsService {
2179
2340
  * Track user journey with full context
2180
2341
  */
2181
2342
  static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
2343
+ // Transform IP location data to match backend expected format (camelCase)
2344
+ const transformedIPLocation = transformIPLocationForBackend(ipLocation);
2182
2345
  await this.trackEvent({
2183
2346
  sessionId,
2184
2347
  pageUrl,
@@ -2190,7 +2353,8 @@ class AnalyticsService {
2190
2353
  userId: userId ?? sessionId,
2191
2354
  customData: {
2192
2355
  ...customData,
2193
- ...(ipLocation && { ipLocation }),
2356
+ // Store transformed IP location in customData for backend integration
2357
+ ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2194
2358
  },
2195
2359
  eventName: 'page_view', // Auto-tracked as page view
2196
2360
  });
@@ -2261,6 +2425,13 @@ class AnalyticsService {
2261
2425
  }
2262
2426
  const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
2263
2427
  const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
2428
+ // Extract IP location from location object if available
2429
+ const locationData = context?.location || autoContext?.location;
2430
+ const ipLocationData = locationData && typeof locationData === 'object'
2431
+ ? locationData?.ipLocationData
2432
+ : undefined;
2433
+ // Transform IP location data to match backend expected format
2434
+ const transformedIPLocation = transformIPLocationForBackend(ipLocationData);
2264
2435
  await this.trackEvent({
2265
2436
  sessionId: finalSessionId,
2266
2437
  pageUrl: finalPageUrl,
@@ -2271,7 +2442,11 @@ class AnalyticsService {
2271
2442
  userId: context?.userId || finalSessionId,
2272
2443
  eventName,
2273
2444
  eventParameters: parameters || {},
2274
- customData: parameters || {},
2445
+ customData: {
2446
+ ...(parameters || {}),
2447
+ // Store transformed IP location in customData for backend integration
2448
+ ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2449
+ },
2275
2450
  });
2276
2451
  }
2277
2452
  /**
@@ -2744,6 +2919,7 @@ exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
2744
2919
  exports.clearLocationConsent = clearLocationConsent;
2745
2920
  exports.clearSession = clearSession;
2746
2921
  exports.default = useAnalytics;
2922
+ exports.getCompleteIPLocation = getCompleteIPLocation;
2747
2923
  exports.getIPFromRequest = getIPFromRequest;
2748
2924
  exports.getIPLocation = getIPLocation;
2749
2925
  exports.getLocationConsentTimestamp = getLocationConsentTimestamp;
@@ -2760,6 +2936,7 @@ exports.saveJSON = saveJSON;
2760
2936
  exports.saveSessionJSON = saveSessionJSON;
2761
2937
  exports.setLocationConsentGranted = setLocationConsentGranted;
2762
2938
  exports.trackPageVisit = trackPageVisit;
2939
+ exports.transformIPLocationForBackend = transformIPLocationForBackend;
2763
2940
  exports.updateSessionActivity = updateSessionActivity;
2764
2941
  exports.useAnalytics = useAnalytics;
2765
2942
  //# sourceMappingURL=index.cjs.js.map