user-analytics-tracker 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -1,5 +1,96 @@
1
1
  import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
2
2
 
3
+ /**
4
+ * Core types for the analytics tracker package
5
+ */
6
+ /**
7
+ * Default essential fields for IP location storage
8
+ * These fields are stored when mode is 'essential' (default)
9
+ */
10
+ const DEFAULT_ESSENTIAL_IP_FIELDS = [
11
+ // Core identification
12
+ 'ip',
13
+ 'country',
14
+ 'countryCode',
15
+ 'region',
16
+ 'city',
17
+ // Geographic coordinates (stored here, not duplicated in location)
18
+ 'lat',
19
+ 'lon',
20
+ // Additional geographic info
21
+ 'continent',
22
+ 'continentCode',
23
+ // Network info
24
+ 'type',
25
+ 'isEu',
26
+ 'isp',
27
+ 'connection',
28
+ 'connection.asn',
29
+ 'connection.org',
30
+ 'connection.isp',
31
+ 'connection.domain',
32
+ // Timezone (stored here, not duplicated in location)
33
+ 'timezone',
34
+ 'timezoneDetails',
35
+ 'timezoneDetails.id',
36
+ 'timezoneDetails.abbr',
37
+ 'timezoneDetails.utc',
38
+ // Flag (only emoji in essential mode)
39
+ 'flag.emoji',
40
+ ];
41
+ /**
42
+ * Default essential fields for Device Info storage
43
+ */
44
+ const DEFAULT_ESSENTIAL_DEVICE_FIELDS = [
45
+ 'type',
46
+ 'os',
47
+ 'osVersion',
48
+ 'browser',
49
+ 'browserVersion',
50
+ 'deviceModel',
51
+ 'deviceBrand',
52
+ 'userAgent',
53
+ ];
54
+ /**
55
+ * Default essential fields for Network Info storage
56
+ */
57
+ const DEFAULT_ESSENTIAL_NETWORK_FIELDS = [
58
+ 'type',
59
+ 'effectiveType',
60
+ 'downlink',
61
+ 'rtt',
62
+ 'saveData',
63
+ ];
64
+ /**
65
+ * Default essential fields for Location Info storage
66
+ */
67
+ const DEFAULT_ESSENTIAL_LOCATION_FIELDS = [
68
+ // Minimal location fields - only coordinates and source
69
+ // All IP-related data (ip, country, city, etc.) is stored in customData.ipLocation to avoid duplication
70
+ 'lat',
71
+ 'lon',
72
+ 'source',
73
+ 'ts',
74
+ ];
75
+ /**
76
+ * Default essential fields for Attribution Info storage
77
+ */
78
+ const DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS = [
79
+ 'landingUrl',
80
+ 'path',
81
+ 'hostname',
82
+ 'referrerUrl',
83
+ 'referrerDomain',
84
+ 'navigationType',
85
+ 'isReload',
86
+ 'isBackForward',
87
+ 'utm_source',
88
+ 'utm_medium',
89
+ 'utm_campaign',
90
+ 'utm_term',
91
+ 'utm_content',
92
+ ];
93
+
3
94
  /**
4
95
  * Network Type Detector
5
96
  * Detects WiFi, Mobile Data (Cellular), Hotspot, Ethernet, or Unknown
@@ -2006,18 +2097,206 @@ class MetricsCollector {
2006
2097
  // Global metrics collector instance
2007
2098
  const metricsCollector = new MetricsCollector();
2008
2099
 
2100
+ /**
2101
+ * Generic field storage transformer
2102
+ * Filters object fields based on storage configuration
2103
+ */
2104
+ /**
2105
+ * Filter object fields based on storage configuration
2106
+ *
2107
+ * @param data - The data object to filter
2108
+ * @param config - Storage configuration
2109
+ * @param defaultEssentialFields - Default essential fields for this data type
2110
+ * @returns Filtered data object with only configured fields
2111
+ */
2112
+ function filterFieldsByConfig(data, config, defaultEssentialFields) {
2113
+ if (!data) {
2114
+ return null;
2115
+ }
2116
+ const mode = config?.mode || 'essential';
2117
+ let fieldsToInclude = [];
2118
+ if (mode === 'essential') {
2119
+ // Use default essential fields
2120
+ fieldsToInclude = [...defaultEssentialFields];
2121
+ }
2122
+ else if (mode === 'all') {
2123
+ // Include all fields, then exclude specified ones
2124
+ fieldsToInclude = ['*']; // Special marker for "all fields"
2125
+ }
2126
+ else if (mode === 'custom' && config) {
2127
+ // Use custom field list
2128
+ fieldsToInclude = config.fields || [];
2129
+ }
2130
+ // If mode is 'all', just exclude specified fields
2131
+ if (mode === 'all') {
2132
+ const filtered = { ...data };
2133
+ if (config?.exclude && config.exclude.length > 0) {
2134
+ const excludeSet = new Set(config.exclude);
2135
+ Object.keys(filtered).forEach(key => {
2136
+ if (excludeSet.has(key)) {
2137
+ delete filtered[key];
2138
+ }
2139
+ });
2140
+ // Handle nested exclusions
2141
+ if (filtered.connection && excludeSet.has('connection')) {
2142
+ delete filtered.connection;
2143
+ }
2144
+ if (filtered.timezoneDetails && excludeSet.has('timezoneDetails')) {
2145
+ delete filtered.timezoneDetails;
2146
+ }
2147
+ if (filtered.flag && excludeSet.has('flag')) {
2148
+ delete filtered.flag;
2149
+ }
2150
+ if (filtered.firstTouch && excludeSet.has('firstTouch')) {
2151
+ delete filtered.firstTouch;
2152
+ }
2153
+ if (filtered.lastTouch && excludeSet.has('lastTouch')) {
2154
+ delete filtered.lastTouch;
2155
+ }
2156
+ }
2157
+ return filtered;
2158
+ }
2159
+ // For 'essential' or 'custom' mode, only include specified fields
2160
+ const filtered = {};
2161
+ const includeSet = new Set(fieldsToInclude);
2162
+ // Helper to check if a field path should be included
2163
+ const shouldInclude = (fieldPath) => {
2164
+ // Direct match - most specific
2165
+ if (includeSet.has(fieldPath))
2166
+ return true;
2167
+ // For nested fields (e.g., 'flag.emoji'), only include if explicitly listed
2168
+ // Don't auto-include all children just because parent is included
2169
+ const parts = fieldPath.split('.');
2170
+ if (parts.length > 1) {
2171
+ // For nested fields, require explicit inclusion
2172
+ // This prevents 'flag' from including all 'flag.*' fields
2173
+ return includeSet.has(fieldPath);
2174
+ }
2175
+ // For top-level fields only, check if parent path is included
2176
+ // This allows 'connection' to work when all connection.* fields are listed
2177
+ return false;
2178
+ };
2179
+ // Helper to check if a parent object should be created (for nested objects)
2180
+ const shouldIncludeParent = (parentPath) => {
2181
+ // Check if any child of this parent is included
2182
+ for (const field of fieldsToInclude) {
2183
+ if (field.startsWith(parentPath + '.')) {
2184
+ return true;
2185
+ }
2186
+ }
2187
+ // Also check if parent itself is explicitly included
2188
+ return includeSet.has(parentPath);
2189
+ };
2190
+ // Filter top-level fields
2191
+ Object.keys(data).forEach(key => {
2192
+ if (shouldInclude(key)) {
2193
+ filtered[key] = data[key];
2194
+ }
2195
+ });
2196
+ // Handle nested objects - only create if at least one child field is included
2197
+ if (data.connection && shouldIncludeParent('connection')) {
2198
+ filtered.connection = {};
2199
+ if (shouldInclude('connection.asn'))
2200
+ filtered.connection.asn = data.connection.asn;
2201
+ if (shouldInclude('connection.org'))
2202
+ filtered.connection.org = data.connection.org;
2203
+ if (shouldInclude('connection.isp'))
2204
+ filtered.connection.isp = data.connection.isp;
2205
+ if (shouldInclude('connection.domain'))
2206
+ filtered.connection.domain = data.connection.domain;
2207
+ // If no connection fields were included, remove the object
2208
+ if (Object.keys(filtered.connection).length === 0) {
2209
+ delete filtered.connection;
2210
+ }
2211
+ }
2212
+ if (data.timezoneDetails && shouldIncludeParent('timezoneDetails')) {
2213
+ filtered.timezoneDetails = {};
2214
+ if (shouldInclude('timezoneDetails.id'))
2215
+ filtered.timezoneDetails.id = data.timezoneDetails.id;
2216
+ if (shouldInclude('timezoneDetails.abbr'))
2217
+ filtered.timezoneDetails.abbr = data.timezoneDetails.abbr;
2218
+ if (shouldInclude('timezoneDetails.utc'))
2219
+ filtered.timezoneDetails.utc = data.timezoneDetails.utc;
2220
+ if (shouldInclude('timezoneDetails.isDst'))
2221
+ filtered.timezoneDetails.isDst = data.timezoneDetails.isDst;
2222
+ if (shouldInclude('timezoneDetails.offset'))
2223
+ filtered.timezoneDetails.offset = data.timezoneDetails.offset;
2224
+ if (shouldInclude('timezoneDetails.currentTime'))
2225
+ filtered.timezoneDetails.currentTime = data.timezoneDetails.currentTime;
2226
+ // If no timezoneDetails fields were included, remove the object
2227
+ if (Object.keys(filtered.timezoneDetails).length === 0) {
2228
+ delete filtered.timezoneDetails;
2229
+ }
2230
+ }
2231
+ if (data.flag && shouldIncludeParent('flag')) {
2232
+ filtered.flag = {};
2233
+ // Only include specific flag fields if they're explicitly in the include list
2234
+ if (shouldInclude('flag.emoji'))
2235
+ filtered.flag.emoji = data.flag.emoji;
2236
+ if (shouldInclude('flag.img'))
2237
+ filtered.flag.img = data.flag.img;
2238
+ if (shouldInclude('flag.emojiUnicode'))
2239
+ filtered.flag.emojiUnicode = data.flag.emojiUnicode;
2240
+ // If no specific flag fields are included, don't add the flag object
2241
+ if (Object.keys(filtered.flag).length === 0) {
2242
+ delete filtered.flag;
2243
+ }
2244
+ }
2245
+ if (data.firstTouch && shouldInclude('firstTouch')) {
2246
+ filtered.firstTouch = data.firstTouch;
2247
+ }
2248
+ if (data.lastTouch && shouldInclude('lastTouch')) {
2249
+ filtered.lastTouch = data.lastTouch;
2250
+ }
2251
+ // Remove null and undefined values to reduce payload size
2252
+ const cleanValue = (val) => {
2253
+ if (val === null || val === undefined) {
2254
+ return undefined; // Will be filtered out
2255
+ }
2256
+ // For objects, recursively clean nested null/undefined values
2257
+ if (typeof val === 'object' && !Array.isArray(val) && val !== null) {
2258
+ const cleaned = {};
2259
+ let hasAnyValue = false;
2260
+ Object.keys(val).forEach(key => {
2261
+ const cleanedChild = cleanValue(val[key]);
2262
+ if (cleanedChild !== undefined) {
2263
+ cleaned[key] = cleanedChild;
2264
+ hasAnyValue = true;
2265
+ }
2266
+ });
2267
+ return hasAnyValue ? cleaned : undefined;
2268
+ }
2269
+ // For arrays, clean each element
2270
+ if (Array.isArray(val)) {
2271
+ const cleaned = val.map(cleanValue).filter(item => item !== undefined);
2272
+ return cleaned.length > 0 ? cleaned : undefined;
2273
+ }
2274
+ return val;
2275
+ };
2276
+ const cleaned = {};
2277
+ Object.keys(filtered).forEach(key => {
2278
+ const cleanedValue = cleanValue(filtered[key]);
2279
+ if (cleanedValue !== undefined) {
2280
+ cleaned[key] = cleanedValue;
2281
+ }
2282
+ });
2283
+ return cleaned;
2284
+ }
2285
+
2009
2286
  /**
2010
2287
  * Transform IP location data from API format (snake_case) to backend-expected format (camelCase)
2011
- * This ensures compatibility with the analytics backend integration
2288
+ * Supports configurable field storage to optimize storage capacity
2012
2289
  *
2013
2290
  * @param ipLocation - Raw IP location data from ipwho.is API
2014
- * @returns Transformed IP location data matching backend schema
2291
+ * @param config - Optional configuration for which fields to store
2292
+ * @returns Transformed IP location data matching backend schema (only includes configured fields)
2015
2293
  */
