* 1、环境变量**:新增 `OPENCLAW_LOG_LEVEL`,可取值 `silent|fatal|error|warn|info|debug|trace`。设置后同时覆盖**文件日志**与**控制台**的级别,优先级高于配置文件。 2、启动参数**:在 `openclaw gateway run` 上新增 `--log-level <level>`,对该次进程同时生效于文件与控制台;未传时仍使用环境变量或配置文件。 * fix(logging): make log-level override global and precedence-safe --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
315 lines
9.6 KiB
TypeScript
315 lines
9.6 KiB
TypeScript
import util from "node:util";
|
|
import type { OpenClawConfig } from "../config/types.js";
|
|
import { isVerbose } from "../globals.js";
|
|
import { stripAnsi } from "../terminal/ansi.js";
|
|
import { readLoggingConfig } from "./config.js";
|
|
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
|
|
import { type LogLevel, normalizeLogLevel } from "./levels.js";
|
|
import { getLogger, type LoggerSettings } from "./logger.js";
|
|
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
|
import { loggingState } from "./state.js";
|
|
import { formatLocalIsoWithOffset } from "./timestamps.js";
|
|
|
|
export type ConsoleStyle = "pretty" | "compact" | "json";
|
|
type ConsoleSettings = {
|
|
level: LogLevel;
|
|
style: ConsoleStyle;
|
|
};
|
|
export type ConsoleLoggerSettings = ConsoleSettings;
|
|
|
|
const requireConfig = resolveNodeRequireFromMeta(import.meta.url);
|
|
type ConsoleConfigLoader = () => OpenClawConfig["logging"] | undefined;
|
|
const loadConfigFallbackDefault: ConsoleConfigLoader = () => {
|
|
try {
|
|
const loaded = requireConfig?.("../config/config.js") as
|
|
| {
|
|
loadConfig?: () => OpenClawConfig;
|
|
}
|
|
| undefined;
|
|
return loaded?.loadConfig?.().logging;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
let loadConfigFallback: ConsoleConfigLoader = loadConfigFallbackDefault;
|
|
|
|
export function setConsoleConfigLoaderForTests(loader?: ConsoleConfigLoader): void {
|
|
loadConfigFallback = loader ?? loadConfigFallbackDefault;
|
|
}
|
|
|
|
function normalizeConsoleLevel(level?: string): LogLevel {
|
|
if (isVerbose()) {
|
|
return "debug";
|
|
}
|
|
if (!level && process.env.VITEST === "true" && process.env.OPENCLAW_TEST_CONSOLE !== "1") {
|
|
return "silent";
|
|
}
|
|
return normalizeLogLevel(level, "info");
|
|
}
|
|
|
|
function normalizeConsoleStyle(style?: string): ConsoleStyle {
|
|
if (style === "compact" || style === "json" || style === "pretty") {
|
|
return style;
|
|
}
|
|
if (!process.stdout.isTTY) {
|
|
return "compact";
|
|
}
|
|
return "pretty";
|
|
}
|
|
|
|
function resolveConsoleSettings(): ConsoleSettings {
|
|
let cfg: OpenClawConfig["logging"] | undefined =
|
|
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
|
|
if (!cfg) {
|
|
if (loggingState.resolvingConsoleSettings) {
|
|
cfg = undefined;
|
|
} else {
|
|
loggingState.resolvingConsoleSettings = true;
|
|
try {
|
|
cfg = loadConfigFallback();
|
|
} finally {
|
|
loggingState.resolvingConsoleSettings = false;
|
|
}
|
|
}
|
|
}
|
|
const envLevel = resolveEnvLogLevelOverride();
|
|
const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel);
|
|
const style = normalizeConsoleStyle(cfg?.consoleStyle);
|
|
return { level, style };
|
|
}
|
|
|
|
function consoleSettingsChanged(a: ConsoleSettings | null, b: ConsoleSettings) {
|
|
if (!a) {
|
|
return true;
|
|
}
|
|
return a.level !== b.level || a.style !== b.style;
|
|
}
|
|
|
|
export function getConsoleSettings(): ConsoleLoggerSettings {
|
|
const settings = resolveConsoleSettings();
|
|
const cached = loggingState.cachedConsoleSettings as ConsoleSettings | null;
|
|
if (!cached || consoleSettingsChanged(cached, settings)) {
|
|
loggingState.cachedConsoleSettings = settings;
|
|
}
|
|
return loggingState.cachedConsoleSettings as ConsoleSettings;
|
|
}
|
|
|
|
export function getResolvedConsoleSettings(): ConsoleLoggerSettings {
|
|
return getConsoleSettings();
|
|
}
|
|
|
|
// Route all console output (including tslog console writes) to stderr.
|
|
// This keeps stdout clean for RPC/JSON modes.
|
|
export function routeLogsToStderr(): void {
|
|
loggingState.forceConsoleToStderr = true;
|
|
}
|
|
|
|
export function setConsoleSubsystemFilter(filters?: string[] | null): void {
|
|
if (!filters || filters.length === 0) {
|
|
loggingState.consoleSubsystemFilter = null;
|
|
return;
|
|
}
|
|
const normalized = filters.map((value) => value.trim()).filter((value) => value.length > 0);
|
|
loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
|
|
}
|
|
|
|
export function setConsoleTimestampPrefix(enabled: boolean): void {
|
|
loggingState.consoleTimestampPrefix = enabled;
|
|
}
|
|
|
|
export function shouldLogSubsystemToConsole(subsystem: string): boolean {
|
|
const filter = loggingState.consoleSubsystemFilter;
|
|
if (!filter || filter.length === 0) {
|
|
return true;
|
|
}
|
|
return filter.some((prefix) => subsystem === prefix || subsystem.startsWith(`${prefix}/`));
|
|
}
|
|
|
|
const SUPPRESSED_CONSOLE_PREFIXES = [
|
|
"Closing session:",
|
|
"Opening session:",
|
|
"Removing old closed session:",
|
|
"Session already closed",
|
|
"Session already open",
|
|
] as const;
|
|
|
|
function shouldSuppressConsoleMessage(message: string): boolean {
|
|
if (isVerbose()) {
|
|
return false;
|
|
}
|
|
if (SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix))) {
|
|
return true;
|
|
}
|
|
if (
|
|
message.startsWith("[EventQueue] Slow listener detected") &&
|
|
message.includes("DiscordMessageListener")
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isEpipeError(err: unknown): boolean {
|
|
const code = (err as { code?: string })?.code;
|
|
return code === "EPIPE" || code === "EIO";
|
|
}
|
|
|
|
export function formatConsoleTimestamp(style: ConsoleStyle): string {
|
|
const now = new Date();
|
|
if (style === "pretty") {
|
|
const h = String(now.getHours()).padStart(2, "0");
|
|
const m = String(now.getMinutes()).padStart(2, "0");
|
|
const s = String(now.getSeconds()).padStart(2, "0");
|
|
return `${h}:${m}:${s}`;
|
|
}
|
|
return formatLocalIsoWithOffset(now);
|
|
}
|
|
|
|
function hasTimestampPrefix(value: string): boolean {
|
|
return /^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)/.test(
|
|
value,
|
|
);
|
|
}
|
|
|
|
function isJsonPayload(value: string): boolean {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
return false;
|
|
}
|
|
try {
|
|
JSON.parse(trimmed);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Route console.* calls through file logging while still emitting to stdout/stderr.
|
|
* This keeps user-facing output unchanged but guarantees every console call is captured in log files.
|
|
*/
|
|
export function enableConsoleCapture(): void {
|
|
if (loggingState.consolePatched) {
|
|
return;
|
|
}
|
|
loggingState.consolePatched = true;
|
|
|
|
// Handle async EPIPE errors on stdout/stderr. The synchronous try/catch in
|
|
// the forward() wrapper below only covers errors thrown during write dispatch.
|
|
// When the receiving pipe closes (e.g. during shutdown), Node emits the error
|
|
// asynchronously on the stream. Without a listener this becomes an uncaught
|
|
// exception that crashes the gateway.
|
|
// Guard separately from consolePatched so test resets don't stack listeners.
|
|
if (!loggingState.streamErrorHandlersInstalled) {
|
|
loggingState.streamErrorHandlersInstalled = true;
|
|
for (const stream of [process.stdout, process.stderr]) {
|
|
stream.on("error", (err) => {
|
|
if (isEpipeError(err)) {
|
|
return;
|
|
}
|
|
throw err;
|
|
});
|
|
}
|
|
}
|
|
|
|
let logger: ReturnType<typeof getLogger> | null = null;
|
|
const getLoggerLazy = () => {
|
|
if (!logger) {
|
|
logger = getLogger();
|
|
}
|
|
return logger;
|
|
};
|
|
|
|
const original = {
|
|
log: console.log,
|
|
info: console.info,
|
|
warn: console.warn,
|
|
error: console.error,
|
|
debug: console.debug,
|
|
trace: console.trace,
|
|
};
|
|
loggingState.rawConsole = {
|
|
log: original.log,
|
|
info: original.info,
|
|
warn: original.warn,
|
|
error: original.error,
|
|
};
|
|
|
|
const forward =
|
|
(level: LogLevel, orig: (...args: unknown[]) => void) =>
|
|
(...args: unknown[]) => {
|
|
const formatted = util.format(...args);
|
|
if (shouldSuppressConsoleMessage(formatted)) {
|
|
return;
|
|
}
|
|
const trimmed = stripAnsi(formatted).trimStart();
|
|
const shouldPrefixTimestamp =
|
|
loggingState.consoleTimestampPrefix &&
|
|
trimmed.length > 0 &&
|
|
!hasTimestampPrefix(trimmed) &&
|
|
!isJsonPayload(trimmed);
|
|
const timestamp = shouldPrefixTimestamp
|
|
? formatConsoleTimestamp(getConsoleSettings().style)
|
|
: "";
|
|
try {
|
|
const resolvedLogger = getLoggerLazy();
|
|
// Map console levels to file logger
|
|
if (level === "trace") {
|
|
resolvedLogger.trace(formatted);
|
|
} else if (level === "debug") {
|
|
resolvedLogger.debug(formatted);
|
|
} else if (level === "info") {
|
|
resolvedLogger.info(formatted);
|
|
} else if (level === "warn") {
|
|
resolvedLogger.warn(formatted);
|
|
} else if (level === "error" || level === "fatal") {
|
|
resolvedLogger.error(formatted);
|
|
} else {
|
|
resolvedLogger.info(formatted);
|
|
}
|
|
} catch {
|
|
// never block console output on logging failures
|
|
}
|
|
if (loggingState.forceConsoleToStderr) {
|
|
// in RPC/JSON mode, keep stdout clean
|
|
try {
|
|
const line = timestamp ? `${timestamp} ${formatted}` : formatted;
|
|
process.stderr.write(`${line}\n`);
|
|
} catch (err) {
|
|
if (isEpipeError(err)) {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
} else {
|
|
try {
|
|
if (!timestamp) {
|
|
orig.apply(console, args as []);
|
|
return;
|
|
}
|
|
if (args.length === 0) {
|
|
orig.call(console, timestamp);
|
|
return;
|
|
}
|
|
if (typeof args[0] === "string") {
|
|
orig.call(console, `${timestamp} ${args[0]}`, ...args.slice(1));
|
|
return;
|
|
}
|
|
orig.call(console, timestamp, ...args);
|
|
} catch (err) {
|
|
if (isEpipeError(err)) {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
console.log = forward("info", original.log);
|
|
console.info = forward("info", original.info);
|
|
console.warn = forward("warn", original.warn);
|
|
console.error = forward("error", original.error);
|
|
console.debug = forward("debug", original.debug);
|
|
console.trace = forward("trace", original.trace);
|
|
}
|