|
|
|
|
Quelle logger.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import fs from "node:fs";
import path from "node:path";
import { Logger as TsLogger } from "tslog";
import type { OpenClawConfig } from "../config/types.js";
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
import {
isValidDiagnosticSpanId,
isValidDiagnosticTraceFlags,
isValidDiagnosticTraceId,
type DiagnosticTraceContext,
} from "../infra/diagnostic-trace-context.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
POSIX_OPENCLAW_TMP_DIR,
resolvePreferredOpenClawTmpDir,
} from "../infra/tmp-openclaw-dir.js";
import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js";
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
import { resolveNodeRequireFromMeta } from "./node-require.js";
import { redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js";
import { formatTimestamp } from "./timestamps.js";
import type { LoggerSettings } from "./types.js";
export type { LoggerSettings } from "./types.js";
type ProcessWithBuiltinModule = NodeJS.Process & {
getBuiltinModule?: (id: string) => unknown;
};
function canUseNodeFs(): boolean {
const getBuiltinModule = (process as ProcessWithBuiltinModule).getBuiltinModule;
if (typeof getBuiltinModule !== "function") {
return false;
}
try {
return getBuiltinModule("fs") !== undefined;
} catch {
return false;
}
}
function resolveDefaultLogDir(): string {
return canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR;
}
function resolveDefaultLogFile(defaultLogDir: string): string {
return canUseNodeFs()
? path.join(defaultLogDir, "openclaw.log")
: `${POSIX_OPENCLAW_TMP_DIR}/openclaw.log`;
}
export const DEFAULT_LOG_DIR = resolveDefaultLogDir();
export const DEFAULT_LOG_FILE = resolveDefaultLogFile(DEFAULT_LOG_DIR); // legacy single-file path
const LOG_PREFIX = "openclaw";
const LOG_SUFFIX = ".log";
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
const DEFAULT_MAX_LOG_FILE_BYTES = 500 * 1024 * 1024; // 500 MB
const requireConfig = resolveNodeRequireFromMeta(import.meta.url);
type LogObj = { date?: Date } & Record<string, unknown>;
type ResolvedSettings = {
level: LogLevel;
file: string;
maxFileBytes: number;
};
export type LoggerResolvedSettings = ResolvedSettings;
type TsLogRecord = Record<string, unknown>;
type DiagnosticLogCode = {
line?: number;
functionName?: string;
};
const MAX_DIAGNOSTIC_LOG_BINDINGS_JSON_CHARS = 8 * 1024;
const MAX_DIAGNOSTIC_LOG_MESSAGE_CHARS = 4 * 1024;
const MAX_DIAGNOSTIC_LOG_ATTRIBUTE_COUNT = 32;
const MAX_DIAGNOSTIC_LOG_ATTRIBUTE_VALUE_CHARS = 2 * 1024;
const MAX_DIAGNOSTIC_LOG_NAME_CHARS = 120;
const DIAGNOSTIC_LOG_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,64}$/u;
type DiagnosticLogAttributes = Record<string, string | number | boolean>;
function clampDiagnosticLogText(value: string, maxChars: number): string {
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
}
function sanitizeDiagnosticLogText(value: string, maxChars: number): string {
return clampDiagnosticLogText(
redactSensitiveText(clampDiagnosticLogText(value, maxChars)),
maxChars,
);
}
function normalizeDiagnosticLogName(value: string | undefined): string | undefined {
if (!value || value.trim().startsWith("{")) {
return undefined;
}
const sanitized = sanitizeDiagnosticLogText(value.trim(), MAX_DIAGNOSTIC_LOG_NAME_CHARS);
return DIAGNOSTIC_LOG_ATTRIBUTE_KEY_RE.test(sanitized) ? sanitized : undefined;
}
function assignDiagnosticLogAttribute(
attributes: DiagnosticLogAttributes,
state: { count: number },
key: string,
value: unknown,
): void {
if (state.count >= MAX_DIAGNOSTIC_LOG_ATTRIBUTE_COUNT) {
return;
}
const normalizedKey = key.trim();
if (isBlockedObjectKey(normalizedKey)) {
return;
}
if (redactSensitiveText(normalizedKey) !== normalizedKey) {
return;
}
if (!DIAGNOSTIC_LOG_ATTRIBUTE_KEY_RE.test(normalizedKey)) {
return;
}
if (typeof value === "string") {
attributes[normalizedKey] = sanitizeDiagnosticLogText(
value,
MAX_DIAGNOSTIC_LOG_ATTRIBUTE_VALUE_CHARS,
);
state.count += 1;
return;
}
if (typeof value === "number" && Number.isFinite(value)) {
attributes[normalizedKey] = value;
state.count += 1;
return;
}
if (typeof value === "boolean") {
attributes[normalizedKey] = value;
state.count += 1;
}
}
function addDiagnosticLogAttributesFrom(
attributes: DiagnosticLogAttributes,
state: { count: number },
source: Record<string, unknown> | undefined,
): void {
if (!source) {
return;
}
for (const key in source) {
if (state.count >= MAX_DIAGNOSTIC_LOG_ATTRIBUTE_COUNT) {
break;
}
if (!Object.hasOwn(source, key) || key === "trace") {
continue;
}
assignDiagnosticLogAttribute(attributes, state, key, source[key]);
}
}
function isPlainLogRecordObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function normalizeTraceContext(value: unknown): DiagnosticTraceContext | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const candidate = value as Partial<DiagnosticTraceContext>;
if (!isValidDiagnosticTraceId(candidate.traceId)) {
return undefined;
}
if (candidate.spanId !== undefined && !isValidDiagnosticSpanId(candidate.spanId)) {
return undefined;
}
if (candidate.parentSpanId !== undefined && !isValidDiagnosticSpanId(candidate.parentSpanId)) {
return undefined;
}
if (candidate.traceFlags !== undefined && !isValidDiagnosticTraceFlags(candidate.traceFlags)) {
return undefined;
}
return {
traceId: candidate.traceId,
...(candidate.spanId ? { spanId: candidate.spanId } : {}),
...(candidate.parentSpanId ? { parentSpanId: candidate.parentSpanId } : {}),
...(candidate.traceFlags ? { traceFlags: candidate.traceFlags } : {}),
};
}
function extractTraceContext(value: unknown): DiagnosticTraceContext | undefined {
const direct = normalizeTraceContext(value);
if (direct) {
return direct;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return normalizeTraceContext((value as { trace?: unknown }).trace);
}
function findLogTraceContext(
bindings: Record<string, unknown> | undefined,
numericArgs: readonly unknown[],
): DiagnosticTraceContext | undefined {
const fromBindings = extractTraceContext(bindings);
if (fromBindings) {
return fromBindings;
}
for (const arg of numericArgs) {
const fromArg = extractTraceContext(arg);
if (fromArg) {
return fromArg;
}
}
return undefined;
}
function buildDiagnosticLogRecord(logObj: TsLogRecord) {
const meta = logObj._meta as
| {
logLevelName?: string;
date?: Date;
name?: string;
parentNames?: string[];
path?: {
filePath?: string;
fileLine?: string;
fileColumn?: string;
filePathWithLine?: string;
method?: string;
};
}
| undefined;
const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key))
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
.map(([, value]) => value);
let bindings: Record<string, unknown> | undefined;
if (
typeof numericArgs[0] === "string" &&
numericArgs[0].length <= MAX_DIAGNOSTIC_LOG_BINDINGS_JSON_CHARS &&
numericArgs[0].trim().startsWith("{")
) {
try {
const parsed = JSON.parse(numericArgs[0]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
bindings = parsed as Record<string, unknown>;
numericArgs.shift();
}
} catch {
// ignore malformed json bindings
}
}
const trace = findLogTraceContext(bindings, numericArgs);
const structuredArg = numericArgs[0];
const structuredBindings = isPlainLogRecordObject(structuredArg) ? structuredArg : undefined;
if (structuredBindings) {
numericArgs.shift();
}
let message = "";
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
message = sanitizeDiagnosticLogText(
String(numericArgs.pop()),
MAX_DIAGNOSTIC_LOG_MESSAGE_CHARS,
);
} else if (
numericArgs.length === 1 &&
(typeof numericArgs[0] === "number" || typeof numericArgs[0] === "boolean")
) {
message = String(numericArgs[0]);
numericArgs.length = 0;
}
if (!message) {
message = "log";
}
const attributes: DiagnosticLogAttributes = Object.create(null) as DiagnosticLogAttributes;
const attributeState = { count: 0 };
addDiagnosticLogAttributesFrom(attributes, attributeState, bindings);
addDiagnosticLogAttributesFrom(attributes, attributeState, structuredBindings);
const code: DiagnosticLogCode = {};
if (meta?.path?.fileLine) {
const line = Number(meta.path.fileLine);
if (Number.isFinite(line)) {
code.line = line;
}
}
if (meta?.path?.method) {
code.functionName = sanitizeDiagnosticLogText(meta.path.method, MAX_DIAGNOSTIC_LOG_NAME_CHARS);
}
const loggerName = normalizeDiagnosticLogName(meta?.name);
const loggerParents = meta?.parentNames
?.map(normalizeDiagnosticLogName)
.filter((name): name is string => Boolean(name));
return {
type: "log.record" as const,
level: meta?.logLevelName ?? "INFO",
message,
...(loggerName ? { loggerName } : {}),
...(loggerParents?.length ? { loggerParents } : {}),
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
...(Object.keys(code).length > 0 ? { code } : {}),
...(trace ? { trace } : {}),
};
}
function attachDiagnosticEventTransport(logger: TsLogger<LogObj>): void {
logger.attachTransport((logObj: LogObj) => {
try {
emitDiagnosticEvent(buildDiagnosticLogRecord(logObj as TsLogRecord));
} catch {
// never block on logging failures
}
});
}
function canUseSilentVitestFileLogFastPath(envLevel: LogLevel | undefined): boolean {
return (
process.env.VITEST === "true" &&
process.env.OPENCLAW_TEST_FILE_LOG !== "1" &&
!envLevel &&
!loggingState.overrideSettings
);
}
function resolveSettings(): ResolvedSettings {
if (!canUseNodeFs()) {
return {
level: "silent",
file: DEFAULT_LOG_FILE,
maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES,
};
}
const envLevel = resolveEnvLogLevelOverride();
// Test runs default file logs to silent. Skip config reads and fallback load in the
// common case to avoid pulling heavy config/schema stacks on startup.
if (canUseSilentVitestFileLogFastPath(envLevel)) {
return {
level: "silent",
file: defaultRollingPathForToday(),
maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES,
};
}
let cfg: OpenClawConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg && !shouldSkipMutatingLoggingConfigRead()) {
try {
const loaded = requireConfig?.("../config/config.js") as
| {
loadConfig?: () => OpenClawConfig;
}
| undefined;
cfg = loaded?.loadConfig?.().logging;
} catch {
cfg = undefined;
}
}
const defaultLevel =
process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info";
const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel);
const level = envLevel ?? fromConfig;
const file = cfg?.file ?? defaultRollingPathForToday();
const maxFileBytes = resolveMaxLogFileBytes(cfg?.maxFileBytes);
return { level, file, maxFileBytes };
}
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
if (!a) {
return true;
}
return a.level !== b.level || a.file !== b.file || a.maxFileBytes !== b.maxFileBytes;
}
export function isFileLogLevelEnabled(level: LogLevel): boolean {
const settings = (loggingState.cachedSettings as ResolvedSettings | null) ?? resolveSettings();
if (!loggingState.cachedSettings) {
loggingState.cachedSettings = settings;
}
if (level === "silent") {
return false;
}
if (settings.level === "silent") {
return false;
}
return levelToMinLevel(level) >= levelToMinLevel(settings.level);
}
function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
const logger = new TsLogger<LogObj>({
name: "openclaw",
minLevel: levelToMinLevel(settings.level),
type: "hidden", // no ansi formatting
});
// Silent logging does not write files; skip all filesystem setup in this path.
if (settings.level === "silent") {
attachDiagnosticEventTransport(logger);
return logger;
}
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
// Clean up stale rolling logs when using a dated log filename.
if (isRollingPath(settings.file)) {
pruneOldRollingLogs(path.dirname(settings.file));
}
let currentFileBytes = getCurrentLogFileBytes(settings.file);
let warnedAboutSizeCap = false;
logger.attachTransport((logObj: LogObj) => {
try {
const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" });
const line = JSON.stringify({ ...logObj, time });
const payload = `${line}\n`;
const payloadBytes = Buffer.byteLength(payload, "utf8");
const nextBytes = currentFileBytes + payloadBytes;
if (nextBytes > settings.maxFileBytes) {
if (!warnedAboutSizeCap) {
warnedAboutSizeCap = true;
const warningLine = JSON.stringify({
time: formatTimestamp(new Date(), { style: "long" }),
level: "warn",
subsystem: "logging",
message: `log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}`,
});
appendLogLine(settings.file, `${warningLine}\n`);
process.stderr.write(
`[openclaw] log file size cap reached; suppressing writes file=${settings.file} maxFileBytes=${settings.maxFileBytes}\n`,
);
}
return;
}
if (appendLogLine(settings.file, payload)) {
currentFileBytes = nextBytes;
}
} catch {
// never block on logging failures
}
});
attachDiagnosticEventTransport(logger);
return logger;
}
function resolveMaxLogFileBytes(raw: unknown): number {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.floor(raw);
}
return DEFAULT_MAX_LOG_FILE_BYTES;
}
function getCurrentLogFileBytes(file: string): number {
try {
return fs.statSync(file).size;
} catch {
return 0;
}
}
function appendLogLine(file: string, line: string): boolean {
try {
fs.appendFileSync(file, line, { encoding: "utf8" });
return true;
} catch {
return false;
}
}
export function getLogger(): TsLogger<LogObj> {
const settings = resolveSettings();
const cachedLogger = loggingState.cachedLogger as TsLogger<LogObj> | null;
const cachedSettings = loggingState.cachedSettings as ResolvedSettings | null;
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
loggingState.cachedLogger = buildLogger(settings);
loggingState.cachedSettings = settings;
}
return loggingState.cachedLogger as TsLogger<LogObj>;
}
export function getChildLogger(
bindings?: Record<string, unknown>,
opts?: { level?: LogLevel },
): TsLogger<LogObj> {
const base = getLogger();
const minLevel = opts?.level ? levelToMinLevel(opts.level) : base.settings.minLevel;
const name = bindings ? JSON.stringify(bindings) : undefined;
return base.getSubLogger({
name,
minLevel,
prefix: bindings ? [name ?? ""] : [],
});
}
// Baileys expects a pino-like logger shape. Provide a lightweight adapter.
export function toPinoLikeLogger(logger: TsLogger<LogObj>, level: LogLevel): PinoLikeLogger {
const buildChild = (bindings?: Record<string, unknown>) =>
toPinoLikeLogger(
logger.getSubLogger({
name: bindings ? JSON.stringify(bindings) : undefined,
minLevel: logger.settings.minLevel,
}),
level,
);
return {
level,
child: buildChild,
trace: (...args: unknown[]) => logger.trace(...args),
debug: (...args: unknown[]) => logger.debug(...args),
info: (...args: unknown[]) => logger.info(...args),
warn: (...args: unknown[]) => logger.warn(...args),
error: (...args: unknown[]) => logger.error(...args),
fatal: (...args: unknown[]) => logger.fatal(...args),
};
}
export type PinoLikeLogger = {
level: string;
child: (bindings?: Record<string, unknown>) => PinoLikeLogger;
trace: (...args: unknown[]) => void;
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
fatal: (...args: unknown[]) => void;
};
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
return resolveSettings();
}
// Test helpers
export function setLoggerOverride(settings: LoggerSettings | null) {
loggingState.overrideSettings = settings;
loggingState.cachedLogger = null;
loggingState.cachedSettings = null;
loggingState.cachedConsoleSettings = null;
}
export function resetLogger() {
loggingState.cachedLogger = null;
loggingState.cachedSettings = null;
loggingState.cachedConsoleSettings = null;
loggingState.overrideSettings = null;
}
export const __test__ = {
shouldSkipMutatingLoggingConfigRead,
};
function formatLocalDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultRollingPathForToday(): string {
const today = formatLocalDate(new Date());
return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`);
}
function isRollingPath(file: string): boolean {
const base = path.basename(file);
return (
base.startsWith(`${LOG_PREFIX}-`) &&
base.endsWith(LOG_SUFFIX) &&
base.length === `${LOG_PREFIX}-YYYY-MM-DD${LOG_SUFFIX}`.length
);
}
function pruneOldRollingLogs(dir: string): void {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const cutoff = Date.now() - MAX_LOG_AGE_MS;
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) {
continue;
}
const fullPath = path.join(dir, entry.name);
try {
const stat = fs.statSync(fullPath);
if (stat.mtimeMs < cutoff) {
fs.rmSync(fullPath, { force: true });
}
} catch {
// ignore errors during pruning
}
}
} catch {
// ignore missing dir or read errors
}
}
¤ Dauer der Verarbeitung: 0.21 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|