操作日志系统设计与实现中的关键问题解析

书生シ巧玲 安全 阅读 620
赞 26 收藏
二维码
手机扫码查看
反馈

操作日志突然不记录了?原来是埋点被拦截了

上周上线一个新功能,结果第二天产品就跑来问:“用户操作日志怎么少了快一半?”我第一反应是后端接口挂了,赶紧去查监控。结果发现接口调用量确实掉了一大截,但前端页面看起来一切正常,按钮都能点,流程也能走完。这就奇怪了——用户明明在操作,为什么日志没打上来?

操作日志系统设计与实现中的关键问题解析

折腾了半天才发现,问题出在我们自己写的埋点逻辑上。而且不是代码写错了,而是被浏览器“悄悄”拦截了。

排查过程:从怀疑后端到盯上 fetch

一开始我以为是网络问题或者后端限流,毕竟日志上报用的是独立的 API,和业务接口分开的。我先看了下 Chrome DevTools 的 Network 面板,发现有些日志请求压根没发出去——连 pending 状态都没有。这就排除了网络或服务端的问题,肯定是前端没触发请求。

接着我在埋点函数里加了个 console.log,手动点了几下,控制台确实有输出,说明事件监听器没丢。但神奇的是,有时候能发请求,有时候不能。特别是快速连续点击同一个按钮时,后面的几次点击完全没动静。

这时候我突然想到:会不会是浏览器对短时间内大量请求做了限制?但我们的日志接口 QPS 根本不高啊。再仔细一看,发现这些“消失”的请求都是在用户执行某些敏感操作(比如删除、提交)之后触发的。而这些操作往往会立刻跳转页面或者关闭弹窗。

灵光一闪——是不是页面跳转/组件卸载的时候,还没发完的请求被中断了?

一查资料,果然如此。浏览器在页面 unload 或者 SPA 路由切换时,会取消所有 pending 的 fetch 请求。而我们的埋点用的就是普通的 fetch,一旦用户点完“确认删除”马上跳转,这个日志请求很可能还没发出去就被干掉了。

试了三种方案,最后选了个最糙但有效的

网上常见的解法有几种:

  • navigator.sendBeacon 发送日志(推荐方案)
  • 把请求改成同步 XHR(不推荐,会卡主线程)
  • 延迟跳转,等日志发完再跳(体验差)

我先试了 sendBeacon,这玩意就是为这种场景设计的:即使页面即将卸载,也能保证数据发出去。文档说它底层用了高优先级队列,不受页面生命周期影响。

但问题来了:我们的日志数据是 JSON 格式,而 sendBeacon 只支持 StringBlob。得手动序列化,而且不能带自定义 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(() => {});
}

这里注意两点:

  1. keepalive: true 是 fetch 的一个选项,允许请求在页面卸载后继续。但兼容性不如 sendBeacon 好(Safari 16.4+ 才支持),所以还是优先用 sendBeacon。
  2. 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 限制),欢迎评论区交流。这个技巧的拓展用法还有很多,比如错误上报、性能指标采集,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论