白留白WHITE SPACE
返回所有文章
NestJS / 后端开发

NestJS 实战 11:日志、请求链路追踪与问题排查

让 BBS 项目具备稳定的日志能力,方便排查线上与开发环境中的问题。统一记录关键日志:应用启动、HTTP 请求、业务异常、数据库错误。为每次请求挂上 requestId,把同一次请求中的多条日志串起来。建立“什么时候该打日志、打什么日志、哪些内容不能打”…

本文属于 NestJS BBS 实战教程,共 11 节,建议按顺序学习。

1. 这节课要实现的功能是什么

  • 让 BBS 项目具备稳定的日志能力,方便排查线上与开发环境中的问题。
  • 统一记录关键日志:应用启动、HTTP 请求、业务异常、数据库错误。
  • 为每次请求挂上 requestId,把同一次请求中的多条日志串起来。
  • 建立“什么时候该打日志、打什么日志、哪些内容不能打”的基本工程习惯。

2. 这节课涉及的 NestJS 知识点有哪些(先讲概念)

2.1 为什么日志是一节单独的课

很多初学者会把日志理解成“在代码里随手写几个 console.log”。这在小 demo 里能工作,但在真实项目里很快就会失控。

日志真正解决的是三个问题:

  • 系统现在发生了什么。
  • 某个请求是在哪一步失败的。
  • 问题发生时,相关上下文是什么。

NestJS 本身提供了日志能力,但更重要的是:Nest 的模块化、依赖注入、Interceptor、Exception Filter 这些机制,天然适合把日志做成一套统一能力,而不是散落在各个 Service 里的临时输出。

2.2 Logger 是什么,为什么 Nest 要提供它

Nest 内置的 Logger 是一个日志工具类,用来输出不同级别的日志。最常见的级别有:

  • log:普通运行日志,比如应用启动成功。
  • warn:可疑但不一定失败的情况,比如缓存未命中、配置缺失兜底。
  • error:明确的错误,比如数据库操作失败、第三方接口异常。
  • debug:调试阶段使用的细节信息。
  • verbose:比 debug 更细的运行细节。

它存在的意义,不只是为了替代 console.log,而是为了让你的日志表达更清晰。别人看到一条 error 日志,会立刻知道这是故障线索;看到 warn,就知道这是需要关注但还没完全失败的问题。

2.3 日志应该记录什么,不应该记录什么

在项目里打日志,不是“越多越好”,而是“关键路径要有,敏感信息不能有”。

推荐记录:

  • 请求方法、路径、状态码、耗时。
  • 关键业务动作,比如“用户注册成功”“主题创建成功”。
  • 异常上下文,比如用户 ID、帖子 ID、请求 ID。

不应该记录:

  • 明文密码。
  • 完整 JWT。
  • 数据库密钥、第三方密钥。
  • 体积很大的原始请求体。

日志的目标是排查问题,不是泄露数据。

2.4 为什么请求日志通常放在 Interceptor,而不是散落在 Controller 里

请求日志属于典型的横切逻辑。所谓横切逻辑,就是“很多接口都需要,但它不属于某个具体业务模块”。

如果你把请求日志写在每个 Controller 方法里,会有三个问题:

  • 每个接口都要重复写一遍。
  • 日志格式不统一。
  • 后面如果想统一增加耗时、状态码、请求 ID,会非常难改。

Interceptor 更适合这件事,因为它天然包裹 Controller 的执行过程。你可以在进入 Controller 之前记录“请求开始”,也可以在返回响应之后记录“请求结束”和“耗时”。

2.5 requestId 是什么,为什么日志里经常要有它

当系统里只有一两个请求时,看日志很简单。但真实服务里,很多请求会同时到来,日志会交错在一起。

这时就需要一个“请求链路编号”。最常见的做法是给每个请求分配一个 requestId,然后把同一次请求中的日志都带上这个字段。

这样做的价值非常直接:

  • 一次请求从入口到异常,能串成一条线。
  • 前端、网关、Nest 服务、数据库代理都可以共用同一个链路编号。
  • 排查问题时,不需要在一堆混杂日志里盲找。

2.6 应用日志、请求日志、异常日志分别应该放在哪里

