易车前端监控系统

425 阅读10分钟

背景

自研工具是为了解决内部问题而生,希望通过这些问题引起大家的共鸣:

  • 是否知道重要的业务,该页面是可以正常服务于用户的?

  • 能否在问题还没有大规模爆发之前,快速的感知到业务的异常?

  • 怎么不去用户的电脑上就能直观的看到问题所在,从而俯瞰项目全局;能否从宏观到微观一路下钻快速的定位线上告警信息?

  • 在跨部门沟通时拿出合理的证据,来告诉他这个时间段该接口就是无法访问的,并告知我们的参数传的很正确,帮助服务端反查问题。

  • 产品和设计同学想要提升用户体验,研发不断迭代功能版本。那这些我们以为的优化点,效果究竟如何?怎么去衡量?

  • 哪个广告位,哪个资源位更有价值?怎么能更为精准的触达用户痛点,为提升业务赋能?

我们看到这些疑问,都需要数据指标的支撑。从解决这些问题的角度出发,把反复出现或无法跟其他部门交代的问题,打造成可以帮助我们解决问题的产品。

所以在这种场景下,易车·前端监控应运而生。

它主要是多场景多维度实时的监控大盘,实现浏览器客户端的全链路监控,方便团队事后追查和整改,转变为事前预警和快速判定根因。

经过详细的规划以后,我们把前端监控分为四期,分别为:异常监控(一期)、性能监控(二期)、数据埋点(三期)、行为采集(四期),于 2020 年 6 月 23 号正式启动研发,目前处于二期阶段。

关键结构

为实现上述需求,监控系统主要分为四个阶段来实现;分别是:指标采集、指标存储、统计与分析、可视化展示。

  • 指标采集阶段:通过前端集成的 SDK 收集请求、性能、异常等指标信息;在客户端简单的处理一次,然后上报到服务器。

  • 指标存储阶段:用于接收前端上报的采集信息,主要目的是数据落地。

  • 统计与分析阶段:自动分析,通过数据的统计,让程序发现问题从而触发报警。人工分析,是通过可视化的数据面板,让使用者看到具体的日志数据,从而发现异常问题根源。

  • 可视化展示阶段:通过可视化的平台;在这些指标(API 监控、异常监控、资源监控、性能监控)中,追查用户行为来定位各项问题。

整体架构图

随着统计需求的增加以及前端应用的上线,数据量由早期的每天 100 多万条数据;到现在的每天约 7000 万条数据。架构上也经历了三次版本的迭代。这是最新版的架构图,主要经过 6 层处理。

  • 采集层:PC 和 H5 使用了一套 SDK 监听事件采集指标,然后将监听到的指标通过 REST 接口往 Logback 推送数据。Logback 以长连接的方式,会把这些不同类型的指标数据推送到 Flume 集群当中。Flume 集群会将这些数据,分发到 Kafka Topic 进行存储。

  • 处理层:由 Flink 去实时消费;Flink 会消费三种类型,分别是:离线数据落地、实时 ETL+图谱、明细日志。

  • 存储层:离线数据会存储到 HDFS 中;实时 ETL+图谱数据会存储到 MySQL 中;明细数据会落入到 ES 中。

  • 统计层:离线(DW、DM)、实时(分钟级->十分钟级->小时级)的方式,对指标进行汇总和统计。

  • 应用层:最后由接口去汇总表和明细 ES 里查询数据。

  • 展示层:然后前端输出图表、报表、明细、链路等信息。

技术方案

数据采集

采集最初的愿景是希望对业务无侵入性,业务系统无需改造,只需要嵌入一段代码即可。所以这些采集,都是 SDK 自动化的处理。 SDK 会全局监听几个事件,分别为:错误监听、资源异常的监听、页面性能的监听、API 调用的监听。 通过这几项监听,最终汇总为 3 项指标的采集。

  • 异常采集:调用 error/unhandledrejection 事件,用于捕获 JS、图片、CSS 等资源异常信息。**

  • 性能采集:调用浏览器原生的 performance.timing API 捕获页面的性能指标。

  • 接口采集:通过 Object.definePropety 代理全局的 XHR 用于捕获浏览器的 XHR/FETCH 的请求。

采集端 SDK 架构

SDK 主要分为两部分:

  • 第一部分:SDK 主要是 SDK 的驱动,包含:入口、核心工具以及通用类型的推断。

  • 第二部分:也叫做插件部分(蓝色区域),主要实现上面的三项数据指标的采集。 接下来主要会详细的介绍第二部分,各项指标的采集方案。

异常采集方案

通过监听 error 错误,即可捕获到所有(JS 错误、图片加载、CSS 加载、JS 加载、Promise 等)异常;它也支持 InternalError、ReferenceError 等 7 种错误捕获。 以下是关键性代码。

监听事件

