如何设计一个前端监控SDK

2,274 阅读14分钟

1 需求背景:前端为什么需要监控

身为前端工程师,我们做过太多页面了。你是否知道你的页面日均 PV 有多少?你是否知道用户对某些功能的使用高峰期在什么时段?当需求评审发生争议时,你是否苦于没有数据而无法建立起话语权?当出现线上问题时,你是否总是被拉进群里而又没有任何日志来自证清白?

身为业务一线的前端工程师,我们是很需要知道这些信息的。无论是对业务理解、需求探讨,还是对问题排障、体验优化,监控是我们从“前端工程师”迈向“工程师”的第一步转变。

2 需求分析:前端需要监控什么

从业务理解来说,我们需要知道用户的使用情况,包括 PV、UV、访问时段、访问时长等。

从问题排障来说,我们需要知道用户的使用快照,包括发生问题时的接口请求、页面报错等。

从体验优化来说,我们需要知道真实的性能数据,包括页面加载和资源加载的耗时。

于是,我们就有了前端监控的第一版需求。

需求 Story监控项
作为前端工程师,我想要自己所做页面/功能上线后的用户使用数据,这样我就可以对业务价值有更深的理解,也能帮助自己获得成就感。页面 PV 自定义上报
作为前端工程师,我想要自己所做页面的接口请求和报错数据,这样我就可以预判页面上的业务功能是否处于正常状态,当发生问题时也能够排查数据。接口请求 页面报错
作为前端工程师,我想要自己所做页面的性能数据,这样我就可以知道用户说的“卡”到底有多卡,并且做专项优化后能够看到前后对比效果。页面性能 资源加载

3 概要设计:怎么设计一个监控

根据上面的需求 Story,我们很容易设计出“数据采集、日志上报、日志查询”这三大块。

  • 数据采集:负责在客户端采集需要监控的数据。这里的客户端可以指浏览器、APP、小程序,因此对每种容器都可以实现一个 SDK 来采集监控数据。本文只针对浏览器来描述设计方案。
  • 日志上报:将采集到的数据通过 HTTP 请求传给服务端,数据以日志的形式存储到数据库。
  • 日志查询:支持对日志按条件查询,并提供对应的 UI 界面功能。

幸运的是,对于日志的存储和查询,市面上已经有成熟的开源技术方案 ELK,它是三个开源项目的首字母缩写。

  • ElasticSearch 是一个分布式搜索引擎,提供搜集、分析、存储数据三大功能。
  • Logstash 是服务器端数据处理的管道,能够同时从多个来源采集、转换数据,然后将数据发送到诸如 ElasticSearch 等“存储库”中。
  • Kibana 则可以为 Logstash 和 ElasticSearch 提供友好的 Web 界面,可以帮助使用者汇总、分析和搜索重要数据日志。

我们用到了 ELK 中的“E”和“K”,而日志数据的处理逻辑放在了 HTTP Server 中进行,处理完后直接将日志数据落到 ElasticSearch 中。

下文将重点介绍 JS-SDK 部分的具体设计和实现思路。

4 详细设计:如何实现监控SDK

4.1 定义使用方式

和大多数组件和函数的设计思路一样,要详细设计,需要先定义出它最终的使用方式,即你希望让别人怎么使用这个东西。

<script src="xxxx/{version}/jssdk.js?token=123" async></script>

我们希望 JS-SDK 对外尽可能简单,简单到只要一行

4.2 SDK 整体拆分

4.3 SDK 初始化

如上图所示,SDK 执行时会有个 main 入口,它其实就是一个自执行函数。

(function() {
  // 获取 script src 里的 query 参数
  const params = getScriptQuery();
  initSDK(params);
})();

main 很简单,它只负责提取 SDK 初始化的参数,然后去调用初始化的函数。

function initSDK(opt: SdkConfig) {
  // 内置默认参数
  const config: SdkConfig = assign({
    sendPV: true, // 是否上报页面 PV
    sendApi: true, // 是否上报 API 接口请求
    sendResource: true, // 是否上报资源请求
    sendError: true, // 是否上报 js error
    sendPerf: true, // 是否上报页面性能
  }, opt, window.$watchDogConfig);

  window.$watchDogConfig = config;

  // 执行各监控模块
  config.sendPV && watchPV(config);
  config.sendApi && watchApi(config);
  config.sendResource && watchResource(config);
  config.sendError && watchError(config);
  config.sendPerf && watchPerf();
  watchCustom(); // 内置支持自定义日志上报
}

