本节目标:把「打印字符串」升级为「输出可查询的事件」。学会结构化日志的字段约定与级别语义,用 pino 搭一套带类型的日志封装,通过 child logger 绑定请求上下文,用
redact与自定义 serializer 做脱敏,并把trace_id与链路追踪打通;同时认清JSON.stringify(error)得到空对象这类高频陷阱。
3.3 结构化日志与脱敏
console.log 在生产环境为什么不够
开发时 console.log("user", id, "created") 很顺手,但到了生产环境,日志的唯一用途是被机器查询:
- 它是拼接后的字符串,无法按
userId过滤; - 没有级别字段,无法只捞 error;
- 没有时间戳与时区,无法对齐分布式链路;
- 没有请求标识,无法把一次请求的多行日志串起来;
- 无法做结构化告警(如「5 分钟内 error 数超过 100」)。
结构化日志的做法是:每条日志都是一行 JSON,字段固定、可索引。
{"level":30,"time":1758441600000,"service":"api","reqId":"a1b2","trace_id":"4bf92f","msg":"user created","userId":"u_42","durationMs":12}
字段约定建议对齐业界语义规范(见延伸阅读 结构化日志语义约定 ):
| 字段 | 类型 | 说明 |
|---|---|---|
level | number | pino 用数字:10 trace / 20 debug / 30 info / 40 warn / 50 error / 60 fatal |
time | number | 毫秒时间戳,由 pino 自动写入 |
msg | string | 人类可读的短句,不要拼接变量 |
service | string | 服务名,便于多服务聚合 |
reqId | string | 请求标识,同一次请求内复用 |
trace_id / span_id | string | OpenTelemetry 链路标识 |
用 pino 起步
pino 是 Node 生态性能最好的结构化日志库,核心 API 极简:
// logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: "api" }, // 每条日志都带上的静态字段
timestamp: pino.stdTimeFunctions.isoTime,
});
logger.info({ userId: "u_42", durationMs: 12 }, "user created");
第二个参数是绑定对象(bindings),第一个参数才是消息。顺序不能反——写反了 pino 会把对象当消息、把消息当绑定字段,输出结构完全错乱。
生产环境直接输出 JSON 给采集器(Loki / Elasticsearch / ClickHouse);开发环境用 pino-pretty 做彩色渲染:
node dist/server.js | npx pino-pretty
类型安全的日志封装
裸用 pino 的 logger.info(obj, msg) 里 obj 是 unknown,拼错字段名不会有任何提示。用泛型把它收口:
import pino, { type Logger } from "pino";
export type LogFields = {
reqId?: string;
trace_id?: string;
userId?: string;
route?: string;
durationMs?: number;
err?: unknown;
};
export interface TypedLogger {
info(fields: LogFields, msg: string): void;
warn(fields: LogFields, msg: string): void;
error(fields: LogFields, msg: string): void;
child(bindings: LogFields): TypedLogger;
}
export const logger: TypedLogger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: "api" },
});
这样 logger.info({ userID: "x" }, "…") 会直接报错:
Object literal may only specify known properties, but 'userID' does not exist
in type 'LogFields'. Did you mean to write 'userId'? ts(2561)
child logger 与请求上下文
日志最大的价值是「把一次请求的多行串起来」。做法是在请求入口创建 child logger,把 reqId、trace_id 一次性绑定,后续所有日志自动携带:
import { randomUUID } from "node:crypto";
export function withRequestContext(route: string, traceId?: string) {
return logger.child({
reqId: randomUUID(),
trace_id: traceId,
route,
});
}
在中间件里挂到请求对象上,处理函数里取用:
app.use((req, _res, next) => {
req.log = withRequestContext(req.url, req.headers["traceparent"]?.toString());
const start = performance.now();
_res.on("finish", () => {
req.log.info({ durationMs: Math.round(performance.now() - start) }, "request done");
});
next();
});
中间件与请求上下文的完整写法见 中间件与请求上下文 。child logger 是零拷贝的:它只保存一份绑定对象引用,不复制父 logger,性能开销极小,可以放心在每层调用里创建。
错误序列化:别让堆栈消失
这是结构化日志里最高频的坑。直接写:
logger.error({ err: new Error("boom") }, "failed");
如果没配 serializer,Error 的 message 与 stack 是不可枚举属性,序列化后变成 {}——你只看到「failed」,看不到任何现场。正确的做法是配置标准错误序列化器:
const logger = pino({
serializers: { err: pino.stdSerializers.err },
});
输出立刻变得可用:
{"level":50,"msg":"failed","err":{"type":"Error","message":"boom","stack":"Error: boom\n at ..."}}
pino.stdSerializers.err 还会处理 cause 链(ES2022 的 new Error("x", { cause }))与 AggregateError。手动实现一个等价版本也不难:
function serializeErr(e: unknown) {
if (e instanceof Error) {
return { type: e.name, message: e.message, stack: e.stack, cause: serializeErr(e.cause) };
}
return { message: String(e) };
}
自定义错误类必须显式带上 name,否则序列化后 type 永远是 Error,丢失了业务语义:
export class PaymentDeclinedError extends Error {
override name = "PaymentDeclinedError"; // 关键
constructor(readonly orderId: string, message: string) {
super(message);
}
}
脱敏:日志里绝不能出现的东西
日志会被长期存储、被多人检索、可能被导出分析,因此它是数据泄露的高危面。必须脱敏的字段至少包括:
| 类别 | 字段 |
|---|---|
| 认证凭据 | password、token、authorization、cookie、apiKey |
| 个人隐私 | idCard、phone、email、bankCard |
| 会话标识 | sessionId、refreshToken、otp |
pino 内置 redact,按路径剔除或替换:
const logger = pino({
redact: {
paths: [
"password",
"token",
"req.headers.authorization",
"req.headers.cookie",
"user.email",
"*.creditCard",
],
censor: "[REDACTED]",
remove: false, // 保留字段但替换值,便于排查「确实传了」
},
});
redact 的语义要记准:路径是点号分隔的属性链,* 只匹配一层;它是在序列化时做替换,不影响业务代码里的对象。
但 redact 只覆盖你已知的路径。对于动态字段(如用户自定义的 metadata),需要自定义 serializer 做正则兜底:
const SENSITIVE = /^(password|token|secret|api[-_]?key)$/i;
function scrub(value: unknown, depth = 0): unknown {
if (depth > 6) return "[DEPTH_LIMIT]";
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map((v) => scrub(v, depth + 1));
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = SENSITIVE.test(k) ? "[REDACTED]" : scrub(v, depth + 1);
}
return out;
}
两层的分工是:已知路径用 redact(零成本、声明式),未知结构用 scrub(有遍历成本、兜底)。更系统的脱敏策略可延伸阅读 可观测性与安全脱敏
与 应用安全加固
。
日志级别、采样与 trace 关联
级别不是装饰,它决定了「什么该被存下来」:
| 级别 | 使用场景 |
|---|---|
debug | 开发期细节,生产默认关闭 |
info | 关键业务事件:请求完成、订单创建 |
warn | 可恢复的异常:重试成功、降级命中 |
error | 需要人介入的失败 |
fatal | 进程即将退出 |
日志量要控制。高频循环里逐条 info 会压垮采集器,做法是采样或聚合:
let counter = 0;
for (const item of items) {
if (++counter % 1000 === 0) {
logger.info({ processed: counter }, "batch progress");
}
}
与链路追踪关联是最后一块拼图:把 OpenTelemetry 当前的 trace_id / span_id 注入每条日志,就能在追踪系统里从「一个慢 span」直接跳到「它的所有日志」。用 pino 的 mixin 自动注入:
import { trace } from "@opentelemetry/api";
const logger = pino({
mixin() {
const span = trace.getActiveSpan()?.spanContext();
return span ? { trace_id: span.traceId, span_id: span.spanId } : {};
},
});
追踪侧的完整搭建见 OpenTelemetry 追踪 。
常见坑与报错对照
坑一:JSON.stringify(error) 得到 {}。 因为 message / stack 不可枚举。用 pino.stdSerializers.err 或 serializeErr。
坑二:循环引用导致序列化崩溃。
TypeError: Converting circular structure to JSON
--> starting at object with constructor 'ClientRequest'
不要把 req / res / socket 整个对象塞进日志,只挑需要的字段。
坑三:把整个请求体打进日志。 用户注册接口的 body 里就有明文密码。永远只记「字段名」而非「值」。
坑四:redact 路径写错。 路径必须与序列化后的结构一致;嵌套数组元素用 *,如 users[*].password。
坑五:日志格式在开发/生产不一致。 开发用 pino-pretty 只看终端,生产 JSON 里的字段名拼错要到线上才发现。建议在 CI 里跑一次真实日志输出的断言测试,写法参考 Vitest 单元测试
。
坑六:级别写死在代码里。 LOG_LEVEL 应来自环境变量并在配置层做类型化,见 环境变量与配置的类型化
。
小结
本节把日志从「给人看的字符串」升级为「给机器查的事件」。要点回顾:
- 结构化日志的每一条都是一行 JSON,字段固定可索引;
level/time/msg/service/reqId/trace_id是基础约定。 - pino 的
logger.info(bindings, msg)顺序不能反;用泛型接口给字段名上类型约束。 - child logger 在请求入口绑定
reqId,让一次请求的所有日志自动串起来。 Error必须用err序列化器,否则堆栈变成{};自定义错误类要显式设置name。- 脱敏两层走:已知路径用
redact,未知结构用scrub兜底;密码、token、cookie 永不入日志。 - 用
mixin注入trace_id,把日志与追踪打通。
至此第 3 章收束:Result 让预期失败显式化,错误边界兜住意外异常,结构化日志留下可检索的现场——三者构成一套完整的错误处理闭环。下一章 Vitest 单元测试
会告诉你如何为这三者写测试:Result 的分支覆盖、错误边界的降级行为、以及日志输出字段的断言。
阅读导航:上一节:3.2 全局错误边界与未捕获异常 · 下一节:4.1 Vitest 单元测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。