小龙虾学习基础知识4. Pino 日志库使用指南

0 阅读7分钟

Pino 日志框架教程

从零掌握 Node.js 最快日志库 Pino 的完整用法。 Pino 版本: ^10.1.0 · 示例参考 openclaw (warelay) 项目


目录

  1. 什么是 Pino
  2. 安装与导入
  3. 创建 Logger
  4. 日志级别
  5. 配置项详解
  6. 输出目标 — destination
  7. Child Logger — 上下文日志
  8. 结构化日志 — 字段合并规则
  9. Error 序列化
  10. 格式化输出 — pino-pretty
  11. Transport 传输管线
  12. 自定义序列化器 — serializers
  13. 字段脱敏 — redact
  14. 单例模式与配置热更新
  15. 测试中的 Logger 处理
  16. 实战:多通道输出架构
  17. API 速查表

1. 什么是 Pino

Pino 是 Node.js 生态中最快的 JSON 日志库,核心设计理念:

  • 每条日志一行 JSON — 天然适合机器解析(jq、ELK、Grafana Loki)
  • 零分配设计 — 低级别日志不产生任何对象分配,性能开销接近零
  • 子日志器继承child() 创建的子日志器自动携带上下文字段
  • 可插拔传输 — 通过 destination / transport 写文件、stdout、或多路输出

与其他库对比:

输出格式性能特点
PinoJSON最快极简核心、transport 机制
Winston多格式中等生态最广、transports 丰富
BunyanJSON自带 CLI 查看、较老
console.log文本-无结构化、无级别控制

2. 安装与导入

npm install pino
// 基本导入
import pino from "pino";

// 带类型导入
import pino, { type Logger, type Bindings, type LevelWithSilent } from "pino";

可选的配套工具:

npm install pino-pretty    # 开发环境美化输出
npm install @types/pino    # TypeScript 类型(Pino 10+ 已内置,通常不需要)

3. 创建 Logger

最简创建

const logger = pino();  // 默认 level=info,输出到 stdout

logger.info("Hello world");
// {"level":30,"time":1705312200000,"pid":12345,"hostname":"mac","msg":"Hello world"}

带配置

const logger = pino({
  level: "debug",
  name: "my-app",
});

带配置 + 输出目标

const logger = pino(
  { level: "info" },
  pino.destination("/tmp/app.log"),   // 第二个参数是输出目标
);

pino() 签名

pino(options?: LoggerOptions, destination?: DestinationStream): Logger
pino(destination?: DestinationStream): Logger   // 省略 options 时 destination 放第一个

注意pino(options)pino(options, destination) 是两种不同的调用方式。如果只传一个参数且是普通对象,它被视为 options,输出到 stdout。


4. 日志级别

级别定义与数值

Pino 内置 6 个级别 + 1 个特殊级别:

级别数值方法用途
trace10logger.trace()最详细的内部追踪
debug20logger.debug()调试信息
info30logger.info()一般运行信息(默认级别)
warn40logger.warn()警告,不影响运行
error50logger.error()错误,需要关注
fatal60logger.fatal()致命错误,系统不可用
silent-无对应方法特殊值,禁用所有输出

级别过滤机制

设定 level: "info" 后,tracedebug 的日志不会进入格式化和写入流程,几乎零开销:

const logger = pino({ level: "info" });
logger.debug("这条不会输出");     // 直接跳过,不消耗资源
logger.info("这条会输出");        // 正常写入

运行时修改级别

logger.level = "debug";    // 动态切换
logger.debug("现在可以看到了");

自定义级别

const logger = pino({
  customLevels: {
    http: 25,     // 在 debug(20) 和 info(30) 之间
    notice: 35,   // 在 info(30) 和 warn(40) 之间
  },
  level: "http",
});

logger.http("HTTP request received");  // 使用自定义级别方法

项目实践:级别决定链

在实际项目中,日志级别通常由多层配置决定。参考 warelay 的实现:

function normalizeLevel(level?: string): LevelWithSilent {
  // 1. CLI 参数优先(--verbose 强制 debug)
  if (isVerbose()) return "debug";
  // 2. 使用配置文件或默认值
  const candidate = level ?? "info";
  // 3. 无效值降级为 info
  return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
    ? (candidate as LevelWithSilent)
    : "info";
}

