接口监控 SDK 怎样代理 fetch 和 XHR?怎样采集耗时与错误,又避免上报死循环和泄露隐私?
用包装函数替换 window.fetch,替换 XMLHttpRequest 原型上的 open 和 send,在请求前后记录时间、状态码和失败原因。fetch 遇到 404、500 不会 reject,要看 response.ok;上报请求本身要跳过,否则会无限上报;URL 去掉查询参数,不采集请求体和响应体。
简答
比如想知道线上哪些接口慢、哪些接口报错。业务代码里请求很分散,不可能每处都手动打点,所以监控 SDK 会在页面启动时把
window.fetch换成自己的包装函数,再改写XMLHttpRequest.prototype上的open和send,之后所有请求都会先经过 SDK。包装函数在请求发出前记下开始时间,请求结束后算出耗时,再带上方法、路径和状态码上报。要注意
fetch遇到 404、500 这类状态码不会 reject,只有网络错误、请求被中止这类情况才会 reject,所以“接口报错”要看response.ok和status。XHR 则监听loadend,成功、失败、中止都会触发它。这样做主要是为了在用户反馈之前发现接口问题,还能把失败请求和 JS 错误、用户操作串起来排查。
有两点要特别注意。第一,上报本身也是一个请求,如果它也走被包装的
fetch,就会“上报产生请求、请求又触发上报”,所以要跳过上报地址。第二,URL 的查询参数、请求体和响应体里常有手机号、token,默认只采集去掉查询参数的路径,不采集内容。
怎样包装 fetch
思路是保存原来的 fetch,返回一个签名相同的函数:先记时间,再调用原函数,在 Promise 的成功和失败分支里分别上报。下面的例子没有改全局对象,而是把原始 fetch、上报函数和时钟都作为参数传进去,这样在 Node 里也能用假的 fetch 跑通。
function createMonitoredFetch(originalFetch, options) {
const { reportUrl, report, now } = options;
return function monitoredFetch(input, init) {
const url = typeof input === 'string' ? input : input.url;
// 上报请求自己也走 fetch,不跳过就会无限上报。
if (url.startsWith(reportUrl)) return originalFetch(input, init);
const start = now();
const method = (init && init.method) || 'GET';
// 只保留路径,查询参数里可能有手机号、token。
const path = url.split('?')[0];
return originalFetch(input, init).then(
(response) => {
report({ path, method, status: response.status, ok: response.ok, duration: now() - start });
return response;
},
(error) => {
report({ path, method, status: 0, ok: false, duration: now() - start, error: error.name });
throw error;
},
);
};
}
let clock = 0;
const now = () => clock;
const records = [];
function fakeFetch(url) {
clock += 120;
if (url.includes('offline')) return Promise.reject(new TypeError('Failed to fetch'));
const status = url.includes('missing') ? 404 : 200;
return Promise.resolve({ status, ok: status < 400 });
}
const monitoredFetch = createMonitoredFetch(fakeFetch, {
reportUrl: '/log',
now,
report(record) {
records.push(record);
monitoredFetch('/log', { method: 'POST' });
},
});
monitoredFetch('/api/user?phone=13800000000')
.then(() => monitoredFetch('/api/missing'))
.then(() => monitoredFetch('/api/offline'))
.catch((error) => console.log(error.name)) // TypeError
.then(() => {
const line = (r) => `${r.method} ${r.path} ${r.status} ${r.ok} ${r.duration}`;
console.log(line(records[0])); // GET /api/user 200 true 120
console.log(line(records[1])); // GET /api/missing 404 false 120
console.log(line(records[2])); // GET /api/offline 0 false 120
console.log(records.length); // 3
});
先看第二行记录。/api/missing 返回 404,假 fetch 照样 resolve,这和真实的 fetch 一致:MDN 写明,fetch() 的 Promise 只在请求失败时 reject,比如 URL 格式错误或网络错误,服务器返回 404、504 这类状态码并不会 reject。所以 SDK 必须在成功分支里看 ok,否则 4xx、5xx 全被当成成功。
第三行是真正的网络失败,Promise reject,没有状态码,SDK 记成 0 并保留错误名。包装函数上报以后还要把错误重新抛出去,业务代码的 catch 才能照常拿到它,所以外面打印出了 TypeError。成功分支同理,要把 response 原样返回。
report 每次都会再发一个 /log 请求,可最后只有 3 条记录。原因是包装函数开头判断了上报地址,直接交给原始 fetch,不再计时上报。去掉这个判断,每次上报都会产生新的上报,就成了死循环。另一种做法是 SDK 在替换前保存原始 fetch,上报时直接用它。
第一行的路径里没有 phone=...,因为采集时只保留了问号前面的部分。
XHR 怎样代理
XMLHttpRequest 不是一个函数调用就完成的,方法和 URL 在 open 里传,请求在 send 时才发出,结果通过事件通知。所以要改写原型上的两个方法:open 里把方法和 URL 存到实例上,send 里记开始时间并监听 loadend。
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__monitor = { method, url: String(url) };
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
const info = this.__monitor;
if (info && !info.url.startsWith(REPORT_URL)) {
const start = performance.now();
this.addEventListener('loadend', () => {
report({
method: info.method,
path: info.url.split('?')[0],
status: this.status,
duration: performance.now() - start,
});
});
}
return originalSend.call(this, body);
};
为什么监听 loadend 而不是 load?因为 MDN 说明,loadend 在请求完成时触发,不管成功(在 load 之后)还是失败(在 abort 或 error 之后)。只听 load 就会漏掉失败的请求。请求失败或被中止时 status 是 0,可以据此和正常返回区分。
能不能采集响应体
有时想从响应里取业务错误码,比如 HTTP 200 但 code 不是 0。这时不能直接读 response.json(),因为响应体只能读一次,SDK 读了,业务代码再读就会失败。MDN 给的办法是 response.clone(),但要在读取之前克隆,响应体已经被读过时 clone() 会抛 TypeError。MDN 还提醒,克隆后如果只读其中一份,整个响应体会被缓存在内存里,所以大响应不适合这样做。
实际上大多数 SDK 默认不采集请求体和响应体,一是内存和上报体积,二是里面常有用户数据。确实需要时,只按白名单取个别字段。
只看 Performance API 行不行
不改写任何函数也能拿到一部分数据:performance.getEntriesByType('resource') 里,initiatorType 为 fetch 或 xmlhttprequest 的条目就是接口请求,自带各阶段耗时。
但它有两个限制。跨域请求如果响应没有 Timing-Allow-Origin 头,详细的阶段耗时会被置为 0;而且 MDN 说明这个头只开放时间信息,不开放状态码、响应体大小这类描述响应本身的属性。所以要拿到接口失败率,还是要代理 fetch 和 XHR,Performance API 用来补充各阶段耗时。
常见追问
SDK 的包装函数会不会影响业务代码?
包装函数要做到“透明”:参数原样传给原函数,返回值原样返回,错误原样抛出。上报逻辑本身要放在
try...catch里,SDK 自己出错也不能让业务请求失败。
接口错误和 JS 错误怎样关联?
每条接口记录带上页面地址、时间和会话 ID,和《前端怎样全局捕获 JS 错误、Promise 拒绝和资源加载失败?》里采集的错误放进同一个会话。排查时按会话和时间顺序排起来,就能看到“哪个接口失败后页面报了错”。
接口数据量大,怎样上报?
不要每个请求发一次,先放进队列攒一批,定时或页面隐藏时发出,可以再按比例采样。页面关闭时的发送方式见《页面关闭时怎样可靠上报?sendBeacon 和 keepalive 怎么选?》,采样和去重见《前端监控数据怎样做采样、去重、聚合和告警?》。