用笨办法应对充满不确定性的未来

『Sentry 源码分析之旅一』:整体流程分析

2023.01.13

前言

我们在研究前端错误监控的过程中,势必会探索一些知名的开源库,从中吸取养分,开拓自己的视野。

Sentry 就是这样一个成熟的开源产品。

笔者在刚开始研究分析其源码的过程中,由于不熟悉源码分析方法,抱着好奇一头扎进源码仓库,在 packages 下 众多的包中浏览,在遍布的 js 代码中看的眼花缭乱,最后花了很多时间在理解 Sentry 的结构上,却没有多少实 际的收获。

过了一段时间后,再来看这份代码,由于已经掌握了一些分析的小技巧,能够像庖丁解牛一样逐步的去分析源码, 虽然实际上还达不到分析彻底的程度,但是已经能有所收获。

本文作为 Sentry 源码分析之旅的第一篇,借助分享初步探索 Sentry 源码的经历,希望和大家一起探索:怎么让源码分析变得有迹可循,有所收获。

Sentry 介绍

市面上关于前端错误监控的开源产品中,Sentry 算是比较成熟的。其支持多种编程语言、框架,功能齐全,具备方便的错误全周期的管理能力。

其中从前端角度来看 Sentry 支持 React、Vue、Angular 等框架,支持 nodejs、浏览器端 javascript,可以说支持非常全面。

接下来就以 sentry-javascript 为例,自顶向下的分析其结构。

准备工作

工具

本文使用的 sdk 版本信息如下:

本文使用的工具

思路

首先会对源码的整体结构进行分析,观察其调用链、各个模块的作用和相互之间的依赖关系,构建一个全局的视野。

其次会综合使用动态分析和静态分析两种方法,对源码进行分析总结,围绕两大线索进行分析总结:

  1. 监控脚本是如何初始化的
  2. 出现错误时是如何捕获、处理、上报的

理清楚这两条线,就能够对 sentry-javascript 的源码执行的来龙去脉有比较好的把握。

最后查看 sentry 使用的一些辅助工具,对本次分析绘制出完整的总结图,分析 Sentry 在质量保障上是如何实践的。完成这些后在 Sentry 源码分析之旅上就向前了一小步。

限于篇幅,本次实际上是对源码分析的一次简单实践,如何让代码分析变得更轻松愉悦,也是本文尝试探讨的问题。

了解使用方式

刚开始时我们拥有的信息非常少,不足以支撑进行下一步,可以先了解使用方式入手。由于笔者主要使用 React 技术栈,这就先看看 React 和 JavaScript 两种情况的接入方式。

从使用文档中我们找到了一段示例:

React 接入方式

  # 安装
  npm install --save @sentry/react @sentry/tracing
  import React from "react";
  import ReactDOM from "react-dom";
  // 引入监控库
  import * as Sentry from "@sentry/react";
  // 引入监控插件
  import { Integrations } from "@sentry/tracing";
  import App from "./App";

  Sentry.init({
  // 服务端地址,包含了项目id等信息
    dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
    integrations: [new Integrations.BrowserTracing()],
    // 采样率,减少对用户端性能影响
    tracesSampleRate: 1.0,
  });

  ReactDOM.render(<App />, document.getElementById("root"));

JavaScript 接入

  # 安装
  npm install --save @sentry/browser @sentry/tracing

React 和 JavaScript 两种方式的初始化参数基本一致,只是在 React 项目中会引入=@sentry/react=,而纯 JavaScript 项目则会引入@sentry/browser

  // 引入监控库
  import * as Sentry from "@sentry/browser";
  // 引入监控插件
  import { Integrations } from "@sentry/
  // 初始化部分和React库的引入方式相同

小结:

这一步可以看到这个监控脚本的使用方式。下一步将去查看仓库代码。

使用静态分析工具查看源码整体结构

查看项目目录结构

先把代码 clone 下来,观察一下项目目录结构。

发现仓库为 monorepo 结构,使用 lerna 管理,包管理工具 为 yarn。

