打造企业级前端监控系统

54 阅读26分钟

从零到一:打造企业级前端监控系统

main.png

本文将深入浅出地介绍如何搭建一个功能完整的前端监控系统,重点讲解核心原理和关键技术点。

监控系统架构概览:

┌─────────────────────────────────────────────────────────────┐
│                    前端监控系统架构                           │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  错误监控 ──┐                                                 │
│  性能监控 ──┼──> SDK ──> LocalStorage ──> Dashboard         │
│  行为追踪 ──┤                                                 │
│  会话录制 ──┘                                                 │
│                                                               │
└─────────────────────────────────────────────────────────────┘

📋 目录


一、为什么需要监控系统

1.1 线上问题的挑战

在实际工作中,我们经常遇到这些场景:

场景 1:用户报错无法复现

  • 用户:"我点击按钮就报错了"
  • 开发:"我这边正常啊,能具体说说吗?"
  • 用户:"我也说不清楚..." 😓

场景 2:性能问题难以定位

  • 老板:"首页加载太慢了!"
  • 开发:"我本地很快啊..."
  • 老板:"用户说慢就是慢!" 😠

场景 3:错误信息不完整

Uncaught TypeError: Cannot read property 'xxx' of undefined
    at a (app.min.js:1:23456)

这行压缩后的代码,根本不知道对应原始代码的哪一行!

1.2 监控系统的核心价值

痛点监控系统如何解决价值
🐛 错误难复现错误监控 + 会话录制看到用户完整操作过程
⚡ 性能难定位性能监控 + 慢请求分析找到性能瓶颈
📊 用户行为不清楚行为追踪 + 点击热力图了解用户真实使用情况
🔍 压缩代码难调试Source Map 解析快速定位源码位置
⏰ 问题发现滞后实时告警第一时间发现问题

二、系统架构设计

2.1 整体架构图

┌─────────────────────────────────────────────────────────┐
│                      前端应用                            │
│  ┌──────────────────────────────────────────────────┐  │
│  │           @monitor-system/sdk                      │  │
│  │  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐         │  │
│  │  │ 错误 │  │ 性能 │  │ 行为 │  │ 会话 │         │  │
│  │  │ 监控 │  │ 监控 │  │ 追踪 │  │ 录制 │         │  │
│  │  └──────┘  └──────┘  └──────┘  └──────┘         │  │
│  └──────────────────────────────────────────────────┘  │
│                          ↓                              │
│                    LocalStorage                         │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│               @monitor-system/dashboard                 │
│  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐              │
│  │ 错误 │  │ 性能 │  │ 告警 │  │ 数据 │              │
│  │ 列表 │  │ 分析 │  │ 管理 │  │ 可视 │              │
│  └──────┘  └──────┘  └──────┘  └──────┘              │
└─────────────────────────────────────────────────────────┘

2.2 Monorepo 架构的优势

为什么选择 Monorepo?

传统多仓库Monorepo优势
代码分散,难以共享代码集中,统一管理便于复用 ✅
版本管理复杂版本统一升级避免版本冲突 ✅
调试困难本地联调方便提升开发效率 ✅

项目结构:

monitor-system/
├── packages/
│   ├── monitor-sdk/          # SDK 包(核心功能)
│   │   ├── src/
│   │   │   ├── errorMonitor.js        # 错误监控
│   │   │   ├── performanceMonitor.js  # 性能监控
│   │   │   ├── behaviorMonitor.js     # 行为追踪
│   │   │   ├── sessionRecorder.js     # 会话录制
│   │   │   ├── sourceMap.js           # Source Map 解析
│   │   │   └── apiMonitor.js          # API 监控
│   │   └── package.json
│   └── monitor-dashboard/    # 展示面板
│       ├── src/views/
│       │   ├── ErrorList.vue          # 错误列表
│       │   ├── ErrorDetail.vue        # 错误详情
│       │   └── Performance.vue        # 性能分析
│       └── package.json
├── pnpm-workspace.yaml       # pnpm 工作区配置
└── package.json

2.3 数据存储策略

为什么使用 localStorage?

优势:

  • 无需后端支持,开箱即用
  • 数据存储在用户本地,隐私安全
  • 支持多项目、多环境隔离

⚠️ 限制:

  • 存储空间有限(5-10MB)
  • 不支持多设备同步

存储 Key 设计:

{module}_logs_{projectId}_{environment}

示例:
- error_monitor_logs_my-app_production
- api_monitor_logs_my-app_development
- performance_logs_dashboard-app_production

这样设计的好处:

  1. 不同项目的数据完全隔离
  2. 不同环境(dev、staging、prod)的数据独立
  3. Dashboard 可以切换项目/环境查看对应数据

数据存储策略示意图:

┌─────────────────────────────────────────────────────────┐
│              多项目、多环境数据隔离                        │
├─────────────────────────────────────────────────────────┤
│                                                           │
│  项目A ──┐                                                │
│    ├─ dev      ──> error_monitor_logs_projectA_dev      │
│    └─ prod     ──> error_monitor_logs_projectA_prod     │
│                                                           │
│  项目B ──┐                                                │
│    ├─ dev      ──> error_monitor_logs_projectB_dev      │
│    └─ prod     ──> error_monitor_logs_projectB_prod     │
│                                                           │
│  存储位置:LocalStorage(浏览器本地)                      │
│                                                           │
└─────────────────────────────────────────────────────────┘

