NestJS 日志最佳实践

内置 Logger、Pino、Winston、结构化日志与生产落地一条龙

Posted by chanweiyan on May 1, 2026

为什么日志很重要

日志是线上排障的第一手线索。日志做不好,生产出了问题只能瞎猜。一份好的日志系统需要:

  • 结构化(JSON):机器可解析,方便 ELK / Loki / CloudWatch 查询
  • 分级(debug / info / warn / error):过滤噪音
  • 有 context(traceId / userId / path / latency):每条日志能串联到一次请求
  • 高性能:日志不能成为瓶颈(Pino 比 console.log 快 5-10 倍)
  • 异步写入:不阻塞请求处理

NestJS 内置 Logger

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Logger } from '@nestjs/common'

@Injectable()
export class CatService {
  private readonly logger = new Logger(CatService.name) // context = 'CatService'

  findAll() {
    this.logger.log('Fetching all cats')
    this.logger.warn('Deprecated endpoint')
    this.logger.error('Something broke', someError.stack)
    this.logger.debug('Raw query result', JSON.stringify(result))
    this.logger.verbose('Even more detail')
  }
}

日志级别控制

1
2
3
4
5
// main.ts
const app = await NestFactory.create(AppModule, {
  logger: ['error', 'warn', 'log'],       // 生产只开这三个
  // logger: false,                       // 关掉所有内置日志
})

优缺点

优点 缺点
零依赖,开箱即用 输出纯文本,不是 JSON
API 简洁 不支持日志文件 / 远程传输
可自定义(实现接口替换) 性能一般(底层 console.log

结论:开发环境用内置 Logger 看彩色输出;生产环境换 Pino 或 Winston

生产方案一:Pino(推荐)

Pino 是目前 Node 生态最快的 JSON logger,nestjs-pino 提供 NestJS 一等集成。

安装

1
npm i nestjs-pino pino-http pino-pretty

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app.module.ts
import { LoggerModule } from 'nestjs-pino'

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty', options: { colorize: true } }  // 开发:彩色可读
          : undefined,                                               // 生产:原生 JSON
        redact: ['req.headers.authorization', 'req.body.password'], // ← 脱敏
        customProps: (req) => ({ traceId: req.headers['x-trace-id'] }),
        serializers: {
          req: (req) => ({ method: req.method, url: req.url, id: req.id }),
          res: (res) => ({ statusCode: res.statusCode }),
        },
      },
    }),
  ],
})
export class AppModule {}

在 main.ts 替换默认 Logger

1
2
3
4
5
6
7
8
// main.ts
import { Logger } from 'nestjs-pino'

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true })
  app.useLogger(app.get(Logger)) // ← 用 Pino 接管 NestJS 内置日志
  await app.listen(3000)
}

在 Service 里注入

1
2
3
4
5
6
7
8
9
10
11
import { Logger } from 'nestjs-pino'

@Injectable()
export class OrderService {
  constructor(private readonly logger: Logger) {}

  async create(dto: CreateOrderDto) {
    this.logger.log({ msg: 'Creating order', userId: dto.userId, amount: dto.amount })
    // 输出 JSON: {"level":"info","msg":"Creating order","userId":42,"amount":99.9,...}
  }
}

nestjs-pino 自动把 pino-http 中间件接进来,每个请求的 req.log 天然带有 traceIdlatency 等字段。

生产方案二:Winston

Winston 生态更老,插件更多(日志文件轮转、多 Transport 同时写)。适合需要同时写文件 + 发 Elasticsearch 的场景。

安装

1
npm i nest-winston winston winston-daily-rotate-file

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// app.module.ts
import { WinstonModule } from 'nest-winston'
import * as winston from 'winston'
import 'winston-daily-rotate-file'

@Module({
  imports: [
    WinstonModule.forRoot({
      level: 'info',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.errors({ stack: true }),
        winston.format.json(),           // 输出 JSON
      ),
      transports: [
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.colorize(),
            winston.format.simple(),     // 开发:简洁可读
          ),
        }),
        new winston.transports.DailyRotateFile({
          filename: 'logs/app-%DATE%.log',
          datePattern: 'YYYY-MM-DD',
          maxFiles: '14d',               // 只保留 14 天
          zippedArchive: true,
        }),
      ],
    }),
  ],
})
export class AppModule {}

在 main.ts 替换

1
2
3
4
5
6
7
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))
  await app.listen(3000)
}

结构化日志:每条日志该带什么

最小字段集

1
2
3
4
5
6
7
8
9
{
  "level": "info",
  "time": "2026-05-01T10:04:00.000Z",
  "msg": "Order created",
  "traceId": "abc-123",
  "userId": 42,
  "orderId": "ord-789",
  "latency": 38
}
字段 说明
level debug / info / warn / error
time ISO 8601,统一 UTC
msg 人类可读的描述,动词 + 名词
traceId 全链路追踪 ID,贯穿整条请求
userId 操作人,方便按用户查日志
err 错误时带 { message, stack, code }
latency 耗时(ms),方便性能分析

禁止出现在日志里的字段

  • password / passwordHash
  • Authorization / token / cookie
  • 身份证号、银行卡号等 PII

