UNPKG

logs-interceptor

Version:

High-performance, production-ready log interceptor for Node.js applications with Loki integration

470 lines 18.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LogsInterceptor = void 0; const api_1 = require("@opentelemetry/api"); const axios_1 = __importDefault(require("axios")); const events_1 = require("events"); const perf_hooks_1 = require("perf_hooks"); const utils_1 = require("./utils"); function createDebugLogger(enabled) { const formatMessage = (level, message, context) => { const timestamp = new Date().toISOString(); const prefix = `[${timestamp}] [logs-interceptor:${level.toUpperCase()}]`; if (context && Object.keys(context).length > 0) { return `${prefix} ${message} ${(0, utils_1.safeStringify)(context)}`; } return `${prefix} ${message}`; }; const noop = () => { }; if (!enabled) { return { debug: noop, info: noop, warn: noop, error: noop }; } return { debug: (message, context) => { process.stderr.write(formatMessage('debug', message, context) + '\n'); }, info: (message, context) => { process.stderr.write(formatMessage('info', message, context) + '\n'); }, warn: (message, context) => { process.stderr.write(formatMessage('warn', message, context) + '\n'); }, error: (message, context) => { process.stderr.write(formatMessage('error', message, context) + '\n'); }, }; } class LogsInterceptor extends events_1.EventEmitter { constructor(config) { super(); this.buffer = []; this.flushTimer = null; this.isDestroyed = false; this.config = this.validateAndNormalizeConfig(config); this.debugLogger = createDebugLogger(this.config.debug); this.startTime = Date.now(); // Initialize metrics this.metrics = { logsProcessed: 0, logsDropped: 0, flushCount: 0, errorCount: 0, bufferSize: 0, avgFlushTime: 0, lastFlushTime: 0, }; // Store original console methods this.originalConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console), debug: console.debug.bind(console), }; this.setupHttpClient(); this.setupProcessHandlers(); if (this.config.interceptConsole) { this.interceptConsole(); } this.debugLogger.info('LogsInterceptor initialized', { appName: this.config.appName, environment: this.config.environment, bufferSize: this.config.buffer.maxSize, flushInterval: this.config.buffer.flushInterval, }); } validateAndNormalizeConfig(config) { if (!config.transport?.url) { throw new Error('Transport URL is required'); } if (!config.transport?.tenantId) { throw new Error('Tenant ID is required'); } if (!config.appName) { throw new Error('App name is required'); } return { transport: { url: config.transport.url, tenantId: config.transport.tenantId, authToken: config.transport.authToken ?? '', timeout: config.transport.timeout ?? 5000, maxRetries: config.transport.maxRetries ?? 3, retryDelay: config.transport.retryDelay ?? 1000, compression: config.transport.compression ?? true, }, appName: config.appName, version: config.version ?? '1.0.0', environment: config.environment ?? 'production', labels: config.labels ?? {}, dynamicLabels: { trace_id: () => { const span = api_1.trace.getSpan(api_1.context.active()); return span?.spanContext().traceId ?? 'undefined'; }, span_id: () => { const span = api_1.trace.getSpan(api_1.context.active()); return span?.spanContext().spanId ?? 'undefined'; }, ...(config.dynamicLabels ?? {}), }, buffer: { maxSize: config.buffer?.maxSize ?? 100, flushInterval: config.buffer?.flushInterval ?? 5000, maxAge: config.buffer?.maxAge ?? 30000, autoFlush: config.buffer?.autoFlush ?? true, }, filter: { levels: config.filter?.levels ?? ['debug', 'info', 'warn', 'error', 'fatal'], patterns: config.filter?.patterns ?? [], samplingRate: config.filter?.samplingRate ?? 1.0, maxMessageLength: config.filter?.maxMessageLength ?? 8192, }, enableMetrics: config.enableMetrics ?? true, enableHealthCheck: config.enableHealthCheck ?? true, interceptConsole: config.interceptConsole ?? false, preserveOriginalConsole: config.preserveOriginalConsole ?? true, debug: config.debug ?? false, silentErrors: config.silentErrors ?? false, }; } setupHttpClient() { const headers = { 'Content-Type': 'application/json', 'X-Scope-OrgID': this.config.transport.tenantId, }; if (this.config.transport.authToken) { headers['Authorization'] = `Bearer ${this.config.transport.authToken}`; } this.httpClient = axios_1.default.create({ baseURL: this.config.transport.url, timeout: this.config.transport.timeout, headers, }); // Add response interceptor for error handling this.httpClient.interceptors.response.use((response) => response, (error) => { this.metrics.errorCount++; this.emit('error', error); if (!this.config.silentErrors) { this.debugLogger.error('HTTP request failed', { error: error.message, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, }); } throw error; }); } setupProcessHandlers() { const gracefulShutdown = () => { this.debugLogger.info('Graceful shutdown initiated'); this.flush().finally(() => { process.exit(0); }); }; process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown); process.on('exit', () => { this.flushSync(); }); process.on('uncaughtException', (error) => { this.error('Uncaught exception', { error: error.message, stack: error.stack }); this.flushSync(); process.exit(1); }); process.on('unhandledRejection', (reason) => { this.error('Unhandled rejection', { reason: (0, utils_1.safeStringify)(reason) }); this.flushSync(); }); } interceptConsole() { const methodMap = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug', }; ['log', 'info', 'warn', 'error', 'debug'].forEach((method) => { const original = this.originalConsole[method]; console[method] = (...args) => { const message = args .map((arg) => (typeof arg === 'string' ? arg : (0, utils_1.safeStringify)(arg))) .join(' '); this.log(methodMap[method] || 'info', message); if (this.config.preserveOriginalConsole) { original(...args); } }; }); } restoreConsole() { console.log = this.originalConsole.log; console.info = this.originalConsole.info; console.warn = this.originalConsole.warn; console.error = this.originalConsole.error; console.debug = this.originalConsole.debug; } shouldLog(level, message) { // Check if level is enabled if (!this.config.filter.levels.includes(level)) { return false; } // Check message patterns if (this.config.filter.patterns.length > 0) { const shouldInclude = this.config.filter.patterns.some(pattern => pattern.test(message)); if (!shouldInclude) { return false; } } // Apply sampling if (!(0, utils_1.shouldSample)(this.config.filter.samplingRate)) { this.metrics.logsDropped++; return false; } return true; } createLogEntry(level, message, context) { // Truncate message if too long const truncatedMessage = message.length > this.config.filter.maxMessageLength ? message.substring(0, this.config.filter.maxMessageLength) + '...[truncated]' : message; // Compute dynamic labels const dynamicLabels = Object.entries(this.config.dynamicLabels).reduce((acc, [key, fn]) => { try { acc[key] = String(fn()); } catch (error) { acc[key] = 'error'; this.debugLogger.warn(`Failed to compute dynamic label ${key}`, { error }); } return acc; }, {}); return { timestamp: new Date().toISOString(), level, message: truncatedMessage, context, traceId: dynamicLabels.trace_id, spanId: dynamicLabels.span_id, labels: { app: this.config.appName, version: this.config.version, environment: this.config.environment, level, ...this.config.labels, ...dynamicLabels, }, }; } scheduleFlush() { if (this.flushTimer || !this.config.buffer.autoFlush) return; this.flushTimer = setTimeout(() => { this.flush().catch((error) => { this.debugLogger.error('Scheduled flush failed', { error }); }); }, this.config.buffer.flushInterval); } async retryOperation(operation, maxRetries = this.config.transport.maxRetries, delay = this.config.transport.retryDelay) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error; if (attempt === maxRetries) { throw lastError; } this.debugLogger.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`, { error: lastError.message, }); await new Promise(resolve => setTimeout(resolve, delay * attempt)); } } throw lastError; } formatForLoki(entries) { const streamMap = new Map(); entries.forEach((entry) => { const streamKey = JSON.stringify(entry.labels); const timestamp = String(Date.parse(entry.timestamp) * 1000000); // nanoseconds // Format log line as plain string, not JSON let logLine = `[${entry.level.toUpperCase()}] ${entry.message}`; if (entry.context && Object.keys(entry.context).length > 0) { logLine += ` ${(0, utils_1.safeStringify)(entry.context)}`; } if (entry.traceId && entry.traceId !== 'undefined') { logLine += ` traceId=${entry.traceId}`; } if (entry.spanId && entry.spanId !== 'undefined') { logLine += ` spanId=${entry.spanId}`; } if (!streamMap.has(streamKey)) { streamMap.set(streamKey, []); } streamMap.get(streamKey).push([timestamp, logLine]); }); return { streams: Array.from(streamMap.entries()).map(([streamKey, values]) => ({ stream: JSON.parse(streamKey), values: values.sort((a, b) => a[0].localeCompare(b[0])), // Sort by timestamp })), }; } flushSync() { if (this.buffer.length === 0) return; try { // This is a synchronous flush for process exit scenarios // In a real implementation, you might want to use a synchronous HTTP library // or implement a proper shutdown sequence this.debugLogger.warn('Synchronous flush attempted - some logs may be lost'); } catch (error) { this.debugLogger.error('Synchronous flush failed', { error }); } } // Public API methods log(level, message, context) { if (this.isDestroyed || !this.shouldLog(level, message)) { return; } const entry = this.createLogEntry(level, message, context); this.buffer.push(entry); this.metrics.logsProcessed++; this.metrics.bufferSize = this.buffer.length; this.emit('log', entry); // Auto-flush if buffer is full if (this.buffer.length >= this.config.buffer.maxSize) { this.flush().catch((error) => { this.debugLogger.error('Auto-flush failed', { error }); }); } else { this.scheduleFlush(); } } debug(message, context) { this.log('debug', message, context); } info(message, context) { this.log('info', message, context); } warn(message, context) { this.log('warn', message, context); } error(message, context) { this.log('error', message, context); } fatal(message, context) { this.log('fatal', message, context); // Force immediate flush for fatal errors this.flush().catch(() => { // Silent fail for fatal errors }); } trackEvent(eventName, properties) { this.info(`[EVENT] ${eventName}`, properties); } async flush() { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } if (this.buffer.length === 0) { return; } const entries = this.buffer.splice(0); const startTime = perf_hooks_1.performance.now(); try { const payload = this.formatForLoki(entries); // Debug payload if (this.config.debug) { this.debugLogger.debug('Sending payload to Loki', { url: this.config.transport.url, streamCount: payload.streams.length, totalLogs: payload.streams.reduce((sum, s) => sum + s.values.length, 0), firstStream: payload.streams[0] ? { labels: payload.streams[0].stream, valueCount: payload.streams[0].values.length, firstValue: payload.streams[0].values[0] } : 'no streams' }); } await this.retryOperation(async () => { let data = JSON.stringify(payload); const headers = {}; // Add gzip compression if enabled if (this.config.transport.compression) { const zlib = require('zlib'); data = zlib.gzipSync(data); headers['Content-Encoding'] = 'gzip'; } await this.httpClient.post('', data, { headers }); }); const flushTime = perf_hooks_1.performance.now() - startTime; this.metrics.flushCount++; this.metrics.lastFlushTime = Date.now(); this.metrics.avgFlushTime = (this.metrics.avgFlushTime * (this.metrics.flushCount - 1) + flushTime) / this.metrics.flushCount; this.metrics.bufferSize = this.buffer.length; this.emit('flush', { count: entries.length, duration: flushTime }); this.debugLogger.debug(`Flushed ${entries.length} logs in ${flushTime.toFixed(2)}ms`); } catch (error) { // Re-add entries to buffer on failure this.buffer.unshift(...entries); this.metrics.errorCount++; this.emit('error', error); throw error; } } getMetrics() { return { ...this.metrics, bufferSize: this.buffer.length }; } getHealth() { const now = Date.now(); const timeSinceLastFlush = now - this.metrics.lastFlushTime; const bufferUtilization = this.buffer.length / this.config.buffer.maxSize; return { healthy: this.metrics.errorCount < 10 && timeSinceLastFlush < this.config.buffer.flushInterval * 5 && bufferUtilization < 0.9, lastSuccessfulFlush: this.metrics.lastFlushTime, consecutiveErrors: this.metrics.errorCount, bufferUtilization, uptime: now - this.startTime, }; } async destroy() { if (this.isDestroyed) return; this.debugLogger.info('Destroying LogsInterceptor'); this.isDestroyed = true; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } if (this.config.interceptConsole) { this.restoreConsole(); } // Final flush try { await this.flush(); } catch (error) { this.debugLogger.error('Final flush failed during destroy', { error }); } this.removeAllListeners(); this.debugLogger.info('LogsInterceptor destroyed'); } } exports.LogsInterceptor = LogsInterceptor; //# sourceMappingURL=logger.js.map