比较全面的"前端监控" - 包括用户行为,报错,项目性能

28,400 阅读13分钟

背景描述

  1. 线上项目报错. 可是具体是什么问题不知道, 第一想到的是让前端去查看,查看是不是前端代码报错了. 传统项目还好,哪里报错点击就能看到源码,可是单页面项目线上代码一般是压缩后的,具体代码报错位置无法捕捉到. 即使开启了sourceMap,用户那边报错也看不到对方的控制台.
  2. 后端监控接口不够全面.后端都是靠查看小黑窗查看报错信息之类的. 不够直观. 甚至一些接口请求很慢只能靠肉眼去观察,不知道还以为是前端代码冗余造成的
  3. 有几个页面使用的是笨重的纯手动埋点,哪个环节要埋点就加一段请求接口的代码. 如果上线后想添加新的埋点,就要写好代码之后重新部署上线,灵活性很差

需求

  1. 传统的埋点上报太笨重,要可配置,过滤掉无意义的上报
  2. 使用rollup来开发纯js的npm包,推送到npm 这个npm插件是要兼容多个不同的项目的,传统的html项目,Vue,React
  3. 用户行为监控 - 根据接口,按钮,页面停留记录用户行为,用以之后统计数据作参考
  4. 错误监控 - 捕捉错误信息. 而且打开公司的后台管理系统就能看到图形化的所有具体的报错信息, 根据具体信息就 可以快速的捕捉和解决Bug,工作效率就可以大大的提高
  5. 性能监控 - 查看项目的首次加载时间等各项性能指标

技术难点和遇到的问题

一.如果你第一次开发npm包就会遇到这个问题. 本地开发npm包的时候,就是还没有打包前的npm包项目,如何拿它和其他项目进行测试

npm link  // 创建软连接,创建好之后在d:\Program Files\nodejs\node_modules\tracking文件里可以看到
npm unlink  // 去掉软连接
npm link 包名  // 在项目里使用上一步创建的软连接
npm unlink 包名  // 删除引用的软连接// main.js里引入包名
import track from 'track'

二.用户行为监控

1.接口请求
XMLHttpRequest和fetch请求是没有请求拦截的, axios是基于XMLHttpRequest开发的. 所以只能对XMLHttpRequest和fetch的原型方法进行二次封装

    // 发送XHL请求
    const oldSend = XMLHttpRequest.prototype.send
    XMLHttpRequest.prototype.send = function (body) {
        const requestBeginTime = new Date().getTime()
        const content = JSON.stringify({ body, ...this[requestObj] })
        const requestCallback = () => {
            const elseObj = { status: this.status, body, ...this[requestObj] }
            loadQuest(requestBeginTime, elseObj, content)
            this.removeEventListener('load', requestCallback)
            this.removeEventListener('error', requestCallback)
        }
        this.addEventListener('load', requestCallback)
        this.addEventListener('error', requestCallback)
        oldSend.apply(this, arguments)
    }
    // 发送fetch请求,Promise二次封装
    const oldFetch = window.fetch
    window.fetch = (url, options) => {
        const requestBeginTime = new Date().getTime()
        const elseObj = { method: !options || !options.method ? 'GET' : 'POST' }
        const content = JSON.stringify({ url, ...options })
        const fetchRequestCallback = (resObj) => {
            Object.assign(elseObj, { status: resObj.status, url })
            loadQuest(requestBeginTime, elseObj, content)
        }
        return new Promise((resolve, reject) => {
            oldFetch(url, options).then((res) => {
                resolve(res)
                fetchRequestCallback(res)
            }).catch((err) => {
                reject(err)
                fetchRequestCallback(err)
            })
        })
    }

2.页面停留时间
页面跳转有两种, 多页面项目的跳转, 单页面项目hash和history两种不同模式的路由跳转.
先说路由,网上很多文章都说hash路由就用hashchange监听,可是我们目前会用到路由的都是单页面框架,例如Vue的Vue-Router和React的React-Router. 这两个插件在原有的路由跳转上都是做了第二次封装的, hashchange这时候就显的很多余

