| 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 { 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"; |
|
|
| export const DEFAULT_LOG_DIR = resolvePreferredOpenClawTmpDir(); |
| export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "openclaw.log"); |
|
|
| const LOG_PREFIX = "openclaw"; |
| const LOG_SUFFIX = ".log"; |
| const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; |
| const DEFAULT_MAX_LOG_FILE_BYTES = 500 * 1024 * 1024; |
|
|
| const requireConfig = resolveNodeRequireFromMeta(import.meta.url); |
|
|
| export type LoggerSettings = { |
| level?: LogLevel; |
| file?: string; |
| maxFileBytes?: number; |
| consoleLevel?: LogLevel; |
| consoleStyle?: ConsoleStyle; |
| }; |
|
|
| type LogObj = { date?: Date } & Record<string, unknown>; |
|
|
| type ResolvedSettings = { |
| level: LogLevel; |
| file: string; |
| maxFileBytes: number; |
| }; |
| export type LoggerResolvedSettings = ResolvedSettings; |
| export type LogTransportRecord = Record<string, unknown>; |
| export type LogTransport = (logObj: LogTransportRecord) => void; |
|
|
| const externalTransports = new Set<LogTransport>(); |
|
|
| function shouldSkipLoadConfigFallback(argv: string[] = process.argv): boolean { |
| const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); |
| return primary === "config" && secondary === "validate"; |
| } |
|
|
| function attachExternalTransport(logger: TsLogger<LogObj>, transport: LogTransport): void { |
| logger.attachTransport((logObj: LogObj) => { |
| if (!externalTransports.has(transport)) { |
| return; |
| } |
| try { |
| transport(logObj as LogTransportRecord); |
| } catch { |
| |
| } |
| }); |
| } |
|
|
| function canUseSilentVitestFileLogFastPath(envLevel: LogLevel | undefined): boolean { |
| return ( |
| process.env.VITEST === "true" && |
| process.env.OPENCLAW_TEST_FILE_LOG !== "1" && |
| !envLevel && |
| !loggingState.overrideSettings |
| ); |
| } |
|
|
| function resolveSettings(): ResolvedSettings { |
| const envLevel = resolveEnvLogLevelOverride(); |
| |
| |
| 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<LogObj> { |
| const logger = new TsLogger<LogObj>({ |
| name: "openclaw", |
| minLevel: levelToMinLevel(settings.level), |
| type: "hidden", |
| }); |
|
|
| |
| if (settings.level === "silent") { |
| for (const transport of externalTransports) { |
| attachExternalTransport(logger, transport); |
| } |
| return logger; |
| } |
|
|
| fs.mkdirSync(path.dirname(settings.file), { recursive: true }); |
| |
| 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 { |
| |
| } |
| }); |
| 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<LogObj> { |
| const settings = resolveSettings(); |
| const cachedLogger = loggingState.cachedLogger as TsLogger<LogObj> | 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<LogObj>; |
| } |
|
|
| export function getChildLogger( |
| bindings?: Record<string, unknown>, |
| opts?: { level?: LogLevel }, |
| ): TsLogger<LogObj> { |
| 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 ?? ""] : [], |
| }); |
| } |
|
|
| |
| export function toPinoLikeLogger(logger: TsLogger<LogObj>, level: LogLevel): PinoLikeLogger { |
| const buildChild = (bindings?: Record<string, unknown>) => |
| 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<string, unknown>) => 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(); |
| } |
|
|
| |
| 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<LogObj> | 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 { |
| |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|