initSDK 也比较简单,就是把初始化的参数处理一下,将内置默认参数、脚本 src 里的 query 参数、以及全局变量中的参数合并起来,并重新写到全局变量中。然后依次去执行各监控模块的初始化函数,下面将分别介绍各监控项的实现原理。

4.4 监控项实现原理

4.4.1 接口监控

接口监控的实现原理就是针对浏览器内置的 XMLHttpRequest 对象,去覆盖原型函数。

export function watchApi(config: SdkConfig) {
  function hijackXHR() {
    const proto = window.XMLHttpRequest.prototype;
    const originalOpen = proto.open;
    const originalSend = proto.send;

    proto.open = function(method: string, url: string) {
      this._ctx = {
        method,
        url: url || '',
        start: getNow(),
      };
      const args = [].slice.call(arguments);
      originalOpen.apply(this, args as any);
    };

    proto.send = function(body: any) {
      const that = this;
      const ctx = that._ctx;

      function handler() {
        if (ctx && that.readyState === 4) {
          try {
            const url = that.responseURL || ctx.url;

            // 剔除 SDK 发出的上报请求
            if (url.indexOf(SERVER_HOST) >= 0) {
              return;
            }
            // 上报 API 请求
            sender.report('api', [{
              url,
              httpMethod: ctx.method,
              httpCode: that.status,
              time: getNow() - ctx.start,
            }]);
          } catch (err) {
            sender.reportError(err);
          }
        }
      }

      const originalFn = that.onreadystatechange;

      if (originalFn && isFunction(originalFn)) {
        that.onreadystatechange = function () {
          const args = [].call(arguments);
          handler.apply(this, args as any);
          originalFn.apply(this, args as any);
        };
      } else {
        that.onreadystatechange = handler;
      }

      const args = [].slice.call(arguments);
      return originalSend.apply(this, args as any);
    };
  }

  window.XMLHttpRequest && hijackXHR();
};

如果接入监控的业务侧网站中使用 fetch 去发起请求,那么考虑到监控 SDK 完整性,我们也需要去覆盖浏览器内置的 fetch 函数。这里就不贴实现代码了,留给读者朋友们去尝试啦。

4.4.2 JS 报错监控

JS 报错可以通过 window.onerror 来监听到,我们先到 MDN 上查一下定义

所以这块代码还是比较容易写的,但要留意一点,如果业务侧网站中已经监听了 window.onerror,我们要保留原来的事件回调函数。

export function watchError(config: SdkConfig) {
  const originalOnError = window.onerror;

  function errorHandler (message: string, source?: string, lineno?: number, colno?: number, error?: Error) {
    if (originalOnError) {
      try {
        originalOnError.call(win, message, source, lineno, colno, error);
      } catch (err) {}
    }

    if (error != null) {
      // 上报脚本错误
      sender.report('error', [{
        message,
        file: source || '',
        line: '' + (lineno || ''),
        col: '' + (colno || ''),
        stack: error.stack || '',
      }]);
    }
  }

  window.onerror = function (message, source, lineno, colno, error) {
    errorHandler(message as string, source, lineno, colno, error);
  }
};

这里有个容易忽视的是跨站脚本的报错,如果 <script> 标签里没有加 crossorigin 属性,那么对于跨站脚本里的 JS 报错,在 window.onerror 里拿到的都是 script error 没有任何细节信息的报错。因此,可以在上面的代码里判断这类场景,根据正则判断 message,把无用的报错过滤掉。

还有一个细节,就是目前我们大多数业务前端页面的开发,都用上了 Promise。当 Promisereject 时,如果我们写代码时没有提供 reject 的回调函数,那么它就会抛出一个 unhandledrejection 的事件(参考 MDN 定义)。至于这部分要怎么上报错误,也留给读者朋友们去实践啦。

4.4.3 资源加载监控

对于前端资源加载信息,我们想知道页面里加载了哪些资源,它们的 url 是什么,它们是否加载成功,以及它们加载了多久。页面中加载的所有资源,都可以在 performance.getEntries() 里拿到。同样在实现之前,我们先查下 MDN 定义的 PerformanceEntry

