前端系统监控

241 阅读7分钟

为什么要进行前端监控?

上线之后,代码报错,或者页面的卡顿或兼容性导致用户无法操作的偶然的异常,有时候都无法复现,尤其是移动端,因此需要设置前端监控。监控代码中的各种错误,监控卡顿的页面,监控用户的行为进行错误分析,从而不断优化代码增强用户体验,同时也是第一时间可以发现错误从而修复错误。

需要监听的内容

  • js代码错误
  • Promise的错误
  • 网络监控
  • 性能上报
  • 白屏数据上报
  • 用户行为上报

js代码错误

可以通过window.onerror或window.addEventListener('error',fn)来监听js运行时代码的错误

// html 部分
<h3 >213 <span onclick="spanBtn()">5555</span></h3>

// js部分
let lastEvent = ''
window.addEventListener('error', function (event) {
    const err = {
        kind: 'js', // 监控指标的大类
        type: 'error', // 小类
        errorType: 'jsError', // 错误类型
        url: location.href, // 报错路径
        message: event.message, // 错误信息
        filename: event.filename, // 报错文件
        position: `${event.lineNo}:${event.columnno}`, // 报错位置
        stack: event.error.stack, // 报错栈
        selector: lastEvent.ele, // 触发的元素
        userAgent: navigator.userAgent, // 用户设备信息
        title: document.title, // 标题
        eventType: lastEvent.type, // 事件类型
        lineno: event.lineno, // 报错行
        colno: event.colno, // 报错列
        time: new Date() // 报错时间
    }
    console.log(err, event)
    // 上传报错
    // 清除上次的报错内容
    lastEvent = ''
})

// 找到当前元素在父级元素下是第几个子元素
function getChildIndex (ele) {
  let childs = ele.parentNode.children
  let index = null
  for (let i = 0; i < childs.length; i ++) {
      if (childs[i] === ele) {
          index = i
          break
      }
  }
  return ele.tagName.toLocaleLowerCase() + `[${index}]`
}
// 获取操作的元素的路径
function getPath (target) {
  let curr = target
  let path = ''
  while(curr !== document){
      path = getChildIndex(curr) + '/' + path
      curr = curr.parentNode
  }
  return path.substring(0, path.length - 1)
}

// 监听所有的事件,获取到报错的具体事件
const arr = ['click','touchstart', 'mousedown', 'keydown', 'mouserover']
arr.forEach(eventType => {
    document.addEventListener(eventType, (event) => {
        // 获取报错的元素的路径
        let path = getPath(event.target)
        lastEvent = {
            ele: path,
            type: event.type // 报错的事件
        }
    },{
        capture: true, // 是否冒泡
        passive: true, // 是否阻止默认事件
    })
})

function spanBtn() {
    b
}

结果

image.png

  • 问题:window.onerror 和window.addEventListener('error',function(event){})的区别?

window.onerror

  1. 无法监听资源的加载报错
  2. 返回true可以阻止默认事件函数的执行,比如函数中具有console.log返回true就不会被执行否则会执行

window.addEventListener('error',function(event){})

  1. 可以监听资源加载的报错
  2. 不可以阻止默认事件函数的执行
  • 问题:为什么window.onerror无法监听资源的加载错误而window.addEventListener('error',function(event){})可以?
    资源的加载错误会触发自身的error事件,该事件不会向上冒泡,因此onerror无法监听到,而window.addEventListener('error',function(event){}, true)可以设置捕获和冒泡,设置捕获就可以捕获到这个错误事件

监听Promise的错误

promise中没有手动处理的错误,那么就会报错,因此务必使用try catch或catch或错误的回调函数来手动处理;
通过unhandledrejection来监听Promise的错误,既可以监听reject抛出的错误也可以监听Promise回调中代码的错误,通过event.reason来区分

// html 部分
<h3 >213 <span onclick="spanBtn()">5555</span></h3>

// js部分
let lastEvent = ''
window.addEventListener('unhandledrejection', (event) => {
  let filename = ''
  let line = 0
  let column = 0
  let stack = ''
  let reason = event.reason
  let errorType = 'PromiseError'
  if (typeof reason === 'string') { // 通过reject抛出的错误
    message = reason
    errorType = 'PromiseRejectError'
  } else if(typeof reason === 'object') { // 是promise回调函数代码的错误
    if (reason.stack) {
      let match = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
      filename = match[1]
      line = match[2]
      column = match[3]
    }
    message = reason.message
  }
  const err = {
    kind: 'js', // 监控指标的大类
    type: 'error', // 小类
    errorType, // 错误类型
    url: location.href, // 报错路径
    message: message, // 错误信息
    stack: reason.stack, // 报错栈
    filename: filename, // 报错文件
    lineno: line, // 报错行
    colno: column, // 报错列
    // stack: event.error.stack, // 报错栈
    selector: lastEvent.ele, // 触发的元素
    userAgent: navigator.userAgent, // 用户设备信息
    title: document.title, // 标题
    eventType: lastEvent.type, // 事件类型
  }
  console.log('err', err)
  // 错误上传
})

