前端「静默降级」链路(API 失败→用缓存→空态→错误页)缺统一可观测指标:怎么给降级深度定 SLO、怎么和后端 error rate 对齐口径?
该问题的正文、回答与讨论。
背景
我维护一个 B 端 SaaS(React 18.2 + Vite 5 + TypeScript 5.4,约 260 个路由、18 个业务子包 monorepo),产品侧要求所有数据页面在 API 异常时走「静默降级」:
- L0 正常:API 200,实时数据渲染
- L1 缓存兜底:API 超时/5xx → 展示 localStorage / IndexedDB 缓存的旧数据,顶部出现「数据可能非实时」提示条
- L2 空态:无缓存可回退 → 展示空态插画 +「刷新」按钮
- L3 错误页:连续 N 次降级失败 → 全屏错误页 +「重试 / 联系管理员」
这套降级逻辑由前端自行决策(后端只返回 HTTP 状态码),用户大概率不知道当前处于 L1/L2/L3——他只觉得"数据好像旧了"或"列表怎么是空的"。
现象与卡点
-
RUM 里只有 error rate 一个数字。我们目前用自研 RUM SDK 上报 API 请求的 status code + 耗时,error rate = 非 2xx 请求 / 总请求。但 L1 降级时 API 本身返回了 503,error rate 会涨——可 L2/L3 是"API 没调"或"调了但前端主动丢弃",error rate 反而不涨。降级越深,error rate 信号越弱,这和我们想监控的方向完全反了。
-
用户投诉无法归因。上周收到 12 条"列表数据不对"的工单,排查发现 7 条是 L1(缓存数据是 3 天前的)、3 条是 L2(缓存被清了)、2 条是 L3(用户自己没意识到进了错误页还截了个"空白"图)。但我这边没有任何一条上报能直接说"这个用户这个时刻处于 L1"——Sentry 里只有零散的
console.warn和unhandledrejection,不是结构化指标。 -
SLO 不知道怎么定。后端团队对 API 有明确的 SLO(99.9% 2xx,5min 窗口),前端侧我写了"页面可用率 99.5%"的 SLO 文档,但"可用"到底算到 L0 还是 L1?如果算到 L1,那"数据是 3 天前的缓存"算可用吗?如果算到 L0,那 L1/L2/L3 全部算不可用,数字会非常难看、也没有改进抓手。
-
和后端口径对不上。后端说"这个接口 P99 320ms、error rate 0.3%,SLA 达标";前端 RUM 说"这个页面 LCP 2.1s、error rate 1.2%"(包含了前端主动 abort 的请求);用户说"我看到的订单列表是 3 天前的"。三套数字描述的是同一次故障,但谁也没法说"到底谁的问题"。
已尝试的排查与结果
-
方案 A:在降级决策点打结构化日志。在 L0→L1/L2/L3 的每个 switch 分支里
report({ level: 'L1', page: '/orders', reason: 'timeout', cacheAge: 259200 })。- 结果:日志确实有了,但散落在 Sentry 的 log 面板里,没有分位数、没有告警、没法算 SLO。而且 L1 降级时用户停留时间很长(他在看旧数据做决策),采样后大部分被丢掉。
-
方案 B:把降级事件当"虚拟请求"上报给 RUM。每次降级触发就伪造一条
request记录(status=200,但加degrade_level字段)。- 结果:RUM 面板能按
degrade_level切了,但和真实 API 请求混在一起,分位数计算被污染(L1 的"耗时"是 0ms 因为没发请求),P99 曲线出现大量 0ms 尖刺。
- 结果:RUM 面板能按
-
方案 C:后端配合加
X-Server-Degraded: true响应头。- 结果:后端推了两周没排上。而且这只能覆盖"后端主动降级"的场景,前端因为网络抖动/超时自己降级的那部分(约 60%)还是抓不到。
-
参考:社区 #889《谁来监控监控:前端 SDK 4 类静默失效与 3 个自证健康指标落地》里提到的"心跳 + 自证健康指标"思路我借鉴了,但那是 SDK 自身的健康,不是业务降级链路的深度。#847《前端可观测性建好了,告警却没人信》里讨论的告警分层(P0 页面不可用 / P1 功能降级 / P2 体验劣化)给了我"降级深度可以映射到告警等级"的启发,但具体怎么算数字还是没想清楚。
期望得到的答案形态
- 指标定义:「降级深度」应该用什么数据结构上报?是枚举(L0/L1/L2/L3)还是连续值(0/33/66/100)?要不要带
cacheAge、degradeReason、pagePath这些维度?有没有人做过"降级深度 P50/P95"这种分位数指标? - SLO 计算:如果 SLO 目标是"L1 以上可用率 99.5%",分母是 PV 还是 UV?时间窗口 5min 还是 1h?L1 的 cacheAge > 24h 要不要单独算一档?有没有人把"降级深度"纳入 SRE 的 error budget 计算?
- 和后端 error rate 的对齐:有没有一套口径约定,让"前端看到的降级事件"和"后端看到的 5xx/timeout"能在同一个告警面板里关联?比如用 traceId 串联,或者约定"前端降级事件必须携带后端 requestId"?
- 告警策略:L1 降级率 > 5% 告警?L3 错误页出现即 P0?还是按"降级深度 × 页面权重"加权?有没有人在 B 端场景下做过这套告警分级并跑通了值班响应?
我贴一个我们目前的降级决策伪代码,方便大家对着讨论:
// 简化版:实际有 18 个子包各自实现,逻辑高度相似但不完全一致
async function fetchWithFallback<T>(url: string, cacheKey: string): Promise<{ data: T; level: DegradeLevel; cacheAge?: number }> {
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) throw new ApiError(resp.status);
const data = await resp.json();
return { data, level: 'L0' };
} catch (e) {
// L1: 尝试缓存
const cached = await idbGet<T>(cacheKey);
if (cached) {
reportDegrade({ level: 'L1', url, reason: e.message, cacheAge: Date.now() - cached.ts });
return { data: cached.data, level: 'L1', cacheAge: Date.now() - cached.ts };
}
// L2: 空态
reportDegrade({ level: 'L2', url, reason: e.message });
return { data: null as T, level: 'L2' };
}
// L3 由外层 retry 逻辑兜底(连续 3 次 L2 → 切错误页)
}
核心痛点:18 个子包各自实现了一套降级逻辑,上报格式不统一,我没法在 RUM 大盘里看到"全应用降级深度分布"。 想请教有类似规模 B 端前端的同学:你们是怎么把"降级"从一个散落的 if-else 分支变成一条可观测、可告警、可算 SLO 的一等公民指标的?