import pino, { type Logger as PinoLogger, type LoggerOptions } from "pino"; import { AppEnvUtil } from "./app-env"; import { reportSentryLog, type RemoteLogLevel, } from "./logger-sentry-reporter"; type LogArgs = unknown[]; type PinoLogArgs = Parameters; type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; /** * 全局 pino root logger(单例,进程级复用)。 * * 配置驱动(基于 `app-env.ts` 的 `AppEnvUtil`): * - production: silent(不输出日志) * - test/dev: debug 级别 + ISO 时间戳 * - browser: 保留原始 message 参数,避免多行日志被转义成 `\n` */ const rootLogger: PinoLogger = pino(createLoggerOptions()); function createLoggerOptions(): LoggerOptions { if (!AppEnvUtil.canOutputLogs()) { return { level: "silent" }; } if (AppEnvUtil.isTest()) { return { level: "debug", timestamp: pino.stdTimeFunctions.isoTime, browser: { asObjectBindingsOnly: true }, }; } // development return { level: "debug", timestamp: pino.stdTimeFunctions.isoTime, browser: { asObjectBindingsOnly: true }, }; } /** * Logger 工具类(pino 包装) * * 设计: * - 构造时绑定 `component` 名(业务模块标识) * - 所有日志自动带 `{ component: "AuthRepository" }` 字段 * - 透传 pino 原生 5 个 level:debug / info / warn / error / fatal * * 用法: * const log = new Logger("AuthRepository"); * log.info("login success"); * log.warn({ err }, "storage write failed"); * log.error(e, "consumer error"); * log.debug({ userId: 123 }, "fetched user"); */ export class Logger { private readonly logger: PinoLogger; private readonly component: string; constructor(component: string) { this.component = component; this.logger = rootLogger.child({ component }); } debug(...args: LogArgs): void { this.write("debug", args); } info(...args: LogArgs): void { this.write("info", args); } warn(...args: LogArgs): void { this.write("warn", args); } error(...args: LogArgs): void { this.write("error", args); } fatal(...args: LogArgs): void { this.write("fatal", args); } /** * 把日志中的任意数据转成人眼友好的多行文本。 * - object / array: pretty JSON * - JSON string: parse 后 pretty JSON * - FormData / File: 使用轻量描述,避免直接输出二进制 */ static formatValue(value: unknown): string { if (value === undefined || value === null) return ""; if (typeof FormData !== "undefined" && value instanceof FormData) { const entries: string[] = []; value.forEach((entryValue, key) => { if (typeof File !== "undefined" && entryValue instanceof File) { entries.push(`${key}=File(${entryValue.name})`); } else { entries.push(`${key}=${String(entryValue)}`); } }); return `FormData{\n ${entries.join(",\n ")}\n}`; } if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length === 0) return ""; try { return JSON.stringify(JSON.parse(trimmed), null, 2); } catch { return value; } } const serializableValue = Logger.toSerializableLogValue(value); if (serializableValue !== value) { try { return JSON.stringify(serializableValue, null, 2); } catch { return String(value); } } try { return JSON.stringify(serializableValue, null, 2); } catch { return String(value); } } private static normalizeLogArgs(args: LogArgs): PinoLogArgs { const [first, second, ...rest] = args; if (typeof first !== "string" || second === undefined) return args as PinoLogArgs; if (second instanceof Error) { return [{ err: second }, first, ...rest] as PinoLogArgs; } if ( typeof second === "object" && second !== null && !Array.isArray(second) ) { return [second, first, ...rest] as PinoLogArgs; } return args as PinoLogArgs; } private write(level: LogLevel, args: LogArgs): void { Logger.reportImportantProductionLog(level, this.component, args); if (Logger.shouldUseBrowserConsole()) { Logger.writeBrowserConsole(level, this.component, args); return; } switch (level) { case "debug": this.logger.debug(...Logger.normalizeLogArgs(args)); return; case "info": this.logger.info(...Logger.normalizeLogArgs(args)); return; case "warn": this.logger.warn(...Logger.normalizeLogArgs(args)); return; case "error": this.logger.error(...Logger.normalizeLogArgs(args)); return; case "fatal": this.logger.fatal(...Logger.normalizeLogArgs(args)); return; } } private static shouldUseBrowserConsole(): boolean { return typeof window !== "undefined" && AppEnvUtil.canOutputLogs(); } private static shouldReportToRemote(level: LogLevel): level is RemoteLogLevel { return AppEnvUtil.isProduction() && ( level === "warn" || level === "error" || level === "fatal" ); } private static reportImportantProductionLog( level: LogLevel, component: string, args: LogArgs, ): void { if (!Logger.shouldReportToRemote(level)) return; const { message, data } = Logger.toBrowserConsolePayload(args); reportSentryLog({ level, component, message, data, formatValue: Logger.formatValue, }); } private static writeBrowserConsole( level: LogLevel, component: string, args: LogArgs, ): void { const { message, data } = Logger.toBrowserConsolePayload(args); const prefix = `${new Date().toISOString()} ${level.toUpperCase()} [${component}]`; const method = level === "fatal" ? "error" : level; if (data.length === 0) { console[method](`${prefix} ${message}`); return; } console[method]( `${prefix} ${message}`, ...Logger.toBrowserConsoleData(data), ); } private static toBrowserConsolePayload(args: LogArgs): { message: string; data: unknown[]; } { const [first, second, ...rest] = args; if (typeof first === "string") { return { message: first, data: second === undefined ? rest : [second, ...rest], }; } if (typeof second === "string") { return { message: second, data: [first, ...rest], }; } return { message: "", data: args, }; } private static toBrowserConsoleData(data: unknown[]): unknown[] { return data.map((item) => Logger.toSerializableLogValue(item)); } private static toSerializableLogValue( value: unknown, seen = new WeakSet(), ): unknown { if (value instanceof Error) { return Logger.serializeError(value, seen); } if (Array.isArray(value)) { return value.map((item) => Logger.toSerializableLogValue(item, seen)); } if (typeof value !== "object" || value === null) { return value; } if (value instanceof Date) { return value.toISOString(); } if (seen.has(value)) { return "[Circular]"; } seen.add(value); const output: Record = {}; for (const [key, item] of Object.entries(value)) { output[key] = Logger.toSerializableLogValue(item, seen); } return output; } private static serializeError( error: Error, seen: WeakSet, ): Record { const output: Record = { name: error.name, message: error.message, stack: error.stack, }; if (seen.has(error)) { return output; } seen.add(error); for (const key of Object.getOwnPropertyNames(error)) { if (key in output) continue; output[key] = Logger.toSerializableLogValue( (error as unknown as Record)[key], seen, ); } return output; } /** 透传 raw pino logger(高级用法:自定义 binding / child / serializers) */ get raw(): PinoLogger { return this.logger; } }