NestJS小技巧08-统计异常和错误指标

692 阅读6分钟
by 雪隐 from https://juejin.cn/user/1433418895994094
本文欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权

原文链接

在上一章节中,我描述了如何使NestJS中的错误更具信息性和统一性。在这个章节中,我将向您展示如何将错误不仅转换为日志行,还转换为普罗米修斯Prometheus用来监控和度量。

如果你还没有读过上一章,我强烈鼓励你读。它将为你提供结构合理的例外情况。

1. 将普罗米修斯添加到我们的项目中

安装nestjs-prometheus

pnpm i --save @willsoto/nestjs-prometheus prom-client

接下来,我们需要在app.module.ts中导入PrometheusModule

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrometheusModule} from '@willsoto/nestjs-prometheus'

@Module({
  /**** 重点 ****/
  imports: [PrometheusModule.register()],
  /**** 重点end ****/
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

检查暴露的指标:

## 浏览器访问下面的地址
http://localhost:3000/metrics

## 得出的结果
# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds.
# TYPE process_cpu_user_seconds_total counter
process_cpu_user_seconds_total 0.292606

# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
# TYPE process_cpu_system_seconds_total counter
process_cpu_system_seconds_total 0.110539

# ...
# ... 

您在这里看到的度量是由nestjs-prometheus公开的默认Node.js度量。签出有关如何禁用它们的文档

创建错误计数器

为了导出发生的异常,我们需要创建一个单独的计数器。计数器可以具有用于对收集的度量进行分类的标签。所有例外都有哪些共同的资源?

  • 错误域(例如用户、订单等)
  • 响应状态代码
  • (可选)错误代码(不是本文的一部分)

不要使用标签来存储基数较高的维度,例如错误消息。这将大大增加存储的数据量。

所以,首先让我们为我们的错误创建一个计数器。在app.module.ts中添加新的提供程序:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import {
  PrometheusModule,
  makeCounterProvider
} from '@willsoto/nestjs-prometheus'

@Module({
  /**** 重点 ****/
  imports: [PrometheusModule.register()],
  /**** 重点end ****/
  controllers: [AppController],
  providers: [
    /**** 重点 ****/
    makeCounterProvider({
      name: 'nestjs_errors',
      help: 'nestjs_errors', // 我知道你可以做的更好
      labelNames: ["domain","status"] // 我们要跟踪的标签名称
    }),
    /**** 重点end ****/
    AppService],
})
export class AppModule {}

将错误计数器添加到ExceptionFilter

为了使用我们的计数器,我们需要将它注入到处理所有异常的地方。这是exception.filter.ts

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
/** 重点 */
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter } from 'prom-client';
import { BusinessException, ErrorDomain } from 'src/business.exception';
/** 重点end */

@Catch(Error)
export class CustomExceptionFilter implements ExceptionFilter {
  ...

  constructor(
    /** 重点 */
    @InjectMetric('nestjs_errors')
    private readonly counter: Counter<string> /** 重点end */,
  ) {}

  catch(exception: Error, host: ArgumentsHost) {
    ...
  }
}

这就是事情变得有趣的地方。之前,我们在main.ts中手动初始化了CustomExceptionFilter:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomExceptionFilter } from './exception/exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 手动初始化我们的ExceptionFilter
  app.useGlobalFilters(new CustomExceptionFilter());
  await app.listen(3000);
}
bootstrap();

但是,因为现在我们的CustomExceptionFilter需要成为依赖注入的一部分,所以它必须作为app.module.ts中的全局筛选器添加:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import {
  PrometheusModule,
  makeCounterProvider,
} from '@willsoto/nestjs-prometheus';
import { CustomExceptionFilter } from './exception/exception.filter';
/**** 重点 ****/
import { APP_FILTER } from '@nestjs/core';
/**** 重点end ****/

@Module({
  /**** 重点 ****/
  imports: [PrometheusModule.register()],
  /**** 重点end ****/
  controllers: [AppController],
  providers: [
    /**** 重点 ****/
    makeCounterProvider({
      name: 'nestjs_errors',
      help: 'nestjs_errors', // 我知道你可以做的更好
      labelNames: ['domain', 'status'], // 我们要跟踪的标签名称
    }),
    {
      provide: APP_FILTER,
      useClass: CustomExceptionFilter,
    },
    /**** 重点end ****/
    AppService,
  ],
})
export class AppModule {}

使用错误计数器

作为上一章的结果,我们有了一个统一的错误类型,作为应用程序中发生的所有错误的结果。因此,我们可以简单地将其财产用作标签值,并在exception.filter.ts中增加错误计数器:

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Injectable,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { BusinessException, ErrorDomain } from './business.exception';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter } from 'prom-client';

// ...

@Catch(Error)
export class CustomExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(CustomExceptionFilter.name);

  constructor(
    @InjectMetric('nestjs_errors') private readonly counter: Counter<string>,
  ) {}

  catch(exception: Error, host: ArgumentsHost) {
    let error: BusinessException;
    let status: HttpStatus;

    //一些代码然后触发this.counter.labels(error.domain,error.status.toString()).inc(); 

    response.status(status).json(error.toApiError());
  }
}

