文章

日志系统设计深度解析

前端日志系统是线上排查的黑匣子,通过分级、结构化、持久化与批量上报四件事把浏览器里发生的一切变成可检索可聚合的数据。 面试常考题:级别判断必须在格式化之前、IndexedDB 持久化的取舍,以及日志噪声控制。

日志系统设计深度解析

一句话概括

前端日志系统是线上问题排查的”黑匣子”——它通过分级(DEBUG/INFO/WARN/ERROR/FATAL)、结构化(JSON 而非纯文本)、持久化(IndexedDB)和批量上报四件事,把用户在浏览器里发生的一切还原成可检索、可聚合、可追踪的数据。日志系统做得好,线上 Bug 5 分钟定位;做得烂,只会制造海量噪声。

核心知识点

1. 日志分级:先过滤再格式化

分级的核心目的是在”信息丰富度”和”性能开销”之间做取舍。关键细节:级别判断必须发生在格式化之前——如果每条日志都先 JSON.stringify 再判断级别,DEBUG 日志在线上就会白白吃掉 CPU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 先比较级别,再执行开销大的序列化
const LEVEL = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, FATAL: 4 };

class Logger {
  constructor(minLevel = 'INFO') {
    this.minLevel = LEVEL[minLevel.toUpperCase()] ?? LEVEL.INFO;
    this.buffer = [];
  }

  log(level, message, data) {
    const lv = LEVEL[level.toUpperCase()];
    if (lv < this.minLevel) return; // ✅ 先拦截,避免无谓的序列化

    const entry = {
      level,
      message: typeof message === 'string' ? message : JSON.stringify(message),
      data: data ?? null,
      ts: Date.now(),
      url: location.href,
      stack: lv >= LEVEL.ERROR ? new Error().stack?.slice(0, 300) : null,
    };
    this.buffer.push(entry);
    if (lv >= LEVEL.ERROR || this.buffer.length >= 20) this.flush();
  }

  flush() {
    if (!this.buffer.length) return;
    const payload = JSON.stringify(this.buffer.splice(0));
    navigator.sendBeacon('/api/logs', payload);
  }
}

const logger = new Logger('INFO');
logger.debug('这行会被丢弃'); // 不产生任何开销
logger.error('支付失败', { orderId: 'o_1', reason: 'timeout' });

2. IndexedDB 持久化:崩溃也不丢日志

内存缓冲区有个致命缺陷:页面在两次上报之间崩溃,缓冲区日志全部丢失。IndexedDB 提供持久化,且支持索引查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 用 timestamp 和 level 建索引,支持"查最近 1 小时的 ERROR"
function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('logDB', 1);
    req.onupgradeneeded = (e) => {
      const store = e.target.result.createObjectStore('logs', { autoIncrement: true });
      store.createIndex('ts', 'ts');
      store.createIndex('level', 'level');
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function queryErrors(db, sinceTs) {
  const tx = db.transaction('logs', 'readonly');
  const store = tx.objectStore('logs');
  const index = store.index('level');
  const range = IDBKeyRange.only('ERROR');

  return new Promise((resolve) => {
    const result = [];
    index.openCursor(range).onsuccess = (e) => {
      const cursor = e.target.result;
      if (cursor && result.length < 50) {
        if (cursor.value.ts >= sinceTs) result.push(cursor.value);
        cursor.continue();
      } else resolve(result);
    };
  });
}

3. 结构化日志:可解析 > 可阅读

纯文本日志人看着舒服,但机器没法聚合。结构化日志(JSON)才能支撑”按 URL 聚合错误数”“按级别统计”这类分析:

1
2
3
4
5
6
7
8
9
10
// 结构化日志示例:固定 schema + 自由 data
{
  "level": "ERROR",
  "message": "接口超时",
  "ts": 1754653200000,
  "url": "/product/123",
  "sessionId": "sess_a1b2",
  "data": { "api": "/api/detail", "retry": 3 }
}
// 有了固定字段,后端才能 SELECT url, COUNT(*) GROUP BY url

4. 上报策略:sendBeacon + 卸载保活

sendBeacon 由浏览器进程独立发起,页面销毁也不丢请求,是日志上报的标配。配合 visibilitychange 和 beforeunload 兜底:

1
2
3
4
5
6
7
8
9
// sendBeacon 与 fetch keepalive 的取舍
window.addEventListener('pagehide', () => {
  const payload = JSON.stringify(logger.buffer.splice(0));
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/logs', payload); // 无响应需求,最省
  } else {
    fetch('/api/logs', { method: 'POST', body: payload, keepalive: true });
  }
});

