import fs from "node:fs"; import path from "node:path"; import { Logger as TsLogger } from "tslog"; import { getCommandPathWithRootOptions } from "../cli/argv.js"; import type { OpenClawConfig } from "../config/types.js"; import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir, } from "../infra/tmp-openclaw-dir.js"; import { readLoggingConfig } from "./config.js"; import type { ConsoleStyle } from "./console.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; import { resolveNodeRequireFromMeta } from "./node-require.js"; import { loggingState } from "./state.js"; import { formatLocalIsoWithOffset } from "./timestamps.js"; type ProcessWithBuiltinModule = NodeJS.Process & { getBuiltinModule?: (id: string) => unknown; }; function canUseNodeFs(): boolean { const getBuiltinModule = (process as ProcessWithBuiltinModule).getBuiltinModule; if (typeof getBuiltinModule !== "function") { return false; } try { return getBuiltinModule("fs") !== undefined; } catch { return false; } } function resolveDefaultLogDir(): string { return canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR; } function resolveDefaultLogFile(defaultLogDir: string): string { return canUseNodeFs() ? path.join(defaultLogDir, "openclaw.log") : `${POSIX_OPENCLAW_TMP_DIR}/openclaw.log`; } export const DEFAULT_LOG_DIR = resolveDefaultLogDir(); export const DEFAULT_LOG_FILE = resolveDefaultLogFile(DEFAULT_LOG_DIR); // legacy single-file path const LOG_PREFIX = "openclaw"; const LOG_SUFFIX = ".log"; const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h const DEFAULT_MAX_LOG_FILE_BYTES = 500 * 1024 * 1024; // 500 MB const requireConfig = resolveNodeRequireFromMeta(import.meta.url); export type LoggerSettings = { level?: LogLevel; file?: string; maxFileBytes?: number; consoleLevel?: LogLevel; consoleStyle?: ConsoleStyle; }; type LogObj = { date?: Date } & Record; type ResolvedSettings = { level: LogLevel; file: string; maxFileBytes: number; }; export type LoggerResolvedSettings = ResolvedSettings; export type LogTransportRecord = Record; export type LogTransport = (logObj: LogTransportRecord) => void; const externalTransports = new Set(); function shouldSkipLoadConfigFallback(argv: string[] = process.argv): boolean { const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); return primary === "config" && secondary === "validate"; } function attachExternalTransport(logger: TsLogger, transport: LogTransport): void { logger.attachTransport((logObj: LogObj) => { if (!externalTransports.has(transport)) { return; } try { transport(logObj as LogTransportRecord); } catch { // never block on logging failures } }); } function canUseSilentVitestFileLogFastPath(envLevel: LogLevel | undefined): boolean { return ( process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" && !envLevel && !loggingState.overrideSettings ); } function resolveSettings(): ResolvedSettings { if (!canUseNodeFs()) { return { level: "silent", file: DEFAULT_LOG_FILE, maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES, }; } const envLevel = resolveEnvLogLevelOverride(); // Test runs default file logs to silent. Skip config reads and fallback load in the // common case to avoid pulling heavy config/schema stacks on startup. if (canUseSilentVitestFileLogFastPath(envLevel)) { return { level: "silent", file: defaultRollingPathForToday(), maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES, }; } let cfg: OpenClawConfig["logging"] | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); if (!cfg && !shouldSkipLoadConfigFallback()) { try { const loaded = requireConfig?.("../config/config.js") as | { loadConfig?: () => OpenClawConfig; } | undefined; cfg = loaded?.loadConfig?.().logging; } catch { cfg = undefined; } } const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel); const level = envLevel ?? fromConfig; const file = cfg?.file ?? defaultRollingPathForToday(); const maxFileBytes = resolveMaxLogFileBytes(cfg?.maxFileBytes); return { level, file, maxFileBytes }; } function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) { if (!a) { return true; } return a.level !== b.level || a.file !== b.file || a.maxFileBytes !== b.maxFileBytes; } export function isFileLogLevelEnabled(level: LogLevel): boolean { const settings = (loggingState.cachedSettings as ResolvedSettings | null) ?? resolveSettings(); if (!loggingState.cachedSettings) { loggingState.cachedSettings = settings; } if (settings.level === "silent") { return false; } return levelToMinLevel(level) <= levelToMinLevel(settings.level); } function buildLogger(settings: ResolvedSettings): TsLogger { const logger = new TsLogger({ name: "openclaw", minLevel: levelToMinLevel(settings.level), type: "hidden", // no ansi formatting }); // Silent logging does not write files; skip all filesystem setup in this path. if (settings.level === "silent") { for (const transport of externalTransports) { attachExternalTransport(logger, transport); } return logger; } fs.mkdirSync(path.dirname(settings.file), { recursive: true }); // Clean up stale rolling logs when using a dated log filename. if (isRollingPath(settings.file)) { pruneOldRollingLogs(path.dirname(settings.file)); } let currentFileBytes = getCurrentLogFileBytes(settings.file); let warnedAboutSizeCap = false; logger.attachTransport((logObj: LogObj) => { try { const time = formatLocalIsoWithOffset(logObj.date ?? new Date()); const line = JSON.stringify({ ...logObj, time }); const payload = `${line}\n`; const payloadBytes = Buffer.byteLength(payload, "utf8"); const nextBytes = currentFileBytes + payloadBytes; if (nextBytes > settings.maxFileBytes) { if (!warnedAboutSizeCap) { warnedAboutSizeCap = true; const warningLine = JSON.stringify({ time: formatLocalIsoWithOffset(new Date()), level: "warn", subsystem: "logging", message: `log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}`, }); appendLogLine(settings.file, `${warningLine}\n`); process.stderr.write( `[openclaw] log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}\n`, ); } return; } if (appendLogLine(settings.file, payload)) { currentFileBytes = nextBytes; } } catch { // never block on logging failures } }); for (const transport of externalTransports) { attachExternalTransport(logger, transport); } return logger; } function resolveMaxLogFileBytes(raw: unknown): number { if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { return Math.floor(raw); } return DEFAULT_MAX_LOG_FILE_BYTES; } function getCurrentLogFileBytes(file: string): number { try { return fs.statSync(file).size; } catch { return 0; } } function appendLogLine(file: string, line: string): boolean { try { fs.appendFileSync(file, line, { encoding: "utf8" }); return true; } catch { return false; } } export function getLogger(): TsLogger { const settings = resolveSettings(); const cachedLogger = loggingState.cachedLogger as TsLogger | null; const cachedSettings = loggingState.cachedSettings as ResolvedSettings | null; if (!cachedLogger || settingsChanged(cachedSettings, settings)) { loggingState.cachedLogger = buildLogger(settings); loggingState.cachedSettings = settings; } return loggingState.cachedLogger as TsLogger; } export function getChildLogger( bindings?: Record, opts?: { level?: LogLevel }, ): TsLogger { const base = getLogger(); const minLevel = opts?.level ? levelToMinLevel(opts.level) : undefined; const name = bindings ? JSON.stringify(bindings) : undefined; return base.getSubLogger({ name, minLevel, prefix: bindings ? [name ?? ""] : [], }); } // Baileys expects a pino-like logger shape. Provide a lightweight adapter. export function toPinoLikeLogger(logger: TsLogger, level: LogLevel): PinoLikeLogger { const buildChild = (bindings?: Record) => toPinoLikeLogger( logger.getSubLogger({ name: bindings ? JSON.stringify(bindings) : undefined, }), level, ); return { level, child: buildChild, trace: (...args: unknown[]) => logger.trace(...args), debug: (...args: unknown[]) => logger.debug(...args), info: (...args: unknown[]) => logger.info(...args), warn: (...args: unknown[]) => logger.warn(...args), error: (...args: unknown[]) => logger.error(...args), fatal: (...args: unknown[]) => logger.fatal(...args), }; } export type PinoLikeLogger = { level: string; child: (bindings?: Record) => PinoLikeLogger; trace: (...args: unknown[]) => void; debug: (...args: unknown[]) => void; info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; fatal: (...args: unknown[]) => void; }; export function getResolvedLoggerSettings(): LoggerResolvedSettings { return resolveSettings(); } // Test helpers export function setLoggerOverride(settings: LoggerSettings | null) { loggingState.overrideSettings = settings; loggingState.cachedLogger = null; loggingState.cachedSettings = null; loggingState.cachedConsoleSettings = null; } export function resetLogger() { loggingState.cachedLogger = null; loggingState.cachedSettings = null; loggingState.cachedConsoleSettings = null; loggingState.overrideSettings = null; } export function registerLogTransport(transport: LogTransport): () => void { externalTransports.add(transport); const logger = loggingState.cachedLogger as TsLogger | null; if (logger) { attachExternalTransport(logger, transport); } return () => { externalTransports.delete(transport); }; } export const __test__ = { shouldSkipLoadConfigFallback, }; function formatLocalDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function defaultRollingPathForToday(): string { const today = formatLocalDate(new Date()); return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`); } function isRollingPath(file: string): boolean { const base = path.basename(file); return ( base.startsWith(`${LOG_PREFIX}-`) && base.endsWith(LOG_SUFFIX) && base.length === `${LOG_PREFIX}-YYYY-MM-DD${LOG_SUFFIX}`.length ); } function pruneOldRollingLogs(dir: string): void { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); const cutoff = Date.now() - MAX_LOG_AGE_MS; for (const entry of entries) { if (!entry.isFile()) { continue; } if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) { continue; } const fullPath = path.join(dir, entry.name); try { const stat = fs.statSync(fullPath); if (stat.mtimeMs < cutoff) { fs.rmSync(fullPath, { force: true }); } } catch { // ignore errors during pruning } } } catch { // ignore missing dir or read errors } }