文章

日志系统设计深度解析

日志系统设计深度解析

一句话概括

前端日志系统是监控 SDK 的数据底座,通过分级管理(DEBUG/INFO/WARN/ERROR/FATAL)、结构化存储、分类索引与高效查询,为线上问题排查和数据分析提供端到端链路。一个设计良好的日志系统能让 5 分钟内定位线上 Bug 根因,而糟糕的日志系统只会产生噪声。本文从日志分级标准、存储引擎设计、索引查询优化到全链路追踪,给出完整可运行的前端日志 SDK。

背景与意义

“没有日志就没有真相”——在线上环境,开发者无法断点调试,console.log 的输出看不到,用户的报错需要靠日志来还原现场。做过大型项目的人都经历过:客户报了一个 Bug,你花了三天却复现不了。这时候如果有一条精准的日志,问题在 5 分钟内就能定位。一份来自 JetBrains 的调查显示,开发者平均 40% 的调试时间花在”理解问题现象”上而非”修复问题”上,而良好的日志系统能将这个比例降低到 10% 以下。面试中,日志系统的设计问题通常出现在系统设计题中,如”设计一个前端日志系统”或”如何做全链路追踪”。

概念与定义

日志分级 (Log Level)

将日志按严重程度分为多个等级,不同等级有不同的生产行为。常见的分级体系:DEBUG < INFO < WARN < ERROR < FATAL。

结构化日志 (Structured Logging)

使用 JSON 等结构化格式记录日志,而非纯文本字符串。结构化日志便于程序化解析、索引和查询。

日志轮转 (Log Rotation)

当日志文件达到大小或时间阈值时,自动归档旧日志并创建新日志文件,防止磁盘被写满。

全链路追踪 (Distributed Tracing)

通过唯一的 Trace ID 串联一次请求在多个服务(包括前端 → 后端 → 数据库)之间的所有日志。

日志持久化 (Log Persistence)

前端日志通常先存储在 IndexedDB 或内存队列中,再批量发送到后端服务进行持久化存储。

核心知识点拆解

1. 日志分级与过滤引擎

分级的核心目的是在”日志丰富度”和”性能开销”之间做权衡。生产环境通常只记录 WARN 及以上级别,DEBUG 日志只在开发环境开启:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// 日志分级系统 - 支持动态调节级别
class LogLevel {
  // 预定义级别(数字越小越详细)
  static DEBUG = { name: 'DEBUG', value: 0, color: '#8B8B8B' };
  static INFO = { name: 'INFO', value: 1, color: '#00AA00' };
  static WARN = { name: 'WARN', value: 2, color: '#FFA500' };
  static ERROR = { name: 'ERROR', value: 3, color: '#FF4444' };
  static FATAL = { name: 'FATAL', value: 4, color: '#CC0000' };

  static fromString(level) {
    const map = {
      debug: LogLevel.DEBUG,
      info: LogLevel.INFO,
      warn: LogLevel.WARN,
      error: LogLevel.ERROR,
      fatal: LogLevel.FATAL,
    };
    return map[level.toLowerCase()] || LogLevel.INFO;
  }
}

// 前端日志系统核心
class Logger {
  constructor(options = {}) {
    this.appName = options.appName || 'app';
    this.version = options.version || '1.0.0';
    this.minLevel = LogLevel.fromString(options.minLevel || 'info');
    this.enableConsole = options.enableConsole !== false;
    this.remoteReport = options.remoteReport !== false;
    this.reportUrl = options.reportUrl || '/api/logs';
    this.bufferSize = options.bufferSize || 50;
    this.logBuffer = [];
    this.context = {}; // 全局上下文(用户ID、页面等)
    this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    
    this.setupBeforeUnload();
  }

  // 核心日志方法
  debug(message, data) { this.log(LogLevel.DEBUG, message, data); }
  info(message, data) { this.log(LogLevel.INFO, message, data); }
  warn(message, data) { this.log(LogLevel.WARN, message, data); }
  error(message, data) { this.log(LogLevel.ERROR, message, data); }
  fatal(message, data) { this.log(LogLevel.FATAL, message, data); }

