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