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

93 阅读14分钟

前言

这是我参与「第五届青训营」伴学笔记创作活动的第 12 天,上回讲到前端PC端移动端的调试技术,这回来讲前端监控,前端监控监控什么呢?为什么需要前端监控?

为什么要聊前端监控

什么是前端监控 | 为什么需要前端监控 | 前端监控到底监控了什么

什么是前端监控

从一个经典面试题聊起 在浏览器里,从输入 URL 到页面展示,这中间发生了什么? 微信截图_20230209115342.png 前端监控就是尽可能的采集这一过程以及后续用户交互中产出的性能指标与发生 的异常事件并上报到平台完成消费

为什么需要前端监控

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

  • 这个网页打开好慢啊!
  • 为什么这个网页会那么卡啊! FPS:14
  • 怎么网页的图片全都裂开了!
  • 这个网页怎么是白花花的一片什么都没有!

救命SOS!谁可以告诉我发生了什么? !

让我们再从开发者(开发网页的人)的视角来回答用户抛出问题

  • 打开好慢用户用的是 XP + IE 浏览器,建议重装系统!.006e
  • 交互卡顿 - 用户的电脑配置太差了,建议升级为 i9 + 4090!
  • 资源加载失败用户网络状态太差,建议到大城市里再试试!
  • 页面白屏我这台电脑没问题啊,应该是偶发事故,不要慌!

以上,都是不负责任对页面质量的错误归因! X

当我们的网页有了前端监控的能力

  • 打开好慢- 页面某个关键资源渲染太慢
  • 交互卡顿 - 页面同步计算任务太重,阻塞染
  • 资源加载失败- 客户端网络状态差,或上游服务节点异常
  • 页面白屏页面脚本执行失败、关键资源加载失败、请求失败等

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

通过设置一层去专门做前端的监控

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

微信截图_20230209120051.png

  1. 缤趣 (图片社交分享网站) 将感知等待时间减少了 40%,这将搜索引擎流量和注册量增加了 15%。
  2. COOK (冷冻速食订购网站) 将页面平均加载时间减少了 850 毫秒,从而将转化次数提高了 7%,将跳出率降低了 7%,并将每个会话的页面增加了 10% 。

研究还表明,性能不佳会对业务目标产生负面影响。例如,BBC 发现他们的网站加载时间每增加一秒,他们就会失去 10% 的用户

前端监控到底监控了什么

  1. 性能指标
  2. 异常事件
  3. 用户行为

微信截图_20230209120534.png

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

web 性能标准的诞生 | 传统的性能指标 | 以用户为中心的性能指标

web 性能的诞生

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

传统的性能指标

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

微信截图_20230209121010.png

微信截图_20230209121033.png

以用户为中心的性能指标

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

这就是创建用户为中心的性能指标的原因,它们专注于用户视角下的浏览体验

微信截图_20230209121250.png

微信截图_20230209121307.png

  • FP (First Paint):首次染的时间点。FP 时间点之前,用户看到的都是没有任何内容的白色屏幕
  • FCP (First Contentful Paint) :首次有内容染的时间点
  • FMP(First Meaningful Paint) : 首次绘制有意义内容的时间点
  • TTI (Time to Interactive) : 测量页面从开始加载到主要子资源完成渲染并能够快速、可靠地响应用户输入所需的时间
    • TTI 反映页面可用性的重要指标。TTI 值越小,代表用户可以更早地操作页面,用户体验就更好

SI(Speed Index) : 量页面可视区域加载速度,,帮助检测页面的加载体验差异

微信截图_20230209121514.png A 和 B 的首次内容出现和完全加载时间是一样的,但是从用户角度A的体验明显更好

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

微信截图_20230209121628.png

LCP(Largest Contentful Paint) : 最大的内容在可视区域内变得可见的时间点

微信截图_20230209121743.png 最大的元素,例如一篇文章中的一大段文字或产品页面上的一张图片,大概就是让你理解页面内容的最有用的元素

微信截图_20230209121804.png LCP 优点:

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

前端监控用的指标就是LCP

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

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

当超过50ms ,用户就会感知到卡顿,所以这是一个阈值关键点

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

微信截图_20230209122008.png

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

微信截图_20230209122026.png

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

静态资源错误 | 请求异常 | Js 错误 | 白屏异常

静态资源错误

  • 静态资源: 加载页面所需的 htmlcssjs 等文件,以及其他各类多媒体文件,如图片、音频和视频等
  • 静态资源错误: 在拉取和加载静态资源的过程中发生了预期之外的错误,如网络异常等,导致静态资源无法正常渲染到页面上

微信截图_20230209122213.png

出现以上错误,易减少用户信赖

请求异常