2016
- function transformIPLocationForBackend(ipLocation) {
2294
+ function transformIPLocationForBackend(ipLocation, config) {
2017
2295
  if (!ipLocation) {
2018
2296
  return null;
2019
2297
  }
2020
2298
  // Transform to match backend expected format (camelCase)
2299
+ // Build complete object first, then filter based on configuration
2021
2300
  const transformed = {
2022
2301
  // Basic fields
2023
2302
  ip: ipLocation.ip,
@@ -2052,9 +2331,11 @@ function transformIPLocationForBackend(ipLocation) {
2052
2331
  timezoneDetails: ipLocation.timezone && typeof ipLocation.timezone === 'object' ? {
2053
2332
  id: ipLocation.timezone.id,
2054
2333
  abbr: ipLocation.timezone.abbr,
2334
+ utc: ipLocation.timezone.utc,
2335
+ // Exclude these in essential mode: isDst, offset, currentTime
2336
+ // They will be filtered out by filterFieldsByConfig if not in essential fields
2055
2337
  isDst: ipLocation.timezone.is_dst,
2056
2338
  offset: ipLocation.timezone.offset,
2057
- utc: ipLocation.timezone.utc,
2058
2339
  currentTime: ipLocation.timezone.current_time,
2059
2340
  } : undefined,
2060
2341
  // Flag - transform to camelCase
@@ -2070,7 +2351,8 @@ function transformIPLocationForBackend(ipLocation) {
2070
2351
  delete transformed[key];
2071
2352
  }
2072
2353
  });
2073
- return transformed;
2354
+ // Filter fields based on configuration using generic filter
2355
+ return filterFieldsByConfig(transformed, config, DEFAULT_ESSENTIAL_IP_FIELDS);
2074
2356
  }
2075
2357
 
2076
2358
  /**
@@ -2336,20 +2618,49 @@ class AnalyticsService {
2336
2618
  * Track user journey with full context
2337
2619
  */
2338
2620
  static async trackUserJourney({ sessionId, pageUrl, networkInfo, deviceInfo, location, attribution, ipLocation, userId, customData, pageVisits = 1, interactions = 0, }) {
2339
- // Transform IP location data to match backend expected format (camelCase)
2340
- const transformedIPLocation = transformIPLocationForBackend(ipLocation);
2621
+ // Get field storage config (support both new and legacy format)
2622
+ const fieldStorage = this.config.fieldStorage || {};
2623
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2624
+ // Transform and filter all data types based on configuration
2625
+ const transformedIPLocation = transformIPLocationForBackend(ipLocation, ipLocationConfig);
2626
+ const filteredDeviceInfo = filterFieldsByConfig(deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2627
+ const filteredNetworkInfo = filterFieldsByConfig(networkInfo, fieldStorage.networkInfo, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2628
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2629
+ // This prevents storing the same data twice (e.g., ip, country, city, region, timezone)
2630
+ const locationConfig = fieldStorage.location;
2631
+ const locationMode = locationConfig?.mode || 'essential';
2632
+ let filteredLocation = filterFieldsByConfig(location, locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2633
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2634
+ // to avoid storing the same data twice
2635
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2636
+ // Remove fields that are duplicated in customData.ipLocation
2637
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2638
+ const minimalLocation = { ...filteredLocation };
2639
+ duplicateFields.forEach(field => {
2640
+ delete minimalLocation[field];
2641
+ });
2642
+ // Only keep essential location fields: lat, lon, source, ts
2643
+ filteredLocation = {
2644
+ lat: minimalLocation.lat,
2645
+ lon: minimalLocation.lon,
2646
+ source: minimalLocation.source,
2647
+ ts: minimalLocation.ts,
2648
+ };
2649
+ }
2650
+ const filteredAttribution = filterFieldsByConfig(attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2341
2651
  await this.trackEvent({
2342
2652
  sessionId,
2343
2653
  pageUrl,
2344
- networkInfo,
2345
- deviceInfo,
2346
- location,
2347
- attribution,
2348
- ipLocation,
2654
+ networkInfo: filteredNetworkInfo || undefined,
2655
+ deviceInfo: filteredDeviceInfo || undefined,
2656
+ location: filteredLocation || undefined,
2657
+ attribution: filteredAttribution || undefined,
2658
+ // Don't include raw ipLocation - we have the filtered/transformed version in customData
2659
+ ipLocation: undefined,
2349
2660
  userId: userId ?? sessionId,
2350
2661
  customData: {
2351
2662
  ...customData,
2352
- // Store transformed IP location in customData for backend integration
2663
+ // Store transformed and filtered IP location in customData for backend integration
2353
2664
  ...(transformedIPLocation && { ipLocation: transformedIPLocation }),
2354
2665
  },
2355
2666
  eventName: 'page_view', // Auto-tracked as page view
@@ -2426,15 +2737,41 @@ class AnalyticsService {
2426
2737
  const ipLocationData = locationData && typeof locationData === 'object'
2427
2738
  ? locationData?.ipLocationData
2428
2739
  : undefined;
2429
- // Transform IP location data to match backend expected format
2430
- const transformedIPLocation = transformIPLocationForBackend(ipLocationData);
2740
+ // Get field storage config (support both new and legacy format)
2741
+ const fieldStorage = this.config.fieldStorage || {};
2742
+ const ipLocationConfig = fieldStorage.ipLocation || this.config.ipLocationFields;
2743
+ // Transform and filter all data types based on configuration
2744
+ const transformedIPLocation = transformIPLocationForBackend(ipLocationData, ipLocationConfig);
2745
+ const filteredDeviceInfo = filterFieldsByConfig(context?.deviceInfo || autoContext?.deviceInfo, fieldStorage.deviceInfo, DEFAULT_ESSENTIAL_DEVICE_FIELDS);
2746
+ const filteredNetworkInfo = filterFieldsByConfig(context?.networkInfo || autoContext?.networkInfo, fieldStorage.networkInfo, DEFAULT_ESSENTIAL_NETWORK_FIELDS);
2747
+ // For location: In essential mode, remove duplicate fields that are already in customData.ipLocation
2748
+ const locationConfig = fieldStorage.location;
2749
+ const locationMode = locationConfig?.mode || 'essential';
2750
+ let filteredLocation = filterFieldsByConfig((context?.location || autoContext?.location), locationConfig, DEFAULT_ESSENTIAL_LOCATION_FIELDS);
2751
+ // In essential mode, if we have IP location data, remove duplicate fields from location
2752
+ if (locationMode === 'essential' && transformedIPLocation && filteredLocation) {
2753
+ // Remove fields that are duplicated in customData.ipLocation
2754
+ const duplicateFields = ['ip', 'country', 'countryCode', 'city', 'region', 'timezone'];
2755
+ const minimalLocation = { ...filteredLocation };
2756
+ duplicateFields.forEach(field => {
2757
+ delete minimalLocation[field];
2758
+ });
2759
+ // Only keep essential location fields: lat, lon, source, ts
2760
+ filteredLocation = {
2761
+ lat: minimalLocation.lat,
2762
+ lon: minimalLocation.lon,
2763
+ source: minimalLocation.source,
2764
+ ts: minimalLocation.ts,
2765
+ };
2766
+ }
2767
+ const filteredAttribution = filterFieldsByConfig(context?.attribution || autoContext?.attribution, fieldStorage.attribution, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS);
2431
2768
  await this.trackEvent({
2432
2769
  sessionId: finalSessionId,
2433
2770
  pageUrl: finalPageUrl,
2434
- networkInfo: context?.networkInfo || autoContext?.networkInfo,
2435
- deviceInfo: context?.deviceInfo || autoContext?.deviceInfo,
2436
- location: context?.location || autoContext?.location,
2437
- attribution: context?.attribution || autoContext?.attribution,
2771
+ networkInfo: filteredNetworkInfo || undefined,
2772
+ deviceInfo: filteredDeviceInfo || undefined,
2773
+ location: filteredLocation || undefined,
2774
+ attribution: filteredAttribution || undefined,
2438
2775
  userId: context?.userId || finalSessionId,
2439
2776
  eventName,
2440
2777
  eventParameters: parameters || {},
@@ -2620,6 +2957,8 @@ function useAnalytics(options = {}) {
2620
2957
  logLevel: config.logLevel,
2621
2958
  enableMetrics: config.enableMetrics,
2622
2959
  sessionTimeout: config.sessionTimeout,
2960
+ fieldStorage: config.fieldStorage,
2961
+ ipLocationFields: config.ipLocationFields, // Legacy support
2623
2962
  });
2624
2963
  }
2625
2964
  }, [
@@ -2905,5 +3244,5 @@ function useAnalytics(options = {}) {
2905
3244
  ]);
2906
3245
  }
2907
3246
 
2908
- export { AnalyticsService, AttributionDetector, DeviceDetector, LocationDetector, NetworkDetector, QueueManager, checkAndSetLocationConsent, clearLocationConsent, clearSession, useAnalytics as default, getCompleteIPLocation, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateSession, getOrCreateUserId, getPublicIP, getSession, hasLocationConsent, initDebug, loadJSON, loadSessionJSON, logger, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, transformIPLocationForBackend, updateSessionActivity, useAnalytics };
3247
+ export { AnalyticsService, AttributionDetector, DEFAULT_ESSENTIAL_ATTRIBUTION_FIELDS, DEFAULT_ESSENTIAL_DEVICE_FIELDS, DEFAULT_ESSENTIAL_IP_FIELDS, DEFAULT_ESSENTIAL_LOCATION_FIELDS, DEFAULT_ESSENTIAL_NETWORK_FIELDS, DeviceDetector, LocationDetector, NetworkDetector, QueueManager, checkAndSetLocationConsent, clearLocationConsent, clearSession, useAnalytics as default, filterFieldsByConfig, getCompleteIPLocation, getIPFromRequest, getIPLocation, getLocationConsentTimestamp, getOrCreateSession, getOrCreateUserId, getPublicIP, getSession, hasLocationConsent, initDebug, loadJSON, loadSessionJSON, logger, saveJSON, saveSessionJSON, setLocationConsentGranted, trackPageVisit, transformIPLocationForBackend, updateSessionActivity, useAnalytics };
2909
3248
  //# sourceMappingURL=index.esm.js.map