Solo  当前访客:0 开始使用

前端监控之轨迹回放功能


由于前端环境的复杂性, 很多时候,我们不只是需要关注用户报出的错误, 还想知道是什么情况下发送,如何复现,这时候用户轨迹就很重要了。

本文将参考常见实现方案,进行优化和实现

1. 常见实现方案

  a. 业界牛选: Sentry, sentry是一款很强大的前端错误监控软件, 并且可以通过安装plugins来实现其他很多功能。

他对于轨迹的实现: 如果有错误发生,会将一段时间内的用户操作行为及自定义行为发生至服务器, 在错误详情中显示成如下:

这种方案,如果收集的信息比较有针对性, 是可以基本定位出问题的,

缺点有二: 1.不够直观。2.需要特定的埋点, 否则查找的时候需要对照代码看了

b. 惊艳之选: logrocket, 看截图

    

它将用户行为直观的回放了出来, 并且按时间节点展示了请求、资源、console相关的情况,一目了然

缺点: 只支持高版本浏览器(因为用到了mutation相关api)

2. 优化1.a中方式

可以在后台创建一个标注的表,对埋点的数据进行标注, 当我们还原轨迹时,只需要将标志对应的还原回来即可。 实现方式比较简单, 不展开讲

3. 实现1.b中方式

3.1 数据收集

由于需要还原用户页面的操作, 首先需要将页面还原

a. 在初始化进入页面时, 可以将页面dom tree遍历一遍, 构建出一个用于在后台还原的简易dom tree

 node类型有很多

NameValue
ELEMENT_NODE 标签类型 1
ATTRIBUTE_NODE  属性 2
TEXT_NODE 文本类型 3
CDATA_SECTION_NODE xml注释描述 4
ENTITY_REFERENCE_NODE   5
ENTITY_NODE  6
PROCESSING_INSTRUCTION_NODE 7
COMMENT_NODE 注释节点 8
DOCUMENT_NODE document 9
DOCUMENT_TYPE_NODE doctype 10
DOCUMENT_FRAGMENT_NODE 11
NOTATION_NODE   12

 选择其中对页面展示有影响的节点, 进行转换, 如下

var type = node.nodeType;
    var tagName = node.tagName && node.tagName.toLowerCase()
    switch(type){
        case Node.ELEMENT_NODE:
            var attrs = {}
            for(let ai = 0; ai< node.attributes.length; ai++) {
                let attr = node.attributes[ai]
                attrs[attr.name] = attr.value
            }
            var value = node.value;
              if("input" === tagName || "select" === tagName) {
                var eleType = node.getAttribute("type");
                "radio" === eleType || "checkbox" === eleType ? attrs.defaultChecked = !!node.checked : "file" !== eleType && (attrs.defaultValue = value)
            }
            return {group: "node",id: mNode.id, nodeType:Node.ELEMENT_NODE, nodeInfo: {tagName: tagName, attributes: attrs, childNodes: []}};
        case Node.TEXT_NODE:
            var o = node.parentNode && node.parentNode.tagName, content = node.textContent;
            return "SCRIPT" === o && (content = ""), {
                group: "node", id: mNode.id, nodeType:Node.TEXT_NODE,
                nodeInfo: {textContent: content, isStyleNode: "STYLE" === o}
            };
        case Node.COMMENT_NODE:
            return {
                group: "node", id: mNode.id, nodeType:Node.COMMENT_NODE,
                nodeInfo: {textContent: node.textContent}
            };
        case Node.DOCUMENT_TYPE_NODE:
            return {
                group: "node", id: mNode.id, nodeType:Node.DOCUMENT_TYPE_NODE,
                nodeInfo: {
                    name: node.name || "",
                    publicId: node.publicId || "",
                    systemId: node.systemId || ""
                }
            };
        case Node.DOCUMENT_NODE:
            return {group: "node", id: mNode.id, nodeType:Node.DOCUMENT_NODE, nodeInfo: {childNodes: []}};
        case Node.CDATA_SECTION_NODE:
            return {group: "node", id: mNode.id, nodeType:Node.CDATA_SECTION_NODE, nodeInfo: {textContent: "", isStyleNode: !1}};
        default:
            return null
    }

 

b. 节点收集是搭架了还原的骨架, 但是还需要对用户操作轨迹进行还原,这时候就需要还原用户点的输入、鼠标操作

监控鼠标操作如下