  log(level, message, data = null) {
    // 级别过滤:低于最小级别的日志不处理
    if (level.value < this.minLevel.value) return;

    const logEntry = {
      level: level.name,
      message: typeof message === 'string' ? message : JSON.stringify(message),
      data: data ? this.sanitizeData(data) : null,
      timestamp: new Date().toISOString(),
      context: { ...this.context },
      sessionId: this.sessionId,
      appName: this.appName,
      version: this.version,
      url: window.location.href,
      userAgent: navigator.userAgent.slice(0, 200),
      // 调用栈信息(可用于定位源码位置)
      stack: level.value >= LogLevel.ERROR.value ? new Error().stack?.slice(0, 500) : null,
    };

    // 1. 控制台输出
    if (this.enableConsole) {
      this.consoleLog(level, logEntry);
    }

    // 2. 加入缓冲区
    this.logBuffer.push(logEntry);

    // 3. 达到阈值或高优级别立即上报
    if (level.value >= LogLevel.ERROR.value || this.logBuffer.length >= this.bufferSize) {
      this.flush();
    }

    // 4. FATAL 级别触发特殊处理
    if (level.value >= LogLevel.FATAL.value) {
      this.onFatal(logEntry);
    }
  }

  consoleLog(level, entry) {
    const prefix = `[${this.appName}] [${level.name}]`;
    const style = `color: ${level.color}; font-weight: bold;`;
    
    switch (level.name) {
      case 'ERROR':
      case 'FATAL':
        console.error(`%c${prefix}`, style, entry.message, entry.data || '', entry.stack || '');
        break;
      case 'WARN':
        console.warn(`%c${prefix}`, style, entry.message, entry.data || '');
        break;
      default:
        console.log(`%c${prefix}`, style, entry.message, entry.data || '');
    }
  }

  // 设置全局上下文(任何日志都会携带)
  setContext(key, value) {
    this.context[key] = value;
  }

  setUser(userInfo) {
    this.setContext('userId', userInfo.id);
    this.setContext('userName', userInfo.name);
    this.setContext('userRole', userInfo.role);
  }

  // 敏感数据脱敏
  sanitizeData(data) {
    if (!data || typeof data !== 'object') return data;
    
    const sensitiveKeys = ['password', 'token', 'secret', 'credit', 'card', 'ssn', 'phone', 'email'];
    const sanitized = Array.isArray(data) ? [...data] : { ...data };
    
    for (const key of Object.keys(sanitized)) {
      if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
        sanitized[key] = '***REDACTED***';
      }
    }
    
    return sanitized;
  }

  // 批量上报
  flush() {
    if (this.logBuffer.length === 0) return;
    
    const batch = this.logBuffer.splice(0);
    const payload = JSON.stringify({
      appName: this.appName,
      version: this.version,
      logs: batch,
      timestamp: Date.now(),
    });

    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.reportUrl, payload);
    } else {
      fetch(this.reportUrl, {
        method: 'POST',
        body: payload,
        keepalive: true,
        headers: { 'Content-Type': 'application/json' },
      }).catch(() => {});
    }
  }

  onFatal(entry) {
    // FATAL 级别除上报外,还可以触发报警、崩溃回传等
    this.flush();
    
    // 如果有自定义的崩溃处理函数
    if (typeof this.config?.onFatal === 'function') {
      this.config.onFatal(entry);
    }
  }

  // 动态修改日志级别(线上紧急降级)
  setLevel(levelName) {
    this.minLevel = LogLevel.fromString(levelName);
    this.info(`日志级别已调整为: ${levelName}`);
  }

  setupBeforeUnload() {
    window.addEventListener('beforeunload', () => this.flush());
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush();
    });
  }
}

// 使用示例
const logger = new Logger({
  appName: 'ecommerce',
  version: '2.5.1',
  minLevel: 'info',
});

logger.setContext('page', 'product_detail');
logger.setUser({ id: 'u_12345', name: '张三', role: 'vip' });

logger.info('用户进入商品详情页', { productId: 'p_67890', price: 5999 });
logger.warn('商品库存不足', { productId: 'p_67890', stock: 0 });
logger.error('支付接口调用失败', { 
  orderId: 'o_11111',
  apiError: 'timeout',
  retryCount: 3,
});

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// 基于 IndexedDB 的日志持久化引擎
class LogStorage {
  constructor(options = {}) {
    this.dbName = options.dbName || 'LogDB';
    this.storeName = options.storeName || 'logs';
    this.maxEntries = options.maxEntries || 10000; // 最大条目数
    this.maxAge = options.maxAge || 7 * 24 * 3600 * 1000; // 最大保留 7 天
    this.db = null;
  }

