user-analytics-tracker 4.1.2 → 4.2.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 +12 -0
- package/dist/index.cjs.js +579 -81
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.cts +164 -57
- package/dist/index.d.ts +164 -57
- package/dist/index.esm.js +576 -82
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
2
2
|
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
4
|
/**
|
|
4
5
|
* Core types for the analytics tracker package
|
|
5
6
|
*/
|
|
@@ -10,16 +11,11 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
|
10
11
|
const DEFAULT_ESSENTIAL_IP_FIELDS = [
|
|
11
12
|
// Core identification
|
|
12
13
|
'ip',
|
|
13
|
-
'country',
|
|
14
14
|
'countryCode',
|
|
15
|
-
'region',
|
|
16
15
|
'city',
|
|
17
16
|
// Geographic coordinates (stored here, not duplicated in location)
|
|
18
17
|
'lat',
|
|
19
18
|
'lon',
|
|
20
|
-
// Additional geographic info
|
|
21
|
-
'continent',
|
|
22
|
-
'continentCode',
|
|
23
19
|
// Network info
|
|
24
20
|
'type',
|
|
25
21
|
'isEu',
|
|
@@ -29,27 +25,14 @@ const DEFAULT_ESSENTIAL_IP_FIELDS = [
|
|
|
29
25
|
'connection.org',
|
|
30
26
|
'connection.isp',
|
|
31
27
|
'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
28
|
];
|
|
41
29
|
/**
|
|
42
30
|
* Default essential fields for Device Info storage
|
|
31
|
+
* In essential mode, only OS and browser are stored
|
|
43
32
|
*/
|
|
44
33
|
const DEFAULT_ESSENTIAL_DEVICE_FIELDS = [
|
|
45
|
-
'type',
|
|
46
34
|
'os',
|
|
47
|
-
'osVersion',
|
|
48
35
|
'browser',
|
|
49
|
-
'browserVersion',
|
|
50
|
-
'deviceModel',
|
|
51
|
-
'deviceBrand',
|
|
52
|
-
'userAgent',
|
|
53
36
|
];
|
|
54
37
|
/**
|
|
55
38
|
* Default essential fields for Network Info storage
|
|
@@ -82,17 +65,6 @@ const DEFAULT_ESSENTIAL_LOCATION_FIELDS = [
|
|
|
82
65
|
const DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = [
|
|
83
66
|
'landingUrl',
|
|
84
67
|
'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
68
|
];
|
|
97
69
|
|
|
98
70
|
/**
|
|
@@ -145,7 +117,7 @@ class NetworkDetector {
|
|
|
145
117
|
const downlink = c.downlink || 0;
|
|
146
118
|
const rtt = c.rtt || 0;
|
|
147
119
|
const saveData = c.saveData || false;
|
|
148
|
-
c.effectiveType?.toLowerCase() || '';
|
|
120
|
+
// const effectiveType = c.effectiveType?.toLowerCase() || ''; // Reserved for future use
|
|
149
121
|
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
150
122
|
const isDesktop = !isMobileDevice;
|
|
151
123
|
// Data saver mode strongly suggests cellular
|
|
@@ -560,6 +532,7 @@ var deviceDetector = /*#__PURE__*/Object.freeze({
|
|
|
560
532
|
DeviceDetector: DeviceDetector
|
|
561
533
|
});
|
|
562
534
|
|
|
535
|
+
/* eslint-disable no-console */
|
|
563
536
|
/**
|
|
564
537
|
* Location Consent Manager
|
|
565
538
|
* When user enters MSISDN, they implicitly consent to location tracking
|
|
@@ -639,44 +612,53 @@ function checkAndSetLocationConsent(msisdn) {
|
|
|
639
612
|
return false;
|
|
640
613
|
}
|
|
641
614
|
|
|
642
|
-
/**
|
|
643
|
-
* IP Geolocation Service
|
|
644
|
-
* Fetches location data (country, region, city) from user's IP address
|
|
645
|
-
* Uses ipwho.is API (no API key required)
|
|
646
|
-
*
|
|
647
|
-
* Stores all keys dynamically from the API response, including nested objects
|
|
648
|
-
* This ensures we capture all available data and any new fields added by the API
|
|
649
|
-
*/
|
|
650
615
|
/**
|
|
651
616
|
* Get complete IP location data from ipwho.is API (HIGH PRIORITY)
|
|
652
617
|
* This is the primary method - gets IP, location, connection, and all data in one call
|
|
653
|
-
* No API key required
|
|
654
618
|
*
|
|
619
|
+
* @param config - Optional configuration for API key and base URL
|
|
655
620
|
* @returns Promise<IPLocation | null> - Complete IP location data, or null if unavailable
|
|
656
621
|
*
|
|
657
622
|
* @example
|
|
658
623
|
* ```typescript
|
|
624
|
+
* // Without API key (free tier)
|
|
659
625
|
* const location = await getCompleteIPLocation();
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
626
|
+
*
|
|
627
|
+
* // With API key (for higher rate limits)
|
|
628
|
+
* const location = await getCompleteIPLocation({
|
|
629
|
+
* apiKey: '<your-api-key>',
|
|
630
|
+
* baseUrl: 'https://ipwho.is'
|
|
631
|
+
* });
|
|
663
632
|
* ```
|
|
664
633
|
*/
|
|
665
|
-
async function getCompleteIPLocation() {
|
|
634
|
+
async function getCompleteIPLocation(config) {
|
|
666
635
|
// Skip if we're in an environment without fetch (SSR)
|
|
667
636
|
if (typeof fetch === 'undefined') {
|
|
668
637
|
return null;
|
|
669
638
|
}
|
|
639
|
+
// Use provided config or defaults
|
|
640
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
641
|
+
const timeout = config?.timeout || 5000;
|
|
642
|
+
const apiKey = config?.apiKey;
|
|
670
643
|
try {
|
|
644
|
+
// Build URL with optional API key
|
|
645
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
646
|
+
// Add API key as query parameter if provided
|
|
647
|
+
if (apiKey) {
|
|
648
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
url += '/';
|
|
652
|
+
}
|
|
671
653
|
// Call ipwho.is without IP parameter - it auto-detects user's IP and returns everything
|
|
672
654
|
// This is the HIGH PRIORITY source - gets IP, location, connection, timezone, flag, etc. in one call
|
|
673
|
-
const response = await fetch(
|
|
655
|
+
const response = await fetch(url, {
|
|
674
656
|
method: 'GET',
|
|
675
657
|
headers: {
|
|
676
658
|
Accept: 'application/json',
|
|
677
659
|
},
|
|
678
660
|
// Add timeout to prevent hanging
|
|
679
|
-
signal: AbortSignal.timeout(
|
|
661
|
+
signal: AbortSignal.timeout(timeout),
|
|
680
662
|
});
|
|
681
663
|
if (!response.ok) {
|
|
682
664
|
return null;
|
|
@@ -738,6 +720,7 @@ async function getCompleteIPLocation() {
|
|
|
738
720
|
* This is kept for backward compatibility and as a fallback
|
|
739
721
|
* Prefer getCompleteIPLocation() which gets everything in one call
|
|
740
722
|
*
|
|
723
|
+
* @param config - Optional configuration for API key and base URL
|
|
741
724
|
* @returns Promise<string | null> - The public IP address, or null if unavailable
|
|
742
725
|
*
|
|
743
726
|
* @example
|
|
@@ -746,20 +729,31 @@ async function getCompleteIPLocation() {
|
|
|
746
729
|
* console.log('Your IP:', ip); // e.g., "203.0.113.42"
|
|
747
730
|
* ```
|
|
748
731
|
*/
|
|
749
|
-
async function getPublicIP() {
|
|
732
|
+
async function getPublicIP(config) {
|
|
750
733
|
// Try to get complete location first (includes IP)
|
|
751
|
-
const completeLocation = await getCompleteIPLocation();
|
|
734
|
+
const completeLocation = await getCompleteIPLocation(config);
|
|
752
735
|
if (completeLocation?.ip) {
|
|
753
736
|
return completeLocation.ip;
|
|
754
737
|
}
|
|
755
738
|
// Fallback: try direct IP fetch (less efficient, lower priority)
|
|
756
739
|
try {
|
|
757
|
-
const
|
|
740
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
741
|
+
const timeout = config?.timeout || 5000;
|
|
742
|
+
const apiKey = config?.apiKey;
|
|
743
|
+
// Build URL with optional API key
|
|
744
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
745
|
+
if (apiKey) {
|
|
746
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
url += '/';
|
|
750
|
+
}
|
|
751
|
+
const response = await fetch(url, {
|
|
758
752
|
method: 'GET',
|
|
759
753
|
headers: {
|
|
760
754
|
Accept: 'application/json',
|
|
761
755
|
},
|
|
762
|
-
signal: AbortSignal.timeout(
|
|
756
|
+
signal: AbortSignal.timeout(timeout),
|
|
763
757
|
});
|
|
764
758
|
if (!response.ok) {
|
|
765
759
|
return null;
|
|
@@ -779,14 +773,28 @@ async function getPublicIP() {
|
|
|
779
773
|
}
|
|
780
774
|
/**
|
|
781
775
|
* Get location from IP address using ipwho.is API (HIGH PRIORITY)
|
|
782
|
-
* Free tier: No API key required
|
|
783
776
|
*
|
|
784
777
|
* Stores all keys dynamically from the API response, including nested objects
|
|
785
778
|
* This ensures we capture all available data and any new fields added by the API
|
|
786
779
|
*
|
|
780
|
+
* @param ip - IP address to geolocate
|
|
781
|
+
* @param config - Optional configuration for API key and base URL
|
|
782
|
+
* @returns Promise<IPLocation | null> - IP location data, or null if unavailable
|
|
783
|
+
*
|
|
784
|
+
* @example
|
|
785
|
+
* ```typescript
|
|
786
|
+
* // Without API key
|
|
787
|
+
* const location = await getIPLocation('203.0.113.42');
|
|
788
|
+
*
|
|
789
|
+
* // With API key
|
|
790
|
+
* const location = await getIPLocation('203.0.113.42', {
|
|
791
|
+
* apiKey: '<your-api-key>'
|
|
792
|
+
* });
|
|
793
|
+
* ```
|
|
794
|
+
*
|
|
787
795
|
* Note: If you don't have an IP yet, use getCompleteIPLocation() which gets everything in one call
|
|
788
796
|
*/
|
|
789
|
-
async function getIPLocation(ip) {
|
|
797
|
+
async function getIPLocation(ip, config) {
|
|
790
798
|
// Skip localhost/private IPs (these can't be geolocated)
|
|
791
799
|
if (!ip ||
|
|
792
800
|
ip === '0.0.0.0' ||
|
|
@@ -800,14 +808,25 @@ async function getIPLocation(ip) {
|
|
|
800
808
|
return null;
|
|
801
809
|
}
|
|
802
810
|
try {
|
|
803
|
-
//
|
|
804
|
-
const
|
|
811
|
+
// Use provided config or defaults
|
|
812
|
+
const baseUrl = config?.baseUrl || 'https://ipwho.is';
|
|
813
|
+
const timeout = config?.timeout || 5000;
|
|
814
|
+
const apiKey = config?.apiKey;
|
|
815
|
+
// Build URL with IP and optional API key
|
|
816
|
+
let url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
817
|
+
url += `/${ip}`;
|
|
818
|
+
// Add API key as query parameter if provided
|
|
819
|
+
if (apiKey) {
|
|
820
|
+
url += `?key=${encodeURIComponent(apiKey)}`;
|
|
821
|
+
}
|
|
822
|
+
// Using ipwho.is API
|
|
823
|
+
const response = await fetch(url, {
|
|
805
824
|
method: 'GET',
|
|
806
825
|
headers: {
|
|
807
826
|
Accept: 'application/json',
|
|
808
827
|
},
|
|
809
828
|
// Add timeout to prevent hanging
|
|
810
|
-
signal: AbortSignal.timeout(
|
|
829
|
+
signal: AbortSignal.timeout(timeout),
|
|
811
830
|
});
|
|
812
831
|
if (!response.ok) {
|
|
813
832
|
console.warn(`[IP Geolocation] Failed to fetch location for IP ${ip}: ${response.status}`);
|
|
@@ -916,6 +935,23 @@ function getIPFromRequest(req) {
|
|
|
916
935
|
* IP-based location works automatically without user permission
|
|
917
936
|
*/
|
|
918
937
|
class LocationDetector {
|
|
938
|
+
/**
|
|
939
|
+
* Configure IP geolocation settings (API key, base URL, timeout)
|
|
940
|
+
*
|
|
941
|
+
* @param config - IP geolocation configuration
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* ```typescript
|
|
945
|
+
* LocationDetector.configureIPGeolocation({
|
|
946
|
+
* apiKey: '<your-ipwho-is-api-key>',
|
|
947
|
+
* baseUrl: 'https://ipwho.is',
|
|
948
|
+
* timeout: 5000
|
|
949
|
+
* });
|
|
950
|
+
* ```
|
|
951
|
+
*/
|
|
952
|
+
static configureIPGeolocation(config) {
|
|
953
|
+
this.ipGeolocationConfig = config;
|
|
954
|
+
}
|
|
919
955
|
/**
|
|
920
956
|
* Detect location using IP-based API only (no GPS, no permission needed)
|
|
921
957
|
* Fast and automatic - works immediately without user interaction
|
|
@@ -1214,14 +1250,15 @@ class LocationDetector {
|
|
|
1214
1250
|
// HIGH PRIORITY: Get complete IP location data from ipwho.is in one call
|
|
1215
1251
|
// This gets IP, location, connection, timezone, flag, and all other data at once
|
|
1216
1252
|
// More efficient than making separate calls
|
|
1217
|
-
|
|
1253
|
+
// Use configured API key and settings if available
|
|
1254
|
+
let ipLocation = await getCompleteIPLocation(this.ipGeolocationConfig || undefined);
|
|
1218
1255
|
// If complete location fetch failed, try fallback: get IP first, then location
|
|
1219
1256
|
if (!ipLocation) {
|
|
1220
1257
|
console.log('[Location] Primary ipwho.is call failed, trying fallback...');
|
|
1221
|
-
const publicIP = await getPublicIP();
|
|
1258
|
+
const publicIP = await getPublicIP(this.ipGeolocationConfig || undefined);
|
|
1222
1259
|
if (publicIP) {
|
|
1223
1260
|
// Fallback: Get location from IP using ipwho.is API
|
|
1224
|
-
ipLocation = await getIPLocation(publicIP);
|
|
1261
|
+
ipLocation = await getIPLocation(publicIP, this.ipGeolocationConfig || undefined);
|
|
1225
1262
|
}
|
|
1226
1263
|
}
|
|
1227
1264
|
if (!ipLocation) {
|
|
@@ -1297,6 +1334,7 @@ LocationDetector.lastLocationRef = { current: null };
|
|
|
1297
1334
|
LocationDetector.locationConsentLoggedRef = { current: false };
|
|
1298
1335
|
LocationDetector.ipLocationFetchingRef = { current: false };
|
|
1299
1336
|
LocationDetector.lastIPLocationRef = { current: null };
|
|
1337
|
+
LocationDetector.ipGeolocationConfig = null;
|
|
1300
1338
|
|
|
1301
1339
|
var locationDetector = /*#__PURE__*/Object.freeze({
|
|
1302
1340
|
__proto__: null,
|
|
@@ -1407,7 +1445,7 @@ function getOrCreateSession(timeout = DEFAULT_SESSION_TIMEOUT) {
|
|
|
1407
1445
|
return updated;
|
|
1408
1446
|
}
|
|
1409
1447
|
// Create new session
|
|
1410
|
-
const sessionId = `session-${Date.now()}-${
|
|
1448
|
+
const sessionId = `session-${Date.now()}-${getSecureRandomString(16)}`;
|
|
1411
1449
|
const newSession = {
|
|
1412
1450
|
sessionId,
|
|
1413
1451
|
startTime: now,
|
|
@@ -1451,6 +1489,25 @@ function clearSession() {
|
|
|
1451
1489
|
// Silently fail
|
|
1452
1490
|
}
|
|
1453
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Generate a cryptographically secure random string.
|
|
1494
|
+
* Length is the number of bytes, which are hex-encoded (2 chars per byte).
|
|
1495
|
+
*/
|
|
1496
|
+
function getSecureRandomString(length) {
|
|
1497
|
+
if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) {
|
|
1498
|
+
const bytes = new Uint8Array(length);
|
|
1499
|
+
window.crypto.getRandomValues(bytes);
|
|
1500
|
+
let result = '';
|
|
1501
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1502
|
+
const hex = bytes[i].toString(16).padStart(2, '0');
|
|
1503
|
+
result += hex;
|
|
1504
|
+
}
|
|
1505
|
+
return result;
|
|
1506
|
+
}
|
|
1507
|
+
// Fallback to a less secure method only if crypto is unavailable.
|
|
1508
|
+
// This should be rare in modern browsers.
|
|
1509
|
+
return Math.random().toString(36).substring(2, 2 + length);
|
|
1510
|
+
}
|
|
1454
1511
|
|
|
1455
1512
|
var storage = /*#__PURE__*/Object.freeze({
|
|
1456
1513
|
__proto__: null,
|
|
@@ -1612,6 +1669,7 @@ var attributionDetector = /*#__PURE__*/Object.freeze({
|
|
|
1612
1669
|
AttributionDetector: AttributionDetector
|
|
1613
1670
|
});
|
|
1614
1671
|
|
|
1672
|
+
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
|
1615
1673
|
/**
|
|
1616
1674
|
* Logger utility for analytics tracker
|
|
1617
1675
|
* Provides configurable logging levels for development and production
|
|
@@ -1662,6 +1720,7 @@ class Logger {
|
|
|
1662
1720
|
}
|
|
1663
1721
|
const logger = new Logger();
|
|
1664
1722
|
|
|
1723
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1665
1724
|
/**
|
|
1666
1725
|
* Queue Manager for Analytics Events
|
|
1667
1726
|
* Handles batching, persistence, and offline support
|
|
@@ -1827,7 +1886,7 @@ class QueueManager {
|
|
|
1827
1886
|
navigator.sendBeacon(this.getEndpointFromCallback(), blob);
|
|
1828
1887
|
this.saveToStorage();
|
|
1829
1888
|
}
|
|
1830
|
-
catch
|
|
1889
|
+
catch {
|
|
1831
1890
|
// Fallback: put events back in queue
|
|
1832
1891
|
this.queue.unshift(...events.map((e) => ({
|
|
1833
1892
|
event: e,
|
|
@@ -2096,6 +2155,7 @@ class MetricsCollector {
|
|
|
2096
2155
|
// Global metrics collector instance
|
|
2097
2156
|
const metricsCollector = new MetricsCollector();
|
|
2098
2157
|
|
|
2158
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2099
2159
|
/**
|
|
2100
2160
|
* Generic field storage transformer
|
|
2101
2161
|
* Filters object fields based on storage configuration
|
|
@@ -2192,6 +2252,11 @@ function filterFieldsByConfig(data, config, defaultEssentialFields) {
|
|
|
2192
2252
|
filtered[key] = data[key];
|
|
2193
2253
|
}
|
|
2194
2254
|
});
|
|
2255
|
+
// If no fields were included, return null (not empty object)
|
|
2256
|
+
// This helps distinguish between "no data" and "filtered out all fields"
|
|
2257
|
+
if (Object.keys(filtered).length === 0) {
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2195
2260
|
// Handle nested objects - only create if at least one child field is included
|
|
2196
2261
|
if (data.connection && shouldIncludeParent('connection')) {
|
|
2197
2262
|
filtered.connection = {};
|
|
@@ -2294,6 +2359,12 @@ function transformIPLocationForBackend(ipLocation, config) {
|
|
|
2294
2359
|
if (!ipLocation) {
|
|
2295
2360
|
return null;
|
|
2296
2361
|
}
|
|
2362
|
+
// If ipLocation is a simple object with just ip, preserve it for validation
|
|
2363
|
+
// Skip transformation and filtering to ensure ip is always preserved
|
|
2364
|
+
if (ipLocation.ip && Object.keys(ipLocation).length === 1) {
|
|
2365
|
+
// Return object with ip directly (ip is essential field, always preserved)
|
|
2366
|
+
return { ip: ipLocation.ip };
|
|
2367
|
+
}
|
|
2297
2368
|
// Transform to match backend expected format (camelCase)
|
|
2298
2369
|
// Build complete object first, then filter based on configuration
|
|
2299
2370
|
const transformed = {
|
|
@@ -2354,6 +2425,194 @@ function transformIPLocationForBackend(ipLocation, config) {
|
|
|
2354
2425
|
return filterFieldsByConfig(transformed, config, DEFAULT_ESSENTIAL_IP_FIELDS);
|
|
2355
2426
|
}
|
|
2356
2427
|
|
|
2428
|
+
/**
|
|
2429
|
+
* Extract required fields from analytics event
|
|
2430
|
+
*/
|
|
2431
|
+
function extractRequiredFields(event) {
|
|
2432
|
+
// Extract IP from ipLocation or location or customData.ipLocation
|
|
2433
|
+
const ip = event.ipLocation?.ip ||
|
|
2434
|
+
event.location?.ip ||
|
|
2435
|
+
event.customData?.ipLocation?.ip ||
|
|
2436
|
+
null;
|
|
2437
|
+
// Extract lat/lon from location or ipLocation
|
|
2438
|
+
const lat = event.location?.lat || event.ipLocation?.latitude || event.ipLocation?.lat || null;
|
|
2439
|
+
const lon = event.location?.lon || event.ipLocation?.longitude || event.ipLocation?.lon || null;
|
|
2440
|
+
// Extract mobile status
|
|
2441
|
+
const mobile = event.deviceInfo?.type === 'mobile' ? true : event.deviceInfo?.type ? false : null;
|
|
2442
|
+
// Extract location (city - country removed in essential mode)
|
|
2443
|
+
const location = event.ipLocation?.city ||
|
|
2444
|
+
event.location?.city ||
|
|
2445
|
+
event.ipLocation?.country ||
|
|
2446
|
+
event.location?.country ||
|
|
2447
|
+
null;
|
|
2448
|
+
// Extract msisdn from customData
|
|
2449
|
+
const msisdn = event.customData?.msisdn || null;
|
|
2450
|
+
// Session ID
|
|
2451
|
+
const session = event.sessionId || null;
|
|
2452
|
+
// Extract operator/ISP
|
|
2453
|
+
const operators = event.ipLocation?.connection?.isp ||
|
|
2454
|
+
event.ipLocation?.connection?.org ||
|
|
2455
|
+
null;
|
|
2456
|
+
// Extract page name from URL
|
|
2457
|
+
const pageUrl = event.pageUrl || null;
|
|
2458
|
+
let page = null;
|
|
2459
|
+
if (pageUrl) {
|
|
2460
|
+
try {
|
|
2461
|
+
page = new URL(pageUrl).pathname;
|
|
2462
|
+
}
|
|
2463
|
+
catch {
|
|
2464
|
+
// If URL parsing fails, try to extract pathname manually
|
|
2465
|
+
const match = pageUrl.match(/^https?:\/\/[^/]+(\/.*)?$/);
|
|
2466
|
+
page = match ? (match[1] || '/') : null;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
// Event type
|
|
2470
|
+
const eventType = event.eventName || 'page_view';
|
|
2471
|
+
// Company name from customData
|
|
2472
|
+
const companyName = event.customData?.companyName || null;
|
|
2473
|
+
// Event ID
|
|
2474
|
+
const eventId = event.eventId || null;
|
|
2475
|
+
// Timestamp
|
|
2476
|
+
const timestamp = event.timestamp || null;
|
|
2477
|
+
// GPS source check
|
|
2478
|
+
const gps = event.location?.source === 'gps' ? true :
|
|
2479
|
+
(event.location?.source === 'ip' ? false : null);
|
|
2480
|
+
// OS
|
|
2481
|
+
const os = event.deviceInfo?.os || null;
|
|
2482
|
+
// Browser
|
|
2483
|
+
const browser = event.deviceInfo?.browser || null;
|
|
2484
|
+
// Service ID
|
|
2485
|
+
const serviceId = event.customData?.serviceId || null;
|
|
2486
|
+
return {
|
|
2487
|
+
ip,
|
|
2488
|
+
lat,
|
|
2489
|
+
lon,
|
|
2490
|
+
mobile,
|
|
2491
|
+
location,
|
|
2492
|
+
msisdn,
|
|
2493
|
+
session,
|
|
2494
|
+
operators,
|
|
2495
|
+
page,
|
|
2496
|
+
pageUrl,
|
|
2497
|
+
eventType,
|
|
2498
|
+
companyName,
|
|
2499
|
+
eventId,
|
|
2500
|
+
timestamp,
|
|
2501
|
+
gps,
|
|
2502
|
+
os,
|
|
2503
|
+
browser,
|
|
2504
|
+
serviceId,
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* Validate that event has minimum required fields
|
|
2509
|
+
* Returns true if valid, false if should be filtered out
|
|
2510
|
+
*/
|
|
2511
|
+
function validateRequiredFields(fields) {
|
|
2512
|
+
// Must have at least one of: IP, or (lat AND lon)
|
|
2513
|
+
const hasLocation = fields.ip || (fields.lat !== null && fields.lon !== null);
|
|
2514
|
+
if (!hasLocation) {
|
|
2515
|
+
return false;
|
|
2516
|
+
}
|
|
2517
|
+
// Must have session (non-empty string, not 'unknown')
|
|
2518
|
+
if (!fields.session || fields.session === 'unknown' || fields.session.trim() === '') {
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
// Must have pageUrl (non-empty string)
|
|
2522
|
+
if (!fields.pageUrl || fields.pageUrl.trim() === '') {
|
|
2523
|
+
return false;
|
|
2524
|
+
}
|
|
2525
|
+
// Must have eventType
|
|
2526
|
+
if (!fields.eventType) {
|
|
2527
|
+
return false;
|
|
2528
|
+
}
|
|
2529
|
+
// Must have eventId
|
|
2530
|
+
if (!fields.eventId) {
|
|
2531
|
+
return false;
|
|
2532
|
+
}
|
|
2533
|
+
// Must have timestamp
|
|
2534
|
+
if (!fields.timestamp) {
|
|
2535
|
+
return false;
|
|
2536
|
+
}
|
|
2537
|
+
// Must have at least one of: mobile, OS, or browser
|
|
2538
|
+
const hasDeviceInfo = fields.mobile !== null || fields.os || fields.browser;
|
|
2539
|
+
if (!hasDeviceInfo) {
|
|
2540
|
+
return false;
|
|
2541
|
+
}
|
|
2542
|
+
return true;
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Check if event has null values for critical fields
|
|
2546
|
+
* Returns true if should be filtered out (too many nulls)
|
|
2547
|
+
*/
|
|
2548
|
+
function hasTooManyNulls(fields) {
|
|
2549
|
+
// Only count non-critical fields for null percentage
|
|
2550
|
+
// Critical fields (ip, lat, lon, session, pageUrl, eventType, eventId, timestamp) are already validated
|
|
2551
|
+
const nonCriticalFields = ['mobile', 'location', 'msisdn', 'operators', 'page', 'companyName', 'gps', 'os', 'browser', 'serviceId'];
|
|
2552
|
+
const nullCount = nonCriticalFields.filter(key => {
|
|
2553
|
+
const value = fields[key];
|
|
2554
|
+
return value === null || value === undefined;
|
|
2555
|
+
}).length;
|
|
2556
|
+
const totalNonCritical = nonCriticalFields.length;
|
|
2557
|
+
const nullPercentage = totalNonCritical > 0 ? nullCount / totalNonCritical : 0;
|
|
2558
|
+
// Filter out if more than 70% of non-critical fields are null (more lenient)
|
|
2559
|
+
return nullPercentage > 0.7;
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Validate and filter event
|
|
2563
|
+
* Returns null if event should be filtered out, otherwise returns the event
|
|
2564
|
+
*/
|
|
2565
|
+
function validateEvent(event) {
|
|
2566
|
+
const fields = extractRequiredFields(event);
|
|
2567
|
+
// Check if has too many null values
|
|
2568
|
+
if (hasTooManyNulls(fields)) {
|
|
2569
|
+
logger.debug('Event filtered: too many null values', {
|
|
2570
|
+
fields,
|
|
2571
|
+
sessionId: event.sessionId,
|
|
2572
|
+
pageUrl: event.pageUrl,
|
|
2573
|
+
eventName: event.eventName,
|
|
2574
|
+
});
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
// Check if has minimum required fields
|
|
2578
|
+
if (!validateRequiredFields(fields)) {
|
|
2579
|
+
logger.debug('Event filtered: missing required fields', {
|
|
2580
|
+
fields,
|
|
2581
|
+
sessionId: event.sessionId,
|
|
2582
|
+
pageUrl: event.pageUrl,
|
|
2583
|
+
eventName: event.eventName,
|
|
2584
|
+
hasIP: !!fields.ip,
|
|
2585
|
+
hasLatLon: fields.lat !== null && fields.lon !== null,
|
|
2586
|
+
hasSession: !!fields.session,
|
|
2587
|
+
hasPageUrl: !!fields.pageUrl,
|
|
2588
|
+
hasEventType: !!fields.eventType,
|
|
2589
|
+
hasEventId: !!fields.eventId,
|
|
2590
|
+
hasTimestamp: !!fields.timestamp,
|
|
2591
|
+
hasDeviceInfo: fields.mobile !== null || !!fields.os || !!fields.browser,
|
|
2592
|
+
});
|
|
2593
|
+
return null;
|
|
2594
|
+
}
|
|
2595
|
+
return event;
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Generate a unique key for deduplication
|
|
2599
|
+
* Based on: sessionId + pageUrl + eventName + timestamp (rounded to nearest second)
|
|
2600
|
+
*/
|
|
2601
|
+
function generateDeduplicationKey(event) {
|
|
2602
|
+
const timestamp = event.timestamp instanceof Date
|
|
2603
|
+
? event.timestamp.getTime()
|
|
2604
|
+
: new Date(event.timestamp).getTime();
|
|
2605
|
+
// Round timestamp to nearest second to handle rapid duplicate events
|
|
2606
|
+
const roundedTimestamp = Math.floor(timestamp / 1000) * 1000;
|
|
2607
|
+
const parts = [
|
|
2608
|
+
event.sessionId || '',
|
|
2609
|
+
event.pageUrl || '',
|
|
2610
|
+
event.eventName || 'page_view',
|
|
2611
|
+
roundedTimestamp.toString(),
|
|
2612
|
+
];
|
|
2613
|
+
return parts.join('|');
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2357
2616
|
/**
|
|
2358
2617
|
* Analytics Service
|
|
2359
2618
|
* Sends analytics events to your backend API
|
|
@@ -2407,6 +2666,8 @@ class AnalyticsService {
|
|
|
2407
2666
|
logLevel: 'warn',
|
|
2408
2667
|
...config,
|
|
2409
2668
|
};
|
|
2669
|
+
// Clear seen event keys when reconfiguring (important for tests)
|
|
2670
|
+
this.seenEventKeys.clear();
|
|
2410
2671
|
// Set log level
|
|
2411
2672
|
if (this.config.logLevel) {
|
|
2412
2673
|
logger.setLevel(this.config.logLevel);
|
|
@@ -2421,6 +2682,10 @@ class AnalyticsService {
|
|
|
2421
2682
|
if (this.config.enableMetrics) {
|
|
2422
2683
|
metricsCollector.reset();
|
|
2423
2684
|
}
|
|
2685
|
+
// Configure IP geolocation if provided
|
|
2686
|
+
if (this.config.ipGeolocation) {
|
|
2687
|
+
LocationDetector.configureIPGeolocation(this.config.ipGeolocation);
|
|
2688
|
+
}
|
|
2424
2689
|
this.isInitialized = true;
|
|
2425
2690
|
}
|
|
2426
2691
|
/**
|
|
@@ -2465,10 +2730,56 @@ class AnalyticsService {
|
|
|
2465
2730
|
}
|
|
2466
2731
|
/**
|
|
2467
2732
|
* Send a batch of events with retry logic
|
|
2733
|
+
* Filters out invalid and duplicate events before sending
|
|
2468
2734
|
*/
|
|
2469
2735
|
static async sendBatch(events) {
|
|
2470
|
-
|
|
2736
|
+
// Validate and filter events
|
|
2737
|
+
const validatedEvents = [];
|
|
2738
|
+
const seenInBatch = new Set();
|
|
2739
|
+
for (const event of events) {
|
|
2740
|
+
// Validate event
|
|
2741
|
+
const validated = validateEvent(event);
|
|
2742
|
+
if (!validated) {
|
|
2743
|
+
logger.debug('Event filtered out in batch: missing required fields', {
|
|
2744
|
+
sessionId: event.sessionId,
|
|
2745
|
+
pageUrl: event.pageUrl,
|
|
2746
|
+
hasIPLocation: !!event.ipLocation,
|
|
2747
|
+
hasIP: !!(event.ipLocation?.ip),
|
|
2748
|
+
});
|
|
2749
|
+
continue;
|
|
2750
|
+
}
|
|
2751
|
+
// Check for duplicates in this batch only
|
|
2752
|
+
// Note: Events are already deduplicated when queued, so we only check within the batch
|
|
2753
|
+
const dedupeKey = generateDeduplicationKey(validated);
|
|
2754
|
+
if (seenInBatch.has(dedupeKey)) {
|
|
2755
|
+
logger.debug('Duplicate event filtered out in batch', { dedupeKey });
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2758
|
+
// Add to seen events for this batch
|
|
2759
|
+
seenInBatch.add(dedupeKey);
|
|
2760
|
+
// Also add to global seen events cache (for cross-batch deduplication)
|
|
2761
|
+
// But don't filter out if already seen - events from queue are already validated
|
|
2762
|
+
if (!this.seenEventKeys.has(dedupeKey)) {
|
|
2763
|
+
this.seenEventKeys.add(dedupeKey);
|
|
2764
|
+
// Manage cache size
|
|
2765
|
+
if (this.seenEventKeys.size > this.DEDUPE_CACHE_SIZE) {
|
|
2766
|
+
const firstKey = this.seenEventKeys.values().next().value;
|
|
2767
|
+
if (firstKey !== undefined) {
|
|
2768
|
+
this.seenEventKeys.delete(firstKey);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
validatedEvents.push(validated);
|
|
2773
|
+
}
|
|
2774
|
+
if (validatedEvents.length === 0) {
|
|
2775
|
+
logger.debug('All events in batch were filtered out');
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
// Use validated events
|
|
2779
|
+
if (validatedEvents.length === 0) {
|
|
2471
2780
|
return;
|
|
2781
|
+
}
|
|
2782
|
+
events = validatedEvents;
|
|
2472
2783
|
// Apply plugin transformations
|
|
2473
2784
|
const transformedEvents = [];
|
|
2474
2785
|
for (const event of events) {
|
|
@@ -2588,6 +2899,7 @@ class AnalyticsService {
|
|
|
2588
2899
|
/**
|
|
2589
2900
|
* Track user journey/analytics event
|
|
2590
2901
|
* Events are automatically queued and batched
|
|
2902
|
+
* Duplicate events and events with null values are filtered out
|
|
2591
2903
|
*/
|
|
2592
2904
|
static async trackEvent(event) {
|
|
2593
2905
|
const payload = {
|
|
@@ -2595,9 +2907,39 @@ class AnalyticsService {
|
|
|
2595
2907
|
timestamp: new Date(),
|
|
2596
2908
|
eventId: this.generateEventId(),
|
|
2597
2909
|
};
|
|
2910
|
+
// Validate event - filter out if missing required fields or too many nulls
|
|
2911
|
+
const validatedEvent = validateEvent(payload);
|
|
2912
|
+
if (!validatedEvent) {
|
|
2913
|
+
logger.debug('Event filtered out: missing required fields or too many null values', {
|
|
2914
|
+
sessionId: payload.sessionId,
|
|
2915
|
+
pageUrl: payload.pageUrl,
|
|
2916
|
+
eventName: payload.eventName,
|
|
2917
|
+
});
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
// Check for duplicates
|
|
2921
|
+
const dedupeKey = generateDeduplicationKey(validatedEvent);
|
|
2922
|
+
if (this.seenEventKeys.has(dedupeKey)) {
|
|
2923
|
+
logger.debug('Duplicate event filtered out', {
|
|
2924
|
+
dedupeKey,
|
|
2925
|
+
sessionId: validatedEvent.sessionId,
|
|
2926
|
+
pageUrl: validatedEvent.pageUrl,
|
|
2927
|
+
eventName: validatedEvent.eventName,
|
|
2928
|
+
});
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
// Add to seen events (with cache size limit)
|
|
2932
|
+
this.seenEventKeys.add(dedupeKey);
|
|
2933
|
+
if (this.seenEventKeys.size > this.DEDUPE_CACHE_SIZE) {
|
|
2934
|
+
// Remove oldest entries (simple FIFO - remove first entry)
|
|
2935
|
+
const firstKey = this.seenEventKeys.values().next().value;
|
|
2936
|
+
if (firstKey !== undefined) {
|
|
2937
|
+
this.seenEventKeys.delete(firstKey);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2598
2940
|
// If queue is available, use it (browser environment)
|
|
2599
2941
|
if (this.queueManager && typeof window !== 'undefined') {
|
|
2600
|
-
this.queueManager.enqueue(
|
|
2942
|
+
this.queueManager.enqueue(validatedEvent);
|
|
2601
2943
|
// Record metrics
|
|
2602
2944
|
if (this.config.enableMetrics) {
|
|
2603
2945
|
metricsCollector.recordQueued();
|
|
@@ -2607,7 +2949,7 @@ class AnalyticsService {
|
|
|
2607
2949
|
}
|
|
2608
2950
|
// Fallback: send immediately (SSR or queue not initialized)
|
|
2609
2951
|
try {
|
|
2610
|
-
await this.sendBatch([
|
|
2952
|
+
await this.sendBatch([validatedEvent]);
|
|
2611
2953
|
}
|
|
2612
2954
|
catch (err) {
|
|
2613
2955
|
logger.warn('Failed to send event:', err);
|
|
@@ -2616,12 +2958,17 @@ class AnalyticsService {
|
|
|
2616
2958
|
/**
|
|
2617
2959
|
* Track user journey with full context
|
|
2618
2960
|
*/
|
|
2619
|
-
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
|
|
2961
|
+
static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits: _pageVisits = 1, interactions: _interactions = 0, }) {
|
|
2620
2962
|
// Get field storage config (support both new and legacy format)
|
|
2621
2963
|
const fieldStorage = this.config.fieldStorage || {};
|
|
2622
2964
|
const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
|
|
2623
2965
|
// Transform and filter all data types based on configuration
|
|
2624
|
-
|
|
2966
|
+
// Use raw ipLocation if transformation returns null (for validation purposes)
|
|
2967
|
+
let transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
|
|
2968
|
+
// Fallback to original ipLocation if transform returns null (needed for validation)
|
|
2969
|
+
if (!transformedIPLocation && ipLocation) {
|
|
2970
|
+
transformedIPLocation = ipLocation;
|
|
2971
|
+
}
|
|
2625
2972
|
const filteredDeviceInfo = filterFieldsByConfig(deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
|
|
2626
2973
|
// In essential mode, don't store browser-based networkInfo
|
|
2627
2974
|
// Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
|
|
@@ -2661,8 +3008,7 @@ class AnalyticsService {
|
|
|
2661
3008
|
deviceInfo: filteredDeviceInfo || undefined,
|
|
2662
3009
|
location: filteredLocation || undefined,
|
|
2663
3010
|
attribution: filteredAttribution || undefined,
|
|
2664
|
-
|
|
2665
|
-
ipLocation: undefined,
|
|
3011
|
+
ipLocation: transformedIPLocation || ipLocation || undefined,
|
|
2666
3012
|
userId: userId ?? sessionId,
|
|
2667
3013
|
customData: {
|
|
2668
3014
|
...customData,
|
|
@@ -2719,7 +3065,7 @@ class AnalyticsService {
|
|
|
2719
3065
|
attribution: AttributionDetector.detect(),
|
|
2720
3066
|
};
|
|
2721
3067
|
}
|
|
2722
|
-
catch
|
|
3068
|
+
catch {
|
|
2723
3069
|
// If auto-collection fails, use minimal context
|
|
2724
3070
|
const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
|
|
2725
3071
|
autoContext = {
|
|
@@ -2736,19 +3082,116 @@ class AnalyticsService {
|
|
|
2736
3082
|
};
|
|
2737
3083
|
}
|
|
2738
3084
|
}
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
3085
|
+
// Ensure sessionId is always a valid non-empty string for validation
|
|
3086
|
+
let finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
|
|
3087
|
+
if (finalSessionId === 'unknown' || finalSessionId.trim() === '') {
|
|
3088
|
+
// Try to get from storage if available
|
|
3089
|
+
if (typeof window !== 'undefined') {
|
|
3090
|
+
try {
|
|
3091
|
+
const { getOrCreateUserId } = await Promise.resolve().then(function () { return storage; });
|
|
3092
|
+
finalSessionId = getOrCreateUserId();
|
|
3093
|
+
}
|
|
3094
|
+
catch {
|
|
3095
|
+
// If storage is not available, generate a temporary session ID
|
|
3096
|
+
finalSessionId = `temp-${Date.now()}`;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
else {
|
|
3100
|
+
// SSR environment - generate a temporary session ID
|
|
3101
|
+
finalSessionId = `temp-${Date.now()}`;
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
// Ensure pageUrl is always a valid non-empty string for validation
|
|
3105
|
+
let finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
|
|
3106
|
+
// If pageUrl is empty, try to get it from window.location if available
|
|
3107
|
+
if (!finalPageUrl && typeof window !== 'undefined' && window.location) {
|
|
3108
|
+
finalPageUrl = window.location.href;
|
|
3109
|
+
}
|
|
3110
|
+
// If still empty, use a default value to pass validation
|
|
3111
|
+
if (!finalPageUrl || finalPageUrl.trim() === '') {
|
|
3112
|
+
finalPageUrl = 'https://unknown';
|
|
3113
|
+
}
|
|
3114
|
+
// Extract IP location from context.ipLocation, location object, or auto-collected location
|
|
3115
|
+
const ipLocationData = context?.ipLocation ||
|
|
3116
|
+
(context?.location && typeof context.location === 'object'
|
|
3117
|
+
? context.location?.ipLocationData
|
|
3118
|
+
: undefined) ||
|
|
3119
|
+
(autoContext?.location && typeof autoContext.location === 'object'
|
|
3120
|
+
? autoContext.location?.ipLocationData
|
|
3121
|
+
: undefined);
|
|
2746
3122
|
// Get field storage config (support both new and legacy format)
|
|
2747
3123
|
const fieldStorage = this.config.fieldStorage || {};
|
|
2748
3124
|
const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
|
|
2749
3125
|
// Transform and filter all data types based on configuration
|
|
2750
|
-
|
|
2751
|
-
|
|
3126
|
+
// Use raw ipLocationData if transformation returns null (for validation purposes)
|
|
3127
|
+
// Always prioritize context?.ipLocation if it has an IP (most direct and reliable source)
|
|
3128
|
+
const rawIPLocation = (context?.ipLocation && context.ipLocation.ip)
|
|
3129
|
+
? context.ipLocation
|
|
3130
|
+
: (ipLocationData || undefined);
|
|
3131
|
+
// Preserve ip field for validation - always ensure ip is available
|
|
3132
|
+
const preserveIP = rawIPLocation?.ip;
|
|
3133
|
+
let transformedIPLocation = transformIPLocationForBackend(rawIPLocation, ipLocationConfig);
|
|
3134
|
+
// Critical: Ensure ip field is always preserved for validation
|
|
3135
|
+
// If transformation removed ip or returned null/empty, restore it from rawIPLocation
|
|
3136
|
+
if (preserveIP) {
|
|
3137
|
+
if (!transformedIPLocation) {
|
|
3138
|
+
// Transformation returned null, use rawIPLocation
|
|
3139
|
+
transformedIPLocation = rawIPLocation;
|
|
3140
|
+
}
|
|
3141
|
+
else if (Object.keys(transformedIPLocation).length === 0) {
|
|
3142
|
+
// Transformation returned empty object, use rawIPLocation
|
|
3143
|
+
transformedIPLocation = rawIPLocation;
|
|
3144
|
+
}
|
|
3145
|
+
else if (!transformedIPLocation.ip) {
|
|
3146
|
+
// Transformation returned object but without ip field, restore it
|
|
3147
|
+
transformedIPLocation = { ...transformedIPLocation, ip: preserveIP };
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
else if (!transformedIPLocation && rawIPLocation) {
|
|
3151
|
+
// No ip to preserve, but use rawIPLocation if transformation failed
|
|
3152
|
+
transformedIPLocation = rawIPLocation;
|
|
3153
|
+
}
|
|
3154
|
+
const rawDeviceInfo = context?.deviceInfo || autoContext?.deviceInfo;
|
|
3155
|
+
const filteredDeviceInfo = filterFieldsByConfig(rawDeviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
|
|
3156
|
+
// Ensure deviceInfo has os and browser for validation (critical fields)
|
|
3157
|
+
// If filtering removed them, restore from rawDeviceInfo
|
|
3158
|
+
let finalDeviceInfo = filteredDeviceInfo;
|
|
3159
|
+
if (rawDeviceInfo) {
|
|
3160
|
+
if (!finalDeviceInfo) {
|
|
3161
|
+
// Filtering returned null, create minimal object with essential fields
|
|
3162
|
+
finalDeviceInfo = {};
|
|
3163
|
+
if (rawDeviceInfo.os)
|
|
3164
|
+
finalDeviceInfo.os = rawDeviceInfo.os;
|
|
3165
|
+
if (rawDeviceInfo.browser)
|
|
3166
|
+
finalDeviceInfo.browser = rawDeviceInfo.browser;
|
|
3167
|
+
}
|
|
3168
|
+
else {
|
|
3169
|
+
// Ensure os and browser are present for validation
|
|
3170
|
+
if (!finalDeviceInfo.os && rawDeviceInfo.os) {
|
|
3171
|
+
finalDeviceInfo = { ...finalDeviceInfo, os: rawDeviceInfo.os };
|
|
3172
|
+
}
|
|
3173
|
+
if (!finalDeviceInfo.browser && rawDeviceInfo.browser) {
|
|
3174
|
+
finalDeviceInfo = { ...finalDeviceInfo, browser: rawDeviceInfo.browser };
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
// Only use finalDeviceInfo if it has at least os or browser
|
|
3178
|
+
// But if rawDeviceInfo has os or browser, ensure finalDeviceInfo has them
|
|
3179
|
+
if (rawDeviceInfo.os || rawDeviceInfo.browser) {
|
|
3180
|
+
if (!finalDeviceInfo) {
|
|
3181
|
+
finalDeviceInfo = {};
|
|
3182
|
+
}
|
|
3183
|
+
if (rawDeviceInfo.os && !finalDeviceInfo.os) {
|
|
3184
|
+
finalDeviceInfo.os = rawDeviceInfo.os;
|
|
3185
|
+
}
|
|
3186
|
+
if (rawDeviceInfo.browser && !finalDeviceInfo.browser) {
|
|
3187
|
+
finalDeviceInfo.browser = rawDeviceInfo.browser;
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
else if (!finalDeviceInfo || (!finalDeviceInfo.os && !finalDeviceInfo.browser)) {
|
|
3191
|
+
// No os or browser in rawDeviceInfo, and finalDeviceInfo doesn't have them either
|
|
3192
|
+
finalDeviceInfo = null;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
2752
3195
|
// In essential mode, don't store browser-based networkInfo
|
|
2753
3196
|
// Connection data from ipwho.is (in customData.ipLocation.connection) is more accurate
|
|
2754
3197
|
const networkInfoConfig = fieldStorage.networkInfo;
|
|
@@ -2778,20 +3221,65 @@ class AnalyticsService {
|
|
|
2778
3221
|
};
|
|
2779
3222
|
}
|
|
2780
3223
|
const filteredAttribution = filterFieldsByConfig(context?.attribution || autoContext?.attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
|
|
3224
|
+
// Ensure ipLocation is available for validation
|
|
3225
|
+
// Always preserve ip field - critical for validation
|
|
3226
|
+
// Use transformedIPLocation if it has ip, otherwise fall back to rawIPLocation
|
|
3227
|
+
let finalIPLocation = undefined;
|
|
3228
|
+
// Priority 1: If we have rawIPLocation with IP, always use it (most reliable source)
|
|
3229
|
+
if (rawIPLocation && rawIPLocation.ip) {
|
|
3230
|
+
if (transformedIPLocation && transformedIPLocation.ip) {
|
|
3231
|
+
// Both have IP, prefer transformed (has filtering applied)
|
|
3232
|
+
finalIPLocation = transformedIPLocation;
|
|
3233
|
+
}
|
|
3234
|
+
else if (transformedIPLocation) {
|
|
3235
|
+
// Transformed exists but no IP, merge with rawIPLocation to preserve IP
|
|
3236
|
+
finalIPLocation = { ...transformedIPLocation, ip: rawIPLocation.ip };
|
|
3237
|
+
}
|
|
3238
|
+
else {
|
|
3239
|
+
// No transformation, use rawIPLocation directly
|
|
3240
|
+
finalIPLocation = rawIPLocation;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
else if (transformedIPLocation) {
|
|
3244
|
+
// No raw IP, but transformation succeeded
|
|
3245
|
+
finalIPLocation = transformedIPLocation;
|
|
3246
|
+
}
|
|
3247
|
+
else if (context?.ipLocation && context.ipLocation.ip) {
|
|
3248
|
+
// Fallback to context.ipLocation if available
|
|
3249
|
+
finalIPLocation = context.ipLocation;
|
|
3250
|
+
}
|
|
3251
|
+
// Final safety check: ensure IP is always present if we have it from any source
|
|
3252
|
+
if (!finalIPLocation || !finalIPLocation.ip) {
|
|
3253
|
+
if (rawIPLocation && rawIPLocation.ip) {
|
|
3254
|
+
finalIPLocation = rawIPLocation;
|
|
3255
|
+
}
|
|
3256
|
+
else if (context?.ipLocation && context.ipLocation.ip) {
|
|
3257
|
+
finalIPLocation = context.ipLocation;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
// Ultimate safeguard: if context.ipLocation has IP, always use it for validation
|
|
3261
|
+
// This ensures validation never fails due to missing IP when it's provided in context
|
|
3262
|
+
if (context?.ipLocation && context.ipLocation.ip) {
|
|
3263
|
+
if (!finalIPLocation || !finalIPLocation.ip) {
|
|
3264
|
+
finalIPLocation = context.ipLocation;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
2781
3267
|
await this.trackEvent({
|
|
2782
3268
|
sessionId: finalSessionId,
|
|
2783
3269
|
pageUrl: finalPageUrl,
|
|
2784
3270
|
networkInfo: filteredNetworkInfo || undefined,
|
|
2785
|
-
deviceInfo:
|
|
3271
|
+
deviceInfo: (finalDeviceInfo && (finalDeviceInfo.os || finalDeviceInfo.browser)) ? finalDeviceInfo : undefined,
|
|
2786
3272
|
location: filteredLocation || undefined,
|
|
2787
3273
|
attribution: filteredAttribution || undefined,
|
|
3274
|
+
ipLocation: finalIPLocation,
|
|
2788
3275
|
userId: context?.userId || finalSessionId,
|
|
2789
3276
|
eventName,
|
|
2790
3277
|
eventParameters: parameters || {},
|
|
2791
3278
|
customData: {
|
|
2792
3279
|
...(parameters || {}),
|
|
2793
3280
|
// Store transformed IP location in customData for backend integration
|
|
2794
|
-
|
|
3281
|
+
// Use transformed if available, otherwise use raw (for validation)
|
|
3282
|
+
...(finalIPLocation && { ipLocation: transformedIPLocation || finalIPLocation }),
|
|
2795
3283
|
},
|
|
2796
3284
|
});
|
|
2797
3285
|
}
|
|
@@ -2814,13 +3302,13 @@ class AnalyticsService {
|
|
|
2814
3302
|
* });
|
|
2815
3303
|
* ```
|
|
2816
3304
|
*/
|
|
2817
|
-
static async trackPageView(pageName, parameters) {
|
|
3305
|
+
static async trackPageView(pageName, parameters, context) {
|
|
2818
3306
|
const page = pageName || (typeof window !== 'undefined' ? window.location.pathname : '');
|
|
2819
3307
|
await this.logEvent('page_view', {
|
|
2820
3308
|
page_name: page,
|
|
2821
3309
|
page_title: typeof document !== 'undefined' ? document.title : undefined,
|
|
2822
3310
|
...parameters,
|
|
2823
|
-
});
|
|
3311
|
+
}, context);
|
|
2824
3312
|
}
|
|
2825
3313
|
/**
|
|
2826
3314
|
* Manually flush the event queue
|
|
@@ -2861,7 +3349,10 @@ AnalyticsService.apiEndpoint = '/api/analytics';
|
|
|
2861
3349
|
AnalyticsService.queueManager = null;
|
|
2862
3350
|
AnalyticsService.config = {};
|
|
2863
3351
|
AnalyticsService.isInitialized = false;
|
|
3352
|
+
AnalyticsService.seenEventKeys = new Set();
|
|
3353
|
+
AnalyticsService.DEDUPE_CACHE_SIZE = 1000; // Keep last 1000 event keys
|
|
2864
3354
|
|
|
3355
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2865
3356
|
/**
|
|
2866
3357
|
* Debug utilities for analytics tracker
|
|
2867
3358
|
* Provides debugging tools in development mode
|
|
@@ -2940,6 +3431,7 @@ function initDebug() {
|
|
|
2940
3431
|
logger.info('Available methods: getQueue(), getQueueSize(), flushQueue(), clearQueue(), getStats(), setLogLevel()');
|
|
2941
3432
|
}
|
|
2942
3433
|
|
|
3434
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2943
3435
|
/**
|
|
2944
3436
|
* React Hook for Analytics Tracking
|
|
2945
3437
|
* Provides device, network, location, and attribution data
|
|
@@ -2984,6 +3476,8 @@ function useAnalytics(options = {}) {
|
|
|
2984
3476
|
config?.logLevel,
|
|
2985
3477
|
config?.enableMetrics,
|
|
2986
3478
|
config?.sessionTimeout,
|
|
3479
|
+
config?.fieldStorage,
|
|
3480
|
+
config?.ipLocationFields,
|
|
2987
3481
|
]);
|
|
2988
3482
|
const [networkInfo, setNetworkInfo] = useState(null);
|
|
2989
3483
|
const [deviceInfo, setDeviceInfo] = useState(null);
|
|
@@ -3260,5 +3754,5 @@ function useAnalytics(options = {}) {
|
|
|
3260
3754
|
]);
|
|
3261
3755
|
}
|
|
3262
3756
|
|
|
3263
|
-
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 };
|
|
3757
|
+
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, extractRequiredFields, filterFieldsByConfig, generateDeduplicationKey, getCompleteIPLocation, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateSession, getOrCreateUserId, getPublicIP, getSession, hasLocationConsent, initDebug, loadJSON, loadSessionJSON, logger, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, transformIPLocationForBackend, updateSessionActivity, useAnalytics, validateEvent, validateRequiredFields };
|
|
3264
3758
|
//# sourceMappingURL=index.esm.js.map
|