前端监控原理及实践


         随着产品的用户数量的不断增长,对于站点体验衡量的的需求也日益紧迫,用户会将产品和他们每天使用的体验最好的 Web 站点进行比较。想着手优化,则必须先有相关的监控数据,才能对症下药。

性能是留住用户的关键。 大量的研究报告已经表明了性能和商业成绩的关系,糟糕的性能会让您的站点损失用户数、转化率和口碑。错误监控则能够让开发者第一时间发现并修复问题单靠用户遇到问题并反馈是不现实的,当用户遇到白屏或者接口错误时,更多的人可能会重试几次、失去耐心然后直接关掉您的网站。

为什么要做前端监控

  1. 更快的发现问题和解决问题
  2. 做产品的决策依据
  3. 为业务扩展提供更多的可能性
  4. 根据指标优化产品

监控哪些东西?

稳定性(报错相关):

错误名称 说明
js错误 js执行错误,promise异常
资源异常 script,link,img,css等资源的加载异常
接口错误 ajax,fetch请求接口异常
白屏 页面空白

用户体验(性能相关):

性能指标名称 说明
加载时间 各个阶段的加载时间
TTFB(time to first byte 首字节时间) 是指浏览器发起第一个请求到数据返回第一个字节所消耗时间
FP(First Paint 首次绘制时间) 首次渲染的时间,是第一个像素点绘制到屏幕的时间
FCP(First Contentful Paint 首次内容绘制时间) 首次有内容绘制渲染的时间,指浏览器将第一个dom渲染到屏幕的时间
FMP(First Meaningful paint 首次有意义绘制) 首次有意义绘制时间
FID(First Input Delay 首次输入延迟) 用户首次和页面交互到页面响应交互的时间
卡顿 超过150ms的长任务

业务:

指标 说明
PV page view 即页面浏览量和点击量
UV 指访某个站点的不同ip地址的人数
页面的停留时间 用户在每一个页面的停留时间

前端监控的流程

前端埋点 --> 数据采集 --> 数据建模和存储 --> 数据传输(实时/批量) --> 数据统计(分析) --> 数据可视化 --> 报告和报警(短信)


常见埋点方案:

方案 说明
代码埋点 代码埋点就是以嵌入式代码的形式进行埋点,比如需要监控用户的点击事件,会选择在用户点击时,插入一段代码,保存这个监听行为或者直接将监听行为以某种数据格式直接传递给服务器端
-优点:可以在任意时刻,精确的发送或保存所需要的数据信息
-缺点: 工作量很大
可视化埋点 通过可视化交互的手段代替代码埋点,将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后输出的代码耦合业务代码和埋点代码
无痕埋点 前端的任意一个事件都被绑定一个标识,所有事件都被记录下来,通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告提供给专业人员分析
-优点:采集全量数据,不会出现漏埋和误埋的现象
-缺点:给数据传输和服务器增加压力,也无法灵活定制数据结构

稳定性(报错相关)代码实践:

定义数据采集和上报的数据结构:

{
            kind: 'xxx', //大类
            type: 'xxx', //小类
            errorType: 'xxx', //js执行错误,如:jsError
            url: '', //访问哪个路径报错了
            message: event.message,  //报错的信息
            filename: event.filename, //哪个文件报错了
            position: `${event.lineno}:${event.colno}`,  //报错的行和列
            stack: getStack(event.error.stack), //报错堆栈,能知道报错前调用了谁
            selector: selector //最后一个操作的元素,报错之前操作的最近的一个元素是谁
        }

准备工具函数和前置需要:
为了能拿到报错前最后操作的一个dom元素是谁,我们可以在click,keydown事件的捕获阶段,每次把值替换存起来,
因为冒泡阶段一般都会由开发者操控,可能会阻止时间冒泡,而捕获阶段就没有这种担忧

const getLastEvent = (function () {
        let lastEvent
        ['click', 'keydown'].forEach(eventType => {
            document.addEventListener(eventType, (e) => {
                lastEvent = event
            }, {
                passive: true,
                capture: true
            })
        })
        function getLastEvent() {
            return lastEvent
        }
        return getLastEvent
    })()

getStack函数:用于提取报错的堆栈中的信息

function getStack(stack) {
        return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g, '')).join("^")
 }

在这里插入图片描述

getSelector函数:从getLastEvent()执行后返回的最后一个交互dom的事件对象中提取出css选择器

