前言

作为一名前端,最头疼的问题莫过于用户反馈了线上问题但是却复现不了。有些复现不了的问题跟操作步骤有关,但是用户描述问题常常不够准确,需要通过客服再进行多次沟通。还有一种情况就是用户明明进行了某项操作(比如出价)却硬说自己没有任何点击行为。因此,对用户行为进行监控,对于定位线上问题和减少客诉来说很重要。

如果要监控用户的操作行为,我们采用的是埋点上报的形式,这样做的好处是上报准确,而且依托于我们现有的埋点搜集系统,使用起来也比较方便。缺点也比较明显,那就是对代码的耦合度比较高,如果新增操作或者改变交互,上报的方法也要随之变动,如果操作过程的上报有所遗漏,整个用户行为其实是不完整的。本文尝试采用通用的一种上报方式,可以追踪用户的所有轨迹,希望能够在不侵入业务代码的情况下,对用户轨迹进行上报。

用户行为

要监控用户行为,不仅仅要监控用户的操作,比如打开页面、点击等,还需要监控这些行为产生的的数据请求以及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: '', // 用户轨迹Id
ua: '', // ua信息
url: '', // 页面地址
refer: '', // 页面refer
... // 其他信息
},
logs: [{
type: '', // 轨迹名称
value: 0, // 轨迹value
data: {} // 轨迹上报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 = handleConsoleError

const errorMsg = arguments[0] && arguments[0].message
const url = window.location.href
const lineNumber = 0
const columnNumber = 0
let errorObj = arguments[0] && arguments[0].stack
if (!errorObj) errorObj = arguments[0]
if (jsErrorReported) {
// onerror和onunhandledrejection开始处理报错后,停止console.error的上报
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 请求支持的事件如下:
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
// 监听XMLHttpRequest事件中增加自定义事件
function ajaxEventTrigger(event) {
const ajaxEvent = new CustomEvent(event, {
detail: this
})
window.dispatchEvent(ajaxEvent)
}
const oldXHR = window.XMLHttpRequest
function 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 = newXHR

// 监听自定义事件
window.addEventListener('ajaxAbort', event => {
addNetworkEventLog(event)
})
window.addEventListener('ajaxError', event => {
addNetworkEventLog(event)
})
window.addEventListener('ajaxLoad', event => {
addNetworkEventLog(event)
})

因为 CustomEvent 在 IE 等浏览器中有兼容问题(如下),需要使用 document.createEvent(‘CustomEvent’) 来创建:

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 没有提供可供监听的事件,因此我们需要在 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 的上报数据格式需要合并

总之,这只是一个小小的开始,希望我能坚持完善下去。