优先级:CLI 参数 > 配置文件 > 默认值 "info"


5. 配置项详解

const logger = pino({
  level: "info",                          // 最低输出级别
  name: "my-app",                         // 日志器名称(出现在每条日志中)
  base: { pid: process.pid },             // 每条日志的基础字段
  timestamp: pino.stdTimeFunctions.isoTime, // 时间戳格式
  serializers: { err: pino.stdSerializers.err }, // 自定义序列化器
  redact: ["password", "token"],          // 字段脱敏
  formatters: {                           // 自定义格式化
    level(label, number) { return { level: label }; },
  },
});

level

控制最低输出级别,低于此级别的日志完全跳过:

level: "info"    // 输出 info / warn / error / fatal
level: "debug"   // 额外输出 debug
level: "trace"   // 输出所有级别
level: "silent"  // 不输出任何日志

name

为日志器命名,出现在每条日志的 name 字段:

const logger = pino({ name: "api-server" });
logger.info("started");
// {"level":30,"time":...,"name":"api-server","msg":"started"}

base

每条日志自动附带的基础字段。默认值是 { pid, hostname }

// 保留默认(pid + hostname)
const logger = pino();
// {"level":30,"time":...,"pid":12345,"hostname":"mac","msg":"..."}

// 移除所有 base 字段
const logger = pino({ base: undefined });
// {"level":30,"time":...,"msg":"..."}

// 自定义 base 字段
const logger = pino({ base: { service: "auth", version: "2.0" } });
// {"level":30,"time":...,"service":"auth","version":"2.0","msg":"..."}

timestamp

控制时间戳格式:

// 默认 — epoch 毫秒数
timestamp: pino.stdTimeFunctions.epochTime
// "time": 1705312200000

// ISO 8601 字符串(推荐,可读性好)
timestamp: pino.stdTimeFunctions.isoTime
// "time": "2024-01-15T08:30:00.000Z"

// Unix 秒数
timestamp: pino.stdTimeFunctions.unixTime
// "time": 1705312200

// 不输出时间戳
timestamp: pino.stdTimeFunctions.nullTime
// 无 time 字段

// 完全禁用(比 nullTime 更彻底,不调用任何函数)
timestamp: false

formatters

自定义日志字段格式化方式:

const logger = pino({
  formatters: {
    // 将 level 从数值改为字符串标签
    level(label, number) {
      return { level: label };  // "level": "info" 而非 "level": 30
    },
    // 自定义 bindings 格式化(pid, hostname, name 等)
    bindings(bindings) {
      return { pid: bindings.pid };  // 只保留 pid
    },
  },
});

hooks

在日志写入前拦截和修改数据:

const logger = pino({
  hooks: {
    logMethod(args, method) {
      // 给所有日志添加前缀
      if (typeof args[0] === "string") {
        args[0] = `[APP] ${args[0]}`;
      }
      return method.apply(this, args);
    },
  },
});

6. 输出目标 — destination

Pino 通过 pino.destination() 控制日志写到哪里。

输出到 stdout(默认)

const logger = pino();  // 不传 destination,默认 stdout

输出到文件

// 简洁写法
const logger = pino(pino.destination("/tmp/app.log"));

// 完整写法
const destination = pino.destination({
  dest: "/tmp/app.log",   // 文件路径
  mkdir: true,             // 自动创建目录
  sync: false,             // 异步写入(默认)
  append: true,            // 追加模式(默认)
});
const logger = pino({}, destination);

destination 参数详解

pino.destination({
  dest: string | number,   // 文件路径 或 文件描述符
  mkdir?: boolean,          // 自动创建父目录(默认 false)
  sync?: boolean,           // 同步写入(默认 false)
  append?: boolean,         // 追加模式(默认 true,false 则覆盖)
});

输出到 stderr

const logger = pino(pino.destination(2));  // fd 2 = stderr

sync vs async

