美文网首页
前端监控

前端监控

作者: 强某某 | 来源:发表于2020-08-20 15:06 被阅读0次
    1.jpg

    前端监控细分可为俩大类,技术监控和行为监控。

    技术准备

    此处目的是生成使用的sdk,sdk打包更适合rollup

    • npm创建项目
    • 安装依赖
     "devDependencies": {
        "@babel/core": "^7.11.1",
        "babel": "^6.23.0",
        "cross-env": "^7.0.2",
        "rollup": "^2.26.3",
        "rollup-plugin-babel": "^4.4.0"
      }
    
    • 配置文件
    //rollup.config.js
    
    import babel from "rollup-plugin-babel";
    let isDev = (process.env.NODE_ENV === 'develop');
    let babaelConfig = {
        "presets": [
            [
                "env", {
                    "modules": false,
                    "targets": {
                        "browers": ["chrome>40", "safari>=7"]
                    }
                }
            ]
        ]
    }
    
    export default {
        input: 'index.js',
        watch: {
            exclude: 'node_modules/**'
        },
        output: {
            file: isDev ? '../website/client/js/eagle-monitor/bundle.umd.js' : '../dist/bundle.umd.js',
            name: 'EagleMonitor',
            format: 'umd',
            sourcemap: true
        },
        plugin: [
            babel({
                babelrc:false,
                presets:babaelConfig.presets,
                plugins:babaelConfig.plugin,
                exclude:'node_modules/**'
            })
        ]
    }
    
    • 打包入口类
    //index.js
    import perf from "./perf";
    import resource from "./resource";
    import xhrHook from "./xhrHook";
    import beh from "./beh";
    import errorCatch from "./errorCatch";
    perf.init(perfData=>{
        console.log('perf');
    })
    
    resource.init(item=>{
        console.log('resource');
    })
    beh.init(value=>{
        console.log('beh');
    })
    errorCatch.init(errorInfo=>{
        console.log('errorInfo');
    })
    xhrHook.init(xhrInfo=>{
        console.log('xhrInfo');
    })
    

    技术监控

    • 页面性能监控
    • performance.timing
    export default {
    
        init: cb => {
            let isDOMReady=false;
            let isOnload=false;
            let Util = {
                getPerfData: p => {
                    let data = {
                        //网络建连
                        prevPage: p.fetchStart - p.navigationStart,//上一个页面的时间
                        redirect: p.redirectEnd - p.redirectStart,//重定向时间
                        dns: p.domainLookupEnd - p.domainLookupStart,//DNS查找时间
                        connect: p.connectEnd - p.connectStart,//TCP建连时间
                        network: p.connectEnd - p.navigationStart,//网络总耗时
    
                        //网络接收
                        send: p.responseStart - p.requestStart,//前端从发送到接收的时间
                        receive: p.responseEnd - p.responseStart,//接收数据用时
                        request: p.responseEnd - p.requestStart,//请求页面的总耗时
    
    
                        //前端渲染
                        dom: p.domComplete - p.domLoading,//dom解析时间
                        loadEvent: p.loadEventEnd - p.loadEventStart,//loadEvent时间
                        frotend: p.loadEventEnd - p.domLoading,//前端总时间
    
                        //关键阶段
                        load: p.loadEventEnd - p.navigationStart,//页面完全加载的时间
                        domReady: p.domContentLoadedEventStart - p.navigationStart,//dom准备时间
                        interactive: p.domInteractive - p.navigationStart,//用户可操作的时间
                        ttfb: p.responseStart - p.navigationStart //首字节时间
                    }
                    return data;
                },
                //dom解析完成
                domready: (callback) => {
                    if (isDOMReady===true) return
                    let timer = null;
    
                    //之所以这样做,而不是初始时候执行一次,是因为各个属性值计算的时候,可能还没就绪,例如dom相关,很容易出现默认值计算
                    //然后结果是复数,所以需要这要递归,直到出现真实结果
    
                    let runCheck = () => {
                        if (performance.timing.domInteractive) {
                            //停止循环检测  然后运行callback
                            clearTimeout(timer);
                            callback();
                            isDOMReady=true
                        } else {
                            //再去循环检测
                            timer = setTimeout(runCheck, 100);
                        }
                    }
                    if (document.readyState === 'interactive') {
                        callback();
                        return
                    }
                    document.addEventListener('DOMContentLoaded', () => {
                        //开始循环检测,是否DOMContentLoaded已经完成
                        runCheck();
                    })
                },
                //页面加载完成
                onload: (callback) => {
                    if (isOnload===true) return
                    let timer = null;
                    let runCheck = () => {
                        if (performance.timing.loadEventEnd) {
                            //停止循环检测  然后运行callback
                            clearTimeout(timer);
                            callback();
                            isOnload=true
                        } else {
                            //再去循环检测
                            timer = setTimeout(runCheck, 100);
                        }
                    }
                    if (document.readyState === 'interactive') {
                        callback();
                        return
                    }
                    window.addEventListener('load', () => {
                        //开始循环检测,是否DOMContentLoaded已经完成
                        runCheck();
                    })
                }
            }
            let performance = window.performance; //兼容性问题,所以此处只是简化代码
            Util.domready(() => {
                let perfData = Util.getPerfData(performance.timing);
                perfData.type='domready';
                cb(perfData);
                debugger
            })
    
            Util.onload(() => {
                let perfData = Util.getPerfData(performance.timing);
                perfData.type='onload';
                cb(perfData);
                debugger
            })
    
    
            // window.addEventListener('load', () => {
                // setTimeout(() => {
                //     console.log(performance.timing);
                //     let perfData = Util.getPerfData(performance.timing);
                //     debugger
                // }, 100);
            // })
        }
    }
    
    /**
     * performance.timing
     *
     *
    含义   默认值
    connectEnd: 1597806127336    向服务器建立连接结束  fetchStart
    connectStart: 1597806127335  向服务器建立连接开始  fetchStart
    domComplete: 0  文档解析完成
    domContentLoadedEventEnd: 0    ContentLoaded结束
    domContentLoadedEventStart: 0   ContentLoaded开始  备注(只针对dom结构,而不针对里面图片等资源)
    domInteractive: 0 解析dom结束     备注:document.readyState为字符串interactive
    domLoading: 1597806127367  解析dom开始     备注:document.readyState为字符串loading
    domainLookupEnd: 1597806127335 dns查询结束  fetchStart
    domainLookupStart: 1597806127335  dns查询开始  fetchStart
    fetchStart: 1597806127332  开始请求网页
    loadEventEnd: 0   load事件发送后
    loadEventStart: 0  load事件发送前
    navigationStart: 1597806127331  前一个网页卸载的时间   fetchStart
    redirectEnd: 0  重定向结束时间  0    需要同域
    redirectStart: 0   重定向开始时间  0  需要同域
    requestStart: 1597806127336  向服务器发送请求开始  无默认值
    responseEnd: 1597806127361   服务器返回数据结束
    responseStart: 1597806127360 服务器返回数据开始
    secureConnectionStart: 0  安全握手开始  0  非https的没有
    unloadEventEnd: 1597806127365   前一个网页的unload(关掉)时间结束   0
    unloadEventStart: 1597806127365   前一个网页的unload(关掉)时间开始   0
     */
    

    总之:注意属性值的触发问题(例如初始化执行时候对应周期还没有,值是默认值导致计算出错,解决方案是递归)

    • 静态资源性能监控
    //util.js
    export default{
        onload:(cb)=>{
            if ( document.readyState==='complete') {
                cb();
                return
            }
            window.addEventListener('load',()=>{
                cb();
            })
        }
    }
    
    import Util from "./util";
    let resolvePerformanceResource = (resourceData) => {
        let r = resourceData;
        let o = {
            initiatorType: r.initiatorType,
            name: r.name,
            duration: parseInt(r.duration),
    
    
            //连接过程
            redirect: r.redirectEnd - r.redirectStart,//重定向
            dns: r.domainLookupEnd - r.domainLookupStart,//DNS查找时间
            connect: r.connectEnd - r.connectStart,//TCP建连时间 
            network: r.connectEnd - r.startTime,//网络总耗时
    
            //接收过程
            send: r.responseStart - r.requestStart,//前端从发送到接收的时间
            receive: r.responseEnd - r.responseStart,//接收数据用时
            request: r.responseEnd - r.requestStart,//请求页面的总耗时
    
            //核心指标
            ttfb: r.responseStart - r.requestStart //首字节时间
        }
        return o;
    }
    //帮助我们循环获得每一个资源的性能数据
    let resolveEntries = (entries) => entries.map(_ => resolvePerformanceResource(_));
    
    
    
    export default {
        init: cb => {
        //此处意义是使用新的api,通过回调触发,而不是else里面的什么请求都进
            if (window.PerformanceObserver) {
                //动态获得每一个资源信息
                let observer = new window.PerformanceObserver((list) => {
                    try {
                        let entries = list.getEntries();
                    } catch (error) {
                        console.error(error);
                    }
                });
                observer.observe({entryTypes: ['resource']})
            } else {
                Util.onload(() => {
                    //在onload之后获得所有的资源信息
                    let entries = performance.getEntries('resource');
                    let entriesData = resolveEntries(entries);
                    cb(entriesData);
                    // resolvePerformanceResource(entries[0])
                    debugger
                });
            }
        }
    }
    

    总之:监控sdk的js尽量放在所有要加载的link,js,css上面,因为只有注册了才能监控,放在下面的话,可能导致监控不到之前的请求

    • 错误监控
    • window.onerror
    let formatError=errorObj=>{
        debugger
        let col=errorObj.column||errorObj.columnNumber;//兼容不同浏览器
        let row=errorObj.line||errorObj.lineNumber;
        let errorType=errorObj.name;
        let message=errorObj.message;
        let {stack}=errorObj;
        if (stack) {
            //正则很多,所以理论上应该try不然很容i报错
            let matchUrl=stack.match(/https?:\/\/[^\n]+/);
            let urlFirstStack=matchUrl?matchUrl[0]:'';
    
            //获取真正的url
            let resourceUrl='';
            let regUrlCheck=/https?:\/\/(\S)*\.js/;
            if (resourceUrl.test(urlFirstStack)) {
                resourceUrl=urlFirstStack.match(regUrlCheck)[0];
            }
    
            //获取真正的行列信息
            let stackCol=null;
            let stackRow=null;
            //chrome只能通过正则匹配出来行列
            let posStack=urlFirstStack.match(/:(\d+):(\d+)/)
            if (posStack&&posStack.length>=3) {
                [,stack,stackRow]=posStack;
            }
            return {
                content:stack,
                col:Number(col||stackCol),
                row:Number(row||stackRow),
                errorType,
                message,
                resourceUrl
            };
        }
    }
    
    
    export default{
        init:cb=>{
            let _origin_error=window.onerror;
           window.onerror=function(message,source,lineno,colno,error) {
               /**
                colno: 5 列
                error: ReferenceError: b is not defined at http://127.0.0.1:3003/:17:5
                lineno: 17    行
                message: "Uncaught ReferenceError: b is not defined"
                source: "http://127.0.0.1:3003/"
                */
    
                //注意:一般项目都会压缩,如果压缩之后可能是第一行,xxxx列,完全无法调试
    
                /**
                 * 之所以通过stack去解析正则匹配,就是因为比如说react项目,打包之后,直接lineno,colno出来的数据可能不对
                 * 甚至source:定位报错的js都不对(例如报到vendor.js中的错误,没有任何意义),所以这些数据只是参考,主要还是上面formatError解析之后的信息,一定是正确的
                 * 而且这些报错都是sourcemap的,需要利用服务端反解,然后返给bug统计界面,说明报错的代码是啥
                 */
               let errorInfo=formatError(error);
               errorInfo._message=message;
               errorInfo._source=source;
               errorInfo._lineno=lineno;
               errorInfo._colno=colno;
               errorInfo.type='error';
               cb(errorInfo);
                _origin_error&&_origin_error.apply(window,arguments);
           }
        }
    }
    

    只有这些不够,因为针对vue,react这种压缩类型的项目,真实运行的都是mapresource资源,所以需要服务端配置,然后解析,最终定位错误代码的上下几行,然后返回给错误监控平台显示

    需要注意的是:chrome的报错信息很多其实都是无效数据,因为无法精确定位行数等(例如单页面应用),所以一般都是解析stack的数据正则匹配,这里面取出的才是真实问题所在;还有错误统计代码本身也可能出错,这时候的出错要try处理,同时做特殊上班,例如分类,但是不能不做任何处理,否则直接回出现死循环

    //服务端解析source-map
    const fs = require('fs');
    const path = require('path');
    const SourceMap = require('source-map');
    
    let sourceMapFilePath = path.join(__dirname, './main.bundle.js.map');
    let sourceFileMap = {};
    //替换不规则路径,此处是该map文件内部文件路径的替换
    let fixPath = filePath => {
        return filePath.replace(/\.[\.\/]+/, '');
    }
    
    
    module.exports = async (ctx, next) => {
        //一般是把sourcemap文件在客户端上传上去,然后再此处再反解,
        //但是这只是案例,sourcemap文件直接放到服务端,只写反解逻辑
    
    
        if (ctx.path = '/sourcemap') {
            let sourceMapContent = fs.readFileSync(sourceMapFilePath, 'utf-8');
            let fileObj = JSON.parse(sourceMapContent);
            let { sources } = fileObj;
    
            sources.forEach(item => {
                sourceFileMap[fixPath(item)] = item;
            })
    
            let column = 554;//此处假设网络请求已经把报错位置上传上来
            let line = 17;
            const consumer = await new SourceMap.SourceMapConsumer(sourceMapContent);
            let result = consumer.originalPositionFor({
                line, column
            });
            /**
             * result
             * {
             *   source:"webpack:///react-app.js",
             *   line:10
             *   column:6
             *   name :vue    vue就是错误,未定义但是使用了
             * }
             */
    
            let originSource = sourceFileMap[result.source];
            //originSource:"webpack:///./react-app.js",
    
            //报错的代码-但是基本上是出错js的全部
            let sourceContent=fileObj.sourceContent[sources.indexOf(originSource)];
            //可以通过这个分行取出所需行数的上下几行,甚至标红出错的行
            let sourceContentArr=sourceContent.split('\n');
            ctx.body = {sourceContent, sourceContentArr,originSource, result };
        }
        //此处是koa的中间件,所以这么写,不需要特别在意
        return next();
    }
    
    • 接口性能监控

    核心就是类似代理模式,重写XMLHttpRequest的send和open函数,然后类似于面向切面的形式,在里面实现信息上报

    export default {
        //TODO  自身SDK请求不需要拦截
        init: cb => {
            //xhr hook
            let xhr = window.XMLHttpRequest;
            //避免多次加载该hook,例如用户把sdk引用两次的情况
            if (xhr._eagle_monitor_flag === true) {
                return
            }
            xhr._eagle_monitor_flag = true;
            let _originOpen = xhr.prototype.open;
            //原生xhr有这几个参数
            xhr.prototype.open = function (method, url, async, user, password) {
                //此处是面向切面编程
                this._eagle_xhr_info = {
                    url, method, status: null
                };
                return _originOpen.apply(this, arguments);
            }
            let _originSend = xhr.prototype.send;
            xhr.prototype.send = function (value) {
                let _self = this;
                this._eagle_start_time = Date.now();
                //注意此处,是多个箭头的高阶函数
                let ajaxEnd = eventType => () => {
                    if (_self.response) {
                        let responseSize = null;
                        switch (_self.responseType) {
                            case 'json':
                                responseSize = JSON.stringify(_self.response).length;
                                break;
                            case 'arraybuffer':
                                responseSize = _self.response.byteLength;
                                break
                            default:
                                //注意这里:responseText和response区别
                                responseSize = _self.responseText.length;
                                break
                        }
                        _self._eagle_xhr_info.event = eventType;
                        _self._eagle_xhr_info.status = _self.status;
                        _self._eagle_xhr_info.success = _self.status === 200;
                        _self._eagle_xhr_info.duration = Date.now() - _self._eagle_start_time;
                        _self._eagle_xhr_info.responseSize = responseSize;
                        _self._eagle_xhr_info.requestSize = value ? value.length : 0;
                        _self._eagle_xhr_info.type = 'xhr';
                        cb(_self._eagle_xhr_info);
                    }
                    //注意:如果sdk也报错,如果不做任何处理会被sdk错误统计上报,然后上报继续报错,不一会CPU就是满,所以sdk错误需要捕获,然后特殊处理
                };
                //这三种状态都代表着请求已经结束了,需要统计一些信息并上报
                this.addEventListener('load', ajaxEnd('load'), false);
                this.addEventListener('error', ajaxEnd('error'), false);
                this.addEventListener('abort', ajaxEnd('abort'), false); //取消请求
                //上面是统计逻辑,这才是真实调用网络请求,例如json请求
                return _originSend.apply(this, arguments);
            }
            //ftech hook
            if (window.fetch) {
                let _origin_fetch = window.fetch;
                window.fetch = function () {
                    let startTime = Date.now();
                    let args = [].slice.call(arguments);
    
                    let fetchInput = args[0];
                    let method = 'GET';
                    let url = null;
                    if (typeof fetchInput === 'string') {
                        url = fetchInput
                    } else if ('Request' in window && fetchInput instanceof window.Request) {
                        url = fetchInput.url;
                        if (fetchInput.method) {
                            method = fetchInput.method;
                        }
                    } else {
                        url = '' + fetchInput;
                    }
    
                    let eagleFetchData = {
                        method, url, status: null
                    }
                    return _origin_fetch.apply(this,args).then(function(response){
                        eagleFetchData.status=response.status;
                        eagleFetchData.type='fetch';
                        eagleFetchData.status=response.status;
                        eagleFetchData.duration = Date.now() - startTime;
                        cb(eagleFetchData);
                        return response;
                    })
                }
            }
        }
    }
    
    1. 自身sdk请求不需要拦截,否则死循环
    2. 此处处理了两个一个是fetch一个是XMLHttpRequest

    行为监控

    行为监控重点只说一下用户行为路径,其他不做讨论,因为各种方式不同,其中打点监控,例如用户点击一下触发一次行为监控也算,方式很多。

    核心是通过xpath形式,统计用户的行为,例如点击了什么

    // /html/body/ul[1]/li[1]    xpath
    
    
    /**
     * 当然xpath不能直接这么用,因为很多dom点击不需要上传
     * 但是比如说支付按钮,则肯定有特殊id或者类名,可以这样过滤需要的
     */
    
    
    
    //获取我的元素是兄弟元素的第几个
    let getIndex=ele=>{
        let children=[].slice.call(ele.parentNode.children);
        let myIndex=null;
    
        children=children.filter(node=>node.tagName===ele.tagName);
        for (let i = 0; i < children.length; i++) {
            if (ele===children[i]) {
                myIndex=i;
                break
            }
        }
        myIndex= `[${myIndex+1}]`;
        let tagName=ele.tagName.toLocaleLowerCase();
        let myLabel=tagName+myIndex;
        return myLabel;
    }
    let getXpath=ele=>{
        let xpath='';
        let currentEle=ele;
        while (currentEle!==document.body) {
            xpath=getIndex(currentEle)+'/'+xpath;
            currentEle=currentEle.parentNode;
        }
    
    }
    export default{
        init:cb=>{
            document.addEventListener('click',e=>{
                let target=e.target;
                let xpath=getXpath(target);
    
            },false);
        }
    }
    

    全局注册点击监控,然后通过递归遍历,查找到具体是点击什么,形成xpath类似于这种/html/body/ul[1]/li[1],然后可以通过不同类名或者id去过滤需要的行为。例如:支付按钮的样式肯定不同,而且一般不可能统计所有行为,因为数据量太大,如果完全不讲究全部上传也行,那样就需要后端处理逻辑去过滤行为,否则行为统计页面数据量太多,完全无法有效观看。

    补充:前端页面打点为什么一般要用gif打点

    参考-拷贝: https://blog.csdn.net/weixin_37719279/article/details/103476567?utm_medium=distribute.pc_aggpage_search_result.none-task-blog-2allfirst_rank_v2~rank_v25-7-103476567.nonecase&utm_term=%E6%89%93%E7%82%B9%E7%9A%84%E6%84%8F%E6%80%9D

    所谓的前端监控,其实是在满足一定条件后,由Web页面将用户信息(UA/鼠标点击位置/页面报错/停留时长/etc)上报给服务器的过程。一般是将上报数据用url_encode(百度统计/CNZZ)或JSON编码(神策/诸葛io)为字符串,通过url参数传递给服务器,然后在服务器端统一处理。

    这套流程的关键在于:

    • 能够收集到用户信息;
    • 能够将收集到的数据上报给服务器。也就是说,只要能上报数据,无论是请求GIF文件还是请求js文件或者是调用页面接口,服务器端其实并不关心具体的上报方式。

    向服务器端上报数据,可以通过请求接口,请求普通文件,或者请求图片资源的方式进行。为什么所有系统都统一使用了请求GIF图片的方式上报数据呢?

    • 首先,为什么不能直接用GET/POST/HEAD请求接口进行上报?

    这个比较容易想到原因。一般而言,打点域名都不是当前域名,所以所有的接口请求都会构成跨域。而跨域请求很容易出现由于配置不当被浏览器拦截并报错,这是不能接受的。所以,直接排除。

    • 其次,为什么不能用请求其他的文件资源(js/css/ttf)的方式进行上报?

    这和浏览器的特性有关。通常,创建资源节点后只有将对象注入到浏览器DOM树后,浏览器才会实际发送资源请求。反复操作DOM不仅会引发性能问题,而且载入js/css资源还会阻塞页面渲染,影响用户体验。

    但是图片请求例外。构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点,这是其他类型的资源请求所做不到的。

    • 那还剩下最后一个问题,同样都是图片,上报时选用了1x1的透明GIF,而不是其他的PNG/JEPG/BMP文件

    首先,1x1像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,这样一来不会影响页面本身展示效果,二者表示图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。因为需要透明色,所以可以直接排除JEPG(BMP32格式可以支持透明色)。

    • 然后还剩下BMP、PNG和GIF,但是为什么会选GIF呢?

    因为体积!,gif最小,最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节。同样的响应,GIF可以比BMP节约41%的流量,比PNG节约35%的流量。这样比较一下,答案就很明显了。

    总结

    前端监控使用GIF进行上报主要是因为:

    • 没有跨域问题;
    • 不会阻塞页面加载,影响用户体验;
    • 在所有图片中体积最小,相较BMP/PNG,可以节约41%/35%的网络资源。

    相关文章

      网友评论

          本文标题:前端监控

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