ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端异常监控体系搭建:捕获、格式化到上报的完整方案

前端异常监控体系搭建:捕获、格式化到上报的完整方案 前两天线上出了个问题用户点某个按钮页面直接白屏群里反馈了好几条消息我在本地试了半天也没复现。最后查日志才发现错误在 low-end 机型上偶发而代码里唯一留下的线索就是一行console.log(error)。问题是这行日志打在用户的浏览器里我根本看不见。那之后我就下决心重做了一遍前端异常捕获和上报链路捕获所有能捕获的错误、统一格式化、服务端接收落库、按错误指纹聚合告警。整套流程跑通之后线上问题从“用户说了才知道”变成了“错误上报先到用户反馈后到”。这篇文章就把这套方案的完整思路和实操步骤拆开讲清楚面向的是前端开发、全栈工程师以及团队里打算搭监控体系的同学。内容基于我自己的落地经验有些细节是常规文档里不会写的建议边看边比对你现在的项目。1. 为什么需要一套统一的前端异常捕获方案先说一个现状绝大多数前端项目不是没有错误处理而是处理得太散。try/catch包一层、console.log(error)打一行、框架的errorHandler里alert一下各管各的。结果就是错误信息散落在不同地方格式五花八门线上出了事没有一条能直接定位根因的完整链路。我见过最典型的场景是这样的生产环境开了压缩和混淆console.log(error)打出来的 stack 是一长串压缩后的单字母变量名根本看不出是哪个模块抛的错。更麻烦的是有些异步错误根本没被try/catch包住连日志都没打直接静默失败。用户以为操作失败了其实后端收到请求了只是前端渲染环节崩了这种错误最坑人。所以我认为一套靠谱的前端异常捕获方案至少要满足三个条件捕获范围完整、格式统一、数据能上报落库。捕获范围完整是指普通的运行时错误、未处理的 Promise 异常、框架组件层级错误、资源加载失败都要能接住格式统一是指不管错误来源是哪条链路最终上报的数据结构都是一套字段名一致、类型一致、含义一致数据能上报落库是让错误日志从浏览器走到服务端再进入日志系统或监控平台这样才谈得上统计、告警和事后复盘。这套方案做下来之后收益非常直接错误可以被归类了可以知道哪个接口出错最频繁哪个页面崩溃率最高哪个版本上线后错误量突增。配合 Source Map报错信息甚至能精确到源码的某一行。这些能力靠console.log(error)是永远拿不到的。1.1 console.log 到底哪里不够用console.log(error)不是完全没用它适合开发调试但不适合线上问题排查原因有三个。第一本地打得出来线上看不见。用户浏览器里的日志不会自己跑到你的电脑上除非用户愿意打开控制台把内容复制给你而绝大多数用户不会这么做。线上问题靠用户口头描述“页面点了没反应”你猜不出是哪一行代码抛的错。第二信息维度太单薄。console.log(error)通常只打了错误对象本身但定位一个问题往往需要更多上下文用户当时在哪个页面、做了什么操作、浏览器版本是多少、接口返回了什么数据。这些信息在 console 里完全没有而在异常上报体系里是可以自动附带上的。第三不可聚合、不可对比。两个用户报了同一个错误你通过 console 日志只能看到两条孤立的记录看不出这个错误的整体影响面。错误上报之后可以做聚合统计同一个错误指纹在 24 小时内出现了多少次、分布在哪些页面、哪些版本。有了这些数据才能排优先级才敢说“这个 bug 影响面大必须立刻修”。1.2 统一格式化解决的核心问题统一格式化听起来像是一个不起眼的工程细节实际上它是整套方案里承上启下的关键节点。捕获是入口上报是出口格式化是中间的转换层。这一层做得不好上面的捕获接了再多错误下面的服务端也收得乱七八糟。我用一个真实例子说明格式不统一的痛点。团队里同时有 Vue 和 React 两个项目Vue 的errorHandler拿到的错误对象结构是一套React 的ErrorBoundary拿到的又是另一套window.onerror回调的参数是message, source, lineno, colno, error五个平铺参数而unhandledrejection回调里拿到的是一个包含reason属性的对象。如果不做统一格式化服务端接到的数据就有五六种结构写统计脚本的时候光是兼容这些格式就能劝退你。统一格式化把问题收敛成“一套字段标准 一个格式化函数 一份序列化逻辑”。不管错误来自哪条链路格式化函数最终输出的是同一个 JSON 结构例如{ eventType, errorType, message, stack, filename, lineno, colno, timestamp, url, userAgent, ... }。这样下游消费数据的人——无论是写监控告警的、做数据可视化的、还是写自动归因脚本的——都只需要对接一套结构。我自己的体会是格式化层的代码量不大收益却非常持久。它就像团队内部的接口协议定好之后大家按协议走新增一个捕获源只需要适配到协议不需要再改服务端。2. 异常捕获的四条主链路一条都不能少前端运行环境的错误来源比大多数人想像的要多。我把日常项目里必须接住的错误来源归纳成四条主链路普通运行时错误、未处理的 Promise 异常、框架组件生命周期内的错误、资源加载失败。这四条链路在 js 引擎层面的触发机制不同捕获方式也各不相同如果只接了其中一条线上仍然会有漏网之鱼。2.1 window.addEventListener(error) 捕获普通运行时错误全局监听error事件是捕获普通运行时错误最基本的手段。需要明确一点window.onerror和window.addEventListener(error, handler)的细节是有差异的前者是 DOM 0 级事件处理后者是 DOM 2 级事件监听我建议用addEventListener它不会覆盖页面上其他逻辑对 error 事件的处理可以共存多个监听器。先看基础代码// 注意第三个参数传 true使用捕获阶段 window.addEventListener(error, function (event) { // 先判断是不是资源加载错误 const target event.target; const isResourceError target (target.tagName SCRIPT || target.tagName LINK || target.tagName IMG); if (isResourceError) { // 资源加载错误单独走一条逻辑下面会讲 handleResourceError(event); return; } const error event.error || {}; const stack error.stack || ; // 统一格式化后上报 reportError(createErrorInfo({ eventType: error, errorType: error.name || Error, message: error.message || String(error), stack: stack, filename: event.filename, lineno: event.lineno, colno: event.colno, })); }, true);这里有几个细节值得特别说。第一必须传true使用捕获阶段。如果不传默认是冒泡阶段某些错误可能不会冒泡到 window。资源加载错误其实不会冒泡只有在捕获阶段才能被全局监听到。第二回调函数里event.error不一定存在。老版本浏览器或者某些跨域场景下错误事件对象上拿不到错误对象只能拿到message。所以代码里要做兜底用error.stack || 避免访问不存在属性时报二次错误。第三event.filename、event.lineno、event.colno这三个字段只在脚本运行时错误才有意义资源加载错误里它们通常是空值或 0所以要单独判断。2.2 unhandledrejection 捕获未处理的 Promise 异常Promise 异步异常是前端错误里最容易漏掉的一类。ES8 的async/await普及之后很多人默认有了async/await就不需要try/catch了结果就是 Promise 链里抛出的异常如果没人接会触发unhandledrejection事件而window.onerror是捕获不到这种异常的。处理unhandledrejection的标准姿势window.addEventListener(unhandledrejection, function (event) { const reason event.reason || {}; const error reason instanceof Error ? reason : new Error(String(reason)); const stack error.stack || ; reportError(createErrorInfo({ eventType: unhandledrejection, errorType: error.name || PromiseRejectionError, message: error.message || String(reason), stack: stack, // unhandledrejection 事件没有 filename/lineno需要从 stack 里尽可能解析 filename: parseFilenameFromStack(stack), lineno: parseLinenoFromStack(stack), colno: parseColnoFromStack(stack), })); }, true);这里有一个非常关键的细节event.reason不一定是 Error 对象。很多代码里直接Promise.reject(some string)或者reject({ code: 500, msg: server error })这种情况下reason是个字符串或普通对象直接reason.stack会取到undefined甚至直接抛 TypeError。所以我把非 Error 类型的 reason 统一包装成new Error(String(reason))。不过要注意String({ code: 500, msg: server error })得到的是[object Object]这对排查问题几乎没有帮助。更好的做法是自己实现一个序列化方法把对象里的关键字段展开成可读字符串比如code500,msgserver error。这一点我在下面的统一格式化章节里会再展开。另外unhandledrejection的filename/lineno/colno并不是事件对象的标准属性我自己是从 stack 字符串里正则解析的。不同浏览器的 stack 格式略有差异Chrome 是at functionName (file:///path/file.js:10:20)Firefox 是functionNamefile:///path/file.js:10:20写解析函数时建议两个正则都匹配。2.3 框架层错误钩子Vue 和 React 的实现差异全局error事件捕获的是运行时错误但框架内部的生命周期错误需要框架自身提供捕获机制。原因在于Vue 和 React 在渲染、更新、生命周期钩子中发生的错误如果只在框架内部处理不一定会冒泡到 window。而且在框架层捕获还能拿到组件实例、组件名这些业务上下文这对定位问题帮助极大。Vue 3 里可以在创建应用实例时配置errorHandlerimport { createApp } from vue; const app createApp(App); app.config.errorHandler (err, instance, info) { // err: 错误对象 // instance: 发生错误的组件实例 // info: 错误来源信息例如 setup function / render function / lifecycle hook reportError(createErrorInfo({ eventType: vue-error, errorType: err.name || VueError, message: err.message || String(err), stack: err.stack || , componentName: instance ? instance.$options.name || instance.$options.__name : , lifecycleInfo: info, })); };Vue 2 里同样可以在构造函数上配置但要注意 Vue 2 的errorHandler默认不会捕获errorCaptured之外的生命周期钩子错误配置方式稍有差异。我在实践里遇到过一个典型问题早期把window.addEventListener(error)和 VueerrorHandler同时接上了结果同一个错误被上报了两次。原因就是 Vue 内部的错误在被框架处理之后仍然会触发全局 error 事件。解决方案是在全局 error 处理器里用一个标记位过滤掉“已经被框架处理过”的错误下面常见问题里我会专门写。React 的情况不太一样。React 错误边界是组件化的你得用一个类组件包住需要保护的子树import React from react; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, info) { // info 里包含 componentStack可以定位到具体组件树位置 reportError(createErrorInfo({ eventType: react-error, errorType: error.name || ReactError, message: error.message || String(error), stack: error.stack || , componentStack: info.componentStack || , componentName: parseComponentNameFromStack(info.componentStack), })); } render() { if (this.state.hasError) { return this.props.fallback || div页面出错了/div; } return this.props.children; } }注意几个 React 特有的细节。错误边界只能捕获生命周期方法、render 方法、构造函数里的错误捕获不了事件处理器里的错误——事件处理器里的错误会直接抛到全局需要靠window.addEventListener(error)兜底。这跟 Vue 的errorHandler相比覆盖范围小一些实际项目中建议两层都接互为补充。React 18 之后的createRoot渲染出来的应用配合并发特性开发模式下某些错误会在错误边界里被特殊处理行为跟生产环境不完全一致。我在调试时踩过这个坑如果发现 dev 环境错误边界不生效先切到生产构建再复现。2.4 资源加载错误与其他边界情况资源加载错误和脚本执行错误是两类完全不同的错误。脚本执行错误发生在 js 引擎里事件对象上会有error属性、lineno、colno资源加载错误发生在网络层事件的target指向的是出错的script、img、link等标签事件类型是error但没有error对象。资源加载错误在捕获阶段更容易拿到判断方式看 target 类型window.addEventListener(error, function (event) { const target event.target; if (!target) return; const isResource target.tagName SCRIPT || target.tagName LINK || target.tagName IMG || target.tagName IFRAME || target.tagName VIDEO || target.tagName AUDIO; if (isResource) { reportError(createErrorInfo({ eventType: resource-error, errorType: ResourceError, message: 资源加载失败: ${target.tagName} ${target.src || target.href || }, resourceUrl: target.src || target.href || , resourceTag: target.tagName, })); } }, true);边界情况里还有一个容易被忽略的点跨域脚本抛出的错误浏览器为了保护安全会向全局错误处理机制暴露一个只包含Script error.的字符串错误对象拿不到堆栈和原始信息。这个问题我在常见问题章节里单独讲解决办法。另外还要考虑iframe内的错误。主页面和 iframe 是不同的全局作用域iframe 内的错误不会冒泡到主页面。如果你有嵌入第三方 iframe 的场景要么在 iframe 页面内部自己埋点上报要么用postMessage把错误信息从 iframe 传回主页面。这个我没有在项目里全部铺开因为涉及跨域权限但思路是清晰的。3. 统一格式化把错误变成结构化的上报数据捕获链路接住错误之后下一步就是格式化。这一层做得好不好直接决定服务端接到的数据可不可用。我在项目里遇到过一种典型情况服务端接到了上报但错误字段里混着[object Object]、undefined、超长堆栈统计时还得先做一遍清洗。这些问题的根源都在格式化层。3.1 序列化 Error 对象的坑与标准字段设计先说Error对象序列化的坑。你直接JSON.stringify(error)是拿不到message和stack的。原因是Error的message和stack属性默认是 non-enumerable 的JSON.stringify只序列化可枚举属性所以输出基本是{}。推荐用一个专门的格式化函数把错误字段显式提取出来。我用的标准字段结构大概是这样的function createErrorInfo(options) { const timestamp Date.now(); return { // 事件类型error / unhandledrejection / vue-error / react-error / resource-error eventType: options.eventType || error, // 错误类型TypeError / ReferenceError / VueError ... errorType: options.errorType || Error, // 错误信息 message: truncate(options.message || , 300), // 堆栈 stack: truncate(options.stack || , 2000), // 源码定位信息 filename: options.filename || , lineno: options.lineno || 0, colno: options.colno || 0, // 业务上下文 componentName: options.componentName || , lifecycleInfo: options.lifecycleInfo || , // 环境信息 url: location.href, userAgent: navigator.userAgent, timestamp, pageId: getPageId(), userId: getUserId(), }; }字段设计要遵循几个原则。第一所有字段都有默认值不允许上报的数据里有undefined或null否则服务端解析和后续统计都会遇到麻烦。第二message和stack要截断。stack在复杂的应用里可能有几十 KB全量上报会撑爆日志系统也影响传输效率。我通常把 message 截到 300 字符以内stack 截到 2000 字符以内。第三固定加timestamp不要依赖服务端接收时间因为上报可能有延迟和批量提交错误发生时间和到达时间不是一回事。3.2 非 Error 对象序列化与请求上下文的自动补充unhandledrejection里的reason和部分框架错误钩子拿到的错误可能是任意值这一层要单独做序列化。我建议写一个单独的stringifyReason函数function stringifyReason(reason) { if (reason instanceof Error) { return reason.message; } if (typeof reason string) { return reason; } if (reason typeof reason object) { try { return JSON.stringify(reason); } catch (e) { // 循环引用等场景退回手动提取 return Object.keys(reason).map(key ${key}${String(reason[key])}).join(,); } } return String(reason); }这里有个细节我特别想强调如果reason是一个包含循环引用的对象JSON.stringify(reason)会抛异常所以在 try/catch 里包一层异常时退回去手动提取第一层字段。请求上下文补充是格式化层的增值点。我在项目里维护了一个全局的最近操作队列记录用户最近发生的交互行为const actionQueue []; function trackAction(action) { actionQueue.push({ action: action, timestamp: Date.now(), url: location.href, }); if (actionQueue.length MAX_ACTIONS) { actionQueue.shift(); } } // 在点击、路由切换、接口请求时调用 document.addEventListener(click, function (event) { const target event.target; if (target target.tagName) { trackAction(click ${target.tagName.toLowerCase()}${target.className ? . target.className : }); } });这样上报的时候可以把最近的 10 条操作记录一起带上去排查“用户做了什么操作之后触发报错”时会非常有用。这个思路在成熟监控平台里叫“用户行为追踪”但自己实现一个轻量版本并不复杂代码量很小收益很明显。3.3 Source Map 让线上堆栈还原到源码生产环境的代码经过压缩混淆之后stack 里的路径通常是https://cdn.example.com/app.8f3k2l.js:1:23345。一行 2 万多个字符根本没法定位。Source Map 的作用就是把压缩后的行列号映射回源码文件的行列号。实际落地有两种方式。一种是在浏览器端拿 stack 里的行列号配合线上可访问的 source map 文件用 source-map 库还原还原后在浏览器端直接上报源码位置。另一种是上报压缩后的行列号服务端保留 source map 文件收到上报后再做还原。我偏向第二种source map 文件通常比较大发布到公网有一定源码泄露的风险放在服务端内部处理更安全。而且服务端做还原后续如果需要重新映射或者调整还原逻辑不需要重新发版。服务端还原的核心逻辑大致是这样的// Node.js 环境使用 source-map 库 import { SourceMapConsumer } from source-map; async function restoreStack(stackLine, mapFile) { const consumer await new SourceMapConsumer(mapFile); const pos consumer.originalPositionFor({ line: stackLine.line, column: stackLine.column, }); return { source: pos.source, line: pos.line, column: pos.column, name: pos.name, }; }要把 stack 字符串逐行拆开取每一行的 line/column 做还原重建一份可读的堆栈。这个方案我在生产环境跑了大半年效果是把“一行压缩代码”还原成“源码目录 函数名 行号”定位效率提升了不是一星半点。4. 从捕获到服务端上报的完整链路实现前面几章解决了“错在哪”和“错得长什么样”这一章解决“怎么把错误送到服务端”。上报链路看起来简单——发个请求而已——但里面涉及性能、可靠性和服务端配合的问题不提前设计好上线后会在关键时刻给你掉链子。4.1 上报策略防抖、批量、采样与通道选择错误上报不能像写日志一样每题一条用户在崩溃边缘连续报错时接口会被打爆。我的做法是“本地缓存 定时批量上报”。基本思路是维护一个上报队列攒到一定数量或间隔一定时间就统一发送let reportQueue []; let reportTimer null; function reportError(errorInfo) { reportQueue.push(errorInfo); // 队列长度达到阈值立即发送 if (reportQueue.length 10) { flushReportQueue(); return; } // 或者定时 5 秒发送 if (!reportTimer) { reportTimer setTimeout(flushReportQueue, 5000); } } function flushReportQueue() { if (reportTimer) { clearTimeout(reportTimer); reportTimer null; } if (reportQueue.length 0) return; const payload reportQueue.splice(0, reportQueue.length); if (navigator.sendBeacon) { const blob new Blob([JSON.stringify(payload)], { type: application/json }); navigator.sendBeacon(/api/error/report, blob); } else { fetch(/api/error/report, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), keepalive: true, }); } }这里我同时用了sendBeacon和fetch。sendBeacon的好处是即使用户关闭页面浏览器也会在卸载期间尽量把请求发出去适合错误上报这种需要在页面离开时确保送达的场景。但它有请求体大小限制通常 64KB 以内都没问题。老浏览器没有sendBeacon所以回退到fetch并加上keepalive: true来提升页面卸载时的送达率。采样策略也值得说。对于高流量应用全量上报的成本不低而且同一个错误可能被几百个用户同时触发全量上报会造成大量重复。我用的策略是普通错误全量上报但同一错误指纹在短时间内比如 1 分钟最多上报 1 条致命错误白屏、页面崩溃不做采样全量上报。错误指纹可以用errorType message filename简单拼接生成。4.2 服务端接收接口的参考实现前端上报之后服务端得有一个接口先接住数据。我用 Node.js Express 写过一个简化的接收接口核心逻辑是校验数据、落盘、写入后续处理的队列。const express require(express); const bodyParser require(body-parser); const fs require(fs); const path require(path); const app express(); app.use(bodyParser.json({ limit: 1mb })); app.post(/api/error/report, (req, res) { const reports Array.isArray(req.body) ? req.body : [req.body]; if (reports.length 0) { res.status(400).json({ ok: false, message: empty }); return; } const logLine reports.map(item JSON.stringify(item)).join(\n) \n; const logFile path.join(__dirname, error-log, ${getToday()}.log); // 简化处理直接追加写入文件 fs.appendFile(logFile, logLine, (err) { if (err) { res.status(500).json({ ok: false }); return; } res.status(200).json({ ok: true }); }); }); function getToday() { const d new Date(); return ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)}; } app.listen(8080);写接口时有两个容易踩的坑。第一个是 CORS。如果前端页面和后端接口不在同一个域名浏览器跨域请求会被拦住。测试时可以直接在浏览器里访问接口发现没问题但页面里发请求就会失败。开发环境用cors中间件放开生产环境建议配置白名单域名。第二个是请求体大小限制。错误堆栈加上下文单条可能几十 KB批量上报一次可能几百 KB。bodyParser.json({ limit: 1mb })限到 1MB防止有人恶意上报超大 body 打爆内存。超过限制的请求会直接 413需要在前端把单次上报的条数控制住。真实生产环境一般不会只把日志往文件里写至少接一个日志采集管道或者写入数据库方便后面查询和聚合。我在这套方案里用文件落盘做基础存储额外写了一个轮询脚本把当天日志同步到 ClickHouse 再聚合但那是另一个话题了这里不展开。4.3 上报数据的安全与隐私过滤错误上报的数据里很容易夹带敏感信息。比如location.href如果带 query 参数而 URL 里有 token 或手机号就会跟着一起上报。又比如错误堆栈里可能包含函数参数参数里有用户输入的内容。所以我做了两层过滤。第一层是前端过滤。上报之前把 URL 里的敏感 query 参数替换成固定占位符function sanitizeUrl(url) { if (!url) return ; try { const parsed new URL(url); const sensitiveKeys [token, access_token, password, phone, account]; parsed.searchParams.forEach((value, key) { if (sensitiveKeys.some(k key.toLowerCase().includes(k))) { parsed.searchParams.set(key, [REDACTED]); } }); return parsed.toString(); } catch (e) { // URL 解析失败时退回到正则替换 return url.replace(/(token|access_token|password|phone|account)[^]/gi, $1[REDACTED]); } }第二层是服务端过滤。收到上报数据之后再根据一份敏感字段清单做一次清洗只保留结构化字段和清理后的 message/stack。两层过滤的意义在于前端过滤是减少传输泄露服务端过滤是兜底即使某些字段之前没考虑到后端也能挡一层。还有个很现实的隐私问题用户上报数据里如果包含个人信息存储时要做脱敏。我的实践是尽量不采集用户主动输入的内容error 的 message 里如果包含也会在服务端清洗时做替换。这块不用搞得太复杂核心原则是“能少采集就少采集能采集到的最小字段就采最小字段”。5. 常见问题与排查技巧实录方案落地过程中我碰到了不少坑。有些坑是不跑一遍线上环境根本发现不了的。这里挑几个高频问题写出来希望能帮你少走弯路。5.1 错误上报发出的请求为什么没有到达服务端这是我上线后第一个遇到的奇怪问题。页面确实报错了上报函数也确实执行了但服务端日志里一条都没有。排查下来发现原因在请求时机上错误发生在页面 loading 阶段请求还没发出去页面就跳转走了。fetch请求被浏览器中断或者压根没发出去。解决办法有三个按优先级排。第一尽量用navigator.sendBeacon它专门为“页面卸载时发送数据”设计。第二fetch加keepalive: true这个特性允许请求在页面卸载后继续传输。第三把关键的初始化错误提前上报不要攒在队列里等定时器触发否则页面生命周期短的话定时器还没到上报时间页面就关了。我还遇到过另一个问题本地测试接口明明通线上却一条上报都没有。查到最后发现是 CSP 策略把上报接口的域名拦了。如果你的页面配置了 Content-Security-Policy 的connect-src记得把上报接口的域名加到connect-src里否则请求会被浏览器直接拦截控制台还会打一条 CSP 相关报错。5.2 跨域脚本报错 Script error 的排查与处理跨域脚本错误是前端异常监控里最经典的老大难。如果你的代码里有部分静态资源托管在 CDN 上而这些资源是通过script srchttps://cdn.example.com/app.js引入的浏览器为了安全不会把这个外域脚本的错误细节暴露给本域的全局错误机制。你捕获到的错误信息永远只有一句Script error.没有堆栈、没有行列号、没有原始 message。解决办法有两个需要前后端配合。一个是给外域脚本标签加上crossoriginanonymous属性并且在 CDN 响应头里加上Access-Control-Allow-Origin: 你的域名。这样浏览器才会把错误细节暴露给全局错误处理器。script srchttps://cdn.example.com/app.js crossoriginanonymous/script另一个是如果不方便改 CDN 响应头就在打包时把脚本内容改成同域路径由网关转发或者干脆把关键业务代码跟第三方脚本分离第三方脚本的错误细节拿不到就算了至少自己业务的错误能完整捕获。我在项目里遇到的一个特殊情况是crossoriginanonymous加了之后某些老版本浏览器的 script 加载从缓存复用变成了需要重新校验响应头导致行为差异。这个概率不高但如果测试环境一直正常、线上频繁上报Script error.可以检查这一层。5.3 重复上报与循环上报的控制前面提过的重复上报问题是这个方案的常见并发症。同一个错误可能同时被window.addEventListener(error)和框架errorHandler接住导致上报两条相同的数据。控制方式有两种。第一种是源头去重。在框架的errorHandler里处理错误之后设置一个模块级标记全局 error 处理器看到这个标记就跳过let processedByFramework false; // 全局 error 处理器入口 window.addEventListener(error, function (event) { if (processedByFramework) { processedByFramework false; return; } // 真正的处理逻辑... }); // Vue errorHandler 里 app.config.errorHandler (err, instance, info) { processedByFramework true; // 上报逻辑... };这个方案有个隐患Vue 处理错误之后全局 error 事件里拿到的错误对象是同一个标记位在同步代码里能生效但如果框架内部处理和全局事件是异步的标记位可能被其他错误“提前消费”。所以我更推荐第二种。第二种是上报端去重。上报前根据错误指纹在本地存一份最近上报的时间戳同一指纹在时间窗口内只上报一次。这个方案不用考虑框架内部的时序问题更稳const dedupeMap new Map(); function shouldReport(errorInfo) { const fingerprint ${errorInfo.errorType}|${errorInfo.message}|${errorInfo.filename}:${errorInfo.lineno}:${errorInfo.colno}; const now Date.now(); const lastTime dedupeMap.get(fingerprint) || 0; if (now - lastTime 60 * 1000) { return false; } dedupeMap.set(fingerprint, now); // 控制 Map 大小避免内存无限增长 if (dedupeMap.size 200) { const oldestKey dedupeMap.keys().next().value; dedupeMap.delete(oldestKey); } return true; }这还不够。更隐蔽的问题是循环上报上报逻辑本身可能有 bug导致上报代码执行时再抛一个错误又被全局错误机制捕获又重新上报形成一个死循环。我在项目里专门加了一道保险上报函数内部用 try/catch 包住所有逻辑并且设置一个模块级标志一旦正在执行上报流程所有错误处理函数直接返回不再触发新的上报。这个“上报过程中禁止再进入上报”的约束是监控类代码的基本要求。5.4 实操中还踩过的几个细节坑这里补充几个小坑都是真实里会遇到但很少人写出来的。第一个是async/await的 try/catch 吞错问题。很多人以为try/catch包住await就万事大吉但如果你在 try 块里调用的函数内部自己 catch 了错误但没继续抛外面的 catch 是接不到的。我建议团队里统一一个规范内部函数不要吞错把握不准的一律向上抛。第二个是开发环境的干扰。开发模式下框架和 webpack 会额外加载很多 polyfill 和 dev 代码行为跟生产环境差异非常大。调试时经常发现 dev 环境报错规则和生产不一致我在项目里加了环境判断只有生产环境才初始化上报逻辑if (import.meta.env.PROD || process.env.NODE_ENV production) { initErrorTracking(); }第三个是移动端低版本浏览器兼容。navigator.sendBeacon在 iOS 14 以下和部分安卓 WebView 里不可用Promise.prototype.finally在旧浏览器也不一定支持。如果你要兼容这些环境上报逻辑里要做能力检测并做好 polyfill。第四个是告警设置。错误上报到服务端之后如果没有告警那只是从“看不见”变成了“藏在日志文件里”。我在服务端加了个简单的聚合扫描每分钟统计最近 5 分钟内上报量如果超过基线阈值就发企业微信通知。这个告警阈值需要根据项目流量动态调整刚开始设得低一些宁可误报不要漏报。6. 方案落地的最后一点建议整套方案从设计到跑通我花了两周时间核心代码量其实不大但收益是持续的。线上错误从“不可见”到“可追踪、可聚合、可告警”排查问题的平均耗时大幅缩短。如果你正准备在自己的项目里落地这套方案我的建议是先做最小闭环全局 error 捕获 unhandledrejection 捕获 统一格式化 服务端接收日志。这四个环节跑通就已经能覆盖绝大多数线上问题。框架层捕获、Source Map 还原、用户行为追踪、告警这些增强能力等最小闭环稳定运行之后再逐步加上去。上来就铺得很大容易在各种边缘情况里消耗太多精力反而坚持不下去。最后分享一个我在实践中反复体会到的原则错误监控系统的价值不在于代码多漂亮而在于它能不能持续、稳定地把线上问题暴露出来。维护这套系统本身也是一份长期工作新框架、新浏览器特性、新打包工具都会引入新的错误形态它需要像业务代码一样持续迭代。你可以先从今天的一个按钮报错开始把它完整地上报一次后面慢慢扩展就足够了。
返回列表