
周五晚上9点半我的手机突然被炸了。运营在群里连发好几条消息下单页白屏用户刷不出来。我放下手里的东西打开电脑、连上内网、本地跑了一遍页面一切正常。测试同学也试了没问题。再看线上也是好的。可用户那边就是白屏而且是间歇性的有人能打开有人打不开同一款手机同一款浏览器结果都不一样。那是我第一次意识到前端监控不是锦上添花的可观测性建设而是事故发生后能不能快速止血的关键。没有监控的前端就像蒙着眼睛开车——你以为一切正常其实用户已经在你的页面上反复碰壁。1. 一次线上事故没有监控时的排查之痛那次白屏事故最终花了将近4个小时才定位到原因某个旧版本安卓WebView对ES2020的可选链语法解析失败导致整段入口脚本抛错页面渲染中断。本地和测试环境之所以复现不了是因为我们用的都是新版本浏览器线上用户设备五花八门总有人卡在旧版本上。最折磨人的不是Bug本身而是未知。影响范围多大不知道因为没有用户维度的报错数据。用户卡在哪个环节不知道因为没有页面流转轨迹。是脚本报错、接口失败还是资源加载不出来不知道因为浏览器控制台只在用户的设备上我们没有远程查看的能力。当时唯一的办法是让用户帮忙开调试模式、截图、录屏甚至远程控制。用户不是开发描述问题全靠我点了没反应白屏了转圈了一会儿信息损耗极大。运营、客服、产品、前后端全被拉进群里每个人都只能提供碎片化的信息。那次之后我认真复盘了一遍得出一条非常朴素的结论前端监控的本质是在用户端装一台行车记录仪仪表盘。我们不一定要它拍下所有细节但至少要它在出事时告诉我们车在哪儿、车速多少、哪个零件报错了。2. 前端监控采集什么四个必须覆盖的核心维度很多团队一上来就想着全量采集把用户每个点击、每个滚动都记下来结果数据量爆炸真正排查问题的时候反而不知道看哪。我个人的经验是前端监控最基础也最核心的采集维度就四个异常、性能、接口、行为。这四个维度对应四个必须回答的问题维度采集内容解决的核心问题异常监控JS运行时错误、Promise未捕获异常、资源加载失败、框架渲染错误用户端到底报了什么错、错在哪个文件哪一行性能监控首屏时间、LCP、FCP、TTFB、长任务、内存异常页面为什么慢、慢在哪一段链路接口监控请求耗时、HTTP状态码、业务错误码、失败率是前端问题还是后端问题、哪个接口在拖后腿行为轨迹页面路由切换、用户关键操作、页面停留时长、请求时序用户点击了什么导致了错误、复现路径是什么这四个维度缺一个排查效率都会大打折扣。比如只有异常监控没有行为轨迹的时候你会知道用户报了错误但不知道他是在打开首页时错的还是提交表单时错的上下文全靠猜。反过来只有行为轨迹没有异常监控的时候你能还原操作路径却看不到具体的报错堆栈还是得让用户去控制台复制报错。还有一点容易被忽略接口监控必须区分HTTP错误和业务错误。HTTP 200不代表接口成功很多后端接口即使业务处理失败也习惯返回200只是在响应体里塞一个code: 50001。如果只统计HTTP状态码往往一堆业务失败被漏网。3. 选型自研监控与第三方平台的取舍确定要建设监控体系之后第一个绕不开的问题是自研还是接入现成的第三方监控平台我接触过不少团队在这个问题上容易走极端。有的团队觉得Sentry很好用直接无脑上有的团队一听自研两个字就开始兴奋什么都要自己写。这两种都容易踩坑。3.1 第三方平台的优点与天花板以Sentry为代表的第三方监控平台优势非常明显开箱即用、规则完善、告警渠道丰富、自带SourceMap解析社区方案成熟接入成本低。对于中小团队来说这是性价比最高的起点。但它的天花板也很现实数据出境与合规问题。如果你的业务有明确的数据合规要求用户行为轨迹里涉及敏感信息把数据推到第三方平台就要格外谨慎。收费模式。免费额度对于低流量站点够用一旦用户量起来错误事件按量计费的成本会让人肉疼。定制灵活性。想要采集一些业务自定义指标比如购物车加购成功率支付页停留时长第三方平台往往不够灵活或者需要写很多插件去适配。网络可达性。第三方平台的上报域名在国内某些网络环境下可能访问不稳定如果上报本身就失败了监控的价值就等于零。3.2 自研监控的成本真相自研不是写几个函数监听一下错误那么简单。一套能用的监控体系至少包含数据采集、数据清洗、存储、查询分析、可视化看板、告警规则还有持续的版本迭代。后面每一项都是成本。我在早期做过一个很粗糙的自研版本用window.onerror捕获错误通过一个像素请求把错误信息发到后端存进MongoDB管理端简单列表展示。这个版本解决了一部分问题但告警完全没有报表全靠手动查数据量上来之后查询慢得没法用。所以我的建议是不要一上来就自研先用第三方方案跑通排查链路同时业务里把采集点埋好当第三方方案的短板开始制约你的时候再逐步将核心部分过渡到自研。我自己最后的落地方案是混合模式错误监控用了一套轻量自研上报性能数据和业务行为数据走自定义埋点展示端用Grafana搭了一套看板。考虑到团队规模和维护成本这个组合的性价比比较高。决策框架其实很简单如果你的核心诉求是快速止血选第三方如果你的核心诉求是深度融合业务指标且有一定人力储备自研最理想的是两个都要但分阶段推进。4. 错误监控实现从window.onerror到完整的上报链路这一节我们落点代码。前端错误监控的采集原理并不复杂但里面有不少细节处理不好就会漏报或者误报。4.1 捕获JS运行时错误最基础的入口是window.onerror它能捕获常规的JS运行时错误。注意这个回调的签名比较老旧要用arguments或者按参数位一个个接window.onerror function (message, source, lineno, colno, error) { const detail { type: js_error, message: message, source: source, lineno: lineno, colno: colno, stack: error ? error.stack : , url: location.href, userAgent: navigator.userAgent, timestamp: Date.now() }; report(detail); return true; // 防止默认的浏览器控制台提示影响用户 };关键点是必须携带完整堆栈。只有message没有stack的报错几乎无法定位问题。但生产环境的代码往往是压缩混淆过的堆栈根本看不了所以还需要配合SourceMap在上报服务端做一次堆栈还原。4.2 捕获Promise未处理异常单靠window.onerror是不够的。Promise里抛出的异常如果不被catch会走向unhandledrejection事件而这个事件不会触发window.onerror。这是前端监控里非常容易漏报的一块。window.addEventListener(unhandledrejection, function (event) { let reason event.reason; let message Unknown promise rejection; let stack ; if (typeof reason string) { message reason; } else if (reason instanceof Error) { message reason.message; stack reason.stack; } report({ type: unhandledrejection, message: message, stack: stack, url: location.href, timestamp: Date.now() }); });实测经验unhandledrejection里的event.reason经常不是Error对象而是一个普通的对象或者字符串。所以必须做类型判断否则你存进去的message永远是[object Object]排错根本没法看。4.3 资源加载错误脚本、图片、样式、字体加载失败也不会进入window.onerror。需要用addEventListener监听error事件并且在捕获阶段处理window.addEventListener(error, function (event) { const target event.target; // 判断是不是资源加载错误区别于 window.onerror 的 js error if (target target ! window target.nodeType 1) { const tagName target.tagName; const src target.src || target.href || ; report({ type: resource_error, tagName: tagName, sourceUrl: src, // 记录当前页面的基线地址方便判断是不是CDN资源问题 pageUrl: location.href, timestamp: Date.now() }); } }, true);判断逻辑里有个小技巧window.onerror也会触发window上的error事件所以这里要用target ! window过滤掉JS运行时错误避免重复上报。4.4 何时上报阈值与去重策略错误监控最怕的是上报风暴某个错误在循环里频繁触发一秒发几千条后端瞬间被打爆而且同一种错误重复一万条对排查毫无帮助。我在项目里做了两层处理本地去重以错误类型错误堆栈前几帧页面URL为key在一个内存Map里做计数同一错误在30秒内最多上报一次计数可以放在上报数据里用于判断影响程度。全局限流单页面同一时间窗口内最多上报一定数量的错误比如10条/秒超过直接丢弃大不了少一些明细不能因为监控本身拖垮业务。代码思路const errorMap new Map(); function shouldReport(key) { const now Date.now(); const last errorMap.get(key); if (!last || now - last 30000) { errorMap.set(key, now); return true; } return false; }4.5 上报通道sendBeacon优先级最高错误上报的通道选择我踩过不少坑。用fetch或者XMLHttpRequest在页面卸载的时候几乎都会丢请求——浏览器根本不给你把异步请求发出去的时间。后来换成navigator.sendBeacon这个问题才算解决。function report(data) { try { const payload JSON.stringify({ ...data, appId: your_app_id, env: production }); if (navigator.sendBeacon) { navigator.sendBeacon(/monitor/report, new Blob([payload], { type: application/json })); } else { // 降级方案用1x1像素图片 const img new Image(); img.src /monitor/report?data encodeURIComponent(payload); } } catch (e) { // 上报本身不能再抛异常否则会影响业务 } }有两个细节值得注意。第一sendBeacon对请求体大小有限制通常建议单条数据控制在64KB以内我们的错误上报一般不会超。第二用sendBeacon时Content-Type不好控制用Blob包一层能稳定发送application/json后端解析更方便。5. 性能监控首屏时间不能只看DOMContentLoaded性能监控比错误监控复杂得多核心原因是指标定义太容易起分歧。你跟后端说页面很慢后端说首屏接口300ms就返回了产品说用户反馈要转4秒其实三个人说的可能是三个完全不同的阶段。5.1 指标选型以Core Web Vitals为主现在业界的主流共识是围绕Google提出的Core Web Vitals来做性能监控指标含义明确而且直接关联用户体验LCPLargest Contentful Paint最大内容绘制时间反映页面主体内容加载完成的速度。FID/INPFirst Input Delay / Interaction to Next Paint用户交互的响应延迟。CLSCumulative Layout Shift页面布局的稳定性抖动有多严重。这三个指标加上传统的FCP和TTFB基本能覆盖页面加载慢不慢、操作卡不卡、布局稳不稳三个体验维度。5.2 采集方式PerformanceObserver优于getEntries早期很多实现是跑到一个固定时间点调用performance.getEntriesByType(paint)去捞数据。但问题在于如果LCP在你轮询之前已经发生并过去了你能拿到的只是当时的值而不是浏览器计算出的最终值。更推荐用PerformanceObserver它能在指标产生的第一时间回调const lcpEntries []; function observeLCP() { if (!PerformanceObserver) return; const observer new PerformanceObserver((list) { const entries list.getEntries(); const lastEntry entries[entries.length - 1]; lcpEntries.push(lastEntry); // LCP可能被修正最后一次回调才是稳定值 report({ type: performance, metric: LCP, value: lastEntry.startTime, url: location.href }); }); observer.observe({ type: largest-contentful-paint, buffered: true }); }5.3 LCP上报时机的一个坑LCP是一个会修正的指标。页面开始加载时LCP可能指向某个图片后续又有更大的图片渲染出来浏览器会更新LCP值。所以一旦观察到LCP值就立刻上报往往上报的只是临时值。一个保守的做法是页面visibilitychange到隐藏状态时再把最终的LCP上报出去。如果是SPA应用路由切换的时候也要重新定义页面加载开始的起点否则一次会话内多条路由记录全都挂在第一个文档的LCP上数据就会失真。document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { const finalLCP lcpEntries[lcpEntries.length - 1]; if (finalLCP) { report({ type: performance, metric: LCP_final, value: finalLCP.startTime }); } } });5.4 自定义首屏指标不能只看浏览器给的Core Web Vitals适合做通用监控但业务上经常还需要自定义指标比如用户看到商品列表的第一屏是什么时候。这类指标浏览器给不了得自己在业务代码里埋点。比较常用的方案是requestAnimationFrame配合MutationObserver观察首屏容器内的元素变化算出一个业务自定义首屏时间。这个方案有个前提首屏容器必须是确定的DOM结构否则无法观察。我的建议是自定义首屏指标不要过度设计。首屏最关键的是用户能看到主要内容如果页面结构简单可以直接在核心数据渲染完毕后手动performance.mark再通过PerformanceObserver(mark)取出来上报。比MutationObserver方案简单一个数量级也更容易维护。6. 用户行为追踪把复现不了变成还原现场错误和性能数据都拿到了但还有一个经常被低估的问题怎么复现。前端问题的复现率本来就不高如果没有用户当时的操作上下文拿到一个堆栈信息往往也只能干瞪眼。用户行为追踪要解决的就是这个问题。6.1 设计一个精简的行为栈类似日志系统里的breadcrumb面包屑前端监控也可以维护一个行为栈按时间顺序记录用户在当前页面的关键动作。我在项目里把行为类型分成三类行为类型采集时机记录内容路由变更hashchange / History API从哪个路由切换到哪个路由用户点击click事件捕获阶段点击元素的标签、类名、文本内容、所在路径接口请求拦截fetch/xhr请求URL、方法、耗时、状态码、业务码行为栈容量建议限制在20~30条以内先进先出。太多了不仅上报数据量大排查的时候反而被噪音淹没。const breadcrumbs []; const MAX_BREADCRUMBS 30; function pushBreadcrumb(type, data) { breadcrumbs.push({ type: type, data: data, time: Date.now() }); if (breadcrumbs.length MAX_BREADCRUMBS) { breadcrumbs.shift(); } }6.2 路由切换的监听细节SPA应用里Hash模式可以直接监听hashchange事件但History模式需要包装pushState和replaceState。function patchHistoryMethod(methodName) { const original history[methodName]; return function (...args) { const result original.apply(this, args); pushBreadcrumb(route_change, { from: location.href, to: args[2] ? new URL(args[2], location.href).href : location.href }); return result; }; } history.pushState patchHistoryMethod(pushState); history.replaceState patchHistoryMethod(replaceState);注意包装之后要再加一层popstate监听否则用户点浏览器前进后退按钮时历史记录变了但回调不触发。6.3 会话串联跨页面还原完整路径单个页面内的行为栈只能还原局部用户可能是首页进入 - 搜索 - 进详情页 - 加入购物车 - 提交订单这一整条链路。为了还原完整路径需要一个会话ID贯穿所有页面。做法是在页面初始化时检查sessionStorage里有没有会话ID没有则生成一个新的UUID存进去。所有上报数据包里都带上会话ID和后端生成的服务端时间戳这样就能在查询时把用户一次性会话的所有行为、错误、性能数据串起来。function getSessionId() { let sid sessionStorage.getItem(monitor_sid); if (!sid) { sid s_ Date.now().toString(36) Math.random().toString(36).slice(2); sessionStorage.setItem(monitor_sid, sid); } return sid; }6.4 隐私与脱敏踩过的合规坑行为追踪涉及用户操作数据合规是躲不开的问题。有个真实的教训我们曾经记录了用户点击元素的textContent结果把用户输入在输入框里的手机号也当成文本采上来了虽然量不大但合规排查的时候非常被动。后来我们做了严格的过滤凡是input、textarea、password类型元素的内容一律不记录其他元素的文本内容也做截断处理最多保留20个字符。另外上报数据里禁止出现URL携带的query参数因为很多系统会把token、sessionID拼在URL里。这条原则值得每个团队刻在脑子里不能为了排查便利把用户隐私变成自己数据库里的明文。7. 告警与排错实战从收到报警到定位根因监控做到能看数据只是第一步真正的价值在告警和排错链路。一个设计不好的告警体系要么无声无息要么天天轰炸到最后没人看。7.1 告警阈值怎么定才不变成狼来了告警阈值没有万能公式但有几个通用的设计原则错误率优先于错误量。错误量会随着访问量波动大促期间量涨了不代表质量下降。用错误率错误用户数/活跃用户数做阈值更能反映真实质量。分级告警。P0级全站大面积错误通过电话/短信通知P1级核心页面错误率超过阈值通过企业微信/钉钉机器人通知P2级一般页面轻微波动只进看板不发通知。连续N个周期才告警。单次抖动可能只是网络瞬时问题连续3个5分钟周期都超阈值再告警能过滤掉大量瞬时噪声。我们当时的告警规则大概是这样的级别条件通知方式P0核心页面JS错误率 5%持续10分钟电话短信P1核心页面JS错误率 2%持续10分钟企业微信机器人P2任意页面错误率 3%持续30分钟进入看板日汇总7.2 一个真实的排错案例接口成功率突降有一次周四下午告警群里突然弹出P1结算页的某个查询接口成功率从99.9%掉到75%。当时第一反应是后端挂了但后端同学查完监控接口的机器负载一切正常。这时候前端监控里的上下文信息就发挥作用了行为栈显示失败请求都集中在一个特定路由来源错误堆栈指向一个JS异常发生在请求拦截器里——某个字段从undefined变成了null会话ID列表里能看到发生异常的用户设备集中在某些安卓机型。最终定位到前一天晚上上线的新版本在请求拦截器里对某个参数做了trim()处理但新版数据结构里这个字段在特定场景下是null旧版本不会。因为是渐进式发版一部分用户走到了新逻辑一部分没有所以成功率掉到75%而不是瞬间归零。这种案例特别典型——问题不是出在后端也不是简单的JS报错而是数据链路的前后端字段约定被悄悄改坏了。如果没有行为轨迹和接口参数的联动这个Bug排查起来可能要按天计算。7.3 前端监控的边界不是所有问题都能靠前端解决做监控越久越要承认一个事实前端监控能发现问题但不少问题根因不在前端。用户网络波动导致的慢请求前端监控能看出来但修不了网络CDN节点故障导致资源加载超时前端能感知但决定权在运维后端接口响应慢但错误率不高前端能看到链路耗时但优化要后端来做。所以前端监控体系里一定要有跨端数据关联的意识。上报时可以带上灰度标签、版本号、地域、运营商如果拿得到这些维度拿到后端或者运维侧对账才能把责任定位得准。关于监控体系的最后一句话踩过这么多坑之后最真实的体会是前端监控的难点从来不在技术实现而在你怎么定义问题。错误率多高算严重首屏多慢算慢哪些行为值得记录这些判断直接决定监控体系对你有没有用。代码本身不难写难的是你愿不愿意在业务平静期投入精力去打磨这些细节等到故障真正发生的那一刻你才会发现这些积累值回票价。如果你现在还没有任何监控我的建议很简单先把你网站上的window.onerror和unhandledrejection接起来用一天时间把错误数据落库再配一个最简单的告警。你会惊喜地发现你的网站问题比你想象中要多得多——而且大多数都是你完全没想到过的。