前端监控、埋点

58 阅读10分钟

异常监控

H5异常监控

1、采集运行时异常

(1)try ....catch 

(2) window.onerror 或者 window.addEventListener 记住事件捕获阶段获得,不是冒泡阶段

/** JS异常全局捕获 */
captureJsError() {
    window.onerror = this.reWriteWindowOnError.bind(this)
}
function isErrorEvent(value) {
    return Object.prototype.toString.call(value) === '[object ErrorEvent]';
}
    /**
     * @description 重写全局捕获普通JS Error
     */
    reWriteWindowOnError(msg, filename, lineno, colno, ex) {
        // If 'ex' is ErrorEvent, get real Error from inside
        const error = isErrorEvent(ex) ? ex.error : ex
        // If 'msg' is ErrorEvent, get real message from inside
        const message = isErrorEvent(msg) ? msg.message : msg
        const event = new ErrorEvent(
            'error', // 实际错误类型可通过error.name获取
            {
                error,
                message,
                lineno,
                colno,
                filename
            }
        )
        this.dispatchError(event)
    }

2、采集promise的unhandledrejection异常

    /** 未处理reject异常捕获 */
    captureUnhandledRejectionError() {
        window.addEventListener('unhandledrejection', err => {
            this.dispatchError(err)
        }, true)
    }

3、自定义promise异常捕获

    /** 自定义promise catch异常处理上报 */
    capturePromise() {
        if (!this.config.enablePromiseCatchEvent) return
        this.promiseCatchInterceptor()
    }
/**
 * @Description 拦截并改写promise的catch
 * @scene 自动劫持捕捉promise已有自定义catch的场景错误等
 */
promiseCatchInterceptor() {
    /**判断promise环境支持 */
    if (typeof Promise === 'function' && Promise.prototype.catch){
        const catcher = Promise.prototype.catch
        const self = this
        Promise.prototype.catch = function(fn) {
            /**通过改写本身的fn回调来获取fn实际的形参 arguments */
            const wrapper = (...args) => {
                const [arg0] = args
                /**调用错误上报 */
                self.dispatchError(arg0)
                return fn && fn.apply(null, args)
            }
            return catcher.call(this, wrapper)
        }
    }
}

4、采集资源加载异常 

(1)object.onerror,如img.onerror 

(2)performance.getEntries (getEntries api返回一个资源加载完成数组,假设为img,再查询页面中一共有多少个img,二者的差就是没有加载上的资源) 

(3)Error事件捕获

    /** 资源加载异常捕获 */
    captureAssetsError() {
        _window.addEventListener('error', err => {
            if (!(err instanceof ErrorEvent)) {
                if (err && err.target && err.target.src) {
                    err.isChunkLoadError = true
                    this.dispatchError(err)
                }
            }
        }, true)
    }

5、采集vue异常

    vueErrorHandler (VUE) {
        // vue 本身已对 window.onerror 作过自身处理,对捕获的错误不会再次触发本工具的错误监听,故重写其错误捕获方法
        VUE.config.errorHandler = (error, vue, msg) => {
	    // 开发测试环境保留(作浏览器控制台显示调试用)
	    console.error(`[Attention] !!! Vue Error: ${msg}\n`, error)
            // 错误数据封装
            const eventInitDict = {
                filename: 'string',
                lineno: 0,
                colno: 0,
                error,
                message: `Vue ${msg} error, ${error.message}`
            }
            const event = new ErrorEvent(`vuerendererror`, eventInitDict)
            this.dispatchError(event)
        }
    }

6、采集网络请求异常

    /** API异常全局捕获 */
    captureURIError() {
        // 保留对原方法的引用
        const XHRProtoOpen = XMLHttpRequest.prototype.open
        const XHRProtoSetHeader = XMLHttpRequest.prototype.setRequestHeader
        const XHRProtoSend = XMLHttpRequest.prototype.send
        const _this = this
        // 拦截请求
        XMLHttpRequest.prototype.open = function (...params) {
            this.requestMethod = params[0]
            this.requestURL = params[1]
            XHRProtoOpen.apply(this, params)
        }
        // 拦截请求头
        XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
            // 当前请求 msgid
            if (key === 'msgid') this.msgid = value
            XHRProtoSetHeader.apply(this, [key, value])
        }
        // 继续发送数据报
        XMLHttpRequest.prototype.send = function(...params) {
            // 请求body暂存
            this._umpRequestBody = params[0]
            XHRProtoSend.apply(this, params)
            // 此处只能用addEventListener重写readystatechange,否则拿不到this内的属性
            this.addEventListener('loadend', () => {
                const { readyState, status } = this
                if (readyState === 4 && status !== 200 && !String(status).startsWith('30')) {
                    // 异常请求记录,上报
                    _this.dispatchError(this)
                }
            }, false);
            // 除了loadend以外的事件添加errMsg描述
            ['timeout', 'abort', 'error'].forEach(errType => {
                this.addEventListener(errType, () => {
                    // 自定义errMsg描述
                    const errMsgMap = {
                        'timeout': `timeout(${this.timeout || 0}ms)`
                    }
                    // 没有自定义errMsg描述的,使用errType作为错误描述
                    this.errMsg = errMsgMap[errType] || errType
                }, false)
            })
        }
    }