Pino 的 redact 选项可以自动脱敏,Winston 可以写自定义 format 过滤。

请求追踪:TraceId 全链路透传

方式一:手动中间件(适合所有 Logger)

1
2
3
4
5
6
7
8
9
10
11
12
// trace.middleware.ts
import { randomUUID } from 'node:crypto'
import { Injectable, NestMiddleware } from '@nestjs/common'

@Injectable()
export class TraceMiddleware implements NestMiddleware {
  use(req: Request, res: any, next: () => void) {
    req['traceId'] = (req.headers['x-trace-id'] as string) ?? randomUUID()
    res.setHeader('X-Trace-Id', req['traceId'])  // 回写到响应头,方便前端对账
    next()
  }
}

方式二:AsyncLocalStorage(推荐)

AsyncLocalStorage 在整个异步调用链上”隐式传递”上下文,不需要每个函数都手动传 traceId

1
2
3
4
5
6
7
8
// trace.context.ts
import { AsyncLocalStorage } from 'node:async_hooks'

export const traceStore = new AsyncLocalStorage<{ traceId: string; userId?: number }>()

export function getTrace() {
  return traceStore.getStore()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// trace.interceptor.ts
import { randomUUID } from 'node:crypto'
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
import { Observable } from 'rxjs'
import { traceStore } from './trace.context'

@Injectable()
export class TraceInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest()
    const traceId = req.headers['x-trace-id'] ?? randomUUID()

    return new Observable(subscriber => {
      traceStore.run({ traceId }, () => {
        next.handle().subscribe(subscriber)
      })
    })
  }
}

在 Service 里随时取:

1
2
3
4
5
6
7
8
9
import { getTrace } from './trace.context'

@Injectable()
export class PaymentService {
  create() {
    const { traceId } = getTrace()!
    this.logger.log({ msg: 'Payment initiated', traceId })
  }
}

错误日志:Exception Filter 统一捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common'

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger('ExceptionFilter')

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx    = host.switchToHttp()
    const req    = ctx.getRequest()
    const res    = ctx.getResponse()
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR

    const message = exception instanceof HttpException
      ? exception.message
      : 'Internal server error'

    this.logger.error({
      msg: message,
      status,
      path: req.url,
      method: req.method,
      traceId: req['traceId'],
      stack: exception instanceof Error ? exception.stack : undefined,
    })

    res.status(status).json({ statusCode: status, message, traceId: req['traceId'] })
  }
}
1
2
// main.ts
app.useGlobalFilters(new AllExceptionsFilter())

日志分级使用规范

级别 什么时候用
debug 开发调试:SQL、参数、中间状态。生产关闭
info 正常业务流程节点:订单创建、用户登录、定时任务执行
warn 可恢复的异常:重试、降级、弃用 API 被调用
error 需要人介入的错误:数据库断连、第三方不可用、未捕获异常
verbose 比 debug 更细,通常只在本地临时用

规则:

  • 同一个请求的 info 不超过 3 条(太多等于没有)
  • 循环体内 不要info,最多 debug
  • error 必须带 stack(或 err 对象)

性能:日志不要成为瓶颈

  • Pino 异步写入,基准测试比 Winston 快 3-5 倍,比 console.log 快 8 倍
  • 生产禁止 pino-pretty(格式化有额外开销,且破坏 JSON 结构)
  • 日志采样:高频接口(如健康检查)可以只记录 1% 的请求
1
2
3
4
5
pinoHttp: {
  autoLogging: {
    ignore: (req) => req.url === '/health',  // 健康检查不记录
  },
}
  • 不要在 debug 里做复杂序列化JSON.stringify(bigObject)),先判断 level:
1
2
3
4
5
// ❌ 无论 level 如何,JSON.stringify 都会执行
this.logger.debug(JSON.stringify(heavyObject))

// ✅ Pino 的 lazy eval(传对象不传字符串)
this.logger.debug({ heavyObject }, 'debug info')  // Pino 只在 debug 开启时才序列化

日志收集与查询

环境 推荐方案
本地开发 pino-pretty 彩色输出 / Winston Console transport
小型自建服务器 写文件 → DailyRotateFilelogrotate
容器 / K8s 直接打到 stdout,由日志 agent(Filebeat / Fluent Bit)收集
云原生 Cloudwatch Logs / 阿里云 SLS / Grafana Loki
全文搜索 Elasticsearch + Kibana(ELK)/ OpenSearch

不要在 NestJS 里实现日志上传。把日志打到 stdout,由基础设施(Agent / Sidecar)处理传输。

一图流总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
请求进来
  │
  ▼
TraceInterceptor / pino-http 中间件
  → 生成 traceId,注入 AsyncLocalStorage
  │
  ▼
业务 Service
  → this.logger.log({ msg, traceId, userId, ... })
  │
  ▼
Exception Filter(未捕获的错误)
  → this.logger.error({ msg, stack, traceId })
  │
  ▼
Pino / Winston
  → 格式化为 JSON
  → 写 stdout(容器)/ 文件(VM)
  │
  ▼
日志 Agent(Filebeat / Fluent Bit / CloudWatch)
  → 上传到 ELK / Loki / SLS
  │
  ▼
Kibana / Grafana 查询 traceId → 串联整条请求链路

参考