@tracelog/lib 0.6.0 → 0.6.3
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/README.md +9 -9
- package/dist/browser/tracelog.esm.js +406 -304
- package/dist/browser/tracelog.esm.js.map +1 -0
- package/dist/browser/tracelog.js +2 -2
- package/dist/browser/tracelog.js.map +1 -0
- package/dist/cjs/api.d.ts +1 -1
- package/dist/cjs/api.js +13 -4
- package/dist/cjs/app.d.ts +1 -1
- package/dist/cjs/app.js +4 -4
- package/dist/cjs/constants/config.constants.d.ts +3 -0
- package/dist/cjs/constants/config.constants.js +5 -2
- package/dist/cjs/handlers/click.handler.js +2 -2
- package/dist/cjs/handlers/scroll.handler.js +1 -1
- package/dist/cjs/handlers/session.handler.js +1 -1
- package/dist/cjs/managers/event.manager.d.ts +3 -0
- package/dist/cjs/managers/event.manager.js +47 -6
- package/dist/cjs/managers/sender.manager.js +4 -5
- package/dist/cjs/managers/storage.manager.d.ts +5 -0
- package/dist/cjs/managers/storage.manager.js +95 -6
- package/dist/cjs/public-api.d.ts +1 -1
- package/dist/cjs/test-bridge.d.ts +1 -1
- package/dist/cjs/test-bridge.js +1 -1
- package/dist/cjs/types/config.types.d.ts +4 -4
- package/dist/cjs/types/state.types.d.ts +1 -1
- package/dist/cjs/types/test-bridge.types.d.ts +1 -1
- package/dist/cjs/utils/logging.utils.d.ts +16 -1
- package/dist/cjs/utils/logging.utils.js +65 -4
- package/dist/cjs/utils/network/url.utils.d.ts +1 -1
- package/dist/cjs/utils/network/url.utils.js +11 -12
- package/dist/cjs/utils/validations/config-validations.utils.d.ts +2 -2
- package/dist/cjs/utils/validations/config-validations.utils.js +30 -18
- package/dist/esm/api.d.ts +1 -1
- package/dist/esm/api.js +13 -4
- package/dist/esm/app.d.ts +1 -1
- package/dist/esm/app.js +5 -5
- package/dist/esm/constants/config.constants.d.ts +3 -0
- package/dist/esm/constants/config.constants.js +3 -0
- package/dist/esm/handlers/click.handler.js +2 -2
- package/dist/esm/handlers/scroll.handler.js +1 -1
- package/dist/esm/handlers/session.handler.js +1 -1
- package/dist/esm/managers/event.manager.d.ts +3 -0
- package/dist/esm/managers/event.manager.js +48 -7
- package/dist/esm/managers/sender.manager.js +4 -5
- package/dist/esm/managers/storage.manager.d.ts +5 -0
- package/dist/esm/managers/storage.manager.js +95 -6
- package/dist/esm/public-api.d.ts +1 -1
- package/dist/esm/test-bridge.d.ts +1 -1
- package/dist/esm/test-bridge.js +1 -1
- package/dist/esm/types/config.types.d.ts +4 -4
- package/dist/esm/types/state.types.d.ts +1 -1
- package/dist/esm/types/test-bridge.types.d.ts +1 -1
- package/dist/esm/utils/logging.utils.d.ts +16 -1
- package/dist/esm/utils/logging.utils.js +65 -4
- package/dist/esm/utils/network/url.utils.d.ts +1 -1
- package/dist/esm/utils/network/url.utils.js +9 -10
- package/dist/esm/utils/validations/config-validations.utils.d.ts +2 -2
- package/dist/esm/utils/validations/config-validations.utils.js +30 -18
- package/package.json +7 -6
|
@@ -3,19 +3,61 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.log = exports.formatLogMsg = void 0;
|
|
4
4
|
const formatLogMsg = (msg, error) => {
|
|
5
5
|
if (error) {
|
|
6
|
+
// In production, sanitize error messages to avoid exposing sensitive paths
|
|
7
|
+
if (process.env.NODE_ENV !== 'dev' && error instanceof Error) {
|
|
8
|
+
// Remove file paths and line numbers from error messages
|
|
9
|
+
const sanitizedMessage = error.message.replace(/\s+at\s+.*$/gm, '').replace(/\(.*?:\d+:\d+\)/g, '');
|
|
10
|
+
return `[TraceLog] ${msg}: ${sanitizedMessage}`;
|
|
11
|
+
}
|
|
6
12
|
return `[TraceLog] ${msg}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
7
13
|
}
|
|
8
14
|
return `[TraceLog] ${msg}`;
|
|
9
15
|
};
|
|
10
16
|
exports.formatLogMsg = formatLogMsg;
|
|
17
|
+
/**
|
|
18
|
+
* Safe logging utility that respects production environment
|
|
19
|
+
*
|
|
20
|
+
* @param type - Log level (info, warn, error, debug)
|
|
21
|
+
* @param msg - Message to log
|
|
22
|
+
* @param extra - Optional extra data
|
|
23
|
+
*
|
|
24
|
+
* Production behavior:
|
|
25
|
+
* - debug: Never logged in production
|
|
26
|
+
* - info: Only logged if showToClient=true
|
|
27
|
+
* - warn: Always logged (important for debugging production issues)
|
|
28
|
+
* - error: Always logged
|
|
29
|
+
* - Stack traces are sanitized
|
|
30
|
+
* - Data objects are sanitized
|
|
31
|
+
*/
|
|
11
32
|
const log = (type, msg, extra) => {
|
|
12
|
-
const { error, data, showToClient } = extra ?? {};
|
|
33
|
+
const { error, data, showToClient = false } = extra ?? {};
|
|
13
34
|
const formattedMsg = error ? (0, exports.formatLogMsg)(msg, error) : `[TraceLog] ${msg}`;
|
|
14
35
|
const method = type === 'error' ? 'error' : type === 'warn' ? 'warn' : 'log';
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
// Production logging strategy:
|
|
37
|
+
// - Development: Log everything
|
|
38
|
+
// - Production:
|
|
39
|
+
// - debug: never logged
|
|
40
|
+
// - info: only if showToClient=true
|
|
41
|
+
// - warn: always logged (critical for debugging)
|
|
42
|
+
// - error: always logged
|
|
43
|
+
const isProduction = process.env.NODE_ENV !== 'dev';
|
|
44
|
+
if (isProduction) {
|
|
45
|
+
// Never log debug in production
|
|
46
|
+
if (type === 'debug') {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Log info only if explicitly flagged
|
|
50
|
+
if (type === 'info' && !showToClient) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// warn and error always logged in production
|
|
17
54
|
}
|
|
18
|
-
|
|
55
|
+
// In production, sanitize data to avoid exposing sensitive information
|
|
56
|
+
if (isProduction && data !== undefined) {
|
|
57
|
+
const sanitizedData = sanitizeLogData(data);
|
|
58
|
+
console[method](formattedMsg, sanitizedData);
|
|
59
|
+
}
|
|
60
|
+
else if (data !== undefined) {
|
|
19
61
|
console[method](formattedMsg, data);
|
|
20
62
|
}
|
|
21
63
|
else {
|
|
@@ -23,3 +65,22 @@ const log = (type, msg, extra) => {
|
|
|
23
65
|
}
|
|
24
66
|
};
|
|
25
67
|
exports.log = log;
|
|
68
|
+
/**
|
|
69
|
+
* Sanitizes log data in production to prevent sensitive information leakage
|
|
70
|
+
* Simple approach: redact sensitive keys only
|
|
71
|
+
*/
|
|
72
|
+
const sanitizeLogData = (data) => {
|
|
73
|
+
const sanitized = {};
|
|
74
|
+
const sensitiveKeys = ['token', 'password', 'secret', 'key', 'apikey', 'api_key', 'sessionid', 'session_id'];
|
|
75
|
+
for (const [key, value] of Object.entries(data)) {
|
|
76
|
+
const lowerKey = key.toLowerCase();
|
|
77
|
+
// Redact sensitive keys
|
|
78
|
+
if (sensitiveKeys.some((sensitiveKey) => lowerKey.includes(sensitiveKey))) {
|
|
79
|
+
sanitized[key] = '[REDACTED]';
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
sanitized[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sanitized;
|
|
86
|
+
};
|
|
@@ -4,7 +4,7 @@ import { Config } from '@/types';
|
|
|
4
4
|
* @param id - The project ID
|
|
5
5
|
* @returns The generated API URL
|
|
6
6
|
*/
|
|
7
|
-
export declare const
|
|
7
|
+
export declare const getCollectApiUrl: (config: Config) => string;
|
|
8
8
|
/**
|
|
9
9
|
* Normalizes a URL by removing sensitive query parameters
|
|
10
10
|
* @param url - The URL to normalize
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.normalizeUrl = exports.
|
|
3
|
+
exports.normalizeUrl = exports.getCollectApiUrl = void 0;
|
|
4
4
|
const logging_utils_1 = require("../logging.utils");
|
|
5
5
|
/**
|
|
6
6
|
* Validates if a URL is valid and optionally allows HTTP URLs
|
|
@@ -24,8 +24,7 @@ const isValidUrl = (url, allowHttp = false) => {
|
|
|
24
24
|
* @param id - The project ID
|
|
25
25
|
* @returns The generated API URL
|
|
26
26
|
*/
|
|
27
|
-
const
|
|
28
|
-
const allowHttp = config.allowHttp ?? false;
|
|
27
|
+
const getCollectApiUrl = (config) => {
|
|
29
28
|
if (config.integrations?.tracelog?.projectId) {
|
|
30
29
|
const url = new URL(window.location.href);
|
|
31
30
|
const host = url.hostname;
|
|
@@ -35,25 +34,25 @@ const getApiUrl = (config) => {
|
|
|
35
34
|
}
|
|
36
35
|
const projectId = config.integrations.tracelog.projectId;
|
|
37
36
|
const cleanDomain = parts.slice(-2).join('.');
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const isValid = isValidUrl(apiUrl, allowHttp);
|
|
37
|
+
const collectApiUrl = `https://${projectId}.${cleanDomain}/collect`;
|
|
38
|
+
const isValid = isValidUrl(collectApiUrl);
|
|
41
39
|
if (!isValid) {
|
|
42
40
|
throw new Error('Invalid URL');
|
|
43
41
|
}
|
|
44
|
-
return
|
|
42
|
+
return collectApiUrl;
|
|
45
43
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
44
|
+
const collectApiUrl = config.integrations?.custom?.collectApiUrl;
|
|
45
|
+
if (collectApiUrl) {
|
|
46
|
+
const allowHttp = config.integrations?.custom?.allowHttp ?? false;
|
|
47
|
+
const isValid = isValidUrl(collectApiUrl, allowHttp);
|
|
49
48
|
if (!isValid) {
|
|
50
49
|
throw new Error('Invalid URL');
|
|
51
50
|
}
|
|
52
|
-
return
|
|
51
|
+
return collectApiUrl;
|
|
53
52
|
}
|
|
54
53
|
return '';
|
|
55
54
|
};
|
|
56
|
-
exports.
|
|
55
|
+
exports.getCollectApiUrl = getCollectApiUrl;
|
|
57
56
|
/**
|
|
58
57
|
* Normalizes a URL by removing sensitive query parameters
|
|
59
58
|
* @param url - The URL to normalize
|
|
@@ -6,7 +6,7 @@ import { Config } from '../../types';
|
|
|
6
6
|
* @throws {ProjectIdValidationError} If project ID validation fails
|
|
7
7
|
* @throws {AppConfigValidationError} If other configuration validation fails
|
|
8
8
|
*/
|
|
9
|
-
export declare const validateAppConfig: (config
|
|
9
|
+
export declare const validateAppConfig: (config?: Config) => void;
|
|
10
10
|
/**
|
|
11
11
|
* Validates and normalizes the app configuration
|
|
12
12
|
* This is the primary validation entry point that ensures consistent behavior
|
|
@@ -15,4 +15,4 @@ export declare const validateAppConfig: (config: Config) => void;
|
|
|
15
15
|
* @throws {ProjectIdValidationError} If project ID validation fails after normalization
|
|
16
16
|
* @throws {AppConfigValidationError} If other configuration validation fails
|
|
17
17
|
*/
|
|
18
|
-
export declare const validateAndNormalizeConfig: (config
|
|
18
|
+
export declare const validateAndNormalizeConfig: (config?: Config) => Config;
|
|
@@ -12,9 +12,12 @@ const logging_utils_1 = require("../logging.utils");
|
|
|
12
12
|
* @throws {AppConfigValidationError} If other configuration validation fails
|
|
13
13
|
*/
|
|
14
14
|
const validateAppConfig = (config) => {
|
|
15
|
-
if (
|
|
15
|
+
if (config !== undefined && (config === null || typeof config !== 'object')) {
|
|
16
16
|
throw new validation_error_types_1.AppConfigValidationError('Configuration must be an object', 'config');
|
|
17
17
|
}
|
|
18
|
+
if (!config) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
18
21
|
if (config.sessionTimeout !== undefined) {
|
|
19
22
|
if (typeof config.sessionTimeout !== 'number' ||
|
|
20
23
|
config.sessionTimeout < constants_1.MIN_SESSION_TIMEOUT_MS ||
|
|
@@ -53,11 +56,6 @@ const validateAppConfig = (config) => {
|
|
|
53
56
|
throw new validation_error_types_1.SamplingRateValidationError(constants_1.VALIDATION_MESSAGES.INVALID_SAMPLING_RATE, 'config');
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
|
-
if (config.allowHttp !== undefined) {
|
|
57
|
-
if (typeof config.allowHttp !== 'boolean') {
|
|
58
|
-
throw new validation_error_types_1.AppConfigValidationError('allowHttp must be a boolean', 'config');
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
59
|
};
|
|
62
60
|
exports.validateAppConfig = validateAppConfig;
|
|
63
61
|
/**
|
|
@@ -149,13 +147,21 @@ const validateIntegrations = (integrations) => {
|
|
|
149
147
|
}
|
|
150
148
|
}
|
|
151
149
|
if (integrations.custom) {
|
|
152
|
-
if (!integrations.custom.
|
|
153
|
-
typeof integrations.custom.
|
|
154
|
-
integrations.custom.
|
|
150
|
+
if (!integrations.custom.collectApiUrl ||
|
|
151
|
+
typeof integrations.custom.collectApiUrl !== 'string' ||
|
|
152
|
+
integrations.custom.collectApiUrl.trim() === '') {
|
|
155
153
|
throw new validation_error_types_1.IntegrationValidationError(constants_1.VALIDATION_MESSAGES.INVALID_CUSTOM_API_URL, 'config');
|
|
156
154
|
}
|
|
157
|
-
if (
|
|
158
|
-
throw new validation_error_types_1.IntegrationValidationError('
|
|
155
|
+
if (integrations.custom.allowHttp !== undefined && typeof integrations.custom.allowHttp !== 'boolean') {
|
|
156
|
+
throw new validation_error_types_1.IntegrationValidationError('allowHttp must be a boolean', 'config');
|
|
157
|
+
}
|
|
158
|
+
const collectApiUrl = integrations.custom.collectApiUrl.trim();
|
|
159
|
+
if (!collectApiUrl.startsWith('http://') && !collectApiUrl.startsWith('https://')) {
|
|
160
|
+
throw new validation_error_types_1.IntegrationValidationError('Custom API URL must start with "http://" or "https://"', 'config');
|
|
161
|
+
}
|
|
162
|
+
const allowHttp = integrations.custom.allowHttp ?? false;
|
|
163
|
+
if (!allowHttp && collectApiUrl.startsWith('http://')) {
|
|
164
|
+
throw new validation_error_types_1.IntegrationValidationError('Custom API URL must use HTTPS in production. Set allowHttp: true in integration config to allow HTTP (not recommended)', 'config');
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
if (integrations.googleAnalytics) {
|
|
@@ -181,14 +187,20 @@ const validateIntegrations = (integrations) => {
|
|
|
181
187
|
const validateAndNormalizeConfig = (config) => {
|
|
182
188
|
(0, exports.validateAppConfig)(config);
|
|
183
189
|
const normalizedConfig = {
|
|
184
|
-
...config,
|
|
185
|
-
sessionTimeout: config
|
|
186
|
-
globalMetadata: config
|
|
187
|
-
sensitiveQueryParams: config
|
|
188
|
-
errorSampling: config
|
|
189
|
-
samplingRate: config
|
|
190
|
-
allowHttp: config.allowHttp ?? false,
|
|
190
|
+
...(config ?? {}),
|
|
191
|
+
sessionTimeout: config?.sessionTimeout ?? constants_1.DEFAULT_SESSION_TIMEOUT,
|
|
192
|
+
globalMetadata: config?.globalMetadata ?? {},
|
|
193
|
+
sensitiveQueryParams: config?.sensitiveQueryParams ?? [],
|
|
194
|
+
errorSampling: config?.errorSampling ?? 1,
|
|
195
|
+
samplingRate: config?.samplingRate ?? 1,
|
|
191
196
|
};
|
|
197
|
+
// Normalize integrations
|
|
198
|
+
if (normalizedConfig.integrations?.custom) {
|
|
199
|
+
normalizedConfig.integrations.custom = {
|
|
200
|
+
...normalizedConfig.integrations.custom,
|
|
201
|
+
allowHttp: normalizedConfig.integrations.custom.allowHttp ?? false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
192
204
|
return normalizedConfig;
|
|
193
205
|
};
|
|
194
206
|
exports.validateAndNormalizeConfig = validateAndNormalizeConfig;
|
package/dist/esm/api.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { App } from './app';
|
|
2
2
|
import { MetadataType, Config, EmitterCallback, EmitterMap } from './types';
|
|
3
3
|
import './types/window.types';
|
|
4
|
-
export declare const init: (config
|
|
4
|
+
export declare const init: (config?: Config) => Promise<void>;
|
|
5
5
|
export declare const event: (name: string, metadata?: Record<string, MetadataType> | Record<string, MetadataType>[]) => void;
|
|
6
6
|
export declare const on: <K extends keyof EmitterMap>(event: K, callback: EmitterCallback<EmitterMap[K]>) => void;
|
|
7
7
|
export declare const off: <K extends keyof EmitterMap>(event: K, callback: EmitterCallback<EmitterMap[K]>) => void;
|
package/dist/esm/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { App } from './app';
|
|
2
2
|
import { log, validateAndNormalizeConfig } from './utils';
|
|
3
3
|
import { TestBridge } from './test-bridge';
|
|
4
|
+
import { INITIALIZATION_TIMEOUT_MS } from './constants';
|
|
4
5
|
import './types/window.types';
|
|
5
6
|
// Buffer for listeners registered before init()
|
|
6
7
|
const pendingListeners = [];
|
|
@@ -22,7 +23,7 @@ export const init = async (config) => {
|
|
|
22
23
|
}
|
|
23
24
|
isInitializing = true;
|
|
24
25
|
try {
|
|
25
|
-
const validatedConfig = validateAndNormalizeConfig(config);
|
|
26
|
+
const validatedConfig = validateAndNormalizeConfig(config ?? {});
|
|
26
27
|
const instance = new App();
|
|
27
28
|
try {
|
|
28
29
|
// Attach buffered listeners BEFORE init() so they capture initial events
|
|
@@ -30,7 +31,14 @@ export const init = async (config) => {
|
|
|
30
31
|
instance.on(event, callback);
|
|
31
32
|
});
|
|
32
33
|
pendingListeners.length = 0;
|
|
33
|
-
|
|
34
|
+
// Wrap initialization with timeout using Promise.race
|
|
35
|
+
const initPromise = instance.init(validatedConfig);
|
|
36
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
reject(new Error(`[TraceLog] Initialization timeout after ${INITIALIZATION_TIMEOUT_MS}ms`));
|
|
39
|
+
}, INITIALIZATION_TIMEOUT_MS);
|
|
40
|
+
});
|
|
41
|
+
await Promise.race([initPromise, timeoutPromise]);
|
|
34
42
|
app = instance;
|
|
35
43
|
}
|
|
36
44
|
catch (error) {
|
|
@@ -105,8 +113,9 @@ export const destroy = async () => {
|
|
|
105
113
|
app = null;
|
|
106
114
|
isInitializing = false;
|
|
107
115
|
pendingListeners.length = 0;
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
// Log error but don't re-throw - destroy should always complete successfully
|
|
117
|
+
// Applications should be able to tear down TraceLog even if internal cleanup fails
|
|
118
|
+
log('warn', 'Error during destroy, forced cleanup completed', { error });
|
|
110
119
|
}
|
|
111
120
|
finally {
|
|
112
121
|
isDestroying = false;
|
package/dist/esm/app.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare class App extends StateManager {
|
|
|
29
29
|
googleAnalytics?: GoogleAnalyticsIntegration;
|
|
30
30
|
};
|
|
31
31
|
get initialized(): boolean;
|
|
32
|
-
init(config
|
|
32
|
+
init(config?: Config): Promise<void>;
|
|
33
33
|
sendCustomEvent(name: string, metadata?: Record<string, unknown> | Record<string, unknown>[]): void;
|
|
34
34
|
on<K extends keyof EmitterMap>(event: K, callback: EmitterCallback<EmitterMap[K]>): void;
|
|
35
35
|
off<K extends keyof EmitterMap>(event: K, callback: EmitterCallback<EmitterMap[K]>): void;
|
package/dist/esm/app.js
CHANGED
|
@@ -7,7 +7,7 @@ import { ClickHandler } from './handlers/click.handler';
|
|
|
7
7
|
import { ScrollHandler } from './handlers/scroll.handler';
|
|
8
8
|
import { EventType, Mode } from './types';
|
|
9
9
|
import { GoogleAnalyticsIntegration } from './integrations/google-analytics.integration';
|
|
10
|
-
import { isEventValid, getDeviceType, normalizeUrl, Emitter,
|
|
10
|
+
import { isEventValid, getDeviceType, normalizeUrl, Emitter, getCollectApiUrl, detectQaMode, log } from './utils';
|
|
11
11
|
import { StorageManager } from './managers/storage.manager';
|
|
12
12
|
import { SCROLL_DEBOUNCE_TIME_MS, SCROLL_SUPPRESS_MULTIPLIER } from './constants/config.constants';
|
|
13
13
|
import { PerformanceHandler } from './handlers/performance.handler';
|
|
@@ -25,7 +25,7 @@ export class App extends StateManager {
|
|
|
25
25
|
get initialized() {
|
|
26
26
|
return this.isInitialized;
|
|
27
27
|
}
|
|
28
|
-
async init(config) {
|
|
28
|
+
async init(config = {}) {
|
|
29
29
|
if (this.isInitialized) {
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
@@ -100,12 +100,12 @@ export class App extends StateManager {
|
|
|
100
100
|
this.isInitialized = false;
|
|
101
101
|
this.handlers = {};
|
|
102
102
|
}
|
|
103
|
-
setupState(config) {
|
|
103
|
+
setupState(config = {}) {
|
|
104
104
|
this.set('config', config);
|
|
105
105
|
const userId = UserManager.getId(this.managers.storage);
|
|
106
106
|
this.set('userId', userId);
|
|
107
|
-
const
|
|
108
|
-
this.set('
|
|
107
|
+
const collectApiUrl = getCollectApiUrl(config);
|
|
108
|
+
this.set('collectApiUrl', collectApiUrl);
|
|
109
109
|
const device = getDeviceType();
|
|
110
110
|
this.set('device', device);
|
|
111
111
|
const pageUrl = normalizeUrl(window.location.href, config.sensitiveQueryParams);
|
|
@@ -22,7 +22,10 @@ export declare const MAX_SCROLL_EVENTS_PER_SESSION = 120;
|
|
|
22
22
|
export declare const DEFAULT_SAMPLING_RATE = 1;
|
|
23
23
|
export declare const MIN_SAMPLING_RATE = 0;
|
|
24
24
|
export declare const MAX_SAMPLING_RATE = 1;
|
|
25
|
+
export declare const RATE_LIMIT_WINDOW_MS = 1000;
|
|
26
|
+
export declare const MAX_EVENTS_PER_SECOND = 200;
|
|
25
27
|
export declare const BATCH_SIZE_THRESHOLD = 50;
|
|
28
|
+
export declare const MAX_PENDING_EVENTS_BUFFER = 100;
|
|
26
29
|
export declare const MIN_SESSION_TIMEOUT_MS = 30000;
|
|
27
30
|
export declare const MAX_SESSION_TIMEOUT_MS = 86400000;
|
|
28
31
|
export declare const MAX_CUSTOM_EVENT_NAME_LENGTH = 120;
|
|
@@ -32,8 +32,11 @@ export const MAX_SCROLL_EVENTS_PER_SESSION = 120;
|
|
|
32
32
|
export const DEFAULT_SAMPLING_RATE = 1;
|
|
33
33
|
export const MIN_SAMPLING_RATE = 0;
|
|
34
34
|
export const MAX_SAMPLING_RATE = 1;
|
|
35
|
+
export const RATE_LIMIT_WINDOW_MS = 1000; // 1 second window
|
|
36
|
+
export const MAX_EVENTS_PER_SECOND = 200; // Maximum 200 events per second
|
|
35
37
|
// Queue and batch limits
|
|
36
38
|
export const BATCH_SIZE_THRESHOLD = 50;
|
|
39
|
+
export const MAX_PENDING_EVENTS_BUFFER = 100; // Maximum events to buffer before session init
|
|
37
40
|
// Session timeout validation limits
|
|
38
41
|
export const MIN_SESSION_TIMEOUT_MS = 30000; // 30 seconds minimum
|
|
39
42
|
export const MAX_SESSION_TIMEOUT_MS = 86400000; // 24 hours maximum
|
|
@@ -14,9 +14,9 @@ export class ClickHandler extends StateManager {
|
|
|
14
14
|
this.clickHandler = (event) => {
|
|
15
15
|
const mouseEvent = event;
|
|
16
16
|
const target = mouseEvent.target;
|
|
17
|
-
const clickedElement = target instanceof HTMLElement
|
|
17
|
+
const clickedElement = typeof HTMLElement !== 'undefined' && target instanceof HTMLElement
|
|
18
18
|
? target
|
|
19
|
-
: target instanceof Node && target.parentElement instanceof HTMLElement
|
|
19
|
+
: typeof HTMLElement !== 'undefined' && target instanceof Node && target.parentElement instanceof HTMLElement
|
|
20
20
|
? target.parentElement
|
|
21
21
|
: null;
|
|
22
22
|
if (!clickedElement) {
|
|
@@ -42,7 +42,7 @@ export class ScrollHandler extends StateManager {
|
|
|
42
42
|
trySetupContainers(selectors, attempt) {
|
|
43
43
|
const elements = selectors
|
|
44
44
|
.map((sel) => this.safeQuerySelector(sel))
|
|
45
|
-
.filter((element) => element instanceof HTMLElement);
|
|
45
|
+
.filter((element) => element != null && typeof HTMLElement !== 'undefined' && element instanceof HTMLElement);
|
|
46
46
|
if (elements.length > 0) {
|
|
47
47
|
for (const element of elements) {
|
|
48
48
|
const isAlreadyTracking = this.containers.some((c) => c.element === element);
|
|
@@ -18,7 +18,7 @@ export class SessionHandler extends StateManager {
|
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
const config = this.get('config');
|
|
21
|
-
const projectId = config?.integrations?.tracelog?.projectId ?? config?.integrations?.custom?.
|
|
21
|
+
const projectId = config?.integrations?.tracelog?.projectId ?? config?.integrations?.custom?.collectApiUrl ?? 'default';
|
|
22
22
|
if (!projectId) {
|
|
23
23
|
throw new Error('Cannot start session tracking: config not available');
|
|
24
24
|
}
|
|
@@ -12,6 +12,8 @@ export declare class EventManager extends StateManager {
|
|
|
12
12
|
private lastEventFingerprint;
|
|
13
13
|
private lastEventTime;
|
|
14
14
|
private sendIntervalId;
|
|
15
|
+
private rateLimitCounter;
|
|
16
|
+
private rateLimitWindowStart;
|
|
15
17
|
constructor(storeManager: StorageManager, googleAnalytics?: GoogleAnalyticsIntegration | null, emitter?: Emitter | null);
|
|
16
18
|
recoverPersistedEvents(): Promise<void>;
|
|
17
19
|
track({ type, page_url, from_page_url, scroll_data, click_data, custom_event, web_vitals, error_data, session_end_reason, }: Partial<EventData>): void;
|
|
@@ -32,6 +34,7 @@ export declare class EventManager extends StateManager {
|
|
|
32
34
|
private startSendInterval;
|
|
33
35
|
private handleGoogleAnalyticsIntegration;
|
|
34
36
|
private shouldSample;
|
|
37
|
+
private checkRateLimit;
|
|
35
38
|
private removeProcessedEvents;
|
|
36
39
|
private emitEvent;
|
|
37
40
|
private emitEventsQueue;
|
|
@@ -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('
|
|
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('
|
|
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
|
-
|
|
124
|
-
|
|
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) => {
|
|
@@ -20,7 +20,7 @@ export class SenderManager extends StateManager {
|
|
|
20
20
|
return true;
|
|
21
21
|
}
|
|
22
22
|
const config = this.get('config');
|
|
23
|
-
if (config?.integrations?.custom?.
|
|
23
|
+
if (config?.integrations?.custom?.collectApiUrl === SpecialApiUrl.Fail) {
|
|
24
24
|
log('warn', 'Fail mode: simulating network failure (sync)', {
|
|
25
25
|
data: { events: body.events.length },
|
|
26
26
|
});
|
|
@@ -90,7 +90,7 @@ export class SenderManager extends StateManager {
|
|
|
90
90
|
return this.simulateSuccessfulSend();
|
|
91
91
|
}
|
|
92
92
|
const config = this.get('config');
|
|
93
|
-
if (config?.integrations?.custom?.
|
|
93
|
+
if (config?.integrations?.custom?.collectApiUrl === SpecialApiUrl.Fail) {
|
|
94
94
|
log('warn', 'Fail mode: simulating network failure', {
|
|
95
95
|
data: { events: body.events.length },
|
|
96
96
|
});
|
|
@@ -152,7 +152,6 @@ export class SenderManager extends StateManager {
|
|
|
152
152
|
return false;
|
|
153
153
|
}
|
|
154
154
|
prepareRequest(body) {
|
|
155
|
-
const url = `${this.get('apiUrl')}/collect`;
|
|
156
155
|
// Enrich payload with metadata for sendBeacon() fallback
|
|
157
156
|
// sendBeacon() doesn't send custom headers, so we include referer in payload
|
|
158
157
|
const enrichedBody = {
|
|
@@ -163,7 +162,7 @@ export class SenderManager extends StateManager {
|
|
|
163
162
|
},
|
|
164
163
|
};
|
|
165
164
|
return {
|
|
166
|
-
url,
|
|
165
|
+
url: this.get('collectApiUrl'),
|
|
167
166
|
payload: JSON.stringify(enrichedBody),
|
|
168
167
|
};
|
|
169
168
|
}
|
|
@@ -269,7 +268,7 @@ export class SenderManager extends StateManager {
|
|
|
269
268
|
}, retryDelay);
|
|
270
269
|
}
|
|
271
270
|
shouldSkipSend() {
|
|
272
|
-
return !this.get('
|
|
271
|
+
return !this.get('collectApiUrl');
|
|
273
272
|
}
|
|
274
273
|
async simulateSuccessfulSend() {
|
|
275
274
|
const delay = Math.random() * 400 + 100;
|
|
@@ -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
|
*/
|