[ 前端监控sdk实战 | 青训营笔记]

281 阅读12分钟

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

一、本堂课重点内容:

  • 关于前端监控

  • 常用性能指标

  • 前端常见异常

  • 监控前端性能与异常

  • 封装一个通用的sdk

  • 使sdk更健壮

二、详细知识点介绍:

1. 关于前端监控
  • 什么是前端监控

前端监控就是尽可能地采集从输入url到页面展示过程,以及后续用户交互中产出的性能指标与发生的异常事件并上报到平台提供给开发者完成消费。

  • 为什么需要前端监控

页面加载出现问题,却不知道原因。

问题举例:打开速度慢、交互卡顿、资源加载失败、页面白屏等。

前端监控通过对页面数据的采集和上报,来帮助开发者更快速地对质量差的页面进行分析与归因。

打开速度慢:页面某个关键资源渲染太慢。

交互卡顿:页面同步计算任务太重,阻塞渲染。

资源加载失败:客户端网络太差,或上游服务器节点异常。

页面白屏:页面脚本执行失败、关键资源加载失败、请求失败等。

页面质量的提高可以提高用户留存率以及流量。

  • 前端监控到底监控了什么

1.性能指标

2.异常事件

3.用户行为

2. 常用性能指标

随着页面动态和交互越来越复杂,页面性能也越来越重要。

以用户为中心的性能指标:

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

1.发生了吗:FP(First Paint,首次渲染时间点),FCP(First Contentful Paint,首次有内容时间点)

2.内容有用吗:FMP(First Meaningful Paint,首次绘制有意义内容的时间点),SI(Speed Index),LCP(largest Contentful Paint)

SI:衡量页面可视区域加载速度,帮助检测页面的加载体验差异。时间相同的情况下,匀速加载的效果要优于最后突然加载完成的效果。

LCP:最大的内容(让用户理解页面内容最有用的元素)在可视区域变得可见的时间点,使用得较多。

LCP优点:1.容易理解 2.给出与FMP相似的结果 3.容易计算和上报

3.内容可用吗:TTI(Time to Interactive),TBT(Total Blocking Time)

TTI:测量页面从开始到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。TTI是反映页面可用性地重要指标,TTI值越小,代表用户可以更早地操作页面,用户体验就更好。

TBT:量化主线程在空闲之前的繁忙程度,有助于理解在加载期间,页面无法响应用户输入的时间有多久。

长任务:如果一个任务在主线程上运行超过50毫秒,那么他就是长任务。超过50ms后的任务耗时,都算作任务的阻塞时间。

一个页面的TBT,是从FCP到TTI之间所有长任务的阻塞时间的总和,即渲染到可以交互的时间长度。

4.令人愉悦吗:FID(First Input Delay),CLS(Cumulative Layout Shift)

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

CLS;量化了在页面加载期间,视口中元素的移动程度。

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

3. 前端常见异常
  • 静态资源错误

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

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

  • 请求异常

http协议是使用最多的协议,http请求状态码分类如下;

0:XMLHttpRequests被停止。

100-199:信息响应

200-299:成功响应

300-399:重定向信息

400-499:客户端错误响应

500-599:服务端错误响应

请求异常 = 请求响应状态码 >= 400,异步请求拉取的静态资源错误也可以归纳到请求异常中。

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

  • JS错误

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

  • 白屏异常

不同于前面几类异常,无法通过浏览器提供的标准化方法来监听到,没有标准化的监听方法,所以更考验前端监控开发者的功底。通常可以通过判断DOM树的结构来粗略判断白屏的发生。

监听到白屏发生后,需要对白屏的发生进行归因,通常的原因有:

发生JS错误导致关键资源渲染失败。

请求异常或静态资源加载失败。

长时间的JS线程繁忙阻塞渲染任务。

4. 监控前端性能与异常
  • 性能指标监控

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

Performance:developer.mozilla.org/zh-CN/docs/…

PerformanceObserver:developer.mozilla.org/zh-CN/docs/…

实践:code.juejin.cn/pen/7173286…

首先注册PerformanceObserver并启动:

