@tracelog/lib 0.6.0 → 0.6.1

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.
@@ -1,4 +1,4 @@
1
- import { EVENT_SENT_INTERVAL_MS, MAX_EVENTS_QUEUE_LENGTH, DUPLICATE_EVENT_THRESHOLD_MS, } from '../constants/config.constants';
1
+ import { EVENT_SENT_INTERVAL_MS, MAX_EVENTS_QUEUE_LENGTH, DUPLICATE_EVENT_THRESHOLD_MS, RATE_LIMIT_WINDOW_MS, MAX_EVENTS_PER_SECOND, MAX_PENDING_EVENTS_BUFFER, } from '../constants/config.constants';
2
2
  import { EmitterEvent, EventType, Mode } from '../types';
3
3
  import { getUTMParameters, log, generateEventId } from '../utils';
4
4
  import { SenderManager } from './sender.manager';
@@ -11,6 +11,8 @@ export class EventManager extends StateManager {
11
11
  this.lastEventFingerprint = null;
12
12
  this.lastEventTime = 0;
13
13
  this.sendIntervalId = null;
14
+ this.rateLimitCounter = 0;
15
+ this.rateLimitWindowStart = 0;
14
16
  this.googleAnalytics = googleAnalytics;
15
17
  this.dataSender = new SenderManager(storeManager);
16
18
  this.emitter = emitter;
@@ -33,10 +35,19 @@ export class EventManager extends StateManager {
33
35
  }
34
36
  track({ type, page_url, from_page_url, scroll_data, click_data, custom_event, web_vitals, error_data, session_end_reason, }) {
35
37
  if (!type) {
36
- log('warn', 'Event type is required');
38
+ log('error', 'Event type is required - event will be ignored');
37
39
  return;
38
40
  }
41
+ // Check session BEFORE rate limiting to avoid consuming quota for buffered events
39
42
  if (!this.get('sessionId')) {
43
+ // Protect against unbounded buffer growth during initialization delays
44
+ if (this.pendingEventsBuffer.length >= MAX_PENDING_EVENTS_BUFFER) {
45
+ // Drop oldest event (FIFO) to make room for new one
46
+ this.pendingEventsBuffer.shift();
47
+ log('warn', 'Pending events buffer full - dropping oldest event', {
48
+ data: { maxBufferSize: MAX_PENDING_EVENTS_BUFFER },
49
+ });
50
+ }
40
51
  this.pendingEventsBuffer.push({
41
52
  type,
42
53
  page_url,
@@ -50,10 +61,16 @@ export class EventManager extends StateManager {
50
61
  });
51
62
  return;
52
63
  }
64
+ // Rate limiting check (except for critical events)
65
+ // Applied AFTER session check to only rate-limit processable events
66
+ const isCriticalEvent = type === EventType.SESSION_START || type === EventType.SESSION_END;
67
+ if (!isCriticalEvent && !this.checkRateLimit()) {
68
+ // Rate limit exceeded - drop event silently
69
+ // Logging would itself cause performance issues
70
+ return;
71
+ }
53
72
  const eventType = type;
54
73
  const isSessionStart = eventType === EventType.SESSION_START;
55
- const isSessionEnd = eventType === EventType.SESSION_END;
56
- const isCriticalEvent = isSessionStart || isSessionEnd;
57
74
  const currentPageUrl = page_url || this.get('pageUrl');
58
75
  const payload = this.buildEventPayload({
59
76
  type: eventType,
@@ -72,7 +89,7 @@ export class EventManager extends StateManager {
72
89
  if (isSessionStart) {
73
90
  const currentSessionId = this.get('sessionId');
74
91
  if (!currentSessionId) {
75
- log('warn', 'Session start event ignored: missing sessionId');
92
+ log('error', 'Session start event requires sessionId - event will be ignored');
76
93
  return;
77
94
  }
78
95
  if (this.get('hasStartSession')) {
@@ -105,6 +122,8 @@ export class EventManager extends StateManager {
105
122
  this.pendingEventsBuffer = [];
106
123
  this.lastEventFingerprint = null;
107
124
  this.lastEventTime = 0;
125
+ this.rateLimitCounter = 0;
126
+ this.rateLimitWindowStart = 0;
108
127
  this.dataSender.stop();
109
128
  }
110
129
  async flushImmediately() {
@@ -120,12 +139,19 @@ export class EventManager extends StateManager {
120
139
  if (this.pendingEventsBuffer.length === 0) {
121
140
  return;
122
141
  }
123
- if (!this.get('sessionId')) {
124
- log('warn', 'Cannot flush pending events: session not initialized');
142
+ const currentSessionId = this.get('sessionId');
143
+ if (!currentSessionId) {
144
+ // Keep events in buffer for future retry - do NOT discard
145
+ // This prevents data loss during legitimate race conditions
146
+ log('warn', 'Cannot flush pending events: session not initialized - keeping in buffer', {
147
+ data: { bufferedEventCount: this.pendingEventsBuffer.length },
148
+ });
125
149
  return;
126
150
  }
151
+ // Create copy before clearing to avoid infinite recursion
127
152
  const bufferedEvents = [...this.pendingEventsBuffer];
128
153
  this.pendingEventsBuffer = [];
154
+ // Process all buffered events now that session exists
129
155
  bufferedEvents.forEach((event) => {
130
156
  this.track(event);
131
157
  });
@@ -301,6 +327,21 @@ export class EventManager extends StateManager {
301
327
  const samplingRate = this.get('config')?.samplingRate ?? 1;
302
328
  return Math.random() < samplingRate;
303
329
  }
330
+ checkRateLimit() {
331
+ const now = Date.now();
332
+ // Reset counter if window has expired
333
+ if (now - this.rateLimitWindowStart > RATE_LIMIT_WINDOW_MS) {
334
+ this.rateLimitCounter = 0;
335
+ this.rateLimitWindowStart = now;
336
+ }
337
+ // Check if limit exceeded
338
+ if (this.rateLimitCounter >= MAX_EVENTS_PER_SECOND) {
339
+ return false;
340
+ }
341
+ // Increment counter
342
+ this.rateLimitCounter++;
343
+ return true;
344
+ }
304
345
  removeProcessedEvents(eventIds) {
305
346
  const eventIdSet = new Set(eventIds);
306
347
  this.eventsQueue = this.eventsQueue.filter((event) => {
@@ -35,6 +35,11 @@ export declare class StorageManager {
35
35
  * This indicates localStorage is full and data may not persist
36
36
  */
37
37
  hasQuotaError(): boolean;
38
+ /**
39
+ * Attempts to cleanup old TraceLog data from storage to free up space
40
+ * Returns true if any data was removed, false otherwise
41
+ */
42
+ private cleanupOldData;
38
43
  /**
39
44
  * Initialize storage (localStorage or sessionStorage) with feature detection
40
45
  */
@@ -37,6 +37,9 @@ export class StorageManager {
37
37
  * Stores an item in storage
38
38
  */
39
39
  setItem(key, value) {
40
+ // Always update fallback FIRST for consistency
41
+ // This ensures fallback is in sync and can serve as backup if storage fails
42
+ this.fallbackStorage.set(key, value);
40
43
  try {
41
44
  if (this.storage) {
42
45
  this.storage.setItem(key, value);
@@ -46,15 +49,37 @@ export class StorageManager {
46
49
  catch (error) {
47
50
  if (error instanceof DOMException && error.name === 'QuotaExceededError') {
48
51
  this.hasQuotaExceededError = true;
49
- log('error', 'localStorage quota exceeded - data will not persist after reload', {
50
- error,
52
+ log('warn', 'localStorage quota exceeded, attempting cleanup', {
51
53
  data: { key, valueSize: value.length },
52
54
  });
55
+ // Attempt to free up space by removing old TraceLog data
56
+ const cleanedUp = this.cleanupOldData();
57
+ if (cleanedUp) {
58
+ // Retry after cleanup
59
+ try {
60
+ if (this.storage) {
61
+ this.storage.setItem(key, value);
62
+ // Successfully stored after cleanup
63
+ return;
64
+ }
65
+ }
66
+ catch (retryError) {
67
+ log('error', 'localStorage quota exceeded even after cleanup - data will not persist', {
68
+ error: retryError,
69
+ data: { key, valueSize: value.length },
70
+ });
71
+ }
72
+ }
73
+ else {
74
+ log('error', 'localStorage quota exceeded and no data to cleanup - data will not persist', {
75
+ error,
76
+ data: { key, valueSize: value.length },
77
+ });
78
+ }
53
79
  }
54
80
  // Else: Silent fallback - user already warned in constructor
81
+ // Data is already in fallbackStorage (set at beginning)
55
82
  }
56
- // Always update fallback for consistency
57
- this.fallbackStorage.set(key, value);
58
83
  }
59
84
  /**
60
85
  * Removes an item from storage
@@ -108,6 +133,69 @@ export class StorageManager {
108
133
  hasQuotaError() {
109
134
  return this.hasQuotaExceededError;
110
135
  }
136
+ /**
137
+ * Attempts to cleanup old TraceLog data from storage to free up space
138
+ * Returns true if any data was removed, false otherwise
139
+ */
140
+ cleanupOldData() {
141
+ if (!this.storage) {
142
+ return false;
143
+ }
144
+ try {
145
+ const tracelogKeys = [];
146
+ const persistedEventsKeys = [];
147
+ // Collect all TraceLog keys
148
+ for (let i = 0; i < this.storage.length; i++) {
149
+ const key = this.storage.key(i);
150
+ if (key?.startsWith('tracelog_')) {
151
+ tracelogKeys.push(key);
152
+ // Prioritize removing old persisted events
153
+ if (key.startsWith('tracelog_persisted_events_')) {
154
+ persistedEventsKeys.push(key);
155
+ }
156
+ }
157
+ }
158
+ // First, try to remove old persisted events (usually the largest data)
159
+ if (persistedEventsKeys.length > 0) {
160
+ persistedEventsKeys.forEach((key) => {
161
+ try {
162
+ this.storage.removeItem(key);
163
+ }
164
+ catch {
165
+ // Ignore errors during cleanup
166
+ }
167
+ });
168
+ // Successfully cleaned up - no need to log in production
169
+ return true;
170
+ }
171
+ // If no persisted events, remove non-critical keys
172
+ // Define critical key prefixes that should be preserved
173
+ const criticalPrefixes = ['tracelog_session_', 'tracelog_user_id', 'tracelog_device_id', 'tracelog_config'];
174
+ const nonCriticalKeys = tracelogKeys.filter((key) => {
175
+ // Keep keys that start with any critical prefix
176
+ return !criticalPrefixes.some((prefix) => key.startsWith(prefix));
177
+ });
178
+ if (nonCriticalKeys.length > 0) {
179
+ // Remove up to 5 non-critical keys
180
+ const keysToRemove = nonCriticalKeys.slice(0, 5);
181
+ keysToRemove.forEach((key) => {
182
+ try {
183
+ this.storage.removeItem(key);
184
+ }
185
+ catch {
186
+ // Ignore errors during cleanup
187
+ }
188
+ });
189
+ // Successfully cleaned up - no need to log in production
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+ catch (error) {
195
+ log('error', 'Failed to cleanup old data', { error });
196
+ return false;
197
+ }
198
+ }
111
199
  /**
112
200
  * Initialize storage (localStorage or sessionStorage) with feature detection
113
201
  */
@@ -145,6 +233,8 @@ export class StorageManager {
145
233
  * Stores an item in sessionStorage
146
234
  */
147
235
  setSessionItem(key, value) {
236
+ // Always update fallback FIRST for consistency
237
+ this.fallbackSessionStorage.set(key, value);
148
238
  try {
149
239
  if (this.sessionStorageRef) {
150
240
  this.sessionStorageRef.setItem(key, value);
@@ -159,9 +249,8 @@ export class StorageManager {
159
249
  });
160
250
  }
161
251
  // Else: Silent fallback - user already warned in constructor
252
+ // Data is already in fallbackSessionStorage (set at beginning)
162
253
  }
163
- // Always update fallback for consistency
164
- this.fallbackSessionStorage.set(key, value);
165
254
  }
166
255
  /**
167
256
  * Removes an item from sessionStorage
@@ -6,8 +6,6 @@ export interface Config {
6
6
  globalMetadata?: Record<string, MetadataType>;
7
7
  /** Selectors defining custom scroll containers to monitor. */
8
8
  scrollContainerSelectors?: string | string[];
9
- /** Enables HTTP requests for testing and development flows. */
10
- allowHttp?: boolean;
11
9
  /** Query parameters to remove before tracking URLs. */
12
10
  sensitiveQueryParams?: string[];
13
11
  /** Error event sampling rate between 0 and 1. */
@@ -25,6 +23,8 @@ export interface Config {
25
23
  custom?: {
26
24
  /** Required API URL for custom integration. */
27
25
  apiUrl: string;
26
+ /** Allow HTTP URLs (not recommended for production). @default false */
27
+ allowHttp?: boolean;
28
28
  };
29
29
  /** Google Analytics integration options. */
30
30
  googleAnalytics?: {
@@ -1,5 +1,20 @@
1
1
  export declare const formatLogMsg: (msg: string, error?: unknown) => string;
2
- export declare const log: (type: "info" | "warn" | "error", msg: string, extra?: {
2
+ /**
3
+ * Safe logging utility that respects production environment
4
+ *
5
+ * @param type - Log level (info, warn, error, debug)
6
+ * @param msg - Message to log
7
+ * @param extra - Optional extra data
8
+ *
9
+ * Production behavior:
10
+ * - debug: Never logged in production
11
+ * - info: Only logged if showToClient=true
12
+ * - warn: Always logged (important for debugging production issues)
13
+ * - error: Always logged
14
+ * - Stack traces are sanitized
15
+ * - Data objects are sanitized
16
+ */
17
+ export declare const log: (type: "info" | "warn" | "error" | "debug", msg: string, extra?: {
3
18
  error?: unknown;
4
19
  data?: Record<string, unknown>;
5
20
  showToClient?: boolean;
@@ -1,20 +1,81 @@
1
1
  export const formatLogMsg = (msg, error) => {
2
2
  if (error) {
3
+ // In production, sanitize error messages to avoid exposing sensitive paths
4
+ if (process.env.NODE_ENV !== 'dev' && error instanceof Error) {
5
+ // Remove file paths and line numbers from error messages
6
+ const sanitizedMessage = error.message.replace(/\s+at\s+.*$/gm, '').replace(/\(.*?:\d+:\d+\)/g, '');
7
+ return `[TraceLog] ${msg}: ${sanitizedMessage}`;
8
+ }
3
9
  return `[TraceLog] ${msg}: ${error instanceof Error ? error.message : 'Unknown error'}`;
4
10
  }
5
11
  return `[TraceLog] ${msg}`;
6
12
  };
13
+ /**
14
+ * Safe logging utility that respects production environment
15
+ *
16
+ * @param type - Log level (info, warn, error, debug)
17
+ * @param msg - Message to log
18
+ * @param extra - Optional extra data
19
+ *
20
+ * Production behavior:
21
+ * - debug: Never logged in production
22
+ * - info: Only logged if showToClient=true
23
+ * - warn: Always logged (important for debugging production issues)
24
+ * - error: Always logged
25
+ * - Stack traces are sanitized
26
+ * - Data objects are sanitized
27
+ */
7
28
  export const log = (type, msg, extra) => {
8
- const { error, data, showToClient } = extra ?? {};
29
+ const { error, data, showToClient = false } = extra ?? {};
9
30
  const formattedMsg = error ? formatLogMsg(msg, error) : `[TraceLog] ${msg}`;
10
31
  const method = type === 'error' ? 'error' : type === 'warn' ? 'warn' : 'log';
11
- if (process.env.NODE_ENV !== 'dev' && !showToClient) {
12
- return;
32
+ // Production logging strategy:
33
+ // - Development: Log everything
34
+ // - Production:
35
+ // - debug: never logged
36
+ // - info: only if showToClient=true
37
+ // - warn: always logged (critical for debugging)
38
+ // - error: always logged
39
+ const isProduction = process.env.NODE_ENV !== 'dev';
40
+ if (isProduction) {
41
+ // Never log debug in production
42
+ if (type === 'debug') {
43
+ return;
44
+ }
45
+ // Log info only if explicitly flagged
46
+ if (type === 'info' && !showToClient) {
47
+ return;
48
+ }
49
+ // warn and error always logged in production
13
50
  }
14
- if (data !== undefined) {
51
+ // In production, sanitize data to avoid exposing sensitive information
52
+ if (isProduction && data !== undefined) {
53
+ const sanitizedData = sanitizeLogData(data);
54
+ console[method](formattedMsg, sanitizedData);
55
+ }
56
+ else if (data !== undefined) {
15
57
  console[method](formattedMsg, data);
16
58
  }
17
59
  else {
18
60
  console[method](formattedMsg);
19
61
  }
20
62
  };
63
+ /**
64
+ * Sanitizes log data in production to prevent sensitive information leakage
65
+ * Simple approach: redact sensitive keys only
66
+ */
67
+ const sanitizeLogData = (data) => {
68
+ const sanitized = {};
69
+ const sensitiveKeys = ['token', 'password', 'secret', 'key', 'apikey', 'api_key', 'sessionid', 'session_id'];
70
+ for (const [key, value] of Object.entries(data)) {
71
+ const lowerKey = key.toLowerCase();
72
+ // Redact sensitive keys
73
+ if (sensitiveKeys.some((sensitiveKey) => lowerKey.includes(sensitiveKey))) {
74
+ sanitized[key] = '[REDACTED]';
75
+ }
76
+ else {
77
+ sanitized[key] = value;
78
+ }
79
+ }
80
+ return sanitized;
81
+ };
@@ -22,7 +22,6 @@ const isValidUrl = (url, allowHttp = false) => {
22
22
  * @returns The generated API URL
23
23
  */
24
24
  export const getApiUrl = (config) => {
25
- const allowHttp = config.allowHttp ?? false;
26
25
  if (config.integrations?.tracelog?.projectId) {
27
26
  const url = new URL(window.location.href);
28
27
  const host = url.hostname;
@@ -32,9 +31,8 @@ export const getApiUrl = (config) => {
32
31
  }
33
32
  const projectId = config.integrations.tracelog.projectId;
34
33
  const cleanDomain = parts.slice(-2).join('.');
35
- const protocol = allowHttp && url.protocol === 'http:' ? 'http' : 'https';
36
- const apiUrl = `${protocol}://${projectId}.${cleanDomain}`;
37
- const isValid = isValidUrl(apiUrl, allowHttp);
34
+ const apiUrl = `https://${projectId}.${cleanDomain}`;
35
+ const isValid = isValidUrl(apiUrl);
38
36
  if (!isValid) {
39
37
  throw new Error('Invalid URL');
40
38
  }
@@ -42,6 +40,7 @@ export const getApiUrl = (config) => {
42
40
  }
43
41
  if (config.integrations?.custom?.apiUrl) {
44
42
  const apiUrl = config.integrations.custom.apiUrl;
43
+ const allowHttp = config.integrations?.custom?.allowHttp ?? false;
45
44
  const isValid = isValidUrl(apiUrl, allowHttp);
46
45
  if (!isValid) {
47
46
  throw new Error('Invalid URL');
@@ -50,11 +50,6 @@ export const validateAppConfig = (config) => {
50
50
  throw new SamplingRateValidationError(VALIDATION_MESSAGES.INVALID_SAMPLING_RATE, 'config');
51
51
  }
52
52
  }
53
- if (config.allowHttp !== undefined) {
54
- if (typeof config.allowHttp !== 'boolean') {
55
- throw new AppConfigValidationError('allowHttp must be a boolean', 'config');
56
- }
57
- }
58
53
  };
59
54
  /**
60
55
  * Validates CSS selector syntax without executing querySelector (XSS prevention)
@@ -150,8 +145,16 @@ const validateIntegrations = (integrations) => {
150
145
  integrations.custom.apiUrl.trim() === '') {
151
146
  throw new IntegrationValidationError(VALIDATION_MESSAGES.INVALID_CUSTOM_API_URL, 'config');
152
147
  }
153
- if (!integrations.custom.apiUrl.startsWith('http')) {
154
- throw new IntegrationValidationError('Custom API URL must start with "http"', 'config');
148
+ if (integrations.custom.allowHttp !== undefined && typeof integrations.custom.allowHttp !== 'boolean') {
149
+ throw new IntegrationValidationError('allowHttp must be a boolean', 'config');
150
+ }
151
+ const apiUrl = integrations.custom.apiUrl.trim();
152
+ if (!apiUrl.startsWith('http://') && !apiUrl.startsWith('https://')) {
153
+ throw new IntegrationValidationError('Custom API URL must start with "http://" or "https://"', 'config');
154
+ }
155
+ const allowHttp = integrations.custom.allowHttp ?? false;
156
+ if (!allowHttp && apiUrl.startsWith('http://')) {
157
+ throw new IntegrationValidationError('Custom API URL must use HTTPS in production. Set allowHttp: true in integration config to allow HTTP (not recommended)', 'config');
155
158
  }
156
159
  }
157
160
  if (integrations.googleAnalytics) {
@@ -183,7 +186,13 @@ export const validateAndNormalizeConfig = (config) => {
183
186
  sensitiveQueryParams: config.sensitiveQueryParams ?? [],
184
187
  errorSampling: config.errorSampling ?? 1,
185
188
  samplingRate: config.samplingRate ?? 1,
186
- allowHttp: config.allowHttp ?? false,
187
189
  };
190
+ // Normalize integrations
191
+ if (normalizedConfig.integrations?.custom) {
192
+ normalizedConfig.integrations.custom = {
193
+ ...normalizedConfig.integrations.custom,
194
+ allowHttp: normalizedConfig.integrations.custom.allowHttp ?? false,
195
+ };
196
+ }
188
197
  return normalizedConfig;
189
198
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@tracelog/lib",
3
3
  "description": "JavaScript library for web analytics and real-time event tracking",
4
4
  "license": "MIT",
5
- "version": "0.6.0",
5
+ "version": "0.6.1",
6
6
  "main": "./dist/cjs/public-api.js",
7
7
  "module": "./dist/esm/public-api.js",
8
8
  "types": "./dist/esm/public-api.d.ts",
@@ -43,6 +43,7 @@
43
43
  "serve": "http-server playground -p 3000 --cors",
44
44
  "playground:setup": "npm run build:browser:dev && cp dist/browser/tracelog.esm.js playground/tracelog.js",
45
45
  "playground:dev": "npm run playground:setup && npm run serve",
46
+ "playground:gh-pages": "npm run build:browser && cp dist/browser/tracelog.esm.js playground/tracelog.js",
46
47
  "test:e2e": "npm run build:browser:dev && cp dist/browser/tracelog.esm.js playground/tracelog.js && NODE_ENV=dev playwright test",
47
48
  "ci:build": "npm run build:all",
48
49
  "prepare": "husky",
@@ -55,7 +56,7 @@
55
56
  "changelog:preview": "node scripts/generate-changelog.js --dry-run"
56
57
  },
57
58
  "dependencies": {
58
- "web-vitals": "^4.2.4"
59
+ "web-vitals": "4.2.4"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@commitlint/config-conventional": "^19.8.1",