  async open() {
    if (this.db) return this.db;

    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          // 创建对象仓库,使用 timestamp 作为索引
          const store = db.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true,
          });
          
          // 创建索引:按时间戳排序
          store.createIndex('timestamp', 'timestamp', { unique: false });
          // 创建索引:按日志级别过滤
          store.createIndex('level', 'level', { unique: false });
          // 创建索引:按会话分组
          store.createIndex('sessionId', 'sessionId', { unique: false });
          // 复合索引:级别 + 时间(用于高效查询某级别某时间段的日志)
          store.createIndex('level_time', ['level', 'timestamp'], { unique: false });
        }
      };

      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(this.db);
      };

      request.onerror = (event) => {
        console.error('[LogStorage] 打开数据库失败:', event.target.error);
        reject(event.target.error);
      };
    });
  }

  // 写入单条日志
  async write(logEntry) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      
      const request = store.add(logEntry);
      request.onsuccess = () => {
        this.trimOldEntries();
        resolve(request.result);
      };
      request.onerror = (e) => reject(e.target.error);
    });
  }

  // 批量写入
  async writeBatch(entries) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      
      let count = 0;
      for (const entry of entries) {
        const request = store.add(entry);
        request.onerror = (e) => reject(e.target.error);
        request.onsuccess = () => {
          count++;
          if (count === entries.length) {
            this.trimOldEntries();
            resolve(count);
          }
        };
      }
    });
  }

  // 按条件查询日志
  async query(filter = {}) {
    const db = await this.open();
    const tx = db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    
    let index;
    let range;
    
    if (filter.level && filter.startTime && filter.endTime) {
      // 使用复合索引
      index = store.index('level_time');
      range = IDBKeyRange.bound(
        [filter.level, filter.startTime],
        [filter.level, filter.endTime],
      );
    } else if (filter.level) {
      index = store.index('level');
      range = IDBKeyRange.only(filter.level);
    } else if (filter.startTime && filter.endTime) {
      index = store.index('timestamp');
      range = IDBKeyRange.bound(filter.startTime, filter.endTime);
    } else if (filter.sessionId) {
      index = store.index('sessionId');
      range = IDBKeyRange.only(filter.sessionId);
    } else if (filter.limit) {
      // 无过滤条件使用主键
      index = store.index('timestamp');
    }
    
    return new Promise((resolve, reject) => {
      const results = [];
      const cursorRequest = index 
        ? index.openCursor(range, 'prev') // 逆序(最新的在前)
        : store.openCursor(null, 'prev');
      
      const limit = filter.limit || 100;
      
      cursorRequest.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor && results.length < limit) {
          results.push(cursor.value);
          cursor.continue();
        } else {
          resolve(results);
        }
      };
      
      cursorRequest.onerror = (e) => reject(e.target.error);
    });
  }

  // 获取未上报的日志(用于批量发送)
  async getPendingLogs(limit = 100) {
    const results = await this.query({ 
      limit,
      // NOTE: 生产环境可以用 uploaded 字段标记已上报
    });
    return results;
  }

  // 清理过期和超量的日志
  async trimOldEntries() {
    const db = await this.open();
    const tx = db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    
    // 获取总条目数
    const countRequest = store.count();
    countRequest.onsuccess = () => {
      if (countRequest.result > this.maxEntries) {
        // 超限时,删除最早的条目
        const excess = countRequest.result - this.maxEntries;
        const index = store.index('timestamp');
        const cursorRequest = index.openCursor();
        let deleted = 0;
        
        cursorRequest.onsuccess = (event) => {
          const cursor = event.target.result;
          if (cursor && deleted < excess) {
            store.delete(cursor.primaryKey);
            deleted++;
            cursor.continue();
          }
        };
      }
    };
    
    // 删除超过保留期限的日志
    const cutoff = new Date(Date.now() - this.maxAge).toISOString();
    const index = store.index('timestamp');
    const range = IDBKeyRange.upperBound(cutoff);
    const cursorRequest = index.openCursor(range);
    
    cursorRequest.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        store.delete(cursor.primaryKey);
        cursor.continue();
      }
    };
  }

  // 清空所有日志
  async clear() {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      const request = store.clear();
      request.onsuccess = () => resolve();
      request.onerror = (e) => reject(e.target.error);
    });
  }

  // 获取统计数据
  async getStats() {
    const db = await this.open();
    const tx = db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    
    const count = await new Promise((resolve) => {
      const req = store.count();
      req.onsuccess = () => resolve(req.result);
    });
    
    return {
      totalCount: count,
      maxEntries: this.maxEntries,
      usagePercent: Math.round((count / this.maxEntries) * 100),
      dbName: this.dbName,
    };
  }
}

