UNPKG

weelog

Version:

Next-generation JavaScript logging library with performance tracking, memory monitoring, analytics, and advanced debugging features.

730 lines (639 loc) 20.9 kB
/** * WeeLog - Tiny Logging Library for JavaScript * Zero dependencies, browser and Node.js compatible */ export interface LoggerOptions { level?: LogLevel; enabled?: boolean; useTimestamp?: boolean; useHumanReadableTimestamp?: boolean; enablePerformanceTracking?: boolean; enableMemoryTracking?: boolean; logMemoryInline?: boolean; maxLogHistory?: number; enableLogAnalytics?: boolean; // Environment-aware configuration autoDetectEnvironment?: boolean; environment?: 'development' | 'production' | 'staging' | 'test'; developmentConfig?: Partial<LoggerOptions>; productionConfig?: Partial<LoggerOptions>; stagingConfig?: Partial<LoggerOptions>; testConfig?: Partial<LoggerOptions>; } export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogInterceptor = (level: LogLevel, message: string, context?: string, data?: any) => void; export interface LogEntry { level: LogLevel; message: string; context?: string; data?: any; timestamp: Date; formatted: string; performance?: PerformanceMetrics; memory?: MemoryInfo; stackTrace?: string; sessionId?: string; } export interface PerformanceMetrics { duration?: number; timestamp: number; memoryUsage?: number; } export interface MemoryInfo { used: number; total: number; percentage: number; } export interface LogAnalytics { totalLogs: number; logsByLevel: Record<LogLevel, number>; averageLogRate: number; errorRate: number; topContexts: Array<{ context: string; count: number }>; } export class Logger { private level: LogLevel; private enabled: boolean; private useTimestamp: boolean; private useHumanReadableTimestamp: boolean; private context?: string; private interceptors: LogInterceptor[]; private enablePerformanceTracking: boolean; private enableMemoryTracking: boolean; private logMemoryInline: boolean; private maxLogHistory: number; private enableLogAnalytics: boolean; private logHistory: LogEntry[]; private sessionId: string; private performanceMarks: Map<string, number>; private analytics: LogAnalytics; private detectedEnvironment: 'development' | 'production' | 'staging' | 'test'; private readonly levels: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }; private readonly colors: Record<LogLevel, string> = { debug: '#6b7280', info: '#2563eb', warn: '#f59e0b', error: '#ef4444' }; constructor(options: LoggerOptions = {}) { // Detect environment first this.detectedEnvironment = this.detectEnvironment(options); // Apply environment-specific configuration const finalOptions = this.applyEnvironmentConfig(options); this.level = finalOptions.level || 'info'; this.enabled = finalOptions.enabled !== false; this.useTimestamp = finalOptions.useTimestamp || false; this.useHumanReadableTimestamp = finalOptions.useHumanReadableTimestamp || false; this.enablePerformanceTracking = finalOptions.enablePerformanceTracking || false; this.enableMemoryTracking = finalOptions.enableMemoryTracking || false; this.logMemoryInline = finalOptions.logMemoryInline || false; this.maxLogHistory = finalOptions.maxLogHistory || 1000; this.enableLogAnalytics = finalOptions.enableLogAnalytics || false; this.interceptors = []; this.logHistory = []; this.sessionId = this.generateSessionId(); this.performanceMarks = new Map(); this.analytics = { totalLogs: 0, logsByLevel: { debug: 0, info: 0, warn: 0, error: 0 }, averageLogRate: 0, errorRate: 0, topContexts: [] }; } /** * Detect the current environment */ private detectEnvironment(options: LoggerOptions): 'development' | 'production' | 'staging' | 'test' { // If explicitly set, use that if (options.environment) { return options.environment; } // If auto-detection is disabled, default to development if (options.autoDetectEnvironment === false) { return 'development'; } // Try to detect from WEELOG_ENV first (higher priority) if (typeof process !== 'undefined' && process.env && process.env.WEELOG_ENV) { const weelogEnv = process.env.WEELOG_ENV.toLowerCase(); if (weelogEnv === 'production' || weelogEnv === 'prod') return 'production'; if (weelogEnv === 'staging' || weelogEnv === 'stage') return 'staging'; if (weelogEnv === 'test' || weelogEnv === 'testing') return 'test'; if (weelogEnv === 'development' || weelogEnv === 'dev') return 'development'; } // Try to detect from NODE_ENV (Node.js) if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV) { const nodeEnv = process.env.NODE_ENV.toLowerCase(); if (nodeEnv === 'production' || nodeEnv === 'prod') return 'production'; if (nodeEnv === 'staging' || nodeEnv === 'stage') return 'staging'; if (nodeEnv === 'test' || nodeEnv === 'testing') return 'test'; if (nodeEnv === 'development' || nodeEnv === 'dev') return 'development'; } // Browser environment detection if (typeof window !== 'undefined') { // Check for common development indicators if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname.includes('dev') || window.location.hostname.includes('test') || window.location.port !== '') { return 'development'; } // Check for staging indicators if (window.location.hostname.includes('staging') || window.location.hostname.includes('stage')) { return 'staging'; } // Otherwise assume production in browser return 'production'; } // Default to development if nothing else detected return 'development'; } /** * Apply environment-specific configuration */ private applyEnvironmentConfig(options: LoggerOptions): LoggerOptions { const env = this.detectedEnvironment; // Get environment-specific config let envConfig: Partial<LoggerOptions> = {}; switch (env) { case 'development': envConfig = options.developmentConfig || { level: 'debug', useTimestamp: true, enablePerformanceTracking: true, enableMemoryTracking: true, enableLogAnalytics: true }; break; case 'production': envConfig = options.productionConfig || { level: 'warn', useTimestamp: true, enablePerformanceTracking: false, enableMemoryTracking: false, logMemoryInline: false, enableLogAnalytics: false }; break; case 'staging': envConfig = options.stagingConfig || { level: 'info', useTimestamp: true, enablePerformanceTracking: true, enableMemoryTracking: true, enableLogAnalytics: true }; break; case 'test': envConfig = options.testConfig || { level: 'error', enabled: false, useTimestamp: false, enablePerformanceTracking: false, enableMemoryTracking: false, enableLogAnalytics: false }; break; } // Merge configurations with explicit options taking precedence const finalConfig = { ...envConfig }; // Only override environment config if explicitly set in options Object.keys(options).forEach(key => { if (key !== 'autoDetectEnvironment' && key !== 'environment' && key !== 'developmentConfig' && key !== 'productionConfig' && key !== 'stagingConfig' && key !== 'testConfig' && options[key as keyof LoggerOptions] !== undefined) { (finalConfig as any)[key] = options[key as keyof LoggerOptions]; } }); return finalConfig; } /** * Get the detected environment */ getEnvironment(): 'development' | 'production' | 'staging' | 'test' { return this.detectedEnvironment; } /** * Set the minimum log level */ setLevel(level: LogLevel): Logger { this.level = level; return this; } /** * Enable or disable logging */ enable(enabled: boolean): Logger { this.enabled = enabled; return this; } /** * Create a logger with a specific context */ withContext(context: string): Logger { const newLogger = Object.create(this); newLogger.context = context; return newLogger; } /** * Add a log interceptor callback */ onLog(callback: LogInterceptor): Logger { this.interceptors.push(callback); return this; } /** * Generate a unique session ID */ private generateSessionId(): string { return 'session_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now(); } /** * Get memory usage information */ private getMemoryInfo(): MemoryInfo | undefined { if (!this.enableMemoryTracking) return undefined; // Browser environment if (typeof window !== 'undefined' && (performance as any).memory) { const memory = (performance as any).memory; return { used: memory.usedJSHeapSize, total: memory.totalJSHeapSize, percentage: Math.round((memory.usedJSHeapSize / memory.totalJSHeapSize) * 100) }; } // Node.js environment if (typeof process !== 'undefined' && process.memoryUsage) { const memory = process.memoryUsage(); return { used: memory.heapUsed, total: memory.heapTotal, percentage: Math.round((memory.heapUsed / memory.heapTotal) * 100) }; } return undefined; } /** * Format memory usage for inline display */ private formatMemoryUsage(): string { if (!this.logMemoryInline) { return ''; } const memoryInfo = this.getMemoryInfo(); if (!memoryInfo) { return ''; } const memoryMB = (memoryInfo.used / 1024 / 1024).toFixed(2); return ` (Memory: ${memoryMB} MB)`; } /** * Start performance tracking for a specific operation */ startPerformanceTimer(label: string): Logger { if (this.enablePerformanceTracking) { this.performanceMarks.set(label, Date.now()); } return this; } /** * End performance tracking and log the duration */ endPerformanceTimer(label: string, message?: string): LogEntry | null { if (this.enablePerformanceTracking && this.performanceMarks.has(label)) { const startTime = this.performanceMarks.get(label)!; const duration = Date.now() - startTime; this.performanceMarks.delete(label); const perfMessage = message || `Performance: ${label} completed`; return this.info(perfMessage, { performanceTimer: label, duration: `${duration}ms`, timestamp: Date.now() }); } return null; } /** * Log with automatic stack trace capture */ trace(message: string, data?: any): LogEntry | null { const stackTrace = new Error().stack; return this.log('debug', message, data, stackTrace); } /** * Get current analytics data */ getAnalytics(): LogAnalytics { return { ...this.analytics }; } /** * Get log history */ getLogHistory(): LogEntry[] { return [...this.logHistory]; } /** * Clear log history */ clearHistory(): Logger { this.logHistory = []; return this; } /** * Export logs as JSON */ exportLogs(): string { return JSON.stringify({ sessionId: this.sessionId, exportedAt: new Date().toISOString(), analytics: this.analytics, logs: this.logHistory }, null, 2); } /** * Search logs by criteria */ searchLogs(criteria: { level?: LogLevel; context?: string; message?: string; timeRange?: { start: Date; end: Date }; }): LogEntry[] { return this.logHistory.filter(entry => { if (criteria.level && entry.level !== criteria.level) return false; if (criteria.context && entry.context !== criteria.context) return false; if (criteria.message && !entry.message.includes(criteria.message)) return false; if (criteria.timeRange) { if (entry.timestamp < criteria.timeRange.start || entry.timestamp > criteria.timeRange.end) { return false; } } return true; }); } /** * Check if a log level should be output */ private shouldLog(level: LogLevel): boolean { return this.enabled && this.levels[level] >= this.levels[this.level]; } /** * Format a log message */ private formatMessage(level: LogLevel, message: string, data?: any): string { let formatted = ''; if (this.useTimestamp) { const timestamp = new Date(); if (this.useHumanReadableTimestamp) { // Human readable format: "Dec 16, 2024 at 9:45:23 PM" formatted += `[${timestamp.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true })}] `; } else { formatted += `[${timestamp.toISOString()}] `; } } formatted += `[${level.toUpperCase()}]`; if (this.context) { formatted += ` [${this.context}]`; } formatted += ` ${message}`; // Add inline memory usage if enabled if (this.logMemoryInline) { formatted += this.formatMemoryUsage(); } if (data !== undefined && data !== null) { if (typeof data === 'object') { try { // Process the data to apply human readable timestamps if enabled const processedData = this.processDataTimestamps(data); formatted += ` ${JSON.stringify(processedData)}`; } catch (e) { formatted += ` [Object (circular)]`; } } else { formatted += ` ${data}`; } } return formatted; } /** * Process data object to apply human readable timestamps */ private processDataTimestamps(data: any): any { if (!this.useHumanReadableTimestamp) { return data; } if (data === null || data === undefined) { return data; } if (typeof data !== 'object') { return data; } if (Array.isArray(data)) { return data.map(item => this.processDataTimestamps(item)); } const processed: any = {}; for (const [key, value] of Object.entries(data)) { if (key === 'timestamp' && (typeof value === 'number' || value instanceof Date)) { // Convert timestamp to human readable format const date = typeof value === 'number' ? new Date(value) : value; processed[key] = date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); } else if (typeof value === 'object') { processed[key] = this.processDataTimestamps(value); } else { processed[key] = value; } } return processed; } /** * Internal log method */ private log(level: LogLevel, message: string, data?: any, stackTrace?: string): LogEntry | null { if (!this.shouldLog(level)) { return null; } const timestamp = new Date(); const formatted = this.formatMessage(level, message, data); const logEntry: LogEntry = { level, message, context: this.context, data, timestamp, formatted, sessionId: this.sessionId, stackTrace: stackTrace }; // Add performance and memory tracking if (this.enablePerformanceTracking) { logEntry.performance = { timestamp: Date.now(), memoryUsage: this.enableMemoryTracking ? this.getMemoryInfo()?.used : undefined }; } if (this.enableMemoryTracking) { logEntry.memory = this.getMemoryInfo(); } // Update analytics if (this.enableLogAnalytics) { this.updateAnalytics(level, this.context); } // Add to history this.logHistory.push(logEntry); if (this.logHistory.length > this.maxLogHistory) { this.logHistory.shift(); } // Call interceptors this.interceptors.forEach(interceptor => { try { interceptor(level, message, this.context, data); } catch (e) { // Avoid infinite loops by using plain console.error if (typeof console !== 'undefined' && console.error) { console.error('Logger interceptor error:', e); } } }); // Output to console this.outputToConsole(level, formatted); return logEntry; } /** * Update analytics data */ private updateAnalytics(level: LogLevel, context?: string): void { this.analytics.totalLogs++; this.analytics.logsByLevel[level]++; if (level === 'error') { this.analytics.errorRate = (this.analytics.logsByLevel.error / this.analytics.totalLogs) * 100; } if (context) { const existingContext = this.analytics.topContexts.find(c => c.context === context); if (existingContext) { existingContext.count++; } else { this.analytics.topContexts.push({ context, count: 1 }); } // Keep only top 10 contexts this.analytics.topContexts.sort((a, b) => b.count - a.count); this.analytics.topContexts = this.analytics.topContexts.slice(0, 10); } } /** * Output formatted message to console with colors (browser only) */ private outputToConsole(level: LogLevel, formatted: string): void { if (typeof console === 'undefined') { return; } const color = this.colors[level]; // Detect browser environment more reliably const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; // Browser environment - use colored output with CSS styling if (isBrowser && console.log) { const weight = level === 'error' ? 'bold' : 'normal'; const styles = `color: ${color}; font-weight: ${weight}; font-family: monospace;`; try { console.log(`%c${formatted}`, styles); } catch (e) { // Fallback if styling fails console.log(formatted); } } // Node.js environment - use appropriate console method with ANSI colors else if (isNode) { const ansiColors: Record<LogLevel, string> = { debug: '\x1b[90m', // gray info: '\x1b[36m', // cyan warn: '\x1b[33m', // yellow error: '\x1b[31m' // red }; const reset = '\x1b[0m'; const coloredMessage = `${ansiColors[level]}${formatted}${reset}`; switch (level) { case 'debug': console.debug ? console.debug(coloredMessage) : console.log(coloredMessage); break; case 'info': console.info(coloredMessage); break; case 'warn': console.warn(coloredMessage); break; case 'error': console.error(coloredMessage); break; default: console.log(coloredMessage); } } // Fallback for other environments else { console.log(formatted); } } /** * Log a debug message */ debug(message: string, data?: any): LogEntry | null { return this.log('debug', message, data); } /** * Log an info message */ info(message: string, data?: any): LogEntry | null { return this.log('info', message, data); } /** * Log a warning message */ warn(message: string, data?: any): LogEntry | null { return this.log('warn', message, data); } /** * Log an error message */ error(message: string, data?: any): LogEntry | null { return this.log('error', message, data); } } // Create a default logger instance for convenience functions const defaultLogger = new Logger(); // Named exports for individual logging functions export const log = (message: string, data?: any) => defaultLogger.info(message, data); export const info = (message: string, data?: any) => defaultLogger.info(message, data); export const warn = (message: string, data?: any) => defaultLogger.warn(message, data); export const error = (message: string, data?: any) => defaultLogger.error(message, data); export const debug = (message: string, data?: any) => defaultLogger.debug(message, data); export const success = (message: string, data?: any) => defaultLogger.info(`✅ ${message}`, data); // Types are already exported inline above // Default export for easy importing export default Logger;