7、错误如何上报

(1)XmlHttpRequest

(2)image的src上报

  • (new Image()).src = '错误上报的请求地址'

    一般来说,大厂都是采用利用image对象的方式上报错误的;使用图片发送get请求,上报信息,由于浏览器对图片有缓存,同样的请求,图片只会发送一次,避免重复上报。

微信小程序异常监控

1、采集运行时异常

    /**
     * @description 劫持原小程序App初始化方法
     */
    rewriteWxappMethod () {
        const self = this;
        // 合并方法,插入记录脚本
        ['onLaunch', 'onShow', 'onHide', 'onError'].forEach((methodName) => {
            const userDefinedMethod = self.originAppOptions[methodName]; // 暂存用户定义的方法
            if (methodName === 'onLaunch') {
                getNetworkType(this.config)
                getSystemInfo(this.config)
            }
            if (methodName === 'onLoad' || methodName === 'onShow') {
                getActivePage(this.config)
            }
            this.originAppOptions[methodName] = function (options) {
                methodName === 'onError' && self.dispatchError({ msg: options }) // 错误上报
                return userDefinedMethod && userDefinedMethod.call(this, options)
            };
        });
        return this
    }

2、采集promise异常

/**
 * @Description 对象是否可配置
 */
const isConfigurable = (obj, key) => Object.getOwnPropertyDescriptor(obj, key).configurable
    /**
     * @Description 拦截并改写promise的catch
     * @scene 自动劫持捕捉promise已有自定义catch的场景错误等
     */
    promiseCatchInterceptor() {
        /**判断promise环境支持 */
        if (typeof Promise === 'function' && Promise.prototype.catch) {
            const canWrite = isConfigurable(Promise.prototype, 'catch')
            if (!canWrite) return
            const catcher = Promise.prototype.catch
            const self = this
            Promise.prototype.catch = function(fn) {
                /**通过改写本身的fn回调来获取fn实际的形参 arguments */
                const wrapper = (...args) => {
                    const [arg0] = args
                    /**调用错误上报 */
                    self.dispatchError({ msg: arg0 })
                    return fn && fn.apply(null, args)
                }
                return catcher.call(this, wrapper)
            }
        }
    }

3、采集网络请求异常

    /**
     * @description 监听原小程序wx.request
     */
    rewriteRequest () {
        const canConfigRequest = isConfigurable(wx, 'request')
        if (!canConfigRequest) {
            return
        }
        const handlerURIMsg = (failInfo) => {
            const config = failInfo['config'] || {}
            const message = failInfo['message'] || {}
            const uriMessage = {
                ...message,
                ...config,
                msg: message['errMsg']
            }
            this.dispatchError(uriMessage)
        }
        new Request({
            handlerUriCallback: handlerURIMsg
        })
    }
export class Request{

    constructor (options = {}) {

        this.handlerUriCallback = options.handlerUriCallback

        this.destConfig = this.initConfig()

        this.completeCount = 0

        this.handlerWxRequest()

    }

    initConfig () {
        return {
            url: '',
            data: {},
            header: {},
            method: '',
            dataType: '',
            responseType: '',
            success () {},
            fail () {},
            complete () {},
            statusCode: 0
        }
    }

    handlerWxRequest () {

        const __this = this

        const originRequest = wx.request;

        Object.defineProperty(wx, 'request', {
            configurable: true,
            enumerable: true,
            writable: true,
            value: function() {
				__this.handlerRequestSuccess(arguments[0])
				__this.handlerRequestFail(arguments[0])
                return originRequest.apply(this, arguments)
            }
        })

    }

