前端监控-学习笔记

312 阅读12分钟

学习文章来源于ELab团队 ,作者ELab.caocheng

apm.png

监控流程

graph LR
A("1.数据采集") --> B("2.数据上报") 
B("2.数据上报") --> C("3.数据清洗、存储") 
C("3.数据清洗、存储") --> D("4.数据消费") 
  1. 数据采集:明确需要采集哪些指标以及采集的方式。
  2. 数据上报:将上一步采集的数据以一定的策略进行上报。
  3. 数据清洗、存储:服务端在接收到上报数据后需要对数据进行清洗和存储。
  4. 数据消费:数据最终会在类似 APM 这样的监控平台以图、表等形式分类别地进行可视化展示,并提供诸如监控报警等消费能力。

数据采集

做好前端监控的第一步要明确哪些数据是值得我们采集的,前端环境下监控数据从大的维度上可划分成环境信息、异常数据和性能数据:

监控数据采集.png

环境信息

采集的监控数据一般都会设置一些通用的环境信息,这些环境信息可以提供更多的维度以帮助用户发现问题和解决问题。例如:页面地址时间地理位置浏览器操作系统网络用户表示等

异常监控

js异常

Script Error: 当页面加载自不同域的脚本(例如页面的 JS 托管在 CDN)中发生语法错误时,浏览器基于安全机制考虑,不会给出语法错误的细节,而是简单的 Script error.

如果你希望自己页面的详细报错信息被监控 SDK 捕获你需要为页面中的脚本 script 添加 crossorigin= anonymous 属性,且脚本所在的服务设置 CORS 响应头 Access-Control-Allow-Origin: * ,这是 JS 异常监控的第一项准备工作。

编译时与运行时错误:常见的 JS 错误可分为编译时错误和运行时错误,其中编译时错误在 IDE 层面就会给出提示,一般不会流入到线上,因此编译时错误不在监控范围。在 APM 上时常看到 SyntaxError 的字样,这种情况一般都是 JSON.parse 解析出错或浏览器兼容性问题导致,属于运行时错误并非编译时错误。

对于异常监控我们主要关注 JS 运行时错误,多数场景下的处理手段如下:

错误场景如何上报
场景一:自行感知的同步运行时异常try-catch 后进行错误上报
场景二:没有手动 catch 的运行时异常(包括异步但不包括 promise 异常)通过 window.onerror进行监听
场景三:自行感知的 promise 异常promise catch 进行捕获后进行错误上报
场景四:没有手动 catch 的 promise 异常监听 window 对象的unhandledrejection 事件
整体来看,监控 SDK 会在全局帮助用户去捕获他们没有自行感知的异常并上报,对于自行捕获的异常一般会提供手动上报接口进行上报。

SourceMap: 假设现在已经采集到页面目前存在的 JS 异常并做了上报,最终消费时你我们当然希望看到的是错误的初始来源和调用堆栈,但实际发生报错的 JS 代码都经过各种转换混淆压缩,早已面目全非了,因此这里需要借助打包阶段生成的 SourceMap 做一个反向解析得到原始报错信息的上下文。

  1. 采集侧收集错误信息发送到监控平台服务端。
  2. 接入的业务方自行上传 SourceMap 文件到监控平台服务端,上传完成后删除本地的 SourceMap文件,且打包后的 js 文件末尾不需要 SourceMap URL,最大程度避免 SourceMap 泄漏。
  3. 服务端通过 source-map 工具结合 SourceMap 和原始错误信息定位到源码具体位置。

静态资源加载异常

静态资源加载异常的捕获存在的方式:1.在出现静态资源加载异常的元素的 onerror 方法中处理。2.资源加载异常触发的error事件不会冒泡,因此使window.addEventListener('error', cb, true) 在事件捕获阶段进行捕获。APM 平台一般会有所有静态资源加载的明细,其原理是通过 PerformanceResourceTiming API 来采集静态资源加载的基本情况,有兴趣自行了解。