// 异步写入(默认)— 性能更好
const dest = pino.destination({ dest: "/tmp/app.log" });
// 使用 write(),经 libuv 事件循环

// 同步写入 — 不丢日志,适合测试
const dest = pino.destination({ dest: "/tmp/app.log", sync: true });
// 使用 writeSync(),立即刷盘
模式性能数据安全适用场景
sync: false(默认)进程崩溃可能丢最后几条生产环境
sync: true不丢测试、日志量小

手动刷新缓冲区

await logger.flush();  // 确保 async 模式下所有日志写入磁盘

7. Child Logger — 上下文日志

child() 是 Pino 最重要的特性,创建一个自动携带固定字段的子日志器。

基本用法

const parent = pino();
const child = parent.child({ module: "database" });

child.info("Connected");
// {"level":30,"time":...,"module":"database","msg":"Connected"}

child()bindings 对象会合并到每条日志的顶层字段中。

多层嵌套

字段层层累加:

const app = pino();
const req = app.child({ requestId: "abc-123" });
const step = req.child({ step: "validate" });

step.info("done");
// {"level":30,"time":...,"requestId":"abc-123","step":"validate","msg":"done"}

子日志器独立级别控制

// 父日志器 info 级别,但某个子模块静默
const silentLogger = parent.child(
  {},                                    // bindings 可以为空
  { level: "silent" },                   // 独立控制级别
);

注意:子日志器的级别设置不会影响父日志器和其他子日志器。

常见使用模式

模块标识
const logger = pino().child({ module: "auth" });
const dbLogger = pino().child({ module: "database" });
请求追踪
import { randomUUID } from "node:crypto";

function handleRequest(req) {
  const logger = pino().child({
    requestId: randomUUID(),
    method: req.method,
    path: req.path,
  });

  logger.info("request started");
  // ... 后续所有日志自动携带 requestId, method, path
}
运行追踪
const runLogger = pino().child({
  runId: randomUUID(),
  startedAt: new Date().toISOString(),
});

性能提示

child() 非常轻量,不需要缓存子日志器。在每次请求/操作中创建新的是正确做法:

// 每个请求创建一个 child — 这是正确的模式
app.use((req, res, next) => {
  req.log = logger.child({ requestId: randomUUID() });
  next();
});

8. 结构化日志 — 字段合并规则

日志方法签名

// 方式 1:纯消息
logger.info("Simple message");

// 方式 2:合并对象 + 消息
logger.info({ userId: 42 }, "User logged in");

// 方式 3:格式化字符串(类似 console.log)
logger.info("User %s logged in", "Alice");

字段合并顺序

当同时使用 child() bindings 和 info() 的合并对象时,字段来源和优先级:

const logger = pino().child({ module: "api", userId: 1 });

logger.info({ userId: 42, action: "login" }, "event");

输出 JSON:

{
  "level": 30,
  "time": 1705312200000,
  "module": "api",
  "userId": 42,
  "action": "login",
  "msg": "event"
}

注意info() 第一个参数中的字段会覆盖 child() bindings 中的同名字段(userId 被覆盖为 42)。

合并规则总结

最终字段 = Pino 内置字段 (level, time)
         + base 字段 (pid, hostname, ...)
         + child() bindings
         + info() 第一个参数的对象    ← 优先级最高
         + { msg: 第二个参数 }

字段值限制

  • 合并对象必须是可 JSON 序列化的普通对象
  • 函数、Symbol、undefined 的属性会被忽略
  • Error 对象有特殊处理(见下一节)
  • BigInt 无法序列化,需要先转字符串
// 错误示例
logger.info({ data: BigInt(123) }, "oops");  // TypeError!

// 正确做法
logger.info({ data: String(BigInt(123)) }, "ok");

9. Error 序列化

基本用法

Pino 对 err 属性名有特殊处理,会自动序列化 Error 对象:

logger.error({ err: new Error("Something broke") }, "operation failed");

输出:

{
  "level": 50,
  "time": 1705312200000,
  "err": {
    "type": "Error",
    "message": "Something broke",
    "stack": "Error: Something broke\n    at ..."
  },
  "msg": "operation failed"
}

要点:只有属性名为 errerror 时才会触发 Error 序列化器。

