openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
273 lines (272 loc) • 10.3 kB
JavaScript
import { c as normalizeOptionalLowercaseString } from "./string-coerce-6kR-86XC.mjs";
import { c as isRecord } from "./record-coerce-TIsquoVR.mjs";
import { C as resolveRedactOptions, g as redactSensitiveLines } from "./redact-BEIE6NCT.mjs";
import { n as clamp } from "./utils-DUAwk2p5.mjs";
import { r as isMissingPathError } from "./errno-FR4CRyya.mjs";
import { d as isRollingLogFilePath, f as isSameRollingLogFileFamily, u as getResolvedLoggerFileTarget } from "./logger-DQckz-uQ.mjs";
import path from "node:path";
import fs from "node:fs/promises";
import { readFileWindowFully } from "@openclaw/fs-safe/advanced";
import { StringDecoder } from "node:string_decoder";
//#region src/logging/parse-log-line.ts
function extractMessage(value) {
const parts = [];
for (const key of Object.keys(value)) {
if (!/^\d+$/.test(key)) continue;
const item = value[key];
if (typeof item === "string") parts.push(item);
else if (item != null) parts.push(JSON.stringify(item));
}
return parts.join(" ");
}
function parseMetaName(raw) {
if (typeof raw !== "string" || !raw.trimStart().startsWith("{")) return {};
try {
const parsed = JSON.parse(raw);
return {
subsystem: typeof parsed.subsystem === "string" ? parsed.subsystem : void 0,
module: typeof parsed.module === "string" ? parsed.module : void 0,
plugin: typeof parsed.plugin === "string" ? parsed.plugin : void 0
};
} catch {
return {};
}
}
function resolveContext(value, meta) {
const metadataContext = parseMetaName(meta?.name);
if (meta?.name === value["0"]) return metadataContext;
const positionalContext = parseMetaName(value["0"]);
return {
subsystem: metadataContext.subsystem ?? positionalContext.subsystem,
module: metadataContext.module ?? positionalContext.module,
plugin: metadataContext.plugin ?? positionalContext.plugin
};
}
/** Parses a raw log line into compact metadata and message text, or null for non-JSON lines. */
function parseLogLine(raw) {
try {
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) return null;
const meta = isRecord(parsed["_meta"]) ? parsed["_meta"] : void 0;
const context = resolveContext(parsed, meta);
const levelRaw = typeof meta?.logLevelName === "string" ? meta.logLevelName : parsed.level;
return {
time: typeof parsed.time === "string" ? parsed.time : typeof meta?.date === "string" ? meta.date : void 0,
level: normalizeOptionalLowercaseString(levelRaw),
subsystem: context.subsystem ?? (typeof parsed.subsystem === "string" ? parsed.subsystem : void 0),
module: context.module,
plugin: context.plugin,
message: typeof parsed.message === "string" ? parsed.message : extractMessage(parsed),
raw
};
} catch {
return null;
}
}
//#endregion
//#region src/logging/log-tail.ts
const DEFAULT_LIMIT = 500;
const DEFAULT_MAX_BYTES = 25e4;
const MAX_LIMIT = 5e3;
const MAX_BYTES = 1e6;
function missingPathToNull(error) {
if (!isMissingPathError(error)) throw error;
return null;
}
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
async function resolveLogFile(file, options) {
const stat = await fs.stat(file).catch(missingPathToNull);
const source = {
file,
stat
};
if (stat || !(options?.rolling ?? isRollingLogFilePath(file))) return source;
const dir = path.dirname(file);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(missingPathToNull);
if (!entries) return source;
return (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(missingPathToNull);
return fileStat ? {
file: fullPath,
stat: fileStat
} : null;
}))).filter((entry) => Boolean(entry)).toSorted((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)[0] ?? source;
}
async function readLogSlice(params) {
const { stat } = params;
if (stat && !stat.isFile()) throw new Error(`Log path is not a regular file: ${params.file}`);
const size = stat?.size ?? 0;
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)) : void 0;
let reset = false;
let skippedBytes;
let truncated = false;
let start;
if (cursor != null) {
if (cursor > size) {
reset = true;
start = Math.max(0, size - maxBytes);
truncated = start > 0;
} else {
start = cursor;
if (size - start > maxBytes) {
reset = true;
truncated = true;
const boundedStart = Math.max(0, size - maxBytes);
skippedBytes = boundedStart - start;
start = boundedStart;
}
}
} else {
start = Math.max(0, size - maxBytes);
truncated = start > 0;
}
if (size === 0 || size <= start) return {
cursor: size,
size,
lines: [],
truncated,
reset,
skippedBytes
};
const handle = await fs.open(params.file, "r").catch(missingPathToNull);
if (!handle) return {
cursor: 0,
size: 0,
lines: [],
truncated: false,
reset: cursor != null && cursor > 0
};
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);
let lines = buffer.toString("utf8", 0, bytesRead).split("\n");
lines.pop();
let lineOffset = 0;
if (start > 0 && prefix !== "\n") {
lines.shift();
lineOffset = buffer.subarray(0, bytesRead).indexOf(10) + 1;
}
let selected = lines.map((line) => params.filter?.(line) ?? true);
let selectedCount = selected.filter(Boolean).length;
if (selectedCount > limit) {
truncated = true;
for (let index = 0; selectedCount > limit; index += 1) if (selected[index]) {
selected[index] = false;
selectedCount -= 1;
}
}
const firstSelected = selected.indexOf(true);
if (firstSelected > 0) {
for (let index = 0; index < firstSelected; index += 1) lineOffset = buffer.indexOf(10, lineOffset) + 1;
lines = lines.slice(firstSelected);
selected = selected.slice(firstSelected);
}
const contexts = params.redaction.patterns.map((pattern) => pattern instanceof RegExp ? void 0 : pattern.createContext?.());
let pending = contexts.filter((context) => context !== void 0);
let contextEnd = start + lineOffset;
if (selectedCount > 0 && contextEnd > 0 && pending.length > 0) {
const prefixBuffer = Buffer.alloc(Math.min(contextEnd, DEFAULT_MAX_BYTES));
let blockBytes = Math.min(4096, prefixBuffer.length);
let cached;
const readPrefix = async (offset, byteCount) => {
if (offset >= start && offset + byteCount <= start + bytesRead) return buffer.subarray(offset - start, offset - start + byteCount);
if (cached && offset >= cached.offset && offset + byteCount <= cached.offset + cached.length) return prefixBuffer.subarray(offset - cached.offset, offset - cached.offset + byteCount);
const chunk = prefixBuffer.subarray(0, byteCount);
if (await readFileWindowFully(handle, chunk, offset) !== byteCount) throw new Error("Log file changed while reading redaction context");
cached = {
offset,
length: byteCount
};
return chunk;
};
while (contextEnd > 0 && pending.length > 0) {
let search = Math.max(0, contextEnd - blockBytes);
let chunk = await readPrefix(search, contextEnd - search);
let newline = chunk.indexOf(10);
let blockStart = search === 0 ? 0 : search + newline + 1;
while (search > 0 && (newline < 0 || blockStart === contextEnd)) {
const end = search;
blockBytes = Math.min(prefixBuffer.length, blockBytes * 2);
search = Math.max(0, end - blockBytes);
chunk = await readPrefix(search, end - search);
newline = chunk.lastIndexOf(10);
blockStart = search === 0 && newline < 0 ? 0 : search + newline + 1;
}
const blocks = pending.map((context) => ({
context,
block: context.prepend()
}));
const decoder = new StringDecoder("utf8");
for (let offset = blockStart; offset < contextEnd;) {
const byteCount = Math.min(prefixBuffer.length, contextEnd - offset);
const prefixText = decoder.write(await readPrefix(offset, byteCount));
for (const { block } of blocks) block.consume(prefixText);
offset += byteCount;
}
const finalText = decoder.end();
for (const { block } of blocks) block.consume(finalText);
pending = blocks.filter(({ block }) => !block.finish()).map(({ context }) => context);
contextEnd = blockStart;
blockBytes = Math.min(prefixBuffer.length, blockBytes * 2);
}
}
const lastNewline = buffer.subarray(0, bytesRead).lastIndexOf(10);
cursor = start + lastNewline + 1;
return {
cursor,
size,
lines: selectedCount === 0 ? [] : redactSensitiveLines(lines, {
...params.redaction,
patterns: params.redaction.patterns.map((pattern, index) => contexts[index]?.pattern ?? pattern)
}, selected),
truncated,
reset,
skippedBytes
};
} finally {
await handle.close();
}
}
/** Reads and redacts the configured log tail with bounded bytes and line count. */
async function readConfiguredLogTail(params, filter) {
const target = getResolvedLoggerFileTarget();
const { file, stat } = await resolveLogFile(target.file, { rolling: target.rolling });
return {
file,
...await readLogSlice({
file,
stat,
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_LIMIT,
maxBytes: params?.maxBytes ?? DEFAULT_MAX_BYTES,
filter,
redaction: resolveRedactOptions()
})
};
}
/** Reads the canonical configured tail and parses its already-redacted lines. */
async function readConfiguredParsedLogTail(params) {
const tail = await readConfiguredLogTail(params, (raw) => {
const parsed = parseLogLine(raw);
return parsed !== null && (params?.filter?.(parsed) ?? true);
});
return {
...tail,
lines: tail.lines.flatMap((line) => {
const parsed = parseLogLine(line);
return parsed ? [parsed] : [];
})
};
}
//#endregion
export { readConfiguredParsedLogTail as n, parseLogLine as r, readConfiguredLogTail as t };