前言 作为一名前端,最头疼的问题莫过于用户反馈了线上问题但是却复现不了。有些复现不了的问题跟操作步骤有关,但是用户描述问题常常不够准确,需要通过客服再进行多次沟通。还有一种情况就是用户明明进行了某项操作(比如出价)却硬说自己没有任何点击行为。因此,对用户行为进行监控,对于定位线上问题和减少客诉来说很重要。
如果要监控用户的操作行为,我们采用的是埋点上报的形式,这样做的好处是上报准确,而且依托于我们现有的埋点搜集系统,使用起来也比较方便。缺点也比较明显,那就是对代码的耦合度比较高,如果新增操作或者改变交互,上报的方法也要随之变动,如果操作过程的上报有所遗漏,整个用户行为其实是不完整的。本文尝试采用通用的一种上报方式,可以追踪用户的所有轨迹,希望能够在不侵入业务代码的情况下,对用户轨迹进行上报。
用户行为 要监控用户行为,不仅仅要监控用户的操作,比如打开页面、点击等,还需要监控这些行为产生的的数据请求以及js报错问题。当然还有更复杂的用户行为,这里我们不做探讨。
我们可以用枚举定义上面的四类用户行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { jsError: { value: 0 , text: 'js报错' }, network: { value: 1 , text: '网络请求' }, navigation: { value: 2 , text: '打开页面' }, clickEvent: { value: 3 , text: '用户点击' } }
上报数据的格式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { base: { traceId: '' , ua: '' , url: '' , refer: '' , ... }, logs: [{ type: '' , value: 0 , data: {} }] }
用户行为的监控 打开页面 对于多页面应用来说,可以通过监听浏览器的 onload 事件来实现。但是在单页面应用中,因为只有在第一次进入页面才会触发 onload 事件,还需要监听路由的变化。单页面应用有两种路由模式:hash 模式和 history 模式,两者的处理方式有所差异:
hash 模式:hash 模式是通过改变 url 的 hash 值来实现无页面刷新的,hash 的变化会触发浏览器的 hashchange 事件,因此需要监听 hashchange 事件。
history 模式:history 模式是通过操纵浏览器原生的 history 对象实现的,方法如下:
1 2 3 4 5 history.go() history.forward() history.back() history.pushState() history.replaceState()
history.go、history.forward 和 history.back 3个方法会触发浏览器的 popstate 事件,但是 history.pushState 和 history.replaceState 这两个方法不会。如果要监听 history.pushState 和 history.replaceState,我们需要在 history.pushState 和 history.replaceState 方法中添加自定义事件:
1 2 3 4 5 6 7 8 9 10 11 12 function addHistoryEvent (eventName ) { if (history[eventName]) { const oFunc = history[eventName] return function ( ) { var res = oFunc.apply(this , arguments ) var event = new Event(eventName) event.arguments = arguments window .dispatchEvent(event) return res } } }
最后,我们给 window 增加监听事件来监控页面的打开:
1 2 3 4 5 function addNavigationListener ( ) { addEvent(window , 'load' , addNavigationLog) addEvent(window , 'hashchange' , addNavigationLog) addEvent(window , 'popstate' , addNavigationLog) }
js报错 我之前在前端异常处理 中总结过,如果要捕获全局的异常,需要监听 onerror 和 onunhandledrejection 事件,这里就不再重复了。
如果浏览器在页面加载之前向注入的 js 发生了错误(比如离线应用)是没有办法捕获到的,这时候我们需要重写下 console.error。如果 js(非浏览器注入)加载完成,需要停止 console.error 的错误上报,由 onerror 和 onunhandledrejection 来接管。
1 2 3 4 5 6 7 8 9 10 11 12 console .error = handleConsoleErrorconst errorMsg = arguments [0 ] && arguments [0 ].messageconst url = window .location.hrefconst lineNumber = 0 const columnNumber = 0 let errorObj = arguments [0 ] && arguments [0 ].stackif (!errorObj) errorObj = arguments [0 ]if (jsErrorReported) { handleError(errorMsg, url, lineNumber, columnNumber, errorObj) }
用户点击 监控用户的点击行为,就是重写下 document.onclick 方法,然后获取元素的文本内容、属性信息等。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 document .onclick = function (event ) { const attributes = event.target.attributes const tag = event.target.tagName.toLowerCase() const attributesObj = {} const ignoreTag = ['html' ] if (ignoreTag.includes(tag)) return for (let key in attributes) { if (attributes[key]['value' ] != undefined ) { attributesObj[attributes[key]['name' ]] = attributes[key]['value' ]; } } const innerText = event.target.innerText.replace(/\s*/g , '' ) addClickEventLog({ attributes: attributesObj, innerText }) }
网络请求 对于网络请求的监控可以通过监听 XMLHttpRequest 和 fetch 请求来实现。
XMLHttpRequest 请求支持的事件如下:
我们在 XMLHttpRequest 的事件中增加自定义事件来触发相应的上报操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 function ajaxEventTrigger (event ) { const ajaxEvent = new CustomEvent(event, { detail: this }) window .dispatchEvent(ajaxEvent) } const oldXHR = window .XMLHttpRequestfunction newXHR ( ) { const realXHR = new oldXHR() realXHR.addEventListener('abort' , function ( ) { ajaxEventTrigger.call(this , 'ajaxAbort' ); }, false ) realXHR.addEventListener('error' , function ( ) { ajaxEventTrigger.call(this , 'ajaxError' ); }, false ) realXHR.addEventListener('load' , function ( ) { ajaxEventTrigger.call(this , 'ajaxLoad' ); }, false ) realXHR.addEventListener('loadstart' , function ( ) { ajaxEventTrigger.call(this , 'ajaxLoadStart' ) }, false ) realXHR.addEventListener('progress' , function ( ) { ajaxEventTrigger.call(this , 'ajaxProgress' ); }, false ) realXHR.addEventListener('timeout' , function ( ) { ajaxEventTrigger.call(this , 'ajaxTimeout' ); }, false ) realXHR.addEventListener('loadend' , function ( ) { ajaxEventTrigger.call(this , 'ajaxLoadEnd' ); }, false ) realXHR.addEventListener('readystatechange' , function ( ) { ajaxEventTrigger.call(this , 'ajaxReadyStateChange' ) }, false ) return realXHR } window .XMLHttpRequest = newXHRwindow .addEventListener('ajaxAbort' , event => { addNetworkEventLog(event) }) window .addEventListener('ajaxError' , event => { addNetworkEventLog(event) }) window .addEventListener('ajaxLoad' , event => { addNetworkEventLog(event) })
因为 CustomEvent 在 IE 等浏览器中有兼容问题(如下),需要使用 document.createEvent(‘CustomEvent’) 来创建:
While a window.CustomEvent object exists, it cannot be called as a constructor. Instead of new CustomEvent(…), you must use e = document.createEvent(‘CustomEvent’) and then e.initCustomEvent(…)
1 2 3 4 5 6 7 8 9 if ( typeof window .CustomEvent === 'function' ) return function CustomEvent ( event, params ) { params = params || { bubbles : false , cancelable : false , detail : undefined } const evt = document .createEvent( 'CustomEvent' ) evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ) return evt } CustomEvent.prototype = window .Event.prototype window .CustomEvent = CustomEvent
fetch 没有提供可供监听的事件,因此我们需要在 fetch 返回的 Promise 中进行处理(当然你也可以参考 vue 数据劫持的思路,使用 defineProperty 或者 proxy)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function rewriteFetcnEvent ( ) { const oldFetch = window .fetch function newFetch (url, options ) { return new Promise ((resolve, reject ) => { oldFetch(url, options).then(res => { addFecthEventLog(res) resolve(res) }).catch(err => { addFecthEventLog(err) reject(err) }) }) } window .fetch = newFetch }
结果 我在页面上模拟了点击和发送请求的操作,最终上报的数据如下:
这个demo只是简单梳理了一下监控用户轨迹的基本思路,还存在很多很多的问题,下面举的例子只是这众多问题的一小部分,是在写 demo 的时候想到的:
页面的hash值改变时,会同时触发 pushState 和 hashChange 事件,造成事件的重复上报
监控用户点击事件时需要对一些无效的事件进行过滤,过滤规则需要全盘考虑下,另外,表单元素 value 值的获取也需要单独处理
网络请求上报的数据需要按照实际场景进行更细化的处理,XMLHttpRequest 和 fetch 的上报数据格式需要合并
总之,这只是一个小小的开始,希望我能坚持完善下去。