我测试了一下,Vue和React的hash路由和history路由:
Vue-Router
hash:
路由跳转的时候 - haschange和popstate都不触发, pushState或者replaceState会被触发
点击回退或者前进按钮 - haschange和popstate都会触发
history:
点击路由跳转的时候 - 触发pushState或者replaceState
点击浏览器自带的回退或者前进按钮 - 触发popstate
所以, pushState + replaceState + popstate 可以同时监听hash和history两种模式

React-Router
hash:
路由跳转的时候 - haschange和popstate都触发, pushState和replaceState都不触发
点击回退和前进按钮 - haschange和popstate都触发
history:
点击路由跳转的时候 - 触发pushState或者replaceState
点击浏览器自带的回退或者前进按钮 - 触发popstate
所以,监听React复杂一点,要看它是什么模式
hash:popstate就够了 history:pushState + replaceState + popstate

总结.hashchange用不上. 都监听 pushState + replaceState + popstate 这三个就可以了. 毕竟我监听了你不触发,就绝对没影响

html销毁的时候会触发beforeunload,再监听这个事件就相当于兼容了传统多页面项目的跳转

    const oldPushState = window.history.pushState
    window.history.pushState = function () {
        oldPushState.apply(this, arguments)
        trackPageStaySend()
    }
    const oldReplaceState = history.replaceState
    window.history.replaceState = function () {
        oldReplaceState.apply(this, arguments)
        trackPageStaySend()
    }
    // beforeunload: html页面销毁阶段,点击刷新按钮或者跳转到一个新项目会触发
    // popstate: back、go和forward触发这个事件
    const routerEvents = ['beforeunload', 'popstate']
    for (const item of routerEvents) {
        window.addEventListener(item, () => {
            trackPageStaySend()
        })
    }

3.点击的按钮或者盒子
直接监听点击事件只能获取到点击的那个元素而已的.
例如点击一个按钮组件是多层盒子的,我们需要注册了点击事件的那个大盒子,可是却获取到了盒子里的子盒子. 这个问题纠结了很久,最后我是这样写的,对添加事件监听这个方法进行二次封装,你用哪个dom添加事件监听的时候我获取你用来注册的那个dom
事件冒泡误会.png

 let oldAddEventListener = HTMLElement.prototype.addEventListener
 HTMLElement.prototype.addEventListener = function () {
    if (arguments[0] === 'click' && this.localName !== 'html') {
       let oldFunc = arguments[1]
       arguments[1] = function () {
         // 这里的获取到的this就是注册了点击事件的那个盒子
         oldFunc.apply(this, arguments)
       }

    }
    oldAddEventListener.apply(this, arguments)
 }

三.错误监控

1.不同的错误要使用不同的方法捕捉

// 1.js执行错误,第三个参数设置为false或者为true都可以捕捉到
window.addEventListener('error', (e) => {
    console.log(e, 'js执行错误')
},false)
​
// 2.资源加载错误,第三个参数设置为true才可以捕捉的到,所以为了避免重复要区分两个来捕捉
window.addEventListener('error', (e) => {
    // 过滤js的error 
    let target = e.target
    let isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement;
    if (!isElementTarget) return false;
    // 上报资源地址 
    let url = target.src || target.href;
    console.log(url, '资源加载错误')
}, true)
​
// 3.未处理的promise错误,注意是unhandledrejection,不要写错了
window.addEventListener('unhandledrejection', e => {
    console.log(e, '未处理的promise错误')
})

2.如果要兼容到Vue,那只监听error事件是捕捉不到,因为Vue框架的报错是Vue拦截了再在控制台展示出来的. 所以只能对Vue提供的api进行二次封装. 而且,报错还无法直接获取到所在位置,只能使用error-stack-parser将它转成一个对象再获取报错代码所在的文件名,行,列 Snipaste_2023-05-05_02-15-38.png

Snipaste_2023-05-05_02-21-55.png

