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/CHANGELOG.md +45 -0
- package/README.md +15 -0
- package/dist/index.cjs.js +552 -30
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +95 -4
- package/dist/index.d.ts +95 -4
- package/dist/index.esm.js +545 -31
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
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
|
|
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<
|
|
660
|
+
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
569
661
|
*
|
|
570
662
|
* @example
|
|
571
663
|
* ```typescript
|
|
572
|
-
* const
|
|
573
|
-
* console.log('
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
2268
|
-
deviceInfo:
|
|
2269
|
-
location:
|
|
2270
|
-
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:
|
|
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
|