// 使用示例
async function setupLogging() {
  const storage = new LogStorage({ maxEntries: 5000 });
  await storage.open();
  
  // 写入测试日志
  await storage.write({
    level: 'ERROR',
    message: 'API 500异常',
    timestamp: new Date().toISOString(),
    sessionId: 'session_123',
    url: '/api/products',
    stack: 'Error: Internal Server Error\n    at handleRequest (api.js:45)',
  });
  
  // 查询最近 1 小时的 ERROR 日志
  const errors = await storage.query({
    level: 'ERROR',
    startTime: new Date(Date.now() - 3600000).toISOString(),
    endTime: new Date().toISOString(),
    limit: 50,
  });
  
  console.log('⚠️ 最近1小时的错误:', errors);
  
  // 获取存储统计
  const stats = await storage.getStats();
  console.log('📊 日志存储统计:', stats);
}

3. 日志查询与分析引擎

光存储不够——需要高效地查询和聚合。一个前端侧的日志查询引擎帮助开发者在本地排查:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// 日志查询与分析引擎
class LogQueryEngine {
  constructor(storage) {
    this.storage = storage;
    this.cache = new Map();
  }

  // 全字段搜索(类似 grep)
  async search(keyword, options = {}) {
    const { level, startTime, endTime, limit = 100 } = options;
    const logs = await this.storage.query({
      level,
      startTime,
      endTime,
      limit: limit * 2, // 多取一些以便过滤
    });

    // 关键词匹配(大小写不敏感)
    const kw = keyword.toLowerCase();
    return logs.filter((log) => {
      return log.message.toLowerCase().includes(kw) ||
             JSON.stringify(log.data || {}).toLowerCase().includes(kw) ||
             log.url?.toLowerCase().includes(kw) ||
             log.stack?.toLowerCase().includes(kw);
    }).slice(0, limit);
  }

  // 按级别聚合统计
  async aggregateByLevel(options = {}) {
    const logs = await this.storage.query(options);
    const stats = {
      DEBUG: 0,
      INFO: 0,
      WARN: 0,
      ERROR: 0,
      FATAL: 0,
    };

    for (const log of logs) {
      if (stats[log.level] !== undefined) {
        stats[log.level]++;
      }
    }

    stats.TOTAL = logs.length;
    return stats;
  }

  // 按时间聚合(时间直方图)
  async aggregateByTime(bucketMs = 60000, options = {}) {
    const logs = await this.storage.query(options);
    const buckets = new Map();

    for (const log of logs) {
      const time = new Date(log.timestamp).getTime();
      const bucket = Math.floor(time / bucketMs) * bucketMs;
      
      if (!buckets.has(bucket)) {
        buckets.set(bucket, { time: bucket, count: 0, errors: 0 });
      }
      const entry = buckets.get(bucket);
      entry.count++;
      if (log.level === 'ERROR' || log.level === 'FATAL') {
        entry.errors++;
      }
    }

    return Array.from(buckets.values()).sort((a, b) => a.time - b.time);
  }

  // 错误按 URL 聚合(找出高频出错的页面)
  async aggregateErrorsByUrl(options = {}) {
    const logs = await this.storage.query({
      ...options,
      level: 'ERROR',
    });

    const urlStats = new Map();
    for (const log of logs) {
      const url = log.url || 'unknown';
      if (!urlStats.has(url)) {
        urlStats.set(url, { url, count: 0, messages: new Set() });
      }
      const stat = urlStats.get(url);
      stat.count++;
      stat.messages.add(log.message);
    }

    return Array.from(urlStats.values())
      .map(({ url, count, messages }) => ({
        url,
        count,
        sampleMessage: Array.from(messages).slice(0, 3),
      }))
      .sort((a, b) => b.count - a.count);
  }

  // 获取错误调用栈的聚合统计(找出核心报错函数)
  async aggregateStackTraces(options = {}) {
    const logs = await this.storage.query({
      ...options,
      level: 'ERROR',
      limit: 500,
    });

    const stackPatterns = new Map();
    for (const log of logs) {
      if (!log.stack) continue;
      
      // 提取第一个非 Native 的调用位置
      const lines = log.stack.split('\n');
      const relevantLine = lines.find(
        (line) => line.includes('at ') && !line.includes('native')
      );
      
      if (relevantLine) {
        const pattern = relevantLine.trim();
        const count = stackPatterns.get(pattern) || 0;
        stackPatterns.set(pattern, count + 1);
      }
    }

    return Array.from(stackPatterns.entries())
      .map(([stack, count]) => ({ stack, count }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 20);
  }

  // 按会话 ID 追踪完整用户链路
  async traceSession(sessionId) {
    const logs = await this.storage.query({
      sessionId,
      limit: 500,
    });

    // 按时间排序
    logs.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));