在具体实现时,可以使用 performance.getEntriesByType('resource') 获取到所有资源加载的信息,包括静态资源和动态资源,因此需要结合 initiatorType 进一步过滤。

export function watchResource(config: SdkConfig) {
  function reportAssets () {
    if (isFunction(performance.getEntriesByType)) {
      const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
      
      // 过滤掉非静态资源的 performance entry
      const resourceEntries = arrayFilter(entries, function(entry) {
        return ['fetch', 'xmlhttprequest', 'beacon'].indexOf(entry.initiatorType) === -1;
      });

      // 上报日志
      if (resourceEntries.length) {
        sender.report('resource', arrayMap(resourceEntries, function(entry) {
          return {
            url: entry.name,
            httpCode: 200,
            time: Math.round(entry.duration),
          };
        }));
      }

      // 清空这一轮所取到的 performance entry
      if (isFunction(performance.clearResourceTimings)) {
        performance.clearResourceTimings();
      }

      // 可定时2秒收集一次
      setTimeout(reportAssets, 2000);
    }
  }

  if (document.readyState === 'complete') {
    reportAssets();
  } else {
    addEventListener(window, 'load', reportAssets);
  }
};

一个真实的业务页面中,资源加载一定是逐步进行的,有些资源本身就做了延迟加载,有些是需要用户发生一些交互后才会去请求一些资源。所以我们想一次性去收集到所有的资源加载信息,这是不可能的,我们只能够定时反复地去收集。整体流程上,当页面 DOM ready 或 onload 时,就可以触发收集一轮资源加载的日志。

在这一轮收集结束后,记得一定要通过调用 clearResourceTimings 将 performance entries 里的信息清空,避免在下一轮收集时取到重复的资源。

这里有个小疑问,通过 performance.getEntriesByType('resource') 获取到的 entry 对象里没有 HTTP 状态码,那要怎么区分出资源加载的成功与否呢?这时就要用到组合拳了,回想一下,当页面中有一个资源加载 404 时,浏览器 console 里是不是会有个报错呢?

如图这种报错,就可以在 window.onerror 里监听到,于是解决的思路就有了。可以从报错信息里提取出加载失败的资源 url,或者从报错的 event.target 里去找元素的 src 属性来作为资源 url。找到资源 url 后,可以用 performance.getEntriesByName 把它对应的 entry 对象找出来,就可以上报日志了。

4.4.4 页面性能监控

页面性能监控的实现比较简单,整体上也是在页面 DOM ready 或 onload 时,去收集 performance 数据。只需要上报一次,具体上报的性能数据可以参考 PerformanceTiming 里定义的属性。

4.4.5 页面 PV 监控

在 SPA 单页应用流行之前,收集页面 PV 非常简单,只要在页面 onload 时上报一次 PV 日志就可以了。但在 SPA 的场景中,实现起来有点复杂,我们先来看一版简单的实现。

export function watchPV(config: SdkConfig) {
  let lastVisit = '';

  function onLoad() {
    sender.reportPV();
    lastVisit = location.href;
  }

  function onHashChange() {
    sender.reportPV({
      spa: config.spa,
      from: lastVisit
    });
    lastVisit = location.href;
  }

  addEventListener(win, 'hashchange', onHashChange);
  addEventListener(win, 'load', onLoad);

  addEventListener(win, 'beforeunload', function () {
    removeEventListener(win, 'hashchange', onHashChange);
    removeEventListener(win, 'load', onLoad);
  });
};

对于一个监控的 JS-SDK,我们可以不关心页面是否 SPA,页面 onload 和单页路由变化时都上报 PV 日志,只要在日志数据里标识下,在之后统计 PV 时可以区分。

为什么说 SPA 的场景下实现复杂呢?因为上面这段代码中对 hashchange 事件的监听,在真实的 SPA 项目中,极大可能监听不到。原因是前端往往会依赖一些单页路由的库,这些库的实现方式不一样,并且单页路由也分为 hash 模式和 history 模式(React Router 里也叫做 Hash History 和 Browser History)。

为了适配各种路由场景,我们还需要对浏览器内置 history 对象中的 pushStatereplaceState 函数重写。具体实现暂不给出了,同样留给读者朋友们去探索啦。

4.4.6 自定义上报

我们在实际的业务页面中,有时需要主动去收集一些用户行为,比如某个按钮的点击。这就需要我们的监控 JS-SDK 也提供自定义上报的功能。笔者参考了百度统计、友盟等专业做用户行为分析的平台,对于自定义上报的功能,通常是这样来使用的。