// 找到当前元素在父级元素下是第几个子元素
function getChildIndex (ele) {
      let childs = ele.parentNode.children
      let index = null
      for (let i = 0; i < childs.length; i ++) {
          if (childs[i] === ele) {
              index = i
              break
          }
      }
      return ele.tagName.toLocaleLowerCase() + `[${index}]`
  }
  // 获取操作的元素的路径
  function getPath (target) {
      let curr = target
      let path = ''
      while(curr !== document){
          path = getChildIndex(curr) + '/' + path
          curr = curr.parentNode
      }
      return path.substring(0, path.length - 1)
  }
// 监听所有的事件,获取到报错的具体事件
const arr = ['click']
arr.forEach(eventType => {
    document.addEventListener(eventType, (event) => {
        let path = getPath(event.target)
        lastEvent = {
            ele: path,
            type: event.type
        }
    },{
        capture: true, // 是否冒泡
        passive: true, // 是否阻止默认事件
    })
})

function spanBtn() {
    Promise.reject('66').catch(err => {  
        f // 报错
        console.log(err) })
}

结果: image.png

监控资源错误

图片,link,script等的资源加载错误;
通过window.addEventListener('error', function(){},true)捕获资源的报错

// html
<img src="./88.png" alt="" >
// js
window.addEventListener('error', function (event, e) {
  // 资源加载错误
  if (event.target && (event.target.src || event.target.href)) {
    const err = {
      kind: 'js', // 监控指标的大类
      type: 'error', // 小类
      errorType: 'resourceError', // 错误类型
      url: location.href, // 报错路径
      message: '图片引入错误', // 错误信息
      src: event.target.src || event.target.href, // 报错文件
      lineno: event.lineno, // 报错行
      colno: event.columnno, // 报错列
      // stack: event.error.stack, // 报错栈
      selector: getPath(event.target), // 触发的元素
      userAgent: navigator.userAgent, // 用户设备信息
      title: document.title, // 标题
      eventType: lastEvent, // 事件类型
    }
    console.log('err1', err)
    // 上传
    // 清空事件类型
    lastEvent = ''
  }
}, true)

结果:

image.png

网络监控

可以监控所有网络请求,也可只监听错误的请求,可以通过在请求拦截器进行存储需要的数据,在响应拦截器进行上报

// html
<h3 >213 <span onclick="spanBtn()">5555</span></h3>
// js
function xhttp () {
    let XMLHttpRequest = window.XMLHttpRequest
    let oldOpen = XMLHttpRequest.prototype.open 
    // 重写open方法进行请求前的拦截
    XMLHttpRequest.prototype.open = function (method, url, async) {
        if (url.indexOf('/logData')) { // 去除发送日志的接口 否则死循环
            // 存储想要的数据
            this.logData = { method, url, async }
        }
        return oldOpen.apply(this, arguments)
    }

    let oldSend = XMLHttpRequest.prototype.send
    // 重写send方法进行,监听load,error和abort事件进行上报
    XMLHttpRequest.prototype.send = function (body) {
        if (this.logData) { // 如果有数据就进行监听
            let startTime = Date.now()
            let handler = (type) => (event) => {
                // 可以根据状态码进行过滤只监听错误的请求
                let duartion = Date.now() - startTime // 请求到响应的时长
                let status = this.status // 状态码
                let statusText = this.statusText
                let obj = {
                    kind: 'js', // 监控指标的大类
                    type: 'error', // 小类
                    errorType: 'xhrError', // 错误类型
                    url: location.href, // 当前页面路径
                    path: this.logData.url, // 请求路径
                    method: this.logData.method, // 请求方法
                    async: this.logData.async, // 是否异步
                    data: body || '', // 参数
                    duartion, // 持续时间
                    time: Date.now(), // 时间
                    response: this.response ? JSON.stringify(this.response) : '', // 响应内容
                    status,
                    statusText,
                    userAgent: navigator.userAgent, // 用户设备信息
                }
                // 上传
                console.log('obj', obj)
            }
            this.addEventListener('load', handler('load'), false)
            this.addEventListener('error', handler('error'), false)
            this.addEventListener('abort', handler('abort'), false)
        }
        return oldSend.apply(this, arguments)
    }
}
xhttp()
function spanBtn() {
    const xhttp = new XMLHttpRequest()
    xhttp.open('post', 'a.txt', true)
    xhttp.onreadystatechange = function(e){ }
    xhttp.send({a:1})
}

