解析Sentry源码(一)| 搞懂Sentry初始化

5,389 阅读11分钟

一、前言

最近对前端监控很有兴趣,所以去使用了前端监控里优秀的开源库Sentry,并且研究了下Sentry源码,整理出这一系列的文章,希望能帮助大家更好地了解前端监控原理。

这一系列的文章我将结合官网api与流程图的方式,通俗易懂地讲解整个流程。

以下是我已完成和计划下一篇文章的主题:


关于Sentry源码如何调试大家可以按照如下(说明我的版本为:"5.29.2"):

  • git clone git@github.com:getsentry/sentry-javascript.git
  • 进入到 packages/browser 进行npm i 下载依赖
  • 然后在packages/browser 进行npm run build 去打包,生成build文件夹
  • 进入到 packages/browser/examples 打开index.html就可以开心调试了(这里建议用Live Server打开)
  • 说明:packages/browser/examples 下的bundle.js就是打包后的源码,他指向了packages/browser/build/bundle.js。这时候你会发现build目录下还有bundle.es6.js,如果你想使用es6的方式去阅读可以将文件替换成bundle.es6.js

二、导读

我们可以通过如下的方式进行Sentry初始化:

Sentry.init({
  dsn: 'xxxxxx',
  autoSessionTracking: true,
  enabled: true,
  release: 'xxxxx',
  environment: 'development',
  default_integrations: true,
  ignoreErrors: [/PickleRick_\d\d/, 'RangeError'],
  denyUrls: ['external-lib.js'],
  allowUrls: ['http://localhost:5500', 'https://browser.sentry-cdn'],
});

在这里需要先了解几个概念方便源码阅读:

  • dsn: Sentry SDK所需的配置的标识。它由几个部分组成,包括协议,公共密钥和密钥,服务器地址和项目标识符。

  • release:版本号。release可以与sourceMap进行关联,设置release版本号后,我们更好地查找错误。

  • hub: 可以将 hub 看作是我们的 sdk 用于将事件路由到 Sentry 的中心点,也理解成控制中心。当您调用 init ()时,将创建一个 hub,并在其上创建一个客户端client和一个空白作用域scope。然后,该中心与当前线程关联,并在内部保存范围堆栈。

  • scope: 将包含应该随事件一起发送的有用信息。例如,上下文context或面包屑Breadcrumbs将被存储在作用域上。当一个作用域被入栈时,它从父作用域继承所有数据,当它出栈时,所有修改都被恢复。

  • client:客户端。

  • context: 上下文。提供额外的上下文数据。通常,这是与当前用户和环境相关的数据并且这个上下文在其生命周期中捕获的任何问题之间共享。也可以自定义上下文内容,可以参考官网

  • integrations: 用来标识启用集成的名称列表。列表应该包含所有启用的集成,包括默认的集成。包含默认集成是因为不同的 SDK 版本可能包含不同的默认集成。

  • breadcrumbs: 面包屑。使用面包屑创建一个事件发生之前发生的跟踪。这些事件与传统日志非常相似,但可以记录更丰富的结构化数据。

三、入口

在 packages/browser/src/sdk.ts 中可以看到Sentry.init的入口,options就是用户传入的参数

export function init(options: BrowserOptions = {}): void {
  if (options.defaultIntegrations === undefined) {
    options.defaultIntegrations = defaultIntegrations;
  }
  if (options.release === undefined) {
    const window = getGlobalObject<Window>();
    // This supports the variable that sentry-webpack-plugin injects
    if (window.SENTRY_RELEASE && window.SENTRY_RELEASE.id) {
      options.release = window.SENTRY_RELEASE.id;
    }
  }
  if (options.autoSessionTracking === undefined) {
    options.autoSessionTracking = false;
  }
  initAndBind(BrowserClient, options);
  if (options.autoSessionTracking) {
    startSessionTracking();
  }
}	

分析:

  • 整个初始化代码很清晰,如果用户未传入defaultIntegrations,release,autoSessionTracking值,就进赋值。
  • initAndBind 则是重点,创建新 SDK 客户端实例并且绑定到hub上。

initAndBind我放在最后重点讲解,而其他部分会更多结合官网去深入了解

四、defaultIntegrations

先看看官网的介绍

System integrations are integrations enabled by default that integrate into the standard library or the interpreter itself. Sentry documents them so you can see what they do and that they can be disabled if they cause issues. To disable all system integrations, set default_integrations=False when calling init().

系统集成是默认情况下启用的集成,集成到standard library或interpreter。Sentry把它们记录下来,这样你就可以看到它们是做什么的并且如果它们导致问题,它们就可以被禁用。若要禁用所有系统集成,请在调用 init ()时设置 default _ integrations = False。

