From a208fcc601e23d5ecd98960e79fad0563de10c1b Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:44:49 +0800 Subject: [PATCH] feat(logging): add pino logger with createModuleLogger --- src/shared/lib/logger.test.ts | 87 +++++++++++++++++++++++++++++++++++ src/shared/lib/logger.ts | 42 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/shared/lib/logger.test.ts create mode 100644 src/shared/lib/logger.ts diff --git a/src/shared/lib/logger.test.ts b/src/shared/lib/logger.test.ts new file mode 100644 index 0000000..ce17c43 --- /dev/null +++ b/src/shared/lib/logger.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest" +import { pino, type Logger } from "pino" +import { Writable } from "node:stream" + +// mock @/env.mjs:避免 jsdom 环境下 @t3-oss/env 拦截 server-side env 访问。 +// logger.ts 模块顶层访问 env.LOG_LEVEL / env.NODE_ENV,必须先 mock 才能导入。 +vi.mock("@/env.mjs", () => ({ + env: { + LOG_LEVEL: "info", + NODE_ENV: "test", + }, +})) + +import { createModuleLogger } from "./logger" +import { getRequestContext, requestContextStorage } from "./request-context" + +/** + * 创建独立 pino 实例(无 transport、同步写入内存流), + * 用于验证 level 行为,避免 transport 异步写入导致 stdout 捕获不稳定。 + */ +function createMemoryLogger(): { logInstance: Logger; lines: string[] } { + const lines: string[] = [] + const stream = new Writable({ + write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + if (chunk instanceof Uint8Array) { + lines.push(Buffer.from(chunk).toString()) + } else if (typeof chunk === "string") { + lines.push(chunk) + } + callback() + }, + }) + const logInstance = pino( + { + level: "info", + base: { service: "cicd-app" }, + formatters: { + level: (label: string): { level: string } => ({ level: label }), + }, + mixin: (): Record => getRequestContext(), + }, + stream, + ) + return { logInstance, lines } +} + +describe("logger", () => { + describe("createModuleLogger", () => { + it("返回的 child logger 包含 module 字段", () => { + const moduleLogger = createModuleLogger("audit") + // bindings 继承父 logger 的 base(service),故用 toMatchObject 检查 module 字段 + expect(moduleLogger.bindings()).toMatchObject({ module: "audit" }) + }) + + it("不同 module 返回不同 child logger", () => { + const a = createModuleLogger("a") + const b = createModuleLogger("b") + expect(a).not.toBe(b) + expect(a.bindings()).toMatchObject({ module: "a" }) + expect(b.bindings()).toMatchObject({ module: "b" }) + }) + }) + + describe("mixin 注入 requestId", () => { + it("在 requestContextStorage.run 内 mixin 返回 requestId", () => { + // logger.ts 的 mixin 调用 getRequestContext(),直接验证其返回值 + const ctx = requestContextStorage.run({ requestId: "req-mixin-test" }, () => { + return getRequestContext() + }) + expect(ctx).toEqual({ requestId: "req-mixin-test" }) + }) + + it("在 requestContextStorage.run 外 mixin 返回空对象", () => { + const ctx = getRequestContext() + expect(ctx).toEqual({}) + }) + }) + + describe("日志级别", () => { + it("默认级别 info 下 debug 不输出", () => { + const { logInstance, lines } = createMemoryLogger() + logInstance.debug("debug message") + const matched = lines.find((l) => l.includes("debug message")) + expect(matched).toBeUndefined() + }) + }) +}) diff --git a/src/shared/lib/logger.ts b/src/shared/lib/logger.ts new file mode 100644 index 0000000..592bc90 --- /dev/null +++ b/src/shared/lib/logger.ts @@ -0,0 +1,42 @@ +import pino, { type Logger } from "pino" + +import { env } from "@/env.mjs" +import { getRequestContext } from "./request-context" + +/** + * 全局 logger 实例。 + * + * - 生产环境:JSON 输出到 stdout(docker logs 友好) + * - 开发环境:pino-pretty 彩色文本 + * - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在) + */ +export const logger = pino({ + level: env.LOG_LEVEL, + base: { service: "cicd-app" }, + formatters: { + level: (label) => ({ level: label }), + }, + mixin: () => getRequestContext(), + ...(env.NODE_ENV === "development" && { + transport: { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:standard" }, + }, + }), +}) + +/** + * 创建模块级子 logger,自动绑定 module 字段。 + * + * @example + * ```ts + * const log = createModuleLogger("audit") + * log.info({ userId }, "User action logged") + * // 输出: {"level":"info","module":"audit","msg":"User action logged", ...} + * ``` + */ +export function createModuleLogger(module: string): Logger { + return logger.child({ module }) +} + +export type { Logger }