visual-journal / src /lib /app-logger.ts
gpt-image-playground deploy
Deploy 912aa68 to Docker Space
704e84f
Raw
History Blame Contribute Delete
11.3 kB
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<string, unknown> | 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<string, unknown>).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<string, unknown>).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<AppLogEntry>;
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<AppLogEntry[]> {
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;
}
}