操作日志系统设计与实现中的关键问题解析
操作日志突然不记录了?原来是埋点被拦截了
上周上线一个新功能,结果第二天产品就跑来问:“用户操作日志怎么少了快一半?”我第一反应是后端接口挂了,赶紧去查监控。结果发现接口调用量确实掉了一大截,但前端页面看起来一切正常,按钮都能点,流程也能走完。这就奇怪了——用户明明在操作,为什么日志没打上来?
折腾了半天才发现,问题出在我们自己写的埋点逻辑上。而且不是代码写错了,而是被浏览器“悄悄”拦截了。
排查过程:从怀疑后端到盯上 fetch
一开始我以为是网络问题或者后端限流,毕竟日志上报用的是独立的 API,和业务接口分开的。我先看了下 Chrome DevTools 的 Network 面板,发现有些日志请求压根没发出去——连 pending 状态都没有。这就排除了网络或服务端的问题,肯定是前端没触发请求。
接着我在埋点函数里加了个 console.log,手动点了几下,控制台确实有输出,说明事件监听器没丢。但神奇的是,有时候能发请求,有时候不能。特别是快速连续点击同一个按钮时,后面的几次点击完全没动静。
这时候我突然想到:会不会是浏览器对短时间内大量请求做了限制?但我们的日志接口 QPS 根本不高啊。再仔细一看,发现这些“消失”的请求都是在用户执行某些敏感操作(比如删除、提交)之后触发的。而这些操作往往会立刻跳转页面或者关闭弹窗。
灵光一闪——是不是页面跳转/组件卸载的时候,还没发完的请求被中断了?
一查资料,果然如此。浏览器在页面 unload 或者 SPA 路由切换时,会取消所有 pending 的 fetch 请求。而我们的埋点用的就是普通的 fetch,一旦用户点完“确认删除”马上跳转,这个日志请求很可能还没发出去就被干掉了。
试了三种方案,最后选了个最糙但有效的
网上常见的解法有几种:
- 用
navigator.sendBeacon发送日志(推荐方案) - 把请求改成同步 XHR(不推荐,会卡主线程)
- 延迟跳转,等日志发完再跳(体验差)
我先试了 sendBeacon,这玩意就是为这种场景设计的:即使页面即将卸载,也能保证数据发出去。文档说它底层用了高优先级队列,不受页面生命周期影响。
但问题来了:我们的日志数据是 JSON 格式,而 sendBeacon 只支持 String 或 Blob。得手动序列化,而且不能带自定义 header(比如 Authorization)。虽然日志接口不需要鉴权,但格式得统一。
改起来也不难,我把原来的 fetch 换成这样:
function logAction(action, payload) {
const data = JSON.stringify({ action, payload, timestamp: Date.now() });
// 优先用 sendBeacon,兜底用 fetch
if (navigator.sendBeacon) {
const success = navigator.sendBeacon('https://jztheme.com/api/log', data);
if (success) return;
}
// sendBeacon 失败或不支持时,回退到 fetch
fetch('https://jztheme.com/api/log', {
method: 'POST',
body: data,
keepalive: true, // 这个很重要!
headers: { 'Content-Type': 'application/json' }
}).catch(() => {});
}
这里注意两点:
keepalive: true是 fetch 的一个选项,允许请求在页面卸载后继续。但兼容性不如 sendBeacon 好(Safari 16.4+ 才支持),所以还是优先用 sendBeacon。sendBeacon返回布尔值表示是否成功加入发送队列,但不保证一定送达。所以不能依赖它的返回值做重试逻辑。
不过改完测试时又踩了个小坑:本地开发环境用 http://localhost,而 sendBeacon 在非 HTTPS 或非 localhost 环境下可能被浏览器限制(出于安全考虑)。还好我们线上是 HTTPS,问题不大。
还有一点容易忽略:数据体积限制
后来发现部分长日志还是没上报成功。查了下规范,sendBeacon 对单次发送的数据大小有限制——通常不超过 64KB(不同浏览器不一样)。我们有些操作会附带完整的表单数据,一串下来轻松超限。
解决办法很简单:截断或压缩。我加了个简单的判断:
function safeLog(action, payload) {
const raw = { action, payload, timestamp: Date.now() };
let data = JSON.stringify(raw);
// 如果超过 50KB,只保留关键字段
if (data.length > 50 * 1024) {
console.warn('Log payload too large, truncating');
data = JSON.stringify({
action,
// 只保留 payload 的前几层 key,或者 hash 一下
payloadHash: btoa(JSON.stringify(payload)).substring(0, 100),
timestamp: raw.timestamp
});
}
if (navigator.sendBeacon) {
navigator.sendBeacon('https://jztheme.com/api/log', data);
} else {
fetch('https://jztheme.com/api/log', {
method: 'POST',
body: data,
keepalive: true,
headers: { 'Content-Type': 'application/json' }
}).catch(() => {});
}
}
其实更优雅的做法是后端提供日志分片接口,但为了一个埋点搞这么复杂不值得。截断后至少能知道“发生了什么操作”,细节丢了也无伤大雅。
现在还有个小瑕疵,但能接受
改完上线后,日志完整率从 60% 左右拉回到了 98% 以上,基本达标。不过偶尔还是会丢个位数的日志,估计是极端情况(比如用户直接关标签页 + 网络极差)导致的。这种场景没法 100% 覆盖,也没必要死磕——毕竟操作日志是辅助分析用的,不是交易流水。
另外,因为 sendBeacon 不支持 Promise,没法做统一的错误上报。如果日志本身对业务很关键(比如审计日志),可能还得结合其他手段,比如本地缓存失败日志,下次访问时重试。但我们这个只是行为分析,丢了就丢了,不影响主流程。
核心代码就这几行,但坑不少
总结下来,一个健壮的操作日志上报函数应该包含:
- 优先使用
navigator.sendBeacon - 兜底用带
keepalive: true的 fetch - 控制 payload 大小,避免超限
- 不要依赖上报结果做业务逻辑
完整版代码我整理了一下,可以直接抄:
function trackUserAction(actionName, metadata = {}) {
// 防止传入 undefined 导致 stringify 出错
if (typeof actionName !== 'string') {
console.error('Invalid action name:', actionName);
return;
}
const logData = {
action: actionName,
meta: metadata,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
};
let payload = JSON.stringify(logData);
// 限制 payload 大小(单位:字节)
const MAX_SIZE = 50 * 1024; // 50KB
if (new Blob([payload]).size > MAX_SIZE) {
// 简单截断 meta
logData.meta = { truncated: true };
payload = JSON.stringify(logData);
// 如果还超,就只保留最基本字段
if (new Blob([payload]).size > MAX_SIZE) {
payload = JSON.stringify({
action: actionName,
timestamp: logData.timestamp
});
}
}
const endpoint = 'https://jztheme.com/api/log';
if (navigator.sendBeacon) {
// sendBeacon 自动设置 Content-Type 为 text/plain,但后端通常能处理
const sent = navigator.sendBeacon(endpoint, payload);
if (sent) return;
}
// fallback to fetch with keepalive
fetch(endpoint, {
method: 'POST',
body: payload,
keepalive: true,
headers: {
'Content-Type': 'application/json'
}
}).catch(() => {
// 静默失败,不影响主流程
});
}
用的时候就一行:
// 比如用户点击了删除按钮
trackUserAction('delete_item', { itemId: '12345', source: 'list_page' });
结尾碎碎念
说实话,以前总觉得埋点是件很简单的事,不就是发个请求嘛。结果真遇到数据对不上,才发现里面水挺深。浏览器的各种限制、网络的不确定性、数据体积的边界……每一个都可能让你的日志“神秘消失”。
以上是我踩坑后的总结,如果你有更好的方案(比如如何优雅处理大 payload,或者跨域场景下的 sendBeacon 限制),欢迎评论区交流。这个技巧的拓展用法还有很多,比如错误上报、性能指标采集,后续会继续分享这类博客。

暂无评论