解析Sentry源码(三)| 数据上报

3,708 阅读9分钟

一、前言

最近对前端监控很有兴趣,所以去使用了前端监控里优秀的开源库最近对前端监控很有兴趣,所以去使用了前端监控里优秀的开源库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处理好的错误数据,这时候需要调用currentHub.captureEvent进行数据上报。

我们先来看看主流的数据上报方式有:

  • 采用Ajax通信的方式上报(Sentry采用的方式)

  • img请求上报, url参数带上错误信息

    比如:(new Image()).src = 'docs.sentry.io/error?error…


而Sentry上报的方式是采用Ajax通信。我在Sentry会在初始化的7.1 BrowserClient中的有提到

而BrowserBackend里有_setupTransport方法去判断ajax上传是走fetch还是XHR

为了兼容低版本浏览器不支持fetch,所以在初始化的时候就确定ajax通信采用的是fetch还是xhr。

我们来看看源码怎么写的:

class BaseBackend {
    constructor(options) {
    	// ...
      this._transport = this._setupTransport();
    }    
	_setupTransport() {
   	// ...
      if (supportsFetch()) {
        return new FetchTransport(transportOptions);
      }
      return new XHRTransport(transportOptions);
    }
}

supportsFetch:

  /**
   * Tells whether current environment supports Fetch API
   * {@link supportsFetch}.
   *
   * @returns Answer to the given question.
   */
  function supportsFetch() {
    if (!('fetch' in getGlobalObject())) {
      return false;
    }
    try {
      new Headers();
      new Request('');
      new Response();
      return true;
    } catch (e) {
      return false;
    }
  }

分析:

  • getGlobalObject是获取全局对象
  • 代码很简单就看全局上是不是有fetch方法
  • 最终this._transport上就有了对应发送请求的方法

接下来就是详细的步骤分析:

三、数据上报

我们先看captureEvent源码:

    captureEvent(event, hint) {
      const eventId = (this._lastEventId = uuid4());
      this._invokeClient('captureEvent', event, Object.assign(Object.assign({}, hint), { event_id: eventId }));
      return eventId;
    }

分析:

  • 可以看到captureEvent实际上是调用_invokeClient

3.1 _invokeClient

    /**
     * Internal helper function to call a method on the top client if it exists.
     *
     * @param method The method to call on the client.
     * @param args Arguments to pass to the client function.
     */
    _invokeClient(method, ...args) {
      const { scope, client } = this.getStackTop();
      if (client && client[method]) {
        client[method](...args, scope);
      }
    }

分析:

  • _invokeClient 是个统一的调度方法,取到scope,执行captureEvent方法。

3.2 captureEvent

   captureEvent(event, hint, scope) {
      let eventId = hint && hint.event_id;
      this._process(
        this._captureEvent(event, hint, scope).then(result => {
          eventId = result;
        }),
      );
      return eventId;
    }

分析:

  • _process 就是流程控制器,记录当前的步骤

3.3 _captureEvent

    _captureEvent(event, hint, scope) {
      return this._processEvent(event, hint, scope).then(
        finalEvent => {
          return finalEvent.event_id;
        },
        reason => {
          logger.error(reason);
          return undefined;
        },
      );
    }

分析:

  • _processEvent是实现的重点
  • 最后会返回event_id 事件id

3.4 重点:_processEvent

