18 · 可观测性与稳定性(详解版)
一句话定位:可观测性是「不用问用户,就能知道系统发生了什么」;稳定性是「出了问题能快速发现、快速止血、不再复发」。
读完本篇你应该能做到
- 搭一套前端错误、性能、行为的采集与上报;
- 用 Source Map 还原堆栈、用指纹聚类异常、用 traceId 串起全链路;
- 设计可执行的告警与 SLO,而不是「告警风暴」或「静默故障」;
- 建立故障响应、复盘与稳定性治理机制。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 归属 |
|---|---|---|
| 用户说白屏,你没有任何日志 | 没有全局错误捕获 | 采集缺失 |
| 错误量突增,但堆栈全是压缩代码 | 未上传/未关联 Source Map | 可读性 |
| 接口慢,不知道是 DNS、网关还是后端 | 没有分段耗时 | 链路追踪 |
| 一天 500 条告警,没人看 | 无聚合、无分级、无抑制 | 告警设计 |
| 故障恢复后没人知道为什么 | 无复盘、无改进项 | 流程 |
可观测性解决的是「未知的未知」:你无法预先为每个 bug 写日志,但可以采集足够的信号,在出问题时推断发生了什么。
0.2 一个类比
把系统想成医院里的病人:
- 日志是病历:发生了什么,按时间记录。
- 指标是监护仪:心率、血压的连续曲线,超阈值就响。
- 追踪是造影:一次完整的血流路径,能看到哪里堵了。
- 告警是护士站的铃:只在真正危险时响,不能一直响。
- 复盘是病例讨论:下次遇到同类情况怎么处理。
只有病历没有监护仪,你会错过突发;只有监护仪没有病历,你知道异常但不知道为什么。
0.3 学习地图
18 可观测性与稳定性
├─ 前置:11 性能(指标)、14 Node/BFF(服务端)
├─ 紧接:20 工程效能(复盘与组织)
├─ 交叉:12 安全(异常检测)、13 测试(回归用例)
└─ 长期:稳定性治理体系
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会接错误监控、看面板 | 错误采集 | 能定位报错页面与版本 |
| L2 进阶 | 会做性能采集、上报优化、告警 | RUM 看板 | 能解释指标含义 |
| L3 高级 | 会做链路追踪、异常聚类、SLO | 稳定性平台 | 能定量评估影响面 |
| L4 专家 | 建稳定性体系与应急机制 | SLO/预案/演练 | 能对可用性负责 |
1. L1 基础:先把错误收上来
1.1 三类信号
| 信号 | 回答什么 | 前端例子 |
|---|---|---|
| 日志(Logs) | 发生了什么 | 错误堆栈、用户操作 |
| 指标(Metrics) | 趋势如何 | 错误率、LCP、白屏率 |
| 追踪(Traces) | 慢在哪一段 | 请求 → BFF → 下游 |
顺序建议:先做错误采集(收益最高),再做性能指标,最后做全链路追踪。
1.2 前端错误的四个来源
/**
* 1) JS 运行时错误
*/
window.addEventListener('error', (event) => {
// 资源加载失败也会触发 error,但没有 error 对象
if (!event.error) return;
report({
type: 'js',
message: event.message,
stack: event.error?.stack,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
/**
* 2) Promise 未捕获拒绝
*/
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
report({
type: 'promise',
message: reason?.message ?? String(reason),
stack: reason?.stack,
});
});
/**
* 3) 资源加载失败(捕获阶段才能拿到)
*/
window.addEventListener('error', (event) => {
const target = event.target;
if (target instanceof HTMLScriptElement ||
target instanceof HTMLLinkElement ||
target instanceof HTMLImageElement) {
report({ type: 'resource', tag: target.tagName, url: target.src || target.href });
}
}, true); // 关键:捕获阶段
/**
* 4) 接口错误(在请求封装层统一处理)
*/
async function request(url, options) {
try {
const res = await fetch(url, options);
if (!res.ok) report({ type: 'api', url, status: res.status });
return res;
} catch (err) {
report({ type: 'network', url, message: err.message });
throw err;
}
}
注意:window.onerror 只能拿到同源脚本的详细信息;跨域脚本需要在 <script> 上加 crossorigin,否则堆栈是 Script error.。
1.3 上报:别让监控拖慢页面
/**
* sendBeacon:页面卸载也能发出,不阻塞导航。
* 降级:fetch keepalive。
*/
function send(payload) {
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
navigator.sendBeacon('/log', new Blob([body], { type: 'application/json' }));
return;
}
fetch('/log', { method: 'POST', body, keepalive: true }).catch(() => {});
}
| 方式 | 页面卸载 | 阻塞 | 建议 |
|---|---|---|---|
sendBeacon | 支持 | 否 | 首选 |
fetch keepalive | 支持 | 否 | 备选 |
img src | 支持 | 否 | 老方案,体积受限 |
XMLHttpRequest 同步 | 支持 | 是 | 禁用 |
1.4 采样与去重
/**
* 采样:错误全采,行为按比例采,避免成本失控。
*/
const SAMPLE = 0.1;
function shouldSample(type) {
if (type === 'error') return true; // 错误不采样
return Math.random() < SAMPLE;
}
/**
* 去重:同一错误在同一会话只报一次,避免风暴。
*/
const seen = new Set();
function dedupe(key) {
if (seen.has(key)) return false;
seen.add(key);
return true;
}
1.5 上下文比堆栈更有用
/**
* 只报 "Cannot read property 'id' of undefined" 基本无法定位。
* 必须带:页面、版本、用户、设备、操作路径、请求 id。
*/
function buildContext() {
return {
url: location.href,
release: window.__APP_VERSION__,
userId: getUserId(), // 注意脱敏
ua: navigator.userAgent,
viewport: `${innerWidth}x${innerHeight}`,
dpr: devicePixelRatio,
network: navigator.connection?.effectiveType,
traceId: window.__TRACE_ID__,
};
}
1.6 框架层错误:全局监听抓不到什么
window.onerror 是兜底,不是全部。框架有自己的错误边界,SSR 与资源加载还有独立路径。
| 错误类型 | window.onerror 能否捕获 | 正确捕获方式 | 备注 |
|---|---|---|---|
| 渲染期异常(React) | 能,但会卸载整棵树 | ErrorBoundary + 全局兜底 | 边界只包住子树,避免全页白屏 |
| 事件回调异常 | 能 | 全局监听 | 边界抓不到事件回调里的错误 |
| 异步回调异常 | 能(未捕获时) | unhandledrejection / onerror | 被 catch 吞掉则无法感知 |
| Vue 组件异常 | 能 | app.config.errorHandler | 可拿到组件名与生命周期钩子 |
动态 import() 失败 | 能 | 监听 + 重试 + 兜底 UI | 发版后旧 chunk 404 的高频场景 |
| 水合不一致(SSR) | 视框架 | 框架 hydration 错误钩子 | 生产环境常被降级为警告 |
| 资源加载失败 | 需捕获阶段 | addEventListener('error', fn, true) | 目标元素上有 src/href |
React 的两个关键事实:
/**
* ErrorBoundary 只捕获子树「渲染、生命周期、构造函数」中的错误。
* 事件回调、定时器、Promise、SSR 数据请求都不在它的范围内。
*/
class Boundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
// info.componentStack 能定位到组件层级
report({ type: 'react', error, componentStack: info.componentStack });
}
render() {
return this.state.error ? <Fallback /> : this.props.children;
}
}
动态 chunk 失败的处理(发版后最常见的一类「白屏」):
/**
* 动态导入失败时重试一次,仍失败则提示刷新。
* 原因:发版后旧 chunk 被清理,用户手里的 index.html 仍指向旧文件。
*/
async function lazyImport(factory, retries = 1) {
try {
return await factory();
} catch (error) {
if (retries > 0 && /Loading chunk|Importing a module script failed/.test(String(error))) {
return lazyImport(factory, retries - 1);
}
report({ type: 'chunk-load', error });
showReloadTip(); // 提示用户刷新,而不是停在空白页
throw error;
}
}
工程上的三条防线:
- 路由级 ErrorBoundary + 根级兜底,避免一个组件拖垮整页;
- 旧版本静态资源保留一个发布周期,或给 chunk 失败加重试;
- 框架错误钩子里补「组件栈 + 路由 + 版本」,否则只剩一句 message。
1.7 采集 SDK 的六个工程约束
监控代码本身不能成为故障源。上线前逐条检查:
| 约束 | 失败后果 | 做法 |
|---|---|---|
| 体积 | 首屏变慢,被业务方要求下线 | 核心 < 10KB gzip,插件按需加载 |
| 初始化时机 | 早期错误漏采 | <head> 内联最小引导脚本,先入队后补发 |
| 异常隔离 | SDK 抛错导致业务白屏 | 所有入口 try/catch,上报失败静默 |
| 队列与重试 | 弱网/卸载时丢数据 | 内存队列 + 批量 + 退避重试 + 隐藏时 flush |
| 隐私 | 合规风险 | 字段白名单、脱敏、征得同意、最小化采集 |
| 自身可回滚 | 新版 SDK 引入新问题 | SDK 独立版本号 + 灰度 + 远程开关 |
/**
* 极简上报队列:批量、失败退避、页面隐藏时补发。
* @param {{endpoint: string, maxBatch?: number, interval?: number}} options
*/
function createReporter({ endpoint, maxBatch = 10, interval = 5000 }) {
const queue = [];
let timer = null;
let retry = 0;
const flush = async () => {
if (queue.length === 0) return;
const batch = queue.splice(0, maxBatch);
try {
await fetch(endpoint, {
method: 'POST',
body: JSON.stringify(batch),
keepalive: true,
});
retry = 0;
} catch {
// 失败放回队首,指数退避,最多保留 200 条
queue.unshift(...batch);
if (queue.length > 200) queue.length = 200;
retry += 1;
setTimeout(flush, Math.min(30000, 1000 * 2 ** retry));
}
};
const push = (item) => {
queue.push(item);
if (queue.length >= maxBatch) flush();
else if (!timer) timer = setTimeout(flush, interval);
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
return { push, flush };
}
一个常被忽略的点:SDK 自己也要有「错误率」与「丢弃率」指标。否则采集链路坏了,你看到的只是「线上很稳定」。
1.8 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| 错误源 | JS / Promise / 资源 / 接口 |
| 上报 | sendBeacon 优先,不阻塞 |
| 采样 | 错误全采,行为采样 |
| 上下文 | 页面/版本/用户/设备/链路 |
自检题
- 为什么跨域脚本的错误信息是
Script error.?——浏览器为防信息泄漏会隐藏跨域脚本细节;需要<script crossorigin="anonymous">且 CDN 返回Access-Control-Allow-Origin。 - 资源加载失败为什么要在捕获阶段监听?——资源错误不冒泡,只有捕获阶段能拿到
target与资源 URL。 - 只上报错误信息不上报上下文,会带来什么问题?——无法复现与定位;需要版本、路由、用户环境、面包屑与 traceId 才能判断影响面。
- 有了
window.onerror,为什么还需要 ErrorBoundary?——全局监听只能上报,无法阻止整棵树被卸载;边界能把故障限制在局部并给出降级 UI,两者职责不同。
2. L2 进阶:可读、可用、可告警
2.1 Source Map:把压缩代码还原成人话
构建 → 生成 .map → 上传到监控平台 → 用 release + filename 关联
上报 → 堆栈是压缩后的位置 → 平台用 map 反解出源码行列
工程要点:
- 构建时生成 sourcemap(
hidden模式); - 上传时带上
release(版本号)与dist(构建号); .map不要公开可访问;- 发布与上传必须原子(先上传 map 再发布)。
/**
* 上报时带上版本,否则平台无法匹配对应的 map。
*/
report({ ..., release: '1.8.3', dist: '20261030.1' });
2.2 性能采集:RUM 的最小实现
/**
* 采集核心指标 + 分位统计所需的原始值。
* 注意:只上报数值,不要在客户端聚合,便于后端按维度切分。
*/
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
report({
type: 'metric',
name: 'LCP',
value: Math.round(entry.startTime),
element: entry.element?.tagName,
url: location.pathname,
});
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
| 指标 | 采集方式 | 注意 |
|---|---|---|
| LCP | PerformanceObserver | 交互后停止更新 |
| INP | event + 最差值 | 需要 durationThreshold |
| CLS | layout-shift | 排除用户输入后的偏移 |
| TTFB | Navigation Timing | 区分缓存命中 |
| 资源 | resource | 大资源单独告警 |
2.3 用户行为链路:还原「用户做了什么」
/**
* 面包屑:记录最近 N 个操作,出错时一起上报。
* 注意:不记录输入内容(隐私)。
*/
const breadcrumbs = [];
const MAX = 20;
function addBreadcrumb(action) {
breadcrumbs.push({ action, at: Date.now() });
if (breadcrumbs.length > MAX) breadcrumbs.shift();
}
// 常用埋点
addBreadcrumb({ type: 'click', target: 'submit-order' });
addBreadcrumb({ type: 'route', from: '/cart', to: '/checkout' });
addBreadcrumb({ type: 'api', url: '/api/order', status: 500 });
2.4 异常聚类:把一万条错误变成十条
/**
* 指纹:用「错误类型 + 消息模板 + 关键堆栈帧」生成稳定 hash。
* 消息里的动态内容要归一化,否则每条都不同。
*/
function fingerprint(error) {
const msg = error.message
.replace(/\d+/g, 'N')
.replace(/"[^"]*"/g, '"S"')
.replace(/'[^']*'/g, "'S'");
const frame = error.stack?.split('\n')[1]?.trim() ?? '';
return hash(`${error.name}|${msg}|${frame}`);
}
聚类后看什么:影响用户数、影响页面、首次出现版本、是否新错误。
2.5 告警设计:少而准
| 反模式 | 后果 | 改进 |
|---|---|---|
| 每个错误都告警 | 风暴,被忽略 | 按影响面聚合 |
| 只看单机指标 | 误报 | 看用户影响 |
| 无分级 | 小事叫醒人 | P0/P1/P2 |
| 无抑制 | 同一问题刷屏 | 去重 + 静默窗口 |
| 无 owner | 没人处理 | 明确责任人 |
告警分级:
P0:核心链路不可用(白屏率 > 5%、下单成功率 < 90%)→ 立即电话
P1:部分用户受影响(错误率翻倍、P99 超阈值)→ 工作时间处理
P2:趋势异常(缓慢上涨)→ 每日巡检
好告警的三个条件:可行动、有 owner、有明确阈值与影响面。
2.6 日志规范
/**
* 结构化日志:字段固定,便于检索与聚合。
* 禁止:拼接字符串、打印敏感信息、无级别。
*/
logger.error('order.create.failed', {
requestId,
userId: mask(userId),
orderId,
code: err.code,
durationMs: Date.now() - start,
});
| 级别 | 用途 |
|---|---|
| debug | 开发排查,生产关闭 |
| info | 关键流程节点 |
| warn | 可恢复异常 |
| error | 需要人处理 |
2.8 分位数、维度与基数
为什么看分位数:均值会被大量快请求稀释。1% 的用户等了 10 秒,均值可能只涨 100ms,但那 1% 正是流失用户。
| 口径 | 适合回答 | 陷阱 |
|---|---|---|
| 均值 | 总量/成本估算 | 掩盖长尾 |
| P50 | 典型用户体验 | 对尾部不敏感 |
| P75 | 官方 Web Vitals 口径 | 低流量页面波动大 |
| P95/P99 | 稳定性与 SLA | 样本少时不可信 |
| 最大值 | 极端个案 | 易被单次异常带偏 |
分位数不能直接求平均:两个页面的 P95 取平均没有意义。正确做法是保留直方图桶或原始值,在查询时按目标维度重新聚合。
维度设计:能回答「谁、在哪、什么版本、什么网络下更差」。
| 维度 | 用途 | 基数风险 |
|---|---|---|
| 页面路由 | 定位问题页面 | 中(动态路由需归一化) |
| 版本/release | 判断是否新引入 | 低 |
| 设备/系统 | 兼容性问题 | 中 |
| 网络类型 | 弱网优化 | 低 |
| 地域 | CDN/运营商问题 | 中 |
| 用户 ID | 禁止作为指标标签 | 极高 |
基数爆炸:标签组合数 = 各维度取值数相乘。页面(200) × 版本(50) × 机型(100) × 地域(30) 已经是 3000 万条时间序列。控制方法:
- 指标只保留可枚举的低基数字段;
- 高基数信息放进日志/追踪,用检索而不是聚合;
- 动态路由归一化(
/order/123→/order/:id); - 对长尾维度做
other归并。
2.9 上报链路:批量、重试与顺序
采集 ─► 内存队列 ─► 批量序列化 ─► 压缩 ─► 传输 ─► 网关 ─► 存储 ─► 查询
│ │ │
└─ 上限淘汰 └─ 超过阈值立即发 └─ 鉴权/限流/采样
| 环节 | 风险 | 做法 |
|---|---|---|
| 队列 | 内存涨、页面卡 | 固定上限(如 200 条),先进先出 |
| 批量 | 卸载时丢最后一批 | visibilitychange 强制 flush |
| 传输 | 弱网失败 | keepalive + 退避重试 + 上限 |
| 网关 | 被打爆 | 客户端限流 + 服务端按来源配额 |
| 顺序 | 事件乱序 | 带客户端时间戳与自增序号,服务端按会话排序 |
上报协议建议:字段固定、可扩展、带 schemaVersion;服务端对未知字段宽容,避免 SDK 升级导致整批丢弃。
成本控制优先级:
错误:全量(但做指纹去重)
性能:全量原始值 + 后端降采样
行为:按会话采样(1%–10%)
调试日志:仅灰度/白名单开启
2.10 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| Source Map | release 关联,私有上传 |
| 性能 | 采原始值,后端切分维度 |
| 面包屑 | 还原操作路径,不记隐私 |
| 聚类 | 消息归一化 + 堆栈指纹 |
| 告警 | 可行动、有 owner、有分级 |
自检题
- 为什么 sourcemap 上传要和发布绑定版本?——堆栈行列对应特定产物,版本错配会还原到错误位置;发布时同步上传并保留历史版本。
- 错误聚类为什么必须归一化消息中的数字?——ID、时间、金额会让同一错误产生海量 fingerprint,聚类失效;替换为占位符再归并。
- 一条「可行动」的告警应该包含哪些信息?——什么错、影响多少用户、哪个版本与路由、首次/最近时间、堆栈与 traceId、负责人与处置建议。
- 为什么不能把用户 ID 作为指标标签?——每个用户都会生成新的时间序列,基数爆炸导致存储与查询成本失控;用户维度应放在日志或追踪里检索。
3. L3 高级:链路、SLO 与定位
3.1 traceId:把前端与后端串起来
浏览器生成 traceId
→ 请求头 x-trace-id
→ BFF 透传 + 生成 span
→ 下游服务继续透传
→ 日志/错误都带上 traceId
/**
* 前端注入 traceId,并在错误上报时携带,
* 这样「用户报错」能直接查到后端日志。
*/
const traceId = crypto.randomUUID();
function withTrace(headers = {}) {
return { ...headers, 'x-trace-id': traceId };
}
report({ ..., traceId });
3.2 OpenTelemetry 前端接入
| 信号 | 前端能力 |
|---|---|
| Trace | 页面加载、路由切换、fetch 调用 |
| Metric | Web Vitals、自定义计数 |
| Log | 结构化错误日志 |
注意:前端不采集所有请求(成本高),只采集关键链路与慢请求。
3.3 白屏检测
/**
* 采样法:在视口内取若干点,检查是否命中有效元素。
* 有效元素:非 html/body、有尺寸、非透明。
*/
function isBlank() {
const points = [
[innerWidth / 2, innerHeight / 2],
[innerWidth / 4, innerHeight / 3],
[innerWidth * 0.75, innerHeight * 0.6],
];
return points.every(([x, y]) => {
const el = document.elementFromPoint(x, y);
if (!el || el === document.body || el === document.documentElement) return true;
const rect = el.getBoundingClientRect();
return rect.width === 0 || rect.height === 0;
});
}
// 在 load + 3s 时检测,避免误判慢加载
setTimeout(() => {
if (isBlank()) report({ type: 'blank-screen', url: location.href });
}, 3000);
局限:单页应用路由切换后也要检测;骨架屏会被误判为「非白屏」,需要结合业务标记。
3.4 SLI / SLO / 错误预算
| 概念 | 定义 | 例子 |
|---|---|---|
| SLI | 实际测量值 | 下单成功率 99.2% |
| SLO | 目标值 | 99.9% / 30 天 |
| 错误预算 | 允许的失败量 | 0.1% × 请求数 |
| 燃烧率 | 预算消耗速度 | 1 小时烧掉 10% |
错误预算用完后:
冻结非必要发布 → 集中修复稳定性 → 复盘 → 恢复发布
前端 SLO 示例:
| 指标 | 目标 |
|---|---|
| 页面可用率(非白屏) | 99.9% |
| 关键接口成功率 | 99.95% |
| LCP P75 | < 2.5s |
| JS 错误率(每千次会话) | < 5 |
3.5 故障定位的标准动作
1. 确认影响面:多少用户、哪些页面、哪些版本、哪些地域
2. 看时间线:什么时候开始,和什么变更重合
3. 分层排查:前端(错误/白屏)→ 网络(DNS/CDN)→ BFF → 下游
4. 用 traceId 找具体请求
5. 对比版本/灰度分组
6. 止血优先于根因
经验:90% 的线上问题与「最近的变更」有关。先看发布记录。
3.6 数据质量:漏报与误报
| 问题 | 原因 | 处理 |
|---|---|---|
| 漏报 | 上报被拦截、采样过高、SDK 未初始化 | 多通道 + 采样校准 |
| 误报 | 白屏检测误判、第三方脚本错误 | 过滤规则 + 白名单 |
| 重复 | 同一错误多处捕获 | 指纹去重 |
| 偏差 | 只在高端机采集 | 分设备/网络统计 |
校验方法:用合成监控(固定环境)与 RUM 对比,差异过大说明采集有问题。
3.7 隐私与合规
/**
* 上报前统一脱敏:
* - 手机号/身份证/邮箱
* - URL 中的 token/查询参数
* - 输入框内容
*/
function mask(text) {
return String(text)
.replace(/\d{11}/g, '***********')
.replace(/[\w.-]+@[\w.-]+/g, '***@***')
.replace(/(token|password|secret)=[^&]*/gi, '$1=***');
}
红线:不上报用户输入内容、不上报完整 Cookie、不上报身份证/银行卡。
3.8 采样策略:头部、尾部与偏差
| 策略 | 何时决定 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 头部采样 | 请求开始时 | 实现简单、开销低 | 可能丢掉真正有价值的慢/错请求 | 高流量正常请求 |
| 尾部采样 | 请求结束后 | 保留错误与慢请求 | 需要缓冲与集中决策 | 链路追踪 |
| 分层采样 | 按类型/路由 | 关键链路全采 | 规则需维护 | 前端 RUM |
| 自适应采样 | 按流量动态 | 成本可控 | 统计口径复杂 | 大促/突发 |
前端推荐组合:
错误:100%
接口慢请求(> 2s):100%
关键页面(下单/支付):50%–100%
普通页面性能:5%–10%
用户行为:1%–5%,按会话一致性采样
会话一致性:同一个会话内的所有事件用同一个采样决定,否则会出现「有错误上报、没有对应行为路径」的断链。
/**
* 会话级采样:用 sessionId 的哈希决定,保证同一会话内一致。
* @param {string} sessionId
* @param {number} rate 0–1
*/
function sampled(sessionId, rate) {
let h = 0;
for (let i = 0; i < sessionId.length; i += 1) {
h = (h * 31 + sessionId.charCodeAt(i)) >>> 0;
}
return h % 10000 < rate * 10000;
}
采样后的统计:如果按 10% 采样,分子分母都要按同一权重放大,或者直接用采样后的分母算比例;混用会导致错误率被高估/低估。
一个反直觉的结论:错误全量采集的成本通常远低于行为全量采集,因为错误是稀疏事件;先保证错误不采样,再考虑行为采样。
3.9 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| 链路 | traceId 贯穿前后端 |
| 白屏 | 采样点检测 + 延迟判定 |
| SLO | SLI 测量、预算约束发布 |
| 定位 | 影响面 → 时间线 → 分层 → trace |
| 质量 | 合成监控校验 RUM |
自检题
- traceId 为什么必须由前端生成并透传?——入口最早,能覆盖首屏与静态资源阶段;透传给后端才能形成端到端链路,后端再生成会丢前端段。
- 白屏检测有哪些误判场景?——骨架屏、空态、深色背景、弹窗遮挡、iframe 内容、采样点落在空白区,以及渲染尚未完成。
- 错误预算用完了应该做什么?——冻结非必要变更,优先修稳定性,复盘后调整 SLO 或架构,而不是直接放宽指标。
- 头部采样和尾部采样的根本差别是什么?——决策时点不同:头部在请求开始时按概率丢弃,可能丢掉错误;尾部在请求结束后根据结果决定,能保留错误与慢请求,但需要缓冲。
4. L4 专家:体系与应急
4.1 稳定性体系四层
预防:测试、灰度、容量、依赖治理
发现:监控、告警、合成拨测、用户反馈
止血:降级开关、回滚、限流、切流
改进:复盘、用例沉淀、机制建设
多数团队只做了「发现」,缺预防与止血,导致每次故障都靠人救火。
4.2 故障响应流程
| 阶段 | 动作 | owner |
|---|---|---|
| 发现 | 告警/反馈,5 分钟内响应 | oncall |
| 定级 | P0/P1/P2,通知相关方 | 值班负责人 |
| 止血 | 回滚/降级/切流 | 技术负责人 |
| 沟通 | 对内同步进展,对外公告 | 指定接口人 |
| 恢复 | 确认指标回归 | oncall |
| 复盘 | 时间线、根因、改进项 | 全员 |
原则:止血优先;单一指挥;信息同步有固定节奏(如每 15 分钟)。
4.3 演练
| 演练 | 目的 |
|---|---|
| 故障注入 | 验证降级是否真的生效 |
| 混沌工程 | 随机杀实例/断网,验证韧性 |
| 预案演练 | 确认回滚与切流能在 X 分钟内完成 |
| 告警演练 | 确认告警能到人、有人响应 |
没有演练的预案 = 文档。
4.4 复盘的正确写法
1. 事实时间线(精确到分钟,不含猜测)
2. 影响面(用户数、时长、金额)
3. 根因(技术 + 流程)
4. 改进项(每条有 owner 与截止时间)
5. 哪些做得好(可复制的经验)
反模式:只写「加强测试」「提高意识」——不可执行、不可验收。
4.5 成本控制
| 成本 | 优化 |
|---|---|
| 日志存储 | 分级保留、采样、冷热分离 |
| 指标基数 | 控制维度组合(避免高基数标签) |
| Trace 存储 | 头部采样 + 慢请求全采 |
| 告警噪音 | 聚合、抑制、分级 |
注意:高基数标签(用户 id、完整 URL)是指标成本爆炸的头号原因。
4.6 事故指挥:让多人协作不乱
故障期间最大的浪费不是技术排查,而是信息不同步与重复劳动。规模稍大的事故必须分角色:
| 角色 | 职责 | 不该做什么 |
|---|---|---|
| 事故指挥(IC) | 定优先级、分配任务、决定止血方案 | 亲自钻进日志里查 |
| 操作负责人 | 执行回滚/降级/切流 | 同时对外解释 |
| 沟通负责人 | 对内同步、对外公告、更新状态页 | 承诺未验证的恢复时间 |
| 记录人 | 时间线、决策、指标变化 | 参与技术争论 |
沟通节奏:固定每 15 分钟同步一次,即使没有进展也要说「当前假设、下一步、需要谁」。没有节奏的同步会变成持续打断。
对内同步模板(可直接粘贴到群里):
[P1] 下单页白屏
影响:约 8% 用户,集中在 iOS 16 / 版本 3.2.1
开始:14:03,当前状态:已定位到 chunk 加载失败
已做:回滚静态资源版本至 3.2.0(14:18)
待做:观察白屏率 15 分钟;确认 CDN 缓存已刷新
下次同步:14:35
指挥:@张三 沟通:@李四
对外公告的三条纪律:
- 只讲已确认的影响面与已采取的动作,不猜测根因;
- 给「下次更新时间」而不是「预计恢复时间」;
- 恢复后补一条总结,说明影响时段与后续改进。
止血优先级(从快到慢):
关闭功能开关 → 回滚版本 → 切流/降级 → 限流 → 紧急修复
判断指挥是否称职:故障结束后,团队能否在 10 分钟内说出「影响谁、什么时候开始、做了什么、还差什么」。
4.7 L4 自检
- 如何把一个「经常小故障」的系统做到 99.9%?——先量化故障源(发布、依赖、容量、前端),再做冗余与降级、灰度与回滚、演练与 oncall,用错误预算驱动优先级。
- 告警每天 300 条,如何在不漏报的前提下降噪?——按 fingerprint 聚合、按影响面分级、设置抑制与静默、合并同源告警,只保留「新增且影响大」的信号。
- 如何证明稳定性投入的 ROI?——把投入换算成故障损失(资损、工时、口碑)与恢复时长的下降,用事故前后数据对比。
5. 综合实战:白屏率从 3% 降到 0.3%
背景:移动端 H5,白屏率 3%,客服每天收到大量反馈,但研发看不到具体原因。
步骤 1 · 建立采集
// 1) 全局错误 + 资源失败 + 白屏检测 + 版本
// 2) 上报带:url、release、设备、网络、traceId、面包屑
步骤 2 · 聚类分析
| 聚类 | 占比 | 特征 |
|---|---|---|
| 老版本 SDK 报错 | 45% | 集中在 3 个月前的版本 |
| 首屏 JS 加载失败 | 30% | 弱网 + 大 chunk |
| 接口 502 | 15% | 特定地域 |
| 未知 | 10% | 无堆栈 |
步骤 3 · 对症处理
| 问题 | 措施 | 效果 |
|---|---|---|
| 老版本残留 | 强制更新 + 兼容补丁 | -1.2% |
| 首屏 JS 过大 | 分包 + 预加载 + CDN | -0.9% |
| 地域 502 | 多可用区 + 重试 | -0.4% |
| 未知白屏 | 增加骨架屏与重试按钮 | -0.2% |
步骤 4 · 验收
- 白屏率 < 0.5%
- 白屏可归因(> 90% 有分类)
- 告警能在 5 分钟内触达
- 回滚 < 10 分钟
- 每个根因都有回归用例
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「接个监控 SDK 就完事」 | 没有上下文与聚类,数据无法定位问题 |
| 「错误全部实时上报」 | 成本高且无必要,错误全采、行为采样 |
| 「告警越多越安全」 | 噪音会让人忽略真正的问题 |
| 「有日志就能排查」 | 没有 traceId,跨服务无法串联 |
| 「白屏检测很准」 | 骨架屏、慢加载都会误判,要结合业务标记 |
| 「SLO 是运维的事」 | 前端体验指标同样是 SLO 的一部分 |
| 「复盘就是写文档」 | 没有 owner 与截止时间的改进项等于没做 |
| 「监控能替代测试」 | 监控是发现,测试是预防 |
| 「采样会漏掉问题」 | 错误不采样,只对高频行为采样 |
| 「稳定性投入看不到收益」 | 用故障时长 × 影响用户 × 成本量化 |
7. 面试高频题(附答题思路)
Q1:前端怎么做错误监控? 四类错误源 → 捕获方式 → 上下文 → 上报(beacon)→ Source Map → 聚类 → 告警。
Q2:Source Map 怎么用? 构建生成 → 私有上传 → release 关联 → 反解堆栈 → 注意安全与原子发布。
Q3:如何设计告警? 分级、聚合、抑制、owner、可行动;给 P0/P1/P2 例子。
Q4:traceId 的作用? 串联前端/BFF/下游日志;前端生成并透传;错误上报携带。
Q5:如何检测白屏?
采样点 + elementFromPoint + 延迟判定 + 业务标记;再说误判与 SPA 场景。
Q6:SLO 与错误预算? SLI 测量 → SLO 目标 → 预算 → 燃烧率 → 冻结发布机制。
Q7:线上故障怎么定位? 影响面 → 时间线 → 分层 → trace → 版本对比 → 止血优先。
Q8:如何降低监控成本? 错误全采 + 行为采样 + 分级存储 + 控制指标基数 + 头部采样。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 采集完整性 | 是否覆盖四类错误源 | 说明资源错误要捕获阶段 | 只监听 window.onerror |
| 可用性 | 上报是否影响页面 | sendBeacon + 采样 + 队列 | 同步 XHR 上报 |
| 还原能力 | Source Map 链路 | 上传、权限、行列映射 | 「开 sourcemap 就行」 |
| 归因 | 能否从告警到代码 | traceId + 用户行为链路 | 只看错误数量 |
| 治理 | SLO 与噪声 | 错误预算 + 聚合 + 分级 | 所有错误都告警 |
8.2 追问链
链 1 · 错误捕获 → 覆盖盲区 → 上下文
- 面试官:前端错误有哪些来源?
- 要点:JS 运行时错误、未处理的 Promise rejection、资源加载失败(img/script/link)、接口错误、框架渲染错误、跨域脚本错误(
Script error.)。每一类的捕获方式不同。
- 要点:JS 运行时错误、未处理的 Promise rejection、资源加载失败(img/script/link)、接口错误、框架渲染错误、跨域脚本错误(
- 追问:为什么只写
window.onerror会漏?- 要点:它拿不到资源错误的详细信息(需要
addEventListener('error', handler, true)在捕获阶段监听);也拿不到unhandledrejection;onerror还会被后续赋值覆盖。
- 要点:它拿不到资源错误的详细信息(需要
- 再追问:跨域脚本的错误为什么是
Script error.?- 要点:浏览器出于安全不暴露跨域脚本的细节,需要 CDN 返回
Access-Control-Allow-Origin并在<script crossorigin="anonymous">上声明,才会给出完整堆栈。
- 要点:浏览器出于安全不暴露跨域脚本的细节,需要 CDN 返回
链 2 · Source Map → 还原链路 → 成本与安全
- 面试官:线上堆栈怎么还原?
- 要点:构建生成
.map,上传到监控平台并与版本号关联;平台用source-map库按行列反查源文件与原始位置;发布时保证版本号与 map 一一对应。
- 要点:构建生成
- 追问:Source Map 能公开吗?
- 要点:不能。它会暴露源码结构与业务逻辑,应上传到私有存储,通过鉴权访问;产物里用
//# sourceMappingURL指向内部地址或直接移除。
- 要点:不能。它会暴露源码结构与业务逻辑,应上传到私有存储,通过鉴权访问;产物里用
- 再追问:还原不准的常见原因?
- 要点:版本错配(发了新版但 map 没更新)、压缩器改动了行列、内联脚本无 map、多个 chunk 的 map 未合并、堆栈被错误处理截断。
链 3 · traceId → SLO → 告警降噪
- 面试官:前端怎么和后端串起来?
- 要点:入口生成 traceId(或从响应头透传),所有请求带
X-Trace-Id,错误与埋点都带上;后端日志与链路系统用同一 ID 关联,形成端到端视图。
- 要点:入口生成 traceId(或从响应头透传),所有请求带
- 追问:SLO 怎么定才有意义?
- 要点:选用户可感知的 SLI(如白屏率、接口成功率、INP p75),设目标与错误预算;预算耗尽时冻结非必要变更,优先修稳定性。
- 再追问:告警太多怎么办?
- 要点:按影响面聚合(同一错误按 fingerprint 合并)、分级(P0 电话、P1 群消息、P2 日报)、设静默与抑制规则;只对「可行动」的信号告警。
链 4 · 上报丢失 → 采样口径 → 数据可信度
- 面试官:监控显示错误率下降,你怎么确认不是采集坏了?
- 要点:先看「采集健康度」:上报成功率、队列丢弃数、SDK 初始化率、各版本覆盖率。再看合成监控(固定环境拨测)是否与 RUM 同步变化。
- 追问:采样 10% 后,错误率怎么算?
- 要点:分子分母必须用同一采样口径,或按权重还原;只放大分子会高估,只放大分母会低估。更稳妥的是错误全采、只对正常请求采样。
- 再追问:为什么同一用户的行为和错误会对不上?
- 要点:两套数据用了不同的采样决定。应按 sessionId 做一致性采样,保证同一会话内要么都采、要么都不采。
链 5 · 告警风暴 → 分级抑制 → SLO 燃烧率
- 面试官:一天 300 条告警,怎么在不漏报的前提下降噪?
- 要点:先按 fingerprint 聚合同源告警,再按影响面分级;对「已知且在处理」的告警设静默,对衍生告警设抑制(数据库挂了就不再报每个接口超时)。
- 追问:为什么用燃烧率而不是固定阈值?
- 要点:固定阈值对缓慢恶化不敏感。燃烧率看「错误预算消耗速度」:1 小时烧掉 2% 预算(30 天窗口)说明按此速度几天就会耗尽,值得叫醒人。
- 再追问:静默会不会掩盖真正的问题?
- 要点:静默要有时限与 owner,并在静默结束时补一条汇总;同时保留「总影响面」指标作为兜底,避免局部静默导致整体失明。
8.3 手写题
题 1 · 前端错误 SDK
/**
* 极简错误采集:采样、去重、批量、页面隐藏时补发。
* @param {{endpoint: string, sampleRate?: number, maxBatch?: number}} options
*/
function createErrorReporter({ endpoint, sampleRate = 0.3, maxBatch = 10 }) {
const queue = [];
const seen = new Set();
/** 生成指纹:同名同栈只报一次 */
const fingerprint = (err) => `${err.name}|${err.message}|${(err.stack ?? '').slice(0, 120)}`;
const flush = () => {
if (queue.length === 0) return;
const batch = queue.splice(0, maxBatch);
navigator.sendBeacon(endpoint, JSON.stringify({ ts: Date.now(), errors: batch }));
};
const push = (payload) => {
if (Math.random() > sampleRate) return;
const fp = fingerprint(payload);
if (seen.has(fp)) return;
seen.add(fp);
queue.push(payload);
if (queue.length >= maxBatch) flush();
};
// JS 运行时错误
window.addEventListener('error', (e) => {
// 资源错误没有 error 对象
if (!e.error) {
push({ type: 'resource', target: e.target?.src || e.target?.href, message: 'load failed' });
return;
}
push({ type: 'js', message: e.message, stack: e.error.stack, filename: e.filename, line: e.lineno });
}, true);
// 未处理的 Promise rejection
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason;
push({ type: 'promise', message: String(reason?.message ?? reason), stack: reason?.stack });
});
// 页面隐藏时兜底发送
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
return { push, flush };
}
追问:
- 为什么用捕获阶段?→ 资源错误不冒泡,只有捕获阶段能拿到。
- 去重会不会漏掉「同一错误不同用户」?→ 指纹只在单次会话内去重,服务端再按用户与版本聚合。
sendBeacon有什么限制?→ 只支持 POST、有体积上限(约 64KB)、不能自定义部分头,适合小批量上报。
题 2 · 白屏检测
/**
* 白屏检测:在首屏区域取采样点,命中内容过少则判定白屏。
* @param {{points?: number, threshold?: number}} [options]
* @returns {boolean}
*/
function isBlank({ points = 20, threshold = 0.02 } = {}) {
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
if (!el) return true;
const html = document.body;
if (!html || html.scrollHeight < window.innerHeight * 0.2) return true;
let hits = 0;
for (let i = 0; i < points; i += 1) {
const x = Math.floor((i % 5) * (window.innerWidth / 5) + window.innerWidth / 10);
const y = Math.floor(Math.floor(i / 5) * (window.innerHeight / 4) + window.innerHeight / 8);
const node = document.elementFromPoint(x, y);
if (node && node !== document.body && node !== document.documentElement) hits += 1;
}
return hits / points <= threshold;
}
追问:
- 为什么不用截图比对?→ 成本高、上传慢,且不同机型渲染差异大,容易误判。
- 骨架屏会不会被判成「非白屏」?→ 会,所以要在骨架屏出现后、数据渲染后再检测,或排除骨架节点。
- 误报怎么控制?→ 加多个时间点检测、结合路由与设备分布看比例,而不是单次命中就告警。
题 3 · 错误指纹聚类器
/**
* 把动态消息归一化后生成稳定指纹,用于聚合「同一类错误」。
* 关键:ID、时间、金额、URL 参数都要替换为占位符。
* @param {{name: string, message: string, stack?: string}} error
* @returns {string} 指纹
*/
function fingerprint(error) {
const normalize = (text = '') =>
text
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '<uuid>')
.replace(/\b\d{10,}\b/g, '<num>')
.replace(/"[^"]{0,200}"/g, '"<str>"')
.replace(/'(?:[^'\\]|\\.){0,200}'/g, "'<str>'")
.replace(/https?:\/\/[^\s)]+/g, '<url>');
// 只取前 3 个业务栈帧,避免不同调用链导致同一错误分成多个指纹
const frames = (error.stack ?? '')
.split('\n')
.slice(1)
.map((line) => line.trim().replace(/:\d+:\d+$/, ''))
.filter((line) => line && !line.includes('node_modules'))
.slice(0, 3)
.join('|');
const raw = `${error.name}|${normalize(error.message)}|${normalize(frames)}`;
// FNV-1a:短、稳定、无需依赖
let hash = 0x811c9dc5;
for (let i = 0; i < raw.length; i += 1) {
hash ^= raw.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
追问:
- 为什么只取前 3 个业务栈帧?→ 调用链上层的工具函数差异很大,取全部会把同一错误拆成很多指纹;取最靠近出错点的业务帧更稳定。
- 归一化过度会怎样?→ 不同错误被合并,定位时互相干扰;要保留错误类型与关键文案,只替换真正动态的部分。
- 指纹要不要包含版本?→ 聚合时不含版本(便于看趋势),查询时把版本作为维度;否则每次发版都会变成「新错误」。
8.4 场景设计题
题:白屏率 3%,但监控里错误量很低,告警又特别吵。你怎么在两周内把白屏降到 0.3%,同时把噪声压下去?
评估点:是否区分「没报错的白屏」、是否做归因与分级。
参考方案:
- 补检测:在
DOMContentLoaded、首屏渲染后、路由切换后三个时点做白屏检测,带上路由、版本、机型、网络。 - 归因:按版本与机型分布看突增;检查资源加载失败(CDN、DNS)、JS 执行错误被吞、接口超时导致空态、SSR 输出异常。
- 止血:对高频失败资源加备用 CDN;对接口加兜底与重试;坏版本快速回滚。
- 降噪:错误按 fingerprint 聚合,按影响用户数分级;只对「新增且影响面大」的告警;设静默窗口与抑制规则。
- 验收:白屏率 p75 与总量双降、告警条数下降、MTTR 缩短;把白屏检测加入发布卡点。
加分:指出「没有错误」不等于「没有故障」,白屏可能来自 CSS/资源/接口;指出要区分「全白」与「局部空白」。
减分:只加 try/catch;把所有错误都设成 P0;不做版本维度对比。
题 2:告警每天 300 条,其中真正需要处理的不到 10 条,但上个月漏掉了一次 P1。你怎么重构告警体系?
评估点:是否区分「信号」与「噪声」、是否建立分级与抑制、是否验证漏报。
参考方案:
- 盘点与标注:拉取近 30 天告警,标注「真实故障 / 已知问题 / 无效 / 重复」,算出每类占比与响应时长。
- 聚合:按 fingerprint 与服务维度合并同源告警;同一根因只保留一条主告警,衍生告警降级为事件记录。
- 分级:P0 电话(核心链路不可用)、P1 群消息(部分用户受影响)、P2 日报(趋势异常);每级明确响应时限与 owner。
- 抑制与静默:维护期、已知问题、上游故障时抑制下游告警;静默必须有时限与登记。
- 补漏:用合成拨测与 SLO 燃烧率兜底,防止「聚合后看不见」;每季度做一次告警演练,回放历史故障验证能否触发。
加分:指出「告警数量」不是目标,「可行动的告警占比」与「MTTD/MTTR」才是;指出要治理「告警疲劳」而不只是调阈值。
减分:直接关掉一半告警;把所有阈值调高;只加静默不做聚合。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
「window.onerror 能捕获所有错误」 | 漏资源错误、Promise rejection,且会被覆盖 |
| 「资源错误会冒泡」 | 不冒泡,必须在捕获阶段监听 |
| 「Source Map 放 CDN 方便排查」 | 会暴露源码,应私有存储并鉴权 |
| 「错误越少说明越稳定」 | 可能是采集缺失,白屏与卡顿未必报错 |
| 「所有错误都该告警」 | 只对可行动、影响面大的信号告警 |
| 「采样会漏掉问题」 | 采样用于控制成本,错误类通常全量或高比例采样 |
| 「埋点越多越好」 | 冗余埋点增加体积与维护成本,应有指标树 |
| 「SLO 是运维的事」 | 前端指标(白屏、INP、接口成功率)需要前端定义与负责 |
| 「错误率下降就是变好了」 | 也可能是采集坏了,要看采集健康度与合成监控 |
| 「告警阈值调低更安全」 | 噪声会让人习惯性忽略,漏报反而增加 |
| 「有 traceId 就能定位」 | 还需 span 分段、版本与变更记录,否则只有一串 ID |
| 「复盘写完就结束了」 | 改进项要进排期、有 owner、有验证,否则同类故障会复发 |
9. 延伸资源
- Google SRE Book:SLI/SLO/错误预算
- OpenTelemetry 文档(前端与 Node SDK)
- web.dev:Web Vitals 采集与 RUM
- Sentry / Datadog RUM 文档:错误聚类与 Source Map 上传
- 《Observability Engineering》(O'Reilly)
- Chaos Engineering 原则(Principles of Chaos Engineering)
- 各厂稳定性白皮书与故障复盘公开案例
- MDN:PerformanceObserver、Reporting API、
sendBeacon