function getSelector(path) {
        if (Array.isArray(path)) {
            return path.reverse().filter(ele => {
                return ele !== document && ele !== window
            }).map(ele => {
                let selector = ''
                if (ele.id) {
                    return `${ele.nodeName.toLowerCase()}#${ele.id}`
                } else if (ele.className && typeof ele.className === 'string') {
                    return `${ele.nodeName.toLowerCase()}.${ele.className}`
                } else {
                    selector = ele.nodeName.toLowerCase()
                }
                return selector
            }).join(' ')
        }
    }

在这里插入图片描述
解析后会变成:
在这里插入图片描述

监听全局未捕获的js错误和全局资源加载异常

注意:对于全局资源加载异常的监听,addEventListener的第三个参数需要指定为true

window.addEventListener("error", (event) => {
        let lastEvent = getLastEvent() //拿到最后一个交互事件
        let selector = lastEvent && getSelector(lastEvent.path)

        if (event.target && (event.target.src || event.target.href)) {
            //说明这是一个脚本错误
            const log = {
                kind: 'stability', //大类
                type: 'error', //小类
                errorType: 'resourceError', //js或css加载错误
                filename: event.target.src || event.target.href, //哪个文件报错了
                tagName: event.target.tagName,
                selector: selector //最后一个操作的元素
            }
            console.log(log)
            return
        }
        // console.log('lastEvent', lastEvent)
        const log = {
            kind: 'stability', //大类
            type: 'error', //小类
            errorType: 'jsError', //js执行错误
            url: '', //访问哪个路径报错了
            message: event.message,
            filename: event.filename, //哪个文件报错了
            position: `${event.lineno}:${event.colno}`,
            stack: getStack(event.error.stack), //报错堆栈,能知道报错前调用了谁
            selector: selector //最后一个操作的元素
        }
        //捕获到的错误,需要上报的数据
        console.log(log)
        sendTracker.send(log)
    }, true)

监听全局未捕获的promise错误

promise的报错信息比较特殊,需要特殊处理一下

 window.addEventListener("unhandledrejection", (event) => {
        let lastEvent = getLastEvent() //拿到最后一个交互事件
        let selector = lastEvent && getSelector(lastEvent.path)
        let message = ''
        let filename = ''
        let line = 0
        let column = 0
        let stack = ''
        let reason = event.reason
        if (typeof reason === 'string') {
            message = reason
        } else if (typeof reason === 'object') {
            if (reason.stack) {
                let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
                console.log(matchResult)
                filename = matchResult[1]
                line = matchResult[2]
                column = matchResult[3]
            }
            message = reason.message
            stack = getStack(reason.stack)
        }
        const log = {
            kind: 'stability', //大类
            type: 'error', //小类
            errorType: 'promiseError', //promise错误
            message,
            filename, //哪个文件报错了
            position: `${line}:${column}`,
            stack, //报错堆栈,能知道报错前调用了谁
            selector: selector //最后一个操作的元素
        }
        console.log(log)
        //上报数据 
    })

监听xhr的请求,上报数据

fetch和ajax监听方式实现一样,都是重写prototype上的方法,加入自己的采集逻辑,这里以xhr为例

//监听xhr的请求,上报数据
        ; (function () {
            let XMLHttpRequest = window.XMLHttpRequest
            let oldOpen = XMLHttpRequest.prototype.open
            XMLHttpRequest.prototype.open = function (method, url, async) {
                //判断出数据上报的接口,防止死循环
                if (!url.match(/xxxxxx/)) {
                    this.logData = {
                        method,
                        url,
                        async
                    }
                }
                return oldOpen.apply(this, arguments)
            }

            let oldSend = XMLHttpRequest.prototype.send
            XMLHttpRequest.prototype.send = function (body) {
                if (this.logData) {
                    let startTime = Date.now()  //请求发送之前记录开始时间
                    
                    //请求响应后的处理函数
                    const handler = (type) => (event) => {
                        //请求持续的时间 
                        let duration = Date.now() - startTime
                        let status = this.status
                        let statusText = this.statusText
                        const log = {
                            kind: 'stability', //大类
                            type: 'xhr', //小类
                            errorType: type, //xhr错误类型
                            pathname: this.logData.url,  //请求路径
                            status: status + '-' + statusText,  //状态码
                            duration, //持续时间
                            response: this.response ? JSON.stringify(this.response) : '',  //响应体
                            params: body || ''   //参数
                        }
                        console.log('xhr', log)
						//上报数据
                    }
                    this.addEventListener('load', handler('load'), false)
                    this.addEventListener('error', handler('error'), false)
                    this.addEventListener('abort', handler('abort'), false)
                }
                return oldSend.apply(this, arguments)
            }
        })()

