跳到主要内容

认识

2024年04月25日
柏拉文
越努力,越幸运

一、认识


常见的用户行为,可以归纳为页面跳转、鼠标 click 行为、键盘 keypress 行为、 fetch / xhr 接口请求、console 打印信息。

Sentry 接入应用以后,会在用户使用应用的过程中,将上述行为一一收集起来。等到捕获到异常时,会将收集到的用户行为和异常信息一起上报。Sentry 是怎么实现收集用户行为的呢?答案: 劫持覆写上述操作涉及的 api

二、页面跳转


收集页面跳转行为: 为了可以收集用户页面跳转行为,Sentry 劫持并覆写了原生 historypushStatereplaceState 方法和 windowonpopstate

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 做了双保险操作:

  1. 通过 document 代理 click 事件来收集 click 行为

  2. 通过劫持 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);
};
});
...
});
}
  1. 首先, Sentry 使用 document 代理了 clickkeypress 事件。通过这种方式,用户的 clickkeypress 行为可以被感知,然后被 Sentry 收集。但这种方式有一个问题,如果应用的 dom 节点是通过 addEventListener 注册了 clickkeypress 事件,并且在事件回调中做了阻止事件冒泡的操作,那么就无法通过代理的方式监控到 clickkeypress 事件了。

  2. 针对这一种情况, Sentry 采用了覆写 Node.prototype.addEventListener 的方式来监控用户的 clickkeypress 行为。由于所有的 dom 节点都继承自 Node 对象,Sentry 劫持覆写了 Node.prototype.addEventListener。当应用代码通过 addEventListener 订阅事件时,会使用覆写以后的 addEventListener 方法。

  3. 新的 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 做了双保险操作:

  1. 通过 document 代理 keypress 事件来收集 keypress 行为

  2. 通过劫持 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);
};
});
...
});
}
  1. 首先, Sentry 使用 document 代理了 clickkeypress 事件。通过这种方式,用户的 clickkeypress 行为可以被感知,然后被 Sentry 收集。但这种方式有一个问题,如果应用的 dom 节点是通过 addEventListener 注册了 clickkeypress 事件,并且在事件回调中做了阻止事件冒泡的操作,那么就无法通过代理的方式监控到 clickkeypress 事件了。

  2. 针对这一种情况, Sentry 采用了覆写 Node.prototype.addEventListener 的方式来监控用户的 clickkeypress 行为。由于所有的 dom 节点都继承自 Node 对象,Sentry 劫持覆写了 Node.prototype.addEventListener。当应用代码通过 addEventListener 订阅事件时,会使用覆写以后的 addEventListener 方法。

  3. 新的 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 打印行为, 行为的收集机制理解起来就非常简单了,实际就是对 consoledebuginfowarnerrorlogassert 这借个 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);
}
}