//方式2
window.addEventListener('error', (event)=>{
  const e = event || window.event
  if(!e){
    return
  }
  const {url, tagName} = getInfoFromTarget(e.target)
  if(url&&tagName){
    report({url, tagName})
  }
}, true)

请求异常

请求异常通常泛指 HTTP 请求失败或者 HTTP 请求返回的状态码非 20X。 方式:重写原生的 XMLHttpRequest 对象和 fetch 方法,在代理对象中实现状态码的监听和错误上报:

重写 XMLHttpRequest 对象

const _send = XMLHttpRequest.prototype.send

XMLHttpRequest.prototype.send = function(){
  const _stateChange = this['onreadystatechange'];
  this['onreadystatechange'] = function (event){
    if(this.readyState === 4){//请求结束时
      const xhr = event.target
      if(xhr?.status?.toString()[0] !== '2'){
        report(event.target)
      }
    }
    return _stateChange && _stateChange.apply(this, arguments);
  }
  return _send.apply(this, arguments)
}

重写fetch方法

const _fetch = window.fetch

const newFetch = function(){
  return _fetch.apply(this, arguments).then((res)=>{
    if(!res.ok){ //response status 不是2XX
      report(res)//上报函数
    }
    return res
  }).catch(err=>{
    report(err)
    return Promise.reject(err)
  })
}

卡顿异常

卡顿指的是显示器刷新时下一帧的画面还没有准备好,导致连续多次展示同样的画面,从而让用户感觉到页面不流畅,也就是所谓的掉帧,衡量一个页面是否卡顿的指标就是我们熟知的 FPS。

如何获取FPS

借助 requestAnimationFrameperformance.now()方法模拟实现,浏览器会在下一次重绘之前执行 rAF 的回调,因此可以通过计算每秒内 rAF 的执行次数来计算当前页面的 FPS。

      let lastTime = performance.now();
      let lastFrameTime = performance.now();
      let frame = 0;

      const loop = function (time) {
        let now = performance.now();
        let fs = now - lastFrameTime;
        lastFrameTime = now;

        //get fps
        let fps = Math.round(1000 / fs);
        frame++;
        if (now > 1000 + lastTime) {//1秒就进来一次
          fps = Math.round((frame * 1000) / (now - lastTime));
        //   console.log(fps)  //60
          frame = 0;
          lastTime = now;
        }
        window.requestAnimationFrame(loop);
        // loop执行的次数为每秒60次
      };

      loop();

上报“真实的卡顿”

从技术角度看 FPS 低于 60 即视为卡顿,但在真实环境中用户很多行为都可能造成 FPS 的波动,并不能无脑地把 FPS 低于 60 以下的 case 全部上报,会造成非常多无效数据,因此需要结合实际的用户体验重新定义“真正的卡顿”,这里贴一下司内 APM 平台的上报策略:

  1. 页面 FPS 持续低于预期:当前页面连续 3s FPS 低于 20。
  2. 用户操作带来的卡顿:当用户进行交互行为后,渲染新的一帧的时间超过 16ms + 100ms。

崩溃异常

Web 页面崩溃指在网页运行过程页面完全无响应的现象,通常有两种情况会造成页面崩溃:

  1. JS 主线程出现无限循环,触发浏览器的保护策略,结束当前页面的进程。
  2. 内存不足

发生崩溃时主线程被阻塞,因此对崩溃的监控只能在独立于 JS 主线程的 Worker 线程中进行,可以采用 Web Worker 心跳检测的方式来对主线程进行不断的探测,如果主线程崩溃,就不会有任何响应,那就可以在 Worker 线程中进行崩溃异常的上报。这里继续贴一下 APM 的检测策略:

  • JS 主线程:
    • 固定时间间隔(2s)向 Web Worker 发送心跳
  • Web Worker:
    • 定期(2s)检查是否收到心跳。
    • 超过一定时间(6s)未收到心跳,则认为页面崩溃。
    • 检测到崩溃后,通过 http 请求进行异常上报。

