美文网首页
前端监控概览

前端监控概览

作者: 梦想成真213 | 来源:发表于2021-04-13 16:57 被阅读0次

    背景

    以往我们知道的监控都是服务端的监控,前端是少有被提及的,线上的页面什么时候挂掉,挂了多长时间,什么原因导致的,都不清楚,也不能第一时间获取报警信息。而服务端都有成熟的监控报警机制。前端也是要做这一块的补充的。

    为什么要做前端监控

    用户在访问页面的时候会经历三个阶段:服务端请求获取资源,浏览器加载资源,资源加载成功之后页面继续运行。而这三个阶段都有报错的可能,第一阶段服务端的监控报警机制很成熟,而前端要做的就是监控后面两个阶段:资源加载和页面交互。

    做前端监控有很多好处:

    • 第一时间上报异常,解决问题
    • 完整的重现问题用户的全流程路径,方便开发者复现问题,定位问题
    • 做产品的决策依据
    • 为业务扩展提供更多可能性

    这样就能做到线上应用异常时,第一时间收到反馈,并及时止损。

    前端监控目标

    前端监控主要包含两大块:错误监控和性能监控

    • 保证稳定性(错误监控)
      错误监控包括 JavaScript 代码错误,Promsie 错误,接口(XHR,fetch)错误,资源加载错误(script,link等)等,这些错误大多会导致页面功能异常甚至白屏。

    • 提升用户体验(性能监控)
      性能监控包括页面的加载时间,接口响应时间等,侧面反应了用户体验的好坏。

    1. 加载时间:页面运行时各个阶段的加载时间;
    2. TTFB(time to first byte)(首字节时间):浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间;
    3. FP(First Paint)(首次绘制):首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻;
    4. FCP(First Content Paint)(首次内容绘制):首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间;
    5. FMP(First Meaningful paint)(首次有意义绘制):首次有意义绘制是页面可用性的量度标准;
    6. LCP(Largest Contentful Paint):视窗内最大的图片或者文本渲染的时间,当最大的内容块渲染完的时候,基本上主内容都加载完了,与现有的页面加载指标相比,与用户体验的相关性更好;
    7. FID(First Input Delay)(首次输入延迟):用户首次和页面交互到页面响应交互的时间;
    8. 卡顿:指超过50ms的长任务;
    • 业务上的统计
      PV:page view 即页面浏览量或点击量
      UV:指访问某个站点的不同 IP 地址的人数
      页面的停留时间:用户在每一个页面的停留时间

    前端监控的流程

    • 前端埋点(通过 sdk 给页面的 dom 都加上标记)
    • 数据上报(收集,存储)
    • 分析和计算(将采集到的数据进行加工汇总)
    • 可视化展示(按照纬度将数据展示)
    • 监控报警(发现异常后按一定的条件触发报警)

    前端埋点方案

    代码埋点

    代码埋点,就是项目中引入埋点 sdk,手动在业务代码中标记,触发埋点事件进行上报。比如页面中的某一个模块的点击事件,会在点击事件的监听中加入触发埋点的代码this.$track('事件名', { 需要上传的业务数据 }),将数据上报到服务器端。

    优点:能够在任何时刻,更精确的发送需要的数据信息,上报数据更灵活。
    缺点:工作量大,代码侵入太强,过于耦合业务代码,一次埋点的更改就要引起发版之类的操作。
    这个方案也是我们实际项目中现有的方案。

    可视化埋点

    通过可视化交互的手段,代替代码埋点,可以新建,编辑,修改埋点。在组件和页面的维度进行埋点的设计。

    将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件,最后输出的代码耦合了业务代码和埋点代码。

    这个方案是可以解决第一种代码埋点的痛点,也是我们目前正准备做的方案。

    无痕埋点

    前端的任意一个事件都被绑定一个标识,所有的事件都被记录下来,通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告。
    无痕埋点的优点是采集全量数据,不会出现漏埋和误埋等现象。缺点是给数据传输和服务器增加压力,也无法灵活定制数据结构。针对业务数据的准确性不高。

    监控脚本

    日志存储

    前端的埋点上报需要存储起来,这个可以使用阿里云的日志服务,不需要投入开发就可以采集。新建一个项目比如:frontend-monitor 新建一个 logStore 存储日志,根据阿里云的要求发起请求,携带需要上报的数据:
    http://${project}.${host}/logstores/${logStore}/track


    代码中调用 track 上报日志:
    日志的上报可以封装成公共的调用方式, monitor/utils/里面放所有的工具方法,
    tracker.js 的实现就是按照阿里云的上报格式发送请求,并带上处理好的需要上报的业务数据即可,下面的都是固定的,在日志服务建好:
    const host = 'cn-shanghai.log.aliyuncs.com'
    const project = 'frontend-monitor'
    const logStore = 'monitor'
    实现一个 tracker 类导出类的实例即可,这样在监控的核心代码中直接调用tracker.send(data)
    // monitor/utils/get/tracker.js
    const host = 'cn-shanghai.log.aliyuncs.com'
    const project = 'frontend-monitor'
    const logStore = 'monitor'
    const userAgent = require('user-agent')
    
    function getExtraData() {
      return {
        title: document.title,
        url: location.href,
        timestamp: Date.now(),
        userAgent: userAgent.parse(navigator.userAgent).name
      }
    }
    
    class SendTracker {
      constructor() {
        this.url = `http://${project}.${host}/logstores/${logStore}/track`
        this.xhr = new XMLHttpRequest()
      }
    
      send(data = {}, callback) {
        const extraData = getExtraData()
        const logs = {...data, ...extraData}
        for(let key in logs) {
          if (typeof logs[key] === 'number') {
            logs[key] = `${logs[key]}` // 阿里云要求,字段不能是数字类型
          }
        }
        
        let body = JSON.stringify({
          __logs__: [logs]
        })
        this.xhr.open('POST', this.url, true)
        this.xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8')
        this.xhr.setRequestHeader('x-log-apiversion', '0.6.0')
        this.xhr.setRequestHeader('x-log-bodyrawsize', body.length)
        this.xhr.onload = function() {
          if (this.status >= 200 && this.status <= 300 || this.status === 304) {
            callback && callback()
          }
        }
        this.xhr.onerror = function(error) {
          console.log(error)
        }
        this.xhr.send(body)
      }
    }
    
    export default new SendTracker()
    

    这里展示的是自定义要上报的数据字段:


    监控错误

    前端需要监控的错误有两类:

    • Javascript 错误(js 错误,promise 异常)
    • 监听 error 错误(资源加载错误)

    脚本实现

    新建一个 fronend-monitor 项目,这个项目就相当于我们的工程项目,监控的核心实现可以写到项目里面,也可以抽成 sdk 的形式 import 引入进来,这里先写到项目中。

    webpack.config.js 用来打包项目,做接口数据 mock,测试 xhr 请求监控接口错误等

    const path = require('path')
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    
    module.exports = {
      mode: 'development',
      context: process.cwd(),
      entry:'./src/index.js',
      output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'monitor.js'
      },
      devServer: {
        contentBase: path.resolve(__dirname, 'dist'),
        before(router) {
          router.get('/success', function(req, res) {
            res.json({ id: 1 })
          })
          router.post('/error', function(req, res) {
            res.sendStatus(500)
          })
        },
      },
      module: {},
      plugins: [
        new HtmlWebpackPlugin({
          template: './src/index.html',
          inject: "head"
        })
      ]
    }
    

    新建目录src/monitor/index.js ,这个目录放监控的核心代码实现的入口,lib 文件夹放所有的核心文件,首先捕获 javascript 错误:

    import injectJsError from './lib/jsError.js'
    import injectXHR from './lib/xhr'
    injectJsError()
    injectXHR()
    

    新建一个入口文件src/index.js,直接引入 监控核心代码入口。

    import './monitor'
    

    新建一个 src/index.html 在这个里面写一些问题代码,然后测试监控的错误捕获。

    // src/index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>monitor</title>
    </head>
    <body>
      <input id="jsErrorBtn" type="button" value="js 代码错误" onclick="btnClick()" />
      <input id="promiseErrorBtn" type="button" value="promise 错误" onclick="promiseClick()" />
      <input id="successBtn" type="button" value="成功 ajax 请求" onclick="successAjax()" />
      <input id="errorBtn" type="button" value="失败 ajax 请求" onclick="errorAjax()" />
      <script>
        function btnClick() {
          window.goods.type = 2
        }
    
        function promiseClick() {
          new Promise((resolve, reject) => {
            resolve(1)
          }, () => {
            console.log(123)
          })
        }
    
        function successAjax() {
          var xhr = new XMLHttpRequest()
          xhr.open('GET', '/success',  true)
          xhr.responseType = 'json'
          xhr.onload = function () {
            console.log(xhr.response)
          }
          xhr.send()
        }
    
        function errorAjax() {
          var xhr = new XMLHttpRequest()
          xhr.open('POST', '/error',  true)
          xhr.responseType = 'json'
          xhr.onload = function() {
            console.log(xhr.response)
          }
          xhr.onerror = function(err) {
            console.log(err)
          }
          xhr.send('name=123')
        }
      </script>
    </body>
    </html>
    

    上报未捕获的 javascript 错误

    javascript 错误分为2种:语法错误,资源家加载错误,这些错误都会被window.addEventListener('error', function(event) {})捕获,根据event.target.src / href来判断是否是资源加载错误,

    window.addEventListener('error', function(event) {
        const lastEvent = getLastEvent()
    
        // 如果 target 是script link 等资源
        if (event.target && (event.target.src || event.target.href)) {
          const selector = getSelector(event.target || event.path)
          tracker.send({
            title: document.title,
            url: location.href,
            timestamp: event.timeStamp,
            userAgent: navigator.userAgent,
            kind: 'stability',
            type: 'resourceError',
            filename: event.target.src || event.target.href,
            tagName: event.target.tagName,
            selector
          })
        } else {
          tracker.send({
            title: document.title,
            url: location.href,
            timestamp: event.timeStamp,
            userAgent: navigator.userAgent,
            kind: 'stability',
            type: 'jsError',
            errorMessage: event.error.message,
            filename: event.filename,
            position: `${event.lineno}:${event.colno}`,
            stack: getStack(event.error.stack),
            selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : '',
          })
        }
      }, true)
    

    代码中未被捕获的 promise 错误,要监听 unhandledrejection事件window.addEventListener('unhandledrejection', function(event) {})

    // 监听未捕获的 promise 错误
      window.addEventListener('unhandledrejection', function(event) {
        // PromiseRejectionEvent
        const lastEvent = getLastEvent()
        let message = ''
        let stack = ''
        const reason =  event.reason
        let filename = ''
        let lineno = ''
        let colno = ''
        if (reason) {
          message = reason.message
          stack = reason.stack
          const match = stack.match(/\s+at\s+(.+):(\d+):(\d+).+/)
          filename = match[1]
          lineno = match[2]
          colno = match[3]
        }
    
        tracker.send({
          title: document.title,
          url: location.href,
          timestamp: event.timeStamp,
          userAgent: navigator.userAgent,
          kind: 'stability',
          type: 'promiseError',
          errorMessage: message,
          filename,
          position: `${lineno}:${colno}`,
          stack: getStack(stack),
          selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : '',
        })
      }, true)
    

    接口异常上报

    接口异常上报主要是拦截请求,拦截 XMLHttpRequest 对象,改写 xhr 的open 和 send 方法,将需要上报的数据发到阿里云存储,监听 load,error,abort 事件,上报数据:

    // src/monitor/lib/xhr.js
    import tracker from '../utils/tracker'
    
    export default function injectXHR() {
      // 获取 window 上的 XMLHttpRequest 对象
      const XMLHttpRequest = window.XMLHttpRequest
      // 保存旧的open, send函数
      const prevOpen = XMLHttpRequest.prototype.open
      const prevSend = XMLHttpRequest.prototype.send
    
      // 不可使用箭头函数,不然会找不到 this 实例
      XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
        // 重写open,拦截请求
        // 不拦截 track 本身以及 socket, 直接放行
        if (!url.match(/logstores/) && !url.match(/sockjs/)) {
          this.logData = { method, url, async, username, password }
        }
        return prevOpen.apply(this, arguments)
      }
    
      XMLHttpRequest.prototype.send = function (body) {
        // 重写 send,拦截有 logData 的请求,获取 body 参数
        if (this.logData) {
          this.logData.body = body
          let startTime = Date.now()
          function handler(type) {
            return function (event) {
              // event: ProgressEvent
              let duration = Date.now() - startTime
              let status = this.status
              let statusText = this.statusText
              console.log(event)
    
              tracker.send({
                kind: 'stability',
                type: 'xhr',
                eventType: type,
                pathname: this.logData.url,
                status: `${status} ${statusText}`,
                duration: `${duration}`, // 接口响应时长
                response: this.response ? JSON.stringify(this.response) : '',
                params: body || '',
              })
            }
          }
          this.addEventListener('load', handler('load'), false)
          this.addEventListener('error', handler('error'), false)
          this.addEventListener('abort', handler('abort'), false)
        }
        
        return prevSend.apply(this, arguments)
      }
    }
    

    监控白屏

    白屏就是页面上什么东西也没有,在页面加载完成之后,如果页面上的空白点很多,就说明页面是白屏的,需要上报,这个上报的时机是:document.readyState === 'complete' 表示文档和所有的子资源已完成加载,表示load(window.addEventListener('load')状态事件即将被触发

    document.readyState 有三个值:loading(document正在加载),interactive(可交互,表示正在加载的状态结束,但是图像,样式和框架之类的子资源仍在加载),complete就是完成,所以监控白屏需要在文档都加载完成的情况下触发:

    // src/monitor/utils/onload.js
    export function onload(callback) {
      if (document.readyState === 'complete') {
        callback()
      } else {
        window.addEventListener('onload', callback)
      }
    }
    

    监控白屏的思路主要是:可以将可视区域中心点作为坐标轴的中心,在x,y轴上各分10个点,找出这个20个坐标点上最上层的 dom 元素,如过这些元素是包裹元素,空白点数就加一,包裹元素可以自定义比如 html body app root container content 等,空白点数大于0就上报白屏日志:

    // src/monitor/lib/blankScreen.js
    import onload from '../utils/onload'
    import tracker from '../utils/tracker'
    
    function getSelector(element) {
      var selector;
      if (element.id) {
          selector = `#${element.id}`;
      } else if (element.className && typeof element.className === 'string') {
          selector = '.' + element.className.split(' ').filter(function (item) { return !!item }).join('.');
      } else {
          selector = element.nodeName.toLowerCase();
      }
      return selector;
    }
    
    export default function blankScreen() {
      // 包裹玉元素列表
      const wrapperSelectors = ['body', 'html', '#container', '.content']
      // 空白节点的个数
      let emptyPoints = 0
      // 判断20个点处的元素是否是包裹元素
      function isWrapper(element) {
        const selector = getSelector(element)
        console.log(selector)
        if (wrapperSelectors.indexOf(selector) >= 0) { // 表示是在包裹元素里面,空白点就要加一
          emptyPoints++
        }
      }
      // 页面加载完成之后 走回调
      onload(function() {
        // 可以在页面中生成 X轴 Y轴 20个点,找出中心点(页面宽高的一半)下的 HTML 元素
        let xElements, yElements // 找出这些坐标点的 html 元素
        for (let i = 0; i <=9; i++) {
          // x轴的点(总宽 * i / 10, 高的一半)上饿元素
          xElements = document.elementFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
          // y轴点上的元素(宽的一半, 总高 * i / 10)
          yElements = document.elementFromPoint(window.innerHeight * i / 10, window.innerWidth / 2)
          // 看这20各点是不是包裹元素,可以定义包裹元素比如 root app container warp等
    
          // document.elementFromPoint 返回的是某一个坐标点的由到外的html元素的集合
          isWrapper(xElements[0]) // x轴上坐标点上的最上层的元素
          isWrapper(yElements[0]) // y轴上坐标点上的最上层的元素
        }
        console.log(emptyPoints)
        if (emptyPoints >= 0) {
          let centerPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
          console.log(centerPoint[0])
          // tracker.send()
        }
      })
    }
    

    监控卡顿

    用户交互的响应时间如果大于某一个时间,用户就会感觉卡顿。可以定一个时间比如100毫秒,就代表响应时间长,会卡顿。
    PerformanceObserver 构造函数使用给定的观察者 callback 生成新的PerformanceObserver 对象,当通过observe()方法注册条目类型(需要监控的类型)的性能条目被记录下来时,会调用该观察者回调。

    所以可以 new PerformanceObserver来监控 longTask,监控的资源加载如果超过100毫秒就表示卡顿,可以浏览器空闲(requestIdleCallback)的时候上报数据。

    // src/monitor/lib/longTask.js
    import getLastEvent from '../utils/getLastEvent' 
    import getSelector from '../utils/getSelector'
    import tracker from '../utils/tracker'
    
    export default function longTask() {
      new PerformanceObserver(function(list) {
        list.getEntries().forEach(function(entry) {
          if (entry.duration > 100) {
            let lastEvent = getLastEvent();
            // 浏览器空闲的时候上报
            requestIdleCallback(() => {
              tracker.send({
                kind: 'experience', // 大类
                type: 'longTask', // 小类
                eventType: lastEvent.type,
                startTime: formatTime(entry.startTime),// 开始时间
                duration: formatTime(entry.duration),// 持续时间
                selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : ''
              });
            });
          }
        })
      }).observe({ entryTypes: ['longtask']})
    }
    

    性能指标

    PerformanceObserver.observe 方法用于观察传入的参数中指定的性能条目类型的集合。当记录一个指定类型的性能条目时,性能监测对象的回调函数将会被调用。performance.timing 记录了从输入 url 到页面加载完成的所有的时间,从这些字段中可以提取对对页面性能的监控,通过分析这些指标来优化页面的体验,比如统计FMP,LCP等,具体可以查看 MDN。

    统计pv (页面的停留时间)

    navigator.connection 对象获取网络连接的信息:effectiveType(网络类型),rtt(估算饿往返时间)等,还能通过监听 window.addEventListener('unload')事件计算用户在页面的停留时间。

    import tracker from '../util/tracker';
    export function pv() {
        var connection = navigator.connection;
        tracker.send({
            kind: 'business',
            type: 'pv',
            effectiveType: connection.effectiveType,  // 网络类型
            rtt: connection.rtt, // 往返时间
            screen: `${window.screen.width}x${window.screen.height}` // 设备分辨率
        });
        let startTime = Date.now();
        window.addEventListener('unload', () => {
            let stayTime = Date.now() - startTime; // 页面停留时间
            tracker.send({
                kind: 'business',
                type: 'stayTime',
                stayTime
            });
        }, false);
    }
    

    总结

    前端监控是一个成熟业务线的标配,目前最多的场景是监控JS错误,接口请求和性能优化,然后根据日志信息进行分析分类的可视化展示,在发生异常的时候通知到相应的业务开发,监控的性能指标给页面的体验优化提供数据对比和优化的方向。

    参考:
    https://juejin.cn/post/6939703198739333127
    https://wicg.github.io/largest-contentful-paint/
    https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver/PerformanceObserver
    https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API
    https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceTiming
    https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator

    相关文章

      网友评论

          本文标题:前端监控概览

          本文链接:https://www.haomeiwen.com/subject/evzqcltx.html