user-analytics-tracker 4.1.2 → 4.3.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 +19 -0
- package/README.md +20 -1
- package/dist/index.cjs.js +588 -82
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +184 -58
- package/dist/index.d.ts +184 -58
- package/dist/index.esm.js +585 -83
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
4
4
|
|
|
5
5
|
var react = require('react');
|
|
6
6
|
|
|
7
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
7
8
|
/**
|
|
8
9
|
* Core types for the analytics tracker package
|
|
9
10
|
*/
|
|
@@ -14,16 +15,11 @@ var react = require('react');
|
|
|
14
15
|
const DEFAULT_ESSENTIAL_IP_FIELDS = [
|
|
15
16
|
// Core identification
|
|
16
17
|
'ip',
|
|
17
|
-
'country',
|
|
18
18
|
'countryCode',
|
|
19
|
-
'region',
|
|
20
19
|
'city',
|
|
21
20
|
// Geographic coordinates (stored here, not duplicated in location)
|
|
22
21
|
'lat',
|
|
23
22
|
'lon',
|
|
24
|
-
// Additional geographic info
|
|
25
|
-
'continent',
|
|
26
|
-
'continentCode',
|
|
27
23
|
// Network info
|
|
28
24
|
'type',
|
|
29
25
|
'isEu',
|
|
@@ -33,27 +29,14 @@ const DEFAULT_ESSENTIAL_IP_FIELDS = [
|
|
|
33
29
|
'connection.org',
|
|
34
30
|
'connection.isp',
|
|
35
31
|
'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
32
|
];
|
|
45
33
|
/**
|
|
46
34
|
* Default essential fields for Device Info storage
|
|
35
|
+
* In essential mode, only OS and browser are stored
|
|
47
36
|
*/
|
|
48
37
|
const DEFAULT_ESSENTIAL_DEVICE_FIELDS = [
|
|
49
|
-
'type',
|
|
50
38
|
'os',
|
|
51
|
-
'osVersion',
|
|
52
39
|
'browser',
|
|
53
|
-
'browserVersion',
|
|
54
|
-
'deviceModel',
|
|
55
|
-
'deviceBrand',
|
|
56
|
-
'userAgent',
|
|
57
40
|
];
|
|
58
41
|
/**
|
|
59
42
|
* Default essential fields for Network Info storage
|
|
@@ -86,17 +69,6 @@ const DEFAULT_ESSENTIAL_LOCATION_FIELDS = [
|
|
|
86
69
|
const DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = [
|
|
87
70
|
'landingUrl',
|
|
88
71
|
'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
72
|
];
|
|
101
73
|
|
|
102
74
|
/**
|
|
@@ -149,7 +121,7 @@ class NetworkDetector {
|
|
|
149
121
|
const downlink = c.downlink || 0;
|
|
150
122
|
const rtt = c.rtt || 0;
|
|
151
123
|
const saveData = c.saveData || false;
|
|
152
|
-
c.effectiveType?.toLowerCase() || '';
|
|
124
|
+
// const effectiveType = c.effectiveType?.toLowerCase() || ''; // Reserved for future use
|
|
153
125
|
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
154
126
|
const isDesktop = !isMobileDevice;
|
|
155
127
|
// Data saver mode strongly suggests cellular
|
|
@@ -564,6 +536,7 @@ var deviceDetector = /*#__PURE__*/Object.freeze({
|
|
|
564
536
|
DeviceDetector: DeviceDetector
|
|
565
537
|
});
|
|
566
538
|
|
|
539
|
+
/* eslint-disable no-console */
|
|
567
540
|
/**
|
|
568
541
|
* Location Consent Manager
|
|
569
542
|
* When user enters MSISDN, they implicitly consent to location tracking
|
|
@@ -643,44 +616,53 @@ function checkAndSetLocationConsent(msisdn) {
|
|
|
643
616
|
return false;
|
|
644
617
|
}
|
|
645
618
|
|
|
646
|
-
/**
|
|
647
|
-
* IP Geolocation Service
|
|
648
|
-
* Fetches location data (country, region, city) from user's IP address
|
|
649
|
-
* Uses ipwho.is API (no API key required)
|
|
650
|
-
*
|
|
651
|
-
* Stores all keys dynamically from the API response, including nested objects
|
|
652
|
-
* This ensures we capture all available data and any new fields added by the API
|
|
653
|
-
*/
|
|
654
619
|
/**
|
|
655
620
|
* Get complete IP location data from ipwho.is API (HIGH PRIORITY)
|
|
656
621
|
* This is the primary method - gets IP, location, connection, and all data in one call
|
|
657
|
-
* No API key required
|
|
658
622
|
*
|
|
623
|
+
* @param config - Optional configuration for API key and base URL
|
|
659
624
|
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
660
625
|
*
|
|
661
626
|
* @example
|
|
662
627
|
* ```typescript
|
|
628
|
+
* // Without API key (free tier)
|
|
663
629
|
* const location = await getCompleteIPLocation();
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
630
|
+
*
|
|
631
|
+
* // With API key (for higher rate limits)
|
|
632
|
+
* const location = await getCompleteIPLocation({
|
|
633
|
+
* apiKey: '<your-api-key>',
|
|
634
|
+
* baseUrl: 'https://ipwho.is'
|
|
635
|
+
* });
|
|
667
636
|
* ```
|
|
668
637
|
*/
|
|
669
|
-
async function getCompleteIPLocation() {
|
|
638
|
+
async function getCompleteIPLocation(config) {
|
|
670
639
|
// Skip if we're in an environment without fetch (SSR)
|
|
671
640
|
if (typeof fetch === 'undefined') {
|
|
672
641
|
return null;
|
|
673
642
|
}
|
|
643
|
+
// Use provided config or defaults
|
|
644
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
645
|
+
const timeout = config?.timeout || 5000;
|
|
646
|
+
const apiKey = config?.apiKey;
|
|
674
647
|
try {
|
|
648
|
+
// Build URL with optional API key
|
|
649
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
650
|
+
// Add API key as query parameter if provided
|
|
651
|
+
if (apiKey) {
|
|
652
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
url += '/';
|
|
656
|
+
}
|
|
675
657
|
// Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
|
|
676
658
|
// This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
|
|
677
|
-
const response = await fetch(
|
|
659
|
+
const response = await fetch(url, {
|
|
678
660
|
method: 'GET',
|
|
679
661
|
headers: {
|
|
680
662
|
Accept: 'application/json',
|
|
681
663
|
},
|
|
682
664
|
// Add timeout to prevent hanging
|
|
683
|
-
signal: AbortSignal.timeout(
|
|
665
|
+
signal: AbortSignal.timeout(timeout),
|
|
684
666
|
});
|
|
685
667
|
if (!response.ok) {
|
|
686
668
|
return null;
|
|
@@ -742,6 +724,7 @@ async function getCompleteIPLocation() {
|
|
|
742
724
|
* This is kept for backward compatibility and as a fallback
|
|
743
725
|
* Prefer getCompleteIPLocation() which gets everything in one call
|
|
744
726
|
*
|
|
727
|
+
* @param config - Optional configuration for API key and base URL
|
|
745
728
|
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
746
729
|
*
|
|
747
730
|
* @example
|
|
@@ -750,20 +733,31 @@ async function getCompleteIPLocation() {
|
|
|
750
733
|
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
751
734
|
* ```
|
|
752
735
|
*/
|
|
753
|
-
async function getPublicIP() {
|
|
736
|
+
async function getPublicIP(config) {
|
|
754
737
|
// Try to get complete location first (includes IP)
|
|
755
|
-
const completeLocation = await getCompleteIPLocation();
|
|
738
|
+
const completeLocation = await getCompleteIPLocation(config);
|
|
756
739
|
if (completeLocation?.ip) {
|
|
757
740
|
return completeLocation.ip;
|
|
758
741
|
}
|
|
759
742
|
// Fallback: try direct IP fetch (less efficient, lower priority)
|
|
760
743
|
try {
|
|
761
|
-
const
|
|
744
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
745
|
+
const timeout = config?.timeout || 5000;
|
|
746
|
+
const apiKey = config?.apiKey;
|
|
747
|
+
// Build URL with optional API key
|
|
748
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
749
|
+
if (apiKey) {
|
|
750
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
url += '/';
|
|
754
|
+
}
|
|
755
|
+
const response = await fetch(url, {
|
|
762
756
|
method: 'GET',
|
|
763
757
|
headers: {
|
|
764
758
|
Accept: 'application/json',
|
|
765
759
|
},
|
|
766
|
-
signal: AbortSignal.timeout(
|
|
760
|
+
signal: AbortSignal.timeout(timeout),
|
|
767
761
|
});
|
|
768
762
|
if (!response.ok) {
|
|
769
763
|
return null;
|
|
@@ -783,14 +777,28 @@ async function getPublicIP() {
|
|
|
783
777
|
}
|
|
784
778
|
/**
|
|
785
779
|
* Get location from IP address using ipwho.is API (HIGH PRIORITY)
|
|
786
|
-
* Free tier: No API key required
|
|
787
780
|
*
|
|
788
781
|
* Stores all keys dynamically from the API response, including nested objects
|
|
789
782
|
* This ensures we capture all available data and any new fields added by the API
|
|
790
783
|
*
|
|
784
|
+
* @param ip - IP address to geolocate
|
|
785
|
+
* @param config - Optional configuration for API key and base URL
|
|
786
|
+
* @returns Promise<IPLocation | null> - IP location data, or null if unavailable
|
|
787
|
+
*
|
|
788
|
+
* @example
|
|
789
|
+
* ```typescript
|
|
790
|
+
* // Without API key
|
|
791
|
+
* const location = await getIPLocation('203.0.113.42');
|
|
792
|
+
*
|
|
793
|
+
* // With API key
|
|
794
|
+
* const location = await getIPLocation('203.0.113.42', {
|
|
795
|
+
* apiKey: '<your-api-key>'
|
|
796
|
+
* });
|
|
797
|
+
* ```
|
|
798
|
+
*
|
|
791
799
|
* Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
|
|
792
800
|
*/
|
|
793
|
-
async function getIPLocation(ip) {
|
|
801
|
+
async function getIPLocation(ip, config) {
|
|
794
802
|
// Skip localhost/private IPs (these can't be geolocated)
|
|
795
803
|
if (!ip ||
|
|
796
804
|
ip === '0.0.0.0' ||
|
|
@@ -804,14 +812,25 @@ async function getIPLocation(ip) {
|
|
|
804
812
|
return null;
|
|
805
813
|
}
|
|
806
814
|
try {
|
|
807
|
-
//
|
|
808
|
-
const
|
|
815
|
+
// Use provided config or defaults
|
|
816
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
817
|
+
const timeout = config?.timeout || 5000;
|
|
818
|
+
const apiKey = config?.apiKey;
|
|
819
|
+
// Build URL with IP and optional API key
|
|
820
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
821
|
+
url += `/${ip}`;
|
|
822
|
+
// Add API key as query parameter if provided
|
|
823
|
+
if (apiKey) {
|
|
824
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
825
|
+
}
|
|
826
|
+
// Using ipwho.is API
|
|
827
|
+
const response = await fetch(url, {
|
|
809
828
|
method: 'GET',
|
|
810
829
|
headers: {
|
|
811
830
|
Accept: 'application/json',
|
|
812
831
|
},
|
|
813
832
|
// Add timeout to prevent hanging
|
|
814
|
-
signal: AbortSignal.timeout(
|
|
833
|
+
signal: AbortSignal.timeout(timeout),
|
|
815
834
|
});
|
|
816
835
|
if (!response.ok) {
|
|
817
836
|
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
@@ -920,6 +939,23 @@ function getIPFromRequest(req) {
|
|
|
920
939
|
* IP-based location works automatically without user permission
|
|
921
940
|
*/
|
|
922
941
|
class LocationDetector {
|
|
942
|
+
/**
|
|
943
|
+
* Configure IP geolocation settings (API key, base URL, timeout)
|
|
944
|
+
*
|
|
945
|
+
* @param config - IP geolocation configuration
|
|
946
|
+
*
|
|
947
|
+
* @example
|
|
948
|
+
* ```typescript
|
|
949
|
+
* LocationDetector.configureIPGeolocation({
|
|
950
|
+
* apiKey: '<your-ipwho-is-api-key>',
|
|
951
|
+
* baseUrl: 'https://ipwho.is',
|
|
952
|
+
* timeout: 5000
|
|
953
|
+
* });
|
|
954
|
+
* ```
|
|
955
|
+
*/
|
|
956
|
+
static configureIPGeolocation(config) {
|
|
957
|
+
this.ipGeolocationConfig = config;
|
|
958
|
+
}
|
|
923
959
|
/**
|
|
924
960
|
* Detect location using IP-based API only (no GPS, no permission needed)
|
|
925
961
|
* Fast and automatic - works immediately without user interaction
|
|
@@ -1218,14 +1254,15 @@ class LocationDetector {
|
|
|
1218
1254
|
// HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
|
|
1219
1255
|
// This gets IP, location, connection, timezone, flag, and all other data at once
|
|
1220
1256
|
// More efficient than making separate calls
|
|
1221
|
-
|
|
1257
|
+
// Use configured API key and settings if available
|
|
1258
|
+
let ipLocation = await getCompleteIPLocation(this.ipGeolocationConfig || undefined);
|
|
1222
1259
|
// If complete location fetch failed, try fallback: get IP first, then location
|
|
1223
1260
|
if (!ipLocation) {
|
|
1224
1261
|
console.log('[Location] Primary ipwho.is call failed, trying fallback...');
|
|
1225
|
-
const publicIP = await getPublicIP();
|
|
1262
|
+
const publicIP = await getPublicIP(this.ipGeolocationConfig || undefined);
|
|
1226
1263
|
if (publicIP) {
|
|
1227
1264
|
// Fallback: Get location from IP using ipwho.is API
|
|
1228
|
-
ipLocation = await getIPLocation(publicIP);
|
|
1265
|
+
ipLocation = await getIPLocation(publicIP, this.ipGeolocationConfig || undefined);
|
|
1229
1266
|
}
|
|
1230
1267
|
}
|
|
1231
1268
|
if (!ipLocation) {
|
|
@@ -1301,6 +1338,7 @@ LocationDetector.lastLocationRef = { current: null };
|
|
|
1301
1338
|
LocationDetector.locationConsentLoggedRef = { current: false };
|
|
1302
1339
|
LocationDetector.ipLocationFetchingRef = { current: false };
|
|
1303
1340
|
LocationDetector.lastIPLocationRef = { current: null };
|
|
1341
|
+
LocationDetector.ipGeolocationConfig = null;
|
|
1304
1342
|
|
|
1305
1343
|
var locationDetector = /*#__PURE__*/Object.freeze({
|
|
1306
1344
|
__proto__: null,
|
|
@@ -1411,7 +1449,7 @@ function getOrCreateSession(timeout = DEFAULT_SESSION_TIMEOUT) {
|
|
|
1411
1449
|
return updated;
|
|
1412
1450
|
}
|
|
1413
1451
|
// Create new session
|
|
1414
|
-
const sessionId = `session-${Date.now()}-${
|
|
1452
|
+
const sessionId = `session-${Date.now()}-${getSecureRandomString(16)}`;
|
|
1415
1453
|
const newSession = {
|
|
1416
1454
|
sessionId,
|
|
1417
1455
|
startTime: now,
|
|
@@ -1455,6 +1493,25 @@ function clearSession() {
|
|
|
1455
1493
|
// Silently fail
|
|
1456
1494
|
}
|
|
1457
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Generate a cryptographically secure random string.
|
|
1498
|
+
* Length is the number of bytes, which are hex-encoded (2 chars per byte).
|
|
1499
|
+
*/
|
|
1500
|
+
function getSecureRandomString(length) {
|
|
1501
|
+
if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) {
|
|
1502
|
+
const bytes = new Uint8Array(length);
|
|
1503
|
+
window.crypto.getRandomValues(bytes);
|
|
1504
|
+
let result = '';
|
|
1505
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1506
|
+
const hex = bytes[i].toString(16).padStart(2, '0');
|
|
1507
|
+
result += hex;
|
|
1508
|
+
}
|
|
1509
|
+
return result;
|
|
1510
|
+
}
|
|
1511
|
+
// Fallback to a less secure method only if crypto is unavailable.
|
|
1512
|
+
// This should be rare in modern browsers.
|
|
1513
|
+
return Math.random().toString(36).substring(2, 2 + length);
|
|
1514
|
+
}
|
|
1458
1515
|
|
|
1459
1516
|
var storage = /*#__PURE__*/Object.freeze({
|
|
1460
1517
|
__proto__: null,
|
|
@@ -1616,6 +1673,7 @@ var attributionDetector = /*#__PURE__*/Object.freeze({
|
|
|
1616
1673
|
AttributionDetector: AttributionDetector
|
|
1617
1674
|
});
|
|
1618
1675
|
|
|
1676
|
+
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
|
1619
1677
|
/**
|
|
1620
1678
|
* Logger utility for analytics tracker
|
|
1621
1679
|
* Provides configurable logging levels for development and production
|
|
@@ -1666,6 +1724,7 @@ class Logger {
|
|
|
1666
1724
|
}
|
|
1667
1725
|
const logger = new Logger();
|
|
1668
1726
|
|
|
1727
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1669
1728
|
/**
|
|
1670
1729
|
* Queue Manager for Analytics Events
|
|
1671
1730
|
* Handles batching, persistence, and offline support
|
|
@@ -1831,7 +1890,7 @@ class QueueManager {
|
|
|
1831
1890
|
navigator.sendBeacon(this.getEndpointFromCallback(), blob);
|
|
1832
1891
|
this.saveToStorage();
|
|
1833
1892
|
}
|
|
1834
|
-
catch
|
|
1893
|
+
catch {
|
|
1835
1894
|
// Fallback: put events back in queue
|
|
1836
1895
|
this.queue.unshift(...events.map((e) => ({
|
|
1837
1896
|
event: e,
|
|
@@ -2100,6 +2159,7 @@ class MetricsCollector {
|
|
|
2100
2159
|
// Global metrics collector instance
|
|
2101
2160
|
const metricsCollector = new MetricsCollector();
|
|
2102
2161
|
|
|
2162
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2103
2163
|
/**
|
|
2104
2164
|
* Generic field storage transformer
|
|
2105
2165
|
* Filters object fields based on storage configuration
|
|
@@ -2196,6 +2256,11 @@ function filterFieldsByConfig(data, config, defaultEssentialFields) {
|
|
|
2196
2256
|
filtered[key] = data[key];
|
|
2197
2257
|
}
|
|
2198
2258
|
});
|
|
2259
|
+
// If no fields were included, return null (not empty object)
|
|
2260
|
+
// This helps distinguish between "no data" and "filtered out all fields"
|
|
2261
|
+
if (Object.keys(filtered).length === 0) {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2199
2264
|
// Handle nested objects - only create if at least one child field is included
|
|
2200
2265
|
if (data.connection && shouldIncludeParent('connection')) {
|
|
2201
2266
|
filtered.connection = {};
|
|
@@ -2298,6 +2363,12 @@ function transformIPLocationForBackend(ipLocation, config) {
|
|
|
2298
2363
|
if (!ipLocation) {
|
|
2299
2364
|
return null;
|
|
2300
2365
|
}
|
|
2366
|
+
// If ipLocation is a simple object with just ip, preserve it for validation
|
|
2367
|
+
// Skip transformation and filtering to ensure ip is always preserved
|
|
2368
|
+
if (ipLocation.ip && Object.keys(ipLocation).length === 1) {
|
|
2369
|
+
// Return object with ip directly (ip is essential field, always preserved)
|
|
2370
|
+
return { ip: ipLocation.ip };
|
|
2371
|
+
}
|
|
2301
2372
|
// Transform to match backend expected format (camelCase)
|
|
2302
2373
|
// Build complete object first, then filter based on configuration
|
|
2303
2374
|
const transformed = {
|
|
@@ -2358,6 +2429,194 @@ function transformIPLocationForBackend(ipLocation, config) {
|
|
|
2358
2429
|
return filterFieldsByConfig(transformed, config, DEFAULT_ESSENTIAL_IP_FIELDS);
|
|
2359
2430
|
}
|
|
2360
2431
|
|
|
2432
|
+
/**
|
|
2433
|
+
* Extract required fields from analytics event
|
|
2434
|
+
*/
|
|
2435
|
+
function extractRequiredFields(event) {
|
|
2436
|
+
// Extract IP from ipLocation or location or customData.ipLocation
|
|
2437
|
+
const ip = event.ipLocation?.ip ||
|
|
2438
|
+
event.location?.ip ||
|
|
2439
|
+
event.customData?.ipLocation?.ip ||
|
|
2440
|
+
null;
|
|
2441
|
+
// Extract lat/lon from location or ipLocation
|
|
2442
|
+
const lat = event.location?.lat || event.ipLocation?.latitude || event.ipLocation?.lat || null;
|
|
2443
|
+
const lon = event.location?.lon || event.ipLocation?.longitude || event.ipLocation?.lon || null;
|
|
2444
|
+
// Extract mobile status
|
|
2445
|
+
const mobile = event.deviceInfo?.type === 'mobile' ? true : event.deviceInfo?.type ? false : null;
|
|
2446
|
+
// Extract location (city - country removed in essential mode)
|
|
2447
|
+
const location = event.ipLocation?.city ||
|
|
2448
|
+
event.location?.city ||
|
|
2449
|
+
event.ipLocation?.country ||
|
|
2450
|
+
event.location?.country ||
|
|
2451
|
+
null;
|
|
2452
|
+
// Extract msisdn from customData
|
|
2453
|
+
const msisdn = event.customData?.msisdn || null;
|
|
2454
|
+
// Session ID
|
|
2455
|
+
const session = event.sessionId || null;
|
|
2456
|
+
// Extract operator/ISP
|
|
2457
|
+
const operators = event.ipLocation?.connection?.isp ||
|
|
2458
|
+
event.ipLocation?.connection?.org ||
|
|
2459
|
+
null;
|
|
2460
|
+
// Extract page name from URL
|
|
2461
|
+
const pageUrl = event.pageUrl || null;
|
|
2462
|
+
let page = null;
|
|
2463
|
+
if (pageUrl) {
|
|
2464
|
+
try {
|
|
2465
|
+
page = new URL(pageUrl).pathname;
|
|
2466
|
+
}
|
|
2467
|
+
catch {
|
|
2468
|
+
// If URL parsing fails, try to extract pathname manually
|
|
2469
|
+
const match = pageUrl.match(/^https?:\/\/[^/]+(\/.*)?$/);
|
|
2470
|
+
page = match ? (match[1] || '/') : null;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
// Event type
|
|
2474
|
+
const eventType = event.eventName || 'page_view';
|
|
2475
|
+
// Company name from customData
|
|
2476
|
+
const companyName = event.customData?.companyName || null;
|
|
2477
|
+
// Event ID
|
|
2478
|
+
const eventId = event.eventId || null;
|
|
2479
|
+
// Timestamp
|
|
2480
|
+
const timestamp = event.timestamp || null;
|
|
2481
|
+
// GPS source check
|
|
2482
|
+
const gps = event.location?.source === 'gps' ? true :
|
|
2483
|
+
(event.location?.source === 'ip' ? false : null);
|
|
2484
|
+
// OS
|
|
2485
|
+
const os = event.deviceInfo?.os || null;
|
|
2486
|
+
// Browser
|
|
2487
|
+
const browser = event.deviceInfo?.browser || null;
|
|
2488
|
+
// Service ID
|
|
2489
|
+
const serviceId = event.customData?.serviceId || null;
|
|
2490
|
+
return {
|
|
2491
|
+
ip,
|
|
2492
|
+
lat,
|
|
2493
|
+
lon,
|
|
2494
|
+
mobile,
|
|
2495
|
+
location,
|
|
2496
|
+
msisdn,
|
|
2497
|
+
session,
|
|
2498
|
+
operators,
|
|
2499
|
+
page,
|
|
2500
|
+
pageUrl,
|
|
2501
|
+
eventType,
|
|
2502
|
+
companyName,
|
|
2503
|
+
eventId,
|
|
2504
|
+
timestamp,
|
|
2505
|
+
gps,
|
|
2506
|
+
os,
|
|
2507
|
+
browser,
|
|
2508
|
+
serviceId,
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* Validate that event has minimum required fields
|
|
2513
|
+
* Returns true if valid, false if should be filtered out
|
|
2514
|
+
*/
|
|
2515
|
+
function validateRequiredFields(fields) {
|
|
2516
|
+
// Must have at least one of: IP, or (lat AND lon)
|
|
2517
|
+
const hasLocation = fields.ip || (fields.lat !== null && fields.lon !== null);
|
|
2518
|
+
if (!hasLocation) {
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
// Must have session (non-empty string, not 'unknown')
|
|
2522
|
+
if (!fields.session || fields.session === 'unknown' || fields.session.trim() === '') {
|
|
2523
|
+
return false;
|
|
2524
|
+
}
|
|
2525
|
+
// Must have pageUrl (non-empty string)
|
|
2526
|
+
if (!fields.pageUrl || fields.pageUrl.trim() === '') {
|
|
2527
|
+
return false;
|
|
2528
|
+
}
|
|
2529
|
+
// Must have eventType
|
|
2530
|
+
if (!fields.eventType) {
|
|
2531
|
+
return false;
|
|
2532
|
+
}
|
|
2533
|
+
// Must have eventId
|
|
2534
|
+
if (!fields.eventId) {
|
|
2535
|
+
return false;
|
|
2536
|
+
}
|
|
2537
|
+
// Must have timestamp
|
|
2538
|
+
if (!fields.timestamp) {
|
|
2539
|
+
return false;
|
|
2540
|
+
}
|
|
2541
|
+
// Must have at least one of: mobile, OS, or browser
|
|
2542
|
+
const hasDeviceInfo = fields.mobile !== null || fields.os || fields.browser;
|
|
2543
|
+
if (!hasDeviceInfo) {
|
|
2544
|
+
return false;
|
|
2545
|
+
}
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Check if event has null values for critical fields
|
|
2550
|
+
* Returns true if should be filtered out (too many nulls)
|
|
2551
|
+
*/
|
|
2552
|
+
function hasTooManyNulls(fields) {
|
|
2553
|
+
// Only count non-critical fields for null percentage
|
|
2554
|
+
// Critical fields (ip, lat, lon, session, pageUrl, eventType, eventId, timestamp) are already validated
|
|
2555
|
+
const nonCriticalFields = ['mobile', 'location', 'msisdn', 'operators', 'page', 'companyName', 'gps', 'os', 'browser', 'serviceId'];
|
|
2556
|
+
const nullCount = nonCriticalFields.filter(key => {
|
|
2557
|
+
const value = fields[key];
|
|
2558
|
+
return value === null || value === undefined;
|
|
2559
|
+
}).length;
|
|
2560
|
+
const totalNonCritical = nonCriticalFields.length;
|
|
2561
|
+
const nullPercentage = totalNonCritical > 0 ? nullCount / totalNonCritical : 0;
|
|
2562
|
+
// Filter out if more than 70% of non-critical fields are null (more lenient)
|
|
2563
|
+
return nullPercentage > 0.7;
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Validate and filter event
|
|
2567
|
+
* Returns null if event should be filtered out, otherwise returns the event
|
|
2568
|
+
*/
|
|
2569
|
+
function validateEvent(event) {
|
|
2570
|
+
const fields = extractRequiredFields(event);
|
|
2571
|
+
// Check if has too many null values
|
|
2572
|
+
if (hasTooManyNulls(fields)) {
|
|
2573
|
+
logger.debug('Event filtered: too many null values', {
|
|
2574
|
+
fields,
|
|
2575
|
+
sessionId: event.sessionId,
|
|
2576
|
+
pageUrl: event.pageUrl,
|
|
2577
|
+
eventName: event.eventName,
|
|
2578
|
+
});
|
|
2579
|
+
return null;
|
|
2580
|
+
}
|
|
2581
|
+
// Check if has minimum required fields
|
|
2582
|
+
if (!validateRequiredFields(fields)) {
|
|
2583
|
+
logger.debug('Event filtered: missing required fields', {
|
|
2584
|
+
fields,
|
|
2585
|
+
sessionId: event.sessionId,
|
|
2586
|
+
pageUrl: event.pageUrl,
|
|
2587
|
+
eventName: event.eventName,
|
|
2588
|
+
hasIP: !!fields.ip,
|
|
2589
|
+
hasLatLon: fields.lat !== null && fields.lon !== null,
|
|
2590
|
+
hasSession: !!fields.session,
|
|
2591
|
+
hasPageUrl: !!fields.pageUrl,
|
|
2592
|
+
hasEventType: !!fields.eventType,
|
|
2593
|
+
hasEventId: !!fields.eventId,
|
|
2594
|
+
hasTimestamp: !!fields.timestamp,
|
|
2595
|
+
hasDeviceInfo: fields.mobile !== null || !!fields.os || !!fields.browser,
|
|
2596
|
+
});
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
return event;
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Generate a unique key for deduplication
|
|
2603
|
+
* Based on: sessionId + pageUrl + eventName + timestamp (rounded to nearest second)
|
|
2604
|
+
*/
|
|
2605
|
+
function generateDeduplicationKey(event) {
|
|
2606
|
+
const timestamp = event.timestamp instanceof Date
|
|
2607
|
+
? event.timestamp.getTime()
|
|
2608
|
+
: new Date(event.timestamp).getTime();
|
|
2609
|
+
// Round timestamp to nearest second to handle rapid duplicate events
|
|
2610
|
+
const roundedTimestamp = Math.floor(timestamp / 1000) * 1000;
|
|
2611
|
+
const parts = [
|
|
2612
|
+
event.sessionId || '',
|
|
2613
|
+
event.pageUrl || '',
|
|
2614
|
+
event.eventName || 'page_view',
|
|
2615
|
+
roundedTimestamp.toString(),
|
|
2616
|
+
];
|
|
2617
|
+
return parts.join('|');
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2361
2620
|
/**
|
|
2362
2621
|
* Analytics Service
|
|
2363
2622
|
* Sends analytics events to your backend API
|
|
@@ -2411,6 +2670,8 @@ class AnalyticsService {
|
|
|
2411
2670
|
logLevel: 'warn',
|
|
2412
2671
|
...config,
|
|
2413
2672
|
};
|
|
2673
|
+
// Clear seen event keys when reconfiguring (important for tests)
|
|
2674
|
+
this.seenEventKeys.clear();
|
|
2414
2675
|
// Set log level
|
|
2415
2676
|
if (this.config.logLevel) {
|
|
2416
2677
|
logger.setLevel(this.config.logLevel);
|
|
@@ -2425,6 +2686,10 @@ class AnalyticsService {
|
|
|
2425
2686
|
if (this.config.enableMetrics) {
|
|
2426
2687
|
metricsCollector.reset();
|
|
2427
2688
|
}
|
|
2689
|
+
// Configure IP geolocation if provided
|
|
2690
|
+
if (this.config.ipGeolocation) {
|
|
2691
|
+
LocationDetector.configureIPGeolocation(this.config.ipGeolocation);
|
|
2692
|
+
}
|
|
2428
2693
|
this.isInitialized = true;
|
|
2429
2694
|
}
|
|
2430
2695
|
/**
|
|
@@ -2469,10 +2734,56 @@ class AnalyticsService {
|
|
|
2469
2734
|
}
|
|
2470
2735
|
/**
|
|
2471
2736
|
* Send a batch of events with retry logic
|
|
2737
|
+
* Filters out invalid and duplicate events before sending
|
|
2472
2738
|
*/
|
|
2473
2739
|
static async sendBatch(events) {
|
|
2474
|
-
|
|
2740
|
+
// Validate and filter events
|
|
2741
|
+
const validatedEvents = [];
|
|
2742
|
+
const seenInBatch = new Set();
|
|
2743
|
+
for (const event of events) {
|
|
2744
|
+
// Validate event
|
|
2745
|
+
const validated = validateEvent(event);
|
|
2746
|
+
if (!validated) {
|
|
2747
|
+
logger.debug('Event filtered out in batch: missing required fields', {
|
|
2748
|
+
sessionId: event.sessionId,
|
|
2749
|
+
pageUrl: event.pageUrl,
|
|
2750
|
+
hasIPLocation: !!event.ipLocation,
|
|
2751
|
+
hasIP: !!(event.ipLocation?.ip),
|
|
2752
|
+
});
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
// Check for duplicates in this batch only
|
|
2756
|
+
// Note: Events are already deduplicated when queued, so we only check within the batch
|
|
2757
|
+
const dedupeKey = generateDeduplicationKey(validated);
|
|
2758
|
+
if (seenInBatch.has(dedupeKey)) {
|
|
2759
|
+
logger.debug('Duplicate event filtered out in batch', { dedupeKey });
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
// Add to seen events for this batch
|
|
2763
|
+
seenInBatch.add(dedupeKey);
|
|
2764
|
+
// Also add to global seen events cache (for cross-batch deduplication)
|
|
2765
|
+
// But don't filter out if already seen - events from queue are already validated
|
|
2766
|
+
if (!this.seenEventKeys.has(dedupeKey)) {
|
|
2767
|
+
this.seenEventKeys.add(dedupeKey);
|
|
2768
|
+
// Manage cache size
|
|
2769
|
+
if (this.seenEventKeys.size > this.DEDUPE_CACHE_SIZE) {
|
|
2770
|
+
const firstKey = this.seenEventKeys.values().next().value;
|
|
2771
|
+
if (firstKey !== undefined) {
|
|
2772
|
+
this.seenEventKeys.delete(firstKey);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
validatedEvents.push(validated);
|
|
2777
|
+
}
|
|
2778
|
+
if (validatedEvents.length === 0) {
|
|
2779
|
+
logger.debug('All events in batch were filtered out');
|
|
2475
2780
|
return;
|
|
2781
|
+
}
|
|
2782
|
+
// Use validated events
|
|
2783
|
+
if (validatedEvents.length === 0) {
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
events = validatedEvents;
|
|
2476
2787
|
// Apply plugin transformations
|
|
2477
2788
|
const transformedEvents = [];
|
|
2478
2789
|
for (const event of events) {
|
|
@@ -2592,6 +2903,7 @@ class AnalyticsService {
|
|
|
2592
2903
|
/**
|
|
2593
2904
|
* Track user journey/analytics event
|
|
2594
2905
|
* Events are automatically queued and batched
|
|
2906
|
+
* Duplicate events and events with null values are filtered out
|
|
2595
2907
|
*/
|
|
2596
2908
|
static async trackEvent(event) {
|
|
2597
2909
|
const payload = {
|
|
@@ -2599,9 +2911,39 @@ class AnalyticsService {
|
|
|
2599
2911
|
timestamp: new Date(),
|
|
2600
2912
|
eventId: this.generateEventId(),
|
|
2601
2913
|
};
|
|
2914
|
+
// Validate event - filter out if missing required fields or too many nulls
|
|
2915
|
+
const validatedEvent = validateEvent(payload);
|
|
2916
|
+
if (!validatedEvent) {
|
|
2917
|
+
logger.debug('Event filtered out: missing required fields or too many null values', {
|
|
2918
|
+
sessionId: payload.sessionId,
|
|
2919
|
+
pageUrl: payload.pageUrl,
|
|
2920
|
+
eventName: payload.eventName,
|
|
2921
|
+
});
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
// Check for duplicates
|
|
2925
|
+
const dedupeKey = generateDeduplicationKey(validatedEvent);
|
|
2926
|
+
if (this.seenEventKeys.has(dedupeKey)) {
|
|
2927
|
+
logger.debug('Duplicate event filtered out', {
|
|
2928
|
+
dedupeKey,
|
|
2929
|
+
sessionId: validatedEvent.sessionId,
|
|
2930
|
+
pageUrl: validatedEvent.pageUrl,
|
|
2931
|
+
eventName: validatedEvent.eventName,
|
|
2932
|
+
});
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
// Add to seen events (with cache size limit)
|
|
2936
|
+
this.seenEventKeys.add(dedupeKey);
|
|
2937
|
+
if (this.seenEventKeys.size > this.DEDUPE_CACHE_SIZE) {
|
|
2938
|
+
// Remove oldest entries (simple FIFO - remove first entry)
|
|
2939
|
+
const firstKey = this.seenEventKeys.values().next().value;
|
|
2940
|
+
if (firstKey !== undefined) {
|
|
2941
|
+
this.seenEventKeys.delete(firstKey);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2602
2944
|
// If queue is available, use it (browser environment)
|
|
2603
2945
|
if (this.queueManager && typeof window !== 'undefined') {
|
|
2604
|
-
this.queueManager.enqueue(
|
|
2946
|
+
this.queueManager.enqueue(validatedEvent);
|
|
2605
2947
|
// Record metrics
|
|
2606
2948
|
if (this.config.enableMetrics) {
|
|
2607
2949
|
metricsCollector.recordQueued();
|
|
@@ -2611,7 +2953,7 @@ class AnalyticsService {
|
|
|
2611
2953
|
}
|
|
2612
2954
|
// Fallback: send immediately (SSR or queue not initialized)
|
|
2613
2955
|
try {
|
|
2614
|
-
await this.sendBatch([
|
|
2956
|
+
await this.sendBatch([validatedEvent]);
|
|
2615
2957
|
}
|
|
2616
2958
|
catch (err) {
|
|
2617
2959
|
logger.warn('Failed to send event:', err);
|
|
@@ -2620,12 +2962,17 @@ class AnalyticsService {
|
|
|
2620
2962
|
/**
|
|
2621
2963
|
* Track user journey with full context
|
|
2622
2964
|
*/
|
|
2623
|
-
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
|
|
2965
|
+
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits: _pageVisits = 1, interactions: _interactions = 0, }) {
|
|
2624
2966
|
// Get field storage config (support both new and legacy format)
|
|
2625
2967
|
const fieldStorage = this.config.fieldStorage || {};
|
|
2626
2968
|
const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
|
|
2627
2969
|
// Transform and filter all data types based on configuration
|
|
2628
|
-
|
|
2970
|
+
// Use raw ipLocation if transformation returns null (for validation purposes)
|
|
2971
|
+
let transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
|
|
2972
|
+
// Fallback to original ipLocation if transform returns null (needed for validation)
|
|
2973
|
+
if (!transformedIPLocation && ipLocation) {
|
|
2974
|
+
transformedIPLocation = ipLocation;
|
|
2975
|
+
}
|
|
2629
2976
|
const filteredDeviceInfo = filterFieldsByConfig(deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
|
|
2630
2977
|
// In essential mode, don't store browser-based networkInfo
|
|
2631
2978
|
// Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
|
|
@@ -2665,8 +3012,7 @@ class AnalyticsService {
|
|
|
2665
3012
|
deviceInfo: filteredDeviceInfo || undefined,
|
|
2666
3013
|
location: filteredLocation || undefined,
|
|
2667
3014
|
attribution: filteredAttribution || undefined,
|
|
2668
|
-
|
|
2669
|
-
ipLocation: undefined,
|
|
3015
|
+
ipLocation: transformedIPLocation || ipLocation || undefined,
|
|
2670
3016
|
userId: userId ?? sessionId,
|
|
2671
3017
|
customData: {
|
|
2672
3018
|
...customData,
|
|
@@ -2723,7 +3069,7 @@ class AnalyticsService {
|
|
|
2723
3069
|
attribution: AttributionDetector.detect(),
|
|
2724
3070
|
};
|
|
2725
3071
|
}
|
|
2726
|
-
catch
|
|
3072
|
+
catch {
|
|
2727
3073
|
// If auto-collection fails, use minimal context
|
|
2728
3074
|
const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
|
|
2729
3075
|
autoContext = {
|
|
@@ -2740,19 +3086,116 @@ class AnalyticsService {
|
|
|
2740
3086
|
};
|
|
2741
3087
|
}
|
|
2742
3088
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
3089
|
+
// Ensure sessionId is always a valid non-empty string for validation
|
|
3090
|
+
let finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
|
|
3091
|
+
if (finalSessionId === 'unknown' || finalSessionId.trim() === '') {
|
|
3092
|
+
// Try to get from storage if available
|
|
3093
|
+
if (typeof window !== 'undefined') {
|
|
3094
|
+
try {
|
|
3095
|
+
const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
|
|
3096
|
+
finalSessionId = getOrCreateUserId();
|
|
3097
|
+
}
|
|
3098
|
+
catch {
|
|
3099
|
+
// If storage is not available, generate a temporary session ID
|
|
3100
|
+
finalSessionId = `temp-${Date.now()}`;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
else {
|
|
3104
|
+
// SSR environment - generate a temporary session ID
|
|
3105
|
+
finalSessionId = `temp-${Date.now()}`;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
// Ensure pageUrl is always a valid non-empty string for validation
|
|
3109
|
+
let finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
|
|
3110
|
+
// If pageUrl is empty, try to get it from window.location if available
|
|
3111
|
+
if (!finalPageUrl && typeof window !== 'undefined' && window.location) {
|
|
3112
|
+
finalPageUrl = window.location.href;
|
|
3113
|
+
}
|
|
3114
|
+
// If still empty, use a default value to pass validation
|
|
3115
|
+
if (!finalPageUrl || finalPageUrl.trim() === '') {
|
|
3116
|
+
finalPageUrl = 'https://unknown';
|
|
3117
|
+
}
|
|
3118
|
+
// Extract IP location from context.ipLocation, location object, or auto-collected location
|
|
3119
|
+
const ipLocationData = context?.ipLocation ||
|
|
3120
|
+
(context?.location && typeof context.location === 'object'
|
|
3121
|
+
? context.location?.ipLocationData
|
|
3122
|
+
: undefined) ||
|
|
3123
|
+
(autoContext?.location && typeof autoContext.location === 'object'
|
|
3124
|
+
? autoContext.location?.ipLocationData
|
|
3125
|
+
: undefined);
|
|
2750
3126
|
// Get field storage config (support both new and legacy format)
|
|
2751
3127
|
const fieldStorage = this.config.fieldStorage || {};
|
|
2752
3128
|
const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
|
|
2753
3129
|
// Transform and filter all data types based on configuration
|
|
2754
|
-
|
|
2755
|
-
|
|
3130
|
+
// Use raw ipLocationData if transformation returns null (for validation purposes)
|
|
3131
|
+
// Always prioritize context?.ipLocation if it has an IP (most direct and reliable source)
|
|
3132
|
+
const rawIPLocation = (context?.ipLocation && context.ipLocation.ip)
|
|
3133
|
+
? context.ipLocation
|
|
3134
|
+
: (ipLocationData || undefined);
|
|
3135
|
+
// Preserve ip field for validation - always ensure ip is available
|
|
3136
|
+
const preserveIP = rawIPLocation?.ip;
|
|
3137
|
+
let transformedIPLocation = transformIPLocationForBackend(rawIPLocation, ipLocationConfig);
|
|
3138
|
+
// Critical: Ensure ip field is always preserved for validation
|
|
3139
|
+
// If transformation removed ip or returned null/empty, restore it from rawIPLocation
|
|
3140
|
+
if (preserveIP) {
|
|
3141
|
+
if (!transformedIPLocation) {
|
|
3142
|
+
// Transformation returned null, use rawIPLocation
|
|
3143
|
+
transformedIPLocation = rawIPLocation;
|
|
3144
|
+
}
|
|
3145
|
+
else if (Object.keys(transformedIPLocation).length === 0) {
|
|
3146
|
+
// Transformation returned empty object, use rawIPLocation
|
|
3147
|
+
transformedIPLocation = rawIPLocation;
|
|
3148
|
+
}
|
|
3149
|
+
else if (!transformedIPLocation.ip) {
|
|
3150
|
+
// Transformation returned object but without ip field, restore it
|
|
3151
|
+
transformedIPLocation = { ...transformedIPLocation, ip: preserveIP };
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
else if (!transformedIPLocation && rawIPLocation) {
|
|
3155
|
+
// No ip to preserve, but use rawIPLocation if transformation failed
|
|
3156
|
+
transformedIPLocation = rawIPLocation;
|
|
3157
|
+
}
|
|
3158
|
+
const rawDeviceInfo = context?.deviceInfo || autoContext?.deviceInfo;
|
|
3159
|
+
const filteredDeviceInfo = filterFieldsByConfig(rawDeviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
|
|
3160
|
+
// Ensure deviceInfo has os and browser for validation (critical fields)
|
|
3161
|
+
// If filtering removed them, restore from rawDeviceInfo
|
|
3162
|
+
let finalDeviceInfo = filteredDeviceInfo;
|
|
3163
|
+
if (rawDeviceInfo) {
|
|
3164
|
+
if (!finalDeviceInfo) {
|
|
3165
|
+
// Filtering returned null, create minimal object with essential fields
|
|
3166
|
+
finalDeviceInfo = {};
|
|
3167
|
+
if (rawDeviceInfo.os)
|
|
3168
|
+
finalDeviceInfo.os = rawDeviceInfo.os;
|
|
3169
|
+
if (rawDeviceInfo.browser)
|
|
3170
|
+
finalDeviceInfo.browser = rawDeviceInfo.browser;
|
|
3171
|
+
}
|
|
3172
|
+
else {
|
|
3173
|
+
// Ensure os and browser are present for validation
|
|
3174
|
+
if (!finalDeviceInfo.os && rawDeviceInfo.os) {
|
|
3175
|
+
finalDeviceInfo = { ...finalDeviceInfo, os: rawDeviceInfo.os };
|
|
3176
|
+
}
|
|
3177
|
+
if (!finalDeviceInfo.browser && rawDeviceInfo.browser) {
|
|
3178
|
+
finalDeviceInfo = { ...finalDeviceInfo, browser: rawDeviceInfo.browser };
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
// Only use finalDeviceInfo if it has at least os or browser
|
|
3182
|
+
// But if rawDeviceInfo has os or browser, ensure finalDeviceInfo has them
|
|
3183
|
+
if (rawDeviceInfo.os || rawDeviceInfo.browser) {
|
|
3184
|
+
if (!finalDeviceInfo) {
|
|
3185
|
+
finalDeviceInfo = {};
|
|
3186
|
+
}
|
|
3187
|
+
if (rawDeviceInfo.os && !finalDeviceInfo.os) {
|
|
3188
|
+
finalDeviceInfo.os = rawDeviceInfo.os;
|
|
3189
|
+
}
|
|
3190
|
+
if (rawDeviceInfo.browser && !finalDeviceInfo.browser) {
|
|
3191
|
+
finalDeviceInfo.browser = rawDeviceInfo.browser;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
else if (!finalDeviceInfo || (!finalDeviceInfo.os && !finalDeviceInfo.browser)) {
|
|
3195
|
+
// No os or browser in rawDeviceInfo, and finalDeviceInfo doesn't have them either
|
|
3196
|
+
finalDeviceInfo = null;
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
2756
3199
|
// In essential mode, don't store browser-based networkInfo
|
|
2757
3200
|
// Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
|
|
2758
3201
|
const networkInfoConfig = fieldStorage.networkInfo;
|
|
@@ -2782,20 +3225,65 @@ class AnalyticsService {
|
|
|
2782
3225
|
};
|
|
2783
3226
|
}
|
|
2784
3227
|
const filteredAttribution = filterFieldsByConfig(context?.attribution || autoContext?.attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
|
|
3228
|
+
// Ensure ipLocation is available for validation
|
|
3229
|
+
// Always preserve ip field - critical for validation
|
|
3230
|
+
// Use transformedIPLocation if it has ip, otherwise fall back to rawIPLocation
|
|
3231
|
+
let finalIPLocation = undefined;
|
|
3232
|
+
// Priority 1: If we have rawIPLocation with IP, always use it (most reliable source)
|
|
3233
|
+
if (rawIPLocation && rawIPLocation.ip) {
|
|
3234
|
+
if (transformedIPLocation && transformedIPLocation.ip) {
|
|
3235
|
+
// Both have IP, prefer transformed (has filtering applied)
|
|
3236
|
+
finalIPLocation = transformedIPLocation;
|
|
3237
|
+
}
|
|
3238
|
+
else if (transformedIPLocation) {
|
|
3239
|
+
// Transformed exists but no IP, merge with rawIPLocation to preserve IP
|
|
3240
|
+
finalIPLocation = { ...transformedIPLocation, ip: rawIPLocation.ip };
|
|
3241
|
+
}
|
|
3242
|
+
else {
|
|
3243
|
+
// No transformation, use rawIPLocation directly
|
|
3244
|
+
finalIPLocation = rawIPLocation;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
else if (transformedIPLocation) {
|
|
3248
|
+
// No raw IP, but transformation succeeded
|
|
3249
|
+
finalIPLocation = transformedIPLocation;
|
|
3250
|
+
}
|
|
3251
|
+
else if (context?.ipLocation && context.ipLocation.ip) {
|
|
3252
|
+
// Fallback to context.ipLocation if available
|
|
3253
|
+
finalIPLocation = context.ipLocation;
|
|
3254
|
+
}
|
|
3255
|
+
// Final safety check: ensure IP is always present if we have it from any source
|
|
3256
|
+
if (!finalIPLocation || !finalIPLocation.ip) {
|
|
3257
|
+
if (rawIPLocation && rawIPLocation.ip) {
|
|
3258
|
+
finalIPLocation = rawIPLocation;
|
|
3259
|
+
}
|
|
3260
|
+
else if (context?.ipLocation && context.ipLocation.ip) {
|
|
3261
|
+
finalIPLocation = context.ipLocation;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
// Ultimate safeguard: if context.ipLocation has IP, always use it for validation
|
|
3265
|
+
// This ensures validation never fails due to missing IP when it's provided in context
|
|
3266
|
+
if (context?.ipLocation && context.ipLocation.ip) {
|
|
3267
|
+
if (!finalIPLocation || !finalIPLocation.ip) {
|
|
3268
|
+
finalIPLocation = context.ipLocation;
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
2785
3271
|
await this.trackEvent({
|
|
2786
3272
|
sessionId: finalSessionId,
|
|
2787
3273
|
pageUrl: finalPageUrl,
|
|
2788
3274
|
networkInfo: filteredNetworkInfo || undefined,
|
|
2789
|
-
deviceInfo:
|
|
3275
|
+
deviceInfo: (finalDeviceInfo && (finalDeviceInfo.os || finalDeviceInfo.browser)) ? finalDeviceInfo : undefined,
|
|
2790
3276
|
location: filteredLocation || undefined,
|
|
2791
3277
|
attribution: filteredAttribution || undefined,
|
|
3278
|
+
ipLocation: finalIPLocation,
|
|
2792
3279
|
userId: context?.userId || finalSessionId,
|
|
2793
3280
|
eventName,
|
|
2794
3281
|
eventParameters: parameters || {},
|
|
2795
3282
|
customData: {
|
|
2796
3283
|
...(parameters || {}),
|
|
2797
3284
|
// Store transformed IP location in customData for backend integration
|
|
2798
|
-
|
|
3285
|
+
// Use transformed if available, otherwise use raw (for validation)
|
|
3286
|
+
...(finalIPLocation && { ipLocation: transformedIPLocation || finalIPLocation }),
|
|
2799
3287
|
},
|
|
2800
3288
|
});
|
|
2801
3289
|
}
|
|
@@ -2818,13 +3306,13 @@ class AnalyticsService {
|
|
|
2818
3306
|
* });
|
|
2819
3307
|
* ```
|
|
2820
3308
|
*/
|
|
2821
|
-
static async trackPageView(pageName, parameters) {
|
|
3309
|
+
static async trackPageView(pageName, parameters, context) {
|
|
2822
3310
|
const page = pageName || (typeof window !== 'undefined' ? window.location.pathname : '');
|
|
2823
3311
|
await this.logEvent('page_view', {
|
|
2824
3312
|
page_name: page,
|
|
2825
3313
|
page_title: typeof document !== 'undefined' ? document.title : undefined,
|
|
2826
3314
|
...parameters,
|
|
2827
|
-
});
|
|
3315
|
+
}, context);
|
|
2828
3316
|
}
|
|
2829
3317
|
/**
|
|
2830
3318
|
* Manually flush the event queue
|
|
@@ -2865,7 +3353,10 @@ AnalyticsService.apiEndpoint = '/api/analytics';
|
|
|
2865
3353
|
AnalyticsService.queueManager = null;
|
|
2866
3354
|
AnalyticsService.config = {};
|
|
2867
3355
|
AnalyticsService.isInitialized = false;
|
|
3356
|
+
AnalyticsService.seenEventKeys = new Set();
|
|
3357
|
+
AnalyticsService.DEDUPE_CACHE_SIZE = 1000; // Keep last 1000 event keys
|
|
2868
3358
|
|
|
3359
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2869
3360
|
/**
|
|
2870
3361
|
* Debug utilities for analytics tracker
|
|
2871
3362
|
* Provides debugging tools in development mode
|
|
@@ -2944,6 +3435,7 @@ function initDebug() {
|
|
|
2944
3435
|
logger.info('Available methods: getQueue(), getQueueSize(), flushQueue(), clearQueue(), getStats(), setLogLevel()');
|
|
2945
3436
|
}
|
|
2946
3437
|
|
|
3438
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2947
3439
|
/**
|
|
2948
3440
|
* React Hook for Analytics Tracking
|
|
2949
3441
|
* Provides device, network, location, and attribution data
|
|
@@ -2951,11 +3443,17 @@ function initDebug() {
|
|
|
2951
3443
|
/**
|
|
2952
3444
|
* React hook for analytics tracking
|
|
2953
3445
|
*
|
|
3446
|
+
* To use your own ipwho.is API key (higher rate limits), pass ipGeolocation in config.
|
|
3447
|
+
* Use an env var so the key is not committed (e.g. VITE_IPWHOIS_API_KEY, REACT_APP_IPWHOIS_API_KEY).
|
|
3448
|
+
*
|
|
2954
3449
|
* @example
|
|
2955
3450
|
* ```tsx
|
|
2956
3451
|
* const { sessionId, networkInfo, deviceInfo, logEvent } = useAnalytics({
|
|
2957
3452
|
* autoSend: true,
|
|
2958
|
-
* config: {
|
|
3453
|
+
* config: {
|
|
3454
|
+
* apiEndpoint: '/api/analytics',
|
|
3455
|
+
* ipGeolocation: { apiKey: import.meta.env.VITE_IPWHOIS_API_KEY }, // optional; omit for free tier
|
|
3456
|
+
* },
|
|
2959
3457
|
* });
|
|
2960
3458
|
* ```
|
|
2961
3459
|
*/
|
|
@@ -2976,6 +3474,7 @@ function useAnalytics(options = {}) {
|
|
|
2976
3474
|
sessionTimeout: config.sessionTimeout,
|
|
2977
3475
|
fieldStorage: config.fieldStorage,
|
|
2978
3476
|
ipLocationFields: config.ipLocationFields, // Legacy support
|
|
3477
|
+
ipGeolocation: config.ipGeolocation, // ipwho.is API key (use env var, e.g. VITE_IPWHOIS_API_KEY)
|
|
2979
3478
|
});
|
|
2980
3479
|
}
|
|
2981
3480
|
}, [
|
|
@@ -2988,6 +3487,9 @@ function useAnalytics(options = {}) {
|
|
|
2988
3487
|
config?.logLevel,
|
|
2989
3488
|
config?.enableMetrics,
|
|
2990
3489
|
config?.sessionTimeout,
|
|
3490
|
+
config?.fieldStorage,
|
|
3491
|
+
config?.ipLocationFields,
|
|
3492
|
+
config?.ipGeolocation,
|
|
2991
3493
|
]);
|
|
2992
3494
|
const [networkInfo, setNetworkInfo] = react.useState(null);
|
|
2993
3495
|
const [deviceInfo, setDeviceInfo] = react.useState(null);
|
|
@@ -3279,7 +3781,9 @@ exports.checkAndSetLocationConsent = checkAndSetLocationConsent;
|
|
|
3279
3781
|
exports.clearLocationConsent = clearLocationConsent;
|
|
3280
3782
|
exports.clearSession = clearSession;
|
|
3281
3783
|
exports.default = useAnalytics;
|
|
3784
|
+
exports.extractRequiredFields = extractRequiredFields;
|
|
3282
3785
|
exports.filterFieldsByConfig = filterFieldsByConfig;
|
|
3786
|
+
exports.generateDeduplicationKey = generateDeduplicationKey;
|
|
3283
3787
|
exports.getCompleteIPLocation = getCompleteIPLocation;
|
|
3284
3788
|
exports.getIPFromRequest = getIPFromRequest;
|
|
3285
3789
|
exports.getIPLocation = getIPLocation;
|
|
@@ -3300,4 +3804,6 @@ exports.trackPageVisit = trackPageVisit;
|
|
|
3300
3804
|
exports.transformIPLocationForBackend = transformIPLocationForBackend;
|
|
3301
3805
|
exports.updateSessionActivity = updateSessionActivity;
|
|
3302
3806
|
exports.useAnalytics = useAnalytics;
|
|
3807
|
+
exports.validateEvent = validateEvent;
|
|
3808
|
+
exports.validateRequiredFields = validateRequiredFields;
|
|
3303
3809
|
//# sourceMappingURL=index.cjs.js.map
|