所以当你init的时候可以设置default _ integrations = false去阻止它赋值。


接着我们看defaultIntegrations默认值有什么:

在packages/browser/src/sdk.ts

// 默认集成
export const defaultIntegrations = [
  new CoreIntegrations.InboundFilters(),
  new CoreIntegrations.FunctionToString(),
  new TryCatch(),
  new Breadcrumbs(),
  new GlobalHandlers(),
  new LinkedErrors(),
  new UserAgent(),
];

可以看到defaultIntegrations默认集成了很多类,我们来看看各个的作用(各个类的具体实现与本文相关不大,将会放在后续文章中讲解)

4.1 new CoreIntegrations.InboundFilters()

Inbound data filters allow you to determine which errors, if any, Sentry should ignore. Explore these by navigating to [Project] » Project Settings » Inbound Filters.

数据过滤器允许您确定Sentry应该忽略哪些错误(如果有的话)。通过导航到 [Project] » Project Settings » Inbound Filters.来探索这些内容。

This integration allows you to ignore specific errors based on the type, message, or URLs in a given exception. It ignores errors that start with Script error or Javascript error: Script error by default. To configure this integration, use ignoreErrors, denyUrls, and allowUrls SDK options directly. Keep in mind that denyURL and allowURL work only for captured exceptions, not raw message events.

这种集成允许您忽略给定异常中基于类型、消息或 url 的特定错误。它忽略以 Script error 或 Javascript error 开头的错误: 默认情况下是 Script error。要配置此集成,请直接使用ignoreErrors、 denyurl 和 allowurlsdk SDK。请记住,denyURL 和 allowURL 仅适用于捕获错误异常,而不适用于普通消息

说明:

它忽略以 Script error 或 Javascript error 开头的错误: 默认情况下是 Script error

这其实与window.onerror兼容性有关,它对于script标签,需要在script标签添加crossorigin属性,并在服务端允许跨域。如果不使用这个属性,错误信息只会显示Script error


接着我们用几个测试例子,加深理解:

设置init

Sentry.init({
	// ...
  ignoreErrors: [/PickleRick_\d\d/, 'RangeError'],
  denyUrls: ['external-lib.js'],
  allowUrls: ['http://localhost:5500', 'https://browser.sentry-cdn'],
});

测试例子

  <body>
    <button id="deny-url">denyUrls example</button>
    <button id="allow-url">allowUrls example</button>
    <button id="ignore-message">ignoreError message example</button>
    <button id="ignore-type">ignoreError type example</button>
  </body>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      //第一种
      document.querySelector('#deny-url').addEventListener('click', () => {
        console.log('click');
        const script = document.createElement('script');
        script.crossOrigin = 'anonymous';
        script.src =
          'https://rawgit.com/kamilogorek/cfbe9f92196c6c61053b28b2d42e2f5d/raw/3aef6ff5e2fd2ad4a84205cd71e2496a445ebe1d/external-lib.js';
        document.body.appendChild(script);
      });
      //第二种
      document.querySelector('#allow-url').addEventListener('click', () => {
        const script = document.createElement('script');
        script.crossOrigin = 'anonymous';
        script.src =
          'https://rawgit.com/kamilogorek/cb67dafbd0e12b782bdcc1fbcaed2b87/raw/3aef6ff5e2fd2ad4a84205cd71e2496a445ebe1d/lib.js';
        document.body.appendChild(script);
      });
      //第三种
      document.querySelector('#ignore-message').addEventListener('click', () => {
        throw new Error('Exception that will be ignored because of this keyword => PickleRick_42 <=');
      });
      //第四种
      document.querySelector('#ignore-type').addEventListener('click', () => {
        throw new RangeError("Exception that will be ignored because of it's type");
      });
    });
  </script>

来看看四个按钮的结果:

说明:

  • 第一种命中了denyUrls
  • 第二种不符合allowUrls允许的范围
  • 第三种则是错误信息中包含了 PickleRick_42相关字段
  • 第四种就是命中了ignoreErrors忽略的错误RangeError

4.2 new CoreIntegrations.FunctionToString

This integration allows the SDK to provide original functions and method names, even when our error or breadcrumbs handlers wrap them.

即使我们的错误或面包屑包装了原始函数,此集成也可以使SDK提供原始函数和方法名称

看看源码

    setupOnce() {
      originalFunctionToString = Function.prototype.toString;
      Function.prototype.toString = function(...args) {
        const context = this.__sentry_original__ || this;
        return originalFunctionToString.apply(context, args);
      };
    }

分析:

  • 这里context 会取去判断是取 this.__sentry_original__还是this本身(主要就是针对被错误或面包屑包装的函数)

4.3 new TryCatch()

