From 06aa7c0a5482d6b5dc2c63acf19d92dbc0c90657 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:00:07 +0100 Subject: [PATCH] fix(logging): make log-level override global and precedence-safe --- docs/help/environment.md | 6 +-- docs/logging.md | 2 +- src/cli/argv.test.ts | 5 ++ src/cli/argv.ts | 2 +- src/cli/gateway-cli/run.ts | 22 --------- src/cli/log-level-option.test.ts | 13 ++++++ src/cli/log-level-option.ts | 12 +++++ src/cli/program/help.ts | 6 +++ src/cli/program/preaction.ts | 25 ++++++++++ src/logging/console.ts | 7 ++- src/logging/env-log-level.ts | 23 ++++++++++ src/logging/levels.ts | 11 ++++- src/logging/logger-env.test.ts | 78 ++++++++++++++++++++++++++++++++ src/logging/logger.ts | 5 +- src/logging/state.ts | 1 + 15 files changed, 183 insertions(+), 35 deletions(-) create mode 100644 src/cli/log-level-option.test.ts create mode 100644 src/cli/log-level-option.ts create mode 100644 src/logging/env-log-level.ts create mode 100644 src/logging/logger-env.test.ts diff --git a/docs/help/environment.md b/docs/help/environment.md index e678ab957e4..7e969c816a5 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -84,9 +84,9 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit ## Logging -| Variable | Purpose | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OPENCLAW_LOG_LEVEL` | Override log level for both file and console (e.g. `debug`, `trace`). Takes precedence over `logging.level` and `logging.consoleLevel` in config. Useful for temporary debugging or CI. | +| Variable | Purpose | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENCLAW_LOG_LEVEL` | Override log level for both file and console (e.g. `debug`, `trace`). Takes precedence over `logging.level` and `logging.consoleLevel` in config. Invalid values are ignored with a warning. | ### `OPENCLAW_HOME` diff --git a/docs/logging.md b/docs/logging.md index 0c22a54fef2..34fb61ce42d 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -118,7 +118,7 @@ All logging configuration lives under `logging` in `~/.openclaw/openclaw.json`. - `logging.level`: **file logs** (JSONL) level. - `logging.consoleLevel`: **console** verbosity level. -You can override both via the **`OPENCLAW_LOG_LEVEL`** environment variable (e.g. `OPENCLAW_LOG_LEVEL=debug`). The env var takes precedence over the config file, so you can raise verbosity for a single run without editing `openclaw.json`. On `openclaw gateway run`, **`--log-level `** (e.g. `--log-level debug`) applies the same override for that process. +You can override both via the **`OPENCLAW_LOG_LEVEL`** environment variable (e.g. `OPENCLAW_LOG_LEVEL=debug`). The env var takes precedence over the config file, so you can raise verbosity for a single run without editing `openclaw.json`. You can also pass the global CLI option **`--log-level `** (for example, `openclaw --log-level debug gateway run`), which overrides the environment variable for that command. `--verbose` only affects console output; it does not change file log levels. diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 19e431a04f9..f5cd7720a07 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -39,6 +39,11 @@ describe("argv helpers", () => { argv: ["node", "openclaw", "--profile", "work", "-v"], expected: true, }, + { + name: "root -v alias with log-level", + argv: ["node", "openclaw", "--log-level", "debug", "-v"], + expected: true, + }, { name: "subcommand -v should not be treated as version", argv: ["node", "openclaw", "acp", "-v"], diff --git a/src/cli/argv.ts b/src/cli/argv.ts index a3e20d3e4c0..7ab7588ae06 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -2,7 +2,7 @@ const HELP_FLAGS = new Set(["-h", "--help"]); const VERSION_FLAGS = new Set(["-V", "--version"]); const ROOT_VERSION_ALIAS_FLAG = "-v"; const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); -const ROOT_VALUE_FLAGS = new Set(["--profile"]); +const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]); const FLAG_TERMINATOR = "--"; export function hasHelpOrVersion(argv: string[]): boolean { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index aec50de87c6..74c8394b5e4 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -17,8 +17,6 @@ import { setVerbose } from "../../globals.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js"; import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js"; -import { normalizeLogLevel } from "../../logging/levels.js"; -import { setLoggerOverride } from "../../logging/logger.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; @@ -52,7 +50,6 @@ type GatewayRunOpts = { rawStreamPath?: unknown; dev?: boolean; reset?: boolean; - logLevel?: string; }; const gatewayLog = createSubsystemLogger("gateway"); @@ -66,7 +63,6 @@ const GATEWAY_RUN_VALUE_KEYS = [ "tailscale", "wsLog", "rawStreamPath", - "logLevel", ] as const; const GATEWAY_RUN_BOOLEAN_KEYS = [ @@ -91,15 +87,6 @@ function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): Gate resolved[key] = inherited ?? resolved[key]; continue; } - if (key === "logLevel") { - resolved.logLevel = - typeof resolved.logLevel === "string" - ? resolved.logLevel - : typeof inherited === "string" - ? inherited - : undefined; - continue; - } resolved[key] = resolved[key] ?? inherited; } @@ -122,11 +109,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { setConsoleTimestampPrefix(true); setVerbose(Boolean(opts.verbose)); - const logLevelRaw = typeof opts.logLevel === "string" ? opts.logLevel.trim() : undefined; - if (logLevelRaw) { - const level = normalizeLogLevel(logLevelRaw, "info"); - setLoggerOverride({ level, consoleLevel: level }); - } if (opts.claudeCliLogs) { setConsoleSubsystemFilter(["agent/claude-cli"]); process.env.OPENCLAW_CLAUDE_CLI_LOG_OUTPUT = "1"; @@ -401,10 +383,6 @@ export function addGatewayRunCommand(cmd: Command): Command { false, ) .option("--force", "Kill any existing listener on the target port before starting", false) - .option( - "--log-level ", - "Log level for file and console (silent|fatal|error|warn|info|debug|trace)", - ) .option("--verbose", "Verbose logging to stdout/stderr", false) .option( "--claude-cli-logs", diff --git a/src/cli/log-level-option.test.ts b/src/cli/log-level-option.test.ts new file mode 100644 index 00000000000..f1a359ecfae --- /dev/null +++ b/src/cli/log-level-option.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { parseCliLogLevelOption } from "./log-level-option.js"; + +describe("parseCliLogLevelOption", () => { + it("accepts allowed log levels", () => { + expect(parseCliLogLevelOption("debug")).toBe("debug"); + expect(parseCliLogLevelOption(" trace ")).toBe("trace"); + }); + + it("rejects invalid log levels", () => { + expect(() => parseCliLogLevelOption("loud")).toThrow("Invalid --log-level"); + }); +}); diff --git a/src/cli/log-level-option.ts b/src/cli/log-level-option.ts new file mode 100644 index 00000000000..407957e9b1a --- /dev/null +++ b/src/cli/log-level-option.ts @@ -0,0 +1,12 @@ +import { InvalidArgumentError } from "commander"; +import { ALLOWED_LOG_LEVELS, type LogLevel, tryParseLogLevel } from "../logging/levels.js"; + +export const CLI_LOG_LEVEL_VALUES = ALLOWED_LOG_LEVELS.join("|"); + +export function parseCliLogLevelOption(value: string): LogLevel { + const parsed = tryParseLogLevel(value); + if (!parsed) { + throw new InvalidArgumentError(`Invalid --log-level (use ${CLI_LOG_LEVEL_VALUES})`); + } + return parsed; +} diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 94bb5ac7a1e..87ef63d8d2e 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -5,6 +5,7 @@ import { escapeRegExp } from "../../utils.js"; import { hasFlag, hasRootVersionAlias } from "../argv.js"; import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; +import { CLI_LOG_LEVEL_VALUES, parseCliLogLevelOption } from "../log-level-option.js"; import { getCoreCliCommandsWithSubcommands } from "./command-registry.js"; import type { ProgramContext } from "./context.js"; import { getSubCliCommandsWithSubcommands } from "./register.subclis.js"; @@ -54,6 +55,11 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { .option( "--profile ", "Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-)", + ) + .option( + "--log-level ", + `Global log level override for file + console (${CLI_LOG_LEVEL_VALUES})`, + parseCliLogLevelOption, ); program.option("--no-color", "Disable ANSI colors", false); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 9c22596900f..3e0580154bd 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; import { isTruthyEnvValue } from "../../infra/env.js"; +import type { LogLevel } from "../../logging/levels.js"; import { defaultRuntime } from "../../runtime.js"; import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js"; import { emitCliBanner } from "../banner.js"; @@ -22,6 +23,26 @@ function setProcessTitleForCommand(actionCommand: Command) { // Commands that need channel plugins loaded const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]); +function getRootCommand(command: Command): Command { + let current = command; + while (current.parent) { + current = current.parent; + } + return current; +} + +function getCliLogLevel(actionCommand: Command): LogLevel | undefined { + const root = getRootCommand(actionCommand); + if (typeof root.getOptionValueSource !== "function") { + return undefined; + } + if (root.getOptionValueSource("logLevel") !== "cli") { + return undefined; + } + const logLevel = root.opts>().logLevel; + return typeof logLevel === "string" ? (logLevel as LogLevel) : undefined; +} + export function registerPreActionHooks(program: Command, programVersion: string) { program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); @@ -40,6 +61,10 @@ export function registerPreActionHooks(program: Command, programVersion: string) } const verbose = getVerboseFlag(argv, { includeDebug: true }); setVerbose(verbose); + const cliLogLevel = getCliLogLevel(actionCommand); + if (cliLogLevel) { + process.env.OPENCLAW_LOG_LEVEL = cliLogLevel; + } if (!verbose) { process.env.NODE_NO_WARNINGS ??= "1"; } diff --git a/src/logging/console.ts b/src/logging/console.ts index dc28c3abfe9..b2b259565d1 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -3,6 +3,7 @@ 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"; @@ -71,10 +72,8 @@ function resolveConsoleSettings(): ConsoleSettings { } } } - const envLevel = process.env.OPENCLAW_LOG_LEVEL?.trim(); - const level = envLevel - ? normalizeLogLevel(envLevel, "info") - : normalizeConsoleLevel(cfg?.consoleLevel); + const envLevel = resolveEnvLogLevelOverride(); + const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel); const style = normalizeConsoleStyle(cfg?.consoleStyle); return { level, style }; } diff --git a/src/logging/env-log-level.ts b/src/logging/env-log-level.ts new file mode 100644 index 00000000000..6b3131d8742 --- /dev/null +++ b/src/logging/env-log-level.ts @@ -0,0 +1,23 @@ +import { ALLOWED_LOG_LEVELS, type LogLevel, tryParseLogLevel } from "./levels.js"; +import { loggingState } from "./state.js"; + +export function resolveEnvLogLevelOverride(): LogLevel | undefined { + const raw = process.env.OPENCLAW_LOG_LEVEL; + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (!trimmed) { + loggingState.invalidEnvLogLevelValue = null; + return undefined; + } + const parsed = tryParseLogLevel(trimmed); + if (parsed) { + loggingState.invalidEnvLogLevelValue = null; + return parsed; + } + if (loggingState.invalidEnvLogLevelValue !== trimmed) { + loggingState.invalidEnvLogLevelValue = trimmed; + process.stderr.write( + `[openclaw] Ignoring invalid OPENCLAW_LOG_LEVEL="${trimmed}" (allowed: ${ALLOWED_LOG_LEVELS.join("|")}).\n`, + ); + } + return undefined; +} diff --git a/src/logging/levels.ts b/src/logging/levels.ts index 0ea3608adf9..55448842f7f 100644 --- a/src/logging/levels.ts +++ b/src/logging/levels.ts @@ -10,9 +10,16 @@ export const ALLOWED_LOG_LEVELS = [ export type LogLevel = (typeof ALLOWED_LOG_LEVELS)[number]; +export function tryParseLogLevel(level?: string): LogLevel | undefined { + if (typeof level !== "string") { + return undefined; + } + const candidate = level.trim(); + return ALLOWED_LOG_LEVELS.includes(candidate as LogLevel) ? (candidate as LogLevel) : undefined; +} + export function normalizeLogLevel(level?: string, fallback: LogLevel = "info") { - const candidate = (level ?? fallback).trim(); - return ALLOWED_LOG_LEVELS.includes(candidate as LogLevel) ? (candidate as LogLevel) : fallback; + return tryParseLogLevel(level) ?? fallback; } export function levelToMinLevel(level: LogLevel): number { diff --git a/src/logging/logger-env.test.ts b/src/logging/logger-env.test.ts new file mode 100644 index 00000000000..979b13baa6b --- /dev/null +++ b/src/logging/logger-env.test.ts @@ -0,0 +1,78 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getResolvedConsoleSettings, + getResolvedLoggerSettings, + resetLogger, + setLoggerOverride, +} from "../logging.js"; +import { loggingState } from "./state.js"; + +const testLogPath = path.join(os.tmpdir(), "openclaw-test-env-log-level.log"); + +describe("OPENCLAW_LOG_LEVEL", () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.OPENCLAW_LOG_LEVEL; + delete process.env.OPENCLAW_LOG_LEVEL; + loggingState.invalidEnvLogLevelValue = null; + resetLogger(); + setLoggerOverride(null); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.OPENCLAW_LOG_LEVEL; + } else { + process.env.OPENCLAW_LOG_LEVEL = originalEnv; + } + loggingState.invalidEnvLogLevelValue = null; + resetLogger(); + setLoggerOverride(null); + vi.restoreAllMocks(); + }); + + it("applies a valid env override to both file and console levels", () => { + setLoggerOverride({ + level: "error", + consoleLevel: "warn", + consoleStyle: "json", + file: testLogPath, + }); + process.env.OPENCLAW_LOG_LEVEL = "debug"; + + expect(getResolvedLoggerSettings()).toEqual({ + level: "debug", + file: testLogPath, + }); + expect(getResolvedConsoleSettings()).toEqual({ + level: "debug", + style: "json", + }); + }); + + it("warns once and ignores invalid env values", () => { + setLoggerOverride({ + level: "error", + consoleLevel: "warn", + consoleStyle: "compact", + file: testLogPath, + }); + process.env.OPENCLAW_LOG_LEVEL = "nope"; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation( + () => true as unknown as ReturnType, // preserve stream contract in test spy + ); + + expect(getResolvedLoggerSettings().level).toBe("error"); + expect(getResolvedConsoleSettings().level).toBe("warn"); + expect(getResolvedLoggerSettings().level).toBe("error"); + + const warnings = stderrSpy.mock.calls + .map(([firstArg]) => String(firstArg)) + .filter((line) => line.includes("OPENCLAW_LOG_LEVEL")); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Ignoring invalid OPENCLAW_LOG_LEVEL="nope"'); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 4a4d2b3ce44..5f39952e56e 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -5,6 +5,7 @@ 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"; @@ -68,8 +69,8 @@ function resolveSettings(): ResolvedSettings { const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel); - const envLevel = process.env.OPENCLAW_LOG_LEVEL?.trim(); - const level = envLevel ? normalizeLogLevel(envLevel, fromConfig) : fromConfig; + const envLevel = resolveEnvLogLevelOverride(); + const level = envLevel ?? fromConfig; const file = cfg?.file ?? defaultRollingPathForToday(); return { level, file }; } diff --git a/src/logging/state.ts b/src/logging/state.ts index f45de04d2ee..3f620b75044 100644 --- a/src/logging/state.ts +++ b/src/logging/state.ts @@ -3,6 +3,7 @@ export const loggingState = { cachedSettings: null as unknown, cachedConsoleSettings: null as unknown, overrideSettings: null as unknown, + invalidEnvLogLevelValue: null as string | null, consolePatched: false, forceConsoleToStderr: false, consoleTimestampPrefix: false,