从console.log到服务端上报:前端异常捕获与监控体系实战

发布时间:2026/10/4 11:29:50
从console.log到服务端上报:前端异常捕获与监控体系实战 前两天线上出了一个挺诡异的事故用户反馈某一批订单创建不出来报错的提示语却是个白屏。我拿到反馈第一反应是打开控制台果不其然——console.log(error)把堆栈打印得清清楚楚但用户那边什么都没有。这个场景是不是很熟悉前端异常一直在发生但真正被我们看见的可能只有开发电脑控制台里的那几行红字。做了几年前端越来越觉得异常捕获这件事不是大厂才需要的基础设施。没有上报体系的时候排查线上问题基本靠三件事用户愿意截图、客服能问到关键信息、或者自己赌运气复现。这个流程太脆弱了。这篇文章我就把自己从console.log(error)到服务端上报的完整落地过程拆开讲讲包括捕获哪些异常、如何统一格式化、上报通道怎么选、以及后期处理 SourceMap 还原的一些经验希望能给正在折腾前端监控的同学一个可参考的路径。1. 先搞清楚前端到底要捕获哪些异常很多项目一做异常捕获就把window.onerror一挂就完事了。但这个做法说实话只能捕获运行时脚本错误这一类覆盖面其实差得很远。前端异常用不同的分类方式可以切成好几块每一块的捕获手段都不一样必须先想清楚边界在哪里再动手。1.1 运行时异常window.onerror 的用武之地运行时异常指的就是 JavaScript 代码执行到一半突然throw new Error()这种情况比如const user getUser() // undefined user.name // TypeError: Cannot read property name of undefined这一类异常在浏览器里会触发window.onerror这也是大家最熟悉的老牌捕获方式。除了onerror现代浏览器还支持window.addEventListener(error, ...)这种标准化写法而且更推荐用后者因为它不会被某些框架覆盖。这里有个容易踩的细节window.onerror能拿到五个参数(message, source, lineno, colno, error)而addEventListener版本只能从event.error里拿到错误对象。如果只用addEventListener不带true去 capture 阶段捕获资源加载错误比如图片 404是监听不到的这个我后面专门说。1.2 Promise 异步异常最容易漏网的那批如果说运行时异常是明面上的敌人Promise 异常就是暗处的潜行者。看这段代码async function getData() { throw new Error(接口返回结构异常) } getData() // 没有 await也没有 catch这段代码执行到getData()的时候异常不会进入window.onerror而是会命中unhandledrejection事件。很多前端项目对异步的处理是反正有 try/catch 或者 axios 拦截器兜底但真到线上漏掉的reject比想象中多得多。捕获方式window.addEventListener(unhandledrejection, (event) { const reason event.reason // 注意event.preventDefault() 可以阻止浏览器在控制台打印默认错误 reportError({ type: unhandledrejection, message: reason?.message || String(reason), stack: reason?.stack || }) })实测下来unhandledrejection的reason不一定是个Error对象可能是一个普通字符串、一个对象、甚至一个null。格式化的时候必须做容错不能默认它有stack和message否则上报系统自己先炸了。1.3 资源加载错误另一片被忽略的战场脚本文件 404、图片加载失败、CSS 文件被 CDN 边缘节点干掉——这些错误不会产生Error对象也不会触发unhandledrejection。它们走的是window.addEventListener(error, handler, true)的捕获阶段因为资源错误不会冒泡。判断是不是资源错误有个很实用的办法window.addEventListener(error, (event) { // 判断事件目标是不是元素节点 const target event.target const isElementTarget target (target.src || target.href) if (isElementTarget) { // 这就是资源加载错误 reportError({ type: resourceError, message: 资源加载失败: ${target.tagName}, source: target.src || target.href, // 注意资源错误没有 error.stack }) return } // 走到这里是运行时脚本错误 reportError({ type: runtimeError, message: event.message || , stack: event.error?.stack || , lineno: event.lineno, colno: event.colno, }) })从这个判断逻辑也能看出来addEventListener的true参数为什么必须加上。默认冒泡阶段内部脚本错误和资源错误都会被别处的捕获逻辑吃掉监听器根本收不到。1.4 框架层错误Vue 和 React 各自的门道用 Vue 或者 React 这类框架光靠原生捕获还不够。框架内部的错误处理有自己的机制不接管的话很多错误会经过框架的日志系统打出来但不会进入window.onerror。Vue 2 里用Vue.config.errorHandler (err, vm, info) { reportError({ type: vueError, message: err.message, stack: err.stack, info, // Vue 自家提供的错误上下文比如生命周期钩子名 }) }Vue 3 的生命周期钩子名换了errorHandler的第二个参数变成了instance用法类似。React 这边更简单也更严格——componentDidCatch或者getDerivedStateFromError必须在错误边界组件里用没有全局的React.onError这种设置。所以 React 项目通常是在顶层组件包一层ErrorBoundary然后在componentDidCatch里做上报。还有一个小点容易被忽略框架的错误处理器接管之后window.onerror不一定还会触发。比如 Vue 的errorHandler会把错误吞到自己的逻辑里如果你同时全局监听了onerror可能收到的是框架包装后的信息原堆栈反而丢了。所以在设计上报的时候要么以框架捕获为主、原生兜底为辅要么做一层去重避免一条错误上报两次。2. 统一格式化设计一套谁都能看懂的异常数据结构捕获只是一半另一半是把异常变成一个结构化、可检索、可排序的数据对象。没有统一格式的时候错误日志长什么样全靠心情有人上报message字符串有人把整个error对象扔进 JSON还有人直接把console.log(error)的内容贴到 issue 里。这直接导致后端的检索逻辑写不下去因为同一个错误来自不同页面字段都对不上。2.1 一个贴近实战的异常数据模型我最终沉淀下来的上报结构是这样前端 SDK 和后端接口同时使用{ // 基础信息 project: mall-console, // 项目标识多项目共用一套上报系统时必填 version: 1.4.2, // 应用版本号配合 SourceMap 使用 env: production, // 环境development / staging / production page: /order/create, // 当前路由SPA 里注意用路由库拿不要用 location.href // 异常核心字段 type: runtimeError, // runtimeError | unhandledrejection | resourceError | vueError | reactError message: Cannot read property name of undefined, stack: at VueComponent.getUser (app.js:1:2345)..., // 原始堆栈 stackFrames: [], // stack 解析后的结构化帧数组可选前端解析或后端解析均可 // 运行时环境 userAgent: Mozilla/5.0 ..., // 需要自己精简完整 UA 太长 browser: { name: Chrome, version: 112.0.0 }, os: { name: Windows, version: 10 }, deviceType: desktop, // desktop | mobile | tablet // 上下文信息 extra: { apiUrl: /api/order/create, requestId: xx-xxx, userId: 123456 }, // 时间 timestamp: 1711000000000 }有些字段不是必须的但有一点要说清楚多余的字段在排查的时候全是线索。比如page字段SPA 路由切换之后location.href往往拿不到准确的业务页面我习惯在路由守卫里给当前页面名挂到全局变量上上报时直接读。2.2 Error 对象的序列化陷阱前端上报前大都会做一层 JSON 序列化这里有个坑JSON.stringify 一个 Error 对象拿到的只有{}。const err new Error(出错了) JSON.stringify(err) // {}因为Error的message、stack这些属性都是不可枚举的JSON.stringify默认只遍历可枚举属性。直接序列化的话后端收到的就是一个空对象排查个寂寞。解决方式很简单做一次显式提取function normalizeError(err) { if (err instanceof Error) { return { message: err.message, stack: err.stack, name: err.name, fileName: err.fileName, lineNumber: err.lineNumber, columnNumber: err.columnNumber, } } // 有些 rejected 的值根本不是 Error if (typeof err object) { return { message: JSON.stringify(err), stack: } } return { message: String(err), stack: } }这里还有个细节JSON.stringify(err)如果 err 里有循环引用比如某个业务对象直接挂在 Error 上会直接抛出TypeError: Converting circular structure to JSON所以normalizeError里的JSON.stringify最好包一层 try/catch否则上报自身也会变成一项新异常。2.3 堆栈信息太长截断和帧结构化解码完整堆栈可能几千字符塞进 GET 请求的 URL 里直接炸掉长度限制塞进日志系统也会占满单条索引。我在生产环境里做了两个处理第一对堆栈字符串做长度截断单条异常消息体和堆栈总和控制在 8KB 以内超出部分丢掉。这不是为了省存储是因为多数日志系统单条索引有大小上限超过了要么被拒绝写入要么被截断成乱码。第二通过 stacktracejs 这类库把堆栈解析成结构化数组。解析后的帧长这样{ fn: VueComponent.getUser, file: app.js, line: 123, col: 45 }结构化帧的优势在于后端可以按fn字段做聚合搜哪个函数出错次数最多比搜全文要快得多。更关键的是行号和列号字段在 SourceMap 还原时可以直接复用不需要再写一套解析逻辑。3. 上报通道怎么选从 img 天眼到 Beacon格式定好了接下来考虑怎么把数据送到服务端。这块的选择直接影响成功率上限我拿几种主流方案实测对比过。3.1 四种上报方式横向对比上报方式优点缺点适用场景new Image().src url不受 CORS 限制GET 请求、代码简单、老浏览器兼容URL 长度限制、只能 GET、不能自定义请求头轻量级上报、跨域场景fetch(/api/report, { method: POST })容量大、可以带请求头、可做批量页面卸载时可能中断、受 CORS 限制常规上报、批量上报navigator.sendBeacon(url, data)页面卸载时也能可靠发送、POST、体积上限 64KB不支持自定义请求头、部分老浏览器不支持页面关闭/路由切换时上报基于XMLHttpRequest兼容性最好、能带请求头、支持超时控制代码最重、同步模式下会阻塞页面需要兼容超老浏览器正常页面运行中对上报的可靠性要求没那么高fetch就够了但用户刷新页面、关闭标签页这种场景常规请求会被立刻 abort唯独sendBeacon能保证把数据送出去。页面卸载时报的那条错往往恰恰是最值得收的。3.2 批量上报与限流别把后端打挂了异常多了以后每条异常都发一个 HTTP 请求对后端是种压力对前端也是种性能损耗。这里我做了两层控制class Reporter { constructor({ apiUrl, batchSize 8, interval 5000 }) { this.apiUrl apiUrl this.batchSize batchSize this.interval interval this.queue [] this.timer null this.lastFlushTime 0 } push(data) { this.queue.push(data) // 达到批量上限立即发送 if (this.queue.length this.batchSize) { this.flush() return } // 防抖距离上次发送超过 interval 才发送 if (Date.now() - this.lastFlushTime this.interval) { this.flush() } } async flush() { if (this.queue.length 0) return const list this.queue.splice(0, this.batchSize) this.lastFlushTime Date.now() try { await fetch(this.apiUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ list }), keepalive: true, // 页面跳转时尽量保活 }) } catch(e) { // 上报失败不阻塞业务打印一条日志即可 console.warn([monitor] report failed, e) } } }这个实现里有几个点值得说一下keepalive: true可以让 fetch 请求在页面卸载时尽可能发完效果和 sendBeacon 类似但注意有大小限制keepalive 请求总和约 64KB。批量接口设计成{ list: [...] }一条请求带上 8 条异常后端按数组解包逐条落库。限流还有一层含义同一个错误在短时间内重复出现比如某接口 5 秒内被调了 20 次错了 20 次就没必要上报 20 条。我在 SDK 里增加了简单去重以type message 单页运行时的 hash为 key短时间内只发一条同时计数 20 次写进extra.occurrence字段。这样后端统计错误影响用户数和影响 PV时能对上真实数据。3.3 上报失败与离线缓存日志不能丢在用户终端移动端网络环境差是很现实的问题上报请求本身可能失败。两个缓解方案一是 localStorage 暂存。发送失败时把数据写进 localStorage下次成功前先合并旧数据再发。容量一般控制在 2MB 内超出就丢弃最早的日志。二是上报握手重试。发送失败时做指数退避重试第一次等 2 秒第二次 4 秒最多三次。超过三次不再挣扎避免反复请求消耗用户流量。这两个方案听起来麻烦但在实际运营里作用很明显。有一次一个低版本 App 内嵌的 H5 页面连续报错用户处于弱网环境靠 localStorage 缓存拿到数据后发现原来是 App 的 WebView 缓存机制把某个 JS 版本缓存成了旧版数据一对比就定位了问题根因。4. 实战排查从一条格式化日志到揪出真正的 Bug上面说的是基建这一节放一个真实案例展示一下这套体系上线之后怎么改变排查链路。这个案例是接口返回异常但前端不报错的经典类型。4.1 案件现场白屏但没有 JS Error某个订单列表页在部分用户手机上白屏但我们的异常监控里一条runtimeError都没有只有零星几条resourceError。如果放到以前我大概率会让用户清缓存、换网络再试而现在我先看上报数据。注意到一个细节白屏用户上报的日志里page字段是/order/listenv是 production但extra.apiData里记录的接口返回结构与其他正常用户不一样——多了一个promotionList字段而且这个字段是字符串不是预期中的数组。4.2 为什么监控没抓到问题藏在异步边界回到代码页面初始化逻辑是这样this.loading true const res await getOrderList() this.orderList res.data.list this.promotions res.data.promotionList.map(...) // 这里假设是数组promotionList是字符串时map方法不存在应当抛TypeError。但为什么监控没抓到排查后发现框架内部或者业务代码中某处有兜底的catchtry { await init() } catch (e) { // 静默吞掉只打了 console.log(e) }这正是很多项目的通病——catch块只做console.log(error)然后什么都补做。错误被捕获并吞掉了window.onerror永远触发不了。这是从 console.log(error) 到服务端上报最关键的差别console.log 只让开发者看见上报让整条链路都能看见。后来我们把所有静默 catch 位置都梳理了一遍规范改成业务 catch 里至少调用一次reportError({ type: caughtError, ... })把吞掉的异常也纳入监控。4.3 修复与验证同样的日志排查效率天壤之别修复方案其实简单接口层做数据校验promotionList不是数组时兜底换成[]。但更有价值的是这次过程暴露了体系薄弱点异常上报不只覆盖未捕获异常还要覆盖被吞掉的异常。上线后我做了个回查针对同一个接口调整监控里立刻能看到不同版本用户的错误分布变化。老版本有 200 条错误新版本 20 条数据对新老版本差异一目了然。没有这套上报体系全凭用户反馈猜很难有这样的效率。5. SourceMap 还原让堆栈从「天书」变成「可读代码」格式化上报之后堆栈里出现的通常只有压缩文件路径和混淆后的变量名比如JS_0.js:1:23456。到这一步排查还是费劲。正确的解法是接入 SourceMap。5.1 生产环境要不要暴露 SourceMap先解决这个顾虑很多团队不敢把 SourceMap 传到公网担心暴露源码。这个担心合理做法上有一个折中方案SourceMap 仅上传到内部监控平台不部署到 CDN 或静态服务器。这样线上用户永远拿不到 map 文件只有管理员在监控后台查询时后端才返回对应的还原片段。常见做法是构建完之后把 map 文件上传监控平台然后从静态资源目录里删掉# 构建脚本示意 npm run build node scripts/upload-sourcemap.js --dir dist/js --endpoint https://monitor.internal.com/sourcemap rm -rf dist/js/*.map5.2 还原过程拿到行列号之后做了什么前端上报时带上了version字段后端拿到stackFrames里的文件名和行列号在 SourceMap 存储中找到对应版本的 map 文件用mozilla/source-map库做反查const { SourceMapConsumer } require(source-map) async function resolvePosition(mapFile, line, col) { const consumer await new SourceMapConsumer(mapFile) const pos consumer.originalPositionFor({ line, column: col }) return { source: pos.source, line: pos.line, column: pos.column, name: pos.name } }还原后的堆栈长这样at getOrderList (src/api/order.ts:45:23) at initPage (src/views/OrderList.vue:132:18)从压缩 JS 第 1 行第 23456 列变成某个源文件的某一行某一列排查效率直接翻倍。这一步做完前端监控才真正进入了能看到源码位置的层次。5.3 版本对不上的坑SourceMap 错位问题SourceMap 还要求前端应用版本与远端存储的 map 文件版本严格对应。如果前端上线 1.4.2SourceMap 系统里没传全是 1.4.1 的那还原出来的行列号一定是错的甚至指向一个完全无关的代码片段。我在上传脚本里加了版本号参数构建流水线把package.json的版本和 git commit hash 一并发给监控平台node scripts/upload-sourcemap.js --version 1.4.2 --commit $(git rev-parse HEAD) --dir dist/js在监控后台做异常列表时version和commit可以印在一起排查时一眼就能确认是不是当前线上版本。这属于基建工程里做了不觉得、不做就出大问题的典型项。6. 继续扩展的方向从「异常日志」到「可量化稳定性」链路跑通之后异常数据会越攒越多下一步自然而然的动作是做统计与聚合。有几个方向我踩过之后觉得很值得投入第一错误聚合分析。后端按type message stackFrames[0].fn做聚合产出接口错误 Top10页面崩溃率 Top10之类的报表。这个能力刚开始可能只是运维参考后面会成为迭代排期的重要依据——哪些模块错误率高就得优先重构。第二用户行为轨迹。异常上报时附加最近几次关键动作点击按钮、路由切换、API 调用比如// 在 SDK 内部维护一个全局轨迹数组 const breadcrumbs [] function addBreadcrumb(action, data) { breadcrumbs.push({ action, data, time: Date.now() }) if (breadcrumbs.length 10) breadcrumbs.shift() // 只保留最近10条 }这个信息会在后端排查白屏问题时起到奇效。比如看到用户是在点击确认支付后白屏而当时平板上超过 10 条resourceError就能把问题缩小到某个静态资源挂了。第三告警。错误聚合超过阈值时推送通知到企业微信或者钉钉。阈值建议先粗后细比如某页面错误数量相比过去 7 天均值增长超过 3 倍再告警避免低级别噪声消耗响应精力。前端异常捕获这条路从console.log(error)到服务端上报中间补的不仅仅是几行代码更是把「发现问题-定位问题-评估影响」这条链路建立起来的过程。我自己的体会是这套体系不一定要做得很大但至少要做到线上出了错我不是最后一个知道的人。如果你正在项目里推进这件事先从window.onerror和unhandledrejection两条最基础的捕获接上配上统一的格式化数据结构再选一个靠得住的上报通道这个闭环一旦跑起来后面所有优化都有数据支撑了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询