Sentry 集成与 NestJS 实践

839 阅读11分钟

团队内使用 Sentry 监控前端线上报错,但效果一直不太理想,究其原因,主要有以下几点:

  • 非故障引起的告警多,导致“狼来了”效应,研发对告警不敏感。比如,一些正常的前端校验,由于抛出了 Error 而被监控到,甚至浏览器插件的报错,也被监控到并告警;
  • 通知对象不精准。由于项目很大,由几十个微前端模块组成,并由十几位前端同事分工负责,但告警是以广播的形式,发送给所有人,久而久之,大家都无视了相关告警;
  • 告警信息杂乱,重点不清晰。由于 Sentry 默认的告警信息情况较多,是否能快速识别出故障,依赖研发经验。同时,另外一些指示是故障的风险因素,如模块正在灰度中、影响了 S 级用户等,从现有信息中是无法获取的。

针对这些问题,也尝试了 Sentry 自带的过滤功能,但灵活性有限。同时,由于使用的是私有部署的 Sentry,版本较低,一些新特性不支持,所以考虑以集成的方式,来解决这些问题。

Sentry 支持 Api 集成调用,同时支持 WEBHOOKS 接收告警,所以以此方式来做集成。

功能设计

结合现状,共设计四块的功能:降噪、通知、风险识别和信息聚合。

降噪,即过滤掉非实际故障的告警。比如,浏览器插件报错、正常功能的无权限报错、表单校验报错等。

通知,即优化告警的通知触达性。由于原本 Sentry 支持的是邮件方式,但邮件的通知即时性不如即时通信,公司内使用的是企业微信,所以通过企业微信的应用号来通知。同时,解析 Sentry 告警中的错误栈信息,找出关联的前端模块,并将告警定向发送给前端模块分工的负责同事。

风险识别,即根据 Sentry 告警信息,提取相关特征,来评估告警的风险等级。根据业务特点,总结了以下几点风险特征:

  • 短时间内相同告警数量激增:通过计算最近 7 天、单个 Issue 的 Event 数量的相对标准差(RSD,即标准差/平均值)来衡量,根据经验,如果 RSD 大于 1.5,则说明该 Issue 最近数量有激增;

  • 引发告警的相关 JS 模块的发布时间较短:提取错误栈中的前端模块,结合内部前端灰度系统,如果相关模块的发布时间小于 7 天,则符合该条风险;

  • 短时间内相同告警数量激增:同样是提取前端模块,结合前端灰度系统,如果相关模块正在灰度中,则符合该条风险;

  • 从报错信息看大概率是前端 Bug 的:如果报错信息中包含一些关键词,如:TypeError/RangeError/SyntaxError/ReferenceError 等,根据经验,是真实的前端故障的可能性较大,符合该条风险;

  • 引发告警的相关模块是前端的重要模块:提取错误栈中的前端模块,如果涉及公共模块或重要的业务模块,则说明错误的影响程度可能较大,符合该条风险;

  • 影响外部的 S/A 级用户:在我们的 Sentry 配置中,会一同上报用户 ID,在集成的程序中,根据上报的用户 ID,调用内部接口查看用户等级,如果是高级别用户,则符合该条风险;

  • 多个不同 ID 的用户遇到的问题:从 Sentry 告警信息中,统计受影响的用户数量(去重),如果大于 3 位,则符合该条风险。

根据以上风险特征匹配下来的结果做统计,如果符合 5 条以上的,判定为 P0 级的告警,2-4 条的,判定为 P1 级告警,其他的为 P2 告警,从而实现对告警的分级。

信息聚合,即将有助于排障的帮助信息聚合。包括,Sentry 主要信息、相关的用户在对应时间段内的用户操作日志、风险因素提示等。

模块设计

根据功能设计,决定基于 NestJS 开发一个服务,实现 Sentry 告警的监听、过滤、信息聚合和推送,并设计以下模块:

  • Watch 处理 sentry 发送的告警信息
  • Events 对 events 进行处理分析
  • Auth 处理 sentry、企业微信等关联系统的鉴权
  • Sentry 获取 sentry 相关信息
  • Gray 获取前端灰度系统的相关信息
  • Matomo 获取 matomo 上用户操作日志信息
  • Notification 处理消息的通知发送
  • Quene 基于 Bull 消息队列

具体模块设计:

01.png

时序图:

02.png

实现的告警通知效果:

03.png

技术点

在使用 NestJS 开发过程中,发现框架预先集成了诸多设计模式和实用工具,为代码质量和可维护性带来了保障。以下总结了部分技术点,值得后续在其他前端项目中借鉴。

