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 = 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 { 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 { 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 { 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; 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 { 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()): 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 = {}; for (const [key, item] of Object.entries(value as Record).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"; }