user-analytics-tracker 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -4,6 +4,97 @@ Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var react = require('react');
6
6
 
7
+ /**
8
+ * Core types for the analytics tracker package
9
+ */
10
+ /**
11
+ * Default essential fields for IP location storage
12
+ * These fields are stored when mode is 'essential' (default)
13
+ */
14
+ const DEFAULT_ESSENTIAL_IP_FIELDS = [
15
+ // Core identification
16
+ 'ip',
17
+ 'country',
18
+ 'countryCode',
19
+ 'region',
20
+ 'city',
21
+ // Geographic coordinates (stored here, not duplicated in location)
22
+ 'lat',
23
+ 'lon',
24
+ // Additional geographic info
25
+ 'continent',
26
+ 'continentCode',
27
+ // Network info
28
+ 'type',
29
+ 'isEu',
30
+ 'isp',
31
+ 'connection',
32
+ 'connection.asn',
33
+ 'connection.org',
34
+ 'connection.isp',
35
+ 'connection.domain',
36
+ // Timezone (stored here, not duplicated in location)
37
+ 'timezone',
38
+ 'timezoneDetails',
39
+ 'timezoneDetails.id',
40
+ 'timezoneDetails.abbr',
41
+ 'timezoneDetails.utc',
42
+ // Flag (only emoji in essential mode)
43
+ 'flag.emoji',
44
+ ];
45
+ /**
46
+ * Default essential fields for Device Info storage
47
+ */
48
+ const DEFAULT_ESSENTIAL_DEVICE_FIELDS = [
49
+ 'type',
50
+ 'os',
51
+ 'osVersion',
52
+ 'browser',
53
+ 'browserVersion',
54
+ 'deviceModel',
55
+ 'deviceBrand',
56
+ 'userAgent',
57
+ ];
58
+ /**
59
+ * Default essential fields for Network Info storage
60
+ */
61
+ const DEFAULT_ESSENTIAL_NETWORK_FIELDS = [
62
+ 'type',
63
+ 'effectiveType',
64
+ 'downlink',
65
+ 'rtt',
66
+ 'saveData',
67
+ ];
68
+ /**
69
+ * Default essential fields for Location Info storage
70
+ */
71
+ const DEFAULT_ESSENTIAL_LOCATION_FIELDS = [
72
+ // Minimal location fields - only coordinates and source
73
+ // All IP-related data (ip, country, city, etc.) is stored in customData.ipLocation to avoid duplication
74
+ 'lat',
75
+ 'lon',
76
+ 'source',
77
+ 'ts',
78
+ ];
79
+ /**
80
+ * Default essential fields for Attribution Info storage
81
+ */
82
+ const DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = [
83
+ 'landingUrl',
84
+ 'path',
85
+ 'hostname',
86
+ 'referrerUrl',
87
+ 'referrerDomain',
88
+ 'navigationType',
89
+ 'isReload',
90
+ 'isBackForward',
91
+ 'utm_source',
92
+ 'utm_medium',
93
+ 'utm_campaign',
94
+ 'utm_term',
95
+ 'utm_content',
96
+ ];
97
+
7
98
  /**
8
99
  * Network Type Detector
9
100
  * Detects WiFi, Mobile Data (Cellular), Hotspot, Ethernet, or Unknown
@@ -562,25 +653,28 @@ function checkAndSetLocationConsent(msisdn) {
562
653
  * This ensures we capture all available data and any new fields added by the API
563
654
  */
564
655
  /**
565
- * Get public IP address using ipwho.is API
656
+ * Get complete IP location data from ipwho.is API (HIGH PRIORITY)
657
+ * This is the primary method - gets IP, location, connection, and all data in one call
566
658
  * No API key required
567
659
  *
568
- * @returns Promise<string | null> - The public IP address, or null if unavailable
660
+ * @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
569
661
  *
570
662
  * @example
571
663
  * ```typescript
572
- * const ip = await getPublicIP();
573
- * console.log('Your IP:', ip); // e.g., "203.0.113.42"
664
+ * const location = await getCompleteIPLocation();
665
+ * console.log('IP:', location?.ip);
666
+ * console.log('Country:', location?.country);
667
+ * console.log('ISP:', location?.connection?.isp);
574
668
  * ```
575
669
  */
