Add backend log management
This commit is contained in:
@@ -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
210
lib/server/log-manager.ts
Normal 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";
|
||||
}
|
||||
@@ -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 })
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user