user-analytics-tracker 2.2.0 → 4.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/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ # [4.0.0](https://github.com/switch-org/analytics-tracker/compare/v3.0.0...v4.0.0) (2025-12-30)
2
+
3
+
4
+ ### Features
5
+
6
+ * optimize essential mode and use ipwho.is connection data ([0f830ea](https://github.com/switch-org/analytics-tracker/commit/0f830eae27009c1cfbfb4e50da4c13a28b497f5e))
7
+
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ * Essential mode deviceInfo now has 8 fields, networkInfo removed
12
+
13
+ # [3.0.0](https://github.com/switch-org/analytics-tracker/compare/v2.2.0...v3.0.0) (2025-12-30)
14
+
15
+
16
+ ### Features
17
+
18
+ * optimize essential mode with deduplication and reduced deviceInfo fields ([b64eefa](https://github.com/switch-org/analytics-tracker/commit/b64eefa4992e0570b1011fa029d0db8de89c321b))
19
+
20
+
21
+ ### BREAKING CHANGES
22
+
23
+ * Essential mode deviceInfo now stores only 8 fields instead of 15
24
+
25
+ ## Changes
26
+
27
+ ### Core Optimizations
28
+ - Reduce deviceInfo essential fields from 15 to 8
29
+ - Implement automatic deduplication between location and customData.ipLocation
30
+ - Remove duplicate fields from location object
31
+ - Add null/undefined value removal from filtered objects
32
+ - Improve nested object handling
33
+
34
+ ### Field Storage Improvements
35
+ - Add generic field storage transformer for all data types
36
+ - Support essential/all/custom modes for all data types
37
+ - Implement recursive null value cleaning
38
+ - Add mode-aware deduplication logic
39
+
40
+ ### Documentation
41
+ - Merge and consolidate essential mode documentation
42
+ - Add comprehensive essential-mode-guide.md
43
+ - Update documentation index
44
+ - Add example payload JSON
45
+
46
+ ## Impact
47
+ - Significantly smaller payloads (~30-40% reduction)
48
+ - No duplicate data storage
49
+ - All crucial information preserved
50
+
1
51
  # [2.2.0](https://github.com/switch-org/analytics-tracker/compare/v2.1.0...v2.2.0) (2025-12-30)
2
52
 
3
53
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ A comprehensive, lightweight analytics tracking library for React applications.
11
11
  ## ✨ Features
12
12
 
13
13
  - 🔍 **Device Detection**: Automatically detects device type, OS, browser, model, brand, and hardware specs using User-Agent Client Hints
14
- - 🌐 **Network Detection**: Identifies WiFi, Cellular, Hotspot, Ethernet connections with quality metrics
14
+ - 🌐 **Network & Connection Info**: Accurate ISP and connection details from ipwho.is API (ASN, organization, domain)
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
@@ -93,6 +93,21 @@ const analytics = useAnalytics({
93
93
 
94
94
  // Metrics configuration
95
95
  enableMetrics: false, // Enable metrics collection (default: false)
96
+
97
+ // Field storage configuration (optional) - control which fields are stored
98
+ fieldStorage: {
99
+ ipLocation: { mode: 'essential' }, // IP location fields (includes connection data)
100
+ deviceInfo: { mode: 'essential' }, // Device info fields
101
+ // networkInfo: Not stored in essential mode - use connection from ipwho.is instead
102
+ location: { mode: 'essential' }, // Location fields
103
+ attribution: { mode: 'essential' }, // Attribution fields
104
+ // Each can be: 'essential' (default) | 'all' | 'custom'
105
+ // For 'custom': specify fields array
106
+ // For 'all': specify exclude array
107
+ },
108
+
109
+ // Legacy: IP Location storage (backward compatible)
110
+ ipLocationFields: { mode: 'essential' },
96
111
  },
97
112
  });
98
113
  ```
@@ -211,7 +226,6 @@ import { useAnalytics } from 'user-analytics-tracker';
211
226
  function MyApp() {
212
227
  const {
213
228
  sessionId,
214
- networkInfo,
215
229
  deviceInfo,
216
230
  location,
217
231
  trackEvent,
@@ -242,7 +256,7 @@ function MyApp() {
242
256
  return (
243
257
  <div>
244
258
  <p>Device: {deviceInfo?.deviceBrand} {deviceInfo?.deviceModel}</p>
245
- <p>Network: {networkInfo?.type}</p>
259
+ {/* Connection data from ipwho.is (in customData.ipLocation.connection) */}
246
260
  <button onClick={handleButtonClick}>
247
261
  Track Click
248
262
  </button>
@@ -743,18 +757,11 @@ Detect and restrict hotspot users:
743
757
  ```tsx
744
758
  import { useAnalytics } from 'user-analytics-tracker';
745
759
 
746
- function HotspotGate({ children }) {
747
- const { networkInfo } = useAnalytics({ autoSend: false });
748
-
749
- if (networkInfo?.type === 'hotspot') {
750
- return (
751
- <div>
752
- <h2>Hotspot Detected</h2>
753
- <p>Please switch to mobile data or Wi-Fi.</p>
754
- </div>
755
- );
756
- }
757
-
760
+ // Note: networkInfo is no longer available in essential mode
761
+ // Connection data is available in customData.ipLocation.connection from ipwho.is
762
+ function ConnectionInfo({ children }) {
763
+ // Connection info comes from ipwho.is API in analytics events
764
+ // Access via: customData.ipLocation.connection (asn, org, isp, domain)
758
765
  return children;
759
766
  }
760
767
  ```
@@ -973,8 +980,8 @@ MIT © [Switch Org](https://github.com/switch-org)
973
980
 
974
981
  ## 🙏 Acknowledgments
975
982
 
976
- - Uses [ipwho.is](https://ipwho.is/) for free IP geolocation
977
- - Built with modern web APIs (User-Agent Client Hints, Network Information API, Geolocation API)
983
+ - Uses [ipwho.is](https://ipwho.is/) for free IP geolocation and accurate connection data
984
+ - Built with modern web APIs (User-Agent Client Hints, Geolocation API)
978
985
 
979
986
  <!-- ## 📞 Support
980
987
 
package/dist/index.cjs.js CHANGED
@@ -4,6 +4,101 @@ 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
+ * NOTE: In essential mode, networkInfo is not stored.
62
+ * Connection data from ipwho.is API (in customData.ipLocation.connection) is used instead,
63
+ * as it provides more accurate network/ISP information.
64
+ */
65
+ const DEFAULT_ESSENTIAL_NETWORK_FIELDS = [
66
+ 'type',
67
+ 'effectiveType',
68
+ 'downlink',
69
+ 'rtt',
70
+ 'saveData',
71
+ ];
72
+ /**
73
+ * Default essential fields for Location Info storage
74
+ */
75
+ const DEFAULT_ESSENTIAL_LOCATION_FIELDS = [
76
+ // Minimal location fields - only coordinates and source
77
+ // All IP-related data (ip, country, city, etc.) is stored in customData.ipLocation to avoid duplication
78
+ 'lat',
79
+ 'lon',
80
+ 'source',
81
+ 'ts',
82
+ ];
83
+ /**
84
+ * Default essential fields for Attribution Info storage
85
+ */
86
+ const DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = [
87
+ 'landingUrl',
88
+ 'path',
89
+ 'hostname',
90
+ 'referrerUrl',
91
+ 'referrerDomain',
92
+ 'navigationType',
93
+ 'isReload',
94
+ 'isBackForward',
95
+ 'utm_source',
96
+ 'utm_medium',
97
+ 'utm_campaign',
98
+ 'utm_term',
99
+ 'utm_content',
100
+ ];
101
+
7
102
  /**
8
103
  * Network Type Detector
9
104
  * Detects WiFi, Mobile Data (Cellular), Hotspot, Ethernet, or Unknown
@@ -139,11 +234,6 @@ class NetworkDetector {
139
234
  }
140
235
  }
141
236
 
142
- var networkDetector = /*#__PURE__*/Object.freeze({
143
- __proto__: null,
144
- NetworkDetector: NetworkDetector
145
- });
146
-
147
237
  /**
148
238
  * Device Information Detector
149
239
  * Detects device type, OS, browser, and hardware specs
@@ -2010,18 +2100,206 @@ class MetricsCollector {
2010
2100
  // Global metrics collector instance
2011
2101
  const metricsCollector = new MetricsCollector();
2012
2102
 
2103
+ /**
2104
+ * Generic field storage transformer
2105
+ * Filters object fields based on storage configuration
2106
+ */
2107
+ /**
2108
+ * Filter object fields based on storage configuration
2109
+ *
2110
+ * @param data - The data object to filter
2111
+ * @param config - Storage configuration
2112
+ * @param defaultEssentialFields - Default essential fields for this data type
2113
+ * @returns Filtered data object with only configured fields
2114
+ */
2115
+ function filterFieldsByConfig(data, config, defaultEssentialFields) {
2116
+ if (!data) {
2117
+ return null;
2118
+ }
2119
+ const mode = config?.mode || 'essential';
2120
+ let fieldsToInclude = [];
2121
+ if (mode === 'essential') {
2122
+ // Use default essential fields
2123
+ fieldsToInclude = [...defaultEssentialFields];
2124
+ }
2125
+ else if (mode === 'all') {
2126
+ // Include all fields, then exclude specified ones
2127
+ fieldsToInclude = ['*']; // Special marker for "all fields"
2128
+ }
2129
+ else if (mode === 'custom' && config) {
2130
+ // Use custom field list
2131
+ fieldsToInclude = config.fields || [];
2132
+ }
2133
+ // If mode is 'all', just exclude specified fields
2134
+ if (mode === 'all') {
2135
+ const filtered = { ...data };
2136
+ if (config?.exclude && config.exclude.length > 0) {
2137
+ const excludeSet = new Set(config.exclude);
2138
+ Object.keys(filtered).forEach(key => {
2139
+ if (excludeSet.has(key)) {
2140
+ delete filtered[key];
2141
+ }
2142
+ });
2143
+ // Handle nested exclusions
2144
+ if (filtered.connection && excludeSet.has('connection')) {
2145
+ delete filtered.connection;
2146
+ }
2147
+ if (filtered.timezoneDetails && excludeSet.has('timezoneDetails')) {
2148
+ delete filtered.timezoneDetails;
2149
+ }
2150
+ if (filtered.flag && excludeSet.has('flag')) {
2151
+ delete filtered.flag;
2152
+ }
2153
+ if (filtered.firstTouch && excludeSet.has('firstTouch')) {
2154
+ delete filtered.firstTouch;
2155
+ }
2156
+ if (filtered.lastTouch && excludeSet.has('lastTouch')) {
2157
+ delete filtered.lastTouch;
2158
+ }
2159
+ }
2160
+ return filtered;
2161
+ }
2162
+ // For 'essential' or 'custom' mode, only include specified fields
2163
+ const filtered = {};
2164
+ const includeSet = new Set(fieldsToInclude);
2165
+ // Helper to check if a field path should be included
2166
+ const shouldInclude = (fieldPath) => {
2167
+ // Direct match - most specific
2168
+ if (includeSet.has(fieldPath))
2169
+ return true;
2170
+ // For nested fields (e.g., 'flag.emoji'), only include if explicitly listed
2171
+ // Don't auto-include all children just because parent is included
2172
+ const parts = fieldPath.split('.');
2173
+ if (parts.length > 1) {
2174
+ // For nested fields, require explicit inclusion
2175
+ // This prevents 'flag' from including all 'flag.*' fields
2176
+ return includeSet.has(fieldPath);
2177
+ }
2178
+ // For top-level fields only, check if parent path is included
2179
+ // This allows 'connection' to work when all connection.* fields are listed
2180
+ return false;
2181
+ };
2182
+ // Helper to check if a parent object should be created (for nested objects)
2183
+ const shouldIncludeParent = (parentPath) => {
2184
+ // Check if any child of this parent is included
2185
+ for (const field of fieldsToInclude) {
2186
+ if (field.startsWith(parentPath + '.')) {
2187
+ return true;
2188
+ }
2189
+ }
2190
+ // Also check if parent itself is explicitly included
2191
+ return includeSet.has(parentPath);
2192
+ };
2193
+ // Filter top-level fields
2194
+ Object.keys(data).forEach(key => {
2195
+ if (shouldInclude(key)) {
2196
+ filtered[key] = data[key];
2197
+ }
2198
+ });
2199
+ // Handle nested objects - only create if at least one child field is included
2200
+ if (data.connection && shouldIncludeParent('connection')) {
2201
+ filtered.connection = {};
2202
+ if (shouldInclude('connection.asn'))
2203
+ filtered.connection.asn = data.connection.asn;
2204
+ if (shouldInclude('connection.org'))
2205
+ filtered.connection.org = data.connection.org;
2206
+ if (shouldInclude('connection.isp'))
2207
+ filtered.connection.isp = data.connection.isp;
2208
+ if (shouldInclude('connection.domain'))
2209
+ filtered.connection.domain = data.connection.domain;
2210
+ // If no connection fields were included, remove the object
2211
+ if (Object.keys(filtered.connection).length === 0) {
2212
+ delete filtered.connection;
2213
+ }
2214
+ }
2215
+ if (data.timezoneDetails && shouldIncludeParent('timezoneDetails')) {
2216
+ filtered.timezoneDetails = {};
2217
+ if (shouldInclude('timezoneDetails.id'))
2218
+ filtered.timezoneDetails.id = data.timezoneDetails.id;
2219
+ if (shouldInclude('timezoneDetails.abbr'))
2220
+ filtered.timezoneDetails.abbr = data.timezoneDetails.abbr;
2221
+ if (shouldInclude('timezoneDetails.utc'))
2222
+ filtered.timezoneDetails.utc = data.timezoneDetails.utc;
2223
+ if (shouldInclude('timezoneDetails.isDst'))
2224
+ filtered.timezoneDetails.isDst = data.timezoneDetails.isDst;
2225
+ if (shouldInclude('timezoneDetails.offset'))
2226
+ filtered.timezoneDetails.offset = data.timezoneDetails.offset;
2227
+ if (shouldInclude('timezoneDetails.currentTime'))
2228
+ filtered.timezoneDetails.currentTime = data.timezoneDetails.currentTime;
2229
+ // If no timezoneDetails fields were included, remove the object
2230
+ if (Object.keys(filtered.timezoneDetails).length === 0) {
2231
+ delete filtered.timezoneDetails;
2232
+ }
2233
+ }
2234
+ if (data.flag && shouldIncludeParent('flag')) {
2235
+ filtered.flag = {};
2236
+ // Only include specific flag fields if they're explicitly in the include list
2237
+ if (shouldInclude('flag.emoji'))
2238
+ filtered.flag.emoji = data.flag.emoji;
2239
+ if (shouldInclude('flag.img'))
2240
+ filtered.flag.img = data.flag.img;
2241
+ if (shouldInclude('flag.emojiUnicode'))
2242
+ filtered.flag.emojiUnicode = data.flag.emojiUnicode;
2243
+ // If no specific flag fields are included, don't add the flag object
2244
+ if (Object.keys(filtered.flag).length === 0) {
2245
+ delete filtered.flag;
2246
+ }
2247
+ }
2248
+ if (data.firstTouch && shouldInclude('firstTouch')) {
2249
+ filtered.firstTouch = data.firstTouch;
2250
+ }
2251
+ if (data.lastTouch && shouldInclude('lastTouch')) {
2252
+ filtered.lastTouch = data.lastTouch;
2253
+ }
2254
+ // Remove null and undefined values to reduce payload size
2255
+ const cleanValue = (val) => {
2256
+ if (val === null || val === undefined) {
2257
+ return undefined; // Will be filtered out
2258
+ }
2259
+ // For objects, recursively clean nested null/undefined values
2260
+ if (typeof val === 'object' && !Array.isArray(val) && val !== null) {
2261
+ const cleaned = {};
2262
+ let hasAnyValue = false;
2263
+ Object.keys(val).forEach(key => {
2264
+ const cleanedChild = cleanValue(val[key]);
2265
+ if (cleanedChild !== undefined) {
2266
+ cleaned[key] = cleanedChild;
2267
+ hasAnyValue = true;
2268
+ }
2269
+ });
2270
+ return hasAnyValue ? cleaned : undefined;
2271
+ }
2272
+ // For arrays, clean each element
2273
+ if (Array.isArray(val)) {
2274
+ const cleaned = val.map(cleanValue).filter(item => item !== undefined);
2275
+ return cleaned.length > 0 ? cleaned : undefined;
2276
+ }
2277
+ return val;
2278
+ };
2279
+ const cleaned = {};
2280
+ Object.keys(filtered).forEach(key => {
2281
+ const cleanedValue = cleanValue(filtered[key]);
2282
+ if (cleanedValue !== undefined) {
2283
+ cleaned[key] = cleanedValue;
2284
+ }
2285
+ });
2286
+ return cleaned;
2287
+ }
2288
+
2013
2289
  /**
2014
2290
  * Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
2015
- * This ensures compatibility with the analytics backend integration
2291
+ * Supports configurable field storage to optimize storage capacity
2016
2292
  *
2017
2293
  * @param ipLocation - Raw IP location data from ipwho.is API
2018
- * @returns Transformed IP location data matching backend schema
2294
+ * @param config - Optional configuration for which fields to store
2295
+ * @returns Transformed IP location data matching backend schema (only includes configured fields)
2019
2296
  */
2020
- function transformIPLocationForBackend(ipLocation) {
2297
+ function transformIPLocationForBackend(ipLocation, config) {
2021
2298
  if (!ipLocation) {
2022
2299
  return null;
2023
2300
  }
2024
2301
  // Transform to match backend expected format (camelCase)
2302
+ // Build complete object first, then filter based on configuration
2025
2303
  const transformed = {
2026
2304
  // Basic fields
2027
2305
  ip: ipLocation.ip,
@@ -2056,9 +2334,11 @@ function transformIPLocationForBackend(ipLocation) {
2056
2334
  timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
2057
2335
  id: ipLocation.timezone.id,
2058
2336
  abbr: ipLocation.timezone.abbr,
2337
+ utc: ipLocation.timezone.utc,
2338
+ // Exclude these in essential mode: isDst, offset, currentTime
2339
+ // They will be filtered out by filterFieldsByConfig if not in essential fields
2059
2340
  isDst: ipLocation.timezone.is_dst,
2060
2341
  offset: ipLocation.timezone.offset,
2061
- utc: ipLocation.timezone.utc,
2062
2342
  currentTime: ipLocation.timezone.current_time,
2063
2343
  } : undefined,
2064
2344
  // Flag - transform to camelCase
@@ -2074,7 +2354,8 @@ function transformIPLocationForBackend(ipLocation) {
2074
2354
  delete transformed[key];
2075
2355
  }
2076
2356
  });
2077
- return transformed;
2357
+ // Filter fields based on configuration using generic filter
2358
+ return filterFieldsByConfig(transformed, config, DEFAULT_ESSENTIAL_IP_FIELDS);
2078
2359
  }
2079
2360
 
2080
2361
  /**
@@ -2340,20 +2621,56 @@ class AnalyticsService {
2340
2621
  * Track user journey with full context
2341
2622
  */
2342
2623
  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);
2624
+ // Get field storage config (support both new and legacy format)
2625
+ const fieldStorage = this.config.fieldStorage || {};
2626
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2627
+ // Transform and filter all data types based on configuration
2628
+ const transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
2629
+ const filteredDeviceInfo = filterFieldsByConfig(deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2630
+ // In essential mode, don't store browser-based networkInfo
2631
+ // Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
2632
+ const networkInfoConfig = fieldStorage.networkInfo;
2633
+ const networkInfoMode = networkInfoConfig?.mode || 'essential';
2634
+ // Skip networkInfo in essential mode - use connection from ipwho.is instead
2635
+ const filteredNetworkInfo = networkInfoMode === 'essential'
2636
+ ? undefined
2637
+ : filterFieldsByConfig(networkInfo, networkInfoConfig, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2638
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2639
+ // This prevents storing the same data twice (e.g., ip, country, city, region, timezone)
2640
+ const locationConfig = fieldStorage.location;
2641
+ const locationMode = locationConfig?.mode || 'essential';
2642
+ let filteredLocation = filterFieldsByConfig(location, locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2643
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2644
+ // to avoid storing the same data twice
2645
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2646
+ // Remove fields that are duplicated in customData.ipLocation
2647
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2648
+ const minimalLocation = { ...filteredLocation };
2649
+ duplicateFields.forEach(field => {
2650
+ delete minimalLocation[field];
2651
+ });
2652
+ // Only keep essential location fields: lat, lon, source, ts
2653
+ filteredLocation = {
2654
+ lat: minimalLocation.lat,
2655
+ lon: minimalLocation.lon,
2656
+ source: minimalLocation.source,
2657
+ ts: minimalLocation.ts,
2658
+ };
2659
+ }
2660
+ const filteredAttribution = filterFieldsByConfig(attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2345
2661
  await this.trackEvent({
2346
2662
  sessionId,
2347
2663
  pageUrl,
2348
- networkInfo,
2349
- deviceInfo,
2350
- location,
2351
- attribution,
2352
- ipLocation,
2664
+ networkInfo: filteredNetworkInfo || undefined,
2665
+ deviceInfo: filteredDeviceInfo || undefined,
2666
+ location: filteredLocation || undefined,
2667
+ attribution: filteredAttribution || undefined,
2668
+ // Don't include raw ipLocation - we have the filtered/transformed version in customData
2669
+ ipLocation: undefined,
2353
2670
  userId: userId ?? sessionId,
2354
2671
  customData: {
2355
2672
  ...customData,
2356
- // Store transformed IP location in customData for backend integration
2673
+ // Store transformed and filtered IP location in customData for backend integration
2357
2674
  ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2358
2675
  },
2359
2676
  eventName: 'page_view', // Auto-tracked as page view
@@ -2393,14 +2710,14 @@ class AnalyticsService {
2393
2710
  try {
2394
2711
  // Import dynamically to avoid circular dependencies
2395
2712
  const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
2396
- const { NetworkDetector } = await Promise.resolve().then(function () { return networkDetector; });
2397
2713
  const { DeviceDetector } = await Promise.resolve().then(function () { return deviceDetector; });
2398
2714
  const { LocationDetector } = await Promise.resolve().then(function () { return locationDetector; });
2399
2715
  const { AttributionDetector } = await Promise.resolve().then(function () { return attributionDetector; });
2716
+ // Don't collect networkInfo - use connection from ipwho.is instead (more accurate)
2400
2717
  autoContext = {
2401
2718
  sessionId: getOrCreateUserId(),
2402
2719
  pageUrl: window.location.href,
2403
- networkInfo: NetworkDetector.detect(),
2720
+ // networkInfo removed - use customData.ipLocation.connection from ipwho.is instead
2404
2721
  deviceInfo: await DeviceDetector.detect(),
2405
2722
  location: await LocationDetector.detect().catch(() => undefined),
2406
2723
  attribution: AttributionDetector.detect(),
@@ -2430,15 +2747,48 @@ class AnalyticsService {
2430
2747
  const ipLocationData = locationData && typeof locationData === 'object'
2431
2748
  ? locationData?.ipLocationData
2432
2749
  : undefined;
2433
- // Transform IP location data to match backend expected format
2434
- const transformedIPLocation = transformIPLocationForBackend(ipLocationData);
2750
+ // Get field storage config (support both new and legacy format)
2751
+ const fieldStorage = this.config.fieldStorage || {};
2752
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2753
+ // Transform and filter all data types based on configuration
2754
+ const transformedIPLocation = transformIPLocationForBackend(ipLocationData, ipLocationConfig);
2755
+ const filteredDeviceInfo = filterFieldsByConfig(context?.deviceInfo || autoContext?.deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2756
+ // In essential mode, don't store browser-based networkInfo
2757
+ // Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
2758
+ const networkInfoConfig = fieldStorage.networkInfo;
2759
+ const networkInfoMode = networkInfoConfig?.mode || 'essential';
2760
+ // Skip networkInfo in essential mode - use connection from ipwho.is instead
2761
+ const filteredNetworkInfo = networkInfoMode === 'essential'
2762
+ ? undefined
2763
+ : filterFieldsByConfig(context?.networkInfo || autoContext?.networkInfo, networkInfoConfig, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2764
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2765
+ const locationConfig = fieldStorage.location;
2766
+ const locationMode = locationConfig?.mode || 'essential';
2767
+ let filteredLocation = filterFieldsByConfig((context?.location || autoContext?.location), locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2768
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2769
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2770
+ // Remove fields that are duplicated in customData.ipLocation
2771
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2772
+ const minimalLocation = { ...filteredLocation };
2773
+ duplicateFields.forEach(field => {
2774
+ delete minimalLocation[field];
2775
+ });
2776
+ // Only keep essential location fields: lat, lon, source, ts
2777
+ filteredLocation = {
2778
+ lat: minimalLocation.lat,
2779
+ lon: minimalLocation.lon,
2780
+ source: minimalLocation.source,
2781
+ ts: minimalLocation.ts,
2782
+ };
2783
+ }
2784
+ const filteredAttribution = filterFieldsByConfig(context?.attribution || autoContext?.attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2435
2785
  await this.trackEvent({
2436
2786
  sessionId: finalSessionId,
2437
2787
  pageUrl: finalPageUrl,
2438
- networkInfo: context?.networkInfo || autoContext?.networkInfo,
2439
- deviceInfo: context?.deviceInfo || autoContext?.deviceInfo,
2440
- location: context?.location || autoContext?.location,
2441
- attribution: context?.attribution || autoContext?.attribution,
2788
+ networkInfo: filteredNetworkInfo || undefined,
2789
+ deviceInfo: filteredDeviceInfo || undefined,
2790
+ location: filteredLocation || undefined,
2791
+ attribution: filteredAttribution || undefined,
2442
2792
  userId: context?.userId || finalSessionId,
2443
2793
  eventName,
2444
2794
  eventParameters: parameters || {},
@@ -2624,6 +2974,8 @@ function useAnalytics(options = {}) {
2624
2974
  logLevel: config.logLevel,
2625
2975
  enableMetrics: config.enableMetrics,
2626
2976
  sessionTimeout: config.sessionTimeout,
2977
+ fieldStorage: config.fieldStorage,
2978
+ ipLocationFields: config.ipLocationFields, // Legacy support
2627
2979
  });
2628
2980
  }
2629
2981
  }, [
@@ -2666,7 +3018,8 @@ function useAnalytics(options = {}) {
2666
3018
  initDebug();
2667
3019
  }, []);
2668
3020
  const refresh = react.useCallback(async () => {
2669
- const net = NetworkDetector.detect();
3021
+ // Don't collect networkInfo - use connection from ipwho.is instead (more accurate)
3022
+ // const net = NetworkDetector.detect(); // Removed - use ipwho.is connection instead
2670
3023
  const dev = await DeviceDetector.detect();
2671
3024
  const attr = AttributionDetector.detect();
2672
3025
  const uid = getOrCreateUserId();
@@ -2709,7 +3062,8 @@ function useAnalytics(options = {}) {
2709
3062
  lastLocationRef.current = loc;
2710
3063
  }
2711
3064
  }
2712
- setNetworkInfo(net);
3065
+ // networkInfo removed - use connection from ipwho.is instead
3066
+ setNetworkInfo(null); // Set to null - connection data comes from ipwho.is
2713
3067
  setDeviceInfo(dev);
2714
3068
  setAttribution(attr);
2715
3069
  setSessionId(uid);
@@ -2719,13 +3073,14 @@ function useAnalytics(options = {}) {
2719
3073
  if (onReady && !sessionLoggedRef.current) {
2720
3074
  onReady({
2721
3075
  sessionId: uid,
2722
- networkInfo: net,
3076
+ networkInfo: null, // Use connection from ipwho.is instead (more accurate)
2723
3077
  deviceInfo: dev,
2724
3078
  location: loc,
2725
3079
  attribution: attr,
2726
3080
  });
2727
3081
  }
2728
- return { net, dev, attr, loc };
3082
+ // Return null for net - connection data comes from ipwho.is instead
3083
+ return { net: null, dev, attr, loc }; // net is null - use ipwho.is connection instead
2729
3084
  }, [onReady]);
2730
3085
  // Initialize on mount
2731
3086
  react.useEffect(() => {
@@ -2911,6 +3266,11 @@ function useAnalytics(options = {}) {
2911
3266
 
2912
3267
  exports.AnalyticsService = AnalyticsService;
2913
3268
  exports.AttributionDetector = AttributionDetector;
3269
+ exports.DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS;
3270
+ exports.DEFAULT_ESSENTIAL_DEVICE_FIELDS = DEFAULT_ESSENTIAL_DEVICE_FIELDS;
3271
+ exports.DEFAULT_ESSENTIAL_IP_FIELDS = DEFAULT_ESSENTIAL_IP_FIELDS;
3272
+ exports.DEFAULT_ESSENTIAL_LOCATION_FIELDS = DEFAULT_ESSENTIAL_LOCATION_FIELDS;
3273
+ exports.DEFAULT_ESSENTIAL_NETWORK_FIELDS = DEFAULT_ESSENTIAL_NETWORK_FIELDS;
2914
3274
  exports.DeviceDetector = DeviceDetector;
2915
3275
  exports.LocationDetector = LocationDetector;
2916
3276
  exports.NetworkDetector = NetworkDetector;
@@ -2919,6 +3279,7 @@ exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
2919
3279
  exports.clearLocationConsent = clearLocationConsent;
2920
3280
  exports.clearSession = clearSession;
2921
3281
  exports.default = useAnalytics;
3282
+ exports.filterFieldsByConfig = filterFieldsByConfig;
2922
3283
  exports.getCompleteIPLocation = getCompleteIPLocation;
2923
3284
  exports.getIPFromRequest = getIPFromRequest;
2924
3285
  exports.getIPLocation = getIPLocation;