文章

监控SDK设计深度解析

监控SDK设计深度解析

一句话概括

监控 SDK 是前端工程质量保障的前哨兵,一个好的 SDK 要在”采集全面性”、”性能零开销幻觉”和”数据可靠性”三个维度上取得极致平衡。本文从架构分层(采集层 → 处理层 → 传输层)、数据压缩策略(JSON 精简、字段编码、Gzip 压缩)、批量上报引擎(队列管理、消峰、重试)到插件化扩展机制,给出一个完整的可嵌入式监控 SDK 设计与实现。

背景与意义

监控 SDK 是所有前端监控体系的”最后一道防线”——它不能影响宿主应用的性能,不能在用户设备上造成额外负担,但又要能在关键时刻采集到必要的诊断数据。在工程化成熟的团队中,监控 SDK 通常被设计为独立 npm 包,通过构建工具注入或手动引入到项目中。面试中,监控 SDK 的设计与实现常常以”系统设计题”的形式出现,考察候选人的架构抽象能力和对性能开销的敏感度。

概念与定义

采集层 (Collector)

监控 SDK 的最底层,负责从各个数据源(浏览器 API、用户交互、自定义打点)获取原始数据。

处理层 (Processor)

对原始数据进行标准化、过滤、采样、聚合和压缩。处理层是性能优化的主战场。

传输层 (Transporter)

将处理后的数据可靠地传输到后端服务,包含队列管理、重试机制、保活策略和数据格式优化。

插件系统 (Plugin)

允许用户按需扩展 SDK 能力的内置机制。通过插件可以添加自定义监控类型而不修改核心代码。

数据压缩 (Data Compression)

通过字段缩写、精度控制、字典编码等技术减少数据传输体积,典型的压缩比可达 10:1。

核心知识点拆解

1. SDK 整体架构设计

一个好的监控 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
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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
// Monitor SDK 核心架构 - 分层设计
class MonitorSDK {
  constructor(options = {}) {
    this.config = this.initConfig(options);
    this.collectors = new Map();   // 采集器注册表
    this.processors = [];          // 处理器链
    this.transporter = null;       // 传输器
    this.plugins = new Map();      // 插件注册表
    this.queue = [];               // 数据队列
    this.isInitialized = false;
    this.hooks = { beforeProcess: [], afterProcess: [], beforeSend: [] };
  }

  // 初始化配置(合并默认值)
  initConfig(options) {
    return {
      appName: options.appName || 'default',
      version: options.version || '1.0.0',
      debug: options.debug || false,
      // 采集配置
      collect: {
        perf: options.collect?.perf ?? true,
        error: options.collect?.error ?? true,
        behavior: options.collect?.behavior ?? false,
        custom: options.collect?.custom ?? true,
      },
      // 采样配置
      sample: {
        perf: options.sample?.perf ?? 0.1,
        error: options.sample?.error ?? 1.0,
        behavior: options.sample?.behavior ?? 0.01,
      },
      // 上报配置
      transport: {
        url: options.transport?.url || '/api/monitor',
        method: options.transport?.method || 'POST',
        batchSize: options.transport?.batchSize || 20,
        interval: options.transport?.interval || 10000,
        retry: options.transport?.retry ?? 3,
        compression: options.transport?.compression ?? 'standard',
      },
      // 性能保护
      protection: {
        maxQueueSize: options.protection?.maxQueueSize || 200,
        maxProcessTime: options.protection?.maxProcessTime || 5,
        memoryLimit: options.protection?.memoryLimit || 5 * 1024 * 1024,
      },
    };
  }

  // 注册采集器
  registerCollector(name, collector) {
    this.collectors.set(name, collector);
    collector.setSDK(this);
    return this;
  }

  // 注册处理器
  addProcessor(processor, priority = 0) {
    this.processors.push({ processor, priority });
    this.processors.sort((a, b) => b.priority - a.priority);
    return this;
  }

  // 设置传输器
  setTransporter(transporter) {
    this.transporter = transporter;
    transporter.setSDK(this);
    return this;
  }

  // 注册插件
  use(plugin) {
    if (typeof plugin === 'function') {
      plugin = plugin(this);
    }
    if (plugin && plugin.name) {
      this.plugins.set(plugin.name, plugin);
      if (typeof plugin.onRegister === 'function') {
        plugin.onRegister(this);
      }
    }
    return this;
  }

