前端监控SDK开发 | 青训营笔记

60 阅读12分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 12 天

一、开篇:为什么我们要聊前端监控

1. 什么是前端监控

从一个经典面试题聊起: 在浏览器里,从输入 URL 到页面展示,这中间发生了什么?

image - 2023-02-12T142249.080.png

前端监控,就是尽可能的采集这一过程以及后续用户交互中产出的性能指标与发生的异常事件并上报到平台完成消费。

2. 为什么需要前端监控

  • 让我们从用户(浏览网页的人)的视角来抛出一些使用时遇到的问题:

    • 这个网页打开好慢啊!

      image (96).png
    • 为什么这个网页会那么卡啊!

      image (97).png

    • 怎么网页的图片全都裂开了!

      image (98).png
    • 这个网页怎么是白花花的一片什么都没有!

      image (99).png

    • 救命🆘!谁可以告诉我发生了什么?!
  • 让我们再从开发者(开发网页的人)的视角来回答用户抛出问题:

    • 打开好慢 —— 用户用的是 XP +IE 浏览器,建议重装系统!

      image (100).png
    • 交互卡顿 —— 用户的电脑配置太差了,建议升级为i9 + 4090!

      image - 2023-02-12T143513.227.png
    • 资源加载失败 —— 用户网络状态太差,建议到大城市里再试试!

      image - 2023-02-12T143518.601.png

    • 页面白屏 —— 我这台电脑没问题啊,应该是偶发事故,不要慌!

      image - 2023-02-12T143522.314.png

    • 以上,都是不负责任对页面质量的错误归因!❌
  • 当我们的网页有了前端监控的能力:

    • 打开好慢 —— 页面某个关键资源渲染太慢。
    • 交互卡顿 —— 页面同步计算任务太重,阻塞渲染。
    • 资源加载失败 —— 客户端网络状态差,或上游服务节点异常。
    • 页面白屏 —— 页面脚本执行失败、关键资源加载失败、请求失败等。
    • 前端监控通过对页面数据的采集和上报,来帮助开发者更快速地对质量差的页面进行分析与归因。✅

3. 监控页面质量为什么那么重要

案例分析: 监控页面质量为什么那么重要?

image - 2023-02-12T144129.056.png

  • 01. 缤趣(图片社交分享网站)将感知等待时间减少了40%,这将搜索引擎流量和注册量增加了15%。
    1. COOK (冷冻速食订购网站)将页面平均加载时间减少了850毫秒,从而将转化次数提高了7%,将跳出率降低了7%,并将每个会话的页面增加了10%。
  • 研究还表明,性能不佳会对业务目标产生负面影响。例如,BBC发现他们的网站加载时间每增加一秒,他们就会失去10%的用户。

4. 前端监控到底监控了什么

  • 性能指标
  • 异常事件
  • 用户行为

image - 2023-02-12T144509.330.png

image - 2023-02-12T144511.337.png

二、概述:前端监控之常用性能指标

1. Web性能标准的诞生

早期网页是纯静态的,但随着Web爆发式发展,页面交互越来越复杂。开发者开始思考如何提高Web性能、改善用户体验。 因此,2010年8月,WBC成立了 Web性能工作组,由来自Google和 Microsoft 的工程师担任主席,目标是制定衡量Web应用性能的方法和 API。 随后,Web性能工作组开始制定一系列 Web 性能标准,应用到桌面和移动浏览器以及其他环境中,帮助Web 开发人员评估和理解应用的性能特征。

image - 2023-02-12T150753.851.png

2. 传统的性能指标

传统的性能指标主要依赖 Navigation Timing 或者 Navigation Timing 2,通过记录一个文档从发起请求到加载完毕的各阶段的性能耗时,以加载速度来衡量性能。

image - 2023-02-12T150935.082.png

image - 2023-02-12T151002.231.png

image - 2023-02-12T151008.334.png

3. 以用户为中心的性能指标

传统的性能指标专注于容易衡量的技术细节,但是它们很难反应出用户所真正关心的是什么。如果你仅仅是把加载速度优化的更快,你很快就会发现网站的用户体验依然很差。 这就是创建用户为中心的性能指标的原因,它们专注于用户视角下的浏览体验。

用户体验指标:

image - 2023-02-12T164515.503.png
  • FP (First Paint):首次渲染的时间点。FP时间点之前,用户看到的都是没有任何内容的白色屏幕。
  • FCP (First Contentful Paint):首次有内容渲染的时间点。
  • FMP (First Meaningful Paint):首次绘制有意义内容的时间点。