switch(type) {
            /**
             * 每 50ms 触发一次事件记录, 或者events 数大于50时, 将第一次和最后一次输出
             * @param event
             */
        case "mousemove":
            var recode = function() {
                if(eventLists.length > 0) {
                    // 取出第一个和最后一个 发送到服务器
                    addEventToQueue(eventLists.shift())
                    addEventToQueue(eventLists.pop())
                }
            }
            return function(e) {
                mmT && clearTimeout(mmT);
                mmT = setTimeout(function() {
                    // 记录下mousemove事件
                    recode();
                    eventLists = [];
                }, 50)
                eventLists.push(getEventStructure(e))
                if(eventLists.length>=50) {
                    recode()
                }
            }
        case "mouseup":
        case "mousedown":
        case "click":
        case "dblclick":
            return function(e) {
                addEventToQueue(getEventStructure(e))
            }
        case "input":
        case "change":
            function record (node, info, e) {
                addEventToQueue(getEventStructure(e, info))
            }
            return function(ev) {
                var t = ev.target;
                if(t) {
                    var n = t.tagName;
                    // 如果是 input  textarea select
                    if(n && ("INPUT" === n || "TEXTAREA" === n || "SELECT" === n)) {
                        var o = t.type && t.type.toLowerCase(),
                            isChecked = ("radio" === o || "checkbox" === o) && !!t.checked,
                            s = mirrorNode.getId(t);
                        record(t, {
                            text: t.value,
                            isChecked: isChecked
                        }, ev)
                        "radio" === o && t.name && isChecked && [].forEach.call(document.querySelectorAll('input[type=radio][name="' + t.name + '"]'), function(e) {
                            e !== t && record(e, {text: e.value, isChecked: !isChecked}, ev)
                        })
                    }
                }
            }
        // 加防抖处理
        case "resize":
            return function(e) {
                var t = null;
                null != window.innerWidth ? t = window.innerWidth :
                        null != document.documentElement && null != document.documentElement.clientWidth ? t = document.documentElement.clientWidth :
                        null != document.body && null != document.body.clientWidth && (t = document.body.clientWidth);
                var r = void 0;
                null != window.innerHeight ? r = window.innerHeight :
                        null != document.documentElement && null != document.documentElement.clientHeight ? r = document.documentElement.clientHeight :
                        null != document.body && null != document.body.clientHeight && (r = document.body.clientHeight);
                addEventToQueue(getEventStructure(e, { type: "resize",
                    width: "string" == typeof t ? parseInt(t, 10) : t,
                    height: "string" == typeof r ? parseInt(r, 10) : r
                }))
            }
        case "scroll":
            var t = function(e) {
                if(scrollTimer) {
                    clearTimeout(scrollTimer)
                }
                scrollTimer = setTimeout(function() {
                    var r = e.target.scrollTop, n = e.target.scrollLeft;
                    if(e.target === document) {
                        var o = document.documentElement;
                        r = (window.pageYOffset || o.scrollTop) - (o.clientTop || 0), n = (window.pageXOffset || o.scrollLeft) - (o.clientLeft || 0)
                    }
                    var curInfo = JSON.stringify({id: mirrorNode.getId(e && e.target), top: r, left: n})
                    // 防止短期无变化的情况, 以目标节点 + 位置 唯一确定
                    if(lastScrollInfo != curInfo){
                        addEventToQueue(getEventStructure(e, { type: "scroll",
                            top: r, left: n
                        }))
                    }
                },100)
            };
        return t
    case "touchstart":
    case "touchmove":
    case "touchend":
            return function(e) {
                if(null != e.touches) {
                    var r = e.touches.length &gt; 0 ? e.touches[0] : e.changedTouches[0];
                    addEventToQueue(getEventStructure(null, {type, target: e.target, clientX: r.clientX, clientY: r.clientY,  button: 0}))
                }
            }
    default:
        return function(e) {
            console &amp;&amp; console.log(e)
        }
}</pre>

有了鼠标操作之后,我们就可以在监控平台对用户的操作轨迹进行还原

c. 第三步,也是最重要的一步,那就是需要将dom节点的动态变化进行监控收集, 

这时候就需要使用到 mutationObserver api相关内容

var mutation = new (window.MutationObserver)(function(e) {
            if(e && e.length>0){
                for(var i=0;i<e.length; i++) {
                    setTimeout(function(item) {
                        return function() {checkMutation(item, reporter)}
                    }(e[i]),0)
                }
                // _mutationRecordMerge(e, reporter)
            }
        });
        mutation.observe(document, {
            childList: !0,
            subtree: !0,
            characterData: !0,
            characterDataOldValue: !0,
            attributes: !0,
            attributeOldValue: !0
        }), function() {
            mutation.disconnect()
        }