  // 注册钩子
  on(event, handler) {
    if (this.hooks[event]) {
      this.hooks[event].push(handler);
    }
    return this;
  }

  // 初始化 SDK
  async init() {
    if (this.isInitialized) return;
    this.log('[MonitorSDK] 初始化...');

    // 1. 初始化所有采集器
    for (const [name, collector] of this.collectors) {
      try {
        collector.init();
        this.log(`  ✅ 采集器 [${name}] 已初始化`);
      } catch (e) {
        this.log(`  ❌ 采集器 [${name}] 初始化失败:`, e.message);
      }
    }

    // 2. 初始化传输器
    if (this.transporter) {
      this.transporter.init();
    }

    // 3. 初始化插件
    for (const [name, plugin] of this.plugins) {
      if (typeof plugin.init === 'function') {
        try {
          plugin.init(this);
          this.log(`  ✅ 插件 [${name}] 已初始化`);
        } catch (e) {
          this.log(`  ❌ 插件 [${name}] 初始化失败:`, e.message);
        }
      }
    }

    // 4. 启动定时上报
    this.startFlushTimer();
    
    this.isInitialized = true;
    this.log('[MonitorSDK] 初始化完成');
  }

  // 采集数据入口
  report(type, data) {
    if (!this.isInitialized) {
      this.queue.push({ type, data, timestamp: Date.now() });
      return;
    }

    // 执行钩子
    this.runHooks('beforeProcess', { type, data });

    // 经过处理器链
    let processed = { type, data, timestamp: Date.now() };
    for (const { processor } of this.processors) {
      try {
        processed = processor.process(processed);
        if (processed === null) return; // 处理器决定丢弃该数据
      } catch (e) {
        this.log('[MonitorSDK] 处理器异常:', e);
      }
    }

    // 执行处理完成钩子
    this.runHooks('afterProcess', processed);

    // 加入传输队列
    this.enqueue(processed);
  }

  // 入队
  enqueue(data) {
    this.queue.push(data);
    
    // 达到批量阈值立即发送
    if (this.queue.length >= this.config.transport.batchSize) {
      this.flush();
    }
    
    // 队列保护
    if (this.queue.length > this.config.protection.maxQueueSize) {
      this.queue.splice(0, this.queue.length - this.config.protection.maxQueueSize);
      this.log('[MonitorSDK] ⚠️ 队列溢出,丢弃最早的数据');
    }
  }

  // 批量发送
  flush() {
    if (this.queue.length === 0 || !this.transporter) return;
    
    const batch = this.queue.splice(0, this.config.transport.batchSize);
    this.runHooks('beforeSend', batch);
    this.transporter.send(batch);
  }

  // 定时刷新
  startFlushTimer() {
    this.flushTimer = setInterval(() => {
      this.flush();
    }, this.config.transport.interval);

    // 页面隐藏时立即刷新
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });

    window.addEventListener('beforeunload', () => this.flush());
  }

  // 获取当前状态
  getStatus() {
    return {
      initialized: this.isInitialized,
      queueLength: this.queue.length,
      collectorCount: this.collectors.size,
      processorCount: this.processors.length,
      pluginCount: this.plugins.size,
      config: this.config,
    };
  }

  runHooks(name, data) {
    for (const handler of this.hooks[name] || []) {
      try {
        handler(data);
      } catch (e) {
        this.log(`[MonitorSDK] Hook [${name}] 异常:`, e);
      }
    }
  }

  log(...args) {
    if (this.config.debug) {
      console.log(`[${this.config.appName}]`, ...args);
    }
  }

  destroy() {
    this.flush();
    clearInterval(this.flushTimer);
    for (const [, collector] of this.collectors) {
      if (typeof collector.destroy === 'function') collector.destroy();
    }
    this.isInitialized = false;
  }
}

// 采集器基类
class BaseCollector {
  constructor(options = {}) {
    this.name = options.name || 'base';
    this.sdk = null;
    this.enabled = true;
  }

  setSDK(sdk) { this.sdk = sdk; }
  init() {} // 子类实现
  destroy() {} // 子类实现
}

// 传输器基类
class BaseTransporter {
  constructor(options = {}) {
    this.sdk = null;
    this.pendingRetries = new Map();
  }

  setSDK(sdk) { this.sdk = sdk; }
  init() {}
  
