由于前端环境的复杂性, 很多时候,我们不只是需要关注用户报出的错误, 还想知道是什么情况下发送,如何复现,这时候用户轨迹就很重要了。
本文将参考常见实现方案,进行优化和实现
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) }})
}
}
初步效果如下
以上就是录屏数据收集在我们内部监控系统的实践,当然中间还有很多细节,并没有一一列出, 欢迎大家给出意见~
写在最后, 安全性依旧是我们需要重点考虑的, 在数据收集的过程中, 我们同样会采用标记、过滤等手段将敏感数据去除或脱敏
参考资料:
-
logrocket 试用版