前言
我们在研究前端错误监控的过程中,势必会探索一些知名的开源库,从中吸取养分,开拓自己的视野。
Sentry 就是这样一个成熟的开源产品。
笔者在刚开始研究分析其源码的过程中,由于不熟悉源码分析方法,抱着好奇一头扎进源码仓库,在 packages 下 众多的包中浏览,在遍布的 js 代码中看的眼花缭乱,最后花了很多时间在理解 Sentry 的结构上,却没有多少实 际的收获。
过了一段时间后,再来看这份代码,由于已经掌握了一些分析的小技巧,能够像庖丁解牛一样逐步的去分析源码, 虽然实际上还达不到分析彻底的程度,但是已经能有所收获。
本文作为 Sentry 源码分析之旅的第一篇,借助分享初步探索 Sentry 源码的经历,希望和大家一起探索:怎么让源码分析变得有迹可循,有所收获。
Sentry 介绍
市面上关于前端错误监控的开源产品中,Sentry 算是比较成熟的。其支持多种编程语言、框架,功能齐全,具备方便的错误全周期的管理能力。
其中从前端角度来看 Sentry 支持 React、Vue、Angular 等框架,支持 nodejs、浏览器端 javascript,可以说支持非常全面。
接下来就以 sentry-javascript 为例,自顶向下的分析其结构。
准备工作
工具
本文使用的 sdk 版本信息如下:
- 仓库地址:https://github.com/getsentry/sentry-javascript
- commit id:83b5cc68b8ae23f209af0a2c7ef12d0a54a59b74
- 使用文档:https://docs.sentry.io/platforms/javascript
本文使用的工具
- https://npmgraph.js.org/ 可视化 npm 包的依赖
- https://www.npmjs.com/package/dependency-cruiser 代码文件之间依赖分析工具
- WebStorm 的 Diagrams 分析功能
思路
首先会对源码的整体结构进行分析,观察其调用链、各个模块的作用和相互之间的依赖关系,构建一个全局的视野。
其次会综合使用动态分析和静态分析两种方法,对源码进行分析总结,围绕两大线索进行分析总结:
- 监控脚本是如何初始化的
- 出现错误时是如何捕获、处理、上报的
理清楚这两条线,就能够对 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 的依赖图
绘制依赖图所用的工具:
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 中,查看下该文件的依赖
用另一种工具绘制调用图,详细的文件依赖图。点击可查看大图
这里我们就找到入口文件应该是 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
- dsn参数解析
设置地址,包含 sentry key 域名和 projectid ``` {.plain} https://363a337c11a64611be4845ad6e24f3ac@sentry.io/297378 ```
'{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}'
ignoreErrors、denyUrls、allowUrls
过滤错误的条件,通常会根据业务情况定义一批需要忽略的错误
defaultIntegrations
可以用于裁剪或者新增 intergration,intergration 可以理解为插件
Sentry 的相关功能是使用插件形式接入
release
前端代码发布版本号。用于关联错误和源代码版本。
environment
环境
autoSessionTracking
是否开启自动会话追踪
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’,则用户会收到所有你在自己代码中添加的面包屑/消息/事件,
查看以下的依赖图,可以理解
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
Breadcrumbs 面包屑注册流程
使用 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 之间的依赖入手,先分析出底层依赖,然后从底层依赖开始逐层往上阅读。