因爲前端環境的複雜性, 不少時候,咱們不僅是須要關注用戶報出的錯誤, 還想知道是什麼狀況下發送,如何復現,這時候用戶軌跡就很重要了。html
本文將參考常見實現方案,進行優化和實現前端
他對於軌跡的實現:node
若是有錯誤發生,會將一段時間內的用戶操做行爲及自定義行爲發生至服務器, 在錯誤詳情中顯示成以下:api
這種方案,若是收集的信息比較有針對性, 是能夠基本定位出問題的,瀏覽器
缺點有二: 1.不夠直觀。 2.須要特定的埋點, 不然查找的時候須要對照代碼看了 安全
它將用戶行爲直觀的回放了出來, 而且按時間節點展現了請求、資源、console相關的狀況,一目瞭然服務器
缺點: 只支持高版本瀏覽器(由於用到了mutation相關api) 框架
能夠在後臺建立一個標註的表,對埋點的數據進行標註, 當咱們還原軌跡時,只須要將標誌對應的還原回來便可。 實現方式比較簡單, 不展開講dom
本篇主要分享在數據收集方面的實踐, 數據解析將在數據分析篇分享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的類型不一樣, 須要作不一樣的處理
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;
複製代碼
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 試用版