排查线上 Bug 的时候,最让人崩溃的莫过于在 Kibana 里搜到一堆没有上下文的 console.log("data is null") 或者 logger.info("user login success")。你根本不知道这个 null 是哪个用户的,这行日志是哪个请求触发的,当时的调用链条长什么样。这种纯文本的、散落四处的日志,不仅无法建立追踪,反而增加了日志存储的成本,简直是在制造噪声。

在微服务或者分布式架构下,日志如果做不到结构化(Structured Logging)和调用链协同(Trace Context),那基本等于瞎子摸象。出了问题,你得在几个微服务的日志里来回肉眼对比时间戳去“猜”关联性,效率低到让人想辞职。

其实解决思路很简单:所有日志输出一律使用标准的 JSON 格式,且必须包含全局唯一的 traceIdspanId,再配合统一的日志格式器(Formatter)。当请求进入系统时,由网关或者入口中间件生成 Trace 标识,并存入上下文(在 Node.js 中常用 AsyncLocalStorage,在 Go 中直接塞进 context.Context),后续所有的日志库调用都会自动提取并附带这些标识。

以下是我们在 Node.js 核心服务中基于 Winston 配合 AsyncLocalStorage 封装的生产级结构化日志方案,不仅统一了 JSON 字段,还实现了调用链的自动透传:

import winston from 'winston';
import { AsyncLocalStorage } from 'async_hooks';

// 定义调用链上下文存储器
interface LogContext {
  traceId: string;
  spanId: string;
  userId?: string;
}

export const logStorage = new AsyncLocalStorage<LogContext>();

// 封装 Winston 的格式化器,注入 Trace 字段
const traceFormat = winston.format((info) => {
  const store = logStorage.getStore();
  if (store) {
    info.traceId = store.traceId;
    info.spanId = store.spanId;
    if (store.userId) {
      info.userId = store.userId;
    }
  }
  
  // 注入系统元数据
  info.service = process.env.SERVICE_NAME || 'user-service';
  info.environment = process.env.NODE_ENV || 'production';
  return info;
});

// 初始化全局 Logger
export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    traceFormat(),
    winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
    winston.format.json() // 必须输出为 JSON,方便 ELK/Loki 解析
  ),
  transports: [
    new winston.transports.Console()
  ]
});

// 模拟 Express 调用链追踪中间件
export function traceMiddleware(req: any, res: any, next: () => void) {
  // 优先从 Header 中提取网关传过来的 TraceID,没有则自己生成
  const traceId = req.headers['x-trace-id'] || `trace_${Math.random().toString(36).substring(2, 15)}`;
  const spanId = req.headers['x-span-id'] || `span_${Math.random().toString(36).substring(2, 10)}`;
  
  const context: LogContext = {
    traceId,
    spanId,
    userId: req.query?.userId // 仅做演示,真实环境可从 Token 解析
  };

  // 在异步上下文中运行后续逻辑
  logStorage.run(context, () => {
    logger.info('Received incoming HTTP request', {
      method: req.method,
      path: req.path,
      ip: req.ip
    });
    
    // 监听响应结束,记录请求耗时
    const start = Date.now();
    res.on('finish', () => {
      logger.info('Request processed completed', {
        statusCode: res.statusCode,
        durationMs: Date.now() - start
      });
    });

    next();
  });
}

部署这套方案后,输出的日志全都是规范的单行 JSON 串:

{"message":"Received incoming HTTP request","method":"GET","path":"/api/users","ip":"::1","level":"info","traceId":"trace_abc123xyz","spanId":"span_u8s83","service":"user-service","environment":"production","timestamp":"2026-07-13 15:10:30.123"}

这样一来,不管是 Kibana 还是 Grafana Loki,都能完美解析 these key-value。开发人员只需要搜一下对应的 traceId,就能像看剧一样,把这次请求在用户服务、订单服务、支付服务里的所有行为轨迹串联得清清楚楚。

别再在项目里写 console.log 祸害队友了。既然上了微服务,就把基础设施做规范,这才是能让你提早下班的正确姿势。