前阵子内部在梳理前端监控这块的痛点时,我们顺手把积压了大半年的需求清单拉了出来,发现排在最前面的几项全是重复问题:页面白屏了要等用户反馈才知道、接口慢到 3 秒用户都懒得吐槽、版本上线后 JS 报错量猛增却要等运维手工捞日志。现有的开源监控方案不是不能装,而是接入流程太冗长、数据归属不清、定制成本高,团队几个人调研了两周,最终决定自己做一个轻量级内部分析工具。这个项目内部代号就叫"rea",英文全称是Real Experience Analytics,目标很简单:不追求功能大而全,只解决"页面到底卡在哪、用户到底遇到什么错误、版本发布后体验有没有退化"这三件事。整个开发从立项到第一版可用,前后花了大概一个半月,这期记录一下踩过的坑和最终沉淀下来的方案,给准备自建前端监控的朋友做个参考。
如果你所在的团队恰好也处于"开源工具用起来别扭、想自己撸一个又担心工期"的状态,这篇东西尤其适合你。下文不会再铺开讲怎么搭 UI 框架、怎么写后端接口,而是聚焦在监控系统最容易翻车的几个环节:采集层数据怎么定义、传输链路怎么设计、告警规则怎么定才不变成狼来了,以及从测试环境到线上过程中那些容易被忽略的偏差。
1. 需求边界:先想清楚"不做什么",再做技术选型
很多自研项目死在第一步,不是因为技术难度,而是因为需求边界没划清楚。设计阶段团队开了三次会,每次都有人提出"顺便把用户行为录屏也做了""这个也能当 A/B 实验平台用吧",被我拉住了。最终大家在文档里明确写下了 rea 的定位:只做前端运行时状态的量化观测,不做行为分析、不取代后端日志系统、不做会话录屏。理由非常简单,行为分析和录屏是完全不同的数据体系和合规要求,硬塞进来只会拖慢第一版上线。
1.1 三个核心场景的优先级排序
第一版只服务三个场景,按优先级从高到低排:
- 页面可用性监控:白屏、JS 报错、资源加载失败,属于"坏了要第一时间知道"的问题。
- 性能体验量化:首屏耗时、接口调用耗时、长任务执行耗时,属于"没坏但用户已经不爽了"的问题。
- 发布回溯对比:新版本上线后快速评估体验指标有没有退化,属于"变更风险控制"的问题。
这三个场景看起来简单,但一旦落实到数据字段层面,牵扯出的细节非常多。例如"白屏"怎么定义,是根节点没有子节点就算,还是首屏区核心元素缺失才算?"首屏耗时"是从导航开始到哪个时间点,是 FCP 还是可交互时间?这些必须在埋点设计阶段定下来,不然后端和前端对着一份字段文档能吵翻天。
1.2 技术选型的几个硬性约束
选型阶段列了几条硬约束,所有方案都必须满足:
- 首屏采集代码必须内联且控制在 5KB 以内,不能因为监控工具拖慢页面本身。
- 上报不能阻塞主流程,网络不可用或接口变慢时不能影响业务。
- 老版本浏览器要能降级,不求数据完整,但求不能用不了。
- 数据字段需要版本化,后续迭代时不能被旧数据结构锁住。
前两条直接决定了我们用Web Worker 做数据采集和打包,用sendBeacon 做可靠性上报,不复用业务侧的网络请求池。第三、四条则决定采集器要做成独立的纯函数模块,不依赖运行时框架,这块细节后面展开。
第一版技术栈选得很克制,前端 SDK 用 TypeScript 写成零依赖的 UMD 包,体积压缩后约 4.2KB,gzip 后不到 2KB;数据接收端直接用现成的网关服务,先不做自建存储,而是把结构化数据原样转发到内部的时序数据库。后端只保留一个极薄的接收鉴权层和过滤层,把"接入脏数据"的成本挡在最前面。网关阶段就做一层数据清洗,比如过滤掉开发环境 URL、校验字段类型、丢弃超长字段,这些问题前置得越早,后续分析时越省力。
2. 采集层设计:性能数据的定义与取舍
监控系统的地基是数据定义。数据定义一旦含糊,后面做图表、告警、回归分析都会处处碰壁。这一章详细说下 rea 是怎么定义字段和取舍指标的,这也是开发中迭代次数最多的部分。
2.1 Performance API 的选择:兼容性与精度的折中
浏览器提供了一套性能相关的接口,但不同接口的兼容性和精度差异很大。我们最初打算直接用PerformanceNavigationTiming,因为它提供了domInteractive、domComplete、loadEventEnd等一系列时间点,字段非常规整。但实际测下来发现,部分 WebView 环境对这个 API 支持不完整,读取时经常拿到全 0 的时间戳。为了兼容老环境,最终采用的是降级策略:
function getNavTiming() { if (typeof PerformanceNavigationTiming !== 'undefined') { try { const entries = performance.getEntriesByType('navigation'); if (entries.length > 0) return entries[0].toJSON(); } catch (e) { /* 降级到 PerformanceTiming */ } } if (typeof PerformanceTiming !== 'undefined') { return { navigationStart: performance.timing.navigationStart, domContentLoadedEventEnd: performance.timing.domContentLoadedEventEnd, loadEventEnd: performance.timing.loadEventEnd, // 这里只取核心字段,避免把整份 timing 对象序列化 }; } return null; }这段代码看起来简单,但里面有个容易忽略的坑:toJSON()在不同浏览器里返回的字段数量不一样,有的版本会带serverTiming,有的不带。直接整包上报会导致后端表结构对不齐,所以规范化必须在 SDK 内做,只挑我们自己约定的核心字段,宁可放弃额外数据,也不能让后端解析出差异。
2.2 首屏指标:不用标准 API 的考量
前端圈比较流行用 LCP(Largest Contentful Paint)当首屏核心指标,rea 最终却没有直接用 LCP 作为首屏耗时阈值,原因是 LCP 在部分场景下会失真。举个例子,某个页面首屏是一个大 Banner 轮播图,如果图片 lazy-load 被设计成滚动后才加载,LCP 值看起来正常,但用户第一眼看到的是空白背景和加载中的占位框。换句话说,LCP 衡量的是"最大元素渲染时间",不是"用户真正关心的首屏是否可用"。
rea 的处理方法是:以 FCP 为基础,同时额外采集一个自定义指标——首屏主图加载完成时间。方案是给首屏中标记了>const resourceSummary = { type: 'script', host: 'cdn.internal.example.com', p50: 128, // 毫秒 p95: 342, failCount: 1, totalCount: 18 };
这样一条聚合记录就能描述一批资源的大致状况,查询页面加载具体卡在哪类资源时也够用了。唯一需要保留单独记录的是失败资源,因为失败信息的排查价值更高,丢掉就找不回来了。
2.4 接口耗时采集:包一层 fetch 而不是监听 PerformanceObserver
监控接口耗时有很多种做法,最省事的方案是监听PerformanceObserver里的resource条目,按initiatorType === 'fetch'过滤。但实际用下来发现这招有两个问题:一是部分老环境下PerformanceObserver本身不支持;二是它只能拿到耗时和大小,拿不到接口的语义信息——比如业务方想区分是"下单接口"还是"详情接口",靠 URL 正则猜测很不稳定。
rea 选择了更"笨"的采集方式:在 SDK 初始化时包装全局的window.fetch和XMLHttpRequest.prototype.open方法。包装代码量不大,但能精准拿到业务方自己传的标记:
const originalFetch = window.fetch; window.fetch = function (input, init) { const start = Date.now(); const reqMeta = { url: typeof input === 'string' ? input : input.url, method: (init && init.method) || 'GET', }; return originalFetch.apply(this, arguments) .finally(() => { const duration = Date.now() - start; reportIfNeeded(reqMeta, duration); }); };长任务(long task)的采集方式相对简单,用PerformanceObserver监听longtask条目即可,这部分不需要包装任何函数。但要注意的是,long task 的name属性可能暴露脚本 URL,这在后面做隐私数据清洗时需要处理,不能直接把原始条目序列化上报。
3. Worker 传输链路与数据帧格式:防止监控本身拖慢业务
监控系统的反讽之处在于,它自己可能成为性能问题的一部分。如果埋点代码在主线程上跑复杂逻辑、拼接大字符串、甚至同步发请求,那页面的体验就被监控工具拖累了。rea 在传输链路设计上比较早决定使用Web Worker + 批量上报的组合,避免监控逻辑占用主线程。
3.1 主线程与 Worker 的分工
采集代码在页面主线程上只做两件事:被动监听浏览器事件、把原始事件对象的最小组件传给 Worker。比如捕获到一个Error对象,主线程只提取message和stack两个字符串字段,不做任何分析处理,然后通过结构化克隆丢给 Worker。所有后续的采样、聚合、缓存、压缩、发送,都在 Worker 内部执行。
用 Worker 有几个隐性的好处:即使上报接口本身因为网络波动变慢,处理队列堆积,阻塞的也只是 Worker 里的队列,不会撑爆主线程的任务循环。另一个隐性好处是 JavaScript 错误的捕获可以放在 Worker 里独立做try/catch,避免 SDK 内部异常污染业务运行环境。这一点在调试时非常有价值,业务方不会因为监控代码自身的 bug 而页面白屏。
3.2 数据帧格式:JSON 与二进制之间的中间态
数据上报的格式有很多选择,业界常见的做法是直接 JSON + gzip 压缩,简单通用,rea 第一版也这么干了。不过把数据从 Worker 传给主线程再发送时,我们做了一层额外的轻量编码,叫"紧凑帧格式"。简单来说,就是把字段名和值交替拼成指定顺序的字符串数组,避免每个字段重复携带冗长的 key 名。
// 普通 JSON 模式,适合调试期 { "type": "perf", "ts": 1735689600000, "page": "/home", "metric": "fcp", "val": 820 } // 紧凑帧模式,适合生产批量上报 ["perf", 1735689600000, "/home", "fcp", 820]紧凑帧在数据量大了以后能省 40% 左右的传输字节,代价是后端必须按固定的字段顺序解析。因此 rea 为每种数据类型定义了独立协议号,未来加字段时递增协议版本号,旧版本数据照常解析。这个设计在后端存储层上体现为一张协议版本表,算是自研监控和老开源方案相比最灵活的点。
3.3 采样、缓存与离线补送
上报节奏上面,我们单独设计了一套"采样 + 双级缓冲"策略。首屏必须全量采集,因为页面生命周期里首屏数据只有一次;接口、资源、JS 错误则按采样率控制,默认线上为 30%,开发环境通过 URL 参数强制全量。采样不是简单丢数据,而是随机抽取会话,同一个会话内的所有事件要么全收要么全不收,这样分析时才能保证会话维度上的数据一致性。
Worker 内部维护两层缓冲:第一层是内存缓冲,攒够 10 条数据或 3 秒超时,就触发一次批量发送尝试;第二层是localStorage离线通道,用于网络不可用时的临时缓存。离线通道容量限制为 100KB,超过后丢弃最早的未上报数据,避免把 localStorage 写爆。这两层缓冲的设计让上报链路即使遇到弱网环境,也不会丢失首屏那类关键数据。
3.4 上报通道:sendBeacon 优先,降级用 image 打点
上报实现的坑比想象中多,这里直接给出结论:能走navigator.sendBeacon就走它。原因是sendBeacon不受页面卸载影响,浏览器会在后台自动处理发送,不会因为用户直接关闭页面就丢掉尾部数据。部分低版本浏览器不支持 sendBeacon,这时降级方案是生成一个new Image()并设置其src指向接收网关。image 打点的缺点是 URL 长度有限制,所以紧凑帧格式在 image 降级路径下会被再次压缩,只保留最重要的指标字段。
接收网关那边遇到过一个问题:预检请求导致跨域请求变慢。原因是数据接收端口和业务端口不同,浏览器会自动发起OPTIONS预检,每次预检本身就是一个额外请求。解决方案是在服务端允许上报请求免预检,把上报端点的 CORS 头部配置为直接允许指定来源,而不是用通配符 + 自定义头组合。这个细节在压测时发现,优化后上报接口的平均耗时从 87ms 降到了 23ms。
4. 告警引擎:阈值不固化,用动态基线替代静态规则
监控系统如果只做数据采集和展示,价值会打对折。真正让团队愿意每天打开 rea 面板的,是它能在体验出问题的时候主动发出告警。但告警规则设计是个极度容易失衡的事。阈值定得过紧,告警满天飞,一周后大家全部把通知静音;阈值定得过松,告警失去意义,变成"又狼来了"。
4.1 第一版告警规则的失败经历
第一版告警规则是经典的固定阈值方案:JS 错误率超过 1% 就告警、接口 P95 超过 2 秒就告警、白屏率超过 0.5% 就告警。上线第一天就告警了 200 多次,几乎把值班群打爆。事后分析原因,不是规则逻辑写错了,而是业务场景差异太大。一个埋点完善的核心交易页和一个小流量活动的落地页共享同一套固定阈值,活动页本来样本就少,偶尔几条慢请求就能让 P95 飙到 3 秒,连续触发告警,但这并不代表线上有真实问题。
4.2 基于历史基线的动态阈值
第二版改成动态基线方案,这也是 rea 核心算法中最值得讲的一块。思路是:不手工规定"多慢算慢",而是根据过去 7 天同一时段的数据,自动计算当前指标的基准值和浮动范围。
算法上用的是简单移动平均加标准差,没有上复杂的时序预测模型,原因有两个:第一,第一版没有足够的历史数据训练复杂模型;第二,移动平均便于解释,团队成员看告警消息时能快速理解为什么触发。
import numpy as np def compute_threshold(history): # history 是过去 7 天同一小时窗口内的指标列表 mu = np.mean(history) sigma = np.std(history) # 上界取均值 + 3 倍标准差 return mu + 3 * sigma实际运行一段时间后,对波动较大的指标,用 3 倍标准差仍然过于敏感,于是额外加了最小浮动区间的保护逻辑:阈值不能低于历史均值的 1.2 倍,避免业务低峰期均值和方差都极小时,微小的绝对波动就触发告警。
4.3 告警通知的收敛与分级
动态基线解决了"过度告警"问题,但引入了另一个麻烦——某些指标是缓慢恶化的,基线跟着缓慢抬高,等到触发告警时,实际体验已经持续变差好几天了。为了应对这类渐变劣化,rea 增加了两个辅助信号源:版本发布事件和异常突变检测。异常突变检测用 EWMA(指数加权移动平均)实时计算当前值和基线的偏离程度,偏离超过 4 倍标准差就直接触发,不等待窗口聚合结束。
告警通知分级也做了收敛:
- P0 级:全页面白屏率超过基线 5 倍,或支付类核心接口错误率超过 2%,立即电话/短信。
- P1 级:JS 错误率超过基线 3 倍,或主要页面 P95 指标超过基线 1.5 倍,钉钉/企微群告警。
- P2 级:单一区域或低流量页面的性能劣化,仅记录到日报,不实时通知。
同时配置了告警静默期,同一指标的 P1 告警触发后 30 分钟内不重复发送。这套收敛机制运行两周后,告警量从每天 200 多条下降到每天 3~5 条,并且告警的准确率显著提升。
4.4 按页面聚合而不是按全站聚合
动态基线在具体落地时还有一个关键设计:基线按"页面 x 平台 x 版本线"聚合,而不是按全站聚合。同一个页面在首屏展示场景和站内跳转场景的耗时基线可以差出一倍,如果把不同场景的数据混在一起算基线,阈值会被平均化,丧失了敏感度。这一点在分域部署的项目里尤其重要,域名一变,性能特征可能完全不一样。
对了,基线计算的数据还需要定期清理,不能让一周前的发布事故的数据一直留在基线里。rea 的做法是:每天凌晨定时任务自动剔除异常日的数据,只保留正常时段的样本参与计算。这个细节做不做,直接决定了基线的鲁棒性高低。
5. 踩坑记录:从测试环境到线上,三处让数据失真的偏差
再完美的方案,不经过线上真实流量检验都会暴露出问题。rea 开发过程中有几处偏差调试了很久,专门分享一下排查思路,比直接给结论更有参考价值。
5.1 SPA 路由切换时的性能条目丢失
第一版上线后,我们发现站内点击跳转产生的性能数据占比低得反常,排查很久才发现问题出在对 SPA 路由变化的处理上。PerformanceNavigationTiming只在页面整帧加载时产生,SPA 通过 history API 切换路由后,浏览器并不产生新的 navigation 条目。因此我们需要监听路由变化事件,在路由切换时手动标记里程碑时间。
修正方案是在 SDK 内暴露rea.mark(name)方法,业务方在使用内部路由库的afterEach钩子里手动调用,标记业务层面的路由切换开始点。如果没有手动标记,则 SDK 只能靠popstate事件兜底,但popstate捕获不到pushState触发的路由变化。这个问题排查了一整天,最终是在对比 click 流日志和性能数据时发现的。
5.2 采样率导致首屏数据严重缩小
第二处偏差来自采样设计时的疏忽。前面说了接口和资源数据按 30% 采样,首屏数据全量采集。但代码实现时,采样逻辑被放在 Worker 的入口处统一执行了,导致首屏数据也被 30% 采样过滤,白屏检测和首屏耗时指标的数据量少到无法支撑告警基线计算。用大白话说,这就是"全量采集"和"统一入口采样"自相矛盾。
修复很简单,把数据类型判断提前到采样逻辑之前,但排查过程很有代表性:不是功能不工作,而是功能在工作但数据量明显偏小。这种"逻辑正确但意图违背"的 bug,靠看代码不容易发现,最好用数据量的日环比做监控。因此 rea 内部后来加了一条规则:核心指标数据量连续两天环比下降超过 50%,自动在巡检日报里标记。
5.3 用户本地时钟不准导致耗时偏差
这也是个极易翻车的坑。我们在计算接口耗时、首屏耗时的时候,默认拿Date.now()作为结束时间。原本假设Date.now()是相对可靠的单调递增时钟,但实际线上发现,部分用户手机开启了自动校时,在页面加载过程中系统时间被向后调整,导致计算出的耗时出现负值。
解决方案是改用performance.now()作为一切时间差的基准。performance.now()是相对页面导航开始时间的单调时钟,不受系统时间调整影响,这才彻底解决了耗时负值和跳变的问题。这里有一个重要提示:凡是计算时长数据,一律用performance.now();凡是记录发生时刻,用Date.now(),两者不能混用。这个原则写进了团队内部的埋点规范文档。
5.4 数据协议版本化的重要性
前面几处偏差调试过程中,我们多次改了字段结构,但旧的采集器代码还部署在老页面上,新老数据结构混在一起,后端解析经常报错。最终下定决心,在接收网关上增加了协议号强制校验:上报数据必须携带pver字段,服务端只认当前版本和上两个版本,更老的版本返回 4xx 并提示升级。
协议版本化的具体字段定义是:
{ pver: 3, type: 'perf', data: ["/home", "fcp", 820] }后端解析时先看pver,再按对应版本的解析器解码。这个过程刚开始会给开发效率带来一些拖累,每改动一次字段就要升一个版本号,但长远看是避免线上脏数据的关键保障。一旦数据进入存储层才想清洗,成本高十倍还不止。
6. rea 的后续扩展空间与维护体会
第一版跑通后,我们开始规划第二阶段的功能,方向比较明确:从"性能监控"走向"体验评估"。比如首屏指标已经采集了 FCP、主图加载时间、白屏检测数据,理论上可以做一个人工打分模型,把用户实际体验映射成 0 到 100 的体验分。这个比单独看某个指标更直观,产品经理也能直接用。
另一个扩展方向是和发布流程集成。现有做法是版本上线后被动等待数据回流,如果主动在发布单里指定本次变更涉及的核心页面,发布后半小时内就能自动对比核心页面的性能指标走势并生成报告,出现显著劣化时自动阻断后续的灰度扩量。这个功能价值很大,但依赖前期数据的准确积累,急不来。
维护一个内部监控工具,最大的体会是:技术选型再正确,都不如数据可信重要。一旦业务方发现某个数字忽高忽低、和自己实测对不上,整个工具的公信力就崩塌了。所以 rea 后来所有上线功能都有一个强制要求——必须附带一个离线校验脚本,输入一份已知的构造数据,输出必须和预期完全一致,否则不允许合并发布。
监控系统没有一个"做完"的时刻,它更像一个持续维护的基础设施。从第一版到现在,我们平均每周会收到一两处数据采集的反馈,要么是某类环境不支持某个 API,要么是某个字段的定义有歧义。处理这些反馈的速度,某种程度上比最初的功能开发速度更重要。对一个内部工具来说,有人愿意主动反馈问题,恰恰说明它已经真正用起来了。