/**
 * 列举出性能指标对应的 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 });

然后在控制台中就可以看到监听结果:

第一个是FCP,然后是FP,因为页面较简单所以两者差距较小,但实际上FCP会优于FP发生。

image.png

此时还没有FIP的输出,是因为用户还没有与页面交互,当点击页面后,就会输出:

image.png

在前面的代码后加入以下代码,但通过这种方式取值时,不会打印结果。

// 2. 也可以通过 window.performance 对象拿到 fp fcp 和 fip。
// 注意如果同步打印他们是取不到值的,想想为什么?
window.performance.getEntriesByType('paint');
window.performance.getEntriesByType('first-input');

当改为 console.log(window.performance.getEntriesByType('paint')); 后,也只会打印一个空数组。

是因为在body渲染前,这个代码就执行了,页面上还没有进行渲染行为,所以拿不到值。如果放在渲染后,就可以获得值,例如将代码作为命令,在控制台输入获取结果。

image.png

PerformanceObserver可以拿到的性能条目类型多于Performance,因此首先获取的时候会先通过 window.performance获取,没有的话再通过PerformanceObserver寻找。

最后,还要把监控信息封装成一个监听器monitor,才能整合到sdk当中形成一个整体。

具体步骤: 1.起名字

const name = 'performance';

2.具备监听能力

const entryTypes = ['paint', 'largest-contentful-paint', 'first-input']
const p = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(entry);
  }
})
p.observe({ entryTypes });

3.具备主动开启能力,而不是创建即开启

将监听代码放入start方法中,然后返回监听器名字以及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()) {
        console.log(entry);
      }
    })
    p.observe({ entryTypes });
  }
  return { name, start }
}

4.具有上报能力,即暴露数据

使用report,返回name和data,完成封装。

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 }
}
  • JS错误监控

利用window.addEventListener的error和unhandledrejection可以监控全局的js错误。

addEventListener:developer.mozilla.org/zh-CN/docs/…

error:developer.mozilla.org/en-US/docs/…

unhandledrejection:developer.mozilla.org/zh-CN/docs/…

实践:code.juejin.cn/pen/7173303…

首先,利用window.addEventListener监听js执行报错,并且需要过滤。

// 1. 监听 js 执行报错
window.addEventListener("error", (e) => {
  // 只有 error 属性不为空的 ErrorEvent 才是一个合法的 js 错误
  if (e.error) {
    console.log('caputure an error', e.error);
  }
});
throw(new Error('test'));

打印错误test:

image.png

其次,还可以监听promise rejection

// throw(new Error('test'));
// 2. 监听 promise rejection
window.addEventListener("unhandledrejection", (e) => {
  console.log('capture a unrejection', e);
});
Promise.reject('test');

打印结果:

image.png

最后可以将这些监听功能封装为监听器monitor,步骤和性能监听monitor的创建一致,report时要定义好data的内容。

// 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 }
}
  • 静态资源错误监控

利用window.addEventListener的error事件可以监控到静态资源错误,注意要和js error进行区分。

实践:code.juejin.cn/pen/7173310…

首先要监控到静态资源错误,要对错误进行区分:

如果错误的target或srcElement不为空,才可以继续向下执行。为了兼容早期浏览器,即使srcElement已经不再使用,也最好写上。

// 1. 监控静态资源错误,注意需要在捕获阶段才能监听到
window.addEventListener('error', e => {
  // 注意区分 js error
  const target = e.target || e.srcElement;
  if (!target) {
    return
  }
})

接着将代码完善成:

// 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);

就可以在控制台打印错误信息:

image.png

之后再封装为一个monitor:

// 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 }
}
  • 请求异常监控

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

xhr:developer.mozilla.org/zh-CN/docs/…

fetch:developer.mozilla.org/zh-CN/docs/…

实践:code.juejin.cn/pen/7173328…

首先要写一个简易的hook高阶函数:

// 1. 写一个简易的 hook 函数
function hookMethod(
  obj: any,
  key: string,
  hookFunc: Function,
) {
  return (...params: any[]) => {
    obj[key] = hookFunc(obj[key], ...params)
  }
}

然后使用open方法,传入一些初始化的参数,为之后发送数据做准备。

// 2. hook xhr 对象的 open 方法拿到请求地址和方法
hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
  function (method: string, url: string) {
    this.payload = {
      method,
      url,
    };
    // 执行原函数
    origin.apply(this, [method, url]);
  }
)();

之后用send方法,监听状态变化,当请求状态码大于等于400,即请求错误,则执行原函数。

// 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();

最后封装成一个monitor,使他们产生联系即可。

// 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 }
}
5. 封装一个通用的sdk

前面的内容完成的是数据采集和简单的数据组装,接下来还要完成数据上报:

专为前端监控打造的请求函数:Navigator.sendBeacon():developer.mozilla.org/zh-CN/docs/…

将之前实现的监控器按需加载到sdk中,从而封装成一个完整的sdk。

实践:code.juejin.cn/pen/7173343…

首先创建sdk:

在参数中设定上报的url,然后初始化monitor变量,用于储存monitor。再创建sdk对象用于储存url和monitors,再定义report函数,report函数与之前定义的格式相同。之后再创建loadMonitor,push监听器并返回sdk,并创建start函数遍历monitors,最后将loadMonitor,monitors,start放入sdk,实现一启用sdk即可管理所有monitor的链式操作。

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 }
}

之后创建两个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 }
}

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 }
}

通过以下代码将monitors加载到sdk中,启用sdk:

const sdk = createSdk("111.com");
const jsMonitor = createJsErrorMonitor(sdk.report);
sdk.loadMonitor(jsMonitor).loadMonitor(createPerfMonitor(sdk.report));
sdk.start();
throw (new Error('test'));

6. 使sdk更健壮

-性能指标计算算法

可以实现FMP、CLS、TTI等性能指标的计算方法,参考算法如下:

image.png

image.png

CLS:web.dev/cls/

image.png

  • 关注请求性能

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

image.png

  • 更安全稳定的Hook函数

之前的实践中的hook函数缺少了很关键的unhook能力,即将被hook的函数还原的能力,可以补全这一能力并写出更安全稳定的hook函数。

  • 用户行为监控

用户行为监控也是前端监控的关键一环,可以补全这一监控能力。

  • 数据存储与消费

sdk提供了前面两个环节的能力,后两个环节则需要同时依赖前端和后端来完成。

image.png

三、课后个人总结:

通过本节课程,我初步了解了前端监控的意义,还大致学会了一些常用性能指标和异常的的监控方式,并且明白了如何封装一个通用的sdk管理这些监听器,以及一些可以继续补全完善,使得sdk更健壮的能力。