做前端时间久了,最怕听到的其实不是“页面崩了”,而是“用户那边弹了个错,我截了图,就这个”。这张截图大概率只包含弹窗的半截标题,没有触发页面、没有请求参数、没有操作步骤,等你赶到现场,那个错误早就被刷新冲得干干净净。我一直在琢磨同一个问题:错误弹窗本身是产品最诚实的信息出口,为什么我们在排查时,却一直把它当成一次性废料看待?后来我在团队里陆续做了两件事,一是把所有错误弹窗纳入统一记录,二是把弹窗记录和操作上下文关联起来,算是把“用户又报错了”从一句口头禅变成一个可检索、可回放、可以反向推动修复的事实数据库。这篇文章就把这套错误弹窗记录方案的完整思路、代码边界和踩坑过程拆开来讲。
1. 线上“点哪儿都报错”却无法复现:弹窗记录缺的不只是截图
先说我在项目里最直接的一个观察。我们的前端应用是标准的中后台系统,用户角色很多,菜单权限不一样,二开改造也多。过去一年里,工单系统里最常见的线上反馈大概有三类:第一种是“点保存的时候弹出红色报错了,但再点一次又好了”;第二种是“某用户环境里弹了错误,页面卡住,刷新后恢复正常”;第三种最模糊——“经常弹错,不确定是不是我网络问题”。
这类反馈有共同点:报错信息大概率出现在 alert、message toast、Modal 弹窗里,由封装好的 request 拦截器统一弹出。可问题来了,弹窗组件本身没有日志,没有版本号,没有用户标识,甚至很多文案是后端返回的 errorMsg,也不写错误码。用户截图只能截出某个瞬间,可工程师定位时最需要的是时间轴。
1.1 一个错误弹窗从出现到消失,留在工程师手里的只剩回忆
我之前做过一次小试验,让三位同事模拟用户操作埋点环境,触发同一个抛错接口。结果很有意思:页面上的 message 文案完全一致,但触发路径完全不一样,有人是从列表页按钮进来的,有人是提交表单触发的,还有人是从详情页跳转后自动加载时弹出的。如果只把“错误弹窗”本身记录下来,我们只能得到一张好看的错误分级表,却无法回答最重要的那个问题:到底哪个入口、哪个参数组合、哪一版代码最容易把它带出来。
于是,我把思路从“记录错误弹窗”调整为“记录错误弹窗前后 10 步的可复现上下文”。简单说,弹窗不是孤立的,它是用户操作链、网络请求链和前端状态变更链的交汇点。只拍下事故现场还不够,还得知道它是怎么一步步发生的。
1.2 第一版需求边界:别想着全量采集,先把错误弹窗捞干净
初期特别容易犯的错是贪大。有人一上来就想把点击流全部埋了、所有接口响应全存了、页面全量录屏,结果做了一两周还停在埋点列表上。我的建议是把第一版边界收得非常窄:只盯“计划内错误弹窗”和“计划外全局异常弹窗”两类。
- 计划内错误弹窗:业务代码主动调用的错误提示,比如提交失败、数据加载异常,通常通过项目封装的 notify.error 之类方法呼出。
- 计划外错误弹窗:页面里冒出来的系统报错,比如 React Error Boundary 兜底页、未捕获异常导致的错误弹层、全局 unhandledrejection 被框架统一提示的场景。
第一版不需要记录非错误类 toast,也不追普通的成功提示。只有先把错误弹窗做成结构化数据,后面的分析才有根基。
2. 核心实现:整套错误弹窗记录器怎么落地
确定了边界,后面的事就是顺着一条链路做透:捕获异常信号、找到对应的弹窗对象、抽取弹窗关键信息、关联操作上下文、落本地缓冲、异步上报、查询展示。我在项目里基于现有组件库做了一层轻量封装,没有额外引入重量级监控 SDK,整体代码量控制在几百行附近。
2.1 全局异常捕获:window.error 和 unhandledrejection 双通道
前端错误弹窗的来源未必都是业务代码主动弹的,有可能是运行时异常被全局监听后,触发兜底提示。例如代码里某个核心方法抛错,经统一捕获后 setState 出一个全局 ErrorPage。记录器不能只监听某个组件的属性,而要在最外层把异常源抓住。
我在公共模块里注册了两个监听:
// error-tracker.js export function installGlobalErrorCapture() { window.addEventListener('error', (event) => { // 捕获资源加载错误与普通运行时错误 const detail = extractErrorDetail(event.error); pushToLocalBuffer({ type: 'window-error', level: 'error', message: detail.message, stack: detail.stack, filename: event.filename || '', lineno: event.lineno || '', colno: event.colno || '', occurredAt: Date.now(), }); }); window.addEventListener('unhandledrejection', (event) => { const reason = event.reason; const detail = extractErrorDetail(reason); pushToLocalBuffer({ type: 'promise-rejection', level: 'error', message: detail.message, stack: detail.stack, occurredAt: Date.now(), }); }, { passive: true }); }这段代码很多团队都有,但差别在于:我把这两个事件的身份标识与稍后要记录的弹窗信息做了关联。
实际踩过的坑是:部分浏览器对跨域脚本的 stack 会打码,错误对象虽然存在,但 fileName 和行号是空的。因此推入缓冲队列时,不要把 stack 作为唯一线索。Message 里的“请求失败”“保存失败”这类关键词反而能帮我们做弹窗文案匹配。
2.2 弹窗内容与 DOM 提取:截图之外的文本证据链
等到异常真正被渲染为可见弹窗时,仅仅记录错误对象还不够。用户关心的是屏幕上弹出了一行什么字。我建议在组件库通知类方法做统一包装,或者在弹窗挂载后扫描可见区域里的错误提示节点。前者侵入性稍强但拿数据最准确;后者的优点是不用改业务调用方,需要额外做的是判定“哪些节点算错误弹窗”。
我采用了一个中庸方案:在统一的 request 拦截器和 notify 封装里追加记录函数,同时保留 DOM 扫描作为兜底。记录函数会拿到 complete 的弹窗信息:
function captureNotice({ type, title, content, level, code, requestId, duration }) { pushToLocalBuffer({ type: 'business-notice', level, message: content || title || '', errorCode: code || '', requestId, // 方便回放时定位触发的接口调用 pageUrl: location.href, route: router.currentRoute?.value?.fullPath || '', }); }DOM 扫描兜底一般用 MutationObserver,监听 body 下新增的错误样式节点,取它的 innerText 并判断是否在错误枚举列表里。这个方案不够优雅,但真碰上“外部老代码直接调 $.messager.alert”的时候,它能捞回大量漏网之鱼。毕竟“错误弹窗记录”首要任务是先把它记下来,后续再谈结构化。
2.3 快照与轨迹:记录错误发生前10步操作
有的错误弹窗与用户操作没有直接关系,比如定时器拉取了数据、后台消息推送触发了刷新。但多数业务错误有明确的前置操作链。我给记录器维护了一个环形轨迹缓冲区,最多保留当前会话最近 20 条动作。
每一条动作包含四类基础信息:
- 交互事件:click 时所在节点文本、按钮文案、组件 key、事件目标 CSS 选择器。
- 路由变化:上一个路由和当前路由。
- 接口请求:发起请求的 method、url、关键入参摘要、返回状态码。
- 浏览器事件:visibilitychange、online/offline、页面 resize 等可能间接影响的状态。
实现时用一个简单的 trackAction 函数,在各埋点处低侵入追加即可。触发一次点击就压一条 JSON 快照:
const actionRingBuffer = []; const MAX_RECORD = 20; function trackAction(action) { actionRingBuffer.push({ ts: Date.now(), ...action, }); if (actionRingBuffer.length > MAX_RECORD) { actionRingBuffer.shift(); } } export function getRecentActions() { return actionRingBuffer.slice(); }有几个团队在复用这套逻辑时,总想把轨迹做得又细又全,最后存储开销先行爆炸。实际证明 20 条足够覆盖绝大多数问题,且更容易让开发在看记录时抓住重点。
2.4 数据怎么存:IndexedDB 本地缓冲与按需上报
记录最终要出浏览器。如果每弹一个错误就马上上报一次,异常频率高时反而会把少量有价值的堆栈信息淹没,还会影响页面性能。我采用了两级存储方案。
错误记录先进 IndexedDB 的本地表,带上状态标记(pending/uploaded)。页面正常退出前,如果 pending 数量超过一个阈值,使用 sendBeacon 批量上报;如果用户在断网状态下工作,记录会滞留在本地,等网络恢复后自动补传。这里要留意 IndexedDB 的异步特性,避免在页面关闭瞬间写入未完成导致丢数据。
上报的消息体不复杂:
{ "eventId": "uuid-xxxx", "occurredAt": 1700000000000, "sessionId": "ukey-xxx", "version": "1.4.2", "env": "production", "userIdHash": "a1b2c3", "records": [ { "type": "business-notice", "level": "error", "message": "保存订单失败,请稍后重试", "errorCode": "ORDER_SAVE_FAILED", "route": "/order/create", "stack": "", "actions": [] } ] }3. 纯记录没有用,得把它串成一条能回放的时间线
数据存下来只是开始。真正产生排查价值的是,工程师拿到一条错误弹窗记录后,能像看回放一样把这个用户前一分钟的操作过程复现出来。
3.1 用户身份、版本号和路由字段:排查的第一批基本盘
上线第一周我就发现一个真实痛点:同一个错误弹窗,后台查看时只显示“部分用户出现报错”,无法确定是版本灰度问题还是特定操作问题。后来我在记录里强制补上了三个基础字段:应用版本号、当前登录用户标识的哈希值、当前页面路由。
为什么用户标识要用哈希而不是明文?出于隐私考虑。记录的目的不是为了知道“张三做了什么”,而是为了能回答“某个具有 X 权限的人,在 Y 菜单下,使用 Z 版本时是否遇到相同问题”。用户 ID 做一次 HMAC 混淆即可,既保留横向关联能力,又不至于让所有能看到后台的人直接读到用户名。
版本号尤其重要。很多错误不是“所有用户都弹”,而是“新版框架升级后加了字段校验,老接口返回的数据不满足新约束”,导致只有新版本前端会弹错。没有版本字段,复现人员会拿旧版本本地代码去跟线上最新代码比对,浪费几个小时。
3.2 业务状态序列化:错误弹窗往往只是最后一环
真正复杂的问题发生在“弹窗出现时,业务状态已经处于异常”的场景。比如用户先选了某张优惠券,再对商品进行改价,最后提交订单时被后端提示价格不一致。弹窗文案里只有“订单信息错误”,但真正原因是前端状态中的优惠券对象和服务端不一致。
为了覆盖这一类问题,我在业务关键操作的位置留了一个可选的“快照点”,仓库核心数据在状态变更后打上轻量标签:
trackStateSnapshot('cart-store', { selectedItemIds: cartStore.selectedItems.map(i => i.id), couponId: cartStore.appliedCoupon?.id || '', totalAmount: cartStore.totalAmount, });不过业务快照必须克制,否则会有敏感数据混杂进来。我的建议是:快照只存主键 ID 和“参与计算的关键金额/数量”,不存收货人姓名、手机号、备注等无关内容。定位是辅助,不是数据仓库。
3.3 查询面板设计:按时间段、关键词、错误码快速检索
有了数据,团队需要一个查询入口。第一版我直接做了一只简单的管理端页面,本质就是一张大表加一个筛选器。看起来朴素,但维度齐全后非常能打。
筛选维度包括:
- 时间范围:默认最近 24 小时,支持自定义
- 应用版本:精确匹配,用于灰度对比
- 错误类型:business-notice / promise-rejection / window-error / boundary-error
- 关键词:匹配 message、route、stack 摘要
- 操作轨迹中是否包含某个接口 URL
这里最让人舒服的排序逻辑是“错误弹窗出现频次按去重后计数”。同一用户同一错误只计一次,否则某一次接口抖动会让一个底层错误冲上榜首。去重键我用的是 sessionId、错误 message 前 100 字符和 stack 首行三点拼接后的 hash,排序时按去重后数量倒序。
4. 记录上线后的三波冲击:数据噪音、隐私和存储膨胀
这套记录器上线第二天,后台就收到了一万两千多条错误弹窗记录。这数字乍看像系统崩了,实际分析发现其中 80% 来自同一个状态码为 502 的网关报错,发生在两次公网抖动之间。这个现象很典型,也直接暴露了记录链路的第一波坑。
4.1 一个接口把错误弹窗触发了3000次的真相
有个查询列表接口在网关抖动后的 5 秒内无法返回,前端代码里的重试逻辑误以为超时,于是每 3 秒重试一次。用户没有刷新页面,轮询也没有停,错误弹窗就一遍遍提醒“查询失败”。短时间内同一个用户触发了 30 多次记录。
如果不做弹窗去重,数据库里会被同一问题刷爆。我在 record 入库前增加了一个合并窗口:同一个 sessionId 下,相同 message、相同错误码的记录在 5 分钟内合并为一条,并且把 count 字段累加。这样既能说明问题严重度,又不会把用户会话变成垃圾数据的制造机。
当时还发现部分弹窗组件即使重复渲染,DOM 文本也完全一致,合并条件可以轻松命中。真正的难点集中在错误文案里带时间戳的情况,比如“接口超时 900ms”,每次数值都不同,合并键失效。解法是归一化:文案里的数字统一替换成占位符,再用来做去重键。
4.2 脱敏规则的取舍:不能为了排查把用户整个会话都搬走
记录器刚准备扩大采集范围时,我差点把用户输入内容也装进轨迹快照。一次安全自查让我及时刹车:有些表单输入项极具隐私性,身份证号、手机号、备注字段都可能被输入框 change 事件捕获。而故障排查通常根本用不到这些值,工程师真正需要的是“用户填了哪些字段、校验是否通过、提交值是哪些选项”。
因此轨迹采样只保留输入框的 name、非敏感选项类值、校验结果,对 free-text 输入值一律不采集。碰到纯文本 Query 查询条件,仅保留前几位索引参数和长度,避免把用户全文搜索词带出。脱敏规则可以写成配置,每个新增动作类型都要先确认是否包含敏感字段,再决定是否进入环形缓冲。
4.3 采样策略与自动清理,避免把前端性能拖垮
记录器本身不能成为性能杀手。任务最重的是 DOM 场景快照和轨迹序列化,如果在错误弹窗出现瞬间就立刻执行全量页面 HTML 快照,用户会明显感到卡顿。我把页面 DOM 快照阈值设为只做“弹窗所在挂载容器的 outerHTML 截断”,默认前 2000 字符,然后压缩后在网络空闲时上报。
本地的 IndexedDB 还要定期清理。正常情况下保留最近 2 天的完整记录即可,更早的记录只保留聚合字段,例如 message 聚合条数和最近一次出现时间。因为突发问题通常 48 小时内就会被发现,留太久反而让查询接口越跑越慢。
后端接收端也要有丢弃策略。我通过在服务端按 hour 粒度聚合同一个 key 的记录数,超过 30 条后自动把明细转存冷表。这样保障了热查询表的体积,任何一次弹窗风暴都不会拖垮监控系统的读接口。
5. 靠错误弹窗记录抓到的那几个“鬼”
说实话,没有这套记录链路之前,这些问题也不是完全没法查,只是每次都得通过让用户开控制台、装代理、录屏,或者反复沟通口径来碰运气。上线一段时间后,团队从错误弹窗记录中翻出了好几个之前完全无感的问题。
5.1 同一个错误弹窗在不同时区弹出不同文案
有段时间海外销售反馈:“明明本地校验都过了,提交预估单时偶尔会弹出’提交失败,请重试’”。这个错误从工单里根本看不出规律。后台检索错误弹窗记录,按操作轨迹排序后,发现所有出问题的会话共同点是:用户在当天 23:30 左右提交单据,而表单里的“服务日期”字段在转换时间戳时少做了一步时区偏移,导致生成的日期参数早了一天,后端按业务规则拒绝。
弹窗记录里,前端虽然只看到“提交失败”,但轨迹里的请求 payload 把那个错误日期完整保留了下来。开发用这一点数据,快速定位到时间戳工具函数里对本地时区做了 toISOString 处理,却忘记还原 UTC 偏移。这类问题靠用户反馈描述,十有八九是问不清楚的。
5.2 只在老版本容器里出现的条件渲染漏网
另一个问题出现的范围也很诡异,只有几个企业客户环境会弹错,普通 SaaS 用户不受影响。逐条检查弹窗记录发现,错误集中在应用版本号 1.4.0 的会话里。当时我以为是不是灰度升级导致的,但后端 release 单显示 1.4.0 只覆盖了约 10% 流量。
点开记录的动作轨迹后真相大白:出问题的菜单入口是从一个老的容器应用 iframe 嵌进来的,那个容器解析菜单配置时使用了一个旧字段 projectId,而新前端已经在 1.4.0 改成读取 projectUid。老容器环境下 projectUid 为空,请求接口时把 undefined 拼进了 URL,后端直接返回参数缺失,错误弹窗随之出现。
没有版本号与路由字段,这种“环境和版本叠加”的怪问题最难复现。现在看到这类反馈,我第一步一定是先查错误弹窗记录里对应版本和 referrer 的分布,而不是让用户重新录屏。
5.3 前端轮询与后端超时打架,竞态错误终于浮出水面
还有一个案例让我印象更深:系统里的某监控大屏每 10 秒会轮询一批实时数据,偶尔出现“数据获取失败,请刷新页面”的错误弹窗,但刷新后马上恢复正常。因为问题偶尔出现,很难稳定复现。直到某个上午弹窗记录明确地显示:同一时间点,用户的浏览器同时发出了 4 个相同的轮询请求,而且其中两个请求因为前一个请求尚未返回导致了 token 刷新竞争,后端把这两个请求判定为无效会话。
从记录中的 request 轨迹可以清楚看到,前一次轮询还没结束,用户又切换到另一个浏览器标签页触发了一次网络恢复事件,导致页面里两个定时器叠加。我们就这样通过记录里接口发起的时间戳差值,判断出是页面从后台切回前台时重置了轮询定时器,却没有清掉旧实例。修复方式是一行清定时器的代码,但在没看到请求时间轴之前,排查成本高得吓人。
6. 记录之后才是价值:把弹窗数据接回告警与迭代流程
错误弹窗记录做到可查询,不代表整个链路已经完成。真正让团队离不开这套机制的,是后期把记录数据接入了告警、周报和排障协作流程。它们从三条路径反向压低了线上错误弹窗数量。
6.1 错误弹窗告警阈值该怎么定才不炸群
很多团队上线监控后第一件事就是接告警,但没过两天就被告警疲劳淹没。我在初始设定中故意不做细粒度告警,只对三类情况推送:核心交易链路错误弹窗数量 10 分钟内超过 20 个会话;同一个错误码 30 分钟内新增受影响用户数超过 30;新版本错误率环比增长超过 200%。
这三条有一个共同特征,强调“用户会话维度”而不是“事件次数维度”。因为一次网络抖动引起的重复弹窗,事件次数虽高但用户价值损失有限。阈值要结合业务低谷调整,例如凌晨时段,核心交易链路本身几乎没有流量,20 个会话也许就是明显故障信号;而在大促期间,这样一个阈值可能每分钟都在发起告警。所以我后来加了按小时基线浮动判断,告警更加接近真实故障。
6.2 弹窗周报的内容架构:让后端和产品也能看懂
周报不是给技术人员自己看的。我在设计时避免了纯堆 stack 的格式,而是把错误弹窗按“影响用户数”“影响业务模块”“连续出现天数”三个维度排序,生成一份可阅读的摘要。每一条记录里保留核心 message、错误码与一条典型路径截图。
产品经理拿到这份报告后,可以直接看到“购物车优惠计算失败弹窗在过去两周共影响了 600 多名用户,环比上升 150%”。过去他只能靠客服反馈判断优先级,现在数据摆在面前,排需求时也更有依据。后端的同事也很喜欢我附上的接口维tab页,它直接把错误弹窗对应的 HTTP 状态码分布和超时分布列出来了,后端不用再翻业务日志。
我建议从第一周起就固化周报格式,并附带一个简短的“本周新浮现问题”区块。因为记录器上线之后,错误弹窗的真实数量短期内会明显上升,这其实是把原先不可见的问题显性化了,不是质量倒退,大家提前在心里有个预期就行。
6.3 团队里的“弹窗止血SOP”:从发现到修复最短路径
记录器跑通后,我们形成了一套简单实用的响应动作,不需要专职监控人员也能执行:
- 新建或收到告警后,先在错误弹窗查询面板里按错误码聚合,确认影响面。
- 点击首条记录查看动作轨迹,找出最靠前的操作入口和请求参数摘要。
- 如果判断是前端问题,直接在当前会话里补抓一条用户行为记录,把路由、版本、操作前状态一并附到缺陷单上。
- 如果判断是后端或接口问题,把请求参数摘要和返回状态发给后端同事,附带相同的 eventId 便于两边对齐日志。
这套流程最大的价值是消灭了“无法复现”四个字。凡是弹窗记录里有数据的错误,无论前端还是后端,都能在一个小时内把问题范围压缩到很窄,甚至直接定位到具体函数。没有记录的时候,这个流程多半会变成来回要截图、要网络环境、要操作录屏的拉锯战。
这套记录能力上线到现在,我自己的最大感受是它把错误弹窗从“用户打扰项”重构成“质量运营数据源”。它不是万能的,不能代替性能监控,也不能自动修复所有异常,但它是前端团队理解线上真实状态的一双眼睛。如果你也被“用户说弹错了,但自己复现不了”的问题反复折磨,可以考虑从把错误弹窗变成结构化记录开始做起。别一上来追求全量采集和智能分析,先能稳定地存下来、查得到、看得懂,就已经赢过了大多数拍脑袋排错的团队。