  send(batch) {
    throw new Error('传输器必须实现 send 方法');
  }
}

2. 数据压缩策略

数据传输体积是监控 SDK 最大的性能杀手。一个合理的设计能减少 90% 以上的传输量:

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
// 数据压缩引擎
class DataCompressor {
  constructor(options = {}) {
    // 字段缩写映射表(减小 JSON key 长度)
    this.fieldMap = {
      // 标准缩写
      type: 't',
      data: 'd',
      timestamp: 'ts',
      level: 'l',
      message: 'msg',
      appName: 'an',
      version: 'v',
      url: 'u',
      userAgent: 'ua',
      // 性能相关
      startTime: 'st',
      duration: 'dur',
      name: 'n',
      value: 'val',
      // 行为相关
      event: 'evt',
      category: 'cat',
      action: 'act',
      label: 'lb',
      // 错误相关
      stack: 'sk',
      lineno: 'ln',
      colno: 'cl',
      filename: 'fn',
      // 环境
      screenWidth: 'sw',
      screenHeight: 'sh',
      viewportWidth: 'vw',
      viewportHeight: 'vh',
      devicePixelRatio: 'dpr',
      connection: 'conn',
      language: 'lang',
      platform: 'plat',
      timezone: 'tz',
      referrer: 'ref',
      sessionId: 'sid',
      traceId: 'tid',
    };

    this.reverseMap = {};
    for (const [key, val] of Object.entries(this.fieldMap)) {
      this.reverseMap[val] = key;
    }

    this.stringPool = new Set();
    this.useCompression = options.useCompression ?? true;
    this.useAbbreviation = options.useAbbreviation ?? true;
  }

  // 压缩单个数据记录
  compress(record) {
    if (!this.useCompression) return record;

    let compressed = null;

    // 1. 字段缩写
    if (this.useAbbreviation) {
      compressed = this.abbreviateFields(record);
    } else {
      compressed = { ...record };
    }

    // 2. 数值精度控制(时间戳和时长取整)
    compressed = this.controlPrecision(compressed);

    // 3. 删除非必要字段
    compressed = this.removeRedundantFields(compressed);

    return compressed;
  }

  // 批量压缩
  compressBatch(records) {
    return records.map(r => this.compress(r));
  }

  // 字段缩写
  abbreviateFields(obj) {
    if (!obj || typeof obj !== 'object') return obj;
    if (Array.isArray(obj)) return obj.map(item => this.abbreviateFields(item));

    const result = {};
    for (const [key, value] of Object.entries(obj)) {
      const shortKey = this.fieldMap[key] || key;
      result[shortKey] = this.abbreviateFields(value);
    }
    return result;
  }

  // 数值精度控制
  controlPrecision(obj) {
    if (!obj || typeof obj !== 'object') return obj;
    if (Array.isArray(obj)) return obj.map(item => this.controlPrecision(item));

    const result = {};
    for (const [key, value] of Object.entries(obj)) {
      if (typeof value === 'number') {
        // 时间戳取整到秒(原毫秒)
        if (key === 'ts' || key === 'timestamp' || key === 'st') {
          result[key] = Math.round(value / 1000);
        }
        // 性能值保留 1 位小数
        else if (key === 'dur' || key === 'duration' || key === 'val' || key === 'value') {
          result[key] = Math.round(value * 10) / 10;
        }
        // 其他数值保留整数
        else if (Number.isInteger(value)) {
          result[key] = value;
        } else {
          result[key] = Math.round(value * 100) / 100;
        }
      } else {
        result[key] = this.controlPrecision(value);
      }
    }
    return result;
  }

  // 移除非必要的冗余字段
  removeRedundantFields(obj) {
    // 可删除的冗余字段列表
    const redundantFields = [
      'appName', 'an',          // 已在批次级别携带
      'version', 'v',           // 已在批次级别携带
      'sessionId', 'sid',       // 可在批次级别携带
    ];

    if (Array.isArray(obj)) {
      return obj.map(item => this.removeRedundantFields(item));
    }

    if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
      const result = { ...obj };
      for (const field of redundantFields) {
        delete result[field];
      }
      // 递归处理嵌套
      for (const key of Object.keys(result)) {
        result[key] = this.removeRedundantFields(result[key]);
      }
      return result;
    }