    return {
      sessionId,
      totalLogs: logs.length,
      firstLog: logs[0]?.timestamp,
      lastLog: logs[logs.length - 1]?.timestamp,
      duration: logs.length > 1 
        ? new Date(logs[logs.length - 1].timestamp) - new Date(logs[0].timestamp)
        : 0,
      errors: logs.filter(l => l.level === 'ERROR' || l.level === 'FATAL').length,
      timeline: logs.map(l => ({
        time: l.timestamp,
        level: l.level,
        message: l.message?.slice(0, 100),
        url: l.url,
      })),
    };
  }

  // 导出日志为 JSON
  async exportToJSON(options = {}) {
    const logs = await this.storage.query({
      ...options,
      limit: 10000,
    });
    return JSON.stringify(logs, null, 2);
  }
}

// 使用示例
async function demoQuery() {
  const storage = new LogStorage();
  const engine = new LogQueryEngine(storage);
  
  // 搜索包含 "API" 的日志
  const results = await engine.search('API', { level: 'ERROR' });
  console.log('搜索 "API" 错误:', results);
  
  // 按错误 URL 聚合
  const urlErrors = await engine.aggregateErrorsByUrl();
  console.log('高频报错页面:', urlErrors);
  
  // 按时间聚合(每5分钟一个桶)
  const timeSeries = await engine.aggregateByTime(300000);
  console.log('时间序列:', timeSeries);
}

4. 全链路追踪(Trace ID 传递)

全链路追踪是日志系统的皇冠——它用一个 Trace ID 串联前端 → BFF → 微服务的完整链路:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// 全链路追踪器
class Tracer {
  constructor(options = {}) {
    this.appName = options.appName || 'app';
    this.traceId = this.generateTraceId();
    this.spanId = this.generateSpanId();
    this.parentSpanId = null;
    this.spanStack = [];
    this.isEnabled = true;
  }

  generateTraceId() {
    // 16 字节 hex 字符串
    const bytes = new Uint8Array(16);
    crypto.getRandomValues(bytes);
    return Array.from(bytes)
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }

  generateSpanId() {
    return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
  }

  // 创建一个新的 Span(表示一个操作单元)
  startSpan(name, options = {}) {
    if (!this.isEnabled) return null;

    const span = {
      traceId: this.traceId,
      spanId: this.generateSpanId(),
      parentSpanId: this.spanStack.length > 0 
        ? this.spanStack[this.spanStack.length - 1].spanId 
        : null,
      name,
      startTime: performance.now(),
      tags: options.tags || {},
      logs: [],
      status: 'ok',
    };

    this.spanStack.push(span);
    return span;
  }

  // 结束当前 Span
  endSpan(span, status = 'ok') {
    if (!span) return;
    
    span.duration = performance.now() - span.startTime;
    span.status = status;
    
    // 从栈中弹出
    const idx = this.spanStack.indexOf(span);
    if (idx >= 0) {
      this.spanStack.splice(idx, 1);
    }
    
    // 输出追踪日志
    this.emitSpan(span);
  }

  // 给当前 Span 加标签
  setTag(key, value) {
    const current = this.spanStack[this.spanStack.length - 1];
    if (current) {
      current.tags[key] = value;
    }
  }

  // 给当前 Span 加日志事件
  addLog(event, data) {
    const current = this.spanStack[this.spanStack.length - 1];
    if (current) {
      current.logs.push({
        event,
        data,
        timestamp: performance.now(),
      });
    }
  }

  // 发送 Span 数据(可对接日志系统或 APM)
  emitSpan(span) {
    const payload = {
      type: 'trace',
      traceId: span.traceId,
      spanId: span.spanId,
      parentSpanId: span.parentSpanId,
      operationName: span.name,
      startTime: Math.round(performance.timing?.navigationStart + span.startTime),
      duration: Math.round(span.duration),
      tags: span.tags,
      status: span.status,
    };

    // 通过 logger 输出
    if (window.__logger) {
      window.__logger.info(`[Trace] ${span.name}`, payload);
    }
  }

