fix(logging): make log-level override global and precedence-safe

This commit is contained in:
Peter Steinberger
2026-02-22 11:00:07 +01:00
parent 735a5db15b
commit 06aa7c0a54
15 changed files with 183 additions and 35 deletions

View File

@@ -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`

View File

@@ -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 <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 <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.

View File

@@ -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"],

View File

@@ -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 {

View File

@@ -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 <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",

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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 <name>",
"Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-<name>)",
)
.option(
"--log-level <level>",
`Global log level override for file + console (${CLI_LOG_LEVEL_VALUES})`,
parseCliLogLevelOption,
);
program.option("--no-color", "Disable ANSI colors", false);

View File

@@ -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<Record<string, unknown>>().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";
}

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<typeof process.stderr.write>, // 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"');
});
});

View File

@@ -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 };
}

View File

@@ -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,