import { createLogger, format, transports } from "winston"; import DailyRotateFile from "winston-daily-rotate-file"; import path from "path"; import { getEnvString } from "../../utils/env.js"; // Utility function to get environment variables with default values and validation const logDirectory = "logs"; const initialLogLevel = getEnvString("LOG_LEVEL", "info"); const initialNodeEnv = getEnvString("NODE_ENV", "development"); const sdkLogPrefix = getEnvString("SDK_LOG_PREFIX", "SDK:").trim(); const LOGGER_ERROR_PREFIX = "[mm-erp-sdk logger]"; const serializeLoggerError = (error: unknown): string => { if (error instanceof Error) { return error.stack ?? error.message; } if (typeof error === "string") { return error; } try { return JSON.stringify(error); } catch { return String(error); } }; const handleLoggerError = (error: unknown) => { const serialized = serializeLoggerError(error); try { // eslint-disable-next-line no-console console.error(`${LOGGER_ERROR_PREFIX} transport error: ${serialized}`); } catch { /* ignore failures when reporting logger errors */ } }; const MSG_MAX_LEN = 60; const MSG_MAX_CHARS = 2048; const prefixMessage = (msg: string, shouldPrefix: boolean): string => { if (!shouldPrefix) return msg; if (!sdkLogPrefix) return msg; // Prevent accidental double-prefix if upstream code already adds it. if (msg.includes(` ${sdkLogPrefix} `) || msg.startsWith(`${sdkLogPrefix} `)) return msg; return `${sdkLogPrefix} ${msg}`; }; const truncateString = (str: string): string => { // First truncate by lines const lines = str.split("\n"); let result = lines.length > MSG_MAX_LEN ? lines.slice(0, MSG_MAX_LEN).join("\n") + "\n...truncated lines..." : str; // Then truncate by characters if still too long if (result.length > MSG_MAX_CHARS) { result = result.slice(0, MSG_MAX_CHARS) + "...truncated chars..."; } return result; }; /** * Safe JSON serializer that handles circular references and extracts meaningful error data */ const safeStringify = (obj: any, indent: number = 2): string => { const seen = new WeakSet(); return JSON.stringify( obj, (key, value) => { // Handle Error objects specially if (value instanceof Error) { return { message: value.message, name: value.name, stack: value.stack, ...(value.constructor.name === 'HTTPError' || value.constructor.name === 'AxiosError' ? { status: (value as any).status, code: (value as any).code, data: (value as any).data, } : {}), }; } // Handle circular references if (typeof value === "object" && value !== null) { if (seen.has(value)) { return "[Circular]"; } seen.add(value); } return value; }, indent ); }; const baseFormat = format.printf(({ timestamp, level, message, sdk, ...rest }) => { let restString = safeStringify(rest, 2); restString = restString === "{}" ? "" : restString; let formattedMessage: string; const shouldPrefix = Boolean(sdk); if (typeof message == "string" && message.includes("is already running")) { formattedMessage = `${timestamp} ${level.toLocaleUpperCase()}: ${prefixMessage(message, shouldPrefix)}`; } else if (message instanceof Error) { formattedMessage = `${timestamp} ${level.toLocaleUpperCase()}: ${prefixMessage(message.stack ?? message.message, shouldPrefix)}: ${restString}`; } else if (rest instanceof Error) { const str = typeof message == "string" ? message : message instanceof Error ? message.message : ""; formattedMessage = `${timestamp} ${level.toLocaleUpperCase()}: ${prefixMessage(str, shouldPrefix)}: ${rest.stack ?? ""}`; } else { const str = typeof message == "string" ? message : message instanceof Error ? message.message : ""; formattedMessage = `${timestamp} ${level.toLocaleUpperCase()}: ${prefixMessage(str, shouldPrefix)} ${restString}`; } return truncateString(formattedMessage); }); const logFormat = format.combine( format.timestamp(), format.splat(), baseFormat ); const createConsoleTransport = () => { const consoleTransport = new transports.Console({ format: format.combine( format.timestamp(), format.splat(), baseFormat, format.colorize({ all: true }) ), }); consoleTransport.on("error", handleLoggerError); return consoleTransport; }; // Create a basic logger with default configuration const logger = createLogger({ level: initialLogLevel, format: logFormat, transports: [createConsoleTransport()], }); logger.on("error", handleLoggerError); // A dedicated child logger for SDK internals. Consumers (connectors) that import the default logger // won't get the SDK prefix unless they opt into it. export const sdkLogger = logger.child({ sdk: true }); // Helper function to create a file transport with shared configuration const createFileTransport = (): DailyRotateFile => { const transport = new DailyRotateFile({ filename: path.join(logDirectory, "%DATE%.log"), datePattern: "YYYY-MM-DD", zippedArchive: true, maxSize: "20m", maxFiles: "14d", format: logFormat, }); transport.on("error", handleLoggerError); return transport; }; // Rotate mitigation helper: attaches rotate handler to transport and recursively attaches to replacements function attachRotateMitigation( transport: DailyRotateFile, opts: { logLevel: string; nodeEnv: string } ) { const { logLevel, nodeEnv } = opts; let isRefreshing = false; transport.on("rotate", (_old: string, _new: string) => { if (isRefreshing) return; isRefreshing = true; let removalTimer: NodeJS.Timeout | null = null; let next: DailyRotateFile | null = null; const cleanupRefresh = () => { if (removalTimer) { clearTimeout(removalTimer); removalTimer = null; } isRefreshing = false; }; try { // Create replacement first to avoid any logging gap next = createFileTransport(); // When the new file is created, remove the old transport next.on("new", () => { if (removalTimer) { clearTimeout(removalTimer); removalTimer = null; } try { logger.remove(transport); } catch (error) { handleLoggerError(error); } cleanupRefresh(); }); attachRotateMitigation(next, opts); try { logger.add(next); } catch (error) { handleLoggerError(error); cleanupRefresh(); try { logger.remove(next); } catch {} try { if (typeof next.close === "function") { next.close(); } } catch {} return; } // Fallback: if the "new" event doesn't fire, remove the old transport after a grace period const REMOVAL_GRACE_MS = 30000; removalTimer = setTimeout(() => { try { logger.remove(transport); } catch (error) { handleLoggerError(error); } cleanupRefresh(); }, REMOVAL_GRACE_MS); // Keep console and other transports intact; do not silence or clear logger.level = logLevel; } catch (error) { cleanupRefresh(); handleLoggerError(error); if (next) { try { logger.remove(next); } catch {} try { if (typeof next.close === "function") { next.close(); } } catch {} } } }); } // Function to reconfigure the logger once CoreConfiguration is available export const configureLogger = (logLevel: string, nodeEnv: string) => { // Remove existing transports (safely): close any DailyRotateFile streams first try { const existingFileTransports = (logger.transports || []).filter( (t: any) => t instanceof DailyRotateFile ); for (const t of existingFileTransports) { const s = (t as any).logStream; if (s && typeof s.end === "function") { try { s.end(); } catch {} } } } catch {} logger.clear(); // Add file transport const fileTransport = createFileTransport(); attachRotateMitigation(fileTransport, { logLevel, nodeEnv }); logger.add(fileTransport); // Add console transport in non-production environments if (nodeEnv !== "production") { logger.add(createConsoleTransport()); } // Set the log level logger.level = logLevel; }; // Ensure file logging is configured as soon as the module loads configureLogger(initialLogLevel, initialNodeEnv); // Add fatal method for Graceful compatibility (logger as any).fatal = (err: any, meta?: any) => logger.error(err, meta); export default logger;