监听白屏上报

怎么判断是否白屏,根据主流的方案,在屏幕的中线上,xy轴分成9份,根据坐标通过elementsFromPoint取18个点,判断是否有元素,
如果出现16个点以上取不到元素,则说明页面现在处于白屏状态,上报数据

; (function () {
            function getCssSelector(ele) {
                if (ele.id) {
                    return "#" + id
                } else if (ele.className) {
                    return "." + ele.className.split(" ").filter(item => !!item).join(".")
                } else {
                    return ele.nodeName.toLowerCase()
                }
            }


            let wrapper = ['html', 'body']
            let emptyPoints = ''
            function isWrapper(ele) {
                let selector = getCssSelector(ele)
                if (wrapper.indexOf(selector) != -1) {
                    emptyPoints++
                }
            }


            //监控白屏
            function isBlank() {
                for (let i = 1; i <= 9; i++) {
                    let xElements = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
                    let yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10)
                    isWrapper(xElements[0])
                    isWrapper(yElements[0])
                }

                if (emptyPoints > 16) {
                    //认为是白屏了
                    let centerEle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2)
                    const log = {
                        kind: 'stability', //大类
                        type: 'blank', //小类
                        emptyPoints,
                        //屏幕分辨率
                        screen: window.screen.width + " x " + window.screen.height,
                        viewPoint: window.innerWidth + " x " + window.innerHeight,
                        selector: getCssSelector(centerEle[0])

                    }
                    console.log(log)
                }
            }
            isBlank()
        })()

白屏上报的数据格式如下:
前端监控原理及实践_第1张图片

监听js框架的错误:

vue,react都有提供一定的错误捕获能力,比如vue中有提供errorHandler

app.config.errorHandler = (err, vm, info) => {
  // 处理错误
  // `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子
}

特别地: 跨域script error

浏览器只允许同域下的脚本捕获具体的错误信息,别的域名下的报错信息不允许具体打出
如何解决:crossorigin="anonymous"

<script type="text/javascript" src="http://a.com"></script>
<script type="text/javascript" src="http://b.com"   crossorigin="anonymous"></script>

比如: a.com是本域名下的,b.com是第三方的域名,引用第三方脚本的时候需要加上 crossorigin="anonymous" 属性,这样的话,该脚本报错,就能接收到具体的堆栈


用户体验(性能相关)性能监控

前端性能监控主要分为两种方式

  • 一种叫做合成监控(Synthetic Monitoring,SYN)

  • 另一种是真实用户监控(Real User Monitoring,RUM)

合成监控:
什么叫合成监控?就是在一个模拟场景里,去提交一个需要做性能审计的页面,通过一系列的工具、规则去运行你的页面,提取一些性能指标,得出一个审计报告。

合成监控中最近比较流行的是 Google 的 Lighthouse,下面我们就以 Lighthouse 为例(在chrome开发者工具中就有提供)。
前端监控原理及实践_第2张图片
前端监控原理及实践_第3张图片
根据报告中指出的问题,我们才去推荐的方案解决
特别的:Lighthouse工具还可以nodejs中安装包的方式去测分,我们可以批量的多次的去跑指定的网站,测出一个平均分出来,减少偶然性

真实用户监控:
所谓真实用户监控,就是用户在我们的页面上访问,访问之后就会产生各种各样的性能指标,我们在用户访问结束的时候,把这些性能指标上传到我们的日志服务器上,进行数据的提取清洗加工,最后在我们的监控平台上进行展示的一个过程。

那问题来了,使用什么东西去收集什么数据呢?

使用标准的使用 PerformanceTimeline API(在复杂场景,亦可考虑优先使用PerformanceObserver),收集在整个页面渲染的过程中,各个时间段相减得出来的业界标准

有一张很经典的图
前端监控原理及实践_第4张图片
通过上图中各个时间点相减得出来的差值被业界赋予了不同的意义,具体看下表:

