/** * Enhanced Pino-based logger with Loki integration for Stock Bot platform * * Features: * - High performance JSON logging with Pino * - Multiple log levels (debug, info, warn, error, http, verbose, silly) * - Console and file logging with pino-pretty formatting * - Loki integration for centralized logging * - Structured logging with metadata * - Flexible message handling (string or object) * - Service-specific context * - TypeScript-friendly interfaces */ import pino from 'pino'; import { loggingConfig, lokiConfig } from '@stock-bot/config'; import type { LogLevel, LogContext, LogMetadata } from './types'; // Global logger instances cache const loggerInstances = new Map(); /** * Create transport configuration for Pino based on options */ function createTransports(serviceName: string, options?: { enableConsole?: boolean; enableFile?: boolean; enableLoki?: boolean; }): any { const { enableConsole = loggingConfig.LOG_CONSOLE, enableFile = loggingConfig.LOG_FILE, enableLoki = true } = options || {}; const targets: any[] = []; // Console transport with pretty formatting if (enableConsole) { targets.push({ target: 'pino-pretty', level: loggingConfig.LOG_LEVEL, options: { colorize: true, translateTime: 'yyyy-mm-dd HH:MM:ss.l', ignore: 'pid,hostname', messageFormat: '[{service}] {msg}' } }); } // File transport for general logs if (enableFile) { targets.push({ target: 'pino/file', level: loggingConfig.LOG_LEVEL, options: { destination: `${loggingConfig.LOG_FILE_PATH}/${serviceName}.log`, mkdir: true } }); // Separate error file if enabled if (loggingConfig.LOG_ERROR_FILE) { targets.push({ target: 'pino/file', level: 'error', options: { destination: `${loggingConfig.LOG_FILE_PATH}/${serviceName}-error.log`, mkdir: true } }); } } // Loki transport for centralized logging if (enableLoki && lokiConfig.LOKI_HOST) { targets.push({ target: 'pino-loki', level: loggingConfig.LOG_LEVEL, options: { batching: true, interval: lokiConfig.LOKI_BATCH_WAIT, host: lokiConfig.LOKI_URL || `http://${lokiConfig.LOKI_HOST}:${lokiConfig.LOKI_PORT}`, basicAuth: lokiConfig.LOKI_USERNAME && lokiConfig.LOKI_PASSWORD ? { username: lokiConfig.LOKI_USERNAME, password: lokiConfig.LOKI_PASSWORD } : undefined, labels: { service: serviceName, environment: lokiConfig.LOKI_ENVIRONMENT_LABEL, ...(lokiConfig.LOKI_DEFAULT_LABELS ? JSON.parse(lokiConfig.LOKI_DEFAULT_LABELS) : {}) }, timeout: lokiConfig.LOKI_PUSH_TIMEOUT || 10000, silenceErrors: false, // Better JSON handling replaceTimestamp: false, } }); } return { targets }; } /** * Create or retrieve a logger instance for a specific service */ export function createLogger(serviceName: string, options?: { level?: LogLevel; enableLoki?: boolean; enableFile?: boolean; enableConsole?: boolean; }): pino.Logger { const key = `${serviceName}-${JSON.stringify(options || {})}`; if (loggerInstances.has(key)) { return loggerInstances.get(key)!; } const logger = buildLogger(serviceName, options); loggerInstances.set(key, logger); return logger; } /** * Build a Pino logger with all configured transports */ function buildLogger(serviceName: string, options?: { level?: LogLevel; enableLoki?: boolean; enableFile?: boolean; enableConsole?: boolean; }): pino.Logger { const { level = loggingConfig.LOG_LEVEL as LogLevel, enableLoki = true, enableFile = loggingConfig.LOG_FILE, enableConsole = loggingConfig.LOG_CONSOLE } = options || {}; const transport = createTransports(serviceName, { enableConsole, enableFile, enableLoki }); const loggerConfig: pino.LoggerOptions = { level: level, timestamp: () => `,"timestamp":"${new Date().toISOString()}"`, base: { service: serviceName, environment: loggingConfig.LOG_ENVIRONMENT, version: loggingConfig.LOG_SERVICE_VERSION } }; // Only add transport if targets exist to avoid worker thread issues if (transport && transport.targets && transport.targets.length > 0) { loggerConfig.transport = transport; } return pino(loggerConfig); } /** * Enhanced Logger class with convenience methods and flexible message handling */ export class Logger { private pino: pino.Logger; private serviceName: string; private context: LogContext; constructor(serviceName: string, context: LogContext = {}, options?: { level?: LogLevel; enableLoki?: boolean; enableFile?: boolean; enableConsole?: boolean; }) { this.serviceName = serviceName; this.context = context; this.pino = createLogger(serviceName, options); } /** * Flexible log method that accepts string or object messages */ log(level: LogLevel, message: string | object, metadata?: LogMetadata): void { const logData = { ...this.context, ...metadata, timestamp: new Date().toISOString() }; if (typeof message === 'string') { (this.pino as any)[level](logData, message); } else { (this.pino as any)[level]({ ...logData, ...message }); } } /** * Debug level logging */ debug(message: string | object, metadata?: LogMetadata): void { this.log('debug', message, metadata); } /** * Verbose level logging */ verbose(message: string | object, metadata?: LogMetadata): void { // Map verbose to debug level since Pino doesn't have verbose by default this.log('debug', message, { ...metadata, originalLevel: 'verbose' }); } /** * Silly level logging */ silly(message: string | object, metadata?: LogMetadata): void { // Map silly to debug level since Pino doesn't have silly by default this.log('debug', message, { ...metadata, originalLevel: 'silly' }); } /** * Info level logging */ info(message: string | object, metadata?: LogMetadata): void { this.log('info', message, metadata); } /** * Warning level logging */ warn(message: string | object, metadata?: LogMetadata): void { this.log('warn', message, metadata); } /** * Error level logging */ error(message: string | object, error?: Error | any, metadata?: LogMetadata): void { const logData: LogMetadata = { ...metadata }; if (error) { if (error instanceof Error) { logData.error = { name: error.name, message: error.message, stack: loggingConfig.LOG_ERROR_STACK ? error.stack : undefined }; } else { logData.error = error; } } this.log('error', message, logData); } /** * HTTP request logging */ http(message: string | object, requestData?: { method?: string; url?: string; statusCode?: number; responseTime?: number; userAgent?: string; ip?: string; }): void { if (!loggingConfig.LOG_HTTP_REQUESTS) return; this.log('info', message, { request: requestData, type: 'http_request' }); } /** * Performance/timing logging */ performance(message: string | object, timing: { operation: string; duration: number; startTime?: number; endTime?: number; }): void { if (!loggingConfig.LOG_PERFORMANCE) return; this.log('info', message, { performance: timing, type: 'performance' }); } /** * Business event logging */ business(message: string | object, event: { type: string; entity?: string; action?: string; result?: 'success' | 'failure' | 'partial'; amount?: number; symbol?: string; }): void { this.log('info', message, { business: event, type: 'business_event' }); } /** * Security event logging */ security(message: string | object, event: { type: 'authentication' | 'authorization' | 'access' | 'vulnerability'; user?: string; resource?: string; action?: string; result?: 'success' | 'failure'; ip?: string; severity?: 'low' | 'medium' | 'high' | 'critical'; }): void { this.log('warn', message, { security: event, type: 'security_event' }); } /** * Create a child logger with additional context */ child(context: LogContext): Logger { return new Logger(this.serviceName, { ...this.context, ...context }); } /** * Add persistent context to this logger */ addContext(context: LogContext): void { this.context = { ...this.context, ...context }; } /** * Get the underlying Pino logger */ getPinoLogger(): pino.Logger { return this.pino; } /** * Gracefully close all transports */ async close(): Promise { // Pino doesn't require explicit closing like Winston // But we can flush any pending logs this.pino.flush(); } } /** * Create a default logger instance for a service */ export function getLogger(serviceName: string, context?: LogContext): Logger { return new Logger(serviceName, context); } /** * Shutdown all logger instances gracefully */ export async function shutdownLoggers(): Promise { // Flush all logger instances const flushPromises = Array.from(loggerInstances.values()).map(logger => { logger.flush(); return Promise.resolve(); }); await Promise.all(flushPromises); loggerInstances.clear(); } // Export types for convenience export type { LogLevel, LogContext, LogMetadata } from './types';