// 使用前,要先定义一个空数组
window.$watchDogEvents = window.$watchDogEvents || [];
// 自定义上报,即向数组里追加数据
window.$watchDogEvents.push(['自定义名称', '自定义内容']);

上面代码中的 $watchDogEvents 是 JS-SDK 和使用方约定的一个全局变量。为什么自定义上报的 API 要设计成奇奇怪怪的数组呢?因为 JS-SDK <script> 的引入可能是异步的,也可能加载失败,所以在业务页面中自定义上报的那一刻,我们无法保证 JS-SDK 已经执行过。为了不出错,也为了不丢数据,就先将自定义上报的数据存放到一个全局数组中。

class CustomEventTrigger {
  push(args: string[]) {
    if (args instanceof Array && args[0]) {
      sender.report('custom', [{
        ext1: args[0], // 即 eventName, 必填
        ext2: args[1] || '',
        ext3: args[2] || '',
        ext4: args[3] || '',
        ext5: args[4] || '',
      }]);
    }
  }
}

export function watchCustom() {
  const originalLogs = window.$watchDogEvents || [];

  const trigger = new CustomEventTrigger();
  window.$watchDogEvents = trigger;

  // 清理在 SDK 初始化前就已经存在的自定义日志
  setTimeout(() => {
    for (let i = 0; i < originalLogs.length; i++) {
      trigger.push(originalLogs[i]);
    }
  }, 0);
};

如上代码,当 JS-SDK 初始化执行时,将 $watchDogEvents 数组变成一个具备 push 函数的对象实例,即代码中的 CustomEventTrigger。如果在这之前业务页面中已经向 $watchDogEvents 数组中推过自定义数据了,那么在 JS-SDK 执行后就会消费,保证数据不会丢,并且以后再调用 $watchDogEvents.push 都直接走到 CustomEventTrigger 里面了。

可能有读者朋友会吐槽,这种向全局数组 push 来上报自定义数据,而且 push 的入参仍然是一个数组,这种设计也太难看了吧。没有关系,我们完全可以自己写个 npm 工具包把它包装一下,比如下面这样的函数定义。

function sendEvent(eventName: string, field1?: string, field2?: string, field3?: string, field4?: string): void

4.5 日志上报的设计

至此,JS-SDK 中所有监控项的实现原理都已介绍完毕。最后我们再看下日志是如何上报给服务器的。

4.5.1 上报的请求

日志上报的网络请求,通常有3种方式。

请求方式new Image().srcPOSTWebSocket
优点简单,兼容性好,没有跨域问题。请求的 body 参数没有长度限制,可以日志聚合上报。只建立一次连接,后续日志上报的性能较好。
缺点URL 有长度限制,不利于日志聚合上报。需要跨域,且页面退出时 POST 请求发不出去。对上报的服务器压力大,连接数易溢出。

结合我们所上报的内容,我们采用了 POST 的请求方式,并且针对它的缺点,设计了以下两种优化。

4.5.2 日志队列机制

走 POST 跨域请求来发送日志数据,如果请求过于频繁,可能会影响到正常业务页面中的请求,因为浏览器在同一时刻能够同时处理的 TCP 连接数是有限制的。因此这里设计了日志队列,收集到日志后并不会立即发请求,而是每隔两秒去统一发送请求。

let queue: ReportLog[] = []; // 待上报的日志队列
let t: any = null; // 记录 setTimeout id

// 真正上报的请求
function sendByXhr(body: ReportLog) {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', `${SERVER_HOST}/api/collect`);
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && +xhr.status !== 200) {
      const retry = body._re || 0;
      // 在允许的重试次数内,重新进队列
      if (retry < MAX_RETRY_OF_REPORT_LOG) {
        body._re = retry + 1;
        sendLog(body);
      }
    }
  };
  xhr.withCredentials = true;
  xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
  xhr.send(JSON.stringify(body));
}

// 日志聚合上报
function batchFlushQueue() {
  clearTimeout(t);
  t = null;
  // 将队列中的日志按类型聚合,再发请求
  sendByXhr(mergedLogFromQueue);
  // 清空当前轮次的日志队列
  queue = [];
}