结果:

image.png

监控白屏

  • 方法一:获取指定的元素来判断
    如果在页面加载完成之后此元素不存在则处于白屏状态
// html
<body>
    <div id="app"></div>
</body>
// js
function blankScreen (selector) {
  window.addEventListener('load', callBack)
  const callBack = () => {
    const element = document.querySelector(selector)
    if (!element) {
      const obj = {
        kind: 'js', // 监控指标的大类
        type: 'error', // 小类
        errorType: 'BlankScreenError', // 错误类型
        url: location.href, // 报错路径
        time: new Date(), // 时间
        userAgent: navigator.userAgent, // 用户设备信息
      }
      // 上传数据
    }
  }
}
blankScreen ('#app')
  • 方法二:获取页面上多个指定点的元素,如果这些元素都是body等元素,那么就是处于白屏状态
// eg: 获取18个点上的元素,如果这些元素都是html,那么一定是白屏,否则就不是白屏
function blankScreen (eleArr) {
  // 统计元素出现的次数
  let count = 0
  // 获取屏幕中18个点的元素
  for (let i = 1; i <= 9; i ++) {
    // 横坐标上的元素
    let xEle = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
    // 纵坐标上的元素
    let yEle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10)
    isWrapper(xEle[0])
    isWrapper(yEle[0])
  }
  // 如果指定元素出现了16次以上表示当前页面就这么一个元素,已经是白屏了
  if (count > 16) {
    const obj = {
      kind: 'js', // 监控指标的大类
      type: 'error', // 小类
      errorType: 'BlankScreenError', // 错误类型
      url: location.href, // 报错路径
      userAgent: navigator.userAgent, // 用户设备信息
      screen: window.innerWidth  + '*' + window.innerHeight,
      time: new Date(),

    }
    // 上传数据
  }

  function isWrapper (element) {
    let selector = getSelector(element)
    // 判断当前元素是否包含在指定元素中,如果包含就记录次数
    if (eleArr.indexOf(selector) !== -1) {
      count++
    }
  }
  // 获取每个元素身上的id或class或元素本身
  function getSelector (element) {
    if (element.id) {
      return '#' + element.id
    } else if (element.className) {
      return '.' + element.className.split(' ').filter(item => !!item).join('.')
    } else {
      return element.nodeName.toLowerCase()
    }
  }
}
window.onload = function () {
    const eleArr = ['html', 'body']
    blankScreen(eleArr)
}

如果是白屏,那么body下面应该没有元素或者只有一两个元素,那么页面上的18个位置的元素应该都是body,因此通过这个原理可以判断白屏
image.png

性能监控