模块

在 NestJS 中,模块是组织代码的基本单位,也是 NestJS 的精髓。这里你可能会问,如今的 JS 不是都以 ESM 或 Commonjs 的模块规范开发了么?是的,但是光有模块还不行,还需要组织好模块间的关系。那 NestJS 是怎么做的呢?

首先,NestJS 中的模块是通过@Module装饰器定义的。由此来规定,一个模块需要包含的依赖、控制器和提供者:

import { Module } from "@nestjs/common";
import { CatsController } from "./cats.controller";
import { CatsService } from "./cats.service";

@Module({
  imports: [], // 依赖的模块
  controllers: [CatsController], // 控制器
  providers: [CatsService], // 提供者
})
export class CatsModule {}

然后,定义一个根模块,作为应用程序的入口:

import { Module } from "@nestjs/common";
import { CatsModule } from "./cats/cats.module";

@Module({
  imports: [CatsModule], // 导入其他模块
})
export class AppModule {}

模块可以通过imports属性指定需要导入的模块,也可以通过exports属性导出其提供的服务,使其他模块可以使用:

import { Module } from "@nestjs/common";
import { CommonModule } from "./common/common.module";
import { FeatureService } from "./feature.service";

@Module({
  imports: [CommonModule], // 导入其他模块
  providers: [FeatureService],
  exports: [FeatureService], // 导出服务
})
export class FeatureModule {}

其他的话, NestJS 还支持可配置的动态模块、全局模块、模块生命周期钩子等特性,具体可以参考官方文档。

这里提一下 NestJS 使用的两种设计模式:依赖注入单例模式

在模块的导入中,NestJS 会通过依赖注入,将模块的依赖项做解析并注入到构造函数中:

import { Injectable } from "@nestjs/common";

@Injectable()
export class CatsService {
  constructor(private readonly commonService: CommonService) {}
}

相比于 esm 直接的导入和导出,使用依赖注入模式,将模块间的依赖关系放在外部声明,而在模块内,只需要调用注入进来的实例提供的方法来实现自己的功能。这对于简单的应用来说,可能有过度设计之嫌;但对于大型应用,比如做单元测试、或者要替换依赖模块则会非常方便。

而依赖注入,底层思想就是控制反转,这对于前端开发来说,再熟悉不过了。比如在 React 中,通过 props 传递函数给子组件,子组件调用函数来改变父组件的 state,这类就是典型的控制反转。通过控制反转,更好地管理模块间的耦合部分,从而提高系统的可维护性。

另一点,关于单例模式:在 NestJS 中的模块,默认都是单例模式。当程序启动后,NestJS 会从根模块开始解析依赖关系,将模块的providers逐级实例化,并将实例缓存,在后续依赖注入中实现复用。

对于前端开发者来说,这也是非常友好的。因为基于 esm 的模块化,很自然地就会运用单例模式,比如要实现一个缓存模块:

const store = new Map();

const cache = {
  set(key, value) {
    store.set(key, value);
  },
  get(key) {
    return store.get(key);
  },
  remove(key) {
    store.delete(key);
  },
  clear() {
    store.clear();
  },
  size() {
    return store.size;
  },
};

export default cache;

单例模式有减少内存开销、便于状态共享、保持数据同步等诸多功能。在前端开发中,随着 TS 的广泛使用,越来越多的项目里,开始使用 Class 的方式来定义模块,这本身没有问题,但要避免的是,过多没必要的对类做实例化,造成多余的开销。使用单例模式,或许会是更好的方式。

装饰器

NestJS 充分利用了 TS 的装饰器功能,通过装饰器简化了依赖注入、路由处理、请求验证等功能的实现。以下是一些常见的 NestJS 装饰器及其用途:

  • @Controller():定义一个控制器类。
  • @Get(), @Post(), @Put(), @Delete():定义路由处理方法。
  • @Injectable():定义一个可注入的服务类。
  • @Param(), @Query(), @Body():获取请求参数、查询参数和请求体。
import { Controller, Get, Post, Body, Param } from "@nestjs/common";

@Controller("cats")
export class CatsController {
  @Get()
  findAll(): string {
    return "This action returns all cats";
  }

  @Get(":id")
  findOne(@Param("id") id: string): string {
    return `This action returns a #${id} cat`;
  }

  @Post()
  create(@Body() createCatDto: any): string {
    return "This action adds a new cat";
  }
}

