211 lines
6.8 KiB
TypeScript
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";
|
|
}
|