  // Wrap 一个异步函数为追踪 Span
  wrapAsync(fn, name) {
    const tracer = this;
    return async function (...args) {
      const span = tracer.startSpan(name || fn.name || 'anonymous');
      try {
        const result = await fn.apply(this, args);
        tracer.endSpan(span, 'ok');
        return result;
      } catch (error) {
        if (span) {
          tracer.setTag('error', true);
          tracer.setTag('errorMessage', error.message);
        }
        tracer.endSpan(span, 'error');
        throw error;
      }
    };
  }

  // 生成 HTTP 头,传递给后端
  getTraceHeaders() {
    const current = this.spanStack[this.spanStack.length - 1];
    return {
      'X-Trace-Id': this.traceId,
      'X-Span-Id': current?.spanId || this.spanId,
      'X-Parent-Span-Id': current?.parentSpanId || '',
    };
  }
}

// 全链路追踪使用示例
async function demoDistributedTracing() {
  const tracer = new Tracer({ appName: 'mall' });
  window.__tracer = tracer;
  
  // 页面初始化 Span
  const pageSpan = tracer.startSpan('page_load', {
    tags: { url: location.href, referrer: document.referrer },
  });
  
  // 模拟 API 调用(自动传递 Trace Header)
  async function fetchProductList() {
    const span = tracer.startSpan('fetch_products');
    tracer.setTag('api', '/api/products');
    
    try {
      const response = await fetch('/api/products', {
        headers: {
          ...tracer.getTraceHeaders(),
        },
      });
      tracer.addLog('response_received', { status: response.status });
      const data = await response.json();
      tracer.endSpan(span);
      return data;
    } catch (error) {
      tracer.setTag('error', error.message);
      tracer.endSpan(span, 'error');
      throw error;
    }
  }
  
  // 使用 wrapAsync 自动追踪
  const renderProducts = tracer.wrapAsync(async (products) => {
    // 渲染商品列表...
    console.log('渲染', products.length, '个商品');
  }, 'render_products');
  
  try {
    const products = await fetchProductList();
    await renderProducts(products);
  } finally {
    tracer.endSpan(pageSpan);
  }
}

实战案例:完整的前端日志系统

以下是将日志分级、IndexedDB 存储、查询引擎和全链路追踪整合在一起的生产级 SDK:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// FrontendLogSystem - 完整的前端日志系统
class FrontendLogSystem {
  constructor(config) {
    this.config = Object.assign({
      appName: 'app',
      version: '1.0.0',
      reportUrl: '/api/logs',
      minLevel: 'info',
      bufferSize: 100,
      storageMaxEntries: 5000,
      enableConsole: true,
      enableRemote: true,
      enableStorage: true,
      debug: false,
    }, config);

    this.logger = new Logger({
      appName: this.config.appName,
      version: this.config.version,
      minLevel: this.config.minLevel,
      reportUrl: this.config.reportUrl,
      enableConsole: this.config.enableConsole,
      remoteReport: this.config.enableRemote,
      bufferSize: this.config.bufferSize,
    });

    this.storage = this.config.enableStorage 
      ? new LogStorage({ maxEntries: this.config.storageMaxEntries })
      : null;

    this.queryEngine = this.storage 
      ? new LogQueryEngine(this.storage)
      : null;

    this.tracer = new Tracer({ appName: this.config.appName });

    // 将日志系统挂载到全局
    window.__logSystem = this;
    window.__logger = this.logger;

    // 拦截全局错误
    this.setupGlobalErrorCatch();
  }

  async init() {
    if (this.storage) {
      await this.storage.open();
      this.log('INFO', '日志存储引擎已初始化', { 
        maxEntries: this.config.storageMaxEntries,
      });
    }
    
    // 定期同步 IndexedDB 中的日志到远端
    if (this.storage && this.config.enableRemote) {
      setInterval(() => this.syncPendingLogs(), 30000);
    }
  }

  // 统一日志入口(同时写入缓冲区、存储和远端)
  log(level, message, data) {
    this.logger.log(LogLevel.fromString(level), message, data);
    
    // 同步写入 IndexedDB
    if (this.storage) {
      const lastLog = this.logger.logBuffer[this.logger.logBuffer.length - 1];
      if (lastLog) {
        this.storage.write(lastLog).catch((err) => {
          console.warn('[LogSystem] 存储日志失败:', err);
        });
      }
    }
  }

  // 创建追踪 Span
  startTrace(name, tags) {
    return this.tracer.startSpan(name, { tags });
  }

