feat(logging): add pino logger with createModuleLogger

This commit is contained in:
SpecialX
2026-07-07 11:44:49 +08:00
parent 3bd3ebc12d
commit a208fcc601
2 changed files with 129 additions and 0 deletions

View File

@@ -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<string, unknown> => getRequestContext(),
},
stream,
)
return { logInstance, lines }
}
describe("logger", () => {
describe("createModuleLogger", () => {
it("返回的 child logger 包含 module 字段", () => {
const moduleLogger = createModuleLogger("audit")
// bindings 继承父 logger 的 baseservice故用 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()
})
})
})

42
src/shared/lib/logger.ts Normal file
View File

@@ -0,0 +1,42 @@
import pino, { type Logger } from "pino"
import { env } from "@/env.mjs"
import { getRequestContext } from "./request-context"
/**
* 全局 logger 实例。
*
* - 生产环境JSON 输出到 stdoutdocker 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 }