Pino 日志框架教程
从零掌握 Node.js 最快日志库 Pino 的完整用法。 Pino 版本:
^10.1.0· 示例参考 openclaw (warelay) 项目
目录
- 什么是 Pino
- 安装与导入
- 创建 Logger
- 日志级别
- 配置项详解
- 输出目标 — destination
- Child Logger — 上下文日志
- 结构化日志 — 字段合并规则
- Error 序列化
- 格式化输出 — pino-pretty
- Transport 传输管线
- 自定义序列化器 — serializers
- 字段脱敏 — redact
- 单例模式与配置热更新
- 测试中的 Logger 处理
- 实战:多通道输出架构
- API 速查表
1. 什么是 Pino
Pino 是 Node.js 生态中最快的 JSON 日志库,核心设计理念:
- 每条日志一行 JSON — 天然适合机器解析(jq、ELK、Grafana Loki)
- 零分配设计 — 低级别日志不产生任何对象分配,性能开销接近零
- 子日志器继承 —
child()创建的子日志器自动携带上下文字段 - 可插拔传输 — 通过
destination/transport写文件、stdout、或多路输出
与其他库对比:
| 库 | 输出格式 | 性能 | 特点 |
|---|---|---|---|
| Pino | JSON | 最快 | 极简核心、transport 机制 |
| Winston | 多格式 | 中等 | 生态最广、transports 丰富 |
| Bunyan | JSON | 快 | 自带 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 个特殊级别:
| 级别 | 数值 | 方法 | 用途 |
|---|---|---|---|
trace | 10 | logger.trace() | 最详细的内部追踪 |
debug | 20 | logger.debug() | 调试信息 |
info | 30 | logger.info() | 一般运行信息(默认级别) |
warn | 40 | logger.warn() | 警告,不影响运行 |
error | 50 | logger.error() | 错误,需要关注 |
fatal | 60 | logger.fatal() | 致命错误,系统不可用 |
silent | - | 无对应方法 | 特殊值,禁用所有输出 |
级别过滤机制
设定 level: "info" 后,trace 和 debug 的日志不会进入格式化和写入流程,几乎零开销:
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"
}
要点:只有属性名为
err或error时才会触发 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()就是这种模式,只有level或file变化时才重建。
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