通过performance.timing获取相关的性能数据
performance的文档 developer.mozilla.org/zh-CN/docs/…
常用性能名词:
- 首次绘制时间(  FP   :即 First Paint,为首次渲染的时间点。
- 首次内容绘制时间(  FCP   :即 First Contentful Paint,为首次有内容渲染的时间点;
- 首次有效绘制时间(  FMP   :用户启动页面加载与页面呈现首屏之间的时间;
- 首次交互时间(  FID   :即 First Input Delay,记录页面加载阶段,用户首次交互操作的延时时间;
- 完全可交互时间(TTI):即 Time to interactive,记录从页面加载开始,到页面处于完全可交互状态所花费的时间;

function performanceLog() {
    const {
        connectStart, // 开始连接时间
        connectEnd, // 链接结束时间
        requestStart, // 请求开始时间
        responseStart, // 响应开始时间
        responseEnd, // 响应结束时间
        domLoading, // dom加载时间
        domInteractive,
        domContentLoadedEventStart, // 内容加载时间
        domContentLoadedEventEnd, // 内容加载完毕时间
        fetchStart, // 开始请求时间
        loadEventStart,
        domainLookupEnd, // DNS结束解析
        domainLookupStart, // DNS开始解析
        secureConnectionStart, // ssL开始链接
    } = performance.timing


    let FMP = ''
    let LCP = ''
    // 监听页面中有意义的元素开始加载的时间, 需要给元素添加elementtiming:meaningful属性,这个元素才有意义,才能被监听到
    new PerformanceObserver((entryList, observer) => {
        let perfEntries = entryList.getEntries()
        FMP = perfEntries[0] // 获取开始时间
        observer.disconnect() // 结束观察
    }).observe({
        entryTypes: ['element']
    })

    // 监听页面中最大元素的绘制时间
    new PerformanceObserver((entryList, observer) => {
        let perfEntries = entryList.getEntries()
        LCP = perfEntries[0] // 获取开始时间
        observer.disconnect() // 结束观察
    }).observe({
        entryTypes: ['largest-contentful-paint']
    })

    const obj = {
        kind: 'js', // 监控指标的大类
        type: 'performanceLog', // 小类
        url: location.href, // 报错路径
        userAgent: navigator.userAgent, // 用户设备信息
        TCPconnectTime: connectEnd - connectStart, // TCP链接时间
        ttfbTime: responseStart - requestStart, // 响应时间
        responseTime: responseEnd - responseStart, // 响应的读取时间
        parseDomtime: loadEventStart - domLoading, // dom解析时间
        domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,
        TTI: domInteractive - fetchStart, // 可交互时间
        dnsTime: domainLookupEnd - domainLookupStart, // DNS解析时间
        SSLtime: connectEnd - secureConnectionStart, // ssL链接时间
        FMP, // 首次有意义元素的加载时间
        LCP, // 最大内容加载时间
        FPS: performance.getEntriesByName('first-paint')[0], // 页面首次绘制时间
        FCP: performance.getEntriesByName('first-contentful-paint')[0], // 页面内容首次绘制的时间
    }
    console.log('obj1', obj)
    // 发送请求

    // 监听页面中用户首次操作的时间
    new PerformanceObserver((entryList, observer) => {
        let perfEntries = entryList.getEntries()
        let firstInput = perfEntries[0] // 获取开始时间
        if (firstInput) {
            let inputDelay = firstInput.processingStart - firstInput.startTime
            let duration = firstInput.duration
            if (inputDelay > 0 || duartion > 0) {
                const obj = {
                    kind: 'js', // 监控指标的大类
                    type: 'firstInputperformance', // 小类
                    url: location.href, // 报错路径
                    userAgent: navigator.userAgent, // 用户设备信息
                    FID: inputDelay, // 延迟时间
                    duration, // 处理时间
                    FIS: firstInput.startTime, // 触发时间
                    selector: lastEvent, // 操作的元素
                }
                console.log('obj2', obj)
                // 发送请求
            }
        }
        observer.disconnect() // 结束观察
    }).observe({
        type: 'first-input',
        buffered: true
    })
}
setTimeout(() => {
    performanceLog()
},3000)

image.png

用户行为上报

通过监听click等页面中常用的事件,从而搜集用户的行为

// html
<h3 >213 <span onclick="spanBtn()">5555</span></h3>
// js
 function listenUser () {
    // 找到当前元素在父级元素下是第几个子元素
    function getChildIndex (ele) {
        let childs = ele.parentNode.children
        let index = null
        debugger
        for (let i = 0; i < childs.length; i ++) {
            if (childs[i] === ele) {
                index = i
                break
            }
        }
        return ele.tagName.toLocaleLowerCase() + `[${index}]`
    }
    // 获取操作的元素的路径
    function getPath (target) {
        let curr = target
        let path = ''
        while(curr !== document){
            path = getChildIndex(curr) + '/' + path
            curr = curr.parentNode
        }
        return path.substring(0, path.length - 1)
    }

    const handler = type => event => {
        // 可以根据当前元素身上的属性进行过滤要监听的元素,并不一定所有的元素都要被监听到
        let target = event.target
        if (target === document.body.parentNode) {
            return
        }
        let path = getPath(target)
        const obj = {
            kind: 'js', // 监控指标的大类
            type: 'userPath', // 小类
            url: location.href, // 报错路径
            userAgent: navigator.userAgent, // 用户设备信息
            path, // 触发元素路径
            time: new Date(), // 触发时间
            eventType: type,
        }
        console.log('obj', obj)
        // 上传
    }

    // 监听事件功能
    let events = ['click']
    events.forEach(type => {
        document.addEventListener(type,handler(type),false)
    })
    }
    listenUser()
    function spanBtn () {

    }

结果:

image.png

vue错误上报

vue errorHandler
原理也是使用try catch封装了nextTick,$emit, watch,data等

上传监听到的数据

  1. 通过ajax的post请求
  2. 通过创建图片,把数据传递到后端 new Image('https:xxx.a.png?data=data')
    通过web worker启动一个线程去发送请求,不影响主线程的执行

成熟的第三方开源框架sentry

sentry把所有的回调函数使用try catch封装一层

反编译

项目往往通过打包工具进行打包编译并且压缩之后才放到服务器,因此此时的代码都是编译压缩之后的代码,报错展示的行和列是无法阅读的,因此需要反编译进行还原代码;
source-map插件
打包编译之后的包中一定要.map.js文件