// 4.监听Vue特有的错误处理
export function trackVueError(Vue, global) {
  if(!global.options.trackError){ return }
  const beforeErrorHandler = Vue.config.errorHandler
  Vue.config.errorHandler = function (e) {
    console.error(e)
    const errorTrack = ErrorStackParser.parse(e)[0]  // Vue返回的记录是看不到第几行代码的,只能依赖第三方包error-stack-parser
    const { fileName, columnNumber, lineNumber } = errorTrack
    global.sendDataBefore([], 'error', { errorType: 'VueError', fileName, columnNumber, lineNumber, message: String(e) })  // 处理文件上报
    if(beforeErrorHandler) beforeErrorHandler.apply(this,arguments)
  }
}

3.你将发生错误的文件名,行,列都发给后端,可是最终前端要成功回显错误代码要一系列的流程.不同类型的项目流程也不一样. 我绘制了一个被监控的项目,后端,前端回显这三者的流程交互图 数据监控.png 4.项目打包完成后处理项目的文件.不同的打包工具的钩子写法都不一样. 我用的是一个通用的写法,在package.json里边设置执行命令

// package.json
    "postbuild": "node handelSourceMap.js",
​
// handelSourceMap.js. 如果用的Vite,后缀就是.cjs
const fs = require('fs');
const path = require('path')
const axios = require('axios')
​
let jsPath = path.join(__dirname, 'dist','static','js')  // 具体路径就看你的.map文件具体位置了const pathArr = []
fs.readdir(jsPath,function(err,files){
  if(!err){
    const fd = {}
    for (const item of files) {
      if(item.endsWith('.map')){
        pathArr.push({name:item,path:path.join(jsPath, item)})
      }
    }
    axios.post('http://localhost:8080/setSourceMap',{data:{pathArr}}).then((res)=>{
      if(res.data.result === 1){
        console.log(res.data.result,'res.data.result')
        for (const item of pathArr) {
          fs.unlinkSync(item.path);  //直接删除文件
        }
      }
    }).catch((err)=>{
      console.log(err)
    })
  }else{
    console.log(err)
  }
})

5.要根据sourceMap文件获取到报错所在的源码文件名,源码所在行,需要使用到source-map-js, 它会根据打包后文件文件报错所在的行列+sourceMap,计算出报错所在源文件位置,就是经常说的源码映射
错误对象: Snipaste_2023-05-05_11-46-05.png
报错文件对应的sourceMap文件内容:
Snipaste_2023-05-05_11-48-06.png 使用source-map-js得到的准确的错误所在的源文件信息: Snipaste_2023-05-05_11-48-27.png

    const resA = await getSourceMap({ fileName,entryName }).catch(()=>{})
    reader.readAsText(resA)
    reader.onload = async function () {
      const data = JSON.parse(reader.result)
      if(!data.result){
        // sources:有关联的所有页面url
        // sourcesContent:所有页面的内容
        const { sources,sourcesContent } = data
        console.log(sources,sourcesContent,'sources,sourcesContent')
        try {
          let consumer = await new sourceMap.SourceMapConsumer(data)
          const res = consumer.originalPositionFor({line: Number(lineNumber),column: Number(columnNumber)})
          errorObj.value.pagePath = res.source.replace('webpack:///', '')
          const index = sources.indexOf(res.source)  
          let pageContent = sourcesContent[index]
          // pageContent:源码页面内容  res.source:源码文件名  res.line:源码报错所在行  
        } catch (error) {
          ElMessage({message: '源码解析失败',type: 'warning'})
        }
      }else{
        analysisTraditionPage(fileName,lineNumber)
      }
    }

四.性能监控

1.白屏检测
页面加载完成 !== 页面结束了白屏,很多因素都会造成白屏,例如Vue-Router的导航守卫里代码报错,也会造成白屏,即使你页面加载完成了也没用
检测是不是白屏的思路就是,使用document.elementFromPoint()轮询获取坐标点下的元素.如果各个采样点获取到的都是whiteBoxElements数组里的有的,说明只有容器节点.只有容器节点没有内容节点,那就说明是空白页,毕竟html里的盒子都是盒子包着盒子的,例如body里的盒子永远都是被html>body包裹着的

 whiteBoxElements:['html','body','#app'],  // 纯白屏容的时候才会出现的容器盒子.前两个标签是绝对有的,第三个就看自己项目的盒子属性了,一般Vue项目body下边都是<div id="app"></div>

下图中html,body,#app都是容器节点,而.page是内容节点,如果不知道自己项目白屏的时候剩下那些容器,可以把自己的项目调整成白屏点击查看html Snipaste_2023-05-08_22-22-05.png 根据X轴和Y轴来询获取元素,下图中黑色的小点就是获取元素的坐标点,之前网上说如果元素很小可能会出现误差,所以我加大了采集密度 Snipaste_2023-05-08_22-22-052.jpg ① 轮询单位横竖各20个,500毫秒轮询一次,还是白屏就继续轮询
② 轮询获取到的所有元素不是数组里的容器节点了,那说明白屏结束了,准备上报
③ 最大白屏时长为1分钟,一直轮询超过一分钟还白屏说明没救了,准备上报

  const global = this
  const oldNowTime = new Date().getTime()
  let emptyPoints = 0  // 空白的节点个数
  let whiteLoop = null  // 轮询的计时器
  window.addEventListener('load', () => {
    // 检测白屏的思路就是,如果各个采样点获取到的都是whiteBoxElements数组里的有的,说明只有容器节点.只有容器节点没有内容节点,那就说明是空白页
    sampling()
  })
  // 查看是不是白屏
  function sampling(){
    for (let i = 0; i < 20; i++) {
        const xElement = document.elementFromPoint((window.innerWidth/20)*i,window.innerHeight/2)
        const yElement = document.elementFromPoint(window.innerWidth/2,(window.innerHeight/20)*i)
        if(isContainer(xElement)){ emptyPoints++ }
        if(isContainer(yElement)){ emptyPoints++ }
    }
    const intervalTime =  new Date().getTime() - oldNowTime
    if(emptyPoints === 40){
        // 都是容器
        emptyPoints = 0
        openWhiteLoop()
        if(intervalTime > 10000){
            // 白屏时间超过一分钟
            endWhiteLoop(intervalTime)
        }
    }else{
        // 没有都是容器
        endWhiteLoop(intervalTime)
    }
  }
  // 获取采样点是否为容器节点
  function isContainer(element){
    const selector = getSelector(element)
      for (const item of global.options.whiteBoxElements) {
        if(~selector.indexOf(item)){
            return true
        }
      }
      return false
  }
  // 获取dom的名称
  function getSelector(element){
    if (element.id) {
      return '#' + element.id
    } else if (element.className) {
      // div home => .div.home
      return ('.' + element.className.split(' ').filter(item => item).join('.'))
    } else {
      return element.nodeName.toLowerCase()  // DIV转成小写
    }
  }
  // 结束轮询
  function endWhiteLoop(intervalTime){
    clearInterval(whiteLoop)
    whiteLoop = null
    // 白屏都结束了,就一同获取所有性能参数,同时将白屏总时长放到这个对象里
      
  }
  // 开启轮询查看是不是白屏
  function openWhiteLoop(){
      if(whiteLoop){ return }
      whiteLoop = setInterval(function () {
        sampling()
      }, 500)
  }

2.获取项目的各项性能指标,使用performance.getEntries()获取项目的所有性能信息.然后进行计算即可,不懂的可以参考下边这张图 页面加载流程.png ① 要在页面加载完成,白屏结束之后,再调用这个api,否则会出现时长为0的情况
② startTime有时候会比responseStart延迟一些,所以两个计算有时候会出现负数,最终要转正整数,转成正整数就是两个属性值的间隔时间

const entries = performance.getEntries()
    for (const item of entries) {
      if (item.entryType === 'navigation') {
        const obj = {
          DNS: item.domainLookupEnd - item.domainLookupStart,  // DNS查询耗时
          TCP: item.connectEnd - item.connectStart,  // TCP链接耗时
          SSL: item.connectEnd - item.secureConnectionStart,  // SSL安全连接耗时
          request: item.responseEnd - item.responseStart,  // 请求耗时
          domTree: item.domComplete - item.domInteractive,  // 解析dom树耗时
          firstRender: Math.abs(item.responseStart - item.startTime),  // 首次渲染时间.可能会出现开始时间很久,所以要转成正整数
          domready: item.domContentLoadedEventEnd - item.startTime,  // domready时间
          duration: item.duration  // onload时间(总下载时间)
        }
        for (const key in obj) { obj[key] = Math.ceil(obj[key]) }  // 所有值都向上取整
        obj.name = item.name
        obj.type = item.entryType
        obj.whiteScreenTime = intervalTime  // 白屏总时长
        global.sendDataBefore([], 'navigationTime', obj)
      } else if (item.entryType === 'resource') {
        const obj = {
          duration: item.duration,  // 整个过程时间
          DNS: item.domainLookupEnd - item.domainLookupStart,  // DNS查询时间
          TCP: item.connectEnd - item.connectStart,  // TCP三次握手时间(HTTP)
          SSL: item.connectEnd - item.secureConnectionStart,  //  SSL握手时间(HTTPS协议会有SSL握手)
          TTFB: Math.abs(item.responseStart - item.startTime),  // TTFB(首包时间).可能会出现开始时间很久,所以要转成正整数
          response: item.responseEnd - item.responseStart  // 响应时间(剩余包时间)
        }
        for (const key in obj) { obj[key] = Math.ceil(obj[key]) }  // 所有值都向上取整
        obj.name = item.name
        obj.type = item.entryType
        global.sendDataBefore([], 'resourceTime', obj)
      }
    }

五.数据上报

1.网上很多都说用上边两种上报,图片上报或者sendbecaon,其实这两种都是有限制的
①图片url上报是get请求,url最后的总长度有限制,具体就看什么浏览器了.IE:2083, firefox:65532, chrome:8182. 预防万一只能看最低的,所以就当限制为2083
②sendbecaon上报是post请求,请求数据类型是formData, 它也是有限制的,请求的数据大小上限为64kb, 你一旦超过,请求直接无法发送出去

// img:
let data = new URLSearchParams(sendData).toString()
new Image().src = `http://localhost:8080/sendData?${data}`
// sendBeacon:
const fd = new FormData()
fd.append('data', JSON.stringify(sendData))
navigator.sendBeacon('http://localhost:8080/sendData', fd)

2.我一开始报错还没有添加录屏功能,用的是图片url上报,够用. 可是添加录屏功能之后就瞬间不够了, 因为压缩之后字符串长度随便都是好几千. 后来就想用sendbecaon,一测试,请求数据大小已经严重超过了64kb. 最后我是选择使用fetch请求上报

 const fd = new FormData()
 for (let key in sendData) {
   fd.append(key, sendData[key])
 }
fetch("http://localhost:8080/sendData", { method: 'post', body: fd }).then(function (response) {
      if (response.status >= 400) { throw new Error("Bad response from server") }
      return response.text();
    }).then(function (data) {
      console.log(data)
    }).catch(function (err) {
      console.log(err)
    });

3.还有.post发送请求数据的内容总长度后端接收其实也是有限制的,前端可以发送过去,后端接收的时候直接报内容过大. 我一开始传的是json对象,后来为了解决这个问题. 就将视频那个字段的值进行压缩之后添加到一个Blob对象里,相当于将它添加到一个文件里,然后将这个文件发送给后端,这样后端就可以正常接收了

    // 压缩
    utoa(data) {
        // 将字符串转成Uint8Array
        const buffer = strToU8(data)
        // 以最大的压缩级别进行压缩,返回的zipped也是一个Uint8Array
        const zipped = zlibSync(buffer, { level: 9 })
        // 将Uint8Array重新转换成二进制字符串
        const binary = strFromU8(zipped, true)
        // 转成Blob对象方便传给后端保存
        const blob = new Blob([binary], { type: "application/zip" })
        return blob
    }