    return obj;
  }

  // 解压缩(服务端使用)
  decompress(compressed) {
    if (!compressed || typeof compressed !== 'object') return compressed;
    if (Array.isArray(compressed)) return compressed.map(item => this.decompress(item));

    const result = {};
    for (const [key, value] of Object.entries(compressed)) {
      const fullKey = this.reverseMap[key] || key;
      result[fullKey] = this.decompress(value);
    }
    return result;
  }

  // 计算压缩比
  getCompressionRatio(original, compressed) {
    const origSize = new Blob([JSON.stringify(original)]).size;
    const compSize = new Blob([JSON.stringify(compressed)]).size;
    return {
      original: origSize,
      compressed: compSize,
      ratio: origSize > 0 ? (compSize / origSize).toFixed(3) : 1,
      saved: `${Math.round((1 - compSize / origSize) * 100)}%`,
    };
  }
}

// 压缩测试
function testCompression() {
  const compressor = new DataCompressor();
  
  const sampleRecord = {
    type: 'performance',
    data: {
      name: 'LCP',
      value: 2345.678,
      startTime: 1782435678901,
      duration: 2345.678,
    },
    timestamp: 1782435678901,
    level: 'info',
    message: 'LCP captured',
    appName: 'myApp',
    version: '2.1.0',
    url: 'https://example.com/products/123',
    sessionId: 'sess_abc123',
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...',
    screenWidth: 1920,
    screenHeight: 1080,
    viewportWidth: 1440,
    viewportHeight: 900,
    devicePixelRatio: 2,
    connection: '4g',
    language: 'zh-CN',
    platform: 'MacIntel',
    timezone: 'Asia/Shanghai',
    referrer: 'https://google.com',
  };

  const compressed = compressor.compress(sampleRecord);
  const stats = compressor.getCompressionRatio(sampleRecord, compressed);
  
  console.log('压缩前:', JSON.stringify(sampleRecord).length, 'bytes');
  console.log('压缩后:', JSON.stringify(compressed).length, 'bytes');
  console.log('压缩比:', stats.ratio, `(节省 ${stats.saved})`);
  console.log('压缩后数据:', compressed);
}

3. 批量上报与重试引擎

传输层是监控 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
// 智能批量上报引擎
class BatchTransporter extends BaseTransporter {
  constructor(options = {}) {
    super(options);
    this.url = options.url || '/api/monitor';
    this.method = options.method || 'POST';
    this.maxRetries = options.retry || 3;
    this.retryDelay = options.retryDelay || 1000;
    this.compressor = new DataCompressor();
    this.pendingBatches = [];
    this.isSending = false;
    this.stats = {
      sent: 0,
      failed: 0,
      retried: 0,
      dropped: 0,
    };
  }

  // 发送批次
  async send(batch) {
    // 数据压缩
    const compressed = this.compressor.compressBatch(batch);
    
    // 构造 payload
    const payload = {
      an: this.sdk.config.appName,
      v: this.sdk.config.version,
      ts: Math.round(Date.now() / 1000),
      sid: this.generateSessionId(),
      events: compressed,
    };

    // 添加批次级别的公共字段
    const body = JSON.stringify(payload);
    
    // 尝试发送
    await this.trySend(body, 0);
  }

  // 带重试的发送
  async trySend(body, attempt) {
    try {
      this.isSending = true;
      
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 10000);

      const response = await fetch(this.url, {
        method: this.method,
        body,
        headers: {
          'Content-Type': 'application/json',
          'X-Compression': 'abbreviated',
        },
        signal: controller.signal,
        keepalive: attempt === 0, // 仅首次尝试带 keepalive
      });

      clearTimeout(timeoutId);

      if (response.ok) {
        this.stats.sent++;
        this.sdk.log(`[Transporter] ✅ 数据发送成功 (${this.stats.sent})`);
      } else {
        throw new Error(`HTTP ${response.status}`);
      }
    } catch (error) {
      this.stats.failed++;
      
      if (attempt < this.maxRetries) {
        this.stats.retried++;
        const delay = this.retryDelay * Math.pow(2, attempt); // 指数退避
        this.sdk.log(`[Transporter] 🔄 发送失败(第${attempt + 1}次),${delay}ms后重试:`, error.message);
        
        await new Promise(resolve => setTimeout(resolve, delay));
        await this.trySend(body, attempt + 1);
      } else {
        // 超过重试次数,丢弃
        this.stats.dropped++;
        this.sdk.log(`[Transporter] ❌ 数据丢弃(重试${this.maxRetries}次后失败)`);
      }
    } finally {
      this.isSending = false;
      
      // 继续发送队列中的其他批次
      if (this.pendingBatches.length > 0) {
        const next = this.pendingBatches.shift();
        this.send(next);
      }
    }
  }

  generateSessionId() {
    return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
  }

  getStats() {
    return { ...this.stats };
  }
}

