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.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
|
|
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<
|
|
656
|
+
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
565
657
|
*
|
|
566
658
|
* @example
|
|
567
659
|
* ```typescript
|
|
568
|
-
* const
|
|
569
|
-
* console.log('
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
2264
|
-
deviceInfo:
|
|
2265
|
-
location:
|
|
2266
|
-
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:
|
|
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
|