5. 全链路追踪:一个 Trace ID 串起前后端

前端日志的价值天花板是全链路追踪——用 X-Trace-Id 把”前端点击 → 网关 → 微服务 → 数据库”串成一条调用链:

1
2
3
4
5
6
7
8
9
10
11
12
// 生成 Trace ID 并随请求透传
const traceId = crypto.randomUUID().replace(/-/g, ''); // 32位 hex

async function request(url, opts = {}) {
  const res = await fetch(url, {
    ...opts,
    headers: { ...opts.headers, 'X-Trace-Id': traceId },
  });
  logger.info('API 调用', { url, status: res.status, traceId });
  return res;
}
// 后端拿到 X-Trace-Id 后继续透传下游,日志平台按 traceId 聚合

其实你每天都在用

  1. 浏览器 DevTools 的 Console 面板:你按级别(All/Info/Warn/Error)过滤日志、按关键词搜索,就是一套日志分级 + 检索系统的雏形,只是它只存在你本地、不持久化。
  2. Chrome 的 performance 面板和 Long Task 提示:浏览器在后台持续记录各种事件和时间戳,本质上就是内置的结构化日志 + 时间线查询。
  3. 网页加载失败的红色报错:资源 404、接口 500 时控制台那堆红色输出,背后就是 window.onerror / unhandledrejection 在帮你采集错误日志。
  4. Sentry / 友盟 / 神策的报错报告:你收到的线上告警邮件里”哪个页面、哪个用户、什么堆栈”那条记录,就是一套完整的采集→存储→上报→查询链路。
  5. 登录后刷新页面还能看到你的购物车:后端靠你请求里携带的 sessionId / traceId 把”你是谁、你干了啥”串起来,这就是会话级日志追踪的日常形态。

常见误解(FAQ)

❌ 误区一:日志越多越好,反正也占不了多少空间。 错误。每条日志至少一次 JSON.stringify + 一次内存分配 + 一次上报序列化。高频路径(如滚动、mousemove)里埋 DEBUG 日志,能把页面帧率拖垮。正确做法是分级 + 采样,生产环境只记 WARN 及以上。

❌ 误区二:console.log 就是日志系统,够用了。 错误。console.log 的输出只在用户本地控制台,你看不到;页面刷新就丢;无法分级过滤;无法远程收集。生产环境用户报 Bug 时,你拿不到他控制台里的任何东西。

❌ 误区三:sendBeacon 能传任意大小的数据。 错误。sendBeacon 的 payload 有 64KB 上限(不同浏览器略有差异),超限会被静默丢弃、不报错。大批量日志要么分片、要么改走 fetch 的 keepalive(同样有 64KB 限制),要么先落 IndexedDB 分批上报。

❌ 误区四:日志脱敏不重要,反正数据在自家服务器。 错误。用户密码、Token、银行卡号、手机号一旦被写进日志,日志泄露=数据泄露,还可能触发 GDPR/个保法合规问题。脱敏(把敏感字段替换为 ***REDACTED***)必须在上报前、也就是数据离开浏览器之前完成。

一句话总结

日志系统的本质不是”记录”,而是”分级过滤、结构化存储、可靠上报、可检索可追踪”——把每一次用户现场,都变成一条能被 5 分钟定位问题的证据链。

本文由作者按照 CC BY 4.0 进行授权