// 分片传输器(大 payload 自动分片)
class ChunkedTransporter extends BatchTransporter {
  constructor(options = {}) {
    super(options);
    this.maxChunkSize = options.maxChunkSize || 50000; // 50KB
  }

  async send(batch) {
    const compressed = this.compressor.compressBatch(batch);
    const body = JSON.stringify(compressed);
    
    // 如果数据超过分片大小,拆分为多个请求
    if (body.length > this.maxChunkSize) {
      const chunks = this.splitIntoChunks(compressed);
      this.sdk.log(`[ChunkedTransporter] 分片 ${chunks.length} 个请求发送 (${body.length} bytes)`);
      
      for (const chunk of chunks) {
        await super.send(chunk);
      }
    } else {
      await super.send(batch);
    }
  }

  splitIntoChunks(records) {
    const chunks = [];
    let currentChunk = [];
    let currentSize = 0;

    for (const record of records) {
      const recordSize = new Blob([JSON.stringify(record)]).size;
      
      if (currentSize + recordSize > this.maxChunkSize && currentChunk.length > 0) {
        chunks.push(currentChunk);
        currentChunk = [];
        currentSize = 0;
      }
      
      currentChunk.push(record);
      currentSize += recordSize;
    }

    if (currentChunk.length > 0) {
      chunks.push(currentChunk);
    }

    return chunks;
  }
}

4. 插件化扩展机制

插件系统是 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
// 插件系统
class PluginSystem {
  constructor(sdk) {
    this.sdk = sdk;
    this.plugins = new Map();
    this.hooks = {
      beforeInit: [],
      afterInit: [],
      beforeReport: [],
      afterReport: [],
      beforeFlush: [],
      afterFlush: [],
      beforeDestroy: [],
    };
  }

  // 注册插件
  register(plugin) {
    if (this.plugins.has(plugin.name)) {
      this.sdk.log(`[PluginSystem] 插件 "${plugin.name}" 已存在,跳过`);
      return;
    }

    this.plugins.set(plugin.name, plugin);
    
    // 注册生命周期钩子
    if (plugin.hooks) {
      for (const [hookName, handler] of Object.entries(plugin.hooks)) {
        if (this.hooks[hookName]) {
          this.hooks[hookName].push(handler.bind(plugin));
        }
      }
    }

    // 调用插件的 onRegister
    if (typeof plugin.onRegister === 'function') {
      plugin.onRegister(this.sdk);
    }

    this.sdk.log(`[PluginSystem] 插件 "${plugin.name}" 已注册`);
    return this;
  }

  // 执行钩子
  async runHook(hookName, ...args) {
    const handlers = this.hooks[hookName] || [];
    for (const handler of handlers) {
      try {
        await handler(...args);
      } catch (e) {
        this.sdk.log(`[PluginSystem] 插件钩子 [${hookName}] 异常:`, e);
      }
    }
  }
}

// 示例插件:Long Tasks 采集器插件
const LongTasksPlugin = {
  name: 'long-tasks',
  
  hooks: {
    // 在 SDK 初始化后启动 Long Tasks 监听
    afterInit(sdk) {
      this.observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (entry.duration > 50) { // 仅记录 >50ms 的任务
            sdk.report('longtask', {
              duration: entry.duration,
              startTime: entry.startTime,
              attribution: entry.attribution?.map(a => ({
                name: a.name,
                containerType: a.containerType,
                containerSrc: a.containerSrc,
              })),
            });
          }
        }
      });
      
      try {
        this.observer.observe({ type: 'longtask', buffered: true });
      } catch (e) {
        console.warn('[LongTasksPlugin] PerformanceObserver 不支持');
      }
    },

    beforeDestroy() {
      if (this.observer) {
        this.observer.disconnect();
      }
    },
  },
};

