Due to the complexity of the front-end environment, most of the time, we not only need to pay attention to the error reported by the user, but also want to know under what circumstances to send, how to reproduce, at this time the user trajectory is very important.

This paper will refer to common implementation schemes for optimization and implementation

1. Common implementation schemes

A. Sentry is a very powerful front-end error monitoring software, and you can install plugins to implement many other functions.

His implementation of the trajectory:

If an error occurs, user operations and user-defined behaviors within a period of time are displayed on the server. The error details are as follows:

This scheme, if the information collected is more targeted, can basically locate the problem,

There are two disadvantages: 1. It is not intuitive. 2. Need a specific buried point, otherwise when looking for the need to look at the code

B. Amazing choice: Logrocket, screenshot

It provides an intuitive replay of user behavior and shows requests, resources, and console-related information by time node

Cons: Only supports older browsers (due to mutation related apis)

2. Knowing the two ways, how to optimize and implement them

A. Optimization method A

A marked table can be created in the background to mark the data of buried points. When we restore the track, we only need to restore the corresponding marks back. The implementation method is relatively simple and will not be expanded

B. Achieve screen recording similar to Logrocket

This article focuses on the practice of data collection, and data analysis will be shared in the data analysis section

Initialize the

There are many types of DOM Tree nodes that can be used to build a simple DOM Tree node that can be restored in the background by traversing the page during initialization

Name Value
ELEMENT_NODE tag type 1
ATTRIBUTE_NODE properties 2
TEXT_NODE Text type 3
CDATA_SECTION_NODE XML comment description 4
ENTITY_REFERENCE_NODE 5
ENTITY_NODE 6
PROCESSING_INSTRUCTION_NODE 7
COMMENT_NODE comment node 8
DOCUMENT_NODE document 9
DOCUMENT_TYPE_NODE doctype 10
DOCUMENT_FRAGMENT_NODE 11
NOTATION_NODE 12

Select the nodes that have an impact on the page display and perform the transformation as follows

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
    }

Copy the code

Collect event

Node collection is the skeleton of the restoration, but it also needs to restore the user’s operation track, at this time, it needs to restore the user’s input and mouse operation

Monitoring mouse operations are as follows

switch(type) {
            /** * The first and last events will be output if the number of events is greater than 50
        case "mousemove":
            var recode = function() {
                if(eventLists.length > 0) {
                    // Fetch the first and last and send them to the server
                    addEventToQueue(eventLists.shift())
                    addEventToQueue(eventLists.pop())
                }
            }
            return function(e) {
                mmT && clearTimeout(mmT);
                mmT = setTimeout(function() {
                    // Record the Mousemove event
                    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)
                        })
                    }
                }
            }
        // Add anti-shake processing
        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})
                    // To prevent a short period of no change, the target node + position is uniquely determined
                    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)
            }
    }
Copy the code

With the mouse operation, we can restore the user’s operation track in the monitoring platform

Monitoring Node Changes

The third and most important step is to monitor and collect dynamic changes of DOM nodes

This is where you need to use the 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()
        }
Copy the code

The checkMutation method collects and rewrites the changed nodes in a format that monitors platform restoration

Why use setTimeout in the mutationObserver callback? The reason is that mutation is a microtask, and if your business uses an MVVM framework like KO (which uses a timeout delay to update data), the data update will be blocked.

When a mutaionObserver listens, it generates a list of MutationRecord objects

Depending on the type of recode, different processing needs to be done

①. CharacterData changes. You only need to send the new node content to the server
 case "characterData":
            if(isIgnore(target)){
                break;
            }
            var h = target.textContent;
            if(h ! == record.oldValue) {// Build a changing 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;
Copy the code
②. Attributes changes need to list new or deleted attribute data and send it to the server
case "attributes":
            if(isIgnore(target)){
                break;
            }
            var attrName = record.attributeName; // Get the name of the modified property
            var adds = {}, removes = {}
            // Retrieve the value of the changed attribute
            if(target.hasAttribute(attrName)) {
                // If yes, it is modified
                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 {
                // Delete it
                removes[attrName]=true
            }
            // Send only when there is data
            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;
Copy the code
③. When the child node changes, the current node and adjacent nodes need to be sent to determine the exact position of the node
var addNodes = record.addedNodes || [];
            var removeNodes = record.removedNodes || [];

            if(addNodes && addNodes.length > 0) {
                // wrap through
                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) }})
                }
            }

Copy the code

The preliminary results are as follows

The above is the practice of screen recording data collection in our internal monitoring system. Of course, there are many details in the middle, which are not listed one by one. Welcome to give your opinions ~

In the end, security is still our priority. In the process of data collection, we will also remove or desensitize sensitive data by means of marking and filtering

References:

  1. Logrocket trial version

  2. MDN node

  3. MDN mutationObserver

Read the original