一个企业级前端埋点监控插件应该是什么样的

3,055 阅读9分钟

前言

大家好这里是阳九,一个文科转码的野路子码农,热衷于研究和手写前端工具.

我的宗旨就是 万物皆可手写

新手创作不易,有问题欢迎指出和轻喷,谢谢

附上本人的git仓库 github.com/lzy19926, 支持的大佬们可以进来给本人的一些学习项目点个star嘛(是的我就是厚颜无耻要star)


首先我们来看一下 一个比较简易的前端监控的流程

image.png

现在我们将要把这个前端部分,封装成一个监控插件。

架构设计

首先我们大致知道了,需要有四个主要业务模块

  • 用户事件监控模块 (eventWatcher)
  • 页面性能监控模块 (performanceWatcher)
  • 页面跳转监控模块 (routerWatcher)
  • 错误监控模块 (errorWatcher)

为了保证我们程序的健壮性与可拓展性, 我们再给其加上一些辅助模块

  • 数据上报模块(uploader) : 处理上报数据
  • 控制台打印模块(logger) : 控制台打印上报记录
  • 插件模块(plugin) : 可外接其他监控模块

然后他就变成了这样

image.png

接下来我们对每一个模块进行业务分析和优化点探索:

各模块优化整理

模块优化点
事件监控冒泡处理,主动触发
页面性能监控首屏渲染 ,异步资源传输 ,向下兼容 ,onload事件检查
页面跳转监控路由劫持, 兼容hash和history路由模式
错误监控异常监控,Promise异常监控
数据上报sendBeacon与gif上报, 使用uuid, batch发送, requestIdleCallback等

用户事件监控

用户事件有很多我们这里以最常见的click事件举例

埋点

埋点可以分为,代码埋点,可视化埋点和无埋点.

  1. 代码埋点:就是以嵌入代码的形式进行埋点
  // tsx
  // 被标记了trackEvent的dom,点击时会被记录事件
  <button trackEvent={true}>点击</button>

优点:精确

缺点:工作量大,迭代麻烦

2.可视化埋点:通过可视化交互的手段,代替代码埋点,实际上跟代码埋点还是区别不大。也就是用一个系统来实现手动插入代码埋点的过程。

优点: 简易,可视化

缺点: 不灵活

3.无埋点:无埋点并不是说不需要埋点,而是全部埋点,前端的任意一个事件都被绑定一个标识,所有的事件都别记录下来。

优点:全量埋点,迭代方便,不会错埋

缺点:数据量大,性能和服务器压力。

监控点击事件

  1. 首先我们给按钮埋点
 <button trackEvent={true}>点击</button>
  1. 拦截点击事件
 document.addEventListener('click', (e) => { // 点击事件
     // do something
 })
  1. 识别埋点dom(冒泡)

如果点击了按钮内的a标签,我们仍然要将其识别为点击按钮,所以我们需要一层层向上查找dom

const domList:HTMLElement[] = getDomPath(e.target) // 获取点击的元素到最外层的元素数组

const target = domList.find(node=> // 遍历数组,找到拥有trackEvent属性的dom
        node.hasAttribute("trackEvent")&&node.hasAttribute
)
if(!target) return // 无埋点直接返回
  1. 将事件封装为消息对象,传给上报模块进行上报(消息对象大概长这样)
// 我们这里封装一个消息对象message
type EventMessage = {
    type:string // 事件类型(click)
    eventId:string// 事件domId
    triggerTime:number// 事件触发事件
    url:string// 触发页面的url
    ...       // 还有什么消息可以自己从e.target中获取
}
// 生成消息对象并传给上报模块
const eventMsg:EventMessage = {type:"click"...}
// 发送
emit(eventMsg)

页面性能监控

Performance API简介

Performance API 是浏览器提供给我们的用于检测网页性能的 API,其中包含四个主要API

  • Resource Timing API:与网页资源(脚本、样式、图片等)加载相关的耗时信息,定义了接口 PerformanceResourceTiming。

  • Navigation Timing API:从页面导航开始一直到 load 事件结束,中间经历过程的耗时信息。定义了接口 PerformanceNavigationTiming,此接口继承自 PerformanceResourceTiming 接口。

  • Performance.timing:老版本的PerformanceNavigationTiming,现已弃用

  • Paint Timing:与网页绘制相关的耗时信息。定义了接口 PerformancePaintTiming。