// 示例插件:资源加载监控插件
const ResourceTimingPlugin = {
  name: 'resource-timing',

  onRegister(sdk) {
    // 注册采集规则
    sdk.addProcessor({
      process(data) {
        // 仅当是 resource 类型且 CSS 资源加载超过 2s 时标记为慢资源
        if (data.type === 'resource' && data.data?.initiatorType === 'css' && data.data?.duration > 2000) {
          data.data.slowResource = true;
        }
        return data;
      },
    }, 10); // 高优先级处理器
  },

  hooks: {
    afterInit(sdk) {
      // 页面加载完成后采集所有资源数据
      window.addEventListener('load', () => {
        setTimeout(() => {
          const resources = performance.getEntriesByType('resource');
          const slowResources = resources.filter(r => r.duration > 1000);
          
          if (slowResources.length > 0) {
            sdk.report('slow_resources', slowResources.map(r => ({
              name: r.name,
              duration: r.duration,
              initiatorType: r.initiatorType,
              transferSize: r.transferSize,
              nextHopProtocol: r.nextHopProtocol,
            })));
          }
        }, 1000);
      });
    },
  },
};

// 使用示例
const sdk = new MonitorSDK({
  appName: 'demo',
  version: '1.0.0',
  debug: true,
  transport: { url: '/api/monitor' },
});

// 注册插件
sdk.use(LongTasksPlugin);
sdk.use(ResourceTimingPlugin);

// 添加插件后的自定义采集器
sdk.registerCollector('custom', {
  ...new BaseCollector({ name: 'custom' }),
  init() {
    // 自定义业务打点
    document.querySelector('#buy-btn')?.addEventListener('click', () => {
      this.sdk.report('business', {
        action: 'click_buy',
        productId: document.querySelector('[data-product-id]')?.dataset.productId,
        price: document.querySelector('.price')?.textContent,
      });
    });
  },
});

// 初始化
sdk.setTransporter(new BatchTransporter({ url: '/api/monitor' }));
sdk.init();

实战案例:一站到底的监控 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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
// === 实际使用的完整监控 SDK 实例 ===

// 1. 性能采集器
class PerfCollector extends BaseCollector {
  constructor() {
    super({ name: 'performance' });
    this.metrics = { FCP: 0, LCP: 0, CLS: 0, INP: 0, TTFB: 0 };
  }

  init() {
    this.observePaint();
    this.observeLCP();
    this.observeCLS();
    this.observeINP();
    this.observeNavigation();
  }

  observePaint() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          this.metrics.FCP = entry.startTime;
          this.sdk.report('perf', { name: 'FCP', value: entry.startTime });
        }
      }
    });
    observer.observe({ type: 'paint', buffered: true });
  }

  observeLCP() {
    let lcpValue = 0;
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      lcpValue = entries[entries.length - 1].startTime;
    });
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
    
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden' && lcpValue > 0) {
        this.sdk.report('perf', { name: 'LCP', value: lcpValue });
      }
    }, { once: true });
  }

  observeCLS() {
    let clsValue = 0;
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
    });
    observer.observe({ type: 'layout-shift', buffered: true });
    
    window.addEventListener('beforeunload', () => {
      if (clsValue > 0) {
        this.sdk.report('perf', { name: 'CLS', value: parseFloat(clsValue.toFixed(4)) });
      }
    });
  }

  observeINP() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.sdk.report('perf', { name: 'INP', value: entry.duration });
      }
    });
    observer.observe({ type: 'first-input', buffered: true });
    observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
  }

  observeNavigation() {
    window.addEventListener('load', () => {
      const [nav] = performance.getEntriesByType('navigation');
      if (nav) {
        this.metrics.TTFB = nav.responseStart - nav.requestStart;
        this.sdk.report('perf', { name: 'TTFB', value: this.metrics.TTFB });
        this.sdk.report('perf', { name: 'DOMReady', value: nav.domContentLoadedEventEnd });
        this.sdk.report('perf', { name: 'Load', value: nav.loadEventEnd });
      }
    });
  }
}

// 2. 错误采集器
class ErrorCollector extends BaseCollector {
  constructor() {
    super({ name: 'error' });
  }

