我做客户端错误上报的原因很直接:我想知道真实用户遇到了哪些问题,尤其是那些在本地很难稳定复现的问题。上报器也确实完成了我交给它的任务:捕获错误,然后发给我。
问题在于,它让几乎所有事件看起来都同样严重。
第三方分析脚本没加载成功?红色告警。广告脚本被拦截?红色告警。爬虫无法加载 Google Analytics?红色告警。视频预览调用了 play(),但 Promise 还没结束就被暂停?红色告警。收到一个既没有有效来源也没有调用栈的 Script error.?还是红色告警。
而同一条事件流里,也混着真正值得处理的故障:我自己的 URL 被错误拼成了 https://example.comhttps://example.com/...,以及浏览器无法加载 /_next/static/chunks/... 下属于应用自身的 Next.js 文件。
数据收集是正常的。监控却不是。
这个区别改变了我对前端可观测性的理解。一个错误事件只能说明“发生了某件事”。它还不是诊断,不是严重级别,更不是一次事故。
第一个设计错误,是把“error”直接等同于“紧急”
我最初的模型几乎是这样的:
浏览器报告 error
↓
发送 CLIENT ERROR
↓
开发者需要处理
但这里其实混在了一起很多不同的问题:这是我的代码吗?当前页面真的坏了吗?这是预期中的取消操作吗?浏览器有没有足够的信息识别来源?应用是否已经恢复?十条消息代表十次事故,还是一次事故产生了十个症状?
这些问题没有答案之前,一个事件不应该自动升级为告警。
一次早期排查中,我面前大约有18条消息。大多数只是第三方故障或正常生命周期产生的噪声。只有两个案例明显不同:错误拼接的第一方 URL 是确定的缺陷;第一方 JavaScript 分块文件加载失败则可能让页面拿不到必要代码。然而当时的上报器给它们的紧急程度,几乎和广告被拦截一样。
从这里开始,我不再把“收集所有浏览器错误”和“构建生产监控”视为同一件事。采集层负责保存证据,监控层负责把证据压缩成可执行的判断。
浏览器并不存在一个能统一表达所有故障的错误通道
不同类型的客户端故障,本来就带着不同的语义。
window 上的 error 事件用于同步脚本错误,也会涉及资源加载失败。没有处理器的 Promise 拒绝会走另一条路径,由浏览器触发 unhandledrejection。加载脚本、图片或媒体的元素也可以触发自己的 error 事件。React 和 Next.js 还会在更高一层提供框架级的错误边界。
window.error
→ 可能有同步 script error 逃逸到全局
unhandledrejection
→ rejected Promise 在当时没有被处理
element error
→ resource 无法加载或使用
framework boundary
→ rendering 或 execution 到达了 error boundary
全局钩子看到的是系统边界上的症状,它通常并不知道完整的因果链。
接受这一点之后,我不再急着把所有事件都标准化成同一个通用 Error,再套上同一种严重级别。
第一个真正有用的过滤维度,是“这个失败属于谁”
降低噪声的第一步,是先确定失败的代码或资源到底属于谁。
/_next/static/chunks/app/... 加载失败,和另一个源上的广告 SDK 失败不是一回事。我的 URL 构造逻辑生成了错误地址,和分析请求被拦截也不是一回事。浏览器扩展造成的错误又属于另一类。
- 第一方应用:自己的 JavaScript、CSS、API、媒体资源和生成的 URL;
- 框架/运行时:处于应用执行链路中的 Next.js 或 React;
- 第三方集成:分析、广告、组件和外部 SDK;
- 运行环境:浏览器扩展、爬虫、网络状态、隐私工具以及特定浏览器的行为差异。
第三方并不等于不重要。支付或身份验证服务可能非常关键,广告故障也可能直接影响收入。但“第三方集成不健康”不应该自动等同于“应用崩溃”。
如果所有事情都进入同一个紧急告警通道,这个通道很快就失去意义。
两个第一方故障让我真正理解什么叫“可处理的信号”
错误 URL 是最简单的案例:
https://example.comhttps://example.com/resource
这里不需要猜 AdBlock、VPN 或浏览器策略。URL 本身就是无效的。我的代码在某处给一个已经是绝对地址的值又拼了一次源站。
这个事件可处理,是因为证据具体、资源属于我,而且问题明确指向我能控制的代码路径。
Next.js 分块文件加载失败则不同:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
它同样属于第一方,而且确实可能破坏页面。但这个事件本身不能证明原因。旧客户端可能请求了上一个部署版本的资源;请求可能超时;反向代理或 CDN 可能出错;连接可能中断;文件也可能真的不存在。
所以正确结论不是“我知道原因了”,而是“这个错误类别优先级很高,需要收集更多上下文”。
严重级别可以很高,同时对原因的确定性仍然很低。
Script error. 是线索,不是调用栈
Error: Script error.
filename: unknown
line: 0
column: 0
它看起来很严重,但几乎不给任何有效信息。
浏览器会有意限制跨源脚本错误的细节。MDN 说明,如果没有正确的 CORS 配置,window.onerror 只能得到有限信息;<script> 的 crossorigin 行为会直接影响能否获得完整错误信息。
因此,我不会把一个信息不透明的 Script error. 自动解释成“我的应用崩溃了”。来源可能是自己的代码、第三方代码、被注入的代码,也可能只是跨源限制让浏览器无法公开细节。
我会保留这个事件,并把它和页面、构建版本、浏览器以及相邻事件关联起来;但单独一个 0:0 不会触发紧急告警。如果同样的特征开始集中出现在某个发布版本或路由上,优先级才会上升。
“未知”不等于“无害”,也不等于“严重”。
AbortError 可以是真实错误,也可以只是正常生命周期的一部分
视频预览最清楚地展示了这一点:
AbortError:
The play() request was interrupted by a call to pause()
HTMLMediaElement.play() 会返回 Promise,而这个 Promise 可能被拒绝。媒体生命周期操作也可能主动中断尚未开始完成的播放;MDN 还明确记录了 load() 会用 AbortError 中止尚未完成的 play() Promise。
在预览网格里,即使产品没有任何可见故障,这种情况也很容易发生:元素进入视口,代码调用 play();用户继续滚动,元素离开视口;播放还没真正开始,代码就暂停或替换了媒体。
Promise 的拒绝是真实的,但用户侧的事故可能根本不存在。
正确的处理位置通常就在调用附近:区分预期取消和真正的播放失败。unhandledrejection 应该是安全网,而不是第一次理解组件正常生命周期的地方。
第三方故障需要自己的健康模型
早期日志里有大量分析和广告域名的加载失败。有些来自强调隐私保护的浏览器,有些来自爬虫。一个爬虫无法加载 Google Analytics,就是典型的“几乎没有紧急价值”的告警。
它能证明的只是一个网络请求失败了,几乎不能证明真人用户是否还能正常使用应用。
保存这个事件没有错。错的是把它和第一方 JavaScript 分块文件加载失败放进同一条事故流。
- 应用本身是否对用户失效?
- 外部集成是否健康?
被拦截的广告可以进入广告投放指标;分析脚本失败可以用于衡量分析覆盖率。如果核心功能并不依赖它们,就不该以“前端崩溃”的名义触发紧急告警。
分开以后,第三方问题反而更容易按服务商、浏览器和地区进行聚合。
navigator.onLine 提供的是上下文,不是连通性证明
我后来也记录浏览器是否认为自己在线。这个字段有用,但只能作为提示。
有些被归类为网络故障的事件,同时记录了:
Online: true
这并不矛盾。MDN 明确提醒,navigator.onLine 依赖浏览器和操作系统的启发式判断。设备可能连接着局域网,却仍然无法访问我的源站。VPN、防火墙、DNS 和局部网络故障还会让情况更复杂。
online === false
→ 环境问题的强 hint
online === true
→ 不能证明 origin 或 resource 实际可达
这个小区别可以防止监控系统把一个提示包装成确定的诊断。
一个根故障可以制造多个浏览器事件
采集信息变丰富以后,另一种噪声也出现了:一次事故可能产生很多条消息。
某个分块文件可能先触发 resource.error,模块加载器再抛出 ChunkLoadError,React 或 Next.js 随后进入错误边界,恢复逻辑又安排重新加载。如果每一层都独立告警,一次用户操作看起来就像多次线上故障。
五条消息在心理上很像五个受影响用户,但实际上可能只来自一个会话和一个资源。
仅按消息文本去重不够。需要做事故关联:
session
+ 短时间窗口
+ normalized error class
+ first-party resource
+ client build
+ route
原始事件仍然保留,但给人看的应该是信号最强的事故表示。如果错误边界已经包含第一方调用栈和精确的分块 URL,前面那个通用资源错误就没必要再发第二条紧急告警。
对事故报警,对事件留档。
错误周围的上下文后来比错误字符串本身更有价值
后期的遥测数据变得更加结构化:
clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack
对于媒体,我还会记录媒体错误码、能独立观察到时的 HTTP 状态、实际返回的 Content-Type,以及传输故障更像 HTTP 问题还是网络问题。
这些字段能回答单独一条异常字符串无法回答的问题:问题是否从某个构建版本开始?浏览器是否收到 HTTP 响应?Service Worker 是否控制着页面?恢复逻辑是否已经运行?多个事件是否指向同一资源?当前路由是否真的失效?
Resource Timing API 可以提供资源耗时、传输信息,以及在环境支持且允许的情况下提供响应状态。但它有边界:跨源计时数据受到限制;缓存资源可能出现 transferSize: 0;responseStatus 也不是所有浏览器都支持。因此 null 和 0 应当保留为有意义的状态,而不是被填成想当然的结论。
ChunkLoadError 是症状,不是 404 探测器
后来的一次事件明显改变了我解读分块加载故障的方式。
浏览器对一个 Next.js 布局分块报告了 ChunkLoadError,而增强后的遥测数据同时包含:
resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true
记录到的耗时大约是170秒。无论这次事件的准确根因是什么,这些数据已经足以否定一个过度简单的等式:
ChunkLoadError === server 返回 404
其他分块加载故障没有可观察到的状态。有些表现为超时,有些带有传输信息。错误类别相同,但周围的证据并不相同。
这一点在 Next.js 中尤其重要,因为 /_next/static/ 下的文件通常带有内容哈希,并按不可变资源处理。当前的 Next.js 自托管文档也说明了这类资源的长期缓存响应头。因此,ChunkLoadError 可能涉及部署版本不一致、旧客户端、网络传输、反向代理、CDN、缓存、Service Worker,也可能是真的缺少构建产物。
告警层不应该替调查人员编造原因。它应该保存足够的证据。
媒体错误从另一层给出了同样的教训
某些事件里,媒体元素报告:
MEDIA_ELEMENT_ERROR: Format error
只看这句话,很像是编解码器不兼容。
但对其中一些事件做额外的传输验证后发现:
HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http
浏览器请求的是视频,得到的却是包含 HTTP 错误的 HTML。媒体元素无法把 HTML 解码成视频,于是外层症状就变成了“Format error”。真正有用的诊断在传输层。
最先发现症状的那一层,不一定就是制造问题的那一层。
“编解码器故障”“网络中断”“缓存缺陷”“分块丢失”都是结论。遥测数据首先应该记录观察到的事实。
我用五个维度评估浏览器故障
1. 归属
是第一方应用、框架/运行时、第三方集成,还是运行环境?
2. 用户影响
当前路由、渲染、身份验证、聊天、结账或其他核心流程是否真的坏了?还是只失败了可选广告、分析、预加载或预览,而页面仍然可用?
3. 证据质量
有没有第一方调用栈、资源 URL、HTTP 状态、构建 ID 和组件调用栈?还是只有 0:0 的 Script error.?
4. 重复与扩散
只是一个会话,还是同一个特征在同一次发布之后跨用户、路由和浏览器出现?
5. 恢复情况
应用是否已经恢复?是否安排了分块重新加载?回退方案是否生效?用户是否仍然被卡住?
明确的 first-party
+ 高用户影响
+ 强 evidence
+ 多个 session
+ 无 recovery
= urgent incident
third-party
+ optional 功能
+ 弱 evidence
+ 单发
+ 用户未受影响
= metric 或低优先级
降噪时更应该谨慎,而不是简单删除
噪声太多时,很容易想写一大堆正则黑名单,把烦人的东西全部丢掉。但这会隐藏真实问题。
屏蔽所有 AbortError,可能会隐藏真正被中断的 API 请求。删除所有 Script error.,可能错过某个浏览器特有的错误聚集。忽略所有第三方故障,则可能掩盖支付、身份验证或同意管理服务商的严重问题。
ALERT
→ 需要人工处理的强 incident
RETAIN / AGGREGATE
→ 保留并统计;形成 cluster 后再 alert
METRIC / SAMPLE
→ 预期或低影响 noise;保留趋势与样本
目标是让监控更安静,而不是让监控失明。
好的分类器本质上是写进代码里的策略
下面的代码不是我的生产代码,只是一个精简示例,表达我希望一开始就采用的策略:
function classifyClientEvent(event) {
const owner = classifyOwner(event);
if (isExpectedMediaCancellation(event)) {
return { severity: "metric", reason: "expected-cancellation" };
}
if (owner === "first-party" && breaksActiveRoute(event)) {
return { severity: "alert", reason: "first-party-user-impact" };
}
if (isActiveFirstPartyChunkFailure(event)) {
return { severity: "alert", reason: "application-chunk" };
}
if (owner === "third-party") {
return { severity: "aggregate", reason: "integration-health" };
}
if (isOpaqueScriptError(event)) {
return { severity: "aggregate", reason: "insufficient-evidence" };
}
return { severity: "aggregate", reason: "needs-correlation" };
}
真正困难的是 breaksActiveRoute() 这类函数内部的判断:它需要路由上下文、资源归属、错误边界数据,有时还需要具体的产品知识。
指纹应该跟踪事故,而不是消息文本
按完整字符串去重并不好用。压缩后的栈偏移会随构建变化,分块哈希会变化,URL 里会有动态标识符,不同浏览器的措辞也不同。
{
errorClass,
normalizedFirstPartyResource,
routeFamily,
clientBuild,
sessionId,
shortTimeBucket
}
应用全局崩溃时,最上面的第一方栈帧可能更有用;分块加载故障时,规范化后的分块资源更有用;媒体传输故障时,HTTP 故障类别和媒体路由可能比浏览器最外层的错误消息更重要。
resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled
这样,一串相关事件就可以被视为一次带有四条观测记录的事故,而不是四次紧急故障。
紧急告警通道的职责应该窄得多
我会把即时告警留给这些情况:破坏当前路由的第一方运行时错误;确实影响交互的 React/Next.js 错误边界;应用当前需要的第一方 JavaScript 或 CSS 加载失败;跨多个会话或构建版本重复出现的 ChunkLoadError;让用户无法继续的核心 API/数据故障;以及生成了错误 URL 这类明确违反第一方约束的情况。
单次出现且信息不透明的 Script error.、恢复成功的第一方资源故障、根因层次尚未明确的媒体错误,以及需要先聚类观察的浏览器特有异常,都更适合先保留而不是立刻告警。
已知的广告或分析资源故障、预期中的媒体 AbortError、只出现在爬虫上的第三方故障、带有强烈离线提示的失败,以及不影响当前路由的可选推测性资源,通常进入指标或抽样诊断。
紧急告警应该代表可处理的用户影响,而不是浏览器抱怨的原始数量。
我更愿意测量事故,而不是一个总“错误计数”
- 每1,000个会话中的第一方事故数;
- 按构建 ID 统计受影响会话;
- 按路由统计错误边界事故;
- 按资源和部署版本统计分块加载故障;
- 按服务商统计第三方集成失败率;
- 记录预期取消的数量,这样突然增长时仍然能看见;
- 恢复成功率;
- 把独立事故数和原始事件数分开统计。
“发生了一条事件”很少是好的生产告警阈值。“新构建版本上的同一个第一方事故已经影响多个独立会话,而且恢复失败”才是更有用的信号。
客户端监控仍然不能单独证明根因
浏览器遥测有明确的边界。
拿不到 HTTP 状态,可能是 API 不支持、跨源限制、请求被取消,也可能只是其他可观测性缺口。Service Worker 处于活动状态,并不能证明旧资源就是它返回的。部署之后出现 ChunkLoadError,也不能证明一定存在部署版本不一致。online: true 更不能证明源站可达。
客户端遥测的价值是缩小假设范围。服务器日志、反向代理日志、部署清单、缓存状态和实际复现仍然可能是必要的。
我也不希望可观测性变成无限制收集用户数据。每个字段都应该因为能帮助区分故障类型才存在。
更好的遥测不等于更多遥测,而是更能区分故障类型的遥测。
我现在的规则:收集事件,调查事故,针对影响报警
一开始,我只想让上报器回答“有没有什么东西坏了?”。在生产环境中,这个问题太宽了。总会有爬虫无法加载分析脚本、隐私工具拦截广告、媒体 Promise 被有意取消、用户丢失网络,或者第三方 SDK 出现异常。
这是我们自己的问题吗?
用户是否失去了功能?
evidence 有多强?
是否在重复?
application 是否 recover?
这是多个 event,还是一个 incident?
当监控围绕这些问题重新构建后,红色消息的洪流才真正变成工程工具。
浏览器错误是一条观测。事故是把多条证据关联起来后,对用户影响的解释。告警则是“现在值得让人介入”的决定。
我不想再把这三件事当成同一个概念。