微信图片_20220423210855.png

性能监控

性能监控并不只是简单的监控“页面速度有多快”,需要从用户体验的角度全面衡量性能指标。(就是所谓的 RUM 指标)目前业界主流标准是 Google 最新定义的 Core Web Vitals:

  • 加载(loading)  :LCP
  • 交互(interactivity)  :FID
  • 视觉稳定(visual stability)  :CLS

Loading加载

FP/FCP/FMP

FP.png

  • FP  (First Paint):  当前页面首次渲染的时间点,通常将开始访问 Web 页面的时间点到 FP 的时间点的这段时间视为白屏时间,简单来说就是有屏幕中像素点开始渲染的时刻即为 FP。
  • FCP  (  First Contentful Paint  ):  当前页面首次有内容渲染的时间点,这里的 内容 通常指的是文本、图片、svg 或 canvas 元素。

通过PerformancePaintTiming获取FP和FCP

function getPaintTimings(){
  let performance = window.performance;
  if(performance){
    let paintEntries = performance.getEntriesByType('paint');
    return{
      FP: paintEntries.filter(entry=> entry.name === 'first-paint')[0].startTime,
      FCP: paintEntries.filter(entry=>entry.name === 'first-contentful-paint')[0].startTime
    }
  }
}

66.png

FMP

  • FMP  (First Meaningful Paint):  表示首次绘制有意义内容的时间,在这个时刻,页面整体布局和文字内容全部渲染完成,用户能够看到页面主要内容,产品通常也会关注该指标。

FMP 的计算相对复杂,因为浏览器并未提供相应的 API,在此之前我们先看一组图:

77.png 从图中可以发现页面渲染过程中的一些规律:

  1. 在 1.577 秒,页面渲染了一个搜索框,此时已经有 60 个布局对象被添加到了布局树中。
  1. 在 1.760 秒,页面头部整体渲染完成,此时布局对象总数是 103 个。
  1. 在 1.907 秒,页面主体内容已经绘制完成,此时有 261 个布局对象被添加到布局树中从用户体验的角度看,此时的时间点就是是 FMP。

可以看到布局对象的数量与页面完成度高度相关。业界目前比较认可的一个计算 FMP 的方式就是——「页面在加载和渲染过程中最大布局变动之后的那个绘制时间即为当前页面的 FMP 」

实现原理则需要通过 MutationObserver 监听 document 整体的 DOM 变化,在回调计算出当前 DOM 树的分数,分数变化最剧烈的时刻,即为 FMP 的时间点

至于如何计算当前页面 DOM 🌲的分数,LightHouse 的源码中会根据当前节点深度作为变量做一个权重的计算,具体实现可以参考 LightHouse 源码。

LCP

99.png LCP (Largest Contentful Paint) 是就是用来代替 FMP 的一个性能指标  ,用于度量视口中最大的内容元素何时可见,可以用来确定页面的主要内容何时在屏幕上完成渲染。

使用 Largest Contentful Paint API 和 PerformanceObserver 即可获取 LCP 指标的值:

const observer = new PerformanceObserver((entryList)=>{
  for(const entry of entryList.getEntries()){
    console.log("LCP candiate:",entry.startTime,entry);
  }
})

observer.observe({type:'largest-contentful-paint', buffered:true});

6767.png

Interactivity 交互

微信图片_20220423215553.png

TI(Time To Interactive) 表示从页面加载开始到页面处于完全可交互状态所花费的时间,  TTI 值越小,代表用户可以更早地操作页面,用户体验就更好。

这里定义一下什么是完全可交互状态的页面

  1. 页面已经显示有用内容。
  1. 页面上的可见元素关联的事件响应函数已经完成注册。
  1. 事件响应函数可以在事件发生后的 50ms 内开始执行(主线程无 Long Task)。

TTI 的算法略有些复杂,结合下图看一下具体步骤:

微信图片_20220423220036.png

