轨迹回放(录屏)功能- 前端监控之数据收集篇

5,343 阅读6分钟

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

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

1. 常见实现方案

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

他对于轨迹的实现:

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

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

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

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

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

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

2. 知晓了两种方式之后,如何优化和实现呢

a. 优化方式a

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

b. 实现类似logrocket录屏

本篇主要分享在数据收集方面的实践, 数据解析将在数据分析篇分享

初始化

在初始化进入页面时, 可以将页面dom tree遍历一遍, 构建出一个用于在后台还原的简易dom tree node类型有很多

Name Value
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
    }

收集事件

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

监控鼠标操作如下

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 > 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 && console.log(e)
            }
    }

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

监控节点变化

第三步,也是最重要的一步,那就是需要将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方法会将, 变化的节点收集并重写成监控平台还原的格式

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

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

根据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 && addNodes.length > 0) {
                // 遍历进行包装
                let addNodeWrap = [];
                for(let add =0; add< 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 && removeNodes.length > 0){
                let rmNodes = []
                for(let rm =0;rm< removeNodes.length; rm ++) {
                    let rmId = mirrorNode.getId(removeNodes[rm])
                    if(rmId){
                        rmNodes.push({group: "node", id: rmId, nodeType: removeNodes[rm].nodeType})
                    }
                }
                if(rmNodes.length > 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) }})
                }
            }

初步效果如下

以上就是录屏数据收集在我们内部监控系统的实践,当然中间还有很多细节,并没有一一列出, 欢迎大家给出意见~

写在最后, 安全性依旧是我们需要重点考虑的, 在数据收集的过程中, 我们同样会采用标记、过滤等手段将敏感数据去除或脱敏

参考资料:

  1. logrocket 试用版

  2. MDN node

  3. MDN mutationObserver

阅读原文