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/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
- * console.log('IP:', location?.ip);
665
- * console.log('Country:', location?.country);
666
- * console.log('ISP:', location?.connection?.isp);
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('https://ipwho.is/', {
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(5000),
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 response = await fetch('https://ipwho.is/', {
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(5000),
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
- // Using ipwho.is API (no API key required)
808
- const response = await fetch(`https://ipwho.is/${ip}`, {
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(5000),
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
- let ipLocation = await getCompleteIPLocation();
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()}-${Math.random().toString(36).substring(7)}`;
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 (error) {
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
- if (events.length === 0)
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(payload);
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([payload]);
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
- const transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
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
- // Don't include raw ipLocation - we have the filtered/transformed version in customData
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 (error) {
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
- const finalSessionId = context?.sessionId || autoContext?.sessionId || 'unknown';
2744
- const finalPageUrl = context?.pageUrl || autoContext?.pageUrl || '';
2745
- // Extract IP location from location object if available
2746
- const locationData = context?.location || autoContext?.location;
2747
- const ipLocationData = locationData && typeof locationData === 'object'
2748
- ? locationData?.ipLocationData
2749
- : undefined;
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
- const transformedIPLocation = transformIPLocationForBackend(ipLocationData, ipLocationConfig);
2755
- const filteredDeviceInfo = filterFieldsByConfig(context?.deviceInfo || autoContext?.deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
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: filteredDeviceInfo || undefined,
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
- ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
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: { apiEndpoint: '/api/analytics' }
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