  init() {
    // JS 异常
    window.addEventListener('error', (event) => {
      this.sdk.report('error', {
        type: 'js_error',
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error?.stack || '',
      });
    });

    // Promise 异常
    window.addEventListener('unhandledrejection', (event) => {
      const reason = event.reason;
      this.sdk.report('error', {
        type: 'promise_rejection',
        message: reason?.message || String(reason),
        stack: reason?.stack || '',
      });
    });

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

// 3. 行为采集器
class BehaviorCollector extends BaseCollector {
  constructor() {
    super({ name: 'behavior' });
  }

  init() {
    // 点击自动采集
    document.addEventListener('click', (e) => {
      const target = e.target;
      if (target.tagName === 'HTML' || target.tagName === 'BODY') return;
      
      this.sdk.report('behavior', {
        action: 'click',
        tag: target.tagName.toLowerCase(),
        id: target.id || '',
        text: target.textContent?.trim()?.slice(0, 50) || '',
        href: target.href || '',
        x: e.clientX,
        y: e.clientY,
      });
    }, { capture: true });

    // 页面停留时长
    this.pageEnterTime = Date.now();
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        const duration = Date.now() - this.pageEnterTime;
        this.sdk.report('behavior', { action: 'page_duration', duration });
      }
    });
  }
}

// 4. 组装完整 SDK
const monitor = new MonitorSDK({
  appName: 'myApp',
  version: '2.1.0',
  debug: true,
  sample: { perf: 0.2, error: 1.0, behavior: 0.05 },
  transport: {
    url: 'https://monitor.example.com/api/events',
    batchSize: 30,
    interval: 15000,
    compression: 'standard',
  },
});

// 注册采集器
monitor.registerCollector('perf', new PerfCollector());
monitor.registerCollector('error', new ErrorCollector());
monitor.registerCollector('behavior', new BehaviorCollector());

// 注册处理器(过滤、采样)
monitor.addProcessor({
  process(data) {
    // 丢弃移动端弱网场景下的性能数据(不可靠)
    if (data.type === 'perf' && navigator.connection?.effectiveType === 'slow-2g') {
      return null;
    }
    return data;
  },
}, 100);

// 注册传输器
monitor.setTransporter(new ChunkedTransporter({
  url: 'https://monitor.example.com/api/events',
  maxChunkSize: 50000,
}));

// 初始化
monitor.init();
console.log('监控 SDK 状态:', monitor.getStatus());

底层原理

SDK 的性能开销量化

设计监控 SDK 最容易被忽视的是”监控本身的性能开销”。一个好的 SDK 应该做到:

  1. CPU 开销:每次数据上报的序列化操作不能超过 1ms。通过预编译模板(如将 JSON.stringify 替换为手动拼接)优化。

  2. 内存开销:队列中的原始数据对象会被长期引用,导致 GC 无法回收。解决方案是使用对象池(Object Pool)重用数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 对象池模式 - 减少 GC 压力
class DataPool {
  constructor(maxSize = 100) {
    this.pool = [];
    this.maxSize = maxSize;
  }

  acquire() {
    return this.pool.pop() || {};
  }

  release(obj) {
    // 清空属性后归还
    for (const key of Object.keys(obj)) {
      delete obj[key];
    }
    if (this.pool.length < this.maxSize) {
      this.pool.push(obj);
    }
  }
}
  1. 网络开销:sendBeacon 在 Chrome 的实现中,paylaod 的上限是 64KB。超过此限制时,Beacon 请求会被静默丢弃——这就是为什么分片传输器是必要的。

压缩的数学原理

压缩的本质是减少冗余。在监控数据中,90% 的字段名(如 devicePixelRatio)可以缩写成 dpr。这种”字典编码”方式的压缩比理论上限为原 key 长度/缩写 key 长度:对于 devicePixelRatio(16字)→ dpr(3字),单字段压缩比 5.3:1。

在实际场景中,三条典型 LCP 上报数据经过压缩后:JSON.stringify 从 1280 字节减少到 243 字节,压缩比达 5.27:1。如果再配合 Gzip(HTTP 传输层),可以从 1280 → 243 → ~150 字节,总压缩比超过 8:1。

高频面试题解析

面试题 1:监控 SDK 的采集粒度如何控制?采集所有用户的所有事件会有什么问题?

答案要点: 全量采集的问题:1)网络带宽消耗——每个用户每天可能产生数万条事件,带宽开销巨大;2)服务端存储成本——海量数据需要昂贵的存储和计算资源;3)前端性能影响——每次事件采集都要执行序列化和内存分配。解决方案:按事件类型差异化采样率(关键错误 100%,性能 10-20%,行为 1-5%),配合服务端动态调整(Feature Flag 控制)。