这三类日志经常混在一起,但职责不同:

  • 应用日志:通常写在 main.ts、模块初始化、定时任务启动等位置。
  • 请求日志:通常放在 Interceptor 或 Middleware 中。
  • 异常日志:通常放在 Exception Filter 或明确的 catch 分支中。

理解这个分层很重要。它能帮助你判断“这条日志应该放在什么地方才合理”,而不是一遇到问题就往 Service 里塞输出语句。

2.7 出现问题时,应该去哪里查 NestJS 的日志

这里要先建立一个很重要的认知:NestJS 默认并没有内置“日志查询系统”。Nest 做的事情,是把日志输出出去;至于你怎么查,取决于日志最终落到了哪里。

最常见的几种位置是:

  • 本地开发:直接看终端输出。
  • Docker 部署:查看容器日志。
  • PM2 部署:查看 PM2 管理的进程日志。
  • Linux 服务托管:查看 systemd 或宿主机日志。
  • 文件输出:直接查看日志文件。
  • 日志平台:在 ELK、Loki、Datadog、Sentry 等系统里搜索。

所以排查问题时,更准确的说法不是“我要查 Nest 的日志”,而是“我要查 Nest 输出到哪个介质的日志”。

2.8 真正查日志时,应该按什么维度去找

很多新手排查问题时,习惯凭时间点去翻日志。这种方式在请求少的时候还能用,但一旦并发上来,效率会很低。

更合理的做法,是优先用这些字段去搜索:

  • requestId
  • 用户 ID
  • 请求路径
  • 状态码
  • 错误级别
  • 异常堆栈

其中最有价值的,通常是 requestId。因为它能把一次请求从入口日志、业务日志、异常日志串成一条线。只要前端、网关、Nest 服务共享同一个 requestId,排查效率会明显提高。

2.9 日志文件越来越大,应该怎么处理

日志文件变大是必然现象,真正的问题不是“文件大”,而是“文件无限增长、难以检索、难以清理”。

工程上常见的处理方式有三种:

  • 日志轮转:按天或按大小切分新文件。
  • 压缩归档:旧日志自动压缩,减少磁盘占用。
  • 集中采集:把日志采集到统一平台做检索和分析。

如果你现在还是文件方案,至少要做到:

  • 不让单个日志文件无限增大。
  • 保留最近一段时间的日志。
  • 旧日志自动压缩或删除。
  • 重要日志具备统一字段,方便搜索。

2.10 小项目和正式生产环境,日志治理思路有什么区别

不同阶段,日志方案不需要一开始就上最重的系统,但思路要清楚。

  • 开发环境:控制台输出就够用,重点是看得见、调试快。
  • 小型部署:文件日志配合轮转已经能解决大部分问题。
  • 容器化部署:更常见的做法是输出到标准输出,再交给平台采集。
  • 正式生产:更推荐结构化日志和集中式检索平台。

你可以把这件事理解为一个渐进升级过程:先保证“有日志”,再保证“查得到”,最后保证“能关联、能检索、能长期治理”。

3. 实际开发中这些 NestJS 知识点被如何使用(详细代码)

3.1 src/common/logger/app-logger.service.ts - 封装统一日志入口

先看一个最基本、也是最实用的做法:封装一个项目级日志服务,而不是到处直接 new Logger()。

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class AppLoggerService {
  private readonly logger = new Logger('App');

  log(message: string, context?: string, meta?: Record<string, unknown>) {
    this.logger.log(this.format(message, meta), context);
  }

  warn(message: string, context?: string, meta?: Record<string, unknown>) {
    this.logger.warn(this.format(message, meta), context);
  }

  error(message: string, trace?: string, context?: string, meta?: Record<string, unknown>) {
    this.logger.error(this.format(message, meta), trace, context);
  }

  private format(message: string, meta?: Record<string, unknown>) {
    if (!meta) {
      return message;
    }

    return `${message} ${JSON.stringify(meta)}`;
  }
}

这里的重点不是“多包了一层”,而是为后续扩展留好位置。真实项目里,你很可能会继续加上:

  • 统一 JSON 结构化输出。
  • 自动补上 requestId。
  • 接入 winston、pino、ELK、Datadog、Sentry。

如果日志散落在每个文件里,后面这些升级会很痛苦。