三、核心功能详解

image.png

3.1 错误监控:捕获一切异常

3.1.1 错误类型全览

前端错误种类繁多,需要全方位覆盖:

错误类型触发场景捕获方式示例
JavaScript 运行时错误代码执行出错window.addEventListener('error')undefined.xxx
Promise 未捕获错误Promise reject 未处理window.addEventListener('unhandledrejection')fetch().then() 无 catch
资源加载错误图片、脚本加载失败window.addEventListener('error', true)404 图片
Vue 组件错误Vue 组件内部错误app.config.errorHandler生命周期钩子报错
API 请求错误接口返回非 200拦截 fetch/XMLHttpRequest500、404 等
Console 错误手动打印错误重写 console.errorconsole.error('xxx')
3.1.2 错误捕获的核心挑战

挑战 1:如何区分 JavaScript 错误和资源加载错误?

两者都触发 error 事件,但有不同的特征:

window.addEventListener('error', (event) => {
  if (event.target !== window) {
    // 资源加载错误(event.target 是 <img>、<script> 等)
    console.log('资源加载失败:', event.target.src)
  } else {
    // JavaScript 运行时错误
    console.log('JavaScript 错误:', event.message)
  }
}, true)  // ⚠️ 注意:必须使用捕获阶段

为什么必须使用捕获阶段(true)?

  • 资源加载错误不会冒泡!
  • 只能在捕获阶段捕获到

挑战 2:Promise 错误为什么需要单独处理?

Promise 错误不会触发 window.error,必须单独监听:

window.addEventListener('unhandledrejection', (event) => {
  // event.reason 可能是 Error 对象,也可能是字符串
  const error = event.reason instanceof Error 
    ? event.reason 
    : new Error(String(event.reason))
  
  // 保存错误
  saveError(error)
})

常见场景:

// ❌ 这种写法会触发 unhandledrejection
fetch('/api/user').then(res => res.json())

// ✅ 正确写法:添加 catch
fetch('/api/user')
  .then(res => res.json())
  .catch(err => console.error(err))
3.1.3 错误分组与聚合

问题:同一个错误可能被触发成百上千次,如何处理?

解决方案:错误指纹(Fingerprint)

什么是错误指纹?

  • 基于错误特征生成的唯一标识
  • 相同指纹的错误归为一组
  • 记录发生次数、首次/最后时间

指纹生成规则:

错误类型 + 错误消息 + 文件名 + 行号

示例:
javascript|Cannot read property 'xxx' of undefined|app.js|123
→ MD5 → a1b2c3d4e5f6...

分组效果:

错误指纹错误消息发生次数首次时间最后时间
a1b2c3Cannot read 'xxx'156 次2天前5分钟前
d4e5f6Network error89 次1天前1小时前
g7h8i9Timeout error23 次3小时前10分钟前

优势:

  • ✅ 避免存储爆炸(相同错误只存一份)
  • ✅ 快速识别高频错误(按次数排序)
  • ✅ 分析错误趋势(首次/最后时间)

3.2 Source Map 解析:定位源码位置

image.png

3.2.1 为什么需要 Source Map?

线上问题:

生产环境代码被压缩、混淆,错误堆栈完全不可读:

压缩后的错误堆栈:
at a (app.min.js:1:23456)
at b (app.min.js:1:34567)
at c (app.min.js:1:45678)

❓ 这是哪个文件?哪个函数?哪一行?完全不知道!

使用 Source Map 后:

原始错误堆栈:
at handleClick (src/components/Button.vue:45:12)
at onClick (src/views/Home.vue:123:8)
at EventEmitter (src/utils/event.js:67:4)

✅ 清晰明了!直接定位到源码位置!

3.2.2 Source Map 工作原理

映射关系图:

压缩代码                     Source Map                    原始代码
┌──────────────┐            ┌──────────┐            ┌──────────────┐
│ app.min.js   │            │  映射表   │            │  Button.vue  │
│              │            │          │            │              │
│ 第 1 行      │   ────>    │ mappings │   ────>    │ 第 45 行     │
│ 第 23456 列  │            │          │            │ 第 12 列     │
└──────────────┘            └──────────┘            └──────────────┘

Source Map 文件结构:

{
  "version": 3,                          // Source Map 版本
  "sources": ["src/Button.vue"],         // 原始文件路径
  "names": ["handleClick", "onClick"],   // 原始变量名
  "mappings": "AAAA,SAAS,CAAC...",       // 位置映射(Base64VLQ 编码)
  "sourcesContent": ["原始源码内容..."]   // 原始源码(可选)
}

关键字段说明:

  • sources:原始文件列表
  • mappings:压缩位置 → 原始位置的映射表
  • sourcesContent:内联原始源码(避免网络请求)
3.2.3 实现流程

步骤 1:提取 Source Map URL

压缩后的 JS 文件末尾通常有注释:

// app.min.js 文件末尾
//# sourceMappingURL=app.min.js.map

支持三种格式:

  1. 相对路径:app.min.js.map
  2. 绝对路径:/static/js/app.min.js.map
  3. 内联 Base64:data:application/json;base64,eyJ2ZXJzaW9...

步骤 2:加载并解析 Source Map

使用 source-map-js 库(浏览器兼容版本):

import { SourceMapConsumer } from 'source-map-js'