自定义 Error 信息

// 保留 stack 但用自定义 message
logger.error(
  { err: err, context: { userId: 42 } },
  "Failed to process user",
);

用 String() 简化输出

如果不需要完整 stack trace,可以用 String() 将 Error 转为字符串:

logger.error({ error: String(err) }, "handler error");
// "error": "Error: Something broke"

这在项目实践中很常见,可以让日志更简洁可控。

捕获未处理异常

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "Uncaught exception");
  process.exit(1);
});

process.on("unhandledRejection", (err) => {
  logger.error({ err }, "Unhandled rejection");
});

10. 格式化输出 — pino-pretty

开发时 JSON 日志不便于阅读,pino-pretty 可以将其转为彩色文本。

安装

npm install pino-pretty

使用方式 1:管道(推荐)

不修改应用代码,通过管道在开发时美化输出:

node app.js | npx pino-pretty

输出效果:

[17:30:00.000] INFO (12345): User logged in
    userId: 42

使用方式 2:作为 transport

const logger = pino({
  transport: {
    target: "pino-pretty",
    options: {
      colorize: true,
      translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
      ignore: "pid,hostname",
    },
  },
});

注意pino-pretty 仅用于开发环境,生产环境应直接输出 JSON。

常用 pino-pretty 选项

npx pino-pretty \
  --colorize \                          # 彩色输出
  --translateTime "SYS:yyyy-mm-dd HH:MM:ss" \  # 时间格式化
  --ignore "pid,hostname" \             # 忽略字段
  --levelFirst                          # 级别显示在最前

11. Transport 传输管线

Pino v7+ 的 transport 机制支持多路输出和 Worker 线程处理。

基本概念

应用进程 (主线程)
    │
    │  JSON 日志
    ▼
Worker 线程
    │
    ├──→ pino-pretty (开发美化)
    ├──→ pino/file (写入文件)
    └──→ pino-elasticsearch (发送到 ES)

Transport 在独立 Worker 线程中运行,不阻塞主线程。

单个 transport

const logger = pino({
  transport: {
    target: "pino-pretty",       // npm 包名
    options: { colorize: true }, // 传给 transport 的选项
  },
});

多路输出

const logger = pino({
  transport: {
    targets: [
      {
        target: "pino/file",
        options: { destination: "/tmp/app.log" },
        level: "info",
      },
      {
        target: "pino-pretty",
        options: { colorize: true },
        level: "debug",
      },
    ],
  },
});

自定义 transport

// my-transport.js
module.exports = function (options) {
  return function (source) {
    source.on("data", (line) => {
      const obj = JSON.parse(line);
      // 发送到你的日志系统(HTTP、gRPC、etc.)
      console.log("CUSTOM:", obj.msg);
    });
  };
};

// 使用
const logger = pino({
  transport: {
    target: "./my-transport.js",
    options: { endpoint: "http://logs.example.com" },
  },
});

Transport vs Destination 对比

特性pino.destination()transport
运行线程主线程Worker 线程
性能影响极低略高(序列化开销)
多路输出不支持支持 targets
自定义处理不支持支持自定义 transport
适用场景单文件/stdout复杂输出需求

12. 自定义序列化器 — serializers

Serializers 控制特定字段如何被转为 JSON。

内置序列化器

import pino from "pino";

const logger = pino({
  serializers: {
    err: pino.stdSerializers.err,    // Error 对象 → { type, message, stack }
  },
});

自定义序列化器

const logger = pino({
  serializers: {
    // 序列化 HTTP 请求
    req(req) {
      return {
        method: req.method,
        url: req.url,
        headers: {
          "user-agent": req.headers["user-agent"],
        },
      };
    },
    // 精简 Error 输出
    err(err) {
      return {
        message: err.message,
        code: err.code,
      };
    },
  },
});

logger.info({ req: { method: "GET", url: "/api/users", headers: {...} } }, "request");

Serializer 触发规则

只有当日志合并对象中包含对应属性名时才触发:

// serializers: { err: ... }
logger.error({ err: new Error("x") }, "msg");  // ✅ 触发 err serializer
logger.error({ error: new Error("x") }, "msg"); // ❌ 不触发(属性名不匹配)
logger.info({ err: "not an error" }, "msg");     // ✅ 触发,传入字符串

13. 字段脱敏 — redact

redact 用于在日志中遮蔽敏感字段(密码、token 等)。

基本用法

const logger = pino({
  redact: ["password", "token", "headers.authorization"],
});

logger.info({
  username: "alice",
  password: "secret123",
  token: "eyJhbGciOi...",
}, "login attempt");

输出:

{
  "level": 30,
  "time": 1705312200000,
  "username": "alice",
  "password": "[Redacted]",
  "token": "[Redacted]",
  "msg": "login attempt"
}

路径语法

redact: [
  "password",                    // 顶层字段
  "user.password",               // 嵌套字段
  "headers.authorization",       # 多层嵌套
  "*.secret",                    // 通配符(任意对象的 secret 字段)
]

脱敏模式

const logger = pino({
  redact: {
    paths: ["password", "token"],
    censor: "[HIDDEN]",          // 自定义替换文本(默认 "[Redacted]")
  },
});

也可以用函数做更精细的控制:

const logger = pino({
  redact: {
    paths: ["password"],
    censor: (value, path) => {
      return "***" + value.slice(-4);  // 保留后 4 位
    },
  },
});
// password: "secret123" → password: "***t123"

14. 单例模式与配置热更新

为什么要单例

每次调用 pino() 都会创建新的 Logger 和底层文件描述符。在应用中应该复用同一个实例

// ✅ 正确:导出单例
let cached: Logger | null = null;

export function getLogger(): Logger {
  if (!cached) {
    cached = pino({ level: "info" }, pino.destination("/tmp/app.log"));
  }
  return cached;
}

配置热更新

当配置(级别、文件路径)可能变化时,检测变更并重建:

let cached: Logger | null = null;
let cachedLevel: string | null = null;

export function getLogger(level: string): Logger {
  if (!cached || cachedLevel !== level) {
    cached = pino({ level }, pino.destination("/tmp/app.log"));
    cachedLevel = level;
  }
  return cached;
}

参考:warelay 项目中 getLogger() 就是这种模式,只有 levelfile 变化时才重建。


15. 测试中的 Logger 处理

问题

测试中直接使用生产 Logger 会导致:

  • 日志文件冲突(并行测试互相覆盖)
  • 无法断言日志内容
  • 日志写入影响测试性能

方案 1:覆盖 + 重置

// logging.ts
let override: LoggerSettings | null = null;
let cached: Logger | null = null;

export function setOverride(settings: LoggerSettings) {
  override = settings;
  cached = null;  // 清除缓存,强制重建
}

export function resetLogger() {
  override = null;
  cached = null;
}

export function getLogger(): Logger {
  if (cached) return cached;
  const settings = override ?? loadConfig();
  cached = pino({ level: settings.level }, pino.destination(settings.file));
  return cached;
}

测试中:

import { setOverride, resetLogger, getLogger } from "./logging.js";

beforeEach(() => {
  setOverride({ level: "debug", file: `/tmp/test-${Date.now()}.log` });
});

afterEach(() => {
  resetLogger();
});

test("logs info message", () => {
  getLogger().info("test message");
  // 断言日志文件内容...
});

方案 2:使用 silent 级别

简单粗暴,直接静默所有日志:

const logger = pino({ level: "silent" });

方案 3:使用 sink 捕获日志

import { sink } from "pino/test/helper.mjs";

test("captures log output", async () => {
  const stream = sink();
  const logger = pino(stream);

  logger.info("hello");

  const lines = await stream.flush();
  // lines[0] 是 JSON 字符串
  const obj = JSON.parse(lines[0]);
  expect(obj.msg).toBe("hello");
});

方案 4:sync 模式确保确定性

const logger = pino(
  { level: "debug" },
  pino.destination({ dest: "/tmp/test.log", sync: true }),
);

logger.info("message");  // 立即写入,无需 flush

16. 实战:多通道输出架构

参考 warelay 项目,实现一个同时输出到控制台和文件的日志架构。