This integration wraps native time and events APIs (setTimeout, setInterval, requestAnimationFrame, addEventListener/removeEventListener) in try/catch blocks to handle async exceptions

这种集成包装了 try/catch 块中的原始时间和事件 api (setTimeout、 setInterval、 requestAnimationFrame、 addEventListener/removeEventListener) ,以处理异步异常

4.4 new Breadcrumbs()

This integration wraps native APIs to capture breadcrumbs. By default, the Sentry SDK wraps all APIs.

这种集成包装了原生 api 来捕获面包屑

可以选择的方案:

{ beacon: boolean; // Log HTTP requests done with the Beacon API
  console: boolean; // Log calls to `console.log`, `console.debug`, etc
  dom: boolean; // Log all click and keypress events
  fetch: boolean; // Log HTTP requests done with the Fetch API
  history: boolean; // Log calls to `history.pushState` and friends
  sentry: boolean; // Log whenever we send an event to the server
  xhr: boolean; // Log HTTP requests done with the XHR API
}

所以Breadcrumbs面包屑包含的信息有:

  • 自定义的面包屑信息
  • 控制台的console信息
  • ui点击和按下的dom事件
  • fetch请求
  • 历史错误信息
  • 是否发送到服务端
  • xhr 请求

4.5 new GlobalHandlers()

This integration attaches global handlers to capture uncaught exceptions and unhandled rejections.

这种集成附加了全局处理程序以捕获未捕获的异常和未处理的拒绝

可选择的方案:

{
  onerror: boolean;
  onunhandledrejection: boolean;
}

这里面的错误信息在下一篇文章玩转前端监控,全面解析Sentry源码(二)| Sentry如何处理错误数据就讲解到了

4.6 new LinkedErrors()

This integration allows you to configure linked errors. They’ll be recursively read up to a specified limit and lookup will be performed by a specific key. By default, the Sentry SDK sets the limit to five and the key used is cause.

这种集成允许您配置链接的错误。它们将被递归地读取到指定的限制,并由特定的键执行查找。默认情况下,Sentry SDK 将限制limit设置为5,使用的密钥为 cause。

可选择的方案:

{
  key: string;
  limit: number;
}

4.7 new UserAgent()

This integration attaches user-agent information to the event, which allows us to correctly catalog and tag them with specific OS, browser, and version information.

这种集成将用户代理信息附加到事件,这使我们能够正确地编目并用特定的操作系统、浏览器和版本信息对它们进行标记

4.8 总结

defaultIntegrations 为Sentry 集成了很多方法,Sentry采用了类型插件的方式,将各种属性集成到Sentry上。这种写法让代码看起来很清晰,各个集成的作用都一目了然。

五、 release

​ Sentry 会尝试自动配置一个发布版本,但是也可以手动设置它来保证发布版本与你的部署集成或sourcemap上传同步,这也更推荐。

​ 具体内容大家可以参考这篇文章线上bug追踪之Sentry release+sourceMap(二)


看看源码:

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

分析:

  • 源码这里指如果没有设置release,默认情况下,SDK 会尝试从 SENTRY_RELEASE 环境变量中读取这个值
  • 这里的getGlobalObject目的是为了区分当前环境是node还是浏览器,获取全局变量

六、 autoSessionTracking

When set to true, the SDK will send session events to Sentry. This is supported in all browser SDKs, emitting one session per pageload to Sentry.

这里默认情况下是关闭的,当开启后会SDK 将向 Sentry 发送session。这在所有的浏览器 sdk 中都是支持的,每个页面都会发送一个session到Sentry。

  if (options.autoSessionTracking) {
    startSessionTracking();
  }

如果用户传入autoSessionTracking为true的时候,会开始调用startSessionTracking,我们来看看startSessionTracking方法

6.1 startSessionTracking

  function startSessionTracking() {
	// ...
    let loadResolved = document.readyState === 'complete';
    let fcpResolved = false;
    const possiblyEndSession = () => {
      if (fcpResolved && loadResolved) {
        hub.endSession();
      }
    };
    const resolveWindowLoaded = () => {
      loadResolved = true;
      possiblyEndSession();
      window.removeEventListener('load', resolveWindowLoaded);
    };
    hub.startSession();
    if (!loadResolved) {
      window.addEventListener('load', resolveWindowLoaded);
    }
	// ...
  }