// 1. 加载 Source Map 文件
const sourceMapData = await fetch(sourceMapUrl).then(r => r.json())

// 2. 创建消费者
const consumer = await new SourceMapConsumer(sourceMapData)

// 3. 查询原始位置
const original = consumer.originalPositionFor({
  line: 1,       // 压缩后的行号
  column: 23456  // 压缩后的列号
})

console.log(original)
// {
//   source: "src/Button.vue",
//   line: 45,
//   column: 12,
//   name: "handleClick"
// }

步骤 3:重建错误堆栈

将压缩后的堆栈替换为原始堆栈:

// 压缩前:
at a (app.min.js:1:23456)

// 重建后:
at handleClick (src/components/Button.vue:45:12)
3.2.4 关键优化点

优化 1:只解析第一个用户代码帧

错误堆栈通常有很多层,大部分是框架代码:

at handleClick (src/Button.vue:45:12)     ← 用户代码(需要解析)
at callWithErrorHandling (vue.js:123:4)   ← 框架代码(跳过)
at emit (vue.js:456:7)                    ← 框架代码(跳过)
at onClick (src/Home.vue:123:8)           ← 用户代码(可解析)

策略:

  • 只解析第一个用户代码帧(最重要)
  • 跳过框架代码(node_modules、Vue 内部函数)
  • 跳过浏览器内置函数(<anonymous>native

优化 2:Source Map 缓存

const sourceMapCache = new Map()

async function loadSourceMap(file) {
  // 先查缓存
  if (sourceMapCache.has(file)) {
    return sourceMapCache.get(file)
  }
  
  // 加载并缓存
  const sourceMap = await fetchAndParse(file)
  sourceMapCache.set(file, sourceMap)
  
  return sourceMap
}

为什么需要缓存?

  • 同一个文件可能多次查询
  • 避免重复网络请求
  • 提升解析速度 10 倍+

优化 3:异步解析,不阻塞主线程

// 先保存压缩后的错误
const errorInfo = {
  message: error.message,
  stack: error.stack,  // 压缩后的堆栈
  // ...
}
saveError(errorInfo)

// 异步解析 Source Map
parseSourceMap(error).then(originalStack => {
  // 更新为原始堆栈
  updateError(errorInfo.id, { originalStack })
})

优势:

  • ✅ 不阻塞错误保存
  • ✅ 不影响页面性能
  • ✅ 解析失败不影响错误记录

Source Map 解析流程:

┌─────────────────────────────────────────────────────────────┐
│                    Source Map 解析流程                        │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  1. 捕获错误堆栈                                              │
│     └─> at a (app.min.js:1:23456)                           │
│                                                               │
│  2. 提取 Source Map URL                                       │
│     └─> app.min.js.map                                        │
│                                                               │
│  3. 加载 Source Map(带缓存)                                 │
│     └─> 查询缓存 ──> 未命中 ──> 网络请求 ──> 缓存结果        │
│                                                               │
│  4. 解析原始位置                                              │
│     └─> at handleClick (src/Button.vue:45:12)              │
│                                                               │
│  5. 重建错误堆栈                                              │
│     └─> 替换压缩位置为原始位置                                │
│                                                               │
└─────────────────────────────────────────────────────────────┘

3.3 跨平台兼容性:处理不同环境的差异

3.3.1 为什么需要兼容性处理?

在实际生产环境中,用户使用的浏览器、构建工具、运行环境千差万别:

维度差异点影响
浏览器Chrome、Firefox、Safari、Edge错误堆栈格式不同
构建工具Webpack、Vite、Rollup、esbuildSource Map 生成规则不同
运行环境开发环境、生产环境Source Map 位置不同
打包模式内联、外部、隐藏Source Map 加载方式不同

如果不处理兼容性:

  • ❌ 某些浏览器的错误无法解析
  • ❌ 部分构建工具的 Source Map 加载失败
  • ❌ 错误定位不准确
3.3.2 浏览器错误堆栈格式差异

不同浏览器的错误堆栈格式完全不同!

Chrome / Edge(V8 引擎):

Error: Cannot read property 'xxx' of undefined
    at handleClick (http://localhost:3000/app.js:123:45)
    at onClick (http://localhost:3000/main.js:67:8)
    at HTMLButtonElement.<anonymous> (http://localhost:3000/app.js:200:5)

格式特征:

  • at 关键字开头
  • 函数名 + 文件路径 + 行列号
  • 支持匿名函数(<anonymous>

Firefox(SpiderMonkey 引擎):

Error: Cannot read property 'xxx' of undefined
    handleClick@http://localhost:3000/app.js:123:45
    onClick@http://localhost:3000/main.js:67:8
    EventListener.handleEvent*@http://localhost:3000/app.js:200:5

格式特征:

  • 没有 at 关键字
  • 使用 @ 分隔函数名和位置
  • 事件监听器有特殊标记(*

Safari(JavaScriptCore 引擎):

Error: Cannot read property 'xxx' of undefined
    handleClick@http://localhost:3000/app.js:123:45
    onClick@http://localhost:3000/main.js:67:8
    [native code]

格式特征:

  • 类似 Firefox,使用 @
  • 原生代码显示为 [native code]
  • 行列号格式可能不同

IE11(Chakra 引擎):

Error: Unable to get property 'xxx' of undefined or null reference
   at handleClick (http://localhost:3000/app.js:123:5)
   at Anonymous function (http://localhost:3000/app.js:200:3)

格式特征:

  • 错误消息措辞不同
  • 匿名函数显示为 Anonymous function
  • 只有行号,列号不准确

对比表格:

浏览器分隔符格式示例
Chrome/Edgeatat fn (file:line:col)at onClick (app.js:10:5)
Firefox@fn@file:line:colonClick@app.js:10:5
Safari@fn@file:line:colonClick@app.js:10:5
IE11atat fn (file:line:col)at onClick (app.js:10:5)
3.3.3 构建工具差异

不同构建工具生成的 Source Map 格式和位置也不同:

Webpack:

生成规则:

// webpack.config.js
module.exports = {
  devtool: 'hidden-source-map',  // 生产环境推荐
  // 其他选项:
  // - 'source-map': 外部 .map 文件,有注释
  // - 'hidden-source-map': 外部 .map 文件,无注释
  // - 'inline-source-map': 内联到 bundle,Base64 编码
  // - 'eval-source-map': eval 包裹,开发环境快速
}

Source Map 位置:

dist/
├── app.js                 # 压缩后的文件
├── app.js.map             # Source Map(外部)
└── vendor.js
    └── vendor.js.map

特点:

  • 支持多种 devtool 选项
  • 生产环境通常使用 hidden-source-map(无注释,更安全)
  • 支持 Code Splitting,每个 chunk 都有对应的 .map

Vite:

生成规则:

// vite.config.js
export default {
  build: {
    sourcemap: true,  // 或 'inline' / 'hidden'
    // true: 生成外部 .map 文件
    // 'inline': 内联到文件末尾(data:application/json;base64,...)
    // 'hidden': 生成 .map 但不添加注释
  }
}

开发环境特点:

// Vite 开发环境的文件路径
http://localhost:3000/src/components/Button.vue
http://localhost:3000/@vite/deps/vue.js
http://localhost:3000/node_modules/.vite/deps/chunk-XXX.js
  • 源文件直接访问(/src/
  • 依赖预构建(/@vite/deps/.vite/deps/
  • 内联 Source Map(Base64)

生产环境:

dist/
├── assets/
│   ├── index-abc123.js
│   ├── index-abc123.js.map
│   ├── vendor-def456.js
│   └── vendor-def456.js.map

特点:

  • 文件名带 hash
  • 开发环境使用内联 Source Map(快速)
  • 生产环境生成外部 .map

Rollup:

生成规则:

// rollup.config.js
export default {
  output: {
    sourcemap: true,        // 外部 .map
    // sourcemap: 'inline', // 内联
    // sourcemap: 'hidden', // 无注释
  }
}

特点:

  • 配置简单
  • Tree Shaking 导致源码位置可能偏移
  • ES Module 格式的 Source Map

对比表格:

构建工具开发环境 Source Map生产环境 Source Map特殊格式
Webpackeval-source-map(快)hidden-source-map支持 Code Splitting
Viteinline(Base64)外部 .map文件名带 hash
Rollup外部 .map外部 .mapES Module
esbuildinline外部 .map超快构建
3.3.4 Source Map 的多种形式

形式 1:外部文件 + 注释

// app.min.js 文件末尾
//# sourceMappingURL=app.min.js.map

加载方式:

// 1. 提取注释中的 URL
const sourceMapUrl = 'app.min.js.map'

// 2. 拼接完整路径
const fullUrl = new URL(sourceMapUrl, fileUrl).href

// 3. 加载 Source Map
const sourceMap = await fetch(fullUrl).then(r => r.json())

形式 2:外部文件(无注释,hidden)

// app.min.js 文件末尾(没有任何注释)
// 生产环境推荐,避免暴露 Source Map

加载方式:

// 需要自己拼接 .map 路径
const sourceMapUrl = compressedFile + '.map'

// 或者尝试多种常见后缀
const possibleUrls = [
  compressedFile + '.map',
  compressedFile.replace('.js', '.js.map'),
  compressedFile.replace('.js', '.ts.map'),  // TypeScript
  compressedFile.replace('.js', '.jsx.map'), // React
]

形式 3:内联 Base64

// app.min.js 文件末尾
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6...

解析方式:

function parseInlineSourceMap(dataUrl) {
  // 1. 提取 Base64 部分
  const base64 = dataUrl.split(',')[1]
  
  // 2. Base64 解码
  const jsonString = atob(base64)
  
  // 3. 解析 JSON
  return JSON.parse(jsonString)
}

形式 4:相对路径 Source Map

// app.min.js 文件末尾
//# sourceMappingURL=../maps/app.min.js.map
//# sourceMappingURL=./app.min.js.map
//# sourceMappingURL=/static/maps/app.min.js.map

路径解析:

function resolveSourceMapUrl(sourceMapUrl, baseUrl) {
  // 1. 绝对路径(以 / 开头)
  if (sourceMapUrl.startsWith('/')) {
    return window.location.origin + sourceMapUrl
  }
  
  // 2. 完整 URL
  if (sourceMapUrl.startsWith('http')) {
    return sourceMapUrl
  }
  
  // 3. 相对路径
  const base = new URL(baseUrl)
  const basePath = base.pathname.substring(0, base.pathname.lastIndexOf('/') + 1)
  return window.location.origin + basePath + sourceMapUrl
}
3.3.5 实战解决方案

方案 1:统一的堆栈解析器

创建一个支持所有浏览器的堆栈解析器:

// 支持 15+ 种堆栈格式的正则表达式数组
const STACK_PATTERNS = [
  // Chrome/Edge: at functionName (file:line:col)
  /at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/,
  
  // Chrome/Edge: at file:line:col (无函数名)
  /at\s+(.+?):(\d+):(\d+)/,
  
  // Firefox/Safari: functionName@file:line:col
  /(.+?)@(.+?):(\d+):(\d+)/,
  
  // Firefox: file:line:col (无函数名)
  /^(.+?):(\d+):(\d+)$/,
  
  // IE11: at functionName (file:line:col)
  /at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/,
  
  // 异步堆栈: async functionName (file:line:col)
  /async\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/,
  
  // eval: eval at functionName (file:line:col)
  /eval\s+at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/,
  
  // ... 更多格式
]

function parseStackLine(line) {
  for (const pattern of STACK_PATTERNS) {
    const match = line.match(pattern)
    if (match) {
      return {
        functionName: match[1],
        file: match[2],
        line: match[3],
        column: match[4]
      }
    }
  }
  return null
}

方案 2:智能 Source Map 加载器

支持多种 Source Map 形式:

async function loadSourceMap(compressedFile) {
  // 策略 1: 尝试从文件注释中提取
  const sourceMapUrl = await extractSourceMapUrl(compressedFile)
  if (sourceMapUrl) {
    if (sourceMapUrl.startsWith('data:')) {
      // 内联 Base64
      return parseInlineSourceMap(sourceMapUrl)
    } else {
      // 外部文件
      const fullUrl = resolveSourceMapUrl(sourceMapUrl, compressedFile)
      return await fetch(fullUrl).then(r => r.json())
    }
  }
  
  // 策略 2: 尝试常见的 .map 文件路径
  const possiblePaths = [
    compressedFile + '.map',
    compressedFile.replace('.js', '.js.map'),
    compressedFile.replace('.js', '.ts.map'),
    compressedFile.replace('.js', '.jsx.map'),
    compressedFile.replace('.js', '.vue.map'),
  ]
  
  for (const path of possiblePaths) {
    try {
      const sourceMap = await fetch(path).then(r => r.json())
      if (isValidSourceMap(sourceMap)) {
        return sourceMap
      }
    } catch (e) {
      continue  // 尝试下一个
    }
  }
  
  // 策略 3: 如果都失败了,返回 null
  return null
}

方案 3:构建工具自适应

根据不同构建工具调整策略:

function detectBuildTool(file) {
  // Vite: 文件名带 hash,路径包含 /assets/
  if (file.includes('/assets/') && /[a-f0-9]{8}\.(js|css)/.test(file)) {
    return 'vite'
  }
  
  // Webpack: chunk 文件命名规则
  if (/\d+\.[a-f0-9]+\.chunk\.js/.test(file)) {
    return 'webpack'
  }
  
  // Vite 开发环境: /@vite/deps/ 或 /.vite/deps/
  if (file.includes('@vite/deps') || file.includes('.vite/deps')) {
    return 'vite-dev'
  }
  
  return 'unknown'
}

async function loadSourceMapByTool(file) {
  const tool = detectBuildTool(file)
  
  switch (tool) {
    case 'vite':
      // Vite: 文件名带 hash,直接加 .map
      return await fetch(file + '.map').then(r => r.json())
      
    case 'vite-dev':
      // Vite 开发环境: 通常是内联 Source Map
      return await extractInlineSourceMap(file)
      
    case 'webpack':
      // Webpack: 尝试多种路径
      return await tryCommonSourceMapPaths(file)
      
    default:
      return await loadSourceMap(file)
  }
}

方案 4:降级策略

如果 Source Map 解析失败,提供降级方案:

async function parseErrorStack(error) {
  try {
    // 尝试解析 Source Map
    const originalPosition = await getOriginalPosition(...)
    
    if (originalPosition) {
      return {
        ...error,
        originalStack: buildOriginalStack(originalPosition),
        hasSourceMap: true
      }
    }
  } catch (e) {
    console.warn('Source Map 解析失败,使用原始堆栈')
  }
  
  // 降级:返回压缩后的堆栈(总比没有好)
  return {
    ...error,
    originalStack: error.stack,
    hasSourceMap: false,
    sourceMapError: 'Source Map 加载失败'
  }
}
3.3.6 兼容性检查清单

开发时检查:

  • 测试 Chrome、Firefox、Safari、Edge
  • 测试开发环境和生产环境
  • 验证 Source Map 是否正确加载
  • 检查错误堆栈是否正确解析

构建时检查:

  • 确认 Source Map 文件生成
  • 检查 sourceMappingURL 注释
  • 验证 Source Map 路径正确
  • 测试不同构建模式(dev、prod、staging)

运行时检查:

  • 监控 Source Map 加载失败率
  • 记录不同浏览器的错误格式
  • 统计解析成功率
  • 提供降级方案

3.4 性能监控:Core Web Vitals

image.png

image.png

3.4.1 什么是 Core Web Vitals?

Google 定义的三个核心用户体验指标,直接影响 SEO 排名!

指标全称衡量内容目标值用户感知
LCPLargest Contentful Paint最大内容绘制时间< 2.5s"加载快不快"
FIDFirst Input Delay首次输入延迟< 100ms"能不能点"
CLSCumulative Layout Shift累积布局偏移< 0.1"会不会跳"
3.4.2 LCP:最大内容绘制

什么是 LCP?

页面中最大元素的渲染时间。

典型的 LCP 元素:

  • 大图片(轮播图、Banner)
  • 视频封面
  • 大块文本
  • 占屏幕大部分的元素

监控实现:

使用 PerformanceObserver API:

new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const lastEntry = entries[entries.length - 1]  // 取最后一个
  
  const lcpValue = lastEntry.renderTime || lastEntry.loadTime
  console.log('LCP:', lcpValue, 'ms')
}).observe({ entryTypes: ['largest-contentful-paint'] })

为什么取最后一个?

  • LCP 元素可能在加载过程中变化
  • 最开始是标题文字(小元素)
  • 后来是大图片(大元素)→ 这才是真正的 LCP

优化建议:

  • ✅ 预加载关键资源(<link rel="preload">
  • ✅ 使用 CDN 加速图片
  • ✅ 压缩图片大小
  • ✅ 服务端渲染(SSR)
3.4.3 CLS:累积布局偏移

什么是 CLS?

页面元素意外移动的累积分数。

典型场景:

糟糕体验:

用户正在阅读文章...
突然广告加载完成!
整个页面向下跳动!
用户误点广告!😡

良好体验:

广告位提前预留空间
广告加载时不影响布局
用户体验稳定 😊

监控实现:

let clsValue = 0

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 排除用户交互导致的布局偏移
    if (!entry.hadRecentInput) {
      clsValue += entry.value
    }
  }
}).observe({ entryTypes: ['layout-shift'] })

为什么排除用户交互?

  • 用户点击、输入导致的布局变化是正常的
  • 只统计非预期的布局偏移

优化建议:

  • ✅ 图片/视频设置宽高
  • ✅ 广告位预留空间
  • ✅ 避免在内容上方插入元素
  • ✅ 使用 transform 动画(不触发布局)
3.4.4 页面加载性能分析

Navigation Timing API 提供完整的加载时序:

DNS 查询  TCP 连接  请求  TTFB  下载  DOM 解析  加载完成
  |         |         |       |       |         |           |
  10ms      50ms      20ms   100ms   200ms     300ms      800ms

关键指标计算:

指标计算方式含义优化方向
DNS 时间domainLookupEnd - domainLookupStartDNS 解析耗时使用 DNS 预解析
TCP 时间connectEnd - connectStartTCP 连接耗时使用 HTTP/2
TTFBresponseStart - requestStart服务器响应时间优化后端性能
下载时间responseEnd - responseStart资源下载时间压缩、CDN
DOM 解析domInteractive - responseEndDOM 解析耗时减少 DOM 层级

页面加载时序图:

时间轴:0ms ──────────────────────────────────────────> 800ms
        │
        ├─ DNS 查询 (10ms)
        │
        ├─ TCP 连接 (50ms)
        │
        ├─ 请求发送 (20ms)
        │
        ├─ TTFB (100ms) ────┐
        │                   │
        ├─ 下载 (200ms)     │ 关键性能指标
        │                   │
        ├─ DOM 解析 (300ms) │
        │                   │
        └─ 加载完成 (800ms) ┘

3.5 会话录制:环形缓冲区的精妙设计

image.png

image.png

3.5.1 需求分析

用户场景:

用户报错:"我刚才点了几下,然后就报错了..."

开发:"能告诉我具体怎么操作的吗?"

用户:"我也记不清了..." 😓

需求:

  • 能看到用户完整的操作过程
  • 能复现错误发生的场景
  • 重点:能看到错误发生前的操作
3.5.2 技术挑战

传统方案的问题:

方案 1:全程录制

开始录制 → 持续录制 → 用户关闭页面时保存

❌ 问题:

  • 内存占用巨大(200-500MB)
  • 录制时间长,数据量爆炸
  • 用户正常使用也在录制,浪费资源

方案 2:错误时录制

发生错误 → 开始录制 → 保存 60 秒

❌ 问题:

  • 只能看到错误发生后的操作
  • 看不到错误发生前做了什么
  • 无法分析错误原因
3.5.3 环形缓冲区方案

设计思路(参考 Sentry):

始终录制 → 只保留最近 60 秒 → 发生错误时才保存

工作流程:

正常运行:
┌─────────────────────────────────────────┐
│  环形缓冲区(60 秒)                      │
│  [最旧] → → → → → → → → → → → [最新]    │
│   ↑                              ↓       │
│   └───────── 超过 60 秒删除 ──────┘       │
└─────────────────────────────────────────┘
            不写入 localStorage
            (只在内存中)

发生错误:
┌─────────────────────────────────────────┐
│  保存最近 60 秒的数据                     │
│  [错误前 60s][错误发生] ✓             │
└─────────────────────────────────────────┘
         写入 localStorage
         (持久化存储)

核心实现思路:

class CircularBuffer {
  constructor() {
    this.events = []
    this.maxDuration = 60000  // 60 秒
  }

  add(event) {
    // 1. 添加新事件
    this.events.push(event)
    
    // 2. 删除超过 60 秒的旧事件
    const cutoff = Date.now() - this.maxDuration
    this.events = this.events.filter(e => e.timestamp >= cutoff)
  }

  getAll() {
    return [...this.events]  // 返回最近 60 秒的数据
  }
}

优势对比:

方案内存占用能否看到错误前操作localStorage 写入性能影响
全程录制200-500MB频繁严重
错误时录制10MB仅错误时中等
环形缓冲区10-20MB仅错误时轻微
3.5.4 rrweb 集成

rrweb 是什么?

  • 开源的 Web 会话录制库
  • 记录 DOM 快照 + 增量变化
  • 支持回放

集成示例:

import { record } from 'rrweb'

let events = []

// 开始录制
const stopRecording = record({
  emit(event) {
    // 添加到缓冲区
    events.push(event)
    
    // 只保留最近 60 秒
    const cutoff = Date.now() - 60000
    events = events.filter(e => e.timestamp >= cutoff)
  }
})

// 错误时保存
window.addEventListener('error', () => {
  // 保存最近 60 秒的录制数据
  localStorage.setItem('session', JSON.stringify(events))
})

采样优化:

录制也有性能开销,可以降低采样率:

record({
  emit(event) { /* ... */ },
  sampling: {
    scroll: 150,      // 滚动事件每 150ms 采样一次
    mousemove: 50,    // 鼠标移动每 50ms 采样一次
    input: 'last'     // 输入事件只记录最后一次
  }
})

3.6 API 监控:接口异常追踪

3.6.1 为什么需要 API 监控?

常见问题:

  • 接口返回 500,但用户看不到提示
  • 某个接口特别慢,拖累整个页面
  • 接口偶尔超时,难以复现

API 监控的价值:

  • ✅ 及时发现接口异常
  • ✅ 分析接口性能瓶颈
  • ✅ 统计接口成功率
3.6.2 拦截方案

需要拦截的 API:

  • fetch(现代浏览器)
  • XMLHttpRequest(老版本浏览器)

拦截原理:

保存原始 API → 替换为自定义函数 → 记录数据 → 调用原始 API

// 保存原始 fetch
const originalFetch = window.fetch

// 替换为自定义 fetch
window.fetch = async function(...args) {
  const startTime = Date.now()
  
  try {
    // 调用原始 fetch
    const response = await originalFetch.apply(this, args)
    const duration = Date.now() - startTime
    
    // 记录 API 数据
    if (!response.ok || duration > 3000) {  // 错误或慢请求
      recordAPI({
        url: args[0],
        status: response.status,
        duration: duration,
        success: response.ok
      })
    }
    
    return response
  } catch (error) {
    // 记录网络错误
    recordAPI({
      url: args[0],
      success: false,
      error: error.message
    })
    throw error
  }
}

关键点:

  • 只记录错误请求和慢请求(节省存储)
  • 不读取响应体(避免阻塞)
  • 异步记录数据(不影响业务逻辑)
3.6.3 监控指标
指标说明用途
成功率成功请求 / 总请求衡量接口稳定性
平均耗时总耗时 / 请求数衡量接口性能
慢请求数耗时 > 3s 的请求找到性能瓶颈
错误分布各状态码的占比分析错误类型

示例统计:

API 监控统计(最近 24 小时):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
总请求数:1,234
成功请求:1,15693.7%)
失败请求:786.3%)

错误分布:
- 500 服务器错误:45 次(57.7%)
- 404 资源不存在:23 次(29.5%)
- 网络错误:10 次(12.8%)

慢请求(>3s):156 次
平均耗时:856ms
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

四、性能优化策略

4.1 监控系统的性能挑战

监控系统本身也会消耗资源,必须做到:

  • ✅ 不阻塞页面渲染
  • ✅ 不影响用户交互
  • ✅ 不占用过多内存
  • ✅ 不拖慢页面加载

4.2 核心优化策略

4.2.1 延迟初始化

原理: 分批加载模块,避免首屏阻塞

页面加载:
┌────────────────────────────────────────┐
│ 0ms   → 初始化错误监控(最重要)        │
│ 1000ms → 初始化性能监控                 │
│ 2000ms → 初始化行为追踪                 │
│ 3000ms → 初始化会话录制                 │
└────────────────────────────────────────┘

为什么这样设计?

  • 错误监控最重要,必须立即启动
  • 其他功能延迟启动,不影响首屏
  • 用户感知不到延迟
4.2.2 采样控制

不同功能的采样率:

功能采样率理由
错误监控100%不能遗漏任何错误
API 监控100%(仅错误)只记录失败请求
性能监控30%统计样本足够
行为追踪30%分析趋势足够
会话录制10%降低性能影响

采样实现:

function shouldRecord(sampleRate) {
  return Math.random() < sampleRate
}

document.addEventListener('click', (event) => {
  if (!shouldRecord(0.3)) return  // 30% 采样
  
  recordBehavior(event)
})
4.2.3 批量保存

问题: localStorage 写入是同步操作,频繁写入会阻塞主线程

解决方案: 批量写入

单次写入:每次点击都写入 → 阻塞主线程 → 卡顿
批量写入:积累 50 条再写入 → 减少写入次数 → 流畅

批量保存器:

class BatchSaver {
  constructor() {
    this.batch = []
    this.batchSize = 50
  }

  add(item) {
    this.batch.push(item)
    
    if (this.batch.length >= this.batchSize) {
      this.flush()  // 达到阈值,立即保存
    }
  }

  flush() {
    localStorage.setItem('data', JSON.stringify(this.batch))
    this.batch = []
  }
}
4.2.4 环境自适应

开发环境 vs 生产环境:

配置项开发环境生产环境理由
会话录制关闭10% 采样开发时不需要
行为追踪10% 采样30% 采样开发时减少干扰
Source Map开启开启都需要定位源码
告警关闭开启避免开发时频繁告警

4.3 性能对比

优化效果:

指标优化前优化后提升
首次加载时间3-5s0.5-1s⬆️ 80%
内存占用(会话)200-500MB10-20MB⬇️ 95%
CPU 占用15-25%3-8%⬇️ 70%
localStorage 写入频率每秒数十次每分钟 1-2 次⬇️ 99%

五、告警系统设计

5.1 告警渠道

支持多种告警方式:

渠道适用场景特点
企业微信团队协作支持 @提醒、卡片消息
钉钉企业办公支持 Markdown、@提醒
邮件正式通知HTML 格式、可抄送
Webhook自定义集成灵活性高

5.2 告警规则

简单规则:

{
  type: 'error_type',
  errorType: 'javascript',  // 只告警 JavaScript 错误
  channels: ['wechat']
}

复杂规则(多条件):

{
  logic: 'AND',  // 所有条件都满足才告警
  conditions: [
    { field: 'severity', operator: 'equals', value: 'critical' },
    { field: 'environment', operator: 'equals', value: 'production' },
    { field: 'count', operator: 'gt', value: 10 }  // 发生次数 > 10
  ],
  channels: ['wechat', 'email'],
  cooldown: 300000  // 冷却时间 5 分钟
}

规则设计考虑:

  • 防止告警风暴(冷却时间)
  • 分级告警(critical、warning、info)
  • 环境隔离(dev、staging、prod)
  • 时间段限制(只在工作时间告警)

5.3 告警去重

问题: 同一个错误短时间内多次触发

解决方案:

  1. 错误指纹:相同指纹的错误归为一组
  2. 冷却时间:5 分钟内相同错误只告警一次
  3. 智能聚合:短时间内多个错误合并为一条告警

告警去重流程:

错误发生 → 生成指纹 → 检查冷却时间
    ↓                         ↓
匹配规则            是否在冷却期内?
    ↓                         ↓
   是                    是 → 跳过
    ↓                    否 → 发送
发送告警            记录告警时间

六、实战经验总结

6.1 架构设计经验

设计原则具体实践收益
模块化设计每个功能独立模块按需加载、易维护
渐进式加载延迟初始化非核心模块不影响首屏性能
环境隔离多项目、多环境数据分离避免数据混淆
缓存优先Source Map、配置等缓存减少重复计算
异步处理错误上报、数据保存异步不阻塞主线程

6.2 性能优化经验

核心思路: 监控系统不能成为性能瓶颈

优化手段适用场景效果
延迟初始化非核心模块首屏性能提升 80%
采样控制高频事件(点击、滚动)CPU 占用降低 70%
批量保存localStorage 写入写入次数减少 99%
环形缓冲区会话录制内存占用降低 95%
缓存机制Source Map、API 结果响应速度提升 10 倍

6.3 数据存储经验

localStorage 的使用技巧:

  1. 存储容量有限(5-10MB)

    • 定期清理旧数据
    • 只保留最近 N 条记录
    • 错误聚合避免重复存储
  2. 写入是同步操作

    • 批量写入
    • 降低写入频率
    • 避免在关键路径写入
  3. 数据格式优化

    • 使用 JSON 压缩
    • 移除冗余字段
    • 定期压缩整理

6.4 错误监控经验

最佳实践:

  1. 错误必须 100% 采样

    • 不能遗漏任何错误
    • 采样率控制只用于行为和会话
  2. Source Map 只解析用户代码

    • 跳过框架代码
    • 只解析第一个用户代码帧
    • 缓存解析结果
  3. 错误分组降低噪音

    • 使用错误指纹
    • 聚合相同错误
    • 只保留最近详情

6.5 常见坑点

问题原因解决方案
localStorage 满了数据未清理定期清理 + 限制数量
内存泄漏事件监听未移除及时 removeEventListener
错误重复上报未做去重错误指纹 + 冷却时间
性能影响大全量监控采样控制 + 延迟初始化
Source Map 404路径不对支持多种路径格式

总结

核心技术点回顾

  1. 错误监控:全方位捕获(JavaScript、Promise、Vue、API、Console)+ 错误指纹分组
  2. Source Map:压缩代码 → 原始源码,只解析用户代码,缓存优化
  3. 性能监控:Core Web Vitals(LCP、FCP、CLS)+ Navigation Timing
  4. 会话录制:环形缓冲区(始终录制 + 错误时保存)
  5. API 监控:拦截 fetch/XHR,只记录错误和慢请求
  6. 性能优化:延迟初始化 + 采样控制 + 批量保存 + 环境自适应

关键设计思想

1. 最小化原则

  • 功能完整,但不过度
  • 只记录必要数据
  • 只解析关键信息

2. 性能优先

  • 不阻塞主线程
  • 不影响用户体验
  • 不拖慢页面加载

3. 渐进增强

  • 核心功能优先
  • 非核心功能延迟
  • 降级策略完善

参考资源


作者: 薛定谔的猫
更新时间: 2025年12月
项目地址: monitor-system

💡 提示: 本文重点介绍原理和设计思路,具体实现代码请参考项目源码。如果你对某个功能感兴趣,欢迎查看源码深入了解。