JS 的装饰器从最早提出到目前已经经历了十年,目前仍在 Stage 3 阶段,而由于 TS 很早就引入了装饰器,所以也导致装饰器存在多种版本。

NestJS 项目的 TS 配置里,默认开启了experimentalDecorators,也就是说,采用的装饰器是基于 Stage 1 的版本,基本等同于@babel/plugin-proposal-decoratorsversion: legacy版本。

装饰器本身也是一种设计模式,即装饰器模式(Decorator Pattern)。它允许在不改变对象接口的情况下,向对象添加新的功能,从而增强了代码的灵活性和可扩展性。

在前端开发中,也可以自定义装饰器来使代码更加简洁。比如,定义一个防抖的装饰器:

function debounce(delay: number): MethodDecorator {
  return function (
    target: any,
    propertyKey: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;
    let timeout = null;

    descriptor.value = function (...args: any[]) {
      if (timeout) {
        clearTimeout(timeout);
      }
      timeout = setTimeout(() => {
        originalMethod.apply(this, args);
      }, delay);
    };

    return descriptor;
  };
}

装饰器可以应用于类声明、方法、访问器、属性或参数上,但不能用于函数,因为存在函数提升。

RxJS

RxJS 是一个用于处理异步编程和事件流的库。它最核心的,是提供了一个可观察对象(Observables)来处理异步数据流、以及各种操作符来进行数据流的转换、过滤和组合。

关于创建 RxJS 的意义,官方文档在介绍 Observables 中,对于拉取与推送两种模式的对比一节里,已经讲的很清晰了,具体可以阅读拉取与推送

NestJS 的项目,默认已经安装了 RxJS,并且在官方提供的、涉及异步逻辑的模块中,已经很好地集成了 RxJS。

比如,要处理 HTTP 请求:

import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { map } from "rxjs/operators";
import { Observable, catchError } from "rxjs";
import { AxiosError } from "axios";

@Injectable()
export class DataService {
  constructor(private readonly httpService: HttpService) {}

  fetchData(): Observable<any> {
    return this.httpService.get("https://api.example.com/data").pipe(
      map((response) => response.data),
      catchError((error: AxiosError) => {
        console.error(error.message);
        throw "An error happened!";
      })
    );
  }
}

在控制器中使用:

import { Controller, Get } from "@nestjs/common";
import { DataService } from "./data.service";
import { Observable, lastValueFrom } from "rxjs";

@Controller("data")
export class DataController {
  constructor(private readonly dataService: DataService) {}

  @Get()
  async getData(): Promise<any> {
    const data = await lastValueFrom(this.dataService.fetchData());
    return data;
  }
}

在前端项目中,我们也可以引入 RxJS,帮助简化代码的异步逻辑。比如,创建一个带搜索的列表组件,用户在输入搜索内容时,输入框的变化将通过 RxJS 管道处理,去抖动后进行 API 请求并更新结果列表。同时,借助switchMap操作符,实现在每次新查询到来时取消前一个未完成的 API 请求,避免了异步请求的竞争问题:

import React, { useState, useEffect } from "react";
import { Subject } from "rxjs";
import { debounceTime, switchMap } from "rxjs/operators";

const fetchSearchResults = (query) => {
  // 模拟API请求
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(
        ["Result 1", "Result 2", "Result 3"].filter((item) =>
          item.toLowerCase().includes(query.toLowerCase())
        )
      );
    }, 500);
  });
};

const SearchComponent = () => {
  const [searchQuery, setSearchQuery] = useState("");
  const [results, setResults] = useState([]);
  const searchSubject = new Subject();

  useEffect(() => {
    const subscription = searchSubject
      .pipe(
        debounceTime(300), // 去抖动,延迟300毫秒
        switchMap((query) => fetchSearchResults(query))
      )
      .subscribe(setResults);

    return () => subscription.unsubscribe();
  }, []);

  const handleInputChange = (event) => {
    const query = event.target.value;
    setSearchQuery(query);
    searchSubject.next(query);
  };

  return (
    <div>
      <input
        type="text"
        value={searchQuery}
        onChange={handleInputChange}
        placeholder="Search..."
      />
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result}</li>
        ))}
      </ul>
    </div>
  );
};

export default SearchComponent;

以上是在使用 NestJS 开发实践中,个人觉得比较有价值的技术点。在我们使用各种优秀的框架时,除了享受框架给我们带来的便捷和高效外,学习其设计思路并应用于其他项目,这样能发挥框架的更大价值,也能帮助我们技术快速提升。