From e8df081a1f1d8b7bd204072225b5262a7f724486 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 14:51:35 -0700 Subject: [PATCH] feat(logging): add file log correlation fields --- src/logging/logger-redaction-behavior.test.ts | 33 +++++++ src/logging/logger.ts | 97 ++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index 47ba0acbfd2..794b4d0092a 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -123,4 +123,37 @@ describe("file log redaction", () => { spanId: SPAN_ID, }); }); + + it("writes hostname and flattened message as top-level JSONL fields", () => { + const logPath = logPathTracker.nextPath(); + setLoggerOverride({ level: "info", file: logPath }); + + getLogger().info({ route: "/api/health" }, "request completed"); + + const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n"); + const record = JSON.parse(line ?? "{}") as Record; + expect(record.hostname).toEqual(expect.any(String)); + expect(record.hostname).not.toBe(""); + expect(record.message).toBe("request completed"); + }); + + it("promotes agent, session, and channel context to top-level JSONL fields", () => { + const logPath = logPathTracker.nextPath(); + setLoggerOverride({ level: "info", file: logPath }); + const logger = getChildLogger({ + agentId: "agent-main", + messageProvider: "discord", + }); + + logger.info({ sessionKey: "agent:main:discord:channel:c1" }, "session routed"); + + const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n"); + const record = JSON.parse(line ?? "{}") as Record; + expect(record).toMatchObject({ + agent_id: "agent-main", + session_id: "agent:main:discord:channel:c1", + channel: "discord", + message: "session routed", + }); + }); }); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 481ac95512f..ccd3de3c5b5 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { Logger as TsLogger } from "tslog"; import type { OpenClawConfig } from "../config/types.js"; @@ -79,7 +80,10 @@ 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 MAX_FILE_LOG_MESSAGE_CHARS = 4 * 1024; +const MAX_FILE_LOG_CONTEXT_VALUE_CHARS = 512; const DIAGNOSTIC_LOG_ATTRIBUTE_KEY_RE = /^[A-Za-z0-9_.:-]{1,64}$/u; +const HOSTNAME = os.hostname() || "unknown"; type DiagnosticLogAttributes = Record; @@ -210,6 +214,75 @@ function getSortedNumericLogArgs(logObj: TsLogRecord): unknown[] { .map(([, value]) => value); } +function clampFileLogText(value: string, maxChars: number): string { + return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value; +} + +function normalizeFileLogContextValue(value: unknown): string | undefined { + if (typeof value === "string") { + const normalized = value.trim(); + return normalized ? clampFileLogText(normalized, MAX_FILE_LOG_CONTEXT_VALUE_CHARS) : undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "boolean") { + return String(value); + } + return undefined; +} + +function readFirstContextString( + sources: Array | undefined>, + keys: readonly string[], +): string | undefined { + for (const source of sources) { + if (!source) { + continue; + } + for (const key of keys) { + const value = normalizeFileLogContextValue(source[key]); + if (value) { + return value; + } + } + } + return undefined; +} + +function stringifyFileLogMessagePart(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (value instanceof Error) { + return value.message || value.name; + } + if (isPlainLogRecordObject(value) && typeof value.message === "string") { + return value.message; + } + if (value === null || value === undefined) { + return undefined; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function buildFileLogMessage(numericArgs: readonly unknown[]): string | undefined { + const parts = numericArgs + .map(stringifyFileLogMessagePart) + .filter((part): part is string => Boolean(part && part.trim())); + if (parts.length === 0) { + return undefined; + } + return clampFileLogText(parts.join(" "), MAX_FILE_LOG_MESSAGE_CHARS); +} + function extractLogBindingPrefix(numericArgs: unknown[]): { bindings?: Record; args: unknown[]; @@ -265,6 +338,25 @@ function buildTraceFileLogFields(logObj: TsLogRecord): Record | }; } +function buildStructuredFileLogFields(logObj: TsLogRecord): Record { + const { bindings, args } = extractLogBindingPrefix(getSortedNumericLogArgs(logObj)); + const structuredArg = isPlainLogRecordObject(args[0]) ? args[0] : undefined; + const sources = [structuredArg, bindings, logObj]; + const messageArgs = + structuredArg && typeof structuredArg.message !== "string" ? args.slice(1) : args; + const message = buildFileLogMessage(messageArgs); + const agentId = readFirstContextString(sources, ["agent_id", "agentId"]); + const sessionId = readFirstContextString(sources, ["session_id", "sessionId", "sessionKey"]); + const channel = readFirstContextString(sources, ["channel", "messageProvider"]); + return { + hostname: HOSTNAME, + ...(message ? { message } : {}), + ...(agentId ? { agent_id: agentId } : {}), + ...(sessionId ? { session_id: sessionId } : {}), + ...(channel ? { channel } : {}), + }; +} + function buildDiagnosticLogRecord(logObj: TsLogRecord) { const meta = logObj._meta as | { @@ -447,7 +539,10 @@ function buildLogger(settings: ResolvedSettings): TsLogger { } const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" }); const traceFields = buildTraceFileLogFields(logObj as TsLogRecord); - const line = redactSensitiveText(JSON.stringify({ ...logObj, time, ...traceFields })); + const structuredFields = buildStructuredFileLogFields(logObj as TsLogRecord); + const line = redactSensitiveText( + JSON.stringify({ ...logObj, time, ...structuredFields, ...traceFields }), + ); const payload = `${line}\n`; const payloadBytes = Buffer.byteLength(payload, "utf8"); const nextBytes = currentFileBytes + payloadBytes;