Long Task: 阻塞主线程达 50 毫秒或以上的任务。

  1. 从 FCP 时间开始,向前搜索一个不小于 5s 的静默窗口期。(静默窗口期定义:窗口所对应的时间内没有 Long Task,且进行中的网络请求数不超过 2 个)
  1. 找到静默窗口期后,从静默窗口期向后搜索到最近的一个 Long Task,Long Task 的结束时间即为 TTI。
  1. 如果一直找到 FCP 时刻仍然没有找到 Long Task,以 FCP 时间作为 TTI。 FID

微信图片_20220423220427.png FID(First Input Delay) 用于度量用户第一次与页面交互的延迟时间,是用户第一次与页面交互到浏览器真正能够开始处理事件处理程序以响应该交互的时间。

其实现使用简洁的 PerformanceEventTiming API 即可,回调的触发时机是用户首次与页面发生交互并得到浏览器响应(点击链接、输入文字等)。

function onFirstInputEntry(entry){
  const fid = entry.processingStart - entry.startTime;
  //console.log(fid)
   report({fid})//上报
}

const observer = new PerformanceObserver((entryList)=>{
  entryList.getEntries().forEach(onFirstInputEntry);
})

observer.observe({
  type:'first-input',
  buffered:true
})

1111.png

百度的输入框

至于为何新的标准中采用 FID 而非 TTI,可能存在以下几个因素:

  • FID 是需要用户实际参与页面交互的,只有用户进行了交互动作才会上报 FID,TTI 不需要。
  • FID 反映用户对页面交互性和响应性的第一印象,良好的第一印象有助于用户建立对整个应用的良好印象。

Visual Stability 视觉稳定

CLS

cls.png CLS(Cumulative Layout Shift) 是对在页面的整个生命周期中发生的每一次意外布局变化的最大布局变化得分的度量,布局变化得分越小证明你的页面越稳定

听起来有点复杂,这里做一个简单的解释:

  • 不稳定元素:一个非用户操作但发生较大偏移的可见元素称为不稳定元素。
  • 布局变化得分:元素从原始位置偏移到当前位置影响的页面比例 * 元素偏移距离比例

使用 Layout Instability API 和 PerformanceObserver 来获取 CLS:

let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];

new PerformanceObserver((entryList)=>{
  for(const entry of entryList.getEntries()){
    //Only count layout shifts without recent user input
    if(!entry.hadRecentInput){
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length-1];
      // if the entry occurred less than 1 second after the previous entry and
      // less than 5 seconds after the first entry in the session, include the
      // entry in the current session. Otherwise, start a new session.
      if(sessionValue && entry.startTime - lastSessionEntry.startTime < 1000
        && entry.startTime - firstSessionEntry.startTime < 5000){
          sessionValue += entry.value;
          sessionEntries.push(entry)
        }else{
          sessionEntries = entry.value;
          sessionEntries = [entry]
        }
      // if the current session value is larger than the current CLS value
      // update CLS and the entries contributing to it 
      if(sessionValue > clsValue){
        clsValue = sessionValue;
        clsEntries = sessionEntries;
        // Log the updated value (and its entries) to the console.
        console.log("CLS:", clsValue, clsEntries)
      }
    }
  }
}).observe({type:'layout-shift',buffered: true})

数据上报

得到所有错误、性能、用户行为以及相应的环境信息后就要考虑如何进行数据上报,理论上正常使用ajax 即可,但有一些数据上报可能出现在页面关闭 (unload) 的时刻,这些请求会被浏览器的策略 cancel 掉,因此出现了以下几种解决方案:

  1. 优先使用 Navigator.sendBeacon,这个 API 就是为了解决上述问题而诞生,它通过 HTTP POST 将数据异步传输到服务器且不会影响页面卸载。
  1. 如果不支持上述 API,动态创建一个  <img  /   > 标签将数据通过 url 拼接的方式传递。
  1. 使用同步 XHR 进行上报以延迟页面卸载,不过现在很多浏览器禁止了该行为。

9999.png