lever-ui-logger
Version:
Zero-dependency logging library with optional EventBus integration. Built-in PII redaction, multiple transports, and comprehensive logging capabilities.
1,613 lines (1,606 loc) • 75.7 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/transports/index.ts
var transports_exports = {};
__export(transports_exports, {
BaseTransport: () => BaseTransport,
BrowserStyles: () => BrowserStyles,
Colors: () => Colors,
ConsoleTransport: () => ConsoleTransport,
Environment: () => Environment,
ErrorMessageSanitizer: () => ErrorMessageSanitizer,
EventBusTransport: () => EventBusTransport,
Formatters: () => Formatters,
SecureTokenHandler: () => SecureTokenHandler,
SendBeaconTransport: () => SendBeaconTransport,
TRANSPORTS_VERSION: () => TRANSPORTS_VERSION,
TransportMiddleware: () => TransportMiddleware,
addMetadata: () => addMetadata,
batchCompressionMiddleware: () => batchCompressionMiddleware,
compressString: () => compressString,
compressionMiddleware: () => compressionMiddleware,
createConsoleTransport: () => createConsoleTransport,
createEventBusTransport: () => createEventBusTransport,
createSendBeaconTransport: () => createSendBeaconTransport,
defaultErrorSanitizer: () => defaultErrorSanitizer,
defaultSecureTokenHandler: () => defaultSecureTokenHandler,
enrichErrors: () => enrichErrors,
filterByLevel: () => filterByLevel,
isCompressionSupported: () => isCompressionSupported,
rateLimit: () => rateLimit,
sample: () => sample,
transformEvent: () => transformEvent
});
module.exports = __toCommonJS(transports_exports);
// src/transports/transport-interface.ts
var Environment = {
/** Check if running in browser environment */
isBrowser: typeof window !== "undefined" && typeof window.document !== "undefined",
/** Check if running in Node.js environment */
isNode: typeof process !== "undefined" && !!process?.versions?.node,
/** Check if running in production environment */
isProduction: typeof process !== "undefined" && process?.env?.NODE_ENV === "production",
/** Check if console methods support styling */
supportsConsoleStyles: typeof window !== "undefined" || typeof process !== "undefined" && !!process?.stdout?.isTTY
};
var BaseTransport = class {
constructor(name, config = {}) {
this.name = name;
this.config = config;
}
/**
* Flush any pending logs (default: no-op)
*/
flush() {
}
/**
* Close the transport and clean up resources (default: no-op)
*/
close() {
}
/**
* Check if transport should be active in current environment
*/
isEnabled() {
return true;
}
/**
* Measure performance of a function call
*/
measurePerformance(fn, threshold = 1) {
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
if (duration > threshold && !Environment.isProduction) {
console.warn(`Transport "${this.name}" took ${duration.toFixed(2)}ms (threshold: ${threshold}ms)`);
}
return result;
}
};
var Formatters = {
/**
* Format timestamp with configurable format
*/
timestamp(timestamp, format = "HH:mm:ss.SSS") {
const date = new Date(timestamp);
if (format === "iso") {
return date.toISOString();
}
if (format === "HH:mm:ss.SSS") {
return date.toISOString().slice(11, 23);
}
if (format === "HH:mm:ss") {
return date.toISOString().slice(11, 19);
}
return date.toISOString().slice(11, 23);
},
/**
* Pretty-print objects with indentation
*/
prettyObject(obj, indent = 2) {
try {
return JSON.stringify(obj, null, indent);
} catch {
return String(obj);
}
},
/**
* Compact object representation
*/
compactObject(obj) {
try {
return JSON.stringify(obj);
} catch {
return String(obj);
}
},
/**
* Get log level priority for comparison
*/
getLogLevelPriority(level) {
const priorities = {
trace: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
};
return priorities[level] ?? 2;
}
};
var Colors = {
// Log level colors
trace: "\x1B[36m",
// Cyan
debug: "\x1B[34m",
// Blue
info: "\x1B[32m",
// Green
warn: "\x1B[33m",
// Yellow
error: "\x1B[31m",
// Red
// Style codes
reset: "\x1B[0m",
// Reset
bold: "\x1B[1m",
// Bold
dim: "\x1B[2m",
// Dim
// Component/context colors
component: "\x1B[35m",
// Magenta
timestamp: "\x1B[90m"
// Bright Black (Gray)
};
var BrowserStyles = {
trace: "color: #00bcd4; font-weight: normal;",
debug: "color: #2196f3; font-weight: normal;",
info: "color: #4caf50; font-weight: normal;",
warn: "color: #ff9800; font-weight: bold;",
error: "color: #f44336; font-weight: bold;",
component: "color: #9c27b0; font-weight: bold;",
timestamp: "color: #666; font-weight: normal;"
};
// src/transports/console-transport.ts
var ConsoleTransport = class extends BaseTransport {
constructor(config = {}) {
const mergedConfig = {
name: "console",
format: "pretty",
colors: true,
timestamps: true,
timestampFormat: "HH:mm:ss.SSS",
enableInProduction: false,
performanceThreshold: 0.1,
// 0.1ms threshold for console output
consoleMethods: {},
...config
};
super(mergedConfig.name, mergedConfig);
this.transportConfig = mergedConfig;
this.consoleMethods = this.initializeConsoleMethods();
}
/**
* Write a log event to the console
*/
write(event) {
if (!event) {
throw new TypeError("LogEventData is required");
}
if (!event.message || typeof event.message !== "string") {
throw new TypeError("LogEventData must have a valid message");
}
if (!this.isEnabled()) {
return;
}
this.measurePerformance(() => {
const formatted = this.formatEvent(event);
const consoleMethod = this.consoleMethods[event.level];
if (this.transportConfig.colors && Environment.isBrowser) {
this.writeWithBrowserStyles(formatted, consoleMethod);
} else if (this.transportConfig.colors && Environment.supportsConsoleStyles) {
this.writeWithAnsiColors(formatted, consoleMethod);
} else {
this.writeWithoutColors(formatted, consoleMethod);
}
}, this.transportConfig.performanceThreshold);
}
/**
* Check if transport should be active in current environment
*/
isEnabled() {
if (Environment.isProduction && !this.transportConfig.enableInProduction) {
return false;
}
return typeof console !== "undefined";
}
/**
* Initialize console method mapping
*/
initializeConsoleMethods() {
if (typeof console === "undefined") {
const noop = () => {
};
return {
trace: noop,
debug: noop,
info: noop,
warn: noop,
error: noop
};
}
const defaultMethods = {
trace: console.trace?.bind(console) || console.log?.bind(console) || (() => {
}),
debug: console.debug?.bind(console) || console.log?.bind(console) || (() => {
}),
info: console.info?.bind(console) || console.log?.bind(console) || (() => {
}),
warn: console.warn?.bind(console) || console.log?.bind(console) || (() => {
}),
error: console.error?.bind(console) || console.log?.bind(console) || (() => {
})
};
const customMethods = this.transportConfig.consoleMethods;
if (customMethods) {
for (const [level, methodName] of Object.entries(customMethods)) {
const method = console[methodName];
if (typeof method === "function") {
defaultMethods[level] = method.bind(console);
}
}
}
return defaultMethods;
}
/**
* Format a log event according to the configured format
*/
formatEvent(event) {
const timestamp = this.transportConfig.timestamps ? Formatters.timestamp(event.timestamp, this.transportConfig.timestampFormat) : "";
const component = event.component ? `[${event.component}]` : "";
const level = event.level.toUpperCase().padEnd(5);
let contextStr = "";
let argsStr = "";
if (Object.keys(event.context).length > 0) {
contextStr = this.formatData(event.context, this.transportConfig.format);
}
if (event.args.length > 0) {
argsStr = event.args.map((arg) => this.formatData(arg, this.transportConfig.format)).join(" ");
}
return {
timestamp,
level,
component,
message: event.message,
context: contextStr,
args: argsStr,
raw: event
};
}
/**
* Format data based on the configured format mode
*/
formatData(data, format) {
switch (format) {
case "json":
return Formatters.compactObject(data);
case "pretty":
if (typeof data === "object" && data !== null) {
return Formatters.prettyObject(data);
}
return String(data);
case "compact":
return typeof data === "object" && data !== null ? Formatters.compactObject(data) : String(data);
default:
return String(data);
}
}
/**
* Write to console with browser CSS styles
*/
writeWithBrowserStyles(formatted, consoleMethod) {
const parts = [];
const styles = [];
if (formatted.timestamp) {
parts.push(`%c${formatted.timestamp}`);
styles.push(BrowserStyles.timestamp);
}
parts.push(`%c${formatted.level}`);
styles.push(BrowserStyles[formatted.raw.level] || "");
if (formatted.component) {
parts.push(`%c${formatted.component}`);
styles.push(BrowserStyles.component);
}
parts.push(`%c${formatted.message}`);
styles.push("");
const message = parts.join(" ");
const logArgs = [message, ...styles];
if (formatted.context) {
if (this.transportConfig.format === "pretty") {
logArgs.push("\nContext:", formatted.context);
} else {
logArgs.push("Context:", formatted.context);
}
}
if (formatted.args) {
if (this.transportConfig.format === "pretty") {
logArgs.push("\nArgs:", formatted.args);
} else {
logArgs.push("Args:", formatted.args);
}
}
consoleMethod(...logArgs);
}
/**
* Write to console with ANSI colors
*/
writeWithAnsiColors(formatted, consoleMethod) {
const parts = [];
if (formatted.timestamp) {
parts.push(`${Colors.timestamp}${formatted.timestamp}${Colors.reset}`);
}
const levelColor = Colors[formatted.raw.level] || "";
parts.push(`${levelColor}${formatted.level}${Colors.reset}`);
if (formatted.component) {
parts.push(`${Colors.component}${formatted.component}${Colors.reset}`);
}
parts.push(formatted.message);
let output = parts.join(" ");
if (formatted.context) {
output += this.transportConfig.format === "pretty" ? `
${Colors.dim}Context:${Colors.reset} ${formatted.context}` : ` ${Colors.dim}Context:${Colors.reset} ${formatted.context}`;
}
if (formatted.args) {
output += this.transportConfig.format === "pretty" ? `
${Colors.dim}Args:${Colors.reset} ${formatted.args}` : ` ${Colors.dim}Args:${Colors.reset} ${formatted.args}`;
}
consoleMethod(output);
}
/**
* Write to console without colors
*/
writeWithoutColors(formatted, consoleMethod) {
const parts = [];
if (formatted.timestamp) {
parts.push(formatted.timestamp);
}
parts.push(formatted.level);
if (formatted.component) {
parts.push(formatted.component);
}
parts.push(formatted.message);
let output = parts.join(" ");
if (formatted.context) {
output += this.transportConfig.format === "pretty" ? `
Context: ${formatted.context}` : ` Context: ${formatted.context}`;
}
if (formatted.args) {
output += this.transportConfig.format === "pretty" ? `
Args: ${formatted.args}` : ` Args: ${formatted.args}`;
}
consoleMethod(output);
}
};
function createConsoleTransport(config) {
return new ConsoleTransport(config);
}
// src/transports/error-sanitizer.ts
var ErrorMessageSanitizer = class {
constructor(config = {}) {
this.config = {
enableTokenDetection: config.enableTokenDetection ?? true,
replacementStrategy: config.replacementStrategy ?? "mask",
customPatterns: config.customPatterns ?? [],
sensitiveFields: config.sensitiveFields ?? [
"token",
"auth",
"authorization",
"bearer",
"password",
"secret",
"key",
"apikey",
"api_key",
"access_token",
"refresh_token",
"client_secret",
"private_key",
"credential",
"session"
],
maskRevealLength: config.maskRevealLength ?? 8
};
this.tokenPatterns = this.createTokenPatterns();
this.sensitiveFieldQuotedPattern = new RegExp(
`(${this.config.sensitiveFields.join("|")})\\s*[=:]\\s*(['"])([^'"]+)\\2`,
"gi"
);
this.sensitiveFieldUnquotedPattern = new RegExp(
`(${this.config.sensitiveFields.join("|")})\\s*[=:]\\s*([^\\s,}\\]\\)'"]+)`,
"gi"
);
}
/**
* Sanitize an error message or any string containing potentially sensitive data
*
* @param input - The string to sanitize
* @returns Sanitized string with sensitive data masked/redacted
*/
sanitize(input) {
if (!input || typeof input !== "string") {
return String(input);
}
let sanitized = input;
try {
if (this.config.enableTokenDetection) {
sanitized = this.sanitizeTokens(sanitized);
}
sanitized = this.sanitizeSensitiveFields(sanitized);
sanitized = this.applyCustomPatterns(sanitized);
return sanitized;
} catch {
return "[Error message sanitization failed - content redacted for security]";
}
}
/**
* Sanitize sensitive key-value pairs in strings
*/
sanitizeSensitiveFields(input) {
let sanitized = input;
sanitized = sanitized.replace(this.sensitiveFieldQuotedPattern, (match, field, quote, value) => {
const sanitizedValue = this.applySanitization(value);
return `${field}=${quote}${sanitizedValue}${quote}`;
});
sanitized = sanitized.replace(this.sensitiveFieldUnquotedPattern, (match, field, value) => {
const sanitizedValue = this.applySanitization(value);
return match.replace(value, sanitizedValue);
});
return sanitized;
}
/**
* Detect and sanitize various token patterns
*/
sanitizeTokens(input) {
let sanitized = input;
for (const [patternName, pattern] of Object.entries(this.tokenPatterns)) {
if (pattern.global) {
pattern.lastIndex = 0;
}
sanitized = sanitized.replace(pattern, (match) => {
return this.applySanitization(match, `[${patternName.toUpperCase()}_TOKEN]`);
});
}
return sanitized;
}
/**
* Apply custom user-defined patterns
*/
applyCustomPatterns(input) {
let sanitized = input;
for (const pattern of this.config.customPatterns) {
sanitized = sanitized.replace(pattern, (match) => {
return this.applySanitization(match, "[CUSTOM_REDACTED]");
});
}
return sanitized;
}
/**
* Apply the configured sanitization strategy to a detected sensitive value
*/
applySanitization(value, _placeholder) {
switch (this.config.replacementStrategy) {
case "redact":
return "[REDACTED]";
case "hash":
return this.hashValue(value);
case "mask":
default:
return this.maskValue(value);
}
}
/**
* Mask a value showing first and last few characters
*/
maskValue(value) {
if (value.length <= this.config.maskRevealLength) {
return "*".repeat(value.length);
}
const revealLength = Math.floor(this.config.maskRevealLength / 2);
const start = value.substring(0, revealLength);
const end = value.substring(value.length - revealLength);
const maskLength = Math.min(20, Math.max(4, value.length - revealLength * 2));
return `${start}${"*".repeat(maskLength)}${end}`;
}
/**
* Create a hash of the value for logging purposes
*/
hashValue(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
const char = value.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return `[HASH:${Math.abs(hash).toString(16)}]`;
}
/**
* Create comprehensive token detection patterns
*/
createTokenPatterns() {
return {
// JWT tokens: header.payload.signature (base64url encoded)
jwt: /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g,
// Bearer tokens in various formats
bearer: /bearer\s+[A-Za-z0-9+/=_-]{6,}/gi,
// Generic API key patterns - match just the value after field identifiers
genericApiKey: /(?<=(?:api[_-]?key|apikey|key|secret)[\s=:"']+)[A-Za-z0-9+/=_-]{6,}/gi,
// URLs with embedded credentials
urlWithCredentials: /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:]+:[^\s@]+@[^\s/]+/gi,
// Basic PII patterns
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
phone: /\b(?:\+?1[-.)\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.)\s]?[0-9]{3}[-.)\s]?[0-9]{4}\b/g,
creditCard: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g
};
}
/**
* Check if a string contains any detectable sensitive data
*
* @param input - String to check
* @returns True if sensitive data is detected
*/
hasSensitiveData(input) {
if (!input || typeof input !== "string") {
return false;
}
this.sensitiveFieldQuotedPattern.lastIndex = 0;
this.sensitiveFieldUnquotedPattern.lastIndex = 0;
if (this.sensitiveFieldQuotedPattern.test(input) || this.sensitiveFieldUnquotedPattern.test(input)) {
return true;
}
if (this.config.enableTokenDetection) {
for (const pattern of Object.values(this.tokenPatterns)) {
if (pattern.global) {
pattern.lastIndex = 0;
}
if (pattern.test(input)) {
return true;
}
}
}
for (const pattern of this.config.customPatterns) {
if (pattern.global) {
pattern.lastIndex = 0;
}
if (pattern.test(input)) {
return true;
}
}
return false;
}
/**
* Get statistics about what types of sensitive data were found
*
* @param input - String to analyze
* @returns Object with counts of different sensitive data types found
*/
analyzeSensitiveData(input) {
const analysis = {};
if (!input || typeof input !== "string") {
return analysis;
}
if (this.config.enableTokenDetection) {
for (const [patternName, pattern] of Object.entries(this.tokenPatterns)) {
const matches = input.match(pattern);
if (matches) {
analysis[patternName] = matches.length;
}
}
}
const quotedFieldMatches = input.match(this.sensitiveFieldQuotedPattern);
const unquotedFieldMatches = input.match(this.sensitiveFieldUnquotedPattern);
const totalFieldMatches = (quotedFieldMatches?.length || 0) + (unquotedFieldMatches?.length || 0);
if (totalFieldMatches > 0) {
analysis.sensitiveFields = totalFieldMatches;
}
return analysis;
}
};
var defaultErrorSanitizer = new ErrorMessageSanitizer();
// src/transports/secure-token-handler.ts
var SecureTokenHandler = class {
constructor(config = {}) {
this.tokenEntry = null;
this.tokenProvider = null;
this.disposed = false;
this.obfuscationKey = null;
this.config = {
enableSecureMode: config.enableSecureMode ?? true,
tokenTtl: config.tokenTtl ?? 36e5,
// 1 hour default
validateToken: config.validateToken ?? true,
tokenValidator: config.tokenValidator ?? this.defaultTokenValidator.bind(this)
};
this.errorSanitizer = new ErrorMessageSanitizer({
enableTokenDetection: true,
replacementStrategy: "mask",
maskRevealLength: 8
});
if (this.config.enableSecureMode) {
this.makeNonSerializable();
}
}
/**
* Set a static authentication token
*
* @param token - The authentication token to store securely
* @throws Error if token is invalid or handler is disposed
*/
setToken(token) {
this.checkDisposed();
if (token === null) {
this.tokenEntry = null;
this.tokenProvider = null;
return;
}
if (this.config.validateToken && !this.config.tokenValidator(token)) {
throw new Error("Invalid token format");
}
const now = Date.now();
const value = this.config.enableSecureMode ? this.obfuscateToken(token) : token;
this.tokenEntry = {
value,
timestamp: now,
expiresAt: now + this.config.tokenTtl,
source: "static",
secure: this.config.enableSecureMode
};
this.tokenProvider = null;
}
/**
* Set a token provider function for dynamic token retrieval
*
* @param provider - Function that returns an authentication token
* @throws Error if provider is invalid or handler is disposed
*/
setTokenProvider(provider) {
this.checkDisposed();
this.tokenProvider = provider;
this.tokenEntry = null;
}
/**
* Retrieve the current authentication token
*
* @returns The current token or null if none available
* @throws Error if token retrieval fails or handler is disposed
*/
async getToken() {
this.checkDisposed();
if (this.tokenEntry && this.isTokenValid(this.tokenEntry)) {
const value = this.tokenEntry.secure ? this.deobfuscateToken(this.tokenEntry.value) : this.tokenEntry.value;
return value;
}
if (this.tokenProvider) {
try {
const token = await this.tokenProvider();
if (this.config.validateToken && !this.config.tokenValidator(token)) {
throw new Error("Token provider returned invalid token");
}
const now = Date.now();
const value = this.config.enableSecureMode ? this.obfuscateToken(token) : token;
this.tokenEntry = {
value,
timestamp: now,
expiresAt: now + this.config.tokenTtl,
source: "function",
secure: this.config.enableSecureMode
};
return token;
} catch (error) {
const sanitizedError = this.sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
console.error("SecureTokenHandler: Token provider failed:", sanitizedError);
return null;
}
}
return null;
}
/**
* Check if a token is currently available and valid
*/
hasToken() {
if (this.disposed) return false;
if (this.tokenEntry && this.isTokenValid(this.tokenEntry)) {
return true;
}
return this.tokenProvider !== null;
}
/**
* Clear all token data and dispose resources
*
* This method should be called when the handler is no longer needed
* to ensure tokens are properly cleared from memory.
*/
dispose() {
if (this.disposed) return;
if (this.tokenEntry) {
if (typeof this.tokenEntry.value === "string") {
this.tokenEntry.value = this.generateRandomString(this.tokenEntry.value.length);
}
}
this.tokenEntry = null;
this.tokenProvider = null;
if (this.obfuscationKey) {
this.obfuscationKey.fill(0);
this.obfuscationKey = null;
}
this.disposed = true;
if (this.config.enableSecureMode) {
this.removeNonSerializable();
}
}
/**
* Create a secure JSON replacer function that sanitizes sensitive data
*/
createSecureReplacer() {
const seen = /* @__PURE__ */ new WeakSet();
return (_key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "[Circular Reference]";
}
seen.add(value);
}
if (typeof value === "string") {
const keyValuePair = `${_key}=${value}`;
const isSensitiveKey = this.errorSanitizer.hasSensitiveData(keyValuePair);
if (isSensitiveKey) {
return this.forceSanitizeValue(value);
}
if (this.errorSanitizer.hasSensitiveData(value)) {
return this.errorSanitizer.sanitize(value);
}
}
return value;
};
}
/**
* Sanitize HTTP headers to remove or mask authentication tokens
*
* @param headers - Headers object to sanitize
* @returns New headers object with sensitive values masked
*/
sanitizeHeaders(headers) {
const sanitized = {};
const sensitiveHeaderNames = [
"authorization",
"auth",
"x-api-key",
"x-auth-token",
"x-access-token",
"bearer",
"token",
"api-key",
"apikey",
"secret",
"credential"
];
for (const [key, value] of Object.entries(headers)) {
const lowerKey = key.toLowerCase();
if (sensitiveHeaderNames.includes(lowerKey)) {
sanitized[key] = this.maskToken(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Mask a token showing first and last few characters
*
* @param token - Token to mask
* @returns Masked token
*/
maskToken(token) {
if (!token || typeof token !== "string") {
return token;
}
if (token.length <= 8) {
return "*".repeat(token.length);
}
const revealLength = 4;
const start = token.substring(0, revealLength);
const end = token.substring(token.length - revealLength);
const maskLength = Math.min(20, Math.max(4, token.length - revealLength * 2));
return `${start}${"*".repeat(maskLength)}${end}`;
}
/**
* Force sanitization of a value using masking strategy
*/
forceSanitizeValue(value) {
if (!value || value.length <= 8) {
return "*".repeat(value.length);
}
const revealLength = Math.floor(4 / 2);
const start = value.substring(0, revealLength);
const end = value.substring(value.length - revealLength);
const maskLength = Math.min(20, Math.max(4, value.length - revealLength * 2));
return `${start}${"*".repeat(maskLength)}${end}`;
}
/**
* Default token validator
*/
defaultTokenValidator(token) {
if (!token || typeof token !== "string") return false;
if (token.length < 8) return false;
if (token.includes(" ") && !token.startsWith("Bearer ")) return false;
return true;
}
/**
* Check if handler is disposed
*/
checkDisposed() {
if (this.disposed) {
throw new Error("SecureTokenHandler has been disposed");
}
}
/**
* Check if a token entry is valid and not expired
*/
isTokenValid(entry) {
return entry.expiresAt > Date.now();
}
/**
* Multi-round obfuscation to protect tokens in memory using session key.
* Uses a combination of XOR, character substitution, and encoding rounds
* of transformation to protect tokens in memory against casual inspection
* and memory dumps. Not meant for cryptographic security but provides
* defense-in-depth against token extraction.
*/
obfuscateToken(token) {
if (!token) return "";
try {
const key = this.getObfuscationKey();
let obfuscated = token;
obfuscated = this.xorCipher(obfuscated, key);
obfuscated = this.substituteChars(obfuscated);
obfuscated = this.encodeObfuscated(obfuscated);
return obfuscated;
} catch {
return "";
}
}
/**
* Reverse the multi-round obfuscation process
*/
deobfuscateToken(obfuscated) {
if (!obfuscated) return "";
try {
let decoded = this.decodeObfuscated(obfuscated);
decoded = this.reverseSubstituteChars(decoded);
const key = this.getObfuscationKey();
decoded = this.xorCipher(decoded, key);
if (!/^[\x20-\x7E]+$/.test(decoded)) {
throw new Error("Deobfuscated data contains non-printable characters");
}
return decoded;
} catch {
return "";
}
}
/**
* XOR cipher implementation
*/
xorCipher(text, key) {
const result = new Array(text.length);
for (let i = 0; i < text.length; i++) {
result[i] = String.fromCharCode(text.charCodeAt(i) ^ key[i % key.length]);
}
return result.join("");
}
/**
* Character substitution using a custom mapping
*/
substituteChars(text) {
const substitutionMap = this.createSubstitutionMap();
return text.split("").map((char) => substitutionMap[char] || char).join("");
}
/**
* Reverse character substitution
*/
reverseSubstituteChars(text) {
const reverseMap = this.createReverseSubstitutionMap();
return text.split("").map((char) => reverseMap[char] || char).join("");
}
/**
* Encode the obfuscated string using base64 or similar
*/
encodeObfuscated(text) {
try {
return typeof btoa !== "undefined" ? btoa(text) : this.simpleEncode(text);
} catch {
return this.simpleEncode(text);
}
}
/**
* Decode the obfuscated string
*/
decodeObfuscated(encoded) {
try {
return typeof atob !== "undefined" ? atob(encoded) : this.simpleDecode(encoded);
} catch {
return this.simpleDecode(encoded);
}
}
/**
* Sanitize error messages to prevent token leakage
*/
sanitizeErrorMessage(errorMessage) {
return this.errorSanitizer.sanitize(errorMessage);
}
/**
* Create character substitution map
*/
createSubstitutionMap() {
const map = {};
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
const shuffled = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210/+=";
for (let i = 0; i < chars.length; i++) {
map[chars[i]] = shuffled[i];
}
return map;
}
/**
* Create reverse substitution map
*/
createReverseSubstitutionMap() {
const map = {};
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
const shuffled = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210/+=";
for (let i = 0; i < chars.length; i++) {
map[shuffled[i]] = chars[i];
}
return map;
}
/**
* Simple encoding fallback for environments without btoa
*/
simpleEncode(text) {
return text.split("").map((char) => String.fromCharCode(char.charCodeAt(0) + 1)).join("");
}
/**
* Simple decoding fallback for environments without atob
*/
simpleDecode(encoded) {
try {
return encoded.split("").map((char) => String.fromCharCode(char.charCodeAt(0) - 1)).join("");
} catch {
return "";
}
}
/**
* Generate a random string for token clearing
*/
generateRandomString(length) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Get or generate the session-specific obfuscation key
*/
getObfuscationKey() {
if (!this.obfuscationKey) {
this.obfuscationKey = new Uint8Array(8);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(this.obfuscationKey);
} else {
for (let i = 0; i < this.obfuscationKey.length; i++) {
this.obfuscationKey[i] = Math.floor(Math.random() * 256);
}
}
}
return this.obfuscationKey;
}
/**
* Make object non-serializable to prevent token leakage
*/
makeNonSerializable() {
Object.defineProperty(this, "toJSON", {
value: () => "[SecureTokenHandler - Non-Serializable]",
writable: false,
enumerable: false
});
Object.defineProperty(this, "toString", {
value: () => "[SecureTokenHandler - Protected]",
writable: false,
enumerable: false
});
Object.defineProperty(this, "valueOf", {
value: () => "[SecureTokenHandler - Protected]",
writable: false,
enumerable: false
});
}
/**
* Remove non-serializable protection (for disposal)
*/
removeNonSerializable() {
try {
Reflect.deleteProperty(this, "toJSON");
Reflect.deleteProperty(this, "toString");
Reflect.deleteProperty(this, "valueOf");
} catch {
}
}
};
var defaultSecureTokenHandler = new SecureTokenHandler();
// src/transports/sendbeacon-transport.ts
var SendBeaconTransport = class _SendBeaconTransport extends BaseTransport {
/**
* Create a new SendBeacon transport instance
*
* @param config - Configuration options for the transport
* @param config.endpoint - Required endpoint URL for telemetry data
* @param config.batchSize - Maximum events per batch (default: 50)
* @param config.flushInterval - Flush interval in milliseconds (default: 5000)
* @param config.maxPayloadSize - Maximum payload size in bytes (default: 64KB)
* @param config.authToken - Authentication token or provider function
* @param config.enableOfflineStorage - Enable localStorage fallback (default: true)
* @param config.rateLimitPerMinute - Rate limit events per minute (default: 1000)
*
* @example
* ```typescript
* const transport = new SendBeaconTransport({
* endpoint: 'https://api.example.com/telemetry',
* batchSize: 25,
* flushInterval: 3000,
* authToken: async () => await getApiToken(),
* userIdProvider: () => user.id
* });
* ```
*/
constructor(config) {
const mergedConfig = {
name: "sendbeacon",
batchSize: 50,
flushInterval: 5e3,
maxPayloadSize: 64 * 1024,
// 64KB
enableOfflineStorage: true,
storageKeyPrefix: "lever_ui_logger_",
maxRetries: 3,
retryDelay: 1e3,
headers: {},
enableCompression: false,
sessionIdGenerator: () => _SendBeaconTransport.generateSessionId(),
rateLimitPerMinute: 1e3,
enableLifecycleHandling: true,
enableSecureTokenHandling: true,
...config
};
super(mergedConfig.name, mergedConfig);
this.eventQueue = [];
this.rateLimitCounter = 0;
this.rateLimitResetTime = 0;
this.isOnline = true;
this.retryQueue = [];
this.lifecycleHandlersAttached = false;
this.transportConfig = mergedConfig;
this.secureTokenHandler = new SecureTokenHandler({
enableSecureMode: mergedConfig.enableSecureTokenHandling,
tokenTtl: 36e5,
// 1 hour
validateToken: true
});
if (config.authToken) {
if (typeof config.authToken === "string") {
this.secureTokenHandler.setToken(config.authToken);
} else {
this.secureTokenHandler.setTokenProvider(config.authToken);
}
}
this.userIdProvider = config.userIdProvider;
this.sessionId = this.transportConfig.sessionIdGenerator();
if (Environment.isBrowser) {
this.setupOnlineStatusMonitoring();
this.loadOfflineEvents();
if (this.transportConfig.enableLifecycleHandling) {
this.setupLifecycleHandlers();
}
}
this.startFlushTimer();
}
/**
* Write a log event to the transport
*
* Adds the event to the batching queue and triggers immediate flush
* if batch size or payload size limits are reached. Events are
* subject to rate limiting.
*
* @param event - The log event to write
*
* @example
* ```typescript
* transport.write({
* level: 'info',
* message: 'User logged in',
* timestamp: Date.now(),
* component: 'auth',
* context: { userId: '123' },
* args: []
* });
* ```
*/
write(event) {
if (!event) {
throw new TypeError("LogEventData is required");
}
if (!event.message || typeof event.message !== "string") {
throw new TypeError("LogEventData must have a valid message");
}
if (!this.checkRateLimit()) {
console.warn("SendBeacon transport: Rate limit exceeded, dropping event");
return;
}
const eventSize = this.estimateEventSize(event);
const queuedEvent = {
event,
timestamp: Date.now(),
retryCount: 0,
size: eventSize
};
this.eventQueue.push(queuedEvent);
if (this.shouldFlushImmediately()) {
this.flush();
}
}
/**
* Flush all pending events immediately
*
* Sends all queued events and retry events in optimally-sized batches.
* Respects payload size limits and creates multiple batches if necessary.
* Automatically handles online/offline state and retry logic.
*
* @returns Promise that resolves when all events have been processed
*
* @example
* ```typescript
* // Manually flush before page unload
* window.addEventListener('beforeunload', async () => {
* await transport.flush();
* });
* ```
*/
async flush() {
if (this.eventQueue.length === 0 && this.retryQueue.length === 0) {
return;
}
this.clearFlushTimer();
const eventsToSend = [...this.eventQueue, ...this.retryQueue];
this.eventQueue = [];
this.retryQueue = [];
const batches = this.createBatches(eventsToSend);
for (const batch of batches) {
await this.sendBatch(batch);
}
this.startFlushTimer();
}
/**
* Close the transport and clean up resources
*
* Performs a final flush of all pending events, clears timers,
* removes event listeners, saves any remaining events to
* offline storage if enabled, and securely disposes of token handler.
*
* @returns Promise that resolves when cleanup is complete
*
* @example
* ```typescript
* // Clean shutdown
* await transport.close();
* ```
*/
async close() {
await this.flush();
this.clearFlushTimer();
if (this.lifecycleHandlersAttached) {
this.removeLifecycleHandlers();
}
if (this.transportConfig.enableOfflineStorage && this.eventQueue.length > 0) {
this.saveOfflineEvents(this.eventQueue);
}
this.secureTokenHandler.dispose();
}
/**
* Check if immediate flush is needed
*/
shouldFlushImmediately() {
if (this.eventQueue.length >= this.transportConfig.batchSize) {
return true;
}
const totalSize = this.eventQueue.reduce((sum, evt) => sum + evt.size, 0);
if (totalSize >= this.transportConfig.maxPayloadSize * 0.8) {
return true;
}
return false;
}
/**
* Create batches respecting size limits
*/
createBatches(events) {
const batches = [];
let currentBatch = [];
let currentSize = 0;
for (const event of events) {
if (currentBatch.length >= this.transportConfig.batchSize || currentSize + event.size > this.transportConfig.maxPayloadSize * 0.9) {
if (currentBatch.length > 0) {
batches.push(currentBatch);
currentBatch = [];
currentSize = 0;
}
}
currentBatch.push(event);
currentSize += event.size;
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
return batches;
}
/**
* Send a batch of events
*/
async sendBatch(batch) {
if (!this.isOnline) {
this.saveOfflineEvents(batch);
return;
}
const envelope = await this.createEnvelope(batch.map((b) => b.event));
const payload = JSON.stringify(envelope);
try {
const success = await this.sendPayload(payload);
if (!success) {
this.handleSendFailure(batch);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const sanitizedMessage = this.secureTokenHandler.sanitizeErrorMessage(errorMessage);
console.error("SendBeacon transport: Failed to send batch", sanitizedMessage);
this.handleSendFailure(batch);
}
}
/**
* Send payload using sendBeacon or fetch with keepalive
*
* Attempts to use navigator.sendBeacon first for optimal performance,
* then falls back to fetch with keepalive flag. Automatically handles
* payload size limits and browser compatibility.
*
* @param payload - JSON string payload to send
* @returns Promise resolving to true if send was successful
*
* @internal
*/
async sendPayload(payload) {
const headers = await this.buildHeaders();
if (Environment.isBrowser && typeof navigator !== "undefined" && navigator.sendBeacon && payload.length < this.transportConfig.maxPayloadSize) {
const blob = new Blob([payload], { type: "application/json" });
const success = navigator.sendBeacon(this.transportConfig.endpoint, blob);
if (success) {
return true;
}
}
try {
const response = await fetch(this.transportConfig.endpoint, {
method: "POST",
headers,
body: payload,
keepalive: true,
mode: "cors",
credentials: "same-origin"
});
return response.ok;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const sanitizedMessage = this.secureTokenHandler.sanitizeErrorMessage(errorMessage);
console.error("SendBeacon transport: Fetch failed", sanitizedMessage);
return false;
}
}
/**
* Build request headers with secure token handling
*/
async buildHeaders() {
const headers = {
"Content-Type": "application/json",
...this.transportConfig.headers
};
try {
const token = await this.secureTokenHandler.getToken();
if (token) {
headers["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
}
} catch (error) {
console.error("SendBeacon transport: Failed to retrieve auth token:", error instanceof Error ? error.message : String(error));
}
return headers;
}
/**
* Create telemetry envelope with metadata and sanitized events
*
* Wraps log events in a telemetry envelope containing session context,
* user information, and environment metadata. Automatically sanitizes
* events to handle circular references and serialization issues.
*
* @param events - Array of log events to include in envelope
* @returns Promise resolving to complete telemetry envelope
*
* @internal
*/
async createEnvelope(events) {
const sanitizedEvents = events.map((event) => {
try {
return JSON.parse(JSON.stringify(event, this.getCircularReplacer()));
} catch {
return {
level: event.level,
message: event.message || "[Message could not be serialized]",
timestamp: event.timestamp,
component: event.component,
context: "[Context could not be serialized]",
args: []
};
}
});
const envelope = {
sessionId: this.sessionId,
userId: this.userIdProvider?.(),
userAgent: Environment.isBrowser && typeof navigator !== "undefined" ? navigator.userAgent : "node",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timestamp: Date.now(),
events: sanitizedEvents,
eventCount: sanitizedEvents.length,
sizeBytes: this.estimateEventSize(sanitizedEvents)
};
return envelope;
}
/**
* Handle send failure with retry logic
*/
handleSendFailure(batch) {
for (const event of batch) {
event.retryCount++;
if (event.retryCount < this.transportConfig.maxRetries) {
setTimeout(() => {
this.retryQueue.push(event);
}, this.transportConfig.retryDelay * Math.pow(2, event.retryCount));
} else {
if (this.transportConfig.enableOfflineStorage) {
this.saveOfflineEvents([event]);
} else {
console.error("SendBeacon transport: Event dropped after max retries", this.sanitizeEventForLogging(event.event));
}
}
}
}
/**
* Setup online/offline status monitoring
*/
setupOnlineStatusMonitoring() {
this.isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
if (typeof window !== "undefined") {
window.addEventListener("online", () => {
this.isOnline = true;
this.loadOfflineEvents();
this.flush();
});
window.addEventListener("offline", () => {
this.isOnline = false;
});
}
}
/**
* Setup page lifecycle event handlers
*/
setupLifecycleHandlers() {
if (this.lifecycleHandlersAttached) return;
const flushHandler = () => {
this.flush();
};
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
flushHandler();
}
});
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", flushHandler);
window.addEventListener("pagehide", flushHandler);
}
this.lifecycleHandlersAttached = true;
}
/**
* Remove lifecycle event handlers
*/
removeLifecycleHandlers() {
this.lifecycleHandlersAttached = false;
}
/**
* Save events to offline storage
*/
saveOfflineEvents(events) {
if (!this.transportConfig.enableOfflineStorage || !Environment.isBrowser) {
return;
}
try {
const key = `${this.transportConfig.storageKeyPrefix}offline_events`;
const existing = this.getOfflineEvents();
const combined = [...existing, ...events];
const limited = combined.slice(-1e3);
localStorage.setItem(key, JSON.stringify(limited));
} catch (error) {
console.error("SendBeacon transport: Failed to save offline events", error);
}
}
/**
* Load events from offline storage
*/
loadOfflineEvents() {
if (!this.transportConfig.enableOfflineStorage || !Environment.isBrowser) {
return;
}
const events = this.getOfflineEvents();
if (events.length > 0) {
this.retryQueue.push(...events);
this.clearOfflineEvents();
}
}
/**
* Get events from offline storage
*/
getOfflineEvents() {
if (!Environment.isBrowser) return [];
try {
const key = `${this.transportConfig.storageKeyPrefix}offline_events`;
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("SendBeacon transport: Failed to load offline events", error);
return [];
}
}
/**
* Clear offline storage
*/
clearOfflineEvents() {
if (!Environment.isBrowser) return;
try {
const key = `${this.transportConfig.storageKeyPrefix}offline_events`;
localStorage.removeItem(key);
} catch (error) {
console.error("SendBeacon transport: Failed to clear offline events", error);
}
}
/**
* Check rate limit
*/
checkRateLimit() {
const now = Date.now();
const minute = 60 * 1e3;
if (now > this.rateLimitResetTime) {
this.rateLimitCounter = 0;
this.rateLimitResetTime = now + minute;
}
this.rateLimitCounter++;
return this.rateLimitCounter <= this.transportConfig.rateLimitPerMinute;
}
/**
* Start flush timer
*/
startFlushTimer() {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = void 0;
this.flush();
}, this.transportConfig.flushInterval);
}
/**
* Clear flush timer
*/
clearFlushTimer() {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = void 0;
}
}
/**
* Estimate size of event(s) in bytes
*/
estimateEventSize(event) {
try {
const json = JSON.stringify(event);
return new Blob([json]).size;
} catch {
try {
return JSON.stringify(event, this.getCircularReplacer()).length * 2;
} catch {
return 1e3;
}
}
}
/**
* Get a secure replacer function for handling circular references and sensitive data
*/
getCircularReplacer() {
return this.secureTokenHandler.createSecureReplacer();
}
/**
* Sanitize event data for safe logging
*
* @param event - Event data to sanitize
* @returns Sanitized event data safe for logging
*/
sanitizeEventForLogging(event) {
try {
const sanitized = JSON.parse(JSON.stringify(event, this.getCircularReplacer()));
return {
level: sanitized.level,
message: sanitized.message,
component: sanitized.component,
timestamp: sanitized.timestamp,
context: sanitized.context || "[sanitized]",
argsCount: Array.isArray(sanitized.args) ? sanitized.args.length : 0
};
} catch {
return {
level: event.level || "unknown",
message: typeof event.message === "string" ? event.message : "[non-string message]",
component: event.component || "unknown",
timestamp: event.timestamp || Date.now(),
context: "[sanitization failed]",
argsCount: Array.isArray(event.args) ? event.args.length : 0
};
}
}
/**
* Generate a unique session ID
*/
static generateSessionId() {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
};
function createSendBeaconTransport(config) {
return new SendBeaconTransport(config);
}
// src/logger/events.ts
var LoggerBaseEvent = class {
/**
* Creates a new logger base event.
*
* @param timestamp - Unix timestamp when event was created (defaults to Date.now())
* @param clientId - Unique client identifier for this browser session (auto-generated)
*/
constructor(timestamp = Date.now(), clientId = generateClientId()) {
this.timestamp = timestamp;
this.clientId = clientId;
}
};
var LogEvent = class extends Log