最终效果

1.用户行为监控.有单个埋点和链路埋点,为了预防高频率上报,都要进行严格的配置

screenshots.gif

// a页面
  // html
  <el-button id="track">测试点击按钮</el-button>
  <el-button @click="clickBtnJsA">测试接口请求</el-button>
  <el-button @click="clickBtnJsB" id="track">测试页面跳转按钮</el-button>
  // js
  clickBtnJsA(){
    request.get('http://localhost:8080/test1').then((res)=>{
      console.log(res,'获取完成')
    })
  },
  clickBtnJsB(){
    this.$router.push({name:'LoginSub'})
  }

// b页面
<el-button @click="clickBtnJsC" id="track">点击测试按钮到请求完成的时长</el-button>
  // js
  clickBtnJsC(){
    request.get('http://localhost:8080/test2').then(()=>{
      console.log('获取完成')
    })
   }


chain: [
    // 这部分是单个埋点
    // 监听点击事件
    {
      type: 'clickEvent',
      rule: {
        // urls: [],  // 触发的页面地址,非必填,空数组就是所有页面,选填
        selectorId: 'track'  // 必填.标签上的id属性,例如<div id="track"></div>
      },
      // 埋点触发的回调,trackData:返回的埋点数据,send:去对接上报接口方法,可以提前按自己需求过滤数据
      handler: function (trackData, send) {
        send(trackData)
      }
    },
    // 监听页面停留
    {
      type: 'pageStayTime',
      rule: {
        urls: []  // 选填
      },
      handler: function (trackData, send) {
        send(trackData)
      }
    },
    //  监听请求
    {
      type: 'requestTime',
      rule: {
        urls: ['http://localhost:8080/test1']  // 接口请求url,必填
      },
      handler: function (trackData, send) {
        send(trackData)
      }
    },
    // 下边部分是同时埋点多个,链式埋点:clickEvent,requestTime,这两者串联执行
    // chain数组里有很多条链路
    // 链路埋点除了最后一个点上报,在这之前就只做逻辑处理,要想上报就去单次埋点里上报..不要逻辑搞乱了
    {
      status: {
        beginTime: 0,  // 链路开始时间
      },
      pointList: [
        {
          type: 'clickEvent',
          rule: {
            urls: ['http://localhost:9001/#/loginSub'],
            selectorId: 'track'
          },
          handler: function (trackData, send) {
            console.log('链路埋点1')
            this.status.beginTime = trackData.currentTime
          }
        },
        {
          type: 'requestTime',
          rule: {
            urls: ['http://localhost:8080/test2']
          },
          handler: function (trackData, send) {
            console.log('链路埋点2')
            console.log(this.status.beginTime, 'this.status.beginTime--------------')
            // 你有开始时间才会上报你,没有开始时间你就不是从上边的点过来的
            if (this.status.beginTime) {
              trackData.describe = new Date() - this.status.beginTime // 总时长
              trackData.type = 'clickToRequest_time'
              send(trackData, '点击按钮到保存接口完成')
            }
          }
        }
      ]
    }
  ],
2.多种类型项目的错误监控. 包含查看源码和回看录屏

screenshots.gif

  trackError: true,  // 监控错误
  openErrorRecord: true,  // 开启错误录屏,更有利于捕捉错误
3.性能监控. 包含各项性能指标和项目实际的白屏时长

Snipaste_2023-05-10_03-40-40.jpg

  trackPerformance: true,  // 监控项目性能,开启了之后也会白屏检测会自动调用下边的whiteBoxElements属性
  whiteBoxElements:['html','body','#app'],  // 纯白屏容的时候才会出现的容器盒子.前两个标签是绝对有的,第三个就看自己项目的盒子属性了,一般Vue项目body下边都是<div id="app"></div>

npm地址: www.npmjs.com/package/tra…
也可直接查看项目: 项目源码