大概说一下是如何进行session追踪的:

  1. 调用 hub.startSession()方法,此方法中会通过new Session去创建一个session

  2. 然后监听document是不是加载完成,如果加载完成就调用 hub.endSession()

  3. 看看源码:

        endSession() {
          const { scope, client } = this.getStackTop();
          if (!scope) return;
          const session = scope.getSession && scope.getSession();
          if (session) {
            session.close();
            if (client && client.captureSession) {
              client.captureSession(session);
            }
            scope.setSession();
          }
        }
    

    分析:

    • 调用session.close()其实就是调用session.update()方法去更新当前的session的值,记录下当前session的状态。
    • 如果有client客户端,就会调用captureSession方法去向客户端发送数据(上报的内容在之后的玩转前端监控,全面解析Sentry源码(三)| 数据上报的文章中统一讲解)
    • 最后scope.setSession() 把session保存到scope上

七、重点:initAndBind

如下图的流程图,initAndBind主要是通过new BrowserClient初始化了客户端client,并且把 client 绑定到了 hub控制中心 上

来看看源码:

 initAndBind(BrowserClient, options);
---------------------------------------------------------------------
function initAndBind(clientClass, options) {
    if (options.debug === true) {
        utils_1.logger.enable();
    }
    var hub = hub_1.getCurrentHub();
    var client = new clientClass(options);
    hub.bindClient(client);
}

分析:

  • initAndBind()第一个参数是 BrowserClien类代表要实例化的客户端类,第二个参数是初始化后的options
  • 所以可以分成三条线来分析:第一个是BrowserClient实例化的客户端,第二个是获取当前的控制中心hub,第三个是把客户端绑定到当前的控制中心hub上

7.1 BrowserClient

看看源码:

export class BrowserClient extends BaseClient<BrowserBackend, BrowserOptions> {
  public constructor(options: BrowserOptions = {}) {
    super(BrowserBackend, options);
  }
  // ...
}

分析:

  • BrowserClient继承了BaseClient,并且传入BrowserBackend
  • BaseClient这边提供了一些公有的方法比如captureEvent,captureException,captureMessage进行上报消息
  • 而BrowserBackend里有_setupTransport方法去判断ajax上传是走fetch还是xhr

7.2 getCurrentHub()

获取当前的控制中心Hub

看看源码:

function getCurrentHub() {
    // Get main carrier (global for every environment)
    var registry = getMainCarrier();
    if (!hasHubOnCarrier(registry) || getHubFromCarrier(registry).isOlderThan(exports.API_VERSION)) {
        setHubOnCarrier(registry, new Hub());
    }
    // Prefer domains over global if they are there (applicable only to Node environment)
    if (utils_1.isNodeEnv()) {
        return getHubFromActiveDomain(registry);
    }
    // Return hub that lives on a global object
    return getHubFromCarrier(registry);
}

分析:

  • getMainCarrier主要是看全局上是否已经挂载了__ SENTRY __属性,没有就给全局赋一个初始值。

        carrier.__SENTRY__ = carrier.__SENTRY__ || {
          extensions: {},
          hub: undefined,
        };
    
  • 然后如果没有控制中心在载体上,或者它的版本是老版本,就调用setHubOnCarrier设置新的

      function setHubOnCarrier(carrier, hub) {
          if (!carrier)
              return false;
          carrier.__SENTRY__ = carrier.__SENTRY__ || {};
          carrier.__SENTRY__.hub = hub;
          return true;
      }
    
  • 如果是node环境执行getHubFromActiveDomain,这里就不是讨论的重点

  • 最后就是返回hub,拿到当前控制中心

      function getHubFromCarrier(carrier) {
          if (carrier && carrier.__SENTRY__ && carrier.__SENTRY__.hub)
              return carrier.__SENTRY__.hub;
          carrier.__SENTRY__ = carrier.__SENTRY__ || {};
          carrier.__SENTRY__.hub = new Hub();
          return carrier.__SENTRY__.hub;
      }
    

7.3 bindClient

拿到hub客户端后,就要把new BrowserClient 实例的客户端绑定到当前的控制中心

看看源码:

    Hub.prototype.bindClient = function (client) {
        var top = this.getStackTop();
        top.client = client;
        if (client && client.setupIntegrations) {
            client.setupIntegrations();
        }
    };

分析:

  • getStackTop是返回最后加入队列的hub

  • top.client = client就是把 new BrowerClient() 实例 绑定到top上

  • 而setupIntegrations就对defaultIntegrations默认集成或者自定义集成进行遍历instal,为客户端提供各种能力l。

      function setupIntegrations(options) {
          const integrations = {};
          getIntegrationsToSetup(options).forEach(integration => {
              integrations[integration.name] = integration;
              setupIntegration(integration);
          });
          return integrations;
      }
    

    getIntegrationsToSetup就是获取integrations

八、总结

最后,我们通过一张流程图来概括Sentry初始化的内容:

用户通过Sentry.init传入自己所需的属性或方法,Sentry会对这一些属性和方法进行处理,并且最后注册一个客户端client,绑定到控制中心hub上,然后通过集成为客户端提供各种能力。

九、参考资料