【前端监控】数据如何获取及上报?


写在前面

字面解释:前端监控就是监控前端所发生的一些行为

我们可能都遇到过代码都上生产了,结果出现了一些未知的问题;我们也可能想通过查看线上的数据,看看哪些需求带来了收益,哪些并没有。基于这些原因,我们就想明白了为什么要前端监控

  • 1、更快的发现问题并解决

  • 2、为产品提需求提供可靠的依据,为业务拓展提供更多的可能性

所以,我们需要这么一个监控系统,去帮助我们~本篇文章就将说关于前端监控的这些事~

搭建前端监控系统

监控目标

JavaScript层

  • JS执行错误

  • Promise异常

  • 接口错误

  • 页面空白

用户体验层

  • 加载时间 ----> 各个阶段的加载时间

  • TTFB(Time To Firstbyte) ----> 首字节时间

指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包括了网络请求时间、后端处理时间

  • FP(First Paint) -----> 首次绘制

首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻

  • FCP(First Content Paint) ----> 首次内容绘制

首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间

  • FMP(First Meaningful Paint) -----> 首次有意义的绘制

首次有意义的绘制是页面可用性的量度标准

  • LCP(Large Contentful Paint) -----> 最大内容渲染

代表在viewport中最大的页面元素加载时间

  • FID(First Input Delay) -----> 首次输入延迟

用户首次和页面交互到页面响应交互的时间

  • 卡顿:超过50ms

业务层面

  • PV(Page View):页面浏览量或点击量

  • UV():指访问某个站点的不同ip地址的人数

  • 页面停留时间:用户在每一个页面的停留时间

前端监控的流程

埋点方案

上面说到的流程当中,第一步就是要有埋点,那么常见的埋点方案都有哪些呢

代码埋点

嵌入到代码中进行埋点,比如点击事件

  • 优点:可以在任意时刻,精确发送或保存所需要的数据信息
  • 缺点:工作量大

可视化埋点

通过可视化交互的手段,代替代码埋点,将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后代码输出耦合了业务代码和埋点代码

  • 优点:代替了手工插入埋点

  • 缺点:不灵活

无痕埋点

前端的任意一个事件都被绑定一个标识,所有事件都被记录下来;通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告供专业人员分析

  • 优点:采集全量数据,不会出现漏埋和误埋现象

  • 缺点:给数据传输和服务器增加压力,也无法灵活定制数据结构

开始编写

数据统一上报方法

我们会上报很多的数据,但是这些数据都有一个共同的结构,有一个共同的上报方案~

首先我们会准备在阿里云开通日志服务,这是用来存储我们上报的数据的~

阿里云日志服务

在上面这个链接登录然后选到控制台,找到日志服务并开通,最终进入到这个页面就成功啦~

具体教程可以参考这里:日志服务教程

就像我们平时和后端用接口来来回回的传数据一样,上报到阿里云日志的数据也是有接口的,具体可见:putWebtracking

下面我们来写一下代码

let userAgent = require("user-agent");

let host = "cn-beijing.log.aliyuncs.com";
let project = "zyq-monitor";
let logStore = "zyq-monitor-storage";

