1. 为什么需要日志中间件与请求链路追踪
在分布式系统开发中,日志记录和请求追踪是保证系统可观测性的两大基石。想象一下这样的场景:当用户反馈某个API请求失败时,你需要从海量日志中定位到该用户的所有相关操作记录;或者当微服务架构中某个请求经过了5个服务节点,你需要完整还原这个请求的完整生命周期。这就是TraceId和日志中间件的价值所在。
NestJS作为企业级Node.js框架,其日志系统需要满足三个核心需求:
- 结构化日志输出(便于ELK等系统采集分析)
- 跨服务请求追踪(通过唯一TraceId串联)
- 可定制的日志级别(区分DEBUG/INFO/ERROR等)
我在实际项目中曾遇到过这样的问题:当并发请求量达到2000QPS时,传统的console.log会让日志文件瞬间爆炸,且完全无法区分不同请求的日志条目。这就是我们需要专业日志方案的根本原因。
2. 核心组件选型与配置
2.1 日志库选择:Winston vs Pino
当前Node.js生态主流的日志库有:
| 特性 | Winston | Pino |
|---|---|---|
| 性能 | 中等 | 极高(JSON序列化) |
| 生态集成 | 丰富 | 一般 |
| 学习曲线 | 平缓 | 陡峭 |
| 适合场景 | 通用需求 | 高性能场景 |
基于企业级应用的需求,我们选择Winston作为基础日志库,原因有三:
- 与NestJS集成更友好(有现成的@nestjs-modules/winston)
- 支持多Transport(可同时输出到控制台和文件)
- 灵活的格式化能力(支持自定义日志格式)
安装依赖:
npm install winston nest-winston @types/winston2.2 TraceId生成方案对比
生成唯一请求ID的常见方式:
UUID v4:
import { v4 } from 'uuid'; const traceId = v4(); // 类似 '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'- 优点:无需中心化协调
- 缺点:字符串较长(36字符),索引效率低
NanoID:
import { nanoid } from 'nanoid'; const traceId = nanoid(); // 类似 'V1StGXR8_Z5jdHi6B-myT'- 优点:更短(默认21字符),URL安全
- 缺点:需要额外依赖
Snowflake算法:
- 分布式ID生成方案
- 优点:时间有序,数字类型
- 缺点:需要实例ID配置
我们选择NanoID方案,因为:
- 比UUID更短(节省日志存储空间)
- 无特殊字符(兼容所有日志系统)
- 无协调开销(适合分布式场景)
3. 实现日志中间件
3.1 中间件基础结构
创建logging.middleware.ts:
import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { nanoid } from 'nanoid'; import { WinstonLogger } from 'nest-winston'; @Injectable() export class LoggingMiddleware implements NestMiddleware { constructor(private readonly logger: WinstonLogger) {} use(req: Request, res: Response, next: NextFunction) { const traceId = req.headers['x-request-id'] || nanoid(); req['traceId'] = traceId; // 挂载到请求对象 this.logger.log({ level: 'info', message: `Incoming request: ${req.method} ${req.url}`, traceId, context: LoggingMiddleware.name, }); next(); } }关键点说明:
- 优先使用请求头中的
x-request-id(便于跨服务追踪) - 通过
req['traceId']挂载到请求对象供后续使用 - 使用结构化日志格式(包含traceId和context)
3.2 集成Winston日志
在AppModule中配置:
import { WinstonModule } from 'nest-winston'; import * as winston from 'winston'; @Module({ imports: [ WinstonModule.forRoot({ transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), }), new winston.transports.File({ filename: 'logs/application.log', format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), }), ], }), ], }) export class AppModule {}这样配置后:
- 开发环境:日志输出到控制台(JSON格式)
- 生产环境:同时写入文件(按日切割需额外配置)
4. 实现全链路TraceId传递
4.1 拦截器实现
创建trace.interceptor.ts:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class TraceInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const traceId = request['traceId']; return next.handle().pipe( tap(() => { const response = context.switchToHttp().getResponse(); response.setHeader('X-Request-ID', traceId); }), ); } }4.2 全局注册
在main.ts中:
app.useGlobalInterceptors(new TraceInterceptor());这样处理后:
- 所有响应自动携带
X-Request-ID头 - 微服务调用时可将该ID继续传递
4.3 跨服务传递方案
在HTTP调用场景(使用Axios):
import { Injectable, Inject } from '@nestjs/common'; import { Request } from 'express'; import { AxiosInstance } from 'axios'; @Injectable() export class SomeService { constructor( @Inject('AXIOS') private readonly axios: AxiosInstance, ) {} async callExternalApi(req: Request) { const headers = { 'X-Request-ID': req['traceId'], }; return this.axios.get('https://api.example.com', { headers }); } }5. 高级日志配置技巧
5.1 日志分类配置
按模块区分日志文件:
const moduleTransports = (moduleName: string) => [ new winston.transports.File({ filename: `logs/${moduleName}.log`, level: 'debug', format: winston.format.combine( winston.format.label({ label: moduleName }), winston.format.timestamp(), winston.format.json(), ), }), ]; // 在模块中 @Module({ imports: [ WinstonModule.forFeature(moduleTransports('user')), ], }) export class UserModule {}5.2 敏感信息过滤
添加敏感字段过滤:
const sensitiveFields = ['password', 'token', 'creditCard']; const redactFormat = winston.format((info) => { sensitiveFields.forEach(field => { if (info[field]) { info[field] = '***REDACTED***'; } }); return info; }); // 在format中加入 winston.format.combine( redactFormat(), // ...其他format )5.3 性能优化建议
异步日志写入:
new winston.transports.File({ filename: 'logs/app.log', options: { flags: 'a' }, handleExceptions: true, handleRejections: true, maxsize: 1024 * 1024 * 10, // 10MB maxFiles: 5, tailable: true, zippedArchive: true, })生产环境采样配置:
const samplingRate = process.env.NODE_ENV === 'production' ? 0.8 : 1.0; new winston.transports.Console({ level: 'info', sampleRate: samplingRate, // 生产环境采样80% })
6. 实战问题排查指南
6.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日志文件不生成 | 目录权限不足 | 确保logs目录存在且有写入权限 |
| TraceId不一致 | 中间件未全局注册 | 检查app.use()调用顺序 |
| 微服务间TraceId丢失 | 未正确传递header | 检查HTTP客户端拦截器配置 |
| 日志体积增长过快 | 未配置日志轮转 | 使用winston-daily-rotate-file |
| 生产环境日志性能差 | 同步写入 | 启用异步传输+采样 |
6.2 性能压测数据
在4核8G的EC2实例上测试(10000请求):
| 配置方案 | 平均延迟 | 内存占用 |
|---|---|---|
| 纯console.log | 12ms | 1.2GB |
| Winston基础配置 | 15ms | 1.3GB |
| Winston+异步传输 | 14ms | 1.25GB |
| Pino方案 | 11ms | 1.1GB |
建议:对于IO密集型应用,Pino是更好的选择;对于需要丰富生态的场景,Winston更合适。
6.3 ELK集成示例
Logstash配置示例(处理Winston日志):
input { file { path => "/var/log/node-app/application.log" codec => "json" } } filter { mutate { add_field => { "[@metadata][index]" => "nestjs-logs-%{+YYYY.MM.dd}" } } } output { elasticsearch { hosts => ["elasticsearch:9200"] index => "%{[@metadata][index]}" } }7. 监控与告警集成
7.1 Prometheus指标暴露
添加请求监控指标:
import { Counter } from 'prom-client'; const httpRequestsTotal = new Counter({ name: 'http_requests_total', help: 'Total HTTP requests', labelNames: ['method', 'path', 'status'], }); // 在拦截器中 httpRequestsTotal.inc({ method: req.method, path: req.route.path, status: res.statusCode, });7.2 日志告警规则
示例Grafana Alert(针对错误日志):
sum(rate({app="nestjs"} |~ "level\":\"error\"" [5m])) by (service) > 5表示:5分钟内错误日志超过5条即触发告警
7.3 分布式追踪集成
与Jaeger集成的方案:
import { Tracer } from 'jaeger-client'; const tracer = new Tracer({ serviceName: 'nestjs-app', sampler: { type: 'const', param: 1, }, reporter: { agentHost: 'jaeger-agent', }, }); // 在中间件中 const span = tracer.startSpan('http_request'); span.setTag('http.method', req.method); span.log({ event: 'request_received' }); // ... span.finish();