image - 2023-02-12T165659.455.png
  • Sl(Speed Index):衡量页面可视区域加载速度,帮助检测页面的加载体验差异。

    • 如下:A和B的首次内容出现和完全加载时间是一样的,但是从用户角度A的体验明显更好。

image - 2023-02-12T170821.424.png

  • FID (First Input Delay):测量从用户第一次与页面交互(比如当他们单击链接、点按按钮等等)直到浏览器对交互作出响应,实际能够开始处理事件时处理程序所经过的时间

衡量用户体验过程的性能指标:

<img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d198842fdbbe4098b4fd397cca3de6ae~tplv-k3u1fbpfcp-watermark.image?" alt="image - 2023-02-12T173434.595.png" width="100%" />
  • LCP (Largest Contentful Paint):最大的内容在可视区域内变得可见的时间点。

    • 最大的元素,例如一篇文章中的一大段文字或产品页面上的一张图片,大概就是让你理解页面内容的最有用的元素。
    image - 2023-02-12T173608.123.png - LCP优点:1.容易理解 2.给出与FMP相似的结果 3.容易计算和上报 image - 2023-02-12T173644.922.png
  • TBT (Total Blocking Time):量化主线程在空闲之前的繁忙程度,有助于理解在加载期间,页面无法响应用户输入的时间有多久。

    • 长任务:如果一个任务在主线程上运行超过50毫秒,那么它就是长任务。超50ms后的任务耗时,都算作任务的阻塞时间。
    • 一个页面的TBT,是从 FCP到TTI之间所有长任务的阻塞时间的总和。

    image - 2023-02-12T173957.073.png

  • CLS (Cumulative Layout Shift):量化了在页面加载期间,视口中元素的移动程度。

    • 当我们点击按钮时,突然出现了一块内容。无论是以一种增加意外点击几率的方式加载广告,还是在加载图片时文本向下移动,内容的意外移动都会让人非常不舒服。

三、概述:前端监控之前端常见异常

1. 静态资源错误

  • 静态资源:加载页面所需的html、css和js 等文件,以及其他各类多媒体文件,如图片、音频和视频等。

  • 静态资源错误:在拉取和加载静态资源的过程中发生了预期之外的错误,如网络异常等,导致静态资源无法正常渲染到页面上。

    image - 2023-02-12T174800.026.png

    image - 2023-02-12T174807.396.png

    image - 2023-02-12T174816.574.png

2. 请求异常

Http请求状态码分类

100 - 199 ----------------> 信息响应 200 - 299 ----------------> 成功响应

300 - 399 ----------------> 重定向消息

400 - 499 ----------------> 客户端错误响应 500 - 599 ----------------> 服务端错误响应

  • 请求异常 = 请求响应状态码 >= 400

    • 对于通过异步请求拉取的静态资源错误也可选择归纳到请求异常

状态码0是什么?

  • 请求成功率=请求成功数/(请求成功数+请求失败数)

    image - 2023-02-12T180034.566.png

3. Js错误

  • 在页面运行时发生的Js错误会严重影响页面的正常渲染与交互,是前端监控的重点。

    image - 2023-02-12T180151.749.png

4. 白屏异常

  • 前面几类异常都可以通过浏览器提供的标准化方法来监听到,而白屏异常没有标准化的监听方法,所以更考验前端监控开发者的功底。

  • 通常我们可以通过判断 DOM 树的结构来粗略的判断白屏是否发生。

    image - 2023-02-12T180255.354.png

  • 监听到白屏发生后,我们还需要对白屏的发生进行归因。

  • 通常导致白屏发生的原因可能有如下几点:

    • 1.发生J错误导致关键资源渲染失败。
    • 2.请求异常或静态资源加载失败。
    • 3.长时间的Js线程繁忙阻塞渲染任务。

四、小试牛刀:监控前端性能与异常

1. 性能指标监控