架构设计

┌─────────────────────────────────────────┐
│            应用代码                      │
│                                         │
│  ┌─────────────┐   ┌────────────────┐  │
│  │  封装函数     │   │  child logger  │  │
│  │ logInfo()   │   │  (模块日志)     │  │
│  │ logError()  │   │                │  │
│  └──────┬──────┘   └───────┬────────┘  │
│         │                  │            │
│         ▼                  ▼            │
│  ┌─────────────────────────────────┐   │
│  │       getLogger() 单例          │   │
│  │       pino(file destination)    │   │
│  └──────────────┬──────────────────┘   │
│                 │                       │
│    ┌────────────▼────────────┐         │
│    │   /tmp/app.log (JSON)   │         │
│    └─────────────────────────┘         │
│                                        │
│  控制台输出(封装函数额外做)           │
│  runtime.log(chalk.colored(message))   │
└────────────────────────────────────────┘

封装函数实现

// logger.ts
import pino from "pino";
import chalk from "chalk";

const logger = pino(
  { level: "info", base: undefined, timestamp: pino.stdTimeFunctions.isoTime },
  pino.destination({ dest: "/tmp/app.log", mkdir: true, sync: true }),
);

export function logInfo(message: string) {
  console.log(chalk.blue(message));   // 控制台:彩色
  logger.info(message);               // 文件:JSON
}

export function logError(message: string) {
  console.error(chalk.red(message));
  logger.error(message);
}

export function logDebug(message: string) {
  logger.debug(message);              // 文件:始终写入
  if (process.env.VERBOSE) {          // 控制台:仅 verbose 模式
    console.log(chalk.gray(message));
  }
}

何时用封装函数 vs 直接用 child logger

场景选择原因
用户可见的 CLI 输出logInfo() / logError()需要同时输出到控制台和文件
模块内部调试child({ module }).debug()只写文件,不打扰用户
请求追踪child({ correlationId })结构化字段方便检索
第三方库集成child({ module }, { level })独立控制级别,静默无关日志

17. API 速查表

创建

pino()                                           // 默认 stdout, level=info
pino({ level: "debug" })                         // 自定义配置
pino(pino.destination("/tmp/a.log"))             // 文件输出
pino({ level: "info" }, pino.destination(...))   // 配置 + 输出

日志方法

logger.trace(objOrMsg, msg?, ...args)
logger.debug(objOrMsg, msg?, ...args)
logger.info(objOrMsg, msg?, ...args)
logger.warn(objOrMsg, msg?, ...args)
logger.error(objOrMsg, msg?, ...args)
logger.fatal(objOrMsg, msg?, ...args)

Child Logger

const child = logger.child(bindings, opts?)
// bindings: 合并到每条日志的字段
// opts: { level?: string } 独立控制级别

配置项

{
  level?: string                    // 最低级别
  name?: string                     // 日志器名称
  base?: object | undefined         // 基础字段
  timestamp?: Function | false      // 时间戳函数
  serializers?: Record<string, Fn>  // 自定义序列化器
  redact?: string[] | { paths, censor } // 脱敏
  formatters?: { level, bindings }  // 自定义格式化
  hooks?: { logMethod }             // 日志拦截
  transport?: { target, targets }   // Worker 线程传输
}

Destination

pino.destination()                    // stdout
pino.destination("/path/to/file")     // 文件(简洁写法)
pino.destination({ dest, mkdir, sync, append }) // 完整写法
pino.destination(1)                   // stdout (fd=1)
pino.destination(2)                   // stderr (fd=2)

时间戳函数

pino.stdTimeFunctions.isoTime     // "2024-01-15T08:30:00.000Z"
pino.stdTimeFunctions.epochTime   // 1705312200000
pino.stdTimeFunctions.unixTime    // 1705312200
pino.stdTimeFunctions.nullTime    // 无 time 字段

实例方法

logger.level = "debug"           // 运行时修改级别
logger.level                     // 读取当前级别
await logger.flush()             // 刷新 async 缓冲区
logger.child(bindings)           // 创建子日志器
logger.bindings()                // 获取当前 bindings