/package/* 目录中均为子包。以下是该目录的结构

  ❯ tree  -L 1 ./packages
  ./packages
  ├── angular
  ├── browser
  ├── core
  ├── ember
  ├── eslint-config-sdk
  ├── eslint-plugin-sdk
  ├── gatsby
  ├── hub
  ├── integrations
  ├── minimal
  ├── nextjs
  ├── node
  ├── react
  ├── serverless
  ├── tracing
  ├── types
  ├── typescript
  ├── utils
  ├── vue
  └── wasm

根据目录名称,可以看到 react 目录和 browser 目录分别对应 =@sentry/react=和=@sentry/browser=这两个包。

其他目录有的对应 nextjs、node、serverless 等平台,有的是工具包如=utils=、类型定义=types=、typescript 配置文件等。

拆的包有点多,看着眼花缭乱,不过我们之前有看过 demo,知道我们在业务项目中使用时只用引入 browser 包即可。

  // 在业务项目中只需要引入browser目录所在的包
  import * as Sentry from "@sentry/browser";

小结:

本步骤喵了一眼代码仓库,看其中用到了什么工具、目录结构是怎样的。

本次只分析 browser 部分,即纯浏览器部分,这块和具体的框架无关,更具备通用性。因此只分析这一个就够了

后面代码分析重点放在@sentry/browser 中。

分析 npm 包依赖关系

拿到了仓库地址,可以 clone 代码库到本地。根据使用的 demo 代码找到了分析范围。

由于这里的包比较多,理解起来比较麻烦,需要先绘制出依赖关系图。

使用依赖分析工具绘制出依赖图

@sentry/browser 的依赖图

@sentry/browser 的依赖图

@sentry/browser 的依赖图

@sentry/react 的依赖图

@sentry/react 的依赖图

绘制依赖图所用的工具:

https://npmgraph.js.org/?q=%40sentry%2Fbrowser

https://npmgraph.js.org/?q=%40sentry%2Freact

根据这几张依赖依赖图,我们发现不同的接入方式会引入对应框架的包,而不同框架的包都依赖了 browser 。

可以将这些依赖分为不同的级别,绘制出以下图片

整体架构图

整体架构图

基础库

  • @sentry/utils
  • @sentry/types
  • tslib

核心库

  • Core
  • Minimal
  • Hub
  • Intergrations

平台库

  • Browser
  • Node
  • Serverless

通过将逻辑拆分到不同级别,实现将监控的共通部分在核心包里实现,而将不同平台、框架的差异、特点放到对应的包里,形成了一套开发规范,往后想拓展时只需要照章进行处理即可。

分析 browser 包的文件模块之间依赖关系

这一步是为了理清 Browser 包中模块关系

先看看源代码结构

  ❯ tree  -L 1 ./packages/browser/src
  ./packages/browser/src
  ├── backend.ts
  ├── client.ts
  ├── eventbuilder.ts
  ├── exports.ts
  ├── helpers.ts
  ├── index.ts
  ├── integrations
  ├── loader.js
  ├── parsers.ts
  ├── sdk.ts
  ├── tracekit.ts
  ├── transports
  └── version.ts

  2 directories, 11 files

回顾一下前面找到的使用示例

  import * as Sentry from "@sentry/browser";
  import { Integrations } from "@sentry/tracing";

  // 初始化方法init
  Sentry.init({
    dsn: "https://examplePublicKey@o0.ingest.sentry.io/0"
  });

通过搜索代码,看到 Sentry.init 的初始化方法在 sdk.js 中,查看下该文件的依赖

使用 WebStor 的 Diagram 功能 分析 sdk.js 的依赖

使用 WebStor 的 Diagram 功能 分析 sdk.js 的依赖

用另一种工具绘制调用图,详细的文件依赖图。点击可查看大图

绘制的详细依赖图

绘制的详细依赖图

这里我们就找到入口文件应该是 src/sdk.ts

小结

本步骤以 React、Vuejs、JavaScript 三种接入模式进行了依赖分析,绘制出依赖关系图,再据此将这些依赖进行分 层,绘制出了依赖图。

这一步可以从依赖的角度来分析监控库的架构,对这些类名方法名有个初步的印象。

使用断点调试查看代码逻辑

准备工作

  • 进入到 browser 包目录下,运行 npm i 安装好依赖
  • 然后 npm run build
  • 把 build 文件夹中的 bundle.es6.js 和相关的 map 文件复制到 examples 目录,并修改 index.html 文件中对 bundle.js 的引用为 bundle.es6.js,这样查看到的代码是 es6 版本的,更接近源代码
  • 在 examples 目录下启动一个 http 服务,使用 file 协议很多功能会不正常
  • 浏览器中打开 启动的服务,这样就可以进行断点调试了

初始化监控库逻辑

本节先通过断点调试的形式,确定监控初始化涉及哪些流程,各个调用的函数又是干嘛的

本节先绘制出流程图,在下一节解析其详细的实现过程。

定位到入口

根据之前的依赖分析,可以看到初始化方法是 Sentry.init,因此可以在 app.js 的初始化方法上打个断点。

运行到这段代码时点击"Step into next function call",进入 init 方法,看到逻辑如下:

  // Sentry.initi初始化方法
  // 只做了一些赋初始值的操作
  function init(options) {
      // 如果未传入options,则生成默认值
      if (options === void 0) {
          options = {};
      }
      if (options.defaultIntegrations === undefined) {
          options.defaultIntegrations = defaultIntegrations;
      }
      if (options.release === undefined) {
          var window_1 = getGlobalObject();
          // This supports the variable that sentry-webpack-plugin injects
          if (window_1.SENTRY_RELEASE && window_1.SENTRY_RELEASE.id) {
              options.release = window_1.SENTRY_RELEASE.id;
          }
      }
      if (options.autoSessionTracking === undefined) {
          options.autoSessionTracking = true;
      }
      if (options.sendClientReports === undefined) {
          options.sendClientReports = true;
      }
      // 真正的初始化函数
      initAndBind(BrowserClient, options);
      if (options.autoSessionTracking) {
      // 会话追踪
          startSessionTracking();
      }
  }

其中 options 为用户传入的参数,init 逻辑可以分为两个步骤,一是对传入的 options 参数做处理,设置各项参数 的默认值。

默认参数处理完毕后则执行 initAndBind 方法,并且根据参数判断是否要执行会话追踪。

下面逐步分析这些代码

用户传入参数 options 属性解析

详细的内容可以查阅 options 的类型定义,本文只看必不可少的

  // packages/browser/src/backend.ts
  // packages/types/src/options.ts
  1. dsn
            设置地址,包含 sentry key 域名和 projectid
    
            ``` {.plain}
            https://363a337c11a64611be4845ad6e24f3ac@sentry.io/297378
            ```
    参数解析
  '{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}'
  1. ignoreErrors、denyUrls、allowUrls

    过滤错误的条件,通常会根据业务情况定义一批需要忽略的错误

  2. defaultIntegrations

    可以用于裁剪或者新增 intergration,intergration 可以理解为插件

    Sentry 的相关功能是使用插件形式接入

  3. release

    前端代码发布版本号。用于关联错误和源代码版本。

  4. environment

    环境

  5. autoSessionTracking

    是否开启自动会话追踪

  6. sendClientReports

    BaseTransport 类中会使用该参数

初始化流程调用图

经过上面的断点,可以整理出初始化过程的调用图

设置默认的 Intergrations

当传入的参数中,没有该项参数时,使用默认的值。

默认的 Intergrations 有以下几个

  var defaultIntegrations = [new InboundFilters(), new FunctionToString(), new TryCatch(), new Breadcrumbs(), new GlobalHandlers(), new LinkedErrors(), new Dedupe(), new UserAgent(), ];

这些代码在 browsers/src/integrations/ 目录下,可以简单观察其功能

功能:

  • InboundFilters, 过滤报错信息
  • FunctionToString, 覆盖 Function 对象的 toString 方法,能够正常获取被 sentry 包裹的函数名称
  • TryCatch,重写计时器方法和事件别的对象,用于提供更丰富的元数据
  • Breadcrumbs,面包屑,在代码运行时记录执行痕迹
  • GlobalHandlers
  • LinkedErrors
  • Dedupe
  • UserAgent

设置默认的 release

如果用户没有传入 release,则会从 webpack 构建中来取 releaseid。前提是构建脚手架里引入了 sentry-webpack-plugin。

要从参数 options 中传入,比较简单的方法是在构建时使用 Webpack 提供的变量将当前的源代码 commitid 当作 release 传进来

这一步会在后面介绍

  if (options.release === undefined) {
      const window = getGlobalObject();
      // This supports the variable that sentry-webpack-plugin injects
      if (window.SENTRY_RELEASE && window.SENTRY_RELEASE.id) {
          options.release = window.SENTRY_RELEASE.id;
      }
  }

getCurrentHub 和 Hub

这里一个发布订阅器,到处都有用到。

获取和更新 Scope

scope 会和 sentry 事件一起发送,可以携带上下文信息、额外参数

minimal

按照文档描述:

(它是)一个最小的 Sentry SDK,当嵌入到一个应用程序中时,使用一个配置好的客户端。它允许库的作者添加对 Sentry SDK 的支持,而不必捆绑整个 SDK 或依赖于特定的平台。如果用户在他们的应用程序中使用 Sentry,而你的库使用`@sentry/minimal’,则用户会收到所有你在自己代码中添加的面包屑/消息/事件,

查看以下的依赖图,可以理解

&quot;@sentry/browser 的依赖图&quot;

"@sentry/browser 的依赖图"

event processor

事件处理器

initAndBind 和 BrowserClient

入参是 BrowserClient 和 options。options 是经过填充默认值的用户参数,那么 BrowserClient 是什么?

观察代码发现它继承了 BaseClient

  class BrowserClient extends BaseClient {
          /**
         * Creates a new Browser SDK instance.
         *
         * @param options Configuration options for this SDK.
         */
          constructor(options={}) {
              options._metadata = options._metadata || {};
              options._metadata.sdk = options._metadata.sdk || {
                  name: 'sentry.javascript.browser',
                  packages: [{
                      name: 'npm:@sentry/browser',
                      version: SDK_VERSION,
                  }, ],
                  version: SDK_VERSION,
              };
              super(BrowserBackend, options);
          }
          /**
         * Show a report dialog to the user to send feedback to a specific event.
         *
         * @param options Set individual options for the dialog
         */
          showReportDialog(options={}) {
              // doesn't work without a document (React Native)
              const document = getGlobalObject().document;
              if (!document) {
                  return;
              }
              if (!this._isEnabled()) {
                  logger.error('Trying to call showReportDialog with Sentry Client disabled');
                  return;
              }
              injectReportDialog(Object.assign(Object.assign({}, options), {
                  dsn: options.dsn || this.getDsn()
              }));
          }
          /**
         * @inheritDoc
         */
          _prepareEvent(event, scope, hint) {
              event.platform = event.platform || 'javascript';
              return super._prepareEvent(event, scope, hint);
          }
          /**
         * @inheritDoc
         */
          _sendEvent(event) {
              const integration = this.getIntegration(Breadcrumbs);
              if (integration) {
                  integration.addSentryBreadcrumb(event);
              }
              super._sendEvent(event);
          }
      }

再看看 initAndBind

  function initAndBind(clientClass, options) {
      var _a;
      if (options.debug === true) {
          logger.enable();
      }
      const hub = getCurrentHub();
      (_a = hub.getScope()) === null || _a === void 0 ? void 0 : _a.update(options.initialScope);
      const client = new clientClass(options);
      hub.bindClient(client);
  }
  • 根据条件开启 logger
  • 获取当前 Hub,赋值给 hub
  • 创建 Scope
  • 实例化 BrowserClient,赋值给 client
  • 将 client 绑定到 hub 上

Logger 是全局的日志函数,提供了分日志级别的开关,开关关闭时不打印相应级别的日志。

启动自动会话追踪

该功能默认开启

调用 hub.startSession 和 hub.captureSession 方法,并且在跳转到新页面时开启新的会话

小结

本节绘制出了初始化过程的流程图,至此根据前面的依赖分析图、调用图,可以看出 Sentry 中有一些关键概念需要 理清楚。下一节就是理清这些概念

初始化所涉及的关键概念及实现

在初始化流程中记录的流程中发现频繁涉及到一些名词。下期会详细解析这方面的细节

  • 方法的重写–> fill。Sentry 中经常组合使用 fill、triggerHandlers 的方法来重写一些函数。
  • intergration 概念。是 Sentry 的插件系统,只要按照规范就能够自己编写新的插件
  • Hub 的概念
  • Scope 概念
  • Session 的概念
  • BreadCrumbs 概念
  • addInstrumentationHandler

使用 addInstrumentationHandler 添加多种面包屑跟踪器。默认支持的有:

  • console
  • dom
  • xhr
  • fetch
  • history

以 xhr 的面包屑配置为例,首先在 setupOnce 方法中执行:

  if (this._options.xhr) {
      addInstrumentationHandler({
          callback: (...args)=>{
              this._xhrBreadcrumb(...args);
          }
          ,
          type: 'xhr',
      });
  }

addInstrumentationHandler 方法内部会根据 type 参数添加到 handlers 对象中

  function addInstrumentationHandler(handler) {
      if (!handler || typeof handler.type !== 'string' || typeof handler.callback !== 'function') {
          return;
      }
      handlers[handler.type] = handlers[handler.type] || [];
      handlers[handler.type].push(handler.callback);
      instrument(handler.type);
  }

handlers 维护了各种面包屑的处理函数队列,而 instrument 函数会根据不同的面包屑类型来执行初始化函数

  const handlers = {};
  const instrumented = {};
  /** Instruments given API */
  function instrument(type) {
      if (instrumented[type]) {
          return;
      }
      instrumented[type] = true;
      switch (type) {
      case 'console':
          instrumentConsole();
          break;
      case 'dom':
          instrumentDOM();
          break;
      case 'xhr':
          instrumentXHR();
          break;
      case 'fetch':
          instrumentFetch();
          break;
      case 'history':
          instrumentHistory();
          break;
      case 'error':
          instrumentError();
          break;
      case 'unhandledrejection':
          instrumentUnhandledRejection();
          break;
      default:
          logger.warn('unknown instrumentation type:', type);
      }
  }

最终 instrumentXHR 中会调用 fill 方法来重写 XMLHttpRequest.prototype

错误捕获、处理上报流程

找入口

根据之前的模块依赖分析,捕获错误相关代码的应该在 GlobalHandlers 中。

为了追踪后续代码执行步骤,需要注入的代码中加入 debugger。例子:

  /** JSDoc */
    _installGlobalOnErrorHandler() {
        if (this._onErrorHandlerInstalled) {
            return;
        }
        addInstrumentationHandler({
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            callback: (data) => {
              debugger
                const error = data.error;
                const currentHub = getCurrentHub();

在浏览器上强制刷新 example 页面,然后在网页上点击 regularException example 按钮,这样就进入到断点附近 了,可以记录整个流程

回调函数的参数分析

GlobalHandler 插件在 onerror 上增加了回调函数,回调函数的参数是

  {
      "column": 15,
      "error": {},
      "line": 5561,
      "msg": "Uncaught Error: Regular exception no. 1635237109017",
      "url": "http://127.0.0.1:8080/bundle.es6.js"
  }

这里实际上是 onerror 的几个参数

获取 Hub 并做状态检查

  if (!hasIntegration || shouldIgnoreOnError() || isFailedOwnDelivery) {
       return;
   }

`

  • 检查 GlobalHandler 插件是否正确初始化
  • 检查该错误是否是 Sentry 自身的错误
  • 处理异常信息

作者在这里碰到了问题,shouldIgnoreOnError() 一直返回 true

处理异常信息

  const error = data.error;
  // ......
  const client = currentHub.getClient();
          const event =
            error === undefined && isString(data.msg)
              ? this._eventFromIncompleteOnError(data.msg, data.url, data.line, data.column)
              : this._enhanceEventWithInitialFrame(
                  eventFromUnknownInput(error || data.msg, undefined, {
                    attachStacktrace: client && client.getOptions().attachStacktrace,
                    rejection: false,
                  }),
                  data.url,
                  data.line,
                  data.column,
                );

首先检查 error 是否是标准的,如果不是标准的,需要将错误信息处理成标准的格式。这里标准与否的判断是错 误堆栈里没有 error,但是有 msg。这里的标准化实际上是从 msg 中提取信息并做格式化处理

如果是标准的,则直接调用enhanceEventWithInitialFrame

流程

整体调用流程

整体调用流程

总结

本文探讨的内容

本文从项目目录结构开始,分析 sentry-javascript 仓库中各个 npm 包的依赖关系,构建出全局的视野。

接着将目光移动到@sentry/browser 包的文件结构,使用静态分析工具绘制出模块依赖关系,能够让我们找到关键点。

然后我们使用动态调试方法来逐步调试。配置好调试环境后,根据文档中的监控库初始化方法,对初始化和错误捕获上报流程进行了分析

最终我们根据分析结果,绘制出了整体的架构和流程。至此分析内容告一段落,后续更为深入的功能实现解析,我们可以下次再深入分析。

难点和弯路

  • 在查看 Sentry 源码时,需要对如何使用 Sentry 整个系统有所了解。例如需要了解接入 Sentry Sdk,如何配 置、使用它提供的各项监控能力、甚至 Sentry 管理后台是如何展现这些能力的。在理解源码端的概念时,在没有以上背景知识的情况下是不大好理解的。

  • Sentry-JavaScript 由多个 package 组成。将不同层级的代码划分到不同的 package 里,不理解这些包的作用、相互之间关系的话容易一头雾水,找不着分析入口。

  • 文件、类非常多,混合 TS 后代码量变大。SDK 为了增强拓展性将代码拆分到多个文件、类、package,想看代 码的话得在多个文件之间切换,同时大量的 ts 定义在需要概览逻辑时会让事情变得复杂。如果直接看仓库 src 目录代码有些疑惑的话,可以看打包出来的=bundle.es6.js=文件。该文件使用 es6 代码,过滤掉了 TS,并且将代码都放到一个文件,在 debug 时非常方便。

  • 贯穿整个 SDK 的 Hub 的概念。笔者一开始没有理解 Hub 的作用,在碰到某个具体的函数功能时试图直接查看函数定义。结果用 WebStorm 的全局搜索功能找到了一大堆同名的函数,令人抓瞎。建议先是从 package 之间的依赖入手,先分析出底层依赖,然后从底层依赖开始逐层往上阅读。