image - 2023-02-12T180555.541.png

  • 利用 Performance 和 PerformanceObserver 可以监控到一些标准的渲染性能数据

    image - 2023-02-12T180854.862.png

    image - 2023-02-12T180857.190.png

  • 性能监控 实战演练demo

    /**
     * 列举出性能指标对应的 entry type
     * fp,fcp --> paint
     * lcp --> largest-contentful-paint
     * fip --> first-input
     */
    const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
    ​
    // 1. 通过 PerformanceObserver 监听
    const p = new PerformanceObserver(list => {
      for (const entry of list.getEntries()) {
        console.log(entry);
      }
    })
    p.observe({ entryTypes });
    ​
    // 2. 也可以通过 window.performance 对象拿到 fp fcp 和 fip。
    // 注意如果同步打印他们是取不到值的,想想为什么?
    window.performance.getEntriesByType('paint');
    window.performance.getEntriesByType('first-input');
    ​
    // 3. 封装成一个 monitor
    function createPerfMonitor(report: ({ name: string, data: any }) => void) {
      const name = 'performance';
      const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
      function start() {
        const p = new PerformanceObserver(list => {
          for (const entry of list.getEntries()) {
            report({ name, data: entry });
          }
        })
        p.observe({ entryTypes });
      }
      return { name, start }
    }
    

    image - 2023-02-12T181151.864.png

    image - 2023-02-12T181159.652.png

    image - 2023-02-12T181202.121.png

2. Js错误监控

  • Js错误监控 实战演练demo

    // 1. 监听 js 执行报错
    window.addEventListener("error", (e) => {
      // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 js 错误
      if (e.error) {
        console.log('caputure an error', e.error);
      }
    });
    // throw(new Error('test'));
    // 2. 监听 promise rejection
    window.addEventListener("unhandledrejection", (e) => {
      console.log('capture a unrejection', e);
    });
    Promise.reject('test');
    ​
    // 3. 封装成一个 monitor
    function createJsErrorMonitor(report: ({ name: string, data: any }) => void) {
      const name = "js-error";
      function start() {
        window.addEventListener("error", (e) => {
          // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 js 错误
          if (e.error) {
            report({ name, data: { type: e.type, message: e.message } });
          }
        });
        window.addEventListener("unhandledrejection", (e) => {
          report({ name, data: { type: e.type, reason: e.reason } });
        });
      }
      return { name, start }
    }
    

    image - 2023-02-12T181825.426.png

    image - 2023-02-12T181827.641.png

3. 静态资源错误监控

  • 静态资源错误监控 实战演练demo

    // 1. 监控静态资源错误,注意需要在捕获阶段才能监听到
    window.addEventListener('error', e => {
      // 注意区分 js error
      const target = e.target || e.srcElement;
      if (!target) {
        return
      }
      if (target instanceof HTMLElement) {
        let url;
        // 区分 link 标签,获取静态资源地址
        if (target.tagName.toLowerCase() === 'link') {
          url = target.getAttribute('href');
        } else {
          url = target.getAttribute('src');
        }
        console.log('异常的资源', url);
      }
    }, true)
    const link = document.createElement("link");
    link.href = "1.css";
    link.rel = "stylesheet";
    document.head.append(link);
    ​
    const script = document.createElement("script");
    script.src = "2.js";
    document.head.append(script);
    ​
    // 2. 封装成一个 monitor
    function createResourceErrorMonitor(report: ({ name: string, data: any }) => void) {
      const name = "resource-error";
      function start() {
        window.addEventListener('error', e => {
          // 注意区分 js error
          const target = e.target || e.srcElement;
          if (!target) {
            return
          }
          if (target instanceof HTMLElement) {
            let url;
            // 区分 link 标签,获取静态资源地址
            if (target.tagName.toLowerCase() === 'link') {
              url = target.getAttribute('href');
            } else {
              url = target.getAttribute('src');
            }
            report({ name, data: { url } });
          }
        }, true)
      }
      return { name, start }
    }
    

    image - 2023-02-12T182159.914.png

    image - 2023-02-12T182201.886.png