// 共用数据
function getExtraData({
    return {
        titledocument.title,
        url: location.href,
        timestampDate.now(),
        userAgent: userAgent.parse(navigator.userAgent).name,
    }
}

class SendTracker {
    constructor() {
        this.url = `http://${project}.${host}/logstores/${logStore}/track`;  // 上报路径
        this.xhr = new XMLHttpRequest;
    }
    send(data = {}) {
        let extraData = getExtraData();
        let log = {...extraData, ...data};
        // 这里是阿里云要求对象的值不能是数字,所以要转成字符串
        for(let key in log) {
            if(typeof log[key] === "number") {
                log[key] = `${log[key]}`;
            }
        }
        let body = JSON.stringify({
            __logs__: [log]
        });
        this.xhr.open("POST"this.url, true)
        this.xhr.setRequestHeader("Content-Type""application/json");
        // 下面两个请求头是阿里云要求的
        this.xhr.setRequestHeader("x-log-apiversion""0.6.0");
        this.xhr.setRequestHeader("x-log-bodyrawsize", body.length);  // 请求体大小
        this.xhr.onload = function({
            // console.log(this.xhr.response);
        }
        this.xhr.onerror = function(error{
            // console.log(error);
        }
        this.xhr.send(body);
    }
}

export default new SendTracker()

JavaScript层面

JavaScript层主要监听了一些错误的发生,比如说:语法错误、promsie的异常等等,下面我们一点一点说

先大致说一下JavaScript层上报数据需要哪些字段(每个错误上报可能会有不一致的地方)

{
    kind,      // 监控指标的大类
    type,      // 小类型 错误
    errorType, // js或css加载错误
    filename,  // 哪个文件报错
    tagName,   // 报错的标签名
    selector,  // 代表最后操作的元素
}
  • JS语法错误

使用window.addEventListener监听error事件,获取事件对象,取出对应的值~

let lastEvent = getLastEvent();
window.addEventListener("error", e => {
    tracker.send({
        kind: "stability"
        type: "error",   
        errorType: "jsError",
        message: event.message,
        stack: getLines(event.error.stack),
        filename: event.filename, 
        position: `${event.lineno}:${event.colno}`,
        selector: lastEvent ? getSelector(lastEvent.path) : ""
    })
}, true)

这里面调用了三个方法,一个是getLines(),另一个是getSelector()

// 获取堆栈信息
function getLines({
    return stack.split("\n").slice(1).map(item => item.replace(/^\s+at\s+/g"")).join("^");
}

// 获取选择器
function getSelector(pathOrTarget{  // 对象or数组
    if(Array.isArray(pathOrTarget)) {
        return getSelectors(pathOrTarget)
    } else {
        let path = [];
        while(pathOrTarget) {
            path.push(pathOrTarget);
            pathOrTarget = pathOrTarget.parentNode;
        }
        return getSelectors(path)
    }
}
// 获取最后一个交互事件
function getLastEvent({
    let lastEvent;
    ["click""touchstart""mousedown""keydown",         "mouseover"].forEach(eventType => {
    document.addEventListener(eventType, event => {
        lastEvent = event;
    }, {
        capturetrue// 捕获阶段
        passive: true// 默认不阻止默认事件
    });
})
    return lastEvent;
}
  • Promise异常

Promise异常是通过监听unhandledrejection事件,获取事件对象~

window.addEventListener("unhandledrejection"function(event{
        let lastEvent = getLastEvent(); //最后一个交互事件
        let message;
        let filename;
        let line = 0;
        let col = 0;
        let stack = "";
        let reason = event.reason;
        if(typeof reason === "string") {
            message = reason
        } else if(typeof reason === "object") {
            message = reason.message
            if(reason.stack) {
                let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/)
                filename = matchResult[1];
                line = matchResult[2];
                col = matchResult[3];
            }
            stack = getLines(reason.stack);
        }
        tracker.send({
            kind"stability",
            type"error"
            errorType"promiseError",
            message,
            stack,
            filename,
            position`${line}:${col}`,
            selector: lastEvent ? getSelector(lastEvent.path) : ""
        })
    }, true)
  • 引入资源异常

通常,引用资源异常为资源找不到或者获取不到资源,它也是监听error事件,只不过他和JS执行异常有一些区别,我们是通过事件源去判断的

// 监听全局未捕获的错误
window.addEventListener('error', function(event) { 
    let lastEvent = getLastEvent(); //最后一个交互事件
    // 脚本加载错误
    if(event.target && (event.target.src || event.target.href)) {
        tracker.send({
            kind: "stability"
            type: "error",   
            errorType: "resourceError"
            filename: event.target.src || event.target.href, 
            tagName: event.target.tagName,
            selector: getSelector(event.target), 
        })
    } else {  // JS执行报错
        tracker.send({
            kind: "stability"
            type: "error",   
            errorType: "jsError"
            message: event.message, 
            stack: getLines(event.error.stack),
            filename: event.filename, 
            position: `${event.lineno}:${event.colno}`,
            selector: lastEvent ? getSelector(lastEvent.path) : ""
        })
    }
}, true)
  • 接口异常

在模拟接口成功或者异常时,我们可以在webpack中的devServer加入before,做一下拦截处理

 before(router) {
    router.get('/success'function(req, res{
        res.json({id1})
    })
    router.post('/error'function(req, res{
        res.sendStatus(500)
    })
}

然后对请求做一下处理

function injectXHR({
    let XMLHttpRequest = window.XMLHttpRequest;
    let oldOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url, async{
        // 这里是避免重复上报数据
        if(!url.match(/logstores/)) {
            this.logData = {
                method,
                url,
                async,
            }
        }

        return oldOpen.apply(thisarguments);
    }
    let oldSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function(body{
        if(this.logData) {
            let startTime = Date.now();  // 发送之前记录开始时间
            let handler = type => event => {
                let duration = Date.now() - startTime;
                let status = this.status;
                let statusText = this.statusText;
                tracker.send({
                    kind"stability",
                    type"xhr",
                    eventType: event.type,
                    pathnamethis.logData.url,
                    status: status + '-' + statusText,
                    duration,
                    responsethis.response ? JSON.stringify(this.response) : "",
                    params: body || ""
                })
            }
            this.addEventListener("load", handler("load"), false)
            this.addEventListener("error", handler("error"), false)
            this.addEventListener("abort", handler("abort"), false)
        }
        return oldSend.apply(thisarguments);
    }
}
  • 页面空白

页面空白的原因有很多,比如说一个小小的js错误就会导致页面空白,对页面空白的处理,我们采用取点的形式,然后看每个点上是否有对应的标签(可配置一下阈值,看多少个点更合适),如果点对应的标签都是body或者html的话,那么就可以认为此页面是空白的

function blankScreen({
    let wrapperElements = ["html""body""#container""content.main"];
    let emptyPoints = 0;
    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();
        }
    }
    function isWrapper(element{
        let selector = getSelector(element);
        if(wrapperElements.indexOf(selector) !== -1) {
            emptyPoints++;
        }
    }
    onload(function({
        for(let i = 1; i <=9 ; i++) {
            let xElements = document.elementsFromPoint(window.innerWidth * i / 10window.innerHeight / 2)
            let yElements = document.elementsFromPoint(window.innerWidth / 2window.innerHeight * i / 10)
            isWrapper(xElements[0]);
            isWrapper(yElements[0]);
        }
        if(emptyPoints >= 16) {  // 自定义
            let centerElements = document.elementsFromPoint(
                window.innerWidth / 2window.innerHeight / 2
            )
            tracker.send({
                kind"stability",
                type"blank",
                emptyPoints,
                screenwindow.screen.width + "X" + window.screen.height,
                viewPonintwindow.innerWidth + "X" + window.innerHeight,
                selector: getSelector(centerElements[0])
            })
        }
    })
}

function onload(callback{
    if(document.readyState === "complete") {
        callback();
    } else {
        window.addEventListener("load", callback)
    }
}

用户体验层面

对于用户体验层面的一些指标,上面有说,具体做法是监听一些指标以及从performance.timing对象中获取一些时间,进行上报操作,实际做法为:

function timing({
    let FMP, LCP;
    if(PerformanceObserver) {
        // 增加一个性能条目的观察者 FMP
        new PerformanceObserver((entryList, observer) => {
            let perfEntries = entryList.getEntries();
            console.log(perfEntries, "perfEntries");
            FMP = perfEntries[0];
            observer.disconnect();  // 不再观察了
        }).observe({entryTypes: ["element"]});  // 观察页面中有意义的元素
        // LCP
        new PerformanceObserver((entryList, observer) => {
            let perfEntries = entryList.getEntries();
            LCP = perfEntries[0];
            observer.disconnect();  // 不再观察了
        }).observe({entryTypes: ["largest-contentful-paint"]});  // 观察页面中有意义的元素
        // FID 首次输入延迟
        new PerformanceObserver((entryList, observer) => {
            let lastEvent = getLastEvent();
            let firstInput = entryList.getEntries()[0];
            console.log(firstInput, "firstInput");
            if(firstInput) {
                // 开始处理时间   开始点击时间  差值就是处理的延迟
                let inputDelay = firstInput.processingStart - firstInput.startTime;
                let duration = firstInput.duration;  // 处理耗时
                if(inputDelay > 0 || duration > 0) {
                    tracker.send({
                        kind"experience",  // 用户体验指标
                        type: "firstInputDelay",    // 首次输入延迟
                        duration, // 处理时间
                        inputDelay, // 延时时间
                        startTime: firstInput.startTime,
                        selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : "",
                    })
                }
            }
            observer.disconnect();  // 不再观察了
        }).observe({type"first-input"bufferedtrue});  // 用户第一次交互 点击页面等
    }
    onload(function({
        setTimeout(() => {
            const {
                fetchStart,
                connectStart,
                connectEnd,
                requestStart,
                responseStart,
                responseEnd,
                domLoading,
                domInteractive,
                domContentLoadedEventStart,
                domContentLoadedEventEnd,
                loadEventStart,
            } = performance.timing

            tracker.send({
                kind"experience",  // 用户体验指标
                type: "timing",    // 统计每个阶段的时间
                connectTiming: connectEnd - connectStart,  // 连接时间
                ttfbTime: responseStart - requestStart,    // 首字节时间
                responseTime: responseEnd - responseStart,  // 响应读取时间
                parseDOMTime: loadEventStart - domLoading,  // dom解析时间
                domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,
                timeToInteractive: domInteractive - fetchStart,  // 首次可交互时间
                loadTime: loadEventStart - fetchStart,   // 完整加载时间
            })

           let FP = performance.getEntriesByName("first-paint")[0]
           let FCP = performance.getEntriesByName("first-contentful-paint")[0]

            // 开始发送性能指标
            tracker.send({
                kind"experience",  // 用户体验指标
                type: "paint",    // 小类绘制
                firstPaint: FP.startTime,
                firstContentfulPaint: FCP.startTime,
                firsMeaningfulPaint: FMP.startTime,
                largestContentfulPaint: LCP.startTime,
            })
        }, 3000)
    })
}
  • 卡顿

对于页面卡顿,我们是最熟悉了,看视频时卡,玩游戏时卡等等场景,作为用户,我们遇见卡顿很难受,作为开发人员,我们要努力的去解决页面卡顿问题,所以现在我们需要一个监测页面卡顿的监控。其实监测页面卡顿就是监测页面的FPS

FPS是在浏览器渲染这些变化时的帧率,帧率越高,网页越流畅,反之则卡顿,最优的帧率时60fps,即为16.67ms左右渲染一次,也就是说,在浏览器显示页面的过程中,处理js以及渲染等,每个执行片段不能超过16.67ms,如超过,则认为卡顿。我们可以通过chrome的一些工具看得见fps(这里不做过多说明),但是在生产环境也就是在用户使用的时候,我们只能通过上报的方式,接下来说下如何通过代码进行上报卡顿

通过浏览器的ewquestAnimationFrame获取页面的fps,下面是代码

var lastTime = performance.now();
var frame = 0;
var lastFameTime = performance.now();
var loop = function(time) {
    var now =  performance.now();
    var fs = (now - lastFameTime);
    lastFameTime = now;
    var fps = Math.round(1000/fs);
    frame++;
    if (now > 1000 + lastTime) {
        var fps = Math.round( ( frame * 1000 ) / ( now - lastTime ) );
        frame = 0;    
        lastTime = now;    
    };           
    window.requestAnimationFrame(loop);   
}

获取到fps之后,就是监控页面的卡顿问题了,根据参考,我们将fps连续出现低于20三次即认为网页存在卡顿,代码如下

function isCaton(fpsList, below = 20, last = 3{
  var count = 0
  for(var i = 0; i < fpsList.length; i++) {
    if (fpsList[i] && fpsList[i] < below) {
      count++;
    } else {
      count = 0
    }
    if (count >= last) {
      return true
    }
  }
  return false
}

最后

参考文章

  • https://zhuanlan.zhihu.com/p/39292837

  • https://www.cnblogs.com/yincheng/p/avoid-jank.html

本篇文章说了下前端监控上报基础思想以及基础代码编写,在公司的底层建设过程中,可以将这些东西封装成一个库,以供各个业务去使用~这个时候,业务方只需要调用方法以及传送必要的参数即可(当然可能我们并不需要这些个指标,可能还需要更多,可能有的并不需要,这要和业务相吻合)

对于上报后的数据,我们可以拿来再做前端可视化内容,例如曲线等,通过这些内容,我们可以观测到数据的变化,同时更完善一些的话,可以配置报警功能,让开发人员更快的知道哪里出现了问题并及时解决~前端监控不仅仅停留在此篇文章,它仅仅是最最基础的一部分,后边一系列的流程还需要我们去探索~

刚刚接触前端监控两个月,便深深的感受到了它的重要性以及必要性,很多刚刚上线的业务监控报警,能够提醒我及时回滚代码或者修复问题,所以从头学起监控系统,并把知识分享给同样有需要的大家~如有不对之处,欢迎大家指出~

最后,分享下我的公众号「web前端日记」,欢迎小伙伴前来关注~~~


评论