	handlerRequestSuccess (config) {
		const originSuccess = config.success
		if (!originSuccess) return
		const __this = this
		const originProp = Object.getOwnPropertyDescriptor(config, 'success')
		Object.defineProperty(config, 'success', {
			...originProp,
			value: function (val) {
				if (String(val.statusCode) !== '200') {
					__this.handlerFailMessage(config, val)
				}
				return originSuccess.call(config, val)
			}
		})
	}
	handlerRequestFail (config) {
		const originFail = config.fail
		if (!originFail) return
		const __this = this
		const originProp = Object.getOwnPropertyDescriptor(config, 'fail')
		Object.defineProperty(config, 'fail', {
			...originProp,
			value: function (val) {
				if (String(val.statusCode) !== '200') {
					__this.handlerFailMessage(config, val)
				}
				return originFail.call(config, val)
			}
		})
	}

    handlerFailMessage (config, message) {

        const failConfig = {
            config,
            message
        }

        if (typeof this.handlerUriCallback !== 'function') {
            return console.error('uri callback is not a function')
        }

        this.handlerUriCallback(failConfig)
    }
}

export const wxRequest = ({ url, data, method, timeout, cb }) => {
    return new Promise((resolve, reject) => {
        wx.request({
            url,
            method: method || 'POST',
            data,
            timeout: timeout || 2000,
            success: res => {
                if (Number(res.statusCode === 200) && res.errMsg === 'request:ok') {
                    cb && cb(res)
                    resolve(res)
                } else {
                    reject(res)
                }
            },
            fail: res => {
                reject(res)
            }
        })
    })
}

ReactNative异常监控

崩溃异常

卡顿异常

网络异常

性能监控

H5性能监控

性能指标采集

一、获取页面加载性能数据

1、取window.performance.timing

采集逻辑

/**
 * @description 转化performance timing性能指标的时间戳为时长
 */
const calcPerformanceTiming = () => {
    const timing = window.performance.timing
    if (!timing) return {}
    const navigationStart = timing.navigationStart
    const newTiming = { navigationStart: 0 }
    for (const key in timing) {
        if (key !== 'navigationStart' && typeof timing[key] === 'number') {
            const element = timing[key]
            newTiming[key] = element > 0 ? element - navigationStart : element
        }
    }
    return newTiming
}

注意:采集performance.timing是为了兼容老的浏览器

2、取window.performance.getEntriesByType('navigation')[0]

3、取window.performance.getEntriesByType('paint')

采集逻辑

const getOriginalPerformance = () => {
    const times = calcPerformanceTiming() // timing即将被废弃


    // 优先使用 navigation v2 
    if (typeof window.PerformanceNavigationTiming === 'function') {
        try {
            let nt2Timing = window.performance.getEntriesByType('navigation')[0]
            if (nt2Timing) {
                for (const key in nt2Timing) {
                    const element = nt2Timing[key]
                    // 去掉属性值为函数的属性
                    if (typeof element !== 'function') {
                        times[key] = typeof element === 'number' ? window.Math.round(element) : element
                    }
                }
            }
        } catch (err) {}
    }


    if (typeof window.PerformanceNavigationTiming === 'function') {
        const paint = window.performance.getEntriesByType('paint') || []
        const firstPaint = paint[0] || {}
        times.firstPaint_duration = window.Math.round(firstPaint.duration)
        times.firstPaint_startTime = window.Math.round(firstPaint.startTime)
        const firstContentfulPaint = paint[1] || {}
        times.firstContentful_duration = window.Math.round(firstContentfulPaint.duration)
        times.firstContentful_startTime = window.Math.round(firstContentfulPaint.startTime)
    }


    return { ...times }
}
二、获取资源请求性能数据

window.performance.getEntriesByType('resource')

采集逻辑

const getPerformanceOfResource = () => {
    const resourceMax = reportConfig.performancePageResouceMax
    let prResource = []
    const result = []
    if (typeof window.PerformanceNavigationTiming === 'function') {
        try {
            prResource = window.performance.getEntriesByType('resource')
        } catch (err) {}
    }
    if (prResource.length > 0) {
        prResource.forEach(r => {
            // 只采集资源名称,类型,大小,加载时间
            const { initiatorType = '', transferSize = 0, name = '', duration = 0, nextHopProtocol = '' } = r
            if (name && duration > 0 && initiatorType !== 'xmlhttprequest') {
                let sourceName = name // name是只读属性,此处相当于copy
                if (sourceName.indexOf('?') > 0) sourceName = sourceName.substring(0, sourceName.indexOf('?'))
                result.push({ name: sourceName, transferSize, duration: window.Math.round(duration), initiatorType, nextHopProtocol })
            }
        })
    }
    // 考虑到页面资源过多仅上传耗时前resourceMax名的资源
    result.sort((a, b) => {
        return a.duration - b.duration
    })


    return result.length > resourceMax ? result.slice(0, resourceMax) : result
}