4. 请求异常监控

  • 通过 hook xhr 和 fetch 对象来监听请求时发生的错误。

    image - 2023-02-12T182433.673.png

    image - 2023-02-12T182437.331.png

  • 请求异常监控 实战演练demo

    <div id="app">hello world</div>
    
    // 1. 写一个简易的 hook 函数
    function hookMethod(
      obj: any,
      key: string,
      hookFunc: Function,
    ) {
      return (...params: any[]) => {
        obj[key] = hookFunc(obj[key], ...params)
      }
    }
    ​
    // 2. hook xhr 对象的 open 方法拿到请求地址和方法
    hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
      function (method: string, url: string) {
        this.payload = {
          method,
          url,
        };
        // 执行原函数
        origin.apply(this, [method, url]);
      }
    )();
    ​
    // 3. hook xhr 对象的 send 方法监听到错误的请求
    hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
      function (...params: any[]) {
        this.addEventListener("readystatechange", function () {
          if (this.readyState === 4 && this.status >= 400) {
            this.payload.status = this.status;
            console.log(this.payload);
          }
        });
        origin.apply(this, params);
      }
    )();
    ​
    const xhr = new XMLHttpRequest();
    xhr.open("post", "111.cc");
    xhr.send();
    ​
    // 4, 封装成一个 monitor
    function createXhrMonitor(report: ({ name: string, data: any }) => void) {
      const name = "xhr-error";
      function hookMethod(
        obj: any,
        key: string,
        hookFunc: Function,
      ) {
        return (...params: any[]) => {
          obj[key] = hookFunc(obj[key], ...params)
        }
      }
      function start() {
        hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
          function (this, method: string, url: string) {
            this.payload = {
              method,
              url,
            };
            origin.apply(this, [method, url]);
          }
        )();
        hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
          function (this, ...params: any[]) {
            this.addEventListener("readystatechange", function () {
              if (this.readyState === 4 && this.status >= 400) {
                this.payload.status = this.status;
                report({ name, data: this.payload });
              }
            });
            origin.apply(this, ...params);
          }
        )();
      }
      return { name, start }
    }
    

    image - 2023-02-12T182640.934.png

    image - 2023-02-12T182644.012.png

    image - 2023-02-12T182729.979.png

五、渐入佳境:封装一个通用的sdk

1. 前端监控流程概述

  • sdk 主要完成前两步,后两步需要后端服务和平台的支持,其中前面的章节已经完成了数据采集以及简单的数据组装。

    image - 2023-02-12T182910.131.png

2. 临门一脚:数据上报

  • 封装一个用于给监控器上报已收集数据的上报函数

    image - 2023-02-12T183043.166.png

3. 按需加载监控能力

  • 下面我们需要将之前实现的监控器按需加载到 sdk 中,从而封装成一个完整的 sdk

    function createSdk(url: string) {
      const monitors: Array<{ name: string, start: Function }> = [];
      const sdk = {
        url,
        report,
        loadMonitor,
        monitors,
        start,
      }
      function report({ name: string, data: any }) {
        // 注意:数据发送前需要先序列化为字符串
        navigator.sendBeacon(url, JSON.stringify({ name: string, data: any }));
      }
      function loadMonitor({ name: string, start: Function }) {
        monitors.push({ name: string, start: Function });
        // 实现链式调用
        return sdk;
      }
      function start() {
        monitors.forEach(m => m.start());
      }
      return sdk;
    }
    const sdk = createSdk("111.com");
    const jsMonitor = createJsErrorMonitor(sdk.report);
    sdk.loadMonitor(jsMonitor).loadMonitor(createPerfMonitor(sdk.report));
    sdk.start();
    throw (new Error('test'));
    ​
    function createJsErrorMonitor(report: ({ name: string, data: any }) => void) {
      const name = "js-error";
      function start() {
        window.addEventListener("error", (e) => {
          // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 js 错误
          if (e.error) {
            report({ name, data: { type: e.type, message: e.message } });
          }
        });
        window.addEventListener("unhandledrejection", (e) => {
          report({ name, data: { type: e.type, reason: e.reason } });
        });
      }
      return { name, start }
    }
    ​
    function createPerfMonitor(report: ({ name: string, data: any }) => void) {
      const name = 'performance';
      const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
      function start() {
        const p = new PerformanceObserver(list => {
          for (const entry of list.getEntries()) {
            report({ name, data: entry });
          }
        })
        p.observe({ entryTypes });
      }
      return { name, start }
    }
    

    image - 2023-02-12T200922.268.png

六、课后探索:让你的sdk更健壮

1. 更多性能指标计算算法

2. 关注请求性能

请求异常除了请求错误外,我们还关注慢请求,你可以参考 Performance Resource Timing 来获取请求各阶段耗时,找出所有慢请求

image - 2023-02-12T210736.901.png

3. 更安全和稳定的Hook函数

在实现请求错误监控时,我们实现了一个很简易的hook函数,这个hook函数缺少了很关键的unhook能力,即将被hook的函数还原的能力。尝试补全这一能力并写出更安全和稳定的hook函数。

4. 用户行为监控

用户行为监控是前端监控的关键一环,尝试学习补全。

5. 数据存储与消费

sdk 提供了前面两个环节的能力,而后两个环节需要同时依赖前端和后端的能力来共同完成,尝试学习完成。

image - 2023-02-12T215244.679.png

七、总结

本节课学习了监控前端的概念和内容、重要性以及必要性,并了解监控的常用性能指标和性能异常,并初步尝试封装一个通用的sdk实现前端监控。