efd89de0b0
This reverts commit b59da8f7e4.
316 lines
8.0 KiB
TypeScript
316 lines
8.0 KiB
TypeScript
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<PinoLogger["debug"]>;
|
||
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<object>(),
|
||
): 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<string, unknown> = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
output[key] = Logger.toSerializableLogValue(item, seen);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
private static serializeError(
|
||
error: Error,
|
||
seen: WeakSet<object>,
|
||
): Record<string, unknown> {
|
||
const output: Record<string, unknown> = {
|
||
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<string, unknown>)[key],
|
||
seen,
|
||
);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
/** 透传 raw pino logger(高级用法:自定义 binding / child / serializers) */
|
||
get raw(): PinoLogger {
|
||
return this.logger;
|
||
}
|
||
}
|