import { readAppLogRetentionMetadata as readRetentionMetadata } from './app-log-retention'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export { APP_LOG_DEFAULT_MAX_ENTRIES, APP_LOG_MAX_CONFIGURED_ENTRIES, APP_LOG_MAX_ENTRIES_ENV, APP_LOG_MIN_ENTRIES, APP_LOG_RETENTION_LOSS_MODES, APP_LOG_RETENTION_STORAGE, readAppLogRetentionMetadata, type AppLogRetentionMetadata } from './app-log-retention'; const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; const LOG_DIR = path.join(/* turbopackIgnore: true */ process.cwd(), 'generated-images', '.app-logs'); const DEFAULT_LOG_FILE_NAME = 'app.log.jsonl'; const TEST_LOG_DIR = path.join(os.tmpdir(), 'gpt-image-playground-app-logs'); const TEST_LOG_FILE_NAME = 'app-test.log.jsonl'; const testLogFileNameOverrideEnv = 'APP_LOG_TEST_FILE_NAME'; const testConsoleMirrorEnv = 'APP_LOG_TEST_CONSOLE_MIRROR'; type LogLevel = (typeof LOG_LEVELS)[number]; type LogContext = string | number | boolean | null | Record | unknown[]; export type AppLogEntry = { id: number; at: string; level: LogLevel; message: string; context?: string; clientRequestId?: string; filenames?: string[]; }; type CachedLogLevel = { configuredLevel: string | undefined; nodeEnv: string | undefined; level: LogLevel; }; let cachedLogLevel: CachedLogLevel | undefined; let nextLogId = 1; const logEntries: AppLogEntry[] = []; const logSubscribers = new Set<(entry: AppLogEntry) => void>(); let hydratedLogFile: string | undefined; let persistenceEnabledForTest = false; function resolveLogFile(): string { if (process.env.NODE_ENV === 'test') { const overrideName = process.env[testLogFileNameOverrideEnv]; const fileName = overrideName && path.basename(overrideName) === overrideName ? overrideName : TEST_LOG_FILE_NAME; return path.join(TEST_LOG_DIR, fileName); } return path.join(LOG_DIR, DEFAULT_LOG_FILE_NAME); } function shouldPersistEntries(): boolean { return process.env.NODE_ENV !== 'test' || persistenceEnabledForTest; } function shouldMirrorToConsole(): boolean { return process.env.NODE_ENV !== 'test' || process.env[testConsoleMirrorEnv] !== 'false'; } function defaultLogLevel(nodeEnv: string | undefined): LogLevel { return nodeEnv === 'production' ? 'warn' : 'info'; } function resolveLogLevel(configuredLevel: string | undefined, nodeEnv: string | undefined): LogLevel { if (!configuredLevel) { return defaultLogLevel(nodeEnv); } const normalizedLevel = configuredLevel.trim().toLowerCase(); return LOG_LEVELS.includes(normalizedLevel as LogLevel) ? (normalizedLevel as LogLevel) : defaultLogLevel(nodeEnv); } function readLogLevel(): LogLevel { const configuredLevel = process.env.APP_LOG_LEVEL; const nodeEnv = process.env.NODE_ENV; if (cachedLogLevel && cachedLogLevel.configuredLevel === configuredLevel && cachedLogLevel.nodeEnv === nodeEnv) { return cachedLogLevel.level; } const level = resolveLogLevel(configuredLevel, nodeEnv); cachedLogLevel = { configuredLevel, nodeEnv, level }; return level; } function canLog(level: LogLevel): boolean { return LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(readLogLevel()); } function readMaxLogEntries(): number { return readRetentionMetadata().max_entries; } function serializeContext(context: unknown): string | undefined { if (context === undefined) return undefined; if (context instanceof Error) { return context.stack || context.message; } if (typeof context === 'string') return context; try { return JSON.stringify(context, null, 2); } catch { return String(context); } } function readClientRequestId(context: unknown): string | undefined { if (!context || typeof context !== 'object' || Array.isArray(context) || context instanceof Error) { return undefined; } const value = (context as Record).clientRequestId; const normalized = typeof value === 'string' ? value.trim() : ''; return normalized || undefined; } function readFilenames(context: unknown): string[] | undefined { if (!context || typeof context !== 'object' || Array.isArray(context) || context instanceof Error) { return undefined; } const value = (context as Record).filenames; if (!Array.isArray(value)) return undefined; const filenames = normalizeStringArray(value); return filenames.length > 0 ? filenames : undefined; } function normalizeStringArray(value: unknown[]): string[] { return Array.from( new Set( value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter((item) => item.length > 0) ) ); } function parsePersistedEntry(line: string): AppLogEntry | undefined { try { const parsed = JSON.parse(line) as Partial; if ( typeof parsed.id !== 'number' || typeof parsed.at !== 'string' || !LOG_LEVELS.includes(parsed.level as LogLevel) || typeof parsed.message !== 'string' ) { return undefined; } const clientRequestId = readClientRequestId(parsed); const filenames = Array.isArray(parsed.filenames) ? normalizeStringArray(parsed.filenames) : []; return { id: parsed.id, at: parsed.at, level: parsed.level as LogLevel, message: parsed.message, ...(typeof parsed.context === 'string' ? { context: parsed.context } : {}), ...(clientRequestId ? { clientRequestId } : {}), ...(filenames.length > 0 ? { filenames } : {}) }; } catch { return undefined; } } function serializePersistedEntries(entries: AppLogEntry[]): string { return entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length > 0 ? '\n' : ''); } function compactEntries() { const maxEntries = readMaxLogEntries(); if (logEntries.length > maxEntries) { logEntries.splice(0, logEntries.length - maxEntries); } } function hydratePersistedEntries() { if (!shouldPersistEntries()) return; const logFile = resolveLogFile(); if (hydratedLogFile === logFile) return; hydratedLogFile = logFile; let fileContent: string; try { fileContent = fs.readFileSync(/* turbopackIgnore: true */ logFile, 'utf8'); } catch (error) { if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { return; } console.error('读取持久化日志失败。', error); return; } const persistedEntries = fileContent .split(/\r?\n/) .filter((line) => line.trim().length > 0) .map(parsePersistedEntry) .filter((entry): entry is AppLogEntry => entry !== undefined) .slice(-readMaxLogEntries()); logEntries.length = 0; logEntries.push(...persistedEntries); nextLogId = Math.max(0, ...persistedEntries.map((entry) => entry.id)) + 1; } function persistEntries(rewrite: boolean) { if (!shouldPersistEntries()) return; const logFile = resolveLogFile(); try { fs.mkdirSync(/* turbopackIgnore: true */ path.dirname(logFile), { recursive: true }); if (rewrite) { fs.writeFileSync(/* turbopackIgnore: true */ logFile, serializePersistedEntries(logEntries), 'utf8'); return; } fs.appendFileSync( /* turbopackIgnore: true */ logFile, JSON.stringify(logEntries[logEntries.length - 1]) + '\n', 'utf8' ); } catch (error) { console.error('写入持久化日志失败。', error); } } function appendLogEntry(level: LogLevel, message: string, context?: unknown) { hydratePersistedEntries(); const clientRequestId = readClientRequestId(context); const filenames = readFilenames(context); const entry: AppLogEntry = { id: nextLogId++, at: new Date().toISOString(), level, message, ...(context === undefined ? {} : { context: serializeContext(context) }), ...(clientRequestId ? { clientRequestId } : {}), ...(filenames ? { filenames } : {}) }; logEntries.push(entry); const needsRewrite = logEntries.length > readMaxLogEntries(); compactEntries(); persistEntries(needsRewrite); logSubscribers.forEach((subscriber) => { try { subscriber(entry); } catch (error) { console.error('日志订阅者处理失败。', error); } }); } function writeLog( level: LogLevel, writer: (message?: unknown, ...optionalParams: unknown[]) => void, message: string, context?: LogContext | unknown ) { if (!canLog(level)) { return; } appendLogEntry(level, message, context); if (!shouldMirrorToConsole()) { return; } if (context === undefined) { writer(message); return; } writer(message, context); } export const appLogger = { debug(message: string, context?: unknown) { writeLog('debug', console.debug, message, context); }, info(message: string, context?: unknown) { writeLog('info', console.info, message, context); }, warn(message: string, context?: unknown) { writeLog('warn', console.warn, message, context); }, error(message: string, context?: unknown) { writeLog('error', console.error, message, context); } }; export function readAppLogEntries(): AppLogEntry[] { hydratePersistedEntries(); return [...logEntries]; } export function subscribeAppLogs(subscriber: (entry: AppLogEntry) => void): () => void { hydratePersistedEntries(); logSubscribers.add(subscriber); return () => { logSubscribers.delete(subscriber); }; } export function clearAppLogEntriesForTest(options: { preservePersistedFile?: boolean } = {}) { logEntries.length = 0; logSubscribers.clear(); nextLogId = 1; const logFile = hydratedLogFile || resolveLogFile(); hydratedLogFile = undefined; if (!options.preservePersistedFile) { try { fs.rmSync(/* turbopackIgnore: true */ logFile, { force: true }); } catch { // 测试清理不影响生产路径。 } } } export function setAppLogPersistenceForTest(enabled: boolean) { persistenceEnabledForTest = enabled; hydratedLogFile = undefined; } export async function readPersistedAppLogEntriesForTest(): Promise { const logFile = resolveLogFile(); try { const fileContent = await fs.promises.readFile(/* turbopackIgnore: true */ logFile, 'utf8'); return fileContent .split(/\r?\n/) .filter((line) => line.trim().length > 0) .map(parsePersistedEntry) .filter((entry): entry is AppLogEntry => entry !== undefined); } catch (error) { if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { return []; } throw error; } }