指标名 描述 计算方式
DNS查询 DNS 阶段耗时 domainLookupEnd - domainLookupStart
TCP连接 TCP 阶段耗时 connectEnd - connectStart
SSL建连 SSL 连接时间 connectEnd - secureConnectionStart
首字节网络请求 首字节响应时间(ttfb) responseStart - requestStart
内容传输 内容传输,Response阶段耗时 responseEnd - responseStart
DOM解析 Dom解析时间 domInteractive - responseEnd
资源加载 资源加载 loadEventStart - domContentLoadedEventEnd
首字节 首字节 responseStart - fetchStart
DOM Ready dom ready domContentLoadedEventEnd - fetchStart

代码实践:

//页面性能指标
        ; (function () {
            setTimeout(() => {
                const {
                    connectEnd,//2
                    connectStart,//2
                    domContentLoadedEventEnd,//4
                    domContentLoadedEventStart,//4
                    domInteractiv,//1
                    fetchStart,//1
                    domLoading, //5
                    loadEventStart,//5
                    requestStart,//3
                    responseStart,//3
                    responseEnd//3
                } = performance.timing
                const log = {
                    kind: "experience",
                    type: 'timing',
                    connectTime: connectEnd - connectStart, //连接时间
                    ttfbTime: responseStart - requestStart,//首字节到达时间
                    responseTime: responseEnd - responseEnd, //响应的读取时间
                    parseDOMTime: loadEventStart - domLoading,//dom解析时间
                    domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,
                    timeToInteractive: domInteractiv - fetchStart,//首次可交互时间
                    loadTime: loadEventStart - fetchStart,//完整的加载时间
                }
            }, 1000);
        })()

总结与补充:

异常监控
1.前端有哪些异常?
2.如何捕获异常
3.异常如何上报

前端异常三大类:

  • js运行错误
  • 网络加载错误
  • http请求错误

js运行错误:

  • evalError:已在eva()函数中发生的错误
  • internalError:内部错误
  • rangeError:超出数字范围的错误
  • referenceError:非法引用
  • syntaxError:语法错误
  • typeError:类型错误
  • urlError:在encodeURL中发生的错误
  • jsonError:JSON.parse解析发生的错误

异步异常:

  • setTimeout
  • setInterval
  • Promise
  • requestAnimation
function asyncError() {
        try {
            setTimeout(() => {
                console.log(name.age.a)
            }, 1000);
        } catch (e) {
            console.log(e)
        }
    }

    asyncError()

在这里插入图片描述

  • 网络加载错误/加载失败(link,script,img,css)
  • http请求错误(XMLHttpRequest,fetch)

异常捕获

  • try,catch
  • window.onerror全局捕获
  • promise异常(unhandledrejection)
  • 重写addEventListener,更方便统一的进行异常捕获
const originAddEventListener = EventTarget.prototype.addEventListener
    EventTarget.prototype.addEventListener = function (type, listener, options) {
        const listen = function (...args) {
            try {
                return listener.apply(this, args)
            } catch (e) {
                throw e
            }
        }
        return originAddEventListener.call(this, type, listen, options)
    }
  • 资源加载错误
window.addEventListener('error', function (e) {
        if (e.target.tagName.toUpperCase() === 'IMG') {
            console.log('img error')
        }
    })
  • http请求错误

劫持fetch,XMLHttpRequest重写prototype上的方法

  • 网页奔溃捕获(service worker拦截监听)

错误上报

  • XMLHttpRequest
  • navigator.sendBeacon(用于页面卸载发起请求)
  • IndexDB缓存,异步上报
  • 页面截图(html2canvas,dom-to-image)

特别的: 对于上传的错误频繁且庞大的时候,可以稀释错误上报,减轻服务端压力,去重收集的错误

errorReporter.send = function(data){
        if(Math.random>0.5){
            send(data)
        }
    }

开源成熟现成的监控平台:
Sentry

是一个开源的实时错误追踪系统,可以帮助开发者实时监控并修复异常问题。其专注于错误监控以及提取一切事后处理所需的信息;支持几乎所有主流开发语言(
JS/Java/Python/php )和平台, 并提供了web界面来展示输出错误。Sentry 分为服务端和客户端
SDK,前者可以直接使用它家提供的在线服务,也可以本地自行搭建;后者提供了对多种主流语言和框架的支持,包括
React、Angular、Node、Django、RoR、PHP、Laravel、Android、.NET、JAVA
等。同时它可提供了和其他流行服务集成的方案,例如 GitHub、GitLab、bitbuck、heroku、slack、Trello 等。

Sentry官网: https://sentry.io/

Github项目地址: https://github.com/getsentry/onpremise

你可能感兴趣的:(前端,js,监控,前端,性能监控,js)