576
- async function getPublicIP() {
670
+ async function getCompleteIPLocation() {
577
671
  // Skip if we're in an environment without fetch (SSR)
578
672
  if (typeof fetch === 'undefined') {
579
673
  return null;
580
674
  }
581
675
  try {
582
- // Call ipwho.is without IP parameter - it auto-detects user's IP
583
- // Using HTTPS endpoint for better security
676
+ // Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
677
+ // This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
584
678
  const response = await fetch('https://ipwho.is/', {
585
679
  method: 'GET',
586
680
  headers: {
@@ -597,22 +691,105 @@ async function getPublicIP() {
597
691
  if (data.success === false) {
598
692
  return null;
599
693
  }
600
- return data.ip || null;
694
+ // Store all keys dynamically from the response
695
+ // This ensures we capture all fields, including nested objects and any new fields
696
+ const locationData = {
697
+ ip: data.ip,
698
+ // Map all fields from the API response dynamically
699
+ ...Object.keys(data).reduce((acc, key) => {
700
+ // Store all keys and their values, preserving nested objects
701
+ acc[key] = data[key];
702
+ return acc;
703
+ }, {}),
704
+ };
705
+ // Add backward compatibility mappings for existing code
706
+ if (data.latitude !== undefined) {
707
+ locationData.lat = data.latitude;
708
+ }
709
+ if (data.longitude !== undefined) {
710
+ locationData.lon = data.longitude;
711
+ }
712
+ if (data.country_code !== undefined) {
713
+ locationData.countryCode = data.country_code;
714
+ }
715
+ if (data.region !== undefined) {
716
+ locationData.regionName = data.region;
717
+ }
718
+ if (data.connection?.isp !== undefined) {
719
+ locationData.isp = data.connection.isp;
720
+ }
721
+ if (data.connection?.org !== undefined) {
722
+ locationData.org = data.connection.org;
723
+ }
724
+ if (data.connection?.asn !== undefined) {
725
+ locationData.as = `AS${data.connection.asn}`;
726
+ }
727
+ if (data.timezone?.id !== undefined) {
728
+ locationData.timezone = data.timezone.id;
729
+ }
730
+ locationData.query = data.ip;
731
+ return locationData;
601
732
  }
602
733
  catch (error) {
603
734
  // Silently fail - don't break user experience
604
735
  if (error.name !== 'AbortError') {
605
- console.warn('[IP Geolocation] Error fetching public IP:', error.message);
736
+ console.warn('[IP Geolocation] Error fetching complete IP location from ipwho.is:', error.message);
606
737
  }
607
738
  return null;
608
739
  }
609
740
  }
610
741
  /**
611
- * Get location from IP address using ipwho.is API
742
+ * Get public IP address using ipwho.is API (FALLBACK - lower priority)
743
+ * This is kept for backward compatibility and as a fallback
744
+ * Prefer getCompleteIPLocation() which gets everything in one call
745
+ *
746
+ * @returns Promise<string | null> - The public IP address, or null if unavailable
747
+ *
748
+ * @example
749
+ * ```typescript
750
+ * const ip = await getPublicIP();
751
+ * console.log('Your IP:', ip); // e.g., "203.0.113.42"
752
+ * ```
753
+ */
754
+ async function getPublicIP() {
755
+ // Try to get complete location first (includes IP)
756
+ const completeLocation = await getCompleteIPLocation();
757
+ if (completeLocation?.ip) {
758
+ return completeLocation.ip;
759
+ }
760
+ // Fallback: try direct IP fetch (less efficient, lower priority)
761
+ try {
762
+ const response = await fetch('https://ipwho.is/', {
763
+ method: 'GET',
764
+ headers: {
765
+ Accept: 'application/json',
766
+ },
767
+ signal: AbortSignal.timeout(5000),
768
+ });
769
+ if (!response.ok) {
770
+ return null;
771
+ }
772
+ const data = await response.json();
773
+ if (data.success === false) {
774
+ return null;
775
+ }
776
+ return data.ip || null;
777
+ }
778
+ catch (error) {
779
+ if (error.name !== 'AbortError') {
780
+ console.warn('[IP Geolocation] Error fetching public IP (fallback):', error.message);
781
+ }
782
+ return null;
783
+ }
784
+ }
785
+ /**
786
+ * Get location from IP address using ipwho.is API (HIGH PRIORITY)
612
787
  * Free tier: No API key required
613
788
  *
614
789
  * Stores all keys dynamically from the API response, including nested objects
615
790
  * This ensures we capture all available data and any new fields added by the API
791
+ *
792
+ * Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
616
793
  */
617
794
  async function getIPLocation(ip) {
618
795
  // Skip localhost/private IPs (these can't be geolocated)
@@ -1039,15 +1216,21 @@ class LocationDetector {
1039
1216
  }
1040
1217
  this.ipLocationFetchingRef.current = true;
1041
1218
  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');
1219
+ // HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
1220
+ // This gets IP, location, connection, timezone, flag, and all other data at once
1221
+ // More efficient than making separate calls
1222
+ let ipLocation = await getCompleteIPLocation();
1223
+ // If complete location fetch failed, try fallback: get IP first, then location
1224
+ if (!ipLocation) {
1225
+ console.log('[Location] Primary ipwho.is call failed, trying fallback...');
1226
+ const publicIP = await getPublicIP();
1227
+ if (publicIP) {
1228
+ // Fallback: Get location from IP using ipwho.is API
1229
+ ipLocation = await getIPLocation(publicIP);
1230
+ }
1046
1231
  }
1047
- // Get location from IP using ipwho.is API
1048
- const ipLocation = await getIPLocation(publicIP);
1049
1232
  if (!ipLocation) {
1050
- throw new Error('Could not fetch location data');
1233
+ throw new Error('Could not fetch location data from ipwho.is');
1051
1234
  }
1052
1235
  // Convert IP location to LocationInfo format
1053
1236
  // Map all available fields from the IP location response
@@ -1062,7 +1245,7 @@ class LocationDetector {
1062
1245
  permission: 'granted', // IP location doesn't require permission
1063
1246
  source: 'ip',
1064
1247
  ts: new Date().toISOString(),
1065
- ip: ipLocation.ip || publicIP,
1248
+ ip: ipLocation.ip || undefined,
1066
1249
  country: ipLocation.country || undefined,
1067
1250
  countryCode: ipLocation.country_code || ipLocation.countryCode || undefined,
1068
1251
  city: ipLocation.city || undefined,
@@ -1072,7 +1255,7 @@ class LocationDetector {
1072
1255
  // Store the full IP location data in a custom field for access to all keys
1073
1256
  // This preserves all dynamic keys from the API response
1074
1257
  locationResult.ipLocationData = ipLocation;
1075
- console.log('[Location] IP-based location obtained:', {
1258
+ console.log('[Location] IP-based location obtained from ipwho.is:', {
1076
1259
  ip: locationResult.ip,
1077
1260
  lat: locationResult.lat,
1078
1261
  lon: locationResult.lon,
@@ -1080,6 +1263,8 @@ class LocationDetector {
1080
1263
  country: locationResult.country,
1081
1264
  continent: ipLocation.continent,
1082
1265
  timezone: locationResult.timezone,
1266
+ isp: ipLocation.connection?.isp,
1267
+ connection: ipLocation.connection,
1083
1268
  });
1084
1269
  this.lastIPLocationRef.current = locationResult;
1085
1270
  return locationResult;
@@ -1916,6 +2101,264 @@ class MetricsCollector {
1916
2101
  // Global metrics collector instance
1917
2102
  const metricsCollector = new MetricsCollector();
1918
2103
 
2104
+ /**
2105
+ * Generic field storage transformer
2106
+ * Filters object fields based on storage configuration
2107
+ */
2108
+ /**
2109
+ * Filter object fields based on storage configuration
2110
+ *
2111
+ * @param data - The data object to filter
2112
+ * @param config - Storage configuration
2113
+ * @param defaultEssentialFields - Default essential fields for this data type
2114
+ * @returns Filtered data object with only configured fields
2115
+ */
2116
+ function filterFieldsByConfig(data, config, defaultEssentialFields) {
2117
+ if (!data) {
2118
+ return null;
2119
+ }
2120
+ const mode = config?.mode || 'essential';
2121
+ let fieldsToInclude = [];
2122
+ if (mode === 'essential') {
2123
+ // Use default essential fields
2124
+ fieldsToInclude = [...defaultEssentialFields];
2125
+ }
2126
+ else if (mode === 'all') {
2127
+ // Include all fields, then exclude specified ones
2128
+ fieldsToInclude = ['*']; // Special marker for "all fields"
2129
+ }
2130
+ else if (mode === 'custom' && config) {
2131
+ // Use custom field list
2132
+ fieldsToInclude = config.fields || [];
2133
+ }
2134
+ // If mode is 'all', just exclude specified fields
2135
+ if (mode === 'all') {
2136
+ const filtered = { ...data };
2137
+ if (config?.exclude && config.exclude.length > 0) {
2138
+ const excludeSet = new Set(config.exclude);
2139
+ Object.keys(filtered).forEach(key => {
2140
+ if (excludeSet.has(key)) {
2141
+ delete filtered[key];
2142
+ }
2143
+ });
2144
+ // Handle nested exclusions
2145
+ if (filtered.connection && excludeSet.has('connection')) {
2146
+ delete filtered.connection;
2147
+ }
2148
+ if (filtered.timezoneDetails && excludeSet.has('timezoneDetails')) {
2149
+ delete filtered.timezoneDetails;
2150
+ }
2151
+ if (filtered.flag && excludeSet.has('flag')) {
2152
+ delete filtered.flag;
2153
+ }
2154
+ if (filtered.firstTouch && excludeSet.has('firstTouch')) {
2155
+ delete filtered.firstTouch;
2156
+ }
2157
+ if (filtered.lastTouch && excludeSet.has('lastTouch')) {
2158
+ delete filtered.lastTouch;
2159
+ }
2160
+ }
2161
+ return filtered;
2162
+ }
2163
+ // For 'essential' or 'custom' mode, only include specified fields
2164
+ const filtered = {};
2165
+ const includeSet = new Set(fieldsToInclude);
2166
+ // Helper to check if a field path should be included
2167
+ const shouldInclude = (fieldPath) => {
2168
+ // Direct match - most specific
2169
+ if (includeSet.has(fieldPath))
2170
+ return true;
2171
+ // For nested fields (e.g., 'flag.emoji'), only include if explicitly listed
2172
+ // Don't auto-include all children just because parent is included
2173
+ const parts = fieldPath.split('.');
2174
+ if (parts.length > 1) {
2175
+ // For nested fields, require explicit inclusion
2176
+ // This prevents 'flag' from including all 'flag.*' fields
2177
+ return includeSet.has(fieldPath);
2178
+ }
2179
+ // For top-level fields only, check if parent path is included
2180
+ // This allows 'connection' to work when all connection.* fields are listed
2181
+ return false;
2182
+ };
2183
+ // Helper to check if a parent object should be created (for nested objects)
2184
+ const shouldIncludeParent = (parentPath) => {
2185
+ // Check if any child of this parent is included
2186
+ for (const field of fieldsToInclude) {
2187
+ if (field.startsWith(parentPath + '.')) {
2188
+ return true;
2189
+ }
2190
+ }
2191
+ // Also check if parent itself is explicitly included
2192
+ return includeSet.has(parentPath);
2193
+ };
2194
+ // Filter top-level fields
2195
+ Object.keys(data).forEach(key => {
2196
+ if (shouldInclude(key)) {
2197
+ filtered[key] = data[key];
2198
+ }
2199
+ });
2200
+ // Handle nested objects - only create if at least one child field is included
2201
+ if (data.connection && shouldIncludeParent('connection')) {
2202
+ filtered.connection = {};
2203
+ if (shouldInclude('connection.asn'))
2204
+ filtered.connection.asn = data.connection.asn;
2205
+ if (shouldInclude('connection.org'))
2206
+ filtered.connection.org = data.connection.org;
2207
+ if (shouldInclude('connection.isp'))
2208
+ filtered.connection.isp = data.connection.isp;
2209
+ if (shouldInclude('connection.domain'))
2210
+ filtered.connection.domain = data.connection.domain;
2211
+ // If no connection fields were included, remove the object
2212
+ if (Object.keys(filtered.connection).length === 0) {
2213
+ delete filtered.connection;
2214
+ }
2215
+ }
2216
+ if (data.timezoneDetails && shouldIncludeParent('timezoneDetails')) {
2217
+ filtered.timezoneDetails = {};
2218
+ if (shouldInclude('timezoneDetails.id'))
2219
+ filtered.timezoneDetails.id = data.timezoneDetails.id;
2220
+ if (shouldInclude('timezoneDetails.abbr'))
2221
+ filtered.timezoneDetails.abbr = data.timezoneDetails.abbr;
2222
+ if (shouldInclude('timezoneDetails.utc'))
2223
+ filtered.timezoneDetails.utc = data.timezoneDetails.utc;
2224
+ if (shouldInclude('timezoneDetails.isDst'))
2225
+ filtered.timezoneDetails.isDst = data.timezoneDetails.isDst;
2226
+ if (shouldInclude('timezoneDetails.offset'))
2227
+ filtered.timezoneDetails.offset = data.timezoneDetails.offset;
2228
+ if (shouldInclude('timezoneDetails.currentTime'))
2229
+ filtered.timezoneDetails.currentTime = data.timezoneDetails.currentTime;
2230
+ // If no timezoneDetails fields were included, remove the object
2231
+ if (Object.keys(filtered.timezoneDetails).length === 0) {
2232
+ delete filtered.timezoneDetails;
2233
+ }
2234
+ }
2235
+ if (data.flag && shouldIncludeParent('flag')) {
2236
+ filtered.flag = {};
2237
+ // Only include specific flag fields if they're explicitly in the include list
2238
+ if (shouldInclude('flag.emoji'))
2239
+ filtered.flag.emoji = data.flag.emoji;
2240
+ if (shouldInclude('flag.img'))
2241
+ filtered.flag.img = data.flag.img;
2242
+ if (shouldInclude('flag.emojiUnicode'))
2243
+ filtered.flag.emojiUnicode = data.flag.emojiUnicode;
2244
+ // If no specific flag fields are included, don't add the flag object
2245
+ if (Object.keys(filtered.flag).length === 0) {
2246
+ delete filtered.flag;
2247
+ }
2248
+ }
2249
+ if (data.firstTouch && shouldInclude('firstTouch')) {
2250
+ filtered.firstTouch = data.firstTouch;
2251
+ }
2252
+ if (data.lastTouch && shouldInclude('lastTouch')) {
2253
+ filtered.lastTouch = data.lastTouch;
2254
+ }
2255
+ // Remove null and undefined values to reduce payload size
2256
+ const cleanValue = (val) => {
2257
+ if (val === null || val === undefined) {
2258
+ return undefined; // Will be filtered out
2259
+ }
2260
+ // For objects, recursively clean nested null/undefined values
2261
+ if (typeof val === 'object' && !Array.isArray(val) && val !== null) {
2262
+ const cleaned = {};
2263
+ let hasAnyValue = false;
2264
+ Object.keys(val).forEach(key => {
2265
+ const cleanedChild = cleanValue(val[key]);
2266
+ if (cleanedChild !== undefined) {
2267
+ cleaned[key] = cleanedChild;
2268
+ hasAnyValue = true;
2269
+ }
2270
+ });
2271
+ return hasAnyValue ? cleaned : undefined;
2272
+ }
2273
+ // For arrays, clean each element
2274
+ if (Array.isArray(val)) {
2275
+ const cleaned = val.map(cleanValue).filter(item => item !== undefined);
2276
+ return cleaned.length > 0 ? cleaned : undefined;
2277
+ }
2278
+ return val;
2279
+ };
2280
+ const cleaned = {};
2281
+ Object.keys(filtered).forEach(key => {
2282
+ const cleanedValue = cleanValue(filtered[key]);
2283
+ if (cleanedValue !== undefined) {
2284
+ cleaned[key] = cleanedValue;
2285
+ }
2286
+ });
2287
+ return cleaned;
2288
+ }
2289
+
2290
+ /**
2291
+ * Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
2292
+ * Supports configurable field storage to optimize storage capacity
2293
+ *
2294
+ * @param ipLocation - Raw IP location data from ipwho.is API
2295
+ * @param config - Optional configuration for which fields to store
2296
+ * @returns Transformed IP location data matching backend schema (only includes configured fields)
2297
+ */
2298
+ function transformIPLocationForBackend(ipLocation, config) {
2299
+ if (!ipLocation) {
2300
+ return null;
2301
+ }
2302
+ // Transform to match backend expected format (camelCase)
2303
+ // Build complete object first, then filter based on configuration
2304
+ const transformed = {
2305
+ // Basic fields
2306
+ ip: ipLocation.ip,
2307
+ country: ipLocation.country,
2308
+ countryCode: ipLocation.country_code || ipLocation.countryCode,
2309
+ region: ipLocation.region || ipLocation.regionName,
2310
+ city: ipLocation.city,
2311
+ postal: ipLocation.postal,
2312
+ capital: ipLocation.capital,
2313
+ callingCode: ipLocation.calling_code || ipLocation.callingCode,
2314
+ // Geographic fields
2315
+ continent: ipLocation.continent,
2316
+ continentCode: ipLocation.continent_code || ipLocation.continentCode,
2317
+ lat: ipLocation.latitude ?? ipLocation.lat,
2318
+ lon: ipLocation.longitude ?? ipLocation.lon,
2319
+ borders: ipLocation.borders,
2320
+ // Network fields
2321
+ type: ipLocation.type,
2322
+ isEu: ipLocation.is_eu ?? ipLocation.isEu,
2323
+ // ISP/Connection - preserve connection object and also add top-level isp
2324
+ isp: ipLocation.connection?.isp || ipLocation.isp,
2325
+ connection: ipLocation.connection ? {
2326
+ asn: ipLocation.connection.asn,
2327
+ org: ipLocation.connection.org,
2328
+ isp: ipLocation.connection.isp,
2329
+ domain: ipLocation.connection.domain,
2330
+ } : undefined,
2331
+ // Timezone - store both simple string and full details object
2332
+ timezone: typeof ipLocation.timezone === 'string'
2333
+ ? ipLocation.timezone
2334
+ : ipLocation.timezone?.id,
2335
+ timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
2336
+ id: ipLocation.timezone.id,
2337
+ abbr: ipLocation.timezone.abbr,
2338
+ utc: ipLocation.timezone.utc,
2339
+ // Exclude these in essential mode: isDst, offset, currentTime
2340
+ // They will be filtered out by filterFieldsByConfig if not in essential fields
2341
+ isDst: ipLocation.timezone.is_dst,
2342
+ offset: ipLocation.timezone.offset,
2343
+ currentTime: ipLocation.timezone.current_time,
2344
+ } : undefined,
2345
+ // Flag - transform to camelCase
2346
+ flag: ipLocation.flag ? {
2347
+ img: ipLocation.flag.img,
2348
+ emoji: ipLocation.flag.emoji,
2349
+ emojiUnicode: ipLocation.flag.emoji_unicode,
2350
+ } : undefined,
2351
+ };
2352
+ // Remove undefined values to keep the payload clean
2353
+ Object.keys(transformed).forEach(key => {
2354
+ if (transformed[key] === undefined) {
2355
+ delete transformed[key];
2356
+ }
2357
+ });
2358
+ // Filter fields based on configuration using generic filter
2359
+ return filterFieldsByConfig(transformed, config, DEFAULT_ESSENTIAL_IP_FIELDS);
2360
+ }
2361
+
1919
2362
  /**
1920
2363
  * Analytics Service
1921
2364
  * Sends analytics events to your backend API
@@ -2179,18 +2622,50 @@ class AnalyticsService {
2179
2622
  * Track user journey with full context
2180
2623
  */
2181
2624
  static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
2625
+ // Get field storage config (support both new and legacy format)
2626
+ const fieldStorage = this.config.fieldStorage || {};
2627
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2628
+ // Transform and filter all data types based on configuration
2629
+ const transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
2630
+ const filteredDeviceInfo = filterFieldsByConfig(deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2631
+ const filteredNetworkInfo = filterFieldsByConfig(networkInfo, fieldStorage.networkInfo, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2632
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2633
+ // This prevents storing the same data twice (e.g., ip, country, city, region, timezone)
2634
+ const locationConfig = fieldStorage.location;
2635
+ const locationMode = locationConfig?.mode || 'essential';
2636
+ let filteredLocation = filterFieldsByConfig(location, locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2637
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2638
+ // to avoid storing the same data twice
2639
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2640
+ // Remove fields that are duplicated in customData.ipLocation
2641
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2642
+ const minimalLocation = { ...filteredLocation };
2643
+ duplicateFields.forEach(field => {
2644
+ delete minimalLocation[field];
2645
+ });
2646
+ // Only keep essential location fields: lat, lon, source, ts
2647
+ filteredLocation = {
2648
+ lat: minimalLocation.lat,
2649
+ lon: minimalLocation.lon,
2650
+ source: minimalLocation.source,
2651
+ ts: minimalLocation.ts,
2652
+ };
2653
+ }
2654
+ const filteredAttribution = filterFieldsByConfig(attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2182
2655
  await this.trackEvent({
2183
2656
  sessionId,
2184
2657
  pageUrl,
2185
- networkInfo,
2186
- deviceInfo,
2187
- location,
2188
- attribution,
2189
- ipLocation,
2658
+ networkInfo: filteredNetworkInfo || undefined,
2659
+ deviceInfo: filteredDeviceInfo || undefined,
2660
+ location: filteredLocation || undefined,
2661
+ attribution: filteredAttribution || undefined,
2662
+ // Don't include raw ipLocation - we have the filtered/transformed version in customData
2663
+ ipLocation: undefined,
2190
2664
  userId: userId ?? sessionId,
2191
2665
  customData: {
2192
2666
  ...customData,
2193
- ...(ipLocation && { ipLocation }),
2667
+ // Store transformed and filtered IP location in customData for backend integration
2668
+ ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2194
2669
  },
2195
2670
  eventName: 'page_view', // Auto-tracked as page view
2196
2671
  });
@@ -2261,17 +2736,54 @@ class AnalyticsService {
2261
2736
  }
2262
2737
  const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
2263
2738
  const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
2739
+ // Extract IP location from location object if available
2740
+ const locationData = context?.location || autoContext?.location;
2741
+ const ipLocationData = locationData && typeof locationData === 'object'
2742
+ ? locationData?.ipLocationData
2743
+ : undefined;
2744
+ // Get field storage config (support both new and legacy format)
2745
+ const fieldStorage = this.config.fieldStorage || {};
2746
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2747
+ // Transform and filter all data types based on configuration
2748
+ const transformedIPLocation = transformIPLocationForBackend(ipLocationData, ipLocationConfig);
2749
+ const filteredDeviceInfo = filterFieldsByConfig(context?.deviceInfo || autoContext?.deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2750
+ const filteredNetworkInfo = filterFieldsByConfig(context?.networkInfo || autoContext?.networkInfo, fieldStorage.networkInfo, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2751
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2752
+ const locationConfig = fieldStorage.location;
2753
+ const locationMode = locationConfig?.mode || 'essential';
2754
+ let filteredLocation = filterFieldsByConfig((context?.location || autoContext?.location), locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2755
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2756
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2757
+ // Remove fields that are duplicated in customData.ipLocation
2758
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2759
+ const minimalLocation = { ...filteredLocation };
2760
+ duplicateFields.forEach(field => {
2761
+ delete minimalLocation[field];
2762
+ });
2763
+ // Only keep essential location fields: lat, lon, source, ts
2764
+ filteredLocation = {
2765
+ lat: minimalLocation.lat,
2766
+ lon: minimalLocation.lon,
2767
+ source: minimalLocation.source,
2768
+ ts: minimalLocation.ts,
2769
+ };
2770
+ }
2771
+ const filteredAttribution = filterFieldsByConfig(context?.attribution || autoContext?.attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2264
2772
  await this.trackEvent({
2265
2773
  sessionId: finalSessionId,
2266
2774
  pageUrl: finalPageUrl,
2267
- networkInfo: context?.networkInfo || autoContext?.networkInfo,
2268
- deviceInfo: context?.deviceInfo || autoContext?.deviceInfo,
2269
- location: context?.location || autoContext?.location,
2270
- attribution: context?.attribution || autoContext?.attribution,
2775
+ networkInfo: filteredNetworkInfo || undefined,
2776
+ deviceInfo: filteredDeviceInfo || undefined,
2777
+ location: filteredLocation || undefined,
2778
+ attribution: filteredAttribution || undefined,
2271
2779
  userId: context?.userId || finalSessionId,
2272
2780
  eventName,
2273
2781
  eventParameters: parameters || {},
2274
- customData: parameters || {},
2782
+ customData: {
2783
+ ...(parameters || {}),
2784
+ // Store transformed IP location in customData for backend integration
2785
+ ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2786
+ },
2275
2787
  });
2276
2788
  }
2277
2789
  /**
@@ -2449,6 +2961,8 @@ function useAnalytics(options = {}) {
2449
2961
  logLevel: config.logLevel,
2450
2962
  enableMetrics: config.enableMetrics,
2451
2963
  sessionTimeout: config.sessionTimeout,
2964
+ fieldStorage: config.fieldStorage,
2965
+ ipLocationFields: config.ipLocationFields, // Legacy support
2452
2966
  });
2453
2967
  }
2454
2968
  }, [
@@ -2736,6 +3250,11 @@ function useAnalytics(options = {}) {
2736
3250
 
2737
3251
  exports.AnalyticsService = AnalyticsService;
2738
3252
  exports.AttributionDetector = AttributionDetector;
3253
+ exports.DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS;
3254
+ exports.DEFAULT_ESSENTIAL_DEVICE_FIELDS = DEFAULT_ESSENTIAL_DEVICE_FIELDS;
3255
+ exports.DEFAULT_ESSENTIAL_IP_FIELDS = DEFAULT_ESSENTIAL_IP_FIELDS;
3256
+ exports.DEFAULT_ESSENTIAL_LOCATION_FIELDS = DEFAULT_ESSENTIAL_LOCATION_FIELDS;
3257
+ exports.DEFAULT_ESSENTIAL_NETWORK_FIELDS = DEFAULT_ESSENTIAL_NETWORK_FIELDS;
2739
3258
  exports.DeviceDetector = DeviceDetector;
2740
3259
  exports.LocationDetector = LocationDetector;
2741
3260
  exports.NetworkDetector = NetworkDetector;
@@ -2744,6 +3263,8 @@ exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
2744
3263
  exports.clearLocationConsent = clearLocationConsent;
2745
3264
  exports.clearSession = clearSession;
2746
3265
  exports.default = useAnalytics;
3266
+ exports.filterFieldsByConfig = filterFieldsByConfig;
3267
+ exports.getCompleteIPLocation = getCompleteIPLocation;
2747
3268
  exports.getIPFromRequest = getIPFromRequest;
2748
3269
  exports.getIPLocation = getIPLocation;
2749
3270
  exports.getLocationConsentTimestamp = getLocationConsentTimestamp;
@@ -2760,6 +3281,7 @@ exports.saveJSON = saveJSON;
2760
3281
  exports.saveSessionJSON = saveSessionJSON;
2761
3282
  exports.setLocationConsentGranted = setLocationConsentGranted;
2762
3283
  exports.trackPageVisit = trackPageVisit;
3284
+ exports.transformIPLocationForBackend = transformIPLocationForBackend;
2763
3285
  exports.updateSessionActivity = updateSessionActivity;
2764
3286
  exports.useAnalytics = useAnalytics;
2765
3287
  //# sourceMappingURL=index.cjs.js.map