面试题 2:如何确保监控 SDK 自己的错误不会影响主应用?

答案要点: 1)所有采集逻辑包裹在 try-catch 中;2)使用独立的错误处理通道(如 console.error 而非 throw);3)设置 CPU Time Budget——单个处理器执行超过 5ms 时强制终止;4)内存保护——队列上限(如 200 条)超出后丢弃而非 OOM;5)避免修改全局对象原型;6)使用 Object.definePropertyconfigurable: false 防止被篡改。

面试题 3:在低端 Android 设备上,大量采集性能数据反而导致页面卡顿,如何优化?

答案要点: 1)检测设备性能等级——通过 navigator.hardwareConcurrencydeviceMemory API 判断设备档次,低端设备降低采样率或完全禁用某些采集器;2)使用 requestIdleCallback 调度采集任务,让浏览器在空闲时处理;3)使用 PerformanceObserverbuffered: true 延迟处理,避免在帧开始时抢占主线程;4)避免在高频事件(如 scroll/resize)中使用 getEntriesByType('resource')——该操作需要遍历几百条记录。

面试题 4:如何处理监控 SDK 的版本兼容性(旧版本用户的数据如何兼容)?

答案要点: 1)数据 Schema 带版本号(payload 中的 v 字段);2)采用演进式 Schema 设计——只新增字段不删除旧字段;3)服务端建立版本兼容层——根据版本号使用不同解析器;4)字段默认值策略——新版 SDK 采集的新字段,旧版 SDK 服务端解析时使用 undefined/null;5)定期淘汰旧版本——对于 <1% 用户的极旧版本,服务端直接丢弃或降级处理。

面试题 5:如何测试监控 SDK 本身的可靠性(埋点不丢、数据正确)?

答案要点: 1)E2E 测试——使用 Puppeteer 模拟用户操作,验证 SDK 产生的事件数量和内容是否正确;2)Fuzz 测试——注入异常数据(undefined、null、超大数值),验证 SDK 不会报错且能正确处理;3)网络模拟——断网 -> 恢复的序列节点,验证队列持久化和重试机制;4)性能 Benchmark——量化 SDK 在不同设备上的 CPU/内存开销,设定阈值;5)数据一致性校验——发送端的 payload hash 与服务端接收的 hash 对比,验证无数据损坏。

总结与扩展

知识体系图

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
监控 SDK 设计
├── 架构分层
│   ├── 采集层(Collectors)
│   │   ├── 性能采集器(Web Vitals, Navigation Timing)
│   │   ├── 错误采集器(JS Error, Promise, Resource)
│   │   ├── 行为采集器(Click, Scroll, Route)
│   │   └── 自定义采集器(业务打点)
│   ├── 处理层(Processors)
│   │   ├── 过滤器(级别过滤、白名单)
│   │   ├── 采样器(随机采样、一致性采样)
│   │   ├── 聚合器(去重、计数合并)
│   │   └── 压缩器(字段缩写、精度控制)
│   └── 传输层(Transporters)
│       ├── 批量发送(合并 + 定时)
│       ├── 重试机制(指数退避)
│       ├── 分片传输(大 payload 拆分)
│       └── 保活策略(sendBeacon + keepalive)
├── 扩展机制
│   ├── 插件系统(生命周期钩子)
│   ├── 采集器注册(按需启用)
│   └── 自定义处理器(Pipeline)
├── 性能保障
│   ├── CPU Budget 控制
│   ├── 内存队列上限
│   ├── 低端设备降级
│   └── 对象池复用
└── 数据链路
    ├── 5:1 ~ 8:1 压缩比(缩写+精度+Gzip)
    ├── 采样率差异化配置
    └── 错误隔离(try-catch 保护主应用)

延伸阅读

  1. Sentry JavaScript SDK 源码: GitHub 搜索 getsentry/sentry-javascript — 业界标杆的实现
  2. Perfume.js: 轻量级性能监控库的源码,约 3KB
  3. web-vitals 库: Google 官方的 Web Vitals 采集库实现
  4. Apache SkyWalking 前端 SDK: 分布式追踪的开源方案
  5. w3c/resource-timing: PerformanceResourceTiming 规范详解
本文由作者按照 CC BY 4.0 进行授权

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

本站采用 Jekyll 主题 Chirpy

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