mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Feat/logger support log level validation0222 (#23436)
* 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>
This commit is contained in:
@@ -82,6 +82,12 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit
|
|||||||
| `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). |
|
| `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). |
|
||||||
| `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). |
|
| `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). |
|
||||||
|
|
||||||
|
## 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. Invalid values are ignored with a warning. |
|
||||||
|
|
||||||
### `OPENCLAW_HOME`
|
### `OPENCLAW_HOME`
|
||||||
|
|
||||||
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for all internal path resolution. This enables full filesystem isolation for headless service accounts.
|
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for all internal path resolution. This enables full filesystem isolation for headless service accounts.
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ All logging configuration lives under `logging` in `~/.openclaw/openclaw.json`.
|
|||||||
- `logging.level`: **file logs** (JSONL) level.
|
- `logging.level`: **file logs** (JSONL) level.
|
||||||
- `logging.consoleLevel`: **console** verbosity 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`. 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.
|
`--verbose` only affects console output; it does not change file log levels.
|
||||||
|
|
||||||
### Console styles
|
### Console styles
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ describe("argv helpers", () => {
|
|||||||
argv: ["node", "openclaw", "--profile", "work", "-v"],
|
argv: ["node", "openclaw", "--profile", "work", "-v"],
|
||||||
expected: true,
|
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",
|
name: "subcommand -v should not be treated as version",
|
||||||
argv: ["node", "openclaw", "acp", "-v"],
|
argv: ["node", "openclaw", "acp", "-v"],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const HELP_FLAGS = new Set(["-h", "--help"]);
|
|||||||
const VERSION_FLAGS = new Set(["-V", "--version"]);
|
const VERSION_FLAGS = new Set(["-V", "--version"]);
|
||||||
const ROOT_VERSION_ALIAS_FLAG = "-v";
|
const ROOT_VERSION_ALIAS_FLAG = "-v";
|
||||||
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
|
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 = "--";
|
const FLAG_TERMINATOR = "--";
|
||||||
|
|
||||||
export function hasHelpOrVersion(argv: string[]): boolean {
|
export function hasHelpOrVersion(argv: string[]): boolean {
|
||||||
|
|||||||
13
src/cli/log-level-option.test.ts
Normal file
13
src/cli/log-level-option.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/cli/log-level-option.ts
Normal file
12
src/cli/log-level-option.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { escapeRegExp } from "../../utils.js";
|
|||||||
import { hasFlag, hasRootVersionAlias } from "../argv.js";
|
import { hasFlag, hasRootVersionAlias } from "../argv.js";
|
||||||
import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js";
|
import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js";
|
||||||
import { replaceCliName, resolveCliName } from "../cli-name.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 { getCoreCliCommandsWithSubcommands } from "./command-registry.js";
|
||||||
import type { ProgramContext } from "./context.js";
|
import type { ProgramContext } from "./context.js";
|
||||||
import { getSubCliCommandsWithSubcommands } from "./register.subclis.js";
|
import { getSubCliCommandsWithSubcommands } from "./register.subclis.js";
|
||||||
@@ -54,6 +55,11 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
|||||||
.option(
|
.option(
|
||||||
"--profile <name>",
|
"--profile <name>",
|
||||||
"Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-<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);
|
program.option("--no-color", "Disable ANSI colors", false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { setVerbose } from "../../globals.js";
|
import { setVerbose } from "../../globals.js";
|
||||||
import { isTruthyEnvValue } from "../../infra/env.js";
|
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||||
|
import type { LogLevel } from "../../logging/levels.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
|
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
|
||||||
import { emitCliBanner } from "../banner.js";
|
import { emitCliBanner } from "../banner.js";
|
||||||
@@ -22,6 +23,26 @@ function setProcessTitleForCommand(actionCommand: Command) {
|
|||||||
// Commands that need channel plugins loaded
|
// Commands that need channel plugins loaded
|
||||||
const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]);
|
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) {
|
export function registerPreActionHooks(program: Command, programVersion: string) {
|
||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
setProcessTitleForCommand(actionCommand);
|
setProcessTitleForCommand(actionCommand);
|
||||||
@@ -40,6 +61,10 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
|||||||
}
|
}
|
||||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||||
setVerbose(verbose);
|
setVerbose(verbose);
|
||||||
|
const cliLogLevel = getCliLogLevel(actionCommand);
|
||||||
|
if (cliLogLevel) {
|
||||||
|
process.env.OPENCLAW_LOG_LEVEL = cliLogLevel;
|
||||||
|
}
|
||||||
if (!verbose) {
|
if (!verbose) {
|
||||||
process.env.NODE_NO_WARNINGS ??= "1";
|
process.env.NODE_NO_WARNINGS ??= "1";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.js";
|
|||||||
import { isVerbose } from "../globals.js";
|
import { isVerbose } from "../globals.js";
|
||||||
import { stripAnsi } from "../terminal/ansi.js";
|
import { stripAnsi } from "../terminal/ansi.js";
|
||||||
import { readLoggingConfig } from "./config.js";
|
import { readLoggingConfig } from "./config.js";
|
||||||
|
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
|
||||||
import { type LogLevel, normalizeLogLevel } from "./levels.js";
|
import { type LogLevel, normalizeLogLevel } from "./levels.js";
|
||||||
import { getLogger, type LoggerSettings } from "./logger.js";
|
import { getLogger, type LoggerSettings } from "./logger.js";
|
||||||
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
||||||
@@ -71,7 +72,8 @@ function resolveConsoleSettings(): ConsoleSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const level = normalizeConsoleLevel(cfg?.consoleLevel);
|
const envLevel = resolveEnvLogLevelOverride();
|
||||||
|
const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel);
|
||||||
const style = normalizeConsoleStyle(cfg?.consoleStyle);
|
const style = normalizeConsoleStyle(cfg?.consoleStyle);
|
||||||
return { level, style };
|
return { level, style };
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/logging/env-log-level.ts
Normal file
23
src/logging/env-log-level.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -10,9 +10,16 @@ export const ALLOWED_LOG_LEVELS = [
|
|||||||
|
|
||||||
export type LogLevel = (typeof ALLOWED_LOG_LEVELS)[number];
|
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") {
|
export function normalizeLogLevel(level?: string, fallback: LogLevel = "info") {
|
||||||
const candidate = (level ?? fallback).trim();
|
return tryParseLogLevel(level) ?? fallback;
|
||||||
return ALLOWED_LOG_LEVELS.includes(candidate as LogLevel) ? (candidate as LogLevel) : fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function levelToMinLevel(level: LogLevel): number {
|
export function levelToMinLevel(level: LogLevel): number {
|
||||||
|
|||||||
78
src/logging/logger-env.test.ts
Normal file
78
src/logging/logger-env.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/types.js";
|
|||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { readLoggingConfig } from "./config.js";
|
import { readLoggingConfig } from "./config.js";
|
||||||
import type { ConsoleStyle } from "./console.js";
|
import type { ConsoleStyle } from "./console.js";
|
||||||
|
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
|
||||||
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
|
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
|
||||||
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
||||||
import { loggingState } from "./state.js";
|
import { loggingState } from "./state.js";
|
||||||
@@ -67,7 +68,9 @@ function resolveSettings(): ResolvedSettings {
|
|||||||
}
|
}
|
||||||
const defaultLevel =
|
const defaultLevel =
|
||||||
process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info";
|
process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info";
|
||||||
const level = normalizeLogLevel(cfg?.level, defaultLevel);
|
const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel);
|
||||||
|
const envLevel = resolveEnvLogLevelOverride();
|
||||||
|
const level = envLevel ?? fromConfig;
|
||||||
const file = cfg?.file ?? defaultRollingPathForToday();
|
const file = cfg?.file ?? defaultRollingPathForToday();
|
||||||
return { level, file };
|
return { level, file };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const loggingState = {
|
|||||||
cachedSettings: null as unknown,
|
cachedSettings: null as unknown,
|
||||||
cachedConsoleSettings: null as unknown,
|
cachedConsoleSettings: null as unknown,
|
||||||
overrideSettings: null as unknown,
|
overrideSettings: null as unknown,
|
||||||
|
invalidEnvLogLevelValue: null as string | null,
|
||||||
consolePatched: false,
|
consolePatched: false,
|
||||||
forceConsoleToStderr: false,
|
forceConsoleToStderr: false,
|
||||||
consoleTimestampPrefix: false,
|
consoleTimestampPrefix: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user