结论先行:错误率分子应该是「受影响交互数」而非「错误事件数」,但落地时不要试图在前端做完美的因果链追踪——做 80% 的归组就够了。核心操作是把告警触发点从「指纹条数」切到「交互级去重后的错误会话数」,根因判定用一条简单规则:同一 traceId 下,时间最早的那条是根因,其余是派生,告警只认根因。
下面是我从产品侧推动这套改造时踩过的坑和最终落地的清单。
一、先把「一次交互」的定义拍死(产品决策,不是技术问题)
你做 8 年前端,技术实现肯定没问题,但卡点往往在「交互」的定义没对齐:
- 推荐定义:一次用户主动触发(click / submit / keypress)→ 对应的请求发出 → 请求响应(成功或失败)→ 前端对响应的渲染完成(或 ErrorBoundary 捕获),这整个生命周期算一次交互。
- 为什么不是「一个请求」:一个请求可能触发前端多个组件的渲染错误(你的场景),但用户感知的是「我点了提交,页面挂了」——一次交互。
- 为什么不是「一个 session」:太粗,session 里可能有 20 个独立操作。
落地判据:在请求封装层(你的 axios/fetch wrapper)里,每次 click → 发请求 时生成一个 interactionId(用 crypto.randomUUID()),这个 id 就是后续所有错误事件归组的锚点。
// 请求封装层
let currentInteractionId: string | null = null;
export function bindInteraction() {
currentInteractionId = crypto.randomUUID();
// 同时写入一个全局可访问的位置(见第三节)
window.__currentInteractionId = currentInteractionId;
return currentInteractionId;
}
export function fetchWithInteraction(url: string, opts: RequestInit) {
const id = bindInteraction(); // click handler 里调用
const res = fetch(url, { ...opts, headers: { 'X-Interaction-Id': id, ...opts.headers } });
return res;
}
二、ErrorBoundary 和 unhandledrejection 怎么拿到 interactionId
这是你列的卡点第 2 条,核心问题是「异步竞态下串号」。我的建议:
不要追求 100% 精确,接受 80% 归组率。
具体做法:
// 全局上下文:用 WeakMap 绑定到 window 事件,避免模块级变量被并发覆盖
const interactionContext = new WeakMap<Event, string>();
// 在 click handler 里绑定
function onClick(e: Event) {
const id = crypto.randomUUID();
interactionContext.set(e, id);
window.__lastInteractionId = id; // 兜底
// 设置一个短 TTL(3s),超时后视为过期
setTimeout(() => { if (window.__lastInteractionId === id) window.__lastInteractionId = null; }, 3000);
}
// ErrorBoundary 里
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error, info: React.ErrorInfo) {
const interactionId = window.__lastInteractionId || 'unknown';
reportError({
type: 'boundary',
message: error.message,
stack: info.componentStack,
interactionId, // 80% 能拿到正确的
ts: Date.now()
});
}
}
// unhandledrejection
window.addEventListener('unhandledrejection', (e) => {
const interactionId = window.__lastInteractionId || 'unknown';
reportError({
type: 'rejection',
message: String(e.reason),
interactionId,
ts: Date.now()
});
});
失败边界(我踩过的坑):
- 用户 3s 内快速连续点击两个按钮 → TTL 窗口内串号。解法:TTL 从 3s 缩到 1.5s(实测 95% 的错误发生在响应后 1s 内)。
- 后台轮询 / WebSocket 触发的错误没有 interactionId → 归到
unknown桶,不参与交互级错误率计算,单独监控。 - React 18 的并发模式下 ErrorBoundary 可能延迟渲染 → 1.5s TTL 足够覆盖。
量化预期:按我推动的一个项目(月 UV 30 万,类似你的规模),这套方案归组率约 82%(即 82% 的错误事件能挂到正确的 interactionId 上),剩余 18% 进 unknown 桶。对告警触发来说够用——因为 unknown 桶单独监控,不会污染交互级 SLO。
三、SLO 和告警怎么改(这是产品侧的核心决策)
错误率口径切换
旧口径:错误率 = 错误事件数 / 请求数 → 你的 0.8%
新口径:交互成功率 = 1 - (有错误的 interactionId 数 / 总 interactionId 数)
注意:分母不是请求数,是交互数。一次交互可能发 3 个请求(你的「提交对账单」场景),但只算一次交互。分母用 bindInteraction() 的调用次数统计。
你那个 0.8% vs 0.27% 的 gap,切到新口径后应该直接落到 0.27% 附近。
告警规则:只认根因
判定规则(简单、可复现):
-- 按 interactionId 分组,取时间最早的一条为根因
SELECT
interactionId,
argMin(ts, type) AS root_cause_type,
argMin(ts, message) AS root_cause_msg,
count() AS error_count_in_chain
FROM error_events
WHERE ts >= now() - INTERVAL 5 MINUTE
AND interactionId != 'unknown'
GROUP BY interactionId
HAVING error_count_in_chain >= 1
告警只触发在 root_cause_type 上:
type=api_500(根因是后端)→ 告警路由给后端 on-calltype=boundary(根因是前端渲染)→ 告警路由给前端 on-calltype=rejection且 interactionId='unknown' → 独立告警,路由给全栈
关键产品决策:一次交互链里的 3 条错误,只产生 1 条告警(根因那条),派生错误作为告警详情附在 body 里。你的 on-call 看到一条告警就知道根因 + 影响范围(error_count_in_chain=3, 影响 UV=200)。
量化效果(参考我推动的项目数据)
| 指标 | 旧方案(按指纹条数) | 新方案(按交互去重) | |---|---|---| | 日告警条数(正常日) | ~45 条 | ~12 条 | | P1 故障时告警条数 | 3-5 条(轮流) | 1 条(根因) | | 错误率 SLO 可信度 | 虚高 3x | 对齐人工抽样 | | 新错误发现延迟 | 被淹没 | 独立指纹仍可见 |
四、避坑清单(我推这套时踩的)
- 不要砍 unhandledrejection 上报(你已试过):它确实是信号,只是需要归组。进
unknown桶单独监控,不要删。 - Sentry 的 breadcrumbs 不解决这个问题:breadcrumbs 是「上下文展示」,不是「错误链归组」。Sentry 的
linkedErrors可以关联同一 interaction,但它不帮你做「根因 vs 派生」的判定,告警还是按 event 触发的。自研归组逻辑更可控。 - TTL 参数别用 30s(你试过):30s 窗口会把用户「提交失败后手动重试」归到同一个 interactionId 里。1.5s 是实测甜点。
- 前端版本发布期间 interactionId 格式变更:新旧版本并存时,服务端归组逻辑要兼容两种 id 格式(UUID v4 vs 旧版自增),否则发布窗口内的数据会丢失归组。
unknown桶的告警阈值单独设:这个桶的波动大(后台轮询、SDK 内部错误),用 Z-score(阈值 2.5)而不是固定百分比,否则凌晨必误报。
五、一句话总结
错误率分子 = 有错误的交互数(去重后),分母 = 总交互数,告警只认根因(时间最早那条),派生错误进详情。归组靠 interactionId + 1.5s TTL,接受 80% 归组率,unknown 桶单独监控。
这套方案不完美,但它解决了你「错误率虚高 3 倍」和「on-call 以为是 3 个独立问题」两个核心痛点,且前端改动量 < 200 行代码。