/** * Unified logging system using Winston * Console + File transports with daily rotation * * Features: * - Full error serialization with stack traces * - Development/Production environment differentiation * - Structured logging with context */ import winston from 'winston' import DailyRotateFile from 'winston-daily-rotate-file' import path from 'path' import os from 'os' import { app, BrowserWindow } from 'electron' import { serializeError, sanitizeError } from './error-utils' import { getLogDir, isProduction, cleanupOldScreenshots } from './shared' import { IPC_CHANNELS } from '../../../shared/ipc-channels' import { getContext, run } from './request-context' import { createSeqTransportSync } from './seq-transport' import type { SeqConfig } from '../../types/config.schema' // Custom log levels matching project semantics: // verbose (most detailed) → error (most severe) // Winston rule: logs with level value <= threshold are emitted. const PROJECT_LEVELS = { error: 0, warn: 1, info: 2, debug: 3, verbose: 4 } as const // Cache isProduction() at module load — app.isPackaged never changes at runtime const IS_PROD = isProduction() /** * Check if an IPv4 address is an RFC 1918 private address. * Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 */ function isPrivateIpv4(ip: string): boolean { const parts = ip.split('.').map(Number) if (parts.length !== 4) return false // 10.0.0.0/8 if (parts[0] === 10) return true // 172.16.0.0/12 if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true // 192.168.0.0/16 if (parts[0] === 192 && parts[1] === 168) return true return false } /** * Get the primary local IPv4 address (RFC 1918 private address). * * Strategy: * 1. Collect all non-internal, non-APIPA IPv4 addresses from physical adapters * 2. Return the first private (LAN) address found * 3. Fallback to any remaining non-internal address * 4. Returns 'N/A' if none found */ function getLocalIpAddress(): string { const interfaces = os.networkInterfaces() const candidates: string[] = [] for (const [, addrs] of Object.entries(interfaces)) { if (!addrs) continue for (const iface of addrs) { if (iface.family === 'IPv4' && !iface.internal && !iface.address.startsWith('169.254.')) { candidates.push(iface.address) } } } // Prefer private (LAN) addresses const privateIp = candidates.find(isPrivateIpv4) if (privateIp) return privateIp // Fallback: any non-internal address return candidates[0] || 'N/A' } /** * Check if an error has already been serialized (plain object with name/message but not an Error instance). * Prevents double-serialization when logError() output passes through the format pipeline. */ function isSerializedError(value: unknown): boolean { return ( typeof value === 'object' && value !== null && !(value instanceof Error) && 'name' in value && 'message' in value ) } // Custom format for console output - includes full error details const consoleFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.colorize(), // Auto-inject requestId from async context winston.format((info) => { const context = getContext() if (context) { info.requestId = context.requestId if (context.userId) { info.userId = context.userId } if (context.operation) { info.operation = context.operation } } return info })(), winston.format.printf( ({ timestamp, level, message, context, error, requestId, userId, operation, ...meta }) => { const contextStr = context ? `[${context}]` : '' const requestIdStr = requestId ? ` [${requestId}]` : '' const userStr = userId ? ` (user:${userId})` : '' const opStr = operation ? ` op:${operation}` : '' // Format error with full stack trace let errorStr = '' if (error) { // Skip re-serialization if already a serialized error object const serialized: { stack?: string; message: string } = isSerializedError(error) ? (error as { stack?: string; message: string }) : IS_PROD ? sanitizeError(serializeError(error)) : serializeError(error) if (serialized.stack) { errorStr = `\n${serialized.stack}` } else { errorStr = ` ${serialized.message}` } } let metaStr = '' if (Object.keys(meta).length > 0) { try { metaStr = ` ${JSON.stringify(meta, null, 2)}` } catch { // Fallback for circular references: stringify primitives, replace complex objects with placeholder metaStr = ` ${JSON.stringify( Object.fromEntries( Object.entries(meta).map(([k, v]) => [ k, v !== null && typeof v === 'object' ? `[Object]` : v ]) ), null, 2 )}` } } return `${timestamp} [${level}]${contextStr}${requestIdStr}${userStr}${opStr} ${message}${errorStr}${metaStr}` } ) ) // Custom format for file output - JSON with full error details const fileFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // Auto-inject requestId from async context for file logs winston.format((info) => { const context = getContext() if (context) { info.requestId = context.requestId if (context.userId) { info.userId = context.userId } if (context.operation) { info.operation = context.operation } } return info })(), winston.format((info) => { // Serialize errors in metadata (skip if already serialized) if (info.error) { if (!isSerializedError(info.error)) { info.error = IS_PROD ? sanitizeError(serializeError(info.error)) : serializeError(info.error) } } // Serialize any error in meta fields (skip if already serialized) for (const key of Object.keys(info)) { if (key !== 'error' && info[key] instanceof Error) { info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key]) } } return info })(), winston.format.json() ) // Daily rotate file transport configuration const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => { return new DailyRotateFile({ filename: path.join(getLogDir(), 'app-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: maxFiles || '14d', level, format: fileFormat }) } // Create the logger instance with default level - Console only initially // File transports are added after config is loaded via applyLoggingConfig() const logger = winston.createLogger({ levels: PROJECT_LEVELS, level: 'info', // Default level, can be updated via setLogLevel() defaultMeta: { service: 'erpauto', appVersion: app.getVersion(), computerName: os.hostname(), ipAddress: getLocalIpAddress() }, transports: [ // Console transport - always enabled new winston.transports.Console({ format: consoleFormat }) ] }) /** * Update the logger level dynamically and notify renderer processes * @param level - The new log level */ export function setLogLevel(level: string): void { logger.level = level // Broadcast level change to all renderer windows so they update their cached level for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) { win.webContents.send(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, level) } } } /** * Apply logging configuration from config file * Removes existing DailyRotateFile transports and recreates them with config values * Also configures Seq transport if enabled * * @param config - Logging configuration from config.yaml * @param seqConfig - Optional Seq configuration from config.yaml */ export function applyLoggingConfig( config: { level: string; appRetention: number }, seqConfig?: SeqConfig ): void { // Update log level setLogLevel(config.level) // Remove existing DailyRotateFile transports const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile) for (const transport of existingFileTransports) { logger.remove(transport) } // Add app log transport with configured retention const retentionStr = `${config.appRetention}d` logger.add(createFileTransport(undefined, retentionStr)) // Add error-specific file transport in production if (IS_PROD) { logger.add( new DailyRotateFile({ filename: path.join(getLogDir(), 'error-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: retentionStr, level: 'error', format: fileFormat }) ) } // Add Seq transport if configured and enabled // Note: Uses sync wrapper which initializes asynchronously if (seqConfig && seqConfig.enabled && seqConfig.serverUrl) { try { const seqTransport = createSeqTransportSync(seqConfig) if (seqTransport) { logger.add(seqTransport) logger.info('Seq transport added successfully', { serverUrl: seqConfig.serverUrl, batchPostingLimit: seqConfig.batchPostingLimit, period: seqConfig.period, context: 'seq-transport' }) } else { // Transport not ready yet, will be initialized on next call logger.debug('Seq transport initialization in progress', { serverUrl: seqConfig.serverUrl, context: 'seq-transport' }) // Initialize and add when ready import('./seq-transport').then(({ createSeqTransport }) => { createSeqTransport(seqConfig).then((transport) => { if (transport) { logger.add(transport) logger.info('Seq transport added (async init complete)', { serverUrl: seqConfig.serverUrl, context: 'seq-transport' }) } }) }) } } catch (error) { logger.error('Failed to add Seq transport', { error: error instanceof Error ? error.message : String(error), context: 'seq-transport' }) // Don't throw - allow app to continue without Seq } } // Clean up old screenshot files beyond the retention window cleanupOldScreenshots(config.appRetention) } /** * Create a child logger with a specific context * @param context - The context/module name for the logger * @returns A child logger instance */ export function createLogger(context: string): winston.Logger { return logger.child({ context }) } /** * Execute a function with automatic request-scoped logging * * This wrapper ensures all logging within the function has access to the request context. * It's a convenience wrapper around RequestContext.run() that also ensures the logger * properly captures the context. * * @param fn - The async function to execute within the context * @param context - Optional business context (userId, operation) * @returns Promise resolving to the function's return value * * @example * ```typescript * await withRequestContext(async () => { * logger.info('Processing order') // Will include requestId, userId, operation * await processOrder() * }, { userId: 'user123', operation: 'process-order' }) * ``` */ export async function withRequestContext( fn: () => Promise, context?: { userId?: string; operation?: string } ): Promise { return run(fn, context) } // Re-export error utilities for convenience export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils' // Export request context management for async-context logging export { run, getRequestId, getContext, withContext, type LoggerContext } from './request-context' // Export the main logger for direct use export default logger // Export performance monitoring utilities export { trackDuration, PerformanceTracker, createPerformanceTracker, DEFAULT_SLOW_THRESHOLD_MS } from './performance-monitor' // Export log level types for convenience export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'