  endTrace(span, status) {
    this.tracer.endSpan(span, status);
  }

  // 拦截全局错误
  setupGlobalErrorCatch() {
    // 1. 未捕获异常
    window.addEventListener('error', (event) => {
      const errorLog = {
        level: 'ERROR',
        message: event.message || 'Uncaught Error',
        data: {
          filename: event.filename,
          lineno: event.lineno,
          colno: event.colno,
          error: event.error?.stack,
        },
      };
      this.log('ERROR', errorLog.message, errorLog.data);
    });

    // 2. Promise 未处理拒绝
    window.addEventListener('unhandledrejection', (event) => {
      const reason = event.reason;
      this.log('ERROR', 'Unhandled Promise Rejection', {
        message: reason?.message || String(reason),
        stack: reason?.stack,
      });
    });

    // 3. 资源加载失败
    document.addEventListener('error', (event) => {
      const target = event.target;
      if (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK') {
        this.log('WARN', '资源加载失败', {
          tag: target.tagName,
          src: target.src || target.href,
        });
      }
    }, true);
  }

  // 同步 IndexedDB 中的日志到远端
  async syncPendingLogs() {
    if (!this.storage) return;
    
    try {
      const stats = await this.storage.getStats();
      if (stats.totalCount === 0) return;

      const pendingLogs = await this.storage.query({ 
        limit: this.config.bufferSize,
        startTime: new Date(Date.now() - 3600000).toISOString(), // 最近1小时
      });

      if (pendingLogs.length === 0) return;

      const payload = JSON.stringify({
        type: 'log_sync',
        appName: this.config.appName,
        version: this.config.version,
        logs: pendingLogs,
        totalPending: stats.totalCount,
        timestamp: Date.now(),
      });

      fetch(this.config.reportUrl, {
        method: 'POST',
        body: payload,
        headers: { 'Content-Type': 'application/json' },
      }).then(async (res) => {
        if (res.ok) {
          // 同步成功后清理已上报的日志
          await this.storage.clear();
          this.log('INFO', `已同步 ${pendingLogs.length} 条日志到远端`);
        }
      }).catch(() => {});
    } catch (e) {
      this.log('WARN', '同步日志失败', { error: e.message });
    }
  }

  // 查询日志
  async query(filter) {
    if (this.queryEngine) {
      return this.queryEngine.search(filter.keyword || '', filter);
    }
    return [];
  }

  // 导出日志
  async exportLogs() {
    if (this.queryEngine) {
      return this.queryEngine.exportToJSON();
    }
    return '[]';
  }

  destroy() {
    this.logger.flush();
  }
}

底层原理

IndexedDB 的事务与游标机制

IndexedDB 的所有操作都在事务(Transaction)中执行。事务分为 readonlyreadwrite 两种模式,同一时间一个对象仓库只能有一个 readwrite 事务,但可以有多个 readonly 事务。这就是为什么批量写入日志时需要用同一个事务一次性写入——否则多个写事务会排队。

游标(Cursor)是 IndexedDB 范围查询的核心机制。当 store.openCursor(range) 执行时,浏览器会在存储引擎层(LevelDB / SQLite)创建一个迭代器,每次调用 cursor.continue() 都会移动游标到下一个匹配位置。游标操作是异步的——回调会在微任务中被调用,从而避免阻塞主线程。

sendBeacon 与 fetch keepalive

sendBeacon 底层使用了独立的网络栈(与主页面网络栈隔离),这意味着它能绕过 beforeunload 队列中已取消的请求。Chrome 的实现中,Beacon 请求由浏览器进程(Browser Process)而非渲染进程(Renderer Process)发起,因此页面销毁不会影响 Beacon 请求。

fetch()keepalive: true 选项类似于 sendBeacon,但它提供完整的请求/响应控制(可以读取响应状态码)。keepalive 请求的上限是 64KB 的 payload,超过时会被静默丢弃。

Log Levels 与生产性能

每多记录一条日志,至少增加一次 JSON.stringify 操作和一次内存分配。在大量 WARN 级别日志的场景下,如果将所有 WARN 日志也字符串化了再判断级别,性能表现极差。这就是分级过滤必须发生在”格式化之前”的原因。经典实现如下:

1
2
3
4
5
6
7
8
9
10
11
// 高效的分级过滤 - 在格式化之前拦截
log(level, message, data) {
  // ✅ 先比较级别,再处理数据
  if (level.value < this.minLevel.value) return;
  
  // 只有通过级别过滤后,才执行开销大的操作
  const formattedMsg = typeof message === 'object' 
    ? this.safeStringify(message) 
    : message;
  // ...
}

