軌跡回放(錄屏)功能- 前端監控之數據收集篇

因爲前端環境的複雜性, 不少時候,咱們不僅是須要關注用戶報出的錯誤, 還想知道是什麼狀況下發送,如何復現,這時候用戶軌跡就很重要了。html

本文將參考常見實現方案,進行優化和實現前端

1. 常見實現方案

a. 業界牛選: Sentry, sentry是一款很強大的前端錯誤監控軟件, 而且能夠經過安裝plugins來實現其餘不少功能。

他對於軌跡的實現:node

若是有錯誤發生,會將一段時間內的用戶操做行爲及自定義行爲發生至服務器, 在錯誤詳情中顯示成以下:api

這種方案,若是收集的信息比較有針對性, 是能夠基本定位出問題的,瀏覽器

缺點有二: 1.不夠直觀。 2.須要特定的埋點, 不然查找的時候須要對照代碼看了 安全

b. 驚豔之選: logrocket, 看截圖

它將用戶行爲直觀的回放了出來, 而且按時間節點展現了請求、資源、console相關的狀況,一目瞭然服務器

缺點: 只支持高版本瀏覽器(由於用到了mutation相關api) 框架

2. 知曉了兩種方式以後,如何優化和實現呢

a. 優化方式a

能夠在後臺建立一個標註的表,對埋點的數據進行標註, 當咱們還原軌跡時,只須要將標誌對應的還原回來便可。 實現方式比較簡單, 不展開講dom

b. 實現相似logrocket錄屏

本篇主要分享在數據收集方面的實踐, 數據解析將在數據分析篇分享mvvm

初始化

在初始化進入頁面時, 能夠將頁面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

閱讀原文

相關文章
相關標籤/搜索