// 给监控项使用的上报方法,延迟上报
export function sendLog(log: ReportLog) {
  queue.push(log);
  if (!t) {
    // 2秒定时上报一次
    t = setTimeout(batchFlushQueue, 2000);
  }
}

上面还有个小细节,真正上报时的 POST 请求也有可能请求失败哦,所以还加上了失败重试的机制。请求失败时,如果在允许的重试次数内,就将日志内容重新进队列,等下一轮次再继续上报。

4.5.3 页面退出时的处理

正因为有上面定时上报的队列机制,当页面关闭或跳出时,有可能日志队列中还有没上报的日志。因此要特殊处理,否则就丢数据了。我们可以尝试下监听 beforeunload 事件,在里面发个 POST 请求,然后你就会发现坑了,浏览器根本不会发出这个请求。

window.addEventListener('beforeunload', function () {
  // 发一个 POST 请求 => 发不出去 ❌
  // 发一个 new Image().src 请求 ✅
});

因为我们日志上报选择用 POST 请求,上面的办法是行不通的。笔者查阅资料后得知,有个好东西叫 sendBeacon 可以了解下。由于它对浏览器兼容性有要求,所以作为 JS-SDK 里的代码要考虑周全,不支持时就降级。

function sendByBeacon(body: ReportLog) {
  try {
    if (navigator.sendBeacon && navigator.sendBeacon((`${SERVER_HOST}/api/collect`), JSON.Stringify(body))) {
      return;
    }
  } catch (e) {}

  // 不支持 sendBeacon 或发送失败时,使用 xhr 兜底
  sendByXhr(body);
}

最后,我们是不是在 beforeunload 里用 sendBeacon 来上报日志就好了呢?在手机网页上,用户很可能直接 HOME 键回桌面了,我们最后一轮的日志依旧会丢。更稳妥的做法是利用 visibilitychangepagehide 两个事件,具体就不演示了,读者朋友们可以测试下它们的效果。

4.6 监控 SDK 完成

至此,前端监控的 JS-SDK 就圆满完成了。

监控项如此全面的 JS-SDK,最终 build 出来的代码体积在 10KB 以内,gzip 压缩后不到 4KB。相比同类监控产品,可以说是很精炼了。

5 总结与展望:前端监控的效果

最后来看下在真实业务页面中的前端监控效果。在日志收集的服务端,我们使用了 ELK,日志数据处理后都存储在 ElasticSearch 中,并且在配套的 Kibana 中可以很方便的查询到日志。

如上图,是一个业务页面的灰度扩量后,某个重要接口请求的曲线。有了前端监控,我们每次发布或灰度操作后都可以观察,心里会更笃定。而且我们还可以在接口请求的监控项中,加入对业务状态码的上报,这样可以更容易观察当前业务是否正常。

如上图,是用 Grafana 配出来的日志可视化图表,便于观察大盘数据,它与 Kibana 使用的数据源是同一个。

一个看大盘,一个查细节,利用 Grafana 和 Kibana 搭建出了前端监控平台的基础能力。

==========================================

在不久的将来,前端监控将继续着力于以下三件事:

==========================================

1、更便捷的日志排障工具

用过 Kibana 就会发现,当真正查用户 case 排障时,需要查很多处日志,输入很多次查询条件,效率很低。我们希望的是,可以根据用户ID和给定时间段,查出所有匹配的日志并构建出一个用户行为轨迹,包括了用户在什么时刻打开了哪个页面,在页面里发出了哪些请求,并在什么时刻捕获到了错误。 甚至可以针对线上用户 case 做实时排障。

2、补齐小程序监控生态

目前小程序生态百花齐放,但配套的监控能力只有微信是做得最充分的。实际产品中经常需要投放到多家小程序平台,我们也已经做出了功能完整、框架无关、多端一致的小程序监控 SDK,以后将在更大范围内推广。

3、打通监控告警能力

日志是基础,排障工具是为了问题出现后定位、分析问题。监控更重要的能力在于告警,为了提前发现问题。基于前端监控的 PV、API、JS 报错等日志,制定一个合理的告警规则,才能充分发挥出日志的价值。

实际上我们经历过几次系统故障,已经验证了前端告警的有效性。而针对不同项目的业务特点来定制最恰当的告警规则,是值得继续探索的。

============================================

如果你对我们所做的事情感兴趣,欢迎加入货拉拉大前端!

简历可投至:benny.zeng@huolala.cn

============================================