从零到一:打造企业级前端监控系统
本文将深入浅出地介绍如何搭建一个功能完整的前端监控系统,重点讲解核心原理和关键技术点。
监控系统架构概览:
┌─────────────────────────────────────────────────────────────┐
│ 前端监控系统架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 错误监控 ──┐ │
│ 性能监控 ──┼──> 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
这样设计的好处:
- 不同项目的数据完全隔离
- 不同环境(dev、staging、prod)的数据独立
- 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(浏览器本地) │
│ │
└─────────────────────────────────────────────────────────┘
三、核心功能详解
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/XMLHttpRequest | 500、404 等 |
| Console 错误 | 手动打印错误 | 重写 console.error | console.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...
分组效果:
| 错误指纹 | 错误消息 | 发生次数 | 首次时间 | 最后时间 |
|---|---|---|---|---|
| a1b2c3 | Cannot read 'xxx' | 156 次 | 2天前 | 5分钟前 |
| d4e5f6 | Network error | 89 次 | 1天前 | 1小时前 |
| g7h8i9 | Timeout error | 23 次 | 3小时前 | 10分钟前 |
优势:
- ✅ 避免存储爆炸(相同错误只存一份)
- ✅ 快速识别高频错误(按次数排序)
- ✅ 分析错误趋势(首次/最后时间)
3.2 Source Map 解析:定位源码位置
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
支持三种格式:
- 相对路径:
app.min.js.map - 绝对路径:
/static/js/app.min.js.map - 内联 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、esbuild | Source 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/Edge | at | at fn (file:line:col) | at onClick (app.js:10:5) |
| Firefox | @ | fn@file:line:col | onClick@app.js:10:5 |
| Safari | @ | fn@file:line:col | onClick@app.js:10:5 |
| IE11 | at | at 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 | 特殊格式 |
|---|---|---|---|
| Webpack | eval-source-map(快) | hidden-source-map | 支持 Code Splitting |
| Vite | inline(Base64) | 外部 .map | 文件名带 hash |
| Rollup | 外部 .map | 外部 .map | ES Module |
| esbuild | inline | 外部 .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
3.4.1 什么是 Core Web Vitals?
Google 定义的三个核心用户体验指标,直接影响 SEO 排名!
| 指标 | 全称 | 衡量内容 | 目标值 | 用户感知 |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | < 2.5s | "加载快不快" |
| FID | First Input Delay | 首次输入延迟 | < 100ms | "能不能点" |
| CLS | Cumulative 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 - domainLookupStart | DNS 解析耗时 | 使用 DNS 预解析 |
| TCP 时间 | connectEnd - connectStart | TCP 连接耗时 | 使用 HTTP/2 |
| TTFB | responseStart - requestStart | 服务器响应时间 | 优化后端性能 |
| 下载时间 | responseEnd - responseStart | 资源下载时间 | 压缩、CDN |
| DOM 解析 | domInteractive - responseEnd | DOM 解析耗时 | 减少 DOM 层级 |
页面加载时序图:
时间轴:0ms ──────────────────────────────────────────> 800ms
│
├─ DNS 查询 (10ms)
│
├─ TCP 连接 (50ms)
│
├─ 请求发送 (20ms)
│
├─ TTFB (100ms) ────┐
│ │
├─ 下载 (200ms) │ 关键性能指标
│ │
├─ DOM 解析 (300ms) │
│ │
└─ 加载完成 (800ms) ┘
3.5 会话录制:环形缓冲区的精妙设计
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,156(93.7%)
失败请求:78(6.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-5s | 0.5-1s | ⬆️ 80% |
| 内存占用(会话) | 200-500MB | 10-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 告警去重
问题: 同一个错误短时间内多次触发
解决方案:
- 错误指纹:相同指纹的错误归为一组
- 冷却时间:5 分钟内相同错误只告警一次
- 智能聚合:短时间内多个错误合并为一条告警
告警去重流程:
错误发生 → 生成指纹 → 检查冷却时间
↓ ↓
匹配规则 是否在冷却期内?
↓ ↓
是 是 → 跳过
↓ 否 → 发送
发送告警 记录告警时间
六、实战经验总结
6.1 架构设计经验
| 设计原则 | 具体实践 | 收益 |
|---|---|---|
| 模块化设计 | 每个功能独立模块 | 按需加载、易维护 |
| 渐进式加载 | 延迟初始化非核心模块 | 不影响首屏性能 |
| 环境隔离 | 多项目、多环境数据分离 | 避免数据混淆 |
| 缓存优先 | Source Map、配置等缓存 | 减少重复计算 |
| 异步处理 | 错误上报、数据保存异步 | 不阻塞主线程 |
6.2 性能优化经验
核心思路: 监控系统不能成为性能瓶颈
| 优化手段 | 适用场景 | 效果 |
|---|---|---|
| 延迟初始化 | 非核心模块 | 首屏性能提升 80% |
| 采样控制 | 高频事件(点击、滚动) | CPU 占用降低 70% |
| 批量保存 | localStorage 写入 | 写入次数减少 99% |
| 环形缓冲区 | 会话录制 | 内存占用降低 95% |
| 缓存机制 | Source Map、API 结果 | 响应速度提升 10 倍 |
6.3 数据存储经验
localStorage 的使用技巧:
-
存储容量有限(5-10MB)
- 定期清理旧数据
- 只保留最近 N 条记录
- 错误聚合避免重复存储
-
写入是同步操作
- 批量写入
- 降低写入频率
- 避免在关键路径写入
-
数据格式优化
- 使用 JSON 压缩
- 移除冗余字段
- 定期压缩整理
6.4 错误监控经验
最佳实践:
-
错误必须 100% 采样
- 不能遗漏任何错误
- 采样率控制只用于行为和会话
-
Source Map 只解析用户代码
- 跳过框架代码
- 只解析第一个用户代码帧
- 缓存解析结果
-
错误分组降低噪音
- 使用错误指纹
- 聚合相同错误
- 只保留最近详情
6.5 常见坑点
| 问题 | 原因 | 解决方案 |
|---|---|---|
| localStorage 满了 | 数据未清理 | 定期清理 + 限制数量 |
| 内存泄漏 | 事件监听未移除 | 及时 removeEventListener |
| 错误重复上报 | 未做去重 | 错误指纹 + 冷却时间 |
| 性能影响大 | 全量监控 | 采样控制 + 延迟初始化 |
| Source Map 404 | 路径不对 | 支持多种路径格式 |
总结
核心技术点回顾
- 错误监控:全方位捕获(JavaScript、Promise、Vue、API、Console)+ 错误指纹分组
- Source Map:压缩代码 → 原始源码,只解析用户代码,缓存优化
- 性能监控:Core Web Vitals(LCP、FCP、CLS)+ Navigation Timing
- 会话录制:环形缓冲区(始终录制 + 错误时保存)
- API 监控:拦截 fetch/XHR,只记录错误和慢请求
- 性能优化:延迟初始化 + 采样控制 + 批量保存 + 环境自适应
关键设计思想
1. 最小化原则
- 功能完整,但不过度
- 只记录必要数据
- 只解析关键信息
2. 性能优先
- 不阻塞主线程
- 不影响用户体验
- 不拖慢页面加载
3. 渐进增强
- 核心功能优先
- 非核心功能延迟
- 降级策略完善
参考资源
- Sentry 官方文档 - 错误监控最佳实践
- rrweb 文档 - 会话录制实现
- Web Vitals - 性能监控指标
- Source Map 规范 - Source Map 详细说明
作者: 薛定谔的猫
更新时间: 2025年12月
项目地址: monitor-system
💡 提示: 本文重点介绍原理和设计思路,具体实现代码请参考项目源码。如果你对某个功能感兴趣,欢迎查看源码深入了解。