认识
一、认识
常见的用户行为,可以归纳为页面跳转、鼠标 click
行为、键盘 keypress
行为、 fetch / xhr
接口请求、console
打印信息。
Sentry
接入应用以后,会在用户使用应用的过程中,将上述行为一一收集起来。等到捕获到异常时,会将收集到的用户行为和异常信息一起上报。那 Sentry
是怎么实现收集用户行为的呢?答案: 劫持覆写上述操作涉及的 api
。
二、页面跳转
收集页面跳转行为: 为了可以收集用户页面跳转行为,Sentry
劫持并覆写了原生 history
的 pushState
、replaceState
方法和 window
的 onpopstate
。
2.1 pushState
// 保存原生的 pushState 方法
var originPushState = window.history.pushState;
// 劫持覆写 pushState
window.history.pushState = function() {
var args = [];
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
var url = args.length > 2 ? args[2] : undefined;
if (url) {
var from = lastHref;
var to = String(url);
lastHref = to;
// 将页面跳转行为收集起来
triggerHandlers('history', {
from: from,
to: to,
});
}
// 使用原生的 pushState 做页面跳转
return originPushState.apply(this, args);
}
2.2 replaceState
// 保存原生的 replaceState 方法
var originReplaceState = window.history.replaceState;
// 劫持覆写 replaceState
window.history.replaceState = function() {
var args = [];
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
var url = args.length > 2 ? args[2] : undefined;
if (url) {
var from = lastHref;
var to = String(url);
lastHref = to;
// 将页面跳转行为收集起来
triggerHandlers('history', {
from: from,
to: to,
});
}
// 使用原生的 replaceState 做页面跳转
return originReplaceState.apply(this, args);
}
2.3 onpopstate
// 使用 oldPopState 变量保存原生的 onpopstate
var oldPopState = window.onpopstate;
var lastHref;
// 覆写 onpopstate
window.onpopstate = function() {
...
var to = window.location.href;
var from = lastHref;
lastHref = to;
// 将页面跳转行为收集起来
triggerHandlers('history', {
from: from,
to: to,
});
if (oldOnPopState) {
try {
// 使用原生的 popstate
return oldOnPopState.apply(this, args);
} catch (e) {
...
}
}
...
}
三、点击行为
收集鼠标 click
行为: 为了收集用户鼠标 click
行为, Sentry
做了双保险操作:
-
通过
document
代理click
事件来收集click
行为 -
通过劫持
addEventListener
方法来收集click
行为
劫持、重写逻辑如下
function instrumentDOM() {
...
// triggerDOMHandler 用来收集用户 click / keypress 行为
var triggerDOMHandler = triggerHandlers.bind(null, 'dom');
var globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true);
// 通过 document 代理 click、keypress 事件的方式收集 click、keypress 行为
document.addEventListener('click', globalDOMEventHandler, false);
document.addEventListener('keypress', globalDOMEventHandler, false);
['EventTarget', 'Node'].forEach(function (target) {
var proto = window[target] && window[target].prototype;
if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) {
return;
}
// 劫持覆写 Node.prototype.addEventListener 和 EventTarget.prototype.addEventListener
fill(proto, 'addEventListener', function (originalAddEventListener) {
// 返回新的 addEventListener 覆写原生的 addEventListener
return function (type, listener, options) {
// click、keypress 事件,要做特殊处理,
if (type === 'click' || type == 'keypress') {
try {
var el = this;
var handlers_1 = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {});
var handlerForType = (handlers_1[type] = handlers_1[type] || { refCount: 0 });
// 如果没有收集过 click、keypress 行为
if (!handlerForType.handler) {
var handler = makeDOMEventHandler(triggerDOMHandler);
handlerForType.handler = handler;
originalAddEventListener.call(this, type, handler, options);
}
handlerForType.refCount += 1;
}
catch (e) {
// Accessing dom properties is always fragile.
// Also allows us to skip `addEventListenrs` calls with no proper `this` context.
}
}
// 使用原生的 addEventListener 方法注册事件
return originalAddEventListener.call(this, type, listener, options);
};
});
...
});
}
-
首先,
Sentry
使用document
代理了click
、keypress
事件。通过这种方式,用户的click
、keypress
行为可以被感知,然后被Sentry
收集。但这种方式有一个问题,如果应用的dom
节点是通过addEventListener
注册了click
、keypress
事件,并且在事件回调中做了阻止事件冒泡的操作,那么就无法通过代理的方式监控到click
、keypress
事件了。 -
针对这一种情况,
Sentry
采用了覆写Node.prototype.addEventListener
的方式来监控用户的click
、keypress
行为。由于所有的dom
节点都继承自Node
对象,Sentry
劫持覆写了Node.prototype.addEventListener
。当应用代码通过addEventListener
订阅事件时,会使用覆写以后的addEventListener
方法。 -
新的
addEventListener
方法,内部里面也有很巧妙的实现。如果不是click、keypress
事件,会直接使用原生的addEventListener
方法注册应用提供的listener
。但如果是click、keypress
事件,除了使用原生的addEventListener
方法注册应用提供的listener
外,还使用原生addEventListener
注册了一个handler
,这个handler
执行的时候会将用户click、keypress
行为收集起来。
也就是说,如果是 click、keypress
事件,应用程序在调用 addEventListener
的时候,实际上是调用了两次原生的 addEventListener
。
另外,在收集 click、keypress
行为时,Sentry
还会把 target
节点的的父节点信息收集起来,帮助我们快速定位节点位置。
四、按键行为
收集鼠标 keypress
行为: 为了收集用户鼠标 keypress
行为, Sentry
做了双保险操作:
-
通过
document
代理keypress
事件来收集keypress
行为 -
通过劫持
addEventListener
方法来收集keypress
行为
劫持、重写逻辑如下
function instrumentDOM() {
...
// triggerDOMHandler 用来收集用户 click / keypress 行为
var triggerDOMHandler = triggerHandlers.bind(null, 'dom');
var globalDOMEventHandler = makeDOMEventHandler(triggerDOMHandler, true);
// 通过 document 代理 click、keypress 事件的方式收集 click、keypress 行为
document.addEventListener('click', globalDOMEventHandler, false);
document.addEventListener('keypress', globalDOMEventHandler, false);
['EventTarget', 'Node'].forEach(function (target) {
var proto = window[target] && window[target].prototype;
if (!proto || !proto.hasOwnProperty || !proto.hasOwnProperty('addEventListener')) {
return;
}
// 劫持覆写 Node.prototype.addEventListener 和 EventTarget.prototype.addEventListener
fill(proto, 'addEventListener', function (originalAddEventListener) {
// 返回新的 addEventListener 覆写原生的 addEventListener
return function (type, listener, options) {
// click、keypress 事件,要做特殊处理,
if (type === 'click' || type == 'keypress') {
try {
var el = this;
var handlers_1 = (el.__sentry_instrumentation_handlers__ = el.__sentry_instrumentation_handlers__ || {});
var handlerForType = (handlers_1[type] = handlers_1[type] || { refCount: 0 });
// 如果没有收集过 click、keypress 行为
if (!handlerForType.handler) {
var handler = makeDOMEventHandler(triggerDOMHandler);
handlerForType.handler = handler;
originalAddEventListener.call(this, type, handler, options);
}
handlerForType.refCount += 1;
}
catch (e) {
// Accessing dom properties is always fragile.
// Also allows us to skip `addEventListenrs` calls with no proper `this` context.
}
}
// 使用原生的 addEventListener 方法注册事件
return originalAddEventListener.call(this, type, listener, options);
};
});
...
});
}
-
首先,
Sentry
使用document
代理了click
、keypress
事件。通过这种方式,用户的click
、keypress
行为可以被感知,然后被Sentry
收集。但这种方式有一个问题,如果应用的dom
节点是通过addEventListener
注册了click
、keypress
事件,并且在事件回调中做了阻止事件冒泡的操作,那么就无法通过代理的方式监控到click
、keypress
事件了。 -
针对这一种情况,
Sentry
采用了覆写Node.prototype.addEventListener
的方式来监控用户的click
、keypress
行为。由于所有的dom
节点都继承自Node
对象,Sentry
劫持覆写了Node.prototype.addEventListener
。当应用代码通过addEventListener
订阅事件时,会使用覆写以后的addEventListener
方法。 -
新的
addEventListener
方法,内部里面也有很巧妙的实现。如果不是click、keypress
事件,会直接使用原生的addEventListener
方法注册应用提供的listener
。但如果是click、keypress
事件,除了使用原生的addEventListener
方法注册应用提供的listener
外,还使用原生addEventListener
注册了一个handler
,这个handler
执行的时候会将用户click、keypress
行为收集起来。
也就是说,如果是 click、keypress
事件,应用程序在调用 addEventListener
的时候,实际上是调用了两次原生的 addEventListener
。
另外,在收集 click、keypress
行为时,Sentry
还会把 target
节点的的父节点信息收集起来,帮助我们快速定位节点位置。
五、xhr 接口请求行为
收集 xhr
接口请求行为, 为了收集应用的接口请求行为,Sentry
对原生的 xhr
做了劫持覆写。
劫持、覆写逻辑如下
function instrumentXHR() {
...
var xhrproto = XMLHttpRequest.prototype;
// 覆写 XMLHttpRequest.prototype.open
fill(xhrproto, 'open', function (originalOpen) {
return function () {
...
var onreadystatechangeHandler = function () {
if (xhr.readyState === 4) {
...
// 收集接口调用结果
triggerHandlers('xhr', {
args: args,
endTimestamp: Date.now(),
startTimestamp: Date.now(),
xhr: xhr,
});
}
};
// 覆写 onreadystatechange
if ('onreadystatechange' in xhr && typeof xhr.onreadystatechange === 'function') {
fill(xhr, 'onreadystatechange', function (original) {
return function () {
var readyStateArgs = [];
for (var _i = 0; _i < arguments.length; _i++) {
readyStateArgs[_i] = arguments[_i];
}
onreadystatechangeHandler();
return original.apply(xhr, readyStateArgs);
};
});
}
else {
xhr.addEventListener('readystatechange', onreadystatechangeHandler);
}
return originalOpen.apply(xhr, args);
};
});
// 覆写 XMLHttpRequest.prototype.send
fill(xhrproto, 'send', function (originalSend) {
return function () {
...
// 收集接口调用行为
triggerHandlers('xhr', {
args: args,
startTimestamp: Date.now(),
xhr: this,
});
return originalSend.apply(this, args);
};
});
}
Sentry
是通过劫持覆写 XMLHttpRequest
原型上的 open、send
方法的方式来实现收集接口请求行为的。当应用代码中调用 open
方法时,实际使用的是覆写以后的 open
方法。在新的 open
方法内部,又覆写了 onreadystatechange
,这样就可以收集到接口请求返回的结果。新的 open
方法内部会使用调用原生的 open
方法。同样的,当应用代码中调用 send
方法时,实际使用的是覆写以后的 send
方法。新的 send
方法内部先收集接口调用信息,然后调用原生的 send
方法。
六、fetch 接口请求行为
收集 fetch
接口请求行为, 为了收集应用的接口请求行为,Sentry
对原生的 fetch
做了劫持覆写。
劫持、覆写逻辑如下
var originFetch = window.fetch;
window.fetch = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
// 获取接口 url、method 类型、参数、接口调用时间信息
var handlerData = {
args: args,
fetchData: {
method: getFetchMethod(args),
url: getFetchUrl(args),
},
startTimestamp: Date.now(),
};
// 收集接口调用信息
triggerHandlers('fetch', __assign({}, handlerData));
return originalFetch.apply(window, args).then(function (response) {
// 接口请求成功,收集返回数据
triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), response: response }));
return response;
}, function (error) {
// 接口请求失败,收集接口异常数据
triggerHandlers('fetch', __assign(__assign({}, handlerData), { endTimestamp: Date.now(), error: error }));
throw error;
});
}
应用中使用 fetch
发起请求时,实际使用的是新的 fetch
方法。新的 fetch
内部,会使用原生的 fetch
发起请求,并收集接口请求数据和返回结果。
七、console 打印行为
收集 console
打印行为, 行为的收集机制理解起来就非常简单了,实际就是对 console
的 debug
、info
、warn
、error
、log
、assert
这借个 api
进行劫持覆写。
var originConsoleLog = console.log;
console.log = function() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
// 收集 console.log 行为
triggerHandlers('console', { args: args, level: 'log' });
if (originConsoleLog) {
originConsoleLog.apply(console, args);
}
}