feat(logging): add file log correlation fields

This commit is contained in:
Vincent Koc
2026-04-26 14:51:35 -07:00
parent 5c4c33c7de
commit e8df081a1f
2 changed files with 129 additions and 1 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
expect(record).toMatchObject({
agent_id: "agent-main",
session_id: "agent:main:discord:channel:c1",
channel: "discord",
message: "session routed",
});
});
});

View File

@@ -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<string, string | number | boolean>;
@@ -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<Record<string, unknown> | 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<string, unknown>;
args: unknown[];
@@ -265,6 +338,25 @@ function buildTraceFileLogFields(logObj: TsLogRecord): Record<string, string> |
};
}
function buildStructuredFileLogFields(logObj: TsLogRecord): Record<string, string> {
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<LogObj> {
}
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;