File size: 10,224 Bytes
fc93158 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | 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 {
const envLevel = resolveEnvLogLevelOverride();
// Test runs default to silent console logging unless explicitly overridden.
// Skip config-file and full config fallback reads in this fast path.
if (
process.env.VITEST === "true" &&
process.env.OPENCLAW_TEST_CONSOLE !== "1" &&
!isVerbose() &&
!envLevel &&
!loggingState.overrideSettings
) {
return { level: "silent", style: normalizeConsoleStyle(undefined) };
}
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 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);
}
|