_processEvent是数据上报的关键,这里主要处理事件(错误或信息),并将其发送给Sentry,同时也可以为事件添加面包屑breadcrumbscontext上下文信息,当然前提是有平台的信息比如用户ip地址等。

    _processEvent(event, hint, scope) {
      const { beforeSend, sampleRate } = this.getOptions();
      if (!this._isEnabled()) {
        return SyncPromise.reject(new SentryError('SDK not enabled, will not send event.'));
      }
      const isTransaction = event.type === 'transaction';
      // 1.0 === 100% events are sent
      // 0.0 === 0% events are sent
      // Sampling for transaction happens somewhere else
      if (!isTransaction && typeof sampleRate === 'number' && Math.random() > sampleRate) {
        return SyncPromise.reject(new SentryError('This event has been sampled, will not send event.'));
      }
      return this._prepareEvent(event, scope, hint)
        .then(prepared => {
          if (prepared === null) {
            throw new SentryError('An event processor returned null, will not send event.');
          }
          const isInternalException = hint && hint.data && hint.data.__sentry__ === true;
          if (isInternalException || isTransaction || !beforeSend) {
            return prepared;
          }
          const beforeSendResult = beforeSend(prepared, hint);
          if (typeof beforeSendResult === 'undefined') {
            throw new SentryError('`beforeSend` method has to return `null` or a valid event.');
          } else if (isThenable(beforeSendResult)) {
            return beforeSendResult.then(
              event => event,
              e => {
                throw new SentryError(`beforeSend rejected with ${e}`);
              },
            );
          }
          return beforeSendResult;
        })
        .then(processedEvent => {
          if (processedEvent === null) {
            throw new SentryError('`beforeSend` returned `null`, will not send event.');
          }
          const session = scope && scope.getSession && scope.getSession();
          if (!isTransaction && session) {
            this._updateSessionFromEvent(session, processedEvent);
          }
          this._sendEvent(processedEvent);
          return processedEvent;
        })
        .then(null, reason => {
          if (reason instanceof SentryError) {
            throw reason;
          }
          this.captureException(reason, {
            data: {
              __sentry__: true,
            },
            originalException: reason,
          });
          throw new SentryError(
            `Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event.\nReason: ${reason}`,
          );
        });
    }

因为涉及内容很多,所以我分成几个大块详细讲解分析

