《TypeScript编程实战》3.3 结构化日志与脱敏

本节说明 console.log 为什么在生产环境不可用,并给出结构化日志的字段约定。随后用 pino 搭一套类型安全的日志封装:child logger 绑定请求上下文、err 序列化器还原堆栈与 cause、redact 路径与自定义 serializer 做脱敏。最后讲如何把日志与 trace_id 关联、级别与采样策略,以及 JSON.stringify(error) 得到空对象等坑。

本节目标:把「打印字符串」升级为「输出可查询的事件」。学会结构化日志的字段约定与级别语义,用 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}

字段约定建议对齐业界语义规范(见延伸阅读 结构化日志语义约定 ):

字段类型说明
levelnumberpino 用数字:10 trace / 20 debug / 30 info / 40 warn / 50 error / 60 fatal
timenumber毫秒时间戳,由 pino 自动写入
msgstring人类可读的短句,不要拼接变量
servicestring服务名,便于多服务聚合
reqIdstring请求标识,同一次请求内复用
trace_id / span_idstringOpenTelemetry 链路标识

用 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 单元测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes