前端监控之轨迹回放功能
由于前端环境的复杂性, 很多时候,我们不只是需要关注用户报出的错误, 还想知道是什么情况下发送,如何复现,这时候用户轨迹就很重要了。
本文将参考常见实现方案,进行优化和实现
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类型有很多
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 }
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 > 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) } }</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 && 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) }}) } }</pre>
3.2 数据还原
实现效果如下:
①. 要在页面中模拟播放, 首先得了解iframe的特性,
iframe sandbox属性, 可以让我们方便的操作iframe,并将播放的代码和监控平台代码有效隔离
②. 就是还原节点和鼠标操作, 这类就没有什么难度了, 逐条进行还原即可
主要使用的方法有
createElement
querySelector
appendChild
removeChild
参考资料:
1. logrocket 试用版2. MDN node
标题:前端监控之轨迹回放功能
作者:hugh0524
地址:https://blog.uproject.cn/articles/2019/05/13/1557720877077.html
0 0