对性能指标计算

采集的性能指标都是某个时间点的原始数据,还需要对采集的指标转化为衡量页面性能的指标。

image.png

/**
 * @description 转换原始性能数据为统计需要的性能指标
 */
function transferOriginPerform(t) {
	const times = {}
	if (!t) return times
	// 旧版sdk上报计算后的数据,则直接返回
	if (t.fetchStart === undefined || t.connectStart === undefined) return t
	// DOM节点解析完成需要的时间
	times.domReady = Math.round(t.domComplete - t.responseEnd)
	// DCL需要的时间
	times.dclTime = Math.round(t.domComplete - t.responseEnd)
	// 页面加载到SDK开始收集信息需要的时间
	// times.loadSDK = t.loadSDK
	// 首字节,对用户来说一般无感知,对于开发者来说,则代表访问网络后端的整体响应耗时。
	times.fbTime = Math.round(t.responseStart - (t.navigationStart || t.fetchStart))
	// 白屏时间,用户看到页面展示出现一个元素的时间,区别于首字节,头部资源还没加载完毕前,页面也是白屏
	times.blankTime = Math.round((t.domInteractive || t.domLoading) - t.fetchStart)
	// 首屏时间(FMP),暂取FCP首次有内容的渲染时间
	times.fmpPaintTime = t.firstContentful_startTime || Math.round(t.domContentLoadedEventEnd - t.fetchStart)
	// 总下载时间,总下载时间即window.onload触发的时间节点, 资源同步加载完成的时间
	times.downloadTime = Math.round(t.loadEventEnd - t.fetchStart)
	// 重定向时间
	times.redirectTime = Math.round(t.redirectEnd - t.redirectStart)
	// DNS解析时间
	times.domainLookupTime = Math.round(t.domainLookupEnd - t.domainLookupStart)
	// TCP完成握手时间
	times.connectTime = Math.round(t.connectEnd - t.connectStart)
	// HTTP请求响应完成时间
	times.httpResTime = Math.round(t.responseEnd - t.requestStart)
	// DOM加载完成时间
	times.domReadyTime = Math.round(t.domComplete - t.domInteractive)
	// DOM结构解析完成时间
	times.domInteractiveTime = Math.round(t.domContentLoadedEventStart - t.domInteractive)
	// 脚本加载时间
	times.domContentLoadedTime = Math.round(t.domContentLoadedEventEnd - t.domContentLoadedEventStart)
	// onload事件时间
	times.onloadEventTime = Math.round(t.loadEventEnd - t.loadEventStart)

	// 页面加载完成时间
	times.pageLoadTime =
		times.domainLookupTime +
		times.connectTime +
		times.httpResTime +
		times.domReadyTime +
		times.domInteractiveTime +
		times.domContentLoadedTime +
		times.onloadEventTime

	return times
}

TTFB(首字节时间)

首字节,对用户来说一般无感知,对于开发者来说,则代表访问网络后端的整体响应耗时。

times.fbTime = Math.round(t.responseStart - (t.navigationStart || t.fetchStart))

FP(首次绘制时间)

  • 首次渲染(FP)用于衡量用户从打开页面到首个像素渲染到页面的时间。
  • FP通常反映页面的白屏时间,在FP时间点之前,用户看到的是没有任何内容的白色屏幕。白屏时间反映当前Web页面的网络加载性能情况,FP越短,白屏时间就越短,用户页面加载体验就越好。
times.blankTime = Math.round((t.domInteractive || t.domLoading) - t.fetchStart)

FCP(首次有内容绘制时间)

  • 首次内容渲染(FCP)用于衡量从用户首次导航至网页到页面上任意一部分内容呈现在屏幕上的时间。此处的“内容”是指文本、图像(包括背景图像)、<svg>元素或非白色<canvas>元素。
  • FCP通常反映页面首次呈现内容的时间。在FCP时间点之前,用户所见为没有任何实际内容的屏幕。首次呈现内容的时间能够反映当前Web页面的网络加载性能、页面DOM结构的复杂性以及内联脚本的执行效率等因素。FCP时间越短,首次呈现内容的时间也越短,从而提升用户的页面加载体验。
  • 注意:FCP包括上一个网页的所有卸载时间、连接设置时间、重定向时间和首字节时间(TTFB),在真实场景下,这些时间可能难以准确预估,从而导致实际测量结果与理论预期结果之间存在差异。
