Files
NianAIGC/lib/server/log-manager.ts
2026-06-03 12:03:14 +08:00

211 lines
6.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { logDir } from "@/lib/server/runtime";
export type AppLogLevel = "info" | "warning" | "error";
export type AppLogEntry = {
id: string;
createdAt: string;
level: AppLogLevel;
source: string;
message: string;
status?: number;
method?: string;
path?: string;
stack?: string;
details?: unknown;
};
export type AppLogInput = {
level?: AppLogLevel;
source: string;
message?: string;
error?: unknown;
status?: number;
request?: Request;
details?: unknown;
};
export type AppLogListFilters = {
level?: AppLogLevel | "all";
q?: string;
source?: string;
limit?: number;
};
const LOG_FILE_NAME = "server-events.jsonl";
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
const MAX_ENTRY_TEXT_LENGTH = 20_000;
let writeQueue: Promise<unknown> = Promise.resolve();
export function appLogFilePath(): string {
return join(logDir(), LOG_FILE_NAME);
}
export function appLogMaxBytes(): number {
const parsed = Number(process.env.ZHINIAN_LOG_MAX_BYTES);
return Number.isFinite(parsed) && parsed > 1024 ? Math.floor(parsed) : DEFAULT_MAX_BYTES;
}
export async function recordAppLog(input: AppLogInput): Promise<AppLogEntry> {
const entry = normalizeLogEntry(input);
writeQueue = writeQueue
.catch(() => undefined)
.then(async () => {
await mkdir(logDir(), { recursive: true });
await rotateLogIfNeeded();
await appendFile(appLogFilePath(), `${JSON.stringify(entry)}\n`, "utf8");
});
await writeQueue;
return entry;
}
export async function listAppLogs(filters: AppLogListFilters = {}): Promise<AppLogEntry[]> {
const limit = Math.max(1, Math.min(filters.limit || 100, 500));
const level = filters.level || "all";
const q = (filters.q || "").trim().toLowerCase();
const source = (filters.source || "").trim().toLowerCase();
const filePath = appLogFilePath();
let text = "";
try {
text = await readFile(filePath, "utf8");
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
const entries: AppLogEntry[] = [];
const lines = text.split("\n").filter(Boolean).reverse();
for (const line of lines) {
const entry = parseLogLine(line);
if (!entry) continue;
if (level !== "all" && entry.level !== level) continue;
if (source && !entry.source.toLowerCase().includes(source)) continue;
if (q && !logEntryMatches(entry, q)) continue;
entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
export async function clearAppLogs(): Promise<void> {
await mkdir(logDir(), { recursive: true });
await writeFile(appLogFilePath(), "", "utf8");
}
export function normalizeLogEntry(input: AppLogInput): AppLogEntry {
const error = input.error;
const requestInfo = input.request ? requestLogInfo(input.request) : {};
const message = sanitizeText(input.message || errorMessage(error) || "Unknown log event");
return {
id: `log_${randomUUID().replace(/-/g, "").slice(0, 18)}`,
createdAt: new Date().toISOString(),
level: input.level || (input.status && input.status >= 500 ? "error" : "info"),
source: sanitizeText(input.source || "server"),
message,
status: input.status,
method: requestInfo.method,
path: requestInfo.path,
stack: errorStack(error),
details: sanitizeForLog(input.details)
};
}
function parseLogLine(line: string): AppLogEntry | null {
try {
const parsed = JSON.parse(line) as Partial<AppLogEntry>;
if (!parsed.id || !parsed.createdAt || !parsed.level || !parsed.source || !parsed.message) return null;
if (!["info", "warning", "error"].includes(parsed.level)) return null;
return parsed as AppLogEntry;
} catch {
return null;
}
}
async function rotateLogIfNeeded(): Promise<void> {
const filePath = appLogFilePath();
try {
const current = await stat(filePath);
if (current.size < appLogMaxBytes()) return;
await rename(filePath, `${filePath}.1`);
} catch (error) {
if (!isNotFound(error)) throw error;
}
}
function requestLogInfo(request: Request): { method?: string; path?: string } {
try {
const url = new URL(request.url);
return {
method: request.method,
path: `${url.pathname}${url.search}`
};
} catch {
return { method: request.method };
}
}
function logEntryMatches(entry: AppLogEntry, q: string): boolean {
const haystack = [
entry.message,
entry.source,
entry.path,
entry.method,
entry.status?.toString(),
entry.stack,
typeof entry.details === "string" ? entry.details : JSON.stringify(entry.details || "")
].join(" ").toLowerCase();
return haystack.includes(q);
}
function errorMessage(error: unknown): string | undefined {
if (!error) return undefined;
if (error instanceof Error) return error.message;
return String(error);
}
function errorStack(error: unknown): string | undefined {
if (!(error instanceof Error) || !error.stack) return undefined;
return truncateText(sanitizeText(error.stack));
}
function sanitizeForLog(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") return truncateText(sanitizeText(value));
if (typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "bigint") return value.toString();
if (typeof value !== "object") return sanitizeText(String(value));
if (depth >= 5) return "[truncated]";
if (seen.has(value)) return "[circular]";
seen.add(value);
if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeForLog(item, depth + 1, seen));
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>).slice(0, 80)) {
output[key] = isSensitiveKey(key) ? "[redacted]" : sanitizeForLog(item, depth + 1, seen);
}
return output;
}
function sanitizeText(text: string): string {
return truncateText(text)
.replace(/(authorization\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
.replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]")
.replace(/((?:api[_-]?key|access[_-]?key|secret|token|password|client[_-]?secret)\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt-redacted]");
}
function truncateText(text: string): string {
return text.length > MAX_ENTRY_TEXT_LENGTH ? `${text.slice(0, MAX_ENTRY_TEXT_LENGTH)}...[truncated]` : text;
}
function isSensitiveKey(key: string): boolean {
return /api[_-]?key|access[_-]?key|secret|token|password|authorization|credential/i.test(key);
}
function isNotFound(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT";
}