3.2 常见部署场景下,日志通常怎么查询

这一节不写 Nest 代码,而是帮你建立实际排障时的操作意识。因为很多问题不是“代码不会写”,而是“出故障后不知道去哪里找证据”。

如果你的服务部署方式不同,查日志的入口也不同:

# Docker
docker logs <container-name>

# 实时跟踪
docker logs -f <container-name>

# PM2
pm2 logs

# Linux systemd
journalctl -u nest-app -f

# 文件日志
tail -f logs/app.log
grep "requestId" logs/app.log

这里要注意一个工程判断:这些命令只是“查看入口”,真正提高排障效率的,仍然是日志内容本身是否带了 requestId、路径、状态码、用户 ID 这些关键信息。

3.3 src/common/interceptors/logging.interceptor.ts - 统一记录请求开始与结束

请求日志最适合放在 Interceptor 中,因为它能同时拿到“请求进入”和“响应返回”两个时机。

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { AppLoggerService } from '../logger/app-logger.service';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private readonly logger: AppLoggerService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const http = context.switchToHttp();
    const request = http.getRequest<Request & { requestId?: string }>();
    const response = http.getResponse<{ statusCode: number }>();
    const startedAt = Date.now();

    this.logger.log('HTTP request started', LoggingInterceptor.name, {
      method: request.method,
      path: request.url,
      requestId: request.requestId ?? null,
    });

    return next.handle().pipe(
      tap({
        next: () => {
          this.logger.log('HTTP request completed', LoggingInterceptor.name, {
            method: request.method,
            path: request.url,
            statusCode: response.statusCode,
            durationMs: Date.now() - startedAt,
            requestId: request.requestId ?? null,
          });
        },
        error: (error) => {
          this.logger.error('HTTP request failed', error?.stack, LoggingInterceptor.name, {
            method: request.method,
            path: request.url,
            statusCode: response.statusCode,
            durationMs: Date.now() - startedAt,
            requestId: request.requestId ?? null,
          });
        },
      }),
    );
  }
}

这一段代码要重点理解两个点:

  • next.handle() 代表“继续执行后面的 Controller”。
  • tap() 允许你在响应成功或失败后追加日志,而不用改每个业务方法。

这就是 Interceptor 特别适合日志的原因:日志能力被抽到框架层统一处理,业务代码保持干净。

3.4 src/common/middleware/request-id.middleware.ts - 为请求生成 requestId

如果前面的网关或 Nginx 没有传 x-request-id,Nest 应用也可以自己兜底生成。

import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';

type RequestWithId = Request & { requestId?: string };

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: RequestWithId, res: Response, next: NextFunction) {
    const requestId = req.header('x-request-id') ?? randomUUID();

    req.requestId = requestId;
    res.setHeader('x-request-id', requestId);

    next();
  }
}

这个中间件做了三件很重要的事:

  • 如果客户端已经传了 x-request-id,就沿用它。
  • 如果没有传,就生成一个新的 UUID。
  • 同时把它放到请求对象和响应头中,方便日志与客户端排查一起对齐。

这也是日志工程里很常见的“前后端共用链路编号”做法。

3.5 src/app.module.ts - 在模块里注册日志相关能力

日志能力真正可用,不是因为你写了类,而是因为你把它们正确注册进了 Nest 的模块系统。

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppLoggerService } from './common/logger/app-logger.service';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';