times.fmpPaintTime = t.firstContentful_startTime || Math.round(t.domContentLoadedEventEnd - t.fetchStart)

FMP(首次有意义绘制时间)

指页面关键元素渲染时间。这个概念并没有标准化定义,因为关键元素可以由开发者自行定义——究竟什么是“有意义”的内容,只有开发者或者产品经理自己了解。

times.fmpPaintTime = t.firstContentful_startTime || Math.round(t.domContentLoadedEventEnd - t.fetchStart)

LCP(大量有意义绘制时间)

  • LCP(Largest Contentful Paint)指的是可见视口中最大图片、文本块或视频的渲染时间(相对于用户首次导航至网页的时间)。
  • 注意:LCP包含前一页面的所有卸载时间、连接设置时间、重定向时间以及首字节时间(TTFB)。在实际测量过程中,这可能导致测量结果与理论预期结果之间存在差异。

用于衡量标准报告视口内可见的最大内容元素的渲染时间。为了提供良好的用户体验,网站应努力在开始加载页面的前 2.5 秒内进行 最大内容渲染 。

首屏时间

对于所有网页应用,这是一个非常重要的指标。用大白话来说,就是进入页面之后,应用渲染完整个手机屏幕(未滚动之前)内容的时间。需要注意的是,业界对于这个指标其实同样并没有确切的定论,比如这个时间是否包含手机屏幕内图片的渲染完成时间。

times.pageLoadTime =
		times.domainLookupTime +
		times.connectTime +
		times.httpResTime +
		times.domReadyTime +
		times.domInteractiveTime +
		times.domContentLoadedTime +
		times.onloadEventTime

TTI(用户可交互时间)

顾名思义,也就是用户可以与应用进行交互的时间。一般来讲,我们认为是 domready 的时间,因为我们通常会在这时候绑定事件操作。如果页面中涉及交互的脚本没有下载完成,那么当然没有到达所谓的用户可交互时间。

times.domReadyTime = Math.round(t.domComplete - t.domInteractive)

总下载时间

页面所有资源加载完成所需要的时间。一般可以统计 window.onload 时间,这样可以统计出同步加载的资源全部加载完的耗时。如果页面中存在较多异步渲染,也可以将异步渲染全部完成的时间作为总下载时间。

times.downloadTime = Math.round(t.loadEventEnd - t.fetchStart)

微信小程序性能监控

关注指标:小程序启动、首次渲染、页面切换、代码注入

    /**
     * 获取小程序性能数据
     * @param {Function} cb callback
     * entryType   的合法值:
     * navigation  路由
     * render      渲染
     * script      脚本
     */
    getPerformanceData(cb) {
        if (wx.canIUse('getPerformance')) {
            const performance = wx.getPerformance()
            const observer = performance.createObserver(cb)
            observer.observe({
                entryTypes: ['render', 'script', 'navigation']
            })
        }
    }

    /**
     * 开始监听性能数据
     */
    startPerformanceObserver() {
        this.getPerformanceData(entryList => {
            // 小程序启动数据
            const appLaunchInfo = entryList.getEntriesByName('appLaunch', 'navigation') || []
            this.reportAppLaunch(appLaunchInfo)

            // 页面跳转路由数据
            const routerInfo = entryList.getEntriesByName('route', 'navigation') || []
            this.reportRouter(routerInfo)

            // 页面首次渲染数据
            const firstRenderInfo = entryList.getEntriesByName('firstRender', 'render') || []
            this.reportFirstRender(firstRenderInfo)

            // 逻辑层 JS 代码注入(含编译和执行)耗时
            const evaluateScriptInfo = entryList.getEntriesByName('evaluateScript', 'script') || []
            this.reportEvaluateScript(evaluateScriptInfo)
        })
    }

ReactNative性能监控

Native首屏加载时间、Native启动时间、React-Native首屏加载时间、Bundle加载时间

埋点

整理中...

面试题

1、页面刷新或关闭之前如何上报?

参考:

React Native 全方位异常监控

前端异常监控-看这篇就够了

前端异常和性能监控

深入理解前端性能监控—Performance

前端埋点总结

如何打造一款标准的JS-SDK

钉钉前端-如何设计前端实时分析及报警系统

如何在 Web 关闭页面时发送 Ajax 请求

Web Beacon 刷新/关闭页面之前发送请求

前端页面性能参数搜集

SourceMap 与前端异常监控

前端性能与异常监控系统

如何进行 web 性能监控?

搭建前端监控系统(四)接口请求监控篇

蚂蚁金服如何把前端性能监控做到极致?