checkMutation方法会将, 变化的节点收集并重写成监控平台还原的格式

mutaionObserver监听时会生成一个MutationRecord对象的列表

注:为什么还需要在mutationObserver回调中使用setTimeout? 原因是mutation属于微任务,如果你的业务中使用了类似ko之类的mvvm框架(会使用类似timeout延迟更新数据), 会导致数据更新被阻塞。

 

根据recode的类型不同, 需要做不同的处理

①. characterData 变化,只需要将节点新内容发送到服务器即可

        case "characterData":
            if(isIgnore(target)){
                break;
            }
            var h = target.textContent;
            if(h !== record.oldValue) {
                // 构建一个变化的 recode
                modifyList.push({group: "mutation", id:tid, time: getTime(), pid: mirrorNode.getId(record.target.parentNode), type, operation:"change", nodeInfo: {textContent:h, isStyleNode: "STYLE" === (target.parentNode && target.parentNode.tagName)}})
            }
            break;

②. attributes变化,需要将新增或删除的属性数据列出发送到服务器

case "attributes":
            if(isIgnore(target)){
                break;
            }
            var attrName = record.attributeName; // 获得修改的属性名称
            var adds = {}, removes = {}
            // 取出变化的属性值
            if(target.hasAttribute(attrName)) {
                // 如果有, 就是修改
                var val = target.getAttribute(attrName);
                if(val !== record.oldValue) {
                    var tagName = target.tagName.toLowerCase();
                    if("input" !== tagName && "textarea" !== tagName || "value" !== attrName)
                        if("class" === attrName) {
                            // var R = m(x);
                            //adds[attrName] = val + " " + R
                            adds[attrName] = val
                        } else adds[attrName] = val;
                    else adds.value = ""
                }
            } else if("class" === attrName) {
                removes[attrName]=true
            } else {
                // 没有即删除了
                removes[attrName]=true
            }
            // 有数据的情况下,才发送
            if(Object.keys(adds).length > 0 || Object.keys(removes).length > 0 ){
                modifyList.push({group: "mutation",id:tid, time: getTime(), type, operation:"change", nodeInfo: {attributes:adds, removeAttributes: removes}})
            }
            break;

 ③. 子节点变化, 需要发送当前节点及相邻节点以确定节点确切的位置

var addNodes = record.addedNodes || [];
            var removeNodes = record.removedNodes || [];
        if(addNodes &amp;&amp; addNodes.length &gt; 0) {
            // 遍历进行包装
            let addNodeWrap = [];
            for(let add =0; add&lt; addNodes.length; add ++ ) {
                let curNode = addNodes[add]
                doInitNode(curNode, addNodeWrap);
            }

            modifyList.push({group: "mutation",id:tid, time: getTime(), type, operation:"AddOrRemove", nodeInfo: {addedNodes: addNodeWrap, removedNodes: null, prev:mirrorNode.getId(record.previousSibling), next: mirrorNode.getId(record.nextSibling) }})
        }
        if(removeNodes &amp;&amp; removeNodes.length &gt; 0){
            let rmNodes = []
            for(let rm =0;rm&lt; removeNodes.length; rm ++) {
                let rmId = mirrorNode.getId(removeNodes[rm])
                if(rmId){
                    rmNodes.push({group: "node", id: rmId, nodeType: removeNodes[rm].nodeType})
                }
            }
            if(rmNodes.length &gt; 0){
                modifyList.push({group: "mutation",id:tid, time: getTime(), type, operation:"AddOrRemove", nodeInfo: {addedNodes: null, removedNodes: rmNodes, prev:mirrorNode.getId(record.previousSibling), next: mirrorNode.getId(record.nextSibling) }})
            }
        }</pre>

 

3.2 数据还原

实现效果如下:

①. 要在页面中模拟播放, 首先得了解iframe的特性, 

 iframe sandbox属性, 可以让我们方便的操作iframe,并将播放的代码和监控平台代码有效隔离

②. 就是还原节点和鼠标操作, 这类就没有什么难度了, 逐条进行还原即可

主要使用的方法有

createElement

querySelector

appendChild

removeChild

 

参考资料:
1. logrocket 试用版 

2. MDN node

3. MDN mutationObserver

 

 


标题:前端监控之轨迹回放功能
作者:hugh0524
地址:https://blog.uproject.cn/articles/2019/05/13/1557720877077.html

, , , , , 0 0