@Module({
  providers: [
    AppLoggerService,
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(RequestIdMiddleware).forRoutes('*');
  }
}

这段代码分别体现了两种 NestJS 机制:

  • APP_INTERCEPTOR:把 LoggingInterceptor 注册为全局拦截器。
  • consumer.apply(...).forRoutes('*'):把 RequestIdMiddleware 应用到所有 HTTP 路由。

这两者配合后,就形成了完整的请求日志链路:

  1. 请求先进入 RequestIdMiddleware,获得 requestId。
  2. 然后进入 LoggingInterceptor,记录开始日志。
  3. Controller 和 Service 正常执行业务。
  4. 响应返回或抛错后,LoggingInterceptor 再记录结束或错误日志。

3.6 文件日志越来越大时,轮转通常怎么做

如果你把日志落到文件中,就一定会遇到文件越来越大的问题。这个问题不能靠“人工定期删文件”解决,而应该交给日志轮转机制。

在 Linux 环境里,一个很常见的方案是 logrotate。例如:

/var/log/nestjs/app.log {
  daily
  rotate 14
  compress
  missingok
  notifempty
  copytruncate
}

这段配置的含义是:

  • 每天轮转一次日志。
  • 最多保留 14 份历史日志。
  • 历史日志自动压缩。
  • 文件不存在也不要报错。
  • 空文件不轮转。

它解决的不是业务问题,而是运维治理问题:防止日志长期无限膨胀,占满磁盘。

3.7 src/users/users.service.ts - 在业务层记录关键动作,而不是记录所有细节

日志不是只放在框架层。业务层也需要记录“关键业务动作”,但要克制,不要把每一步内部细节都打出来。

import { ConflictException, Inject, Injectable } from '@nestjs/common';
import { AppLoggerService } from '../common/logger/app-logger.service';
import { USERS_REPOSITORY, UsersRepository } from './users.repository';

@Injectable()
export class UsersService {
  constructor(
    @Inject(USERS_REPOSITORY)
    private readonly usersRepository: UsersRepository,
    private readonly logger: AppLoggerService,
  ) {}

  async create(dto: CreateUserDto) {
    const existing = await this.usersRepository.findByEmail(dto.email);

    if (existing) {
      this.logger.warn('User registration rejected because email already exists', UsersService.name, {
        email: dto.email,
      });

      throw new ConflictException('邮箱已存在');
    }

    const user = await this.usersRepository.create(dto);

    this.logger.log('User created successfully', UsersService.name, {
      userId: user.id,
      email: user.email,
    });

    return user;
  }
}

这一段体现的是日志粒度控制:

  • “用户创建成功”值得记录,因为这是关键业务动作。
  • “执行到第 3 行了”“仓储方法刚开始调用”这种日志通常没有长期价值。

日志太少,排查不了问题;日志太多,真正有价值的信息会被噪声淹没。

3.8 src/common/filters/all-exceptions.filter.ts - 在异常出口统一记录错误

异常日志也不应该每个 Controller 自己写一遍。更合理的方式,是在统一异常过滤器里集中记录。

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { AppLoggerService } from '../logger/app-logger.service';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(private readonly logger: AppLoggerService) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request & { requestId?: string }>();
    const response = ctx.getResponse<Response>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    this.logger.error('Unhandled exception captured', exception instanceof Error ? exception.stack : undefined, AllExceptionsFilter.name, {
      method: request.method,
      path: request.url,
      requestId: request.requestId ?? null,
      statusCode: status,
    });

    response.status(status).json({
      statusCode: status,
      message: exception instanceof HttpException ? exception.message : '服务器内部错误',
      requestId: request.requestId ?? null,
    });
  }
}

这样做的价值很明显:

  • 用户拿到错误响应时,也能看到 requestId。
  • 运维或后端同学可以用同一个 requestId 去检索日志。
  • 即使业务层没来得及记录,异常出口也至少会保留一条故障线索。

3.9 本节落地价值

完成这一节后,你的项目不再只是“能跑”,而是开始具备基本可观测性。

这会带来非常直接的工程收益:

  • 本地调试更快,知道请求走到了哪一步。
  • 线上排障更快,知道哪一次请求失败、为什么失败。
  • 服务协作更顺畅,前端、网关、后端能通过 requestId 对齐同一次请求。

4. 总结这节课知识点

  • 你理解了 Nest 内置 Logger 的作用,以及为什么它比随手写 console.log 更适合工程项目。
  • 你知道了请求日志、业务日志、异常日志应该分别放在什么位置。
  • 你掌握了 Middleware + Interceptor + Exception Filter 组合实现日志链路的方式。
  • 你理解了 requestId 的价值:它不是装饰字段,而是排查问题时非常关键的链路标识。
  • 你建立了更重要的工程意识:日志不是写给程序看的,而是写给未来排查问题的人看的。

5. 每节课分支

  • lesson-11-logging-observability
  • 远程仓库:https://github.com/Shiuwn/nestjs-tutorial.git

返回课程目录

上一课:单元测试、E2E 测试与交付标准

— 感谢阅读 —