(1) 校验

      const { beforeSend, sampleRate } = this.getOptions();
      if (!this._isEnabled()) {
        return SyncPromise.reject(new SentryError('SDK not enabled, will not send event.'));
      }
      const isTransaction = event.type === 'transaction';
      // 1.0 === 100% events are sent
      // 0.0 === 0% events are sent
      // Sampling for transaction happens somewhere else
      if (!isTransaction && typeof sampleRate === 'number' && Math.random() > sampleRate) {
        return SyncPromise.reject(new SentryError('This event has been sampled, will not send event.'));

这一段主要是去判断是否满足上报的条件:

  • 参数中event代表发送给Sentry的事件,hint代表包含有关原始异常的其他信息,scope包含事件元数据的作用域

  • _isEnabled这里主要是为了判断用户传入的参数里是不是设置了enabled为false或者dsn为空的情况,这会导致SDK无法使用,无法发送。所以如果当客户端接受不到信息的时候,不要慌看看自己Sentry.init都传了什么值

      _isEnabled() {
          return this.getOptions().enabled !== false && this._dsn !== undefined;
        }
    
  • SyncPromise其实就是模拟了一个Promise

  • sampled 这一块是与Performance性能挂钩的,它其实就是采样

    我们在Sentry.init的时候其实可以传入tracesSampleRate去控制每个事务都有几个百分比的机会被发送到 Sentry。(例如,如果你将 tracesSampleRate 设置为0.2,大约20% 的事务将被记录并发送。):

    Sentry.init({
      // ...
    
      tracesSampleRate: 0.2,
    });
    

    sampled这里判断是不是number类型是因为,sampled也可以设置为boolean值

    比如在创建事务时知道是否希望将事务发送到 Sentry,于是可以采用Sentry.startTransaction方法直接给事务构造函数。这时候,事务就不会受到 tracesSampleRate 的约束,也不会运行 tracesSampler,发送的事务也不会被覆盖。

    Sentry.startTransaction({
      name: "Search from navbar",
      sampled: true,
    });
    

    更多相关内容可参考官网

(2) _prepareEvent添加通用的信息

      return this._prepareEvent(event, scope, hint)
        .then(prepared => {
          if (prepared === null) {
            throw new SentryError('An event processor returned null, will not send event.');
          }

分析:

  • _prepareEvent 主要是为event事件添加通用的信息,包含了从options里获取的发布的版本号release,和环境environment,从作用域scope获取的面包屑breadcrumbs和上下文context等等

  • 在_prepareEvent返回事件前,会有一个self._shouldDropEvent进行判断,如果在Sentry.init中设置了ignoreErrors,denyUrls,allowUrls等数据过滤并且命中的时候,此时会返回null,因此prepared此时也会返回null,会退出事件,不进行事件上报

              if (self._shouldDropEvent(event, options)) {
                return null;
              }
    
  • _prepareEvent其实涉及了很多细节,这里与本文没有太多关联,如果有兴趣我再专门讲解。

(3) beforeSend 数据上报前的回调函数

          const beforeSendResult = beforeSend(prepared, hint);
          if (typeof beforeSendResult === 'undefined') {
            throw new SentryError('`beforeSend` method has to return `null` or a valid event.');
          } else if (isThenable(beforeSendResult)) {
            return beforeSendResult.then(
              event => event,
              e => {
                throw new SentryError(`beforeSend rejected with ${e}`);
              },
            );
          }
          return beforeSendResult;

beforeSend会在事件发送到服务器之前立即调用,而这里的beforeSend其实就是用户传入beforeSend方法

比如:避免发送邮箱信息

Sentry.init({
  beforeSend(event) {
    // Modify the event here
    if (event.user) {
      // Don't send user's email address
      delete event.user.email;
    }
    return event;
  },
});

(4) 序列化错误数据

          const session = scope && scope.getSession && scope.getSession();
          if (!isTransaction && session) {
            this._updateSessionFromEvent(session, processedEvent);
          }
          this._sendEvent(processedEvent);
          return processedEvent;

分析:

  • 有session就调用_updateSessionFromEvent 就是从事件event 获取信息,更新session。具体就看之后的session专题

  • _sendEvent就是告诉backend后端去发送事件

        _sendEvent(event) {
          const integration = this.getIntegration(Breadcrumbs);
          if (integration) {
            integration.addSentryBreadcrumb(event);
          }
          super._sendEvent(event);
        }
     ------------------------------------------------------------------
        _sendEvent(event) {
          this._getBackend().sendEvent(event);
        }
    -------------------------------------------------------------------
       sendEvent(event) {
          this._transport.sendEvent(event).then(null, reason => {
            logger.error(`Error while sending event: ${reason}`);
          });
        }
    --------------------------------------------------------------------
        sendEvent(event) {
          return this._sendRequest(eventToSentryRequest(event, this._api), event);
        }
    

    分析:

    • 发送的时候会获取到Breadcrumbs面包屑

    • 然后获取后端也就是BrowserBackend去执行sendEvent(说明一下backend也就是BrowserClient传入的BrowserBackend,可以看这初始化那篇文章)

    • _transport 其实就是之前初始化已经辨别是走xhr还是fetch,这里走fetch(也就是导读分析的部分)

    • eventToSentryRequest:

      eventToSentryRequest主要是处理event序列化,生成body,url,type

        function eventToSentryRequest(event, api) {
        // ...
          const req = {
            body: JSON.stringify(event),
            type: event.type || 'event',
            url: useEnvelope ? api.getEnvelopeEndpointWithUrlEncodedAuth() : api.getStoreEndpointWithUrlEncodedAuth(),
          };
      // ...
          return req;
        }
      

      最后我们看看,经过我们多篇文章探索所得出的最终上报数据的格式如下:

            body: "{
              exception: {
                values: [
                  {
                    type: 'Error',
                    value: 'externalLibrary method broken: 1610509422407',
                    stacktrace: {
                      frames: [
                        {
                          colno: 1,
                          filename:
                            'https://rawgit.com/kamilogorek/cfbe9f92196c6c61053b28b2d42e2f5d/raw/3aef6ff5e2fd2ad4a84205cd71e2496a445ebe1d/external-lib.js',
                          function: '?',
                          in_app: true,
                          lineno: 5,
                        },
                        {
                          colno: 9,
                          filename:
                            'https://rawgit.com/kamilogorek/cfbe9f92196c6c61053b28b2d42e2f5d/raw/3aef6ff5e2fd2ad4a84205cd71e2496a445ebe1d/external-lib.js',
                          function: 'externalLibrary',
                          in_app: true,
                          lineno: 2,
                        },
                      ],
                    },
                    mechanism: { handled: false, type: 'onerror' },
                  },
                ],
              },
              platform: 'javascript',
              sdk: {
                name: 'sentry.javascript.browser',
                packages: [{ name: 'npm:@sentry/browser', version: '5.29.2' }],
                version: '5.29.2',
                integrations: [
                  'InboundFilters',
                  'FunctionToString',
                  'TryCatch',
                  'Breadcrumbs',
                  'GlobalHandlers',
                  'LinkedErrors',
                  'UserAgent',
                ],
              },
              event_id: 'aec2b5cdf4b34efa92c4766ea76a2f4b',
              timestamp: 1610509422.9,
              environment: 'staging',
              release: '1537345109360',
              breadcrumbs: [
                {
                  timestamp: 1610509411.46,
                  category: 'console',
                  data: {
                    arguments: [
                      'currentHub',
                      { _version: 3, _stack: '[Array]', _lastEventId: 'aec2b5cdf4b34efa92c4766ea76a2f4b' },
                    ],
                    logger: 'console',
                  },
                  level: 'log',
                  message: 'currentHub [object Object]',
                },
                {
                  timestamp: 1610509411.462,
                  category: 'console',
                  data: { arguments: ['Time Hooker Works!'], logger: 'console' },
                  level: 'log',
                  message: 'Time Hooker Works!',
                },
                { timestamp: 1610509411.52, category: 'ui.click', message: 'body > button#plainObject' },
                { timestamp: 1610509415.083, category: 'ui.click', message: 'body > button#deny-url' },
                { timestamp: 1610509416.768, category: 'ui.click', message: 'body > button#deny-url' },
                {
                  timestamp: 1610509422.405,
                  category: 'sentry.event',
                  event_id: 'b91c3bbff53047b7b6b40cd87a82c88e',
                  message: 'Error: externalLibrary method broken: 1610509417092',
                },
              ],
              request: {
                url: 'http://127.0.0.1:5500/packages/browser/examples/index.html',
                headers: {
                  Referer: 'http://127.0.0.1:5500/packages/browser/examples/index.html',
                  'User-Agent':
                    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36',
                },
              },
              tags: {},
            }",
            type: 'event',
            url: 'https://sentry.io/api/297378/store/?sentry_key=363a337c11a64611be4845ad6e24f3ac&sentry_version=7',
      

      分析:

      • body里的exception就是上一篇文章讲过的错误内容
      • sdk就是我们具体使用sdk的版本,还有集成
      • breadcrumbs面包屑内容,之后专题会讲解如何生成
      • request 就是当前发起请求的路径内容
      • url是发向后端的api

(5)_sendRequest发送请求

    /**
     * @param sentryRequest Prepared SentryRequest to be delivered
     * @param originalPayload Original payload used to create SentryRequest
     */
    _sendRequest(sentryRequest, originalPayload) {
      if (this._isRateLimited(sentryRequest.type)) {
        return Promise.reject({
          event: originalPayload,
          type: sentryRequest.type,
          reason: `Transport locked till ${this._disabledUntil(sentryRequest.type)} due to too many requests.`,
          status: 429,
        });
      }
      const options = {
        body: sentryRequest.body,
        method: 'POST',
        // Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default
        // https://caniuse.com/#feat=referrer-policy
        // It doesn't. And it throw exception instead of ignoring this parameter...
        // REF: https://github.com/getsentry/raven-js/issues/1233
        referrerPolicy: supportsReferrerPolicy() ? 'origin' : '',
      };
      if (this.options.fetchParameters !== undefined) {
        Object.assign(options, this.options.fetchParameters);
      }
      if (this.options.headers !== undefined) {
        options.headers = this.options.headers;
      }
      return this._buffer.add(
        new SyncPromise((resolve, reject) => {
          global$3
            .fetch(sentryRequest.url, options)
            .then(response => {
              const headers = {
                'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
                'retry-after': response.headers.get('Retry-After'),
              };
              this._handleResponse({
                requestType: sentryRequest.type,
                response,
                headers,
                resolve,
                reject,
              });
            })
            .catch(reject);
        }),
      );
    }

分析:

  • _isRateLimited就是防止一瞬间太多相同的错误发生

  • 然后就是对options的一些处理,该合并该赋值赋值

  • this._buffer.add 就是把promise加入buffer队列中

  • 之后等待向服务器发起请求,下面是请求的截图

(6)_handleResponse处理返回的请求

我们先看看返回的数据

接着看看_handleResponse对数据都做了哪些处理

    /**
     * Handle Sentry repsonse for promise-based transports.
     */
    _handleResponse({ requestType, response, headers, resolve, reject }) {
      const status = exports.Status.fromHttpCode(response.status);
      /**
       * "The name is case-insensitive."
       * https://developer.mozilla.org/en-US/docs/Web/API/Headers/get
       */
      const limited = this._handleRateLimit(headers);
      if (limited) logger.warn(`Too many requests, backing off until: ${this._disabledUntil(requestType)}`);
      if (status === exports.Status.Success) {
        resolve({ status });
        return;
      }
      reject(response);
    }

分析:

  • fromHttpCode就是返回状态200到300是Success,429就是被限制RateLimit,400到500为Invalid,500以上就是Failed,其他Unknown,所以这里的status是Success
  • 然后成功就是resolve

(6) 请求失败时

我们回过头看_processEvent 中的

    .then(null, reason => {
          if (reason instanceof SentryError) {
            throw reason;
          }
          this.captureException(reason, {
            data: {
              __sentry__: true,
            },
            originalException: reason,
          });
          throw new SentryError(
            `Event processing pipeline threw an error, original event will not be sent. Details have been sent as a new event.\nReason: ${reason}`,
          );
        });

分析:

  • 第一个参数传入null,如果成功发送其实是不会执行到这里会直接退出
  • 而出错就会调用captureException去上报错误

到此,整个自动上报的过程就完成了,接下来我们看看主动上报

四、captureException和captureMessage主动上报数据

captureException是上传一个错误对象

captureMessage则上传递一个消息,这个消息即可以包含错误信息,也可以是普通消息

然后我们看看它们的源码

captureException:

      BaseClient.prototype.captureException(exception, hint, scope) {
          let eventId = hint && hint.event_id;
          this._process(this._getBackend()
              .eventFromException(exception, hint)
              .then(event => this._captureEvent(event, hint, scope))
              .then(result => {
              eventId = result;
          }));
          return eventId;
      }
     

captureMessage:

      BaseClient.prototype.captureMessage = function (message, level, hint, scope) {
        var _this = this;
        var eventId = hint && hint.event_id;
        var promisedEvent = utils_1.isPrimitive(message)
            ? this._getBackend().eventFromMessage(String(message), level, hint)
            : this._getBackend().eventFromException(message, hint);
        this._process(promisedEvent
            .then(function (event) { return _this._captureEvent(event, hint, scope); })
            .then(function (result) {
            eventId = result;
        }));
        return eventId;
    };

这里的关于如何处理消息已经在上一篇讲解过了,所以这里重点是讲解_captureEvent方法

分析:

  • 对于错误消息captureMessage和captureException都会调用eventFromException去处理消息

  • 而captureMessage需要判断信息message是不是基本类型,基本类型走eventFromMessage,引用类型走eventFromException 去处理message。

  • 这里的关于如何处理消息已经在上一篇讲解过了,所以这里重点是讲解_captureEvent方法

       _captureEvent(event, hint, scope) {
          return this._processEvent(event, hint, scope).then(
            finalEvent => {
              return finalEvent.event_id;
            },
            reason => {
              logger.error(reason);
              return undefined;
            },
          );
        }
    

    其实就是调用_processEvent方法

到此,整个数据上报的内容就完成了

五、总结

最后我们通过一张流程图来看看整个数据上报的过程:

六、参考资料