Add backend log management

This commit is contained in:
inman
2026-06-03 11:49:45 +08:00
parent 13ddc66cfe
commit fb0229ba06
14 changed files with 804 additions and 2 deletions

View File

@@ -1,11 +1,30 @@
import { NextResponse } from "next/server";
import { recordAppLog } from "@/lib/server/log-manager";
export function jsonOk<T>(payload: T, init?: ResponseInit) {
return NextResponse.json(payload, init);
}
export function jsonError(error: unknown, status = 400) {
export type JsonErrorOptions = {
request?: Request;
source?: string;
details?: unknown;
logClientErrors?: boolean;
};
export async function jsonError(error: unknown, status = 400, options: JsonErrorOptions = {}) {
const resolvedStatus = statusFromError(error) || status;
if (resolvedStatus >= 500 || options.logClientErrors) {
await recordAppLog({
level: resolvedStatus >= 500 ? "error" : "warning",
source: options.source || "api",
message: error instanceof Error ? error.message : String(error),
error,
status: resolvedStatus,
request: options.request,
details: options.details
}).catch(() => undefined);
}
return NextResponse.json({
error: error instanceof Error ? error.message : String(error)
}, { status: resolvedStatus });

210
lib/server/log-manager.ts Normal file
View File

@@ -0,0 +1,210 @@
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";
}

View File

@@ -23,11 +23,16 @@ export function resultDir(): string {
return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results");
}
export function logDir(): string {
return process.env.ZHINIAN_LOG_DIR || join(runtimeDir(), "logs");
}
export async function ensureRuntimeDirs(): Promise<void> {
await Promise.all([
mkdir(dataDir(), { recursive: true }),
mkdir(uploadDir(), { recursive: true }),
mkdir(resultDir(), { recursive: true })
mkdir(resultDir(), { recursive: true }),
mkdir(logDir(), { recursive: true })
]);
}

View File

@@ -7,6 +7,7 @@ import {
} from "@/lib/server/data-store";
import { advanceImageJob } from "@/lib/server/generation-service";
import { advanceVideoJob } from "@/lib/server/video-generation-service";
import { recordAppLog } from "@/lib/server/log-manager";
import { requestOrigin } from "@/lib/server/runtime";
import { deliverJobWebhook } from "@/lib/server/webhook";
import type { GenerationJob, GenerationStatus } from "@/lib/types";
@@ -53,6 +54,19 @@ export async function runWorkerTick(input: {
action: settled.action
});
} catch (error) {
await recordAppLog({
level: "error",
source: "worker.job",
message: `任务处理失败:${job.id}`,
error,
details: {
jobId: job.id,
capability: job.capability,
provider: job.provider,
attempts: job.attempts,
workerId
}
}).catch(() => undefined);
const failed = await updateGenerationJob(job.id, {
status: "failed",
error: {