import winston from 'winston'; import path from 'path'; import os from 'os'; const logDir = process.env.LOG_FILE_PATH || './logs'; const isProduction = process.env.NODE_ENV === 'production'; // ============ SENSITIVE DATA PATTERNS ============ const SENSITIVE_KEYS = [ 'password', 'secret', 'token', 'key', 'apikey', 'api_key', 'api-key', 'authorization', 'auth', 'credential', 'private', 'access_token', 'refresh_token', 'jwt', 'bearer', 'session', 'cookie', 'csrf', 'vapid', 'smtp_password', 'db_password', 'redis_url', 'connection_string' ]; const SENSITIVE_PATTERN = new RegExp( `(${SENSITIVE_KEYS.join('|')})\\s*[=:]\\s*['"]?([^'\"\\s,}\\]]+)['"]?`, 'gi' ); /** * Mask sensitive values in strings (API keys, passwords, tokens) * Uses a WeakSet to prevent infinite recursion on circular objects */ const maskSensitiveData = (value: any, visited = new WeakSet()): any => { if (typeof value === 'string') { // Mask patterns like "API_KEY = abc123" or "password: secret" let masked = value.replace(SENSITIVE_PATTERN, (match, key, val) => { if (val && val.length > 0) { const maskedVal = val.length > 4 ? val.substring(0, 2) + '***' + val.substring(val.length - 2) : '***'; return `${key}=${maskedVal}`; } return match; }); // Mask standalone tokens/keys (long alphanumeric strings that look like secrets) // e.g., "sk-abc123xyz789..." or "ghp_xxxx..." masked = masked.replace( /\b(sk-|ghp_|gho_|github_pat_|xox[baprs]-|Bearer\s+)([A-Za-z0-9_-]{20,})/gi, (match, prefix, token) => `${prefix}${'*'.repeat(8)}...` ); return masked; } if (Array.isArray(value)) { if (visited.has(value)) return '[Circular]'; visited.add(value); return value.map(item => maskSensitiveData(item, visited)); } if (value && typeof value === 'object') { // Special handling for common non-recursive objects to improve performance if (value instanceof Date || value instanceof RegExp) { return value; } if (visited.has(value)) return '[Circular]'; visited.add(value); // Prevent deep recursion into huge circular objects like Sequelize instances, Request, Response // If it looks like a Sequelize instance or complex object, we might want to be careful // but visited.has() should handle it. const masked: any = {}; for (const [k, v] of Object.entries(value)) { const keyLower = k.toLowerCase(); // Check if key itself is sensitive if (SENSITIVE_KEYS.some(sk => keyLower.includes(sk))) { masked[k] = typeof v === 'string' && v.length > 0 ? '***REDACTED***' : v; } else { masked[k] = maskSensitiveData(v, visited); } } return masked; } return value; }; // ============ COMMON LABELS/METADATA ============ const appMeta = { app: 're-workflow', service: 'backend', environment: process.env.NODE_ENV || 'development', version: process.env.APP_VERSION || '1.2.0', }; // ============ TRANSPORTS ============ const transports: winston.transport[] = [ // Local file transport - Error logs new winston.transports.File({ filename: path.join(logDir, 'error.log'), level: 'error', maxsize: 10 * 1024 * 1024, // 10MB maxFiles: 10, tailable: true, }), // Local file transport - Combined logs new winston.transports.File({ filename: path.join(logDir, 'combined.log'), maxsize: 10 * 1024 * 1024, // 10MB maxFiles: 10, tailable: true, }), ]; // ============ LOKI TRANSPORT (Grafana) ============ // Only enable Loki in production (or LOKI_FORCE=true); in dev, connection errors are noisy when Loki isn't running if (process.env.LOKI_HOST && (isProduction || process.env.LOKI_FORCE === 'true')) { try { const LokiTransport = require('winston-loki'); const lokiTransportOptions: any = { host: process.env.LOKI_HOST, labels: appMeta, json: true, format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), replaceTimestamp: true, onConnectionError: (err: Error) => { if (isProduction) console.error('[Loki] Connection error:', err.message); }, batching: true, interval: 5, }; if (process.env.LOKI_USER && process.env.LOKI_PASSWORD) { lokiTransportOptions.basicAuth = `${process.env.LOKI_USER}:${process.env.LOKI_PASSWORD}`; } transports.push(new LokiTransport(lokiTransportOptions)); } catch (error) { console.warn('[Logger] ⚠️ Failed to initialize Loki transport:', (error as Error).message); } } // ============ CONSOLE TRANSPORT ============ // Enabled for all environments to ensure visibility in terminal/container logs transports.push( new winston.transports.Console({ format: winston.format.combine( winston.format.colorize({ all: !isProduction }), winston.format.printf(({ level, message, timestamp, ...meta }) => { const metaStr = Object.keys(meta).length && !meta.service ? ` ${JSON.stringify(meta)}` : ''; return `${timestamp} [${level}]: ${message}${metaStr}`; }) ), }) ); // ============ ERROR SANITIZER ============ /** * Sanitize error objects for logging - prevents huge Axios error dumps */ const sanitizeError = (error: any): object => { // Handle Axios errors specifically if (error?.isAxiosError || error?.name === 'AxiosError') { return { name: error.name, message: error.message, code: error.code, status: error.response?.status, statusText: error.response?.statusText, url: error.config?.url, method: error.config?.method, responseData: error.response?.data, }; } // Handle standard errors if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, errors: (error as any).errors, ...(error as any).statusCode && { statusCode: (error as any).statusCode }, }; } // Fallback for unknown error types return { message: String(error), type: typeof error, }; }; // Custom format to sanitize errors and mask sensitive data before logging const sanitizeFormat = winston.format((info) => { // Sanitize error objects if (info.error && typeof info.error === 'object') { info.error = sanitizeError(info.error); } // If message is an error object, sanitize it if (info.message && typeof info.message === 'object' && (info.message as any).stack) { info.error = sanitizeError(info.message); info.message = (info.message as Error).message; } // Mask sensitive data in message if (typeof info.message === 'string') { info.message = maskSensitiveData(info.message); } // Mask sensitive data in all metadata for (const key of Object.keys(info)) { if (key !== 'level' && key !== 'timestamp' && key !== 'service') { info[key] = maskSensitiveData(info[key]); } } return info; }); // ============ CREATE LOGGER ============ const logger = winston.createLogger({ level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'), format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true }), sanitizeFormat(), winston.format.json() ), defaultMeta: { service: 're-workflow-backend', hostname: os.hostname(), }, transports, }); // ============ HELPER METHODS FOR STRUCTURED LOGGING ============ /** * Log with additional context labels (will appear in Grafana) */ export const logWithContext = ( level: 'info' | 'warn' | 'error' | 'debug', message: string, context: { // Domain labels requestId?: string; userId?: string; priority?: 'STANDARD' | 'EXPRESS'; status?: string; department?: string; // API labels endpoint?: string; method?: string; statusCode?: number; duration?: number; // Error context errorType?: string; error?: any; stack?: string; // Custom data [key: string]: any; } ) => { // Sanitize error if present const sanitizedContext = { ...context }; if (sanitizedContext.error) { sanitizedContext.error = sanitizeError(sanitizedContext.error); } logger.log(level, message, sanitizedContext); }; /** * Log API request (use in middleware or controllers) */ export const logApiRequest = ( method: string, endpoint: string, statusCode: number, duration: number, userId?: string, error?: string ) => { const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info'; logger.log(level, `${method} ${endpoint} ${statusCode} ${duration}ms`, { endpoint, method, statusCode, duration, userId, ...(error && { error }), }); }; /** * Log workflow events */ export const logWorkflowEvent = ( event: 'created' | 'submitted' | 'approved' | 'rejected' | 'closed' | 'paused' | 'resumed' | 'updated', requestId: string, details: { priority?: string; status?: string; department?: string; userId?: string; userName?: string; message?: string; level?: number; [key: string]: any; } = {} ) => { logger.info(`Workflow ${event}: ${requestId}`, { workflowEvent: event, requestId, ...details, }); }; /** * Log TAT/SLA events */ export const logTATEvent = ( event: 'approaching' | 'breached' | 'resolved' | 'warning', requestId: string, details: { priority?: string; threshold?: number; elapsedHours?: number; tatHours?: number; level?: number; [key: string]: any; } = {} ) => { const level = event === 'breached' ? 'error' : event === 'approaching' || event === 'warning' ? 'warn' : 'info'; logger.log(level, `TAT ${event}: ${requestId}`, { tatEvent: event, requestId, ...details, }); }; /** * Log authentication events */ export const logAuthEvent = ( event: 'login' | 'logout' | 'token_refresh' | 'token_exchange' | 'auth_failure' | 'sso_callback', userId: string | undefined, details: { email?: string; role?: string; ip?: string; userAgent?: string; error?: any; [key: string]: any; } = {} ) => { const level = event === 'auth_failure' ? 'warn' : 'info'; // Sanitize error if present const sanitizedDetails = { ...details }; if (sanitizedDetails.error) { sanitizedDetails.error = sanitizeError(sanitizedDetails.error); } logger.log(level, `Auth ${event}${userId ? `: ${userId}` : ''}`, { authEvent: event, userId, ...sanitizedDetails, }); }; /** * Log document events */ export const logDocumentEvent = ( event: 'uploaded' | 'downloaded' | 'deleted' | 'previewed', documentId: string, details: { requestId?: string; userId?: string; fileName?: string; fileType?: string; fileSize?: number; [key: string]: any; } = {} ) => { logger.info(`Document ${event}: ${documentId}`, { documentEvent: event, documentId, ...details, }); }; /** * Log notification events */ export const logNotificationEvent = ( event: 'sent' | 'failed' | 'queued', details: { type?: string; userId?: string; requestId?: string; channel?: 'push' | 'email' | 'in-app'; error?: any; [key: string]: any; } = {} ) => { const level = event === 'failed' ? 'error' : 'info'; // Sanitize error if present const sanitizedDetails = { ...details }; if (sanitizedDetails.error) { sanitizedDetails.error = sanitizeError(sanitizedDetails.error); } logger.log(level, `Notification ${event}`, { notificationEvent: event, ...sanitizedDetails, }); }; /** * Log AI service events */ export const logAIEvent = ( event: 'request' | 'response' | 'error' | 'fallback', details: { provider?: string; model?: string; requestId?: string; duration?: number; error?: any; [key: string]: any; } = {} ) => { const level = event === 'error' ? 'error' : 'info'; // Sanitize error if present const sanitizedDetails = { ...details }; if (sanitizedDetails.error) { sanitizedDetails.error = sanitizeError(sanitizedDetails.error); } logger.log(level, `AI ${event}`, { aiEvent: event, ...sanitizedDetails, }); }; // ============ MORGAN STREAM ============ // Create a stream object for Morgan HTTP logging const loggerWithStream = logger as any; loggerWithStream.stream = { write: (message: string) => { logger.info(message.trim()); }, }; // Export helper functions and logger export { sanitizeError }; export default loggerWithStream as winston.Logger;