Files
openclaw/src/logging/log-tail.ts
Peter Steinberger f5562748de fix(logging): give non-default profiles their own gateway log file (#112777)
* fix(logging): give non-default profiles their own gateway log file

* chore: defer profile log release note
2026-07-22 19:08:41 -04:00

179 lines
5.0 KiB
TypeScript

// Log tail helpers read recent log lines with optional parsing and redaction.
import fs from "node:fs/promises";
import path from "node:path";
import { readFileWindowFully } from "../infra/file-read.js";
import { clamp } from "../utils.js";
import { isRollingLogFilePath, isSameRollingLogFileFamily } from "./log-file-path.js";
import "./logger.js";
import { getResolvedLoggerFileTarget } from "./logger-settings-internal.js";
import { redactSensitiveLines, resolveRedactOptions } from "./redact.js";
// Tail reader for the active log file, with cursor reset and line redaction.
const DEFAULT_LIMIT = 500;
const DEFAULT_MAX_BYTES = 250_000;
const MAX_LIMIT = 5000;
const MAX_BYTES = 1_000_000;
/** Payload returned to log-tail callers with cursor and truncation metadata. */
export type LogTailPayload = {
file: string;
cursor: number;
size: number;
lines: string[];
truncated: boolean;
reset: boolean;
};
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
export async function resolveLogFile(
file: string,
options?: { rolling?: boolean },
): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
if (stat) {
return file;
}
if (!(options?.rolling ?? isRollingLogFilePath(file))) {
return file;
}
const dir = path.dirname(file);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) {
return file;
}
const candidates = await Promise.all(
entries
.filter((entry) => entry.isFile() && isSameRollingLogFileFamily(file, entry.name))
.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
const fileStat = await fs.stat(fullPath).catch(() => null);
return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null;
}),
);
const sorted = candidates
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
return sorted[0]?.path ?? file;
}
async function readLogSlice(params: {
file: string;
cursor?: number;
limit: number;
maxBytes: number;
}): Promise<Omit<LogTailPayload, "file">> {
const stat = await fs.stat(params.file).catch(() => null);
if (!stat) {
return {
cursor: 0,
size: 0,
lines: [],
truncated: false,
reset: false,
};
}
const size = stat.size;
const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES);
const limit = clamp(params.limit, 1, MAX_LIMIT);
let cursor =
typeof params.cursor === "number" && Number.isFinite(params.cursor)
? Math.max(0, Math.floor(params.cursor))
: undefined;
let reset = false;
let truncated = false;
let start;
if (cursor != null) {
if (cursor > size) {
// File rotated or shrank since the previous cursor; restart near the end.
reset = true;
start = Math.max(0, size - maxBytes);
truncated = start > 0;
} else {
start = cursor;
if (size - start > maxBytes) {
// Cursor is valid but too stale; cap reads and tell the caller state was reset.
reset = true;
truncated = true;
start = Math.max(0, size - maxBytes);
}
}
} else {
start = Math.max(0, size - maxBytes);
truncated = start > 0;
}
if (size === 0 || size <= start) {
return {
cursor: size,
size,
lines: [],
truncated,
reset,
};
}
const handle = await fs.open(params.file, "r");
try {
let prefix = "";
if (start > 0) {
const prefixBuf = Buffer.alloc(1);
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
}
const length = Math.max(0, size - start);
const buffer = Buffer.alloc(length);
const bytesRead = await readFileWindowFully(handle, buffer, start);
const text = buffer.toString("utf8", 0, bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {
// Drop the first partial line when starting in the middle of a file.
lines = lines.slice(1);
}
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (lines.length > limit) {
lines = lines.slice(lines.length - limit);
}
cursor = size;
return {
cursor,
size,
lines,
truncated,
reset,
};
} finally {
await handle.close();
}
}
/** Reads and redacts the configured log tail with bounded bytes and line count. */
export async function readConfiguredLogTail(params?: {
cursor?: number;
limit?: number;
maxBytes?: number;
}): Promise<LogTailPayload> {
const target = getResolvedLoggerFileTarget();
const file = await resolveLogFile(target.file, { rolling: target.rolling });
const result = await readLogSlice({
file,
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_LIMIT,
maxBytes: params?.maxBytes ?? DEFAULT_MAX_BYTES,
});
const redaction = resolveRedactOptions();
return {
file,
...result,
lines: redactSensitiveLines(result.lines, redaction),
};
}