/** * 监听 error、unhandledrejection 方法处理异常信息 * * @param {YicheMonitorInstance} instance SDK 实例 */export default function setupErrorPlugin(instance: YicheMonitorInstance) {    // JS 错误或静态资源加载错误  on('error', (e: Event, url: any, lineno: any) => {    handleError(instance, e, url, lineno);  });  // Promise 错误,IE 不支持  on('unhandledrejection', (e: any) => {    handleError(instance, e);  });}

判断异常类型

/** * W3C 模式支持 ErrorEvent,所有的异常从 ErrorEvent 这里取 * * @param {MutationEvent} error 资源错误、代码错误 */function handleW3C(event: any) {  switch (event.type) {    // 判断脚本错误,还是资源错误    case 'error':      event instanceof ErrorEvent        ? reportJSError(instance, event)        : reportResourceError(instance, event);      break;    // Promise 是否存在未捕获 reject 的错误    case 'unhandledrejection':      reportPromiseError(instance, event);      break;  }}

捕获异常数据

/** * 上报 JS 异常 * * @param {YicheMonitorInstance} instance SDK 实例 * @param {ErrorEvent} event */export default function reportJSError(  instance: YicheMonitorInstance,  event: ErrorEvent,): void {  // 设置上报数据  const report = new ReportDataStruct('error', 'js');  const errorInfo = event.error    ? event.error.message    : `未知错误:${event.message}`;    // 设置错误信息,兼容远程脚本不设置 Script error 导致的异常  report.setData({    det: errorInfo.substring(0, 2000),    des: event.error ? event.error.stack : '',    defn: event.filename,    deln: event.lineno,    delc: event.colno,    rre: 1,  });}

处理 IE 兼容问题

捕获异常时处理下 IE 的兼容性问题即可,IE 的方案如下:

/** * IE 8 的错误项,所以针对于 IE 8 浏览器,我们只需要获取到它出错了即可。 * * 1. 错误消息 * 2. 错误页面 * 3. 错误行号(因为文件通常是压缩的,所以统计 IE8 的行号是没有任何意义的) * * @param {string} error 错误消息 * @param {string | undefined} url 异常的 URL * @param {number | undefined} lineno 异常行数,IE 没有列数 */export function handleIE8Error(  error: string,  url?: string | undefined,  lineno?: number | undefined,) {  return {    colno: 0,    lineno: lineno,    filename: url,    message: error,    error: {      message: error,      stack: `IE8 Error:${error}`,    },  } as ErrorEvent;}/** * IE 9 的错误,需要在 target 里面获取到 * * @param { Element | any } error IE9 异常的元素 */export function handleIE9Error(error: any) {  // 获取 Event  const event = error.currentTarget.event;  return {    colno: event.errorCharacter,    lineno: event.errorLine,    filename: event.errorUrl,    message: event.errorMessage,    error: {      message: event.errorMessage,      stack: `IE9 Error:${event.errorMessage}`,    },  } as ErrorEvent;}

性能采集方案

浏览器页面加载过程

性能指标获取方式

我们借助于浏览器原生的 Navigation Timing API 能够获取到上述页面加载过程中的各项性能指标数据,用于性能分析,它的时间单位是纳秒级。 当然也借助于 PerformanceObserver API 等用于测量 FCP、LCP、FID、TTI、TBT、CLS 等关键性指标。

详细的计算公式

网络请求采集方案

网络请求,通过 Object.definePropety 的方式对 XHR 做的代理。关键性代码如下。

重写 XMLHttpRequest

这部分可以直接参考 ajax-hook 的实现原理。

export function hook(proxy) {    window[realXhr] = window[realXhr] || XMLHttpRequest    XMLHttpRequest = function () {        const xhr = new window[realXhr];        for (let attr in xhr) {            let type = "";            try {                type = typeof xhr[attr]            } catch (e) {            }            if (type === "function") {                this[attr] = hookFunction(attr);            } else {                Object.defineProperty(this, attr, {                    get: getterFactory(attr),                    set: setterFactory(attr),                    enumerable: true                })            }        }              const that = this;        xhr.getProxy = function () {            return that        }              this.xhr = xhr;    }    return window[realXhr];}

拦截所有请求

正常的情况下一个页面会请求多个接口,假如有 20 个请求; 我们期望在阶段性的所有请求都结束已后,汇总成一条记录合并上报,这样能有效减少请求的并发量。

关键性代码如下:

/** * Ajax 请求插件 * * @author wubaiqing <wubaiqing@vip.qq.com> */// 所有的数据请求,以及总量let allRequestRecordArray: any = [];let allRequestRecordCount: any = [];// 成功的数据,200304 的数据let allRequestData: any = [];// 异常的数据,超时,405 等接口不存在的数据let errorData: any = [];/** * 监听 Ajax 请求信息 * * @param {YicheMonitorInstance} instance SDK 实例 */export default function setupAjaxPlugin(instance: YicheMonitorInstance) {  let id = 0;  proxy({    onRequest: (config, handler) => {      // 过滤掉听云、福尔摩斯、APM      if (filterDomain(config)) {        // 添加请求记录的队列        allRequestRecordArray.push({          id,          timeStamp: new Date().getTime(), // 记录请求时长          config, // 包含:请求地址、body 等内容          handler, // XHR 实体        });        // 记录请求总数        allRequestRecordCount.push(1);        id++;      }      handler.next(config);    },    // 失败时会触发一次    onError: (err, handler) => {      if (allRequestRecordArray.length === 0) {        handler.next(err);        return;      }      for (let i = 0; i < allRequestRecordArray.length; i++) {        // 当前的数据        const currentData = allRequestRecordArray[i];        if (          currentData.handler.xhr.status === 0 && // 未发送          currentData.handler.xhr.readyState === 4        ) {          errorData.push(            JSON.stringify(handleReportDataStruct(instance, currentData)),          );          allRequestRecordArray.splice(i, 1);        }      }      sendAllRequestData(instance);      handler.next(err);    },    onResponse: (response, handler) => {      // 没有请求就返回 Null      if (allRequestRecordArray.length === 0) {        handler.next(response);        return;      }      for (let i = 0; i < allRequestRecordArray.length; i++) {        // 当前的数据        const currentData = allRequestRecordArray[i];        // 只要请求加载完成,不管是成功还是失败,都记录是一次请求        if (currentData.handler.xhr.readyState === 4) {          // 正常的请求          if (            (currentData.handler.xhr.status >= 200 &&              currentData.handler.xhr.status < 300) ||            currentData.handler.xhr.status === 304          ) {            allRequestData.push(              JSON.stringify(handleReportDataStruct(instance, currentData)),            );          } else {            if (currentData.handler.xhr.status > 0) {              // 具备状态码              // 错误的请求              errorData.push(                JSON.stringify(handleReportDataStruct(instance, currentData)),              );            }          }          // 删除当前数组的值          allRequestRecordArray.splice(i, 1);        }      }      // 发送数据      sendAllRequestData(instance);      handler.next(response);    },  });}function sendAllRequestData(instance) {  if (    allRequestData.length + errorData.length ===    allRequestRecordCount.length  ) {    // 处理正常请求    if (allRequestData.length > 0 || errorData.length > 0) {      handleAllRequestData(instance);    }    // 处理异常请求    if (errorData.length > 0) {      handleErrorData(instance);    }    // 所有的数据请求,以及总量    allRequestRecordArray = [];    allRequestRecordCount = [];    // 成功的数据,200,304 的数据    allRequestData = [];    // 异常的数据,超时,405 等接口不存在的数据    errorData = [];  }}

探针加载方案

探针加载有两种方式,他们分别有一些优缺点:

同步加载:采集 SDK 放到所有 JS 请求头的前面;因为加载顺序的问题,如果放在其他 JS 请求之后,之前的 JS 出现了异常,就捕获不到了。因为要提前加载 JS 资源,会对性能有一定影响。

异步加载:采集 SDK 通过执行 JS 后注入到页面中;如果能保障首次的 JS 无异常,也可以使用异步的方式加载 SDK,对首屏优化有好处。

目前我们采用的是第一种同步加载的方式。

产品部分截图

首页

首页会展示所有应用的情报,在首页可以直观的发现各应用的异常数据。

大盘页面

如果想对某个应用细项的排查,会进入到应用的大盘页面;

主要会展示该应用,前端的重要性指标,近一个小时内的数据状况。

目前主要有页面性能、资源异常、JS 异常、API 接口成功率等重要指标作为衡量。

详情页

详情页,就可以看到该应用某项指标的数据细项。方便团队进行事后的追查、整改,提前预警和快速判定根因所用。

遇到的问题

SDK 采集到指标以后对数据进行上报时,会做一些过滤性的前置操作,如:

  • 屏蔽掉一些黑名单。

  • 指标的削峰填谷。

  • 应用信息的转换。

  • 客户端 IP 获取。

  • Token 的验证。 前置处理有一个弊端,因为服务器会经过解析转换环节;当数据量达到每日 7000 万左右,上报的服务器就扛不住了。

所以我们把数据前置处理,变为数据落地后置处理;后置处理就是在数据清洗的过程中,在过滤掉黑名单以及异常指标。这样就减轻了上报服务器的压力。

并且仓库也会保留所有的原始数据,如果出现异常的时,也方便我们溯源,对数据进行恢复。

整体规划

我们分为了四期,目前还处于二期性能监控阶段。

其它

自研 APM 系统方便与内部进行的打通和整合;比如应用发布后就可以直接推送 SourceMap 文件;并且能实现线上发布以后自动进行页面性能的分析等工作。

如果目前发展阶段还不需要自建一个这样的系统,但业务需要这样的能力,也可以考虑第三方的一些产品。

商业产品分析

重要性指标对和阿里 ARMS 对比

易车·前端监控和阿里云 ARMS 做了一些重要性的指标对比,均值的浮动在上下在 5%-8% 左右;

结语

如果你觉得此文对你有一丁点帮助,点个赞,鼓励一下作者。

如果你想和获得React交流的机会,和数万React 开发者一起交流学习**,获得100本精选电子书籍PDF 点击这里** ---> React沸点

如果你是有其他目的的,别加我,我不想跟你交朋友,我只想简简单单学习前端,不想搞一些有的没的!!!