Files
cozsweet-frontend-nextjs/src/utils/logger.ts
T
Codex 2e04260ebd
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
revert(payment): remove unsupported Stripe and EzPay upgrade
2026-07-29 18:15:20 +08:00

319 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (process.env.NODE_ENV === "test") {
return { level: "silent" };
}
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 个 leveldebug / 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;
}
}