首屏性能监控

首先我们可以列举一下一些常用的性能指标

export type TimePerformance = {
    fmp: number         // 首屏渲染时间
    fpt?: number         // 白屏时间
    tti?: number         // 首次可交互时间
    ready?: number       // HTML加载完成时间
    onload?: number      // 页面完全加载时间
    firstbyte?: number   // 首包时间
    dns?: number         // dns查询耗时
    appcache?: number    // dns缓存时间
    tcp?: number         // tcp连接耗时
    ttfb?: number        // 请求响应耗时
    trans?: number       // 内容传输耗时    
    dom?: number         // dom解析耗时
    res?: number         // 同步资源加载耗时
    ssllink?: number     // 同步资源加载耗时
    redirect?: number    // 重定向时间
    unloadTime?: number  // 上一个页面的卸载耗时
}

之后通过performance系列API获取关键部分的时间,来计算这些指标

(这里使用performance.timing这个老API做演示,也可以用新API进行替代)

const times:TimePerformance = {} // 记录各项时间数据

const t = window.performance.timing;

 // 获取首屏渲染时间
 const paintEntries = window.performance.getEntriesByType('paint')
  times.fmp = paintEntries.pop().startTime
 
 times.fpt = t.responseEnd - t.fetchStart; // 白屏时间
 
 times.tti = t.domInteractive - t.fetchStart; // 首次可交互时间

 times.ready = t.domContentLoadedEventEnd - t.fetchStart; // HTML加载完成时间

 times.loadon = t.loadEventStart - t.fetchStart; // 页面完全加载时间

 times.firstbyte = t.responseStart - t.domainLookupStart; // 首包时间

 times.dns = t.domainLookupEnd - t.domainLookupStart; // dns查询耗时

 times.appcache = t.domainLookupStart - t.fetchStart; // dns缓存时间

 times.tcp = t.connectEnd - t.connectStart; // tcp连接耗时

 times.ttfb = t.responseStart - t.requestStart; // 请求响应耗时

 times.trans = t.responseEnd - t.responseStart; // 内容传输耗时

 times.dom = t.domInteractive - t.responseEnd; // dom解析耗时

 times.res = t.loadEventStart - t.domContentLoadedEventEnd; // 同步资源加载耗时

 times.ssllink = t.connectEnd - t.secureConnectionStart; // SSL安全连接耗时

 times.redirect = t.redirectEnd - t.redirectStart; // 重定向时间

 times.unloadTime = t.unloadEventEnd - t.unloadEventStart; // 上一个页面的卸载耗时

最后我们监视一下页面onload事件,将需要的首屏渲染各项数据发送上去

  window.addEventListener('load', () => {
      emit(messsg) // 封装好首屏渲染消息,发送给数据上报模块
  })

异步资源加载性能监控

首先,每当我们异步资源加载触发时,我们需要去对他们进行监控,触发我们的监控函数callback

浏览器给我们提供了一个 PerformanceObserver(MDN) API 用于创建监视器。

// 创建一个监视器并注册resource事件,每当异步资源加载时会触发回调callback
const observer = new PerformanceObserver(callback);
observer.observe({ entryTypes: ['resource'] });

在callback中,我们可以通过PerformanceResourceTiming(MDN)这个API去获取对应的时间

// 可通过API获取的一些属性
const performanceResourceTimingAttrs = {

transferSize: 0, // 传输内容大小

encodedBodySize: 0,// 删除任何应用的内容编码之前

decodedBodySize: 0,// 在删除任何应用的内容编码之后

duration: 0,       // responseEnd和startTime相减

connectStart: 0, // 浏览器检索资源,开始建立与服务器的连接之前

connectEnd: 0,   // 浏览器完成与服务器的连接以检索资源之后。

requestStart: 0, // 浏览器开始从服务器请求资源之前

responseStart: 0,// 浏览器收到服务器响应的第一个字节时

responseEnd: 0,  // 浏览器收到资源的最后一个字节之后或紧接在传输连接关闭之前
...
};