高频面试题解析

面试题 1:为什么前端日志系统不能用 console.log 来解决?

答案要点: 1)console.log 的输出仅存在于浏览器控制台,不能在用户环境查看;2)无法持久化——页面刷新后日志丢失;3)无法分级过滤——生产环境不能关闭 console.log;4)无法远程收集——线上问题需要日志数据回传;5)console.log 是同步操作,大量日志会阻塞主线程影响性能。因此生产级日志系统需要:分级、持久化(IndexedDB)、批量上报、远程收集。

面试题 2:如何确保高并发场景下日志不会被大量重复写入?

答案要点: 1)设置缓冲区上限(如 100 条),超出后丢弃最早日志或暂停写入;2)使用去重哈希——相同 message + 相同 stack 的日志在短时间内(如 1 秒内)只记录一次(通过 Leaky Bucket 算法);3)使用 requestAnimationFramerequestIdleCallback 调度日志写入,避免在事件循环的高峰期写入;4)IndexedDB 使用批量事务而非逐条写入。

面试题 3:如何实现用户侧的日志回溯(用户反馈问题时查看日志)?

答案要点: 需要一种”时光机”机制:1)用户端持续将日志写入 IndexedDB(循环队列,保留最近 N 条);2)用户触发反馈时(如点击”反馈”按钮),前端导出最近 1 小时的日志并附加在反馈数据中;3)对于不能预判的 Bug,可在 URL 上加 ?debug=true 参数来动态开启详细日志模式;4)更高级的方案是在用户同意后,后台建立一个 WebSocket 实时从目标设备拉取日志。

面试题 4:前端日志如何与后端链路串联(全链路追踪)?

答案要点: 核心是传递统一的 Trace ID:1)页面加载时生成 Trace ID(首次请求可以从后端返回的页面 HTML 中读取);2)前端所有 API 请求的 HTTP Header 中带上 X-Trace-Id;3)后端接受到 Trace ID 后继续透传到下游服务(如通过 gRPC 的 metadata 传递);4)前后端日志平台以 Trace ID 为关联键,实现端到端的调用链可视化。

面试题 5:线上发现大量 WARN 日志但无法快速定位来源,有什么优化手段?

答案要点: 1)为每条日志增加”代码位置”元信息——通过 new Error().stack 提取调用位置;2)使用 SourceMap 将压缩后的代码位置映射为源码位置(需要 SourceMap 上报能力);3)实施”日志打标签”——每个业务模块分配一个 tag(如 [cart][checkout][search]),便于按模块过滤;4)对相同位置的 WARN 日志做”采样压缩”——相同的 message+位置 在 1 分钟内只上报一次,附带统计次数。

总结与扩展

知识体系图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
前端日志系统
├── 日志生产
│   ├── 分级(DEBUG/INFO/WARN/ERROR/FATAL)
│   ├── 结构化(固定 Schema + 自由数据)
│   └── 上下文(用户、会话、页面等公共字段)
├── 日志存储
│   ├── 内存缓冲区(实时写入,崩溃易失)
│   ├── IndexedDB(持久化,循环队列)
│   └── LocalStorage(兜底,容量限制 5MB)
├── 日志传输
│   ├── 批量合并上报(定时/阈值触发)
│   ├── sendBeacon(卸载保活)
│   └── 优先级队列(ERROR 优先,DEBUG 后置)
├── 日志查询
│   ├── 全文搜索(关键词匹配)
│   ├── 维度过滤(级别/时间/模块/用户)
│   ├── 统计聚合(按 URL/级别按时间聚合)
│   └── 全链路追踪(Trace ID 串联)
└── 日志治理
    ├── 去重 & 采样(噪声控制)
    ├── 脱敏(敏感信息过滤)
    ├── 轮转 & 清理(容量控制)
    └── SourceMap 还原(线上堆栈可读)

延伸阅读

  1. Log4j / Logback 设计模式: Java 日志系统的分级与 Appender 架构(前端可借鉴)
  2. Sentry 前端 SDK 源码: 错误日志采集的开源标杆
  3. W3C Performance Timeline: performance.measure / mark 作为日志的时间戳基础设施
  4. IndexedDB 最佳实践: MDN 文档关于游标和事务的详细说明
  5. OpenTelemetry JavaScript SDK: 分布式追踪的前端 SDK 实现参考
本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量