好吧,我们现在的情况是,每次发生错误,我们的计数器都会增加,并作为一个度量暴露出来。我们将在接下来的步骤中看到数据。

生成一些错误

在app.controller.ts中,让我们使端点在每个请求上抛出所有类型的随机错误:

import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
import { BusinessException, ErrorDomainEnum } from './business.exception';

@Controller()
export class AppController {
  @Get()
  public getHello() {
    throw AppController.genError();
  }

  private static genError(): Error {
    const getRandom = <T>(...values: T[]): T =>
      values[Math.floor(Math.random() * values.length)];

    const domain = getRandom(
      ErrorDomainEnum.Generic,
      ErrorDomainEnum.Orders,
      ErrorDomainEnum.Users,
    );
    const status = getRandom(
      HttpStatus.BAD_REQUEST,
      HttpStatus.NOT_FOUND,
      HttpStatus.CONFLICT,
      HttpStatus.FORBIDDEN,
      HttpStatus.UNAUTHORIZED,
      HttpStatus.BAD_GATEWAY,
      HttpStatus.GATEWAY_TIMEOUT,
    );

    switch (Math.floor(Math.random() * 3)) {
      case 0:
        return new HttpException('nestjs-exception', status);
      case 1:
        return new Error('unknown-error');
      case 2:
        return new BusinessException(
          domain,
          'business-exception',
          'business-exception',
          status,
        );
    }
  }
}

设置普罗米修斯

我将使用docker-compose来挂载一个普罗米修斯的实例。此外,我将把我们的服务作为一个码头集装箱启动,以简化普罗米修斯和我们的服务之间的连接。我有一个关于如何对接NestJS应用程序的分步指南

version: '3.9'
services:
  nestjs:
    image: 'node:19-bullseye-slim'
    user: 'node'
    working_dir: /home/app
    container_name: instructor-nodejs
    ports:
      - '3000:3000'
    volumes:
      # 挂载日志
      - ./logs:/home/app/logs
      # 挂载各种第三方插件
      - ./node_modules:/home/app/node_modules
      # 挂载主要程序
      - ./dist:/home/app/dist
    command: node /home/app/dist/main.js
    environment:
      RUNNING_ENV: 'prod'
  prometheus:
    image: prom/prometheus:v2.36.2
    ports:
      - '9090:9090'
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    # - prometheus-data:/prometheus # TODO
    command: --config.file=/etc/prometheus/prometheus.yml

告诉Prometheus从我们的服务中获取指标,docker compose/Prometheus/Prometheus.yml:

global:  
  scrape_interval: 5s # Scrape every 5 seconds  
  scrape_timeout: 1s  
  
scrape_configs:  
  - job_name: services  
    metrics_path: /metrics  
    static_configs:  
      - targets:  
        - 'nestjs:3000' # Name of our service in docker-compose

这个文件稍后被装载到普罗米修斯容器中。

生成度量标准

好吧,我们现在做得很好。让我们把所有东西都跑一遍,看看普罗米修斯身上有什么。在项目目录中运行:

docker-compose up
[+] Running 3/3
Attaching to docker-prometheus-1, instructor-nodejs
docker-prometheus-1  | ts=2023-03-28T06:19:25.956Z caller=main.go:491 level=info msg="No time or size retention was set so using the default time retention" duration=15d
...
nestjs               | [Nest] 1  - 03/28/2023, 6:21:30 AM     LOG [NestFactory] Starting Nest application...

让我们打开prometheus的仪表板http://localhost:9090/graph.

对我们的错误度量的查询只是nestjs_errors,还记得我们是如何命名计数器的吗?

Phome.jpg

我们得到空结果的原因是,自从我们推出服务以来,没有流量。让我们向我们的服务发出一些请求:

while true; do curl http://localhost:3000/  ; sleep 0.2; done;
{"domain":"generic","apiMessage":"nestjs-exception","status":404,"id":"nPjXH4gi08zfuQEc","timestamp":"2023-03-28T06:32:02.531Z"}{"domain":"generic","apiMessage":"nestjs-exception","status":502,"id":"eYzO0XinFsXH1Ckd","timestamp":"2023-03-28T06:32:02.754Z"}{"id":"fZEM3HgoaBWWXogV","message":"business-exception","domain":"users","timestamp":"2023-03-28T06:32:02.977Z"}
... 

好吧,我们已经生成了一些流量(如果你想持续生成指标,你可以让它继续运行)。重新运行prometheus查询:

graph.jpg

令人惊叹的现在我们有一些指标可以使用。让我们为他们制作一个合适的图表

可视化指标

还记得我们的第一部分要求吗?其中一个目标是能够对我们的错误进行分类。此外,我们肯定希望有错误每秒图表。我们将使用rate prometheus函数,并按域和HTTP状态对指标进行分组:

sum by (domain, status)(rate(nestjs_errors[30s])

sum.jpg

就是这样!我们实现了目标。我们有结构化、统一、丰富的错误,可以通过id引用(例如由用户引用),并轻松地在日志中搜索。此外,所有代码都可以提取到一个单独的库中,并在您的公司内共享。

总结

在本文中,我详细描述了如何收集NestJS错误,并将它们作为度量导出到监控系统(在我们的案例中是Prometheus)。

代码