还是一样,我们可以通过上述时间计算出我们想要的信息,封装起来上报。

页面跳转监控

这个模块就比较简单了,想必背过前端八股文的同学都知道hash路由和history路由的种种API

我们在这里劫持路由的跳转,通过window.historywindow.location进行劫持,封装为路由跳转事件进行上报

(如果不知道history和location是啥的同学请自行百科前端路由原理谢谢)

    const referer = document.referrer; // 获取是从哪个页面跳转来的
    
    // 劫持history.pushState  history.replaceState popState
    const originPush = window.history.pushState.bind(window.history);
    const originRepalce = window.history.replaceState.bind(window.history);
    
    // 修改history的pushState方法
     window.history.pushState = (data, title, url)=>{
         originPush(data, title, url)// 先正常进行跳转
         emit(message) // 再上报数据(自行封装message)
     }
     // 修改replaceState方法
     window.history.replaceState = (data, title, url)=>{
         originRepalce(data, title, url)// 先正常进行跳转
         emit(message) // 再上报数据(自行封装message)
     }
     // popState同理
     ...

hash模式的话我们直接监听hashchange即可

  // 监听hashchange
  window.addEventListener('hashchange', () => {
        ...
        emit(message)
  });

错误监控模块

对于错误,浏览器也提供了监控错误的方法,即

  • error事件(捕获资源加载错误)
// 可以获取资源加载错误,script.onError link.onError img.onError
window.addEventListener('error', (e) => {
  handleError(e) // 处理错误并上报emit
}, true);
  • unhandledrejection事件(捕获promise调用链未捕获异常)
// promise调用链未捕获异常
window.addEventListener('unhandledrejection', (e) => {
  handleError(e) // 处理错误并上报emit
});
  • 劫持console.error
// 劫持console.error
const originConsoleError = console.error;
// 上报每个error
console.error = (...errors)=>{
  errors.forEach((e) => {
       handleError(e) // 处理错误并上报emit
  } );
  originConsoleError.apply(console, errors);
};

数据上报

消息池

我们知道,之前的四个模块会不断发送message,我们在这里创建一个messagePool,将其统一管理,并分批发送

let messagePool = []; // 批次队列

定时发送埋点数据

我们可以将数据分批发送,每5s发送5条,使用定时器进行控制

let timer = setTimeout(() => {  send(url)  }, 5000)

传统方式Gif发送数据

我们将上报的数据JSON.stringfy()一下,放到Image的src属性中即可

为什么大厂前端监控都在用GIF做埋点? - 掘金 (juejin.cn)

const beacon = new Image();
beacon.src = `${url}?data=${JSON.stringify(data)}`;

web beacon(网络信标)发送数据

除了gif图片,从2014年开始,浏览器逐渐实现专门的API,来更优雅的完成这件事:Navigator.sendBeacon(MDN)

 Navigator.sendBeacon(url,data)

相较于图片的src,这种方式的更有优势:

  1. 不会和主要业务代码抢占资源,而是在浏览器空闲时去做发送;
  2. 并且在页面卸载时也能保证请求成功发送,不阻塞页面刷新和跳转;

现在的埋点监控工具通常会优先使用sendBeacon,但由于浏览器兼容性,还是需要用图片的src兜底。

优化

  1. 我们可以将send函数通过requestIdleCallback(MDN)requestAnimationFrame(MDN)等函数,嵌入浏览器空闲时进行发送优化

  2. 配合定时器setTimeout或者setInterval

后记

这篇文章也是肝了好几天, 我也是自己写了一个埋点插件,边写边输出文章。

后续会更新我手写的埋点监控插件的仓库。

同学们可以自行给架构添加其他模块

比如预留外接模块的位置

比如logger控制台打印

比如专门用来外接数据库的模块等等...

希望大家(自己能够再接再厉) 在这个寒冷的时代找到一丝积极的正能量。