Http 请求状态码分类

  • 100 - 199 --> 信息响应
  • 200 - 299 --> 成功响应
  • 300 - 399 --> 重定向消息
  • 400 - 499 --> 客户端错误响应
  • 500 - 599 -->服务端错误响应

请求异常 = 请求响应状态码>= 400
对于通过异步请求拉取的静态资源错误也可选择归纳到请求异常

状态码 0 是什么?

XMLHttpRequests 被停止 如果你的XMLHttpRequest 收到 status=0和 statusText=nul1 的返回,这意味着请求无法执行。就是无法发送.一个可能导致的原因是当 XMLHttpRequest origin(创建的XMLHttpRequest)改变时,XMLHttpRequest 执行 open()。这种情况是可能发生的,举个例子,我们在一个窗口的onunload 事件中关闭XMLHttpRequest,但实际上在即将关闭窗口时,之前创建的 XMLHttpRequest 仍然在那里,最后当这个窗口失去焦点、另一个窗口获得焦点时,它还是发送了请求 (也就是open())。最有效的避免这个问题的方法是为新窗口的activate 事件设置一个监听器,一旦窗口关闭,它的 unload_(en-US)事件便触发。

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

Js 错误

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

微信截图_20230209122659.png

白屏异常

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

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

微信截图_20230209122758.png

微信截图_20230209122804.png

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

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

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

做好白屏监控是前端监控的重要点

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

性能指标监控 | Js 错误监控 | 静态资源错误监控 | 请求异常监控

性能指标监控

微信截图_20230209123103.png Performance
Performance 接口可以获取到当前页面中与性能相关的信息。它是 High Resolution Time API的一部分同时也融合了 Performance Timeline API、Navigation Timing APl、User Timing AP!和 ResourceTiming APl 该类型的对象可以通过调用只读属性 Window.performance 来获得 性能监测对象
Performanceobserver 用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry的时候将会被通知。

备注: 此特性在 Web Worker 中可用

利用 PerformancePerformanceObserver 可以监控到一些标准的渲染性能数据

码上掘金,实战一下

/**
 * 列举出性能指标对应的 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
// 3.1. 起名字 
// 3.2. 监听能力
// 3.3. 主动开启,而不是被动开启
// 3.4.上报能力
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: error event 当资源加载失败或无法使用时,会在 window 对象触发error 事件。例如:script 执行时报错

Bubbles(支持冒泡)No
Cancelable (可撤销)No
Interface (接口)Event or UIFvent
Eventhandler property (事件处理程序属性)onerror

如果它是由用户界面元素生成的,或者是由事件实例生成的,那么此事件是 UIEent实例

unhandledrejectionPromise 被reject 且没有reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在window 下,但也可能发生在 Worker 中。这对于调试和为意外情况提供后备错误处理非常有用。

是否冒泡No
是否可取消Yes
接口PromiseRejectionEvent
事件处理器属性onunhandledrejection [en-us]()

利用 window.addEventListenererrorunhandledrejection 可以监控到全局的 js 错误

码上掘金,实战一下 js 错误监控实战演练

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

对普通的 event 的error,和 promise 的错误运用unhandkerjection的监听

静态资源错误监控

微信截图_20230209124918.png

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

码上掘金,实战一下 静态资源错误监控实战演练

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

通过 hook xhrfetch 对象来监听请求时发生的错误

码上掘金,实战一下 请求异常监控实战演练

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

因为http 请求的方式是一段段的,并且我们重载的时候,不能改变原有的属性和方法,我们通过源函数去调用去恢复源函数。

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

前端监控流程概述 | 临门一脚: 数据上报 | 按需加载监控能力

前端监控流程概述

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

微信截图_20230209131407.png

临门一脚: 数据上报

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

function createXhrMonitor(report: ({name: string, data: any}) => Void) {}

微信截图_20230209131545.png

专为前端监控打造的请求函数 Navigator.sendBeacon()

按需加载监控能力

下面我们需要将之前实现的监控器按需加载到 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 }
}

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

更多性能指标计算算法

请试着实现 FMPCLSTTI 等性能指标的计算方法,参考算法见 附录

关注请求性能

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

微信截图_20230209131911.png

更安全和稳定的 Hook 函数

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

用户行为监控

由于篇幅限制,我们在本章中没有介绍用户行为监控,这不代表它不是前端监控的关键一环,你可以尝试补全这一监控能力吗?

数据存储与消费

sdk 提供了前面两个环节的能力,而后两个环节需要同时依赖前端和后端的能力来共同完成,你可以拉上其他同学们补全每一个环节吗?

微信截图_20230209131407.png

附录1 FMP

微信截图_20230209132327.png

附录2 CLS

微信截图_20230209132351.png 关于如何CLS的计算细节和原理可以查看 web.dev讲述累积布局偏移(CLS)

附录3 TTI

微信截图_20230209132500.png