
一句话:请求层可观测性要回答「这次请求为什么慢/为什么失败、影响多大、是谁的问题」——所以必须做到三件事:把耗时拆成可归因的几段、把错误分类并带上上下文、用
traceparent把前端与后端串成一条链路。📌 与
5.11 RUM 与 Web Vitals的分工:5.11 是「页面级指标(LCP/INP/CLS)」,本篇是「请求级(每个接口调用)」。
一、为什么不能只靠后端日志
⭐⭐⭐ 后端日志看不到的四类信息(这才是前端可观测性的价值):
① 【用户视角的「总耗时」】
后端日志:「服务端处理 120ms」
⭐ 但用户等了 **1.2 秒**——剩下的时间在哪?
· DNS/TCP/TLS(可能是 300ms)
· 请求排队(浏览器并发限制 / Service Worker 拦截)
· ⚠️ 弱网下的传输(4G 抖动、丢包重传)
· ⭐ 前端处理(JSON parse、状态更新、React 渲染)
→ ⭐ 「**慢在哪一段**」这个答案,只有前端能拼出来。
② 【重试与放大】
后端看到「3 次相同的请求」——它不知道「这是前端自动重试」还是「用户点了 3 次」。
⭐ 而「前端重试风暴」是「服务端被打挂」的常见原因之一。
③ 【取消与竞态】
后端看到「一个请求被 abort」——它不知道「是用户离开了页面」还是「前端丢弃了过期结果」。
⭐ 这直接关系到「服务端要不要为被取消的请求继续算」。
④ 【业务上下文】
⭐ 「这个失败发生在哪个页面、哪个操作、哪个版本、哪个用户分层」——
后端只有 trace/userId,前端才知道「用户当时想干什么」。
⭐ 一句话:后端日志回答「**服务端做了什么**」,
请求层可观测性回答「**用户经历了什么、为什么**」。
二、采集什么:四类信息
⭐⭐⭐ 四类必须采集的信息:
① 【性能】——把耗时拆成「可归因的段」
· 总耗时(从「发起」到「前端处理完」)
· ⭐ 分段:排队 / DNS / TCP+TLS / 请求发送 / **服务端处理(TTFB)** / 响应下载 / **前端解析与渲染**
· 分位数(P50/P75/P95/P99,⭐ 不要只看平均,见 5.11)
· ⭐ **串行瀑布**(「A 请求完才发 B」→ 首屏被串起来)与「重复请求」(同接口同参数被调多次)
② 【错误】——分类 + 频次 + 影响面
· 网络层(断网/超时/CORS/5xx)vs 业务层(4xx / GraphQL errors / 业务码)
· ⭐ 「错误率」与「受影响用户数/会话数」(比「错误次数」更有意义)
· ⭐ **「静默失败」**:前端 catch 了但没提示用户的错误(这类最容易积压成事故)
③ 【业务】——成功/失败与关键路径
· ⭐ 关键路径的「端到端成功率」(如「下单流程:创建→支付→回调」)
· ⭐ 「关键请求的耗时」纳入 SLO(如「商品详情接口 P95 < 800ms」)
· 「重试次数」「重试后成功」的比例(⭐ 反映「服务是否在抖」)
④ 【上下文】——让数据能「被解释」
· ⭐ **trace id**(用于与后端串联)
· 用户(id 或匿名 id)、版本(release)、页面(route)、
设备与网络(effectiveType/hardwareConcurrency)、是否首屏关键请求、
是否 SSR/hydration 期间、是否在 transition 中
⭐⭐⭐ 一个「够用」的耗时分解模型(前端视角):
total = 排队 + DNS + TCP + TLS + 请求发送 + 服务端处理 + 响应下载 + 前端处理
来源:
· 排队/DNS/TCP/TLS/发送/下载 → PerformanceResourceTiming(⭐ 见下)
· 服务端处理 = responseStart - requestStart(⭐ 这就是 TTFB 里属于服务端的那部分)
· ⭐ 前端处理 = 「请求完成 → 本次更新提交完成」(需要自己打点,或与 RUM 的 INP 关联)
// ✅ 从 PerformanceResourceTiming 里拿「分段耗时」
function getResourceTiming(url: string): PerformanceResourceTiming | undefined {
// ⚠️ 同名 URL 可能有多条 → 取「最后一条」(最近的那次)
const entries = performance.getEntriesByName(url) as PerformanceResourceTiming[];
return entries[entries.length - 1];
}
interface RequestTiming {
/** 浏览器排队(受「并发连接数限制」与「Service Worker 拦截」影响) */
queueMs: number;
dnsMs: number;
tcpMs: number;
tlsMs: number;
/** 请求发送 + 等待「首字节」(⭐ 含服务端处理) */
ttfbMs: number;
/** 响应下载 */
downloadMs: number;
totalMs: number;
/** ⚠️ 跨域资源可能拿不到细节(需要 `Timing-Allow-Origin`) */
crossOriginLimited: boolean;
}
export function decompose(entry: PerformanceResourceTiming): RequestTiming {
const { startTime, fetchStart, domainLookupStart, domainLookupEnd, connectStart, connectEnd, secureConnectionStart, requestStart, responseStart, responseEnd } = entry;
// ⚠️ 跨域(未设 Timing-Allow-Origin)时,这些值会是 0
const crossOriginLimited =
domainLookupStart === 0 && connectStart === 0 && requestStart > 0;
const queueMs = Math.max(0, fetchStart - startTime);
const dnsMs = domainLookupEnd > 0 ? Math.max(0, domainLookupEnd - domainLookupStart) : 0;
const tcpMs = connectEnd > 0 ? Math.max(0, connectEnd - connectStart) : 0;
const tlsMs = secureConnectionStart > 0 ? Math.max(0, connectEnd - secureConnectionStart) : 0;
const ttfbMs = Math.max(0, responseStart - requestStart);
const downloadMs = Math.max(0, responseEnd - responseStart);
return {
queueMs: Math.round(queueMs),
dnsMs: Math.round(dnsMs),
tcpMs: Math.round(tcpMs),
tlsMs: Math.round(tlsMs),
ttfbMs: Math.round(ttfbMs),
downloadMs: Math.round(downloadMs),
totalMs: Math.round(responseEnd - startTime),
crossOriginLimited,
};
}
⭐⭐⭐ 「怎么从分段耗时判断问题在哪」(这是一张「排查对照表」):
| 症状 | 大概率原因 | 该找谁 |
| --- | --- | --- |
| `dnsMs` 大(几十~几百 ms) | DNS 慢/无 `dns-prefetch` | 前端(加 `dns-prefetch`/`preconnect`,见 5.16) |
| `tcpMs + tlsMs` 大 | 建连慢/未复用连接 | 前端(`preconnect`)+ 运维(就近接入) |
| `queueMs` 大 | ⭐ 浏览器并发限制(同域 6 个)/ 被 SW 拦截 / 太多并发 | 前端(合并请求/减少并发/HTTP2) |
| ⭐ `ttfbMs` 大 | **服务端处理慢**(数据库/下游) | 后端(看它自己的 span) |
| `downloadMs` 大 | 响应体太大/压缩没开/弱网 | 后端(减小响应体 + gzip/br)+ 前端(分页/字段裁剪) |
| ⭐ 「总耗时大但分段都不大」 | **前端处理慢**(parse + 渲染)| 前端(与 RUM 的 INP 关联,见 5.11) |
| 「同一接口被调多次」 | 重复请求(组件重挂/依赖不稳/无缓存)| 前端(查依赖数组/加缓存,见 7.4) |
⭐ 这张表的价值:「**一个慢请求,5 秒内能定位到方向**」。
三、分布式追踪:用 traceparent 把前后端串起来
⭐⭐⭐ 核心:W3C Trace Context 的 `traceparent` 头
格式:traceparent: 00-<traceId 32hex>-<spanId 16hex>-<flags 2hex>
例子:traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
· version = 00
· trace-id = 32 位十六进制(⭐ 「一次用户操作」的全局 id)
· parent-id(span-id)= 16 位十六进制(⭐ 「这一次请求」的 id)
· flags = 01 表示「采样」(00 表示不采样)
⭐⭐ 前端注入它 → 后端把它作为「自己的 trace 的 parent」→
于是「前端上报」「后端日志」「APM」三方的数据能「串成一条链路」。
⭐ 这样你才能回答:「用户在商品详情页点支付时那次 3.2 秒的慢,到底是
前端排队、网关、还是数据库?」
⚠️ 关键细节:
· **采样要一致**:如果「后端采样了、前端没上报」→ 链路断裂
(⭐ 实践:由后端决定采样 → 通过响应头 `traceresponse` 回传 → 前端据此决定上报)
· ⚠️ **CORS**:自定义头 `traceparent` 会触发「预检请求」→
要么后端允许它,要么「只在同源请求上注入」
· ⚠️ 别把「敏感信息」放进 trace id(它是会被记录并跨系统传递的)
/** ============ 生成与解析 trace 上下文(零依赖实现) ============ */
export interface TraceContext {
traceId: string;
spanId: string;
sampled: boolean;
}
function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return [...buf].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export function createTraceContext(sampled = true): TraceContext {
return {
traceId: randomHex(16), // ⭐ 32 hex = 16 字节
spanId: randomHex(8), // ⭐ 16 hex = 8 字节
sampled,
};
}
export function formatTraceparent(ctx: TraceContext): string {
return `00-${ctx.traceId}-${ctx.spanId}-${ctx.sampled ? '01' : '00'}`;
}
export function parseTraceparent(header: string | null): TraceContext | null {
if (!header) return null;
// ⭐ 严格校验格式(否则「脏数据」会污染整条链路)
const m = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})/.exec(header.trim());
if (!m) return null;
const [, version, traceId, spanId, flags] = m;
if (version === 'ff') return null; // ⭐ 规范里 ff 是「非法版本」
if (traceId === '0'.repeat(32) || spanId === '0'.repeat(16)) return null; // ⭐ 全 0 非法
return { traceId: traceId!, spanId: spanId!, sampled: (parseInt(flags!, 16) & 1) === 1 };
}
/** ⭐ 从「后端的响应头」读回采样决策(让前后端采样一致) */
export function readSampleDecision(response: Response): boolean | undefined {
// ⚠️ 自定义响应头在跨域下「默认不可读」→ 需要 `Access-Control-Expose-Headers`
const tp = response.headers.get('traceparent');
if (!tp) return undefined;
return parseTraceparent(tp)?.sampled;
}
四、完整实现:一个请求可观测层(250 行)
// request-observability.ts —— 包装 fetch,采集「性能 + 错误 + 上下文」,采样上报
import { decompose, createTraceContext, formatTraceparent, parseTraceparent } from './trace';
/** ============ 1. 上报载荷 ============ */
interface RequestEvent {
kind: 'request';
/** ⭐ 链路 id(与后端串联) */
traceId: string;
spanId: string;
/** 接口信息(⭐ 用「路由模板」而不是「真实 URL」,避免高基数与敏感参数) */
method: string;
route: string;
/** 结果 */
ok: boolean;
status: number | null;
/** ⭐ 错误分类(见下) */
errorKind?: 'network' | 'timeout' | 'aborted' | 'client' | 'server' | 'unknown';
/** 耗时 */
totalMs: number;
timing?: ReturnType<typeof decompose>;
/** ⭐ 上下文 */
page: string;
release: string;
/** 是否「首屏关键请求」 */
priority: 'critical' | 'normal' | 'background';
/** 重试次数(0 = 首次) */
attempt: number;
/** 响应体大小(若可得) */
transferSize?: number;
ts: number;
}
type Reporter = (events: RequestEvent[]) => void;
/** ============ 2. 分类错误(⭐ 与 7.10 的「两级错误」同源) ============ */
function classifyRequestError(err: unknown, status: number | null): RequestEvent['errorKind'] {
if (err instanceof DOMException && err.name === 'AbortError') return 'aborted';
if (err instanceof DOMException && err.name === 'TimeoutError') return 'timeout';
if (status === null) return 'network';
if (status >= 500) return 'server';
if (status >= 400) return 'client';
return 'unknown';
}
/** ============ 3. 采样(⭐ 分级采样:差的全报、好的采样) ============ */
export interface SamplingOptions {
/** 正常请求的采样率(0~1) */
sampleRate?: number;
/** ⭐ 「慢」的阈值(超过就「必报」) */
slowThresholdMs?: number;
/** ⭐ 「关键请求」的采样率(更高) */
criticalSampleRate?: number;
}
function shouldReport(event: RequestEvent, options: SamplingOptions): boolean {
const { sampleRate = 0.05, slowThresholdMs = 2000, criticalSampleRate = 0.5 } = options;
// ⭐ ① 失败必报(错误是最重要的信号)
if (!event.ok) return true;
// ⭐ ② 慢请求必报(性能问题的样本)
if (event.totalMs >= slowThresholdMs) return true;
// ⭐ ③ 首屏关键请求报得多一些
if (event.priority === 'critical') return Math.random() < criticalSampleRate;
// ④ 其余按采样率
return Math.random() < sampleRate;
}
/** ============ 4. 上报器(批量 + sendBeacon + 页面隐藏时 flush) ============ */
function createReporter(endpoint: string, flushIntervalMs = 5000): Reporter {
let buffer: RequestEvent[] = [];
let timer: ReturnType<typeof setInterval> | null = null;
const flush = (useBeacon: boolean) => {
if (buffer.length === 0) return;
const body = JSON.stringify({ events: buffer });
buffer = [];
if (useBeacon && typeof navigator.sendBeacon === 'function') {
// ⭐ 页面卸载时用 sendBeacon(不会被中断)
navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
return;
}
// ⭐ 普通上报用 fetch + keepalive(也能在卸载时发出)
void fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* 上报失败静默(绝不能因为上报而影响业务) */ });
};
// ⭐ 定时批量(降低上报请求数)
timer = setInterval(() => flush(false), flushIntervalMs);
// ⭐ 页面隐藏/卸载时「立刻上报」
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush(true);
});
window.addEventListener('pagehide', () => flush(true));
return (events) => {
buffer.push(...events);
// ⭐ 缓冲满了立刻发(避免「长时间不报」)
if (buffer.length >= 30) flush(false);
void timer;
};
}
/** ============ 5. 核心:包装 fetch ============ */
export interface ObservabilityOptions extends SamplingOptions {
/** 上报端点 */
endpoint: string;
/** ⭐ 把 URL 归一成「路由模板」(去掉 id/query,避免高基数) */
normalizeRoute?: (url: string) => string;
/** ⭐ 判定「是否首屏关键请求」 */
getPriority?: (url: string, init?: RequestInit) => RequestEvent['priority'];
/** 当前页面(路由) */
getPage: () => string;
/** 版本 */
release: string;
/** ⭐ 注入 trace 头(默认开启) */
injectTrace?: boolean;
/** 超时(ms) */
timeoutMs?: number;
}
export function installFetchObservability(options: ObservabilityOptions) {
const {
endpoint,
normalizeRoute = defaultNormalizeRoute,
getPriority = () => 'normal',
getPage,
release,
injectTrace = true,
timeoutMs = 30_000,
...sampling
} = options;
const report = createReporter(endpoint);
const originalFetch = window.fetch.bind(window);
// ⭐⭐ 用「当前活跃的 trace」:同一「用户操作」触发的多个请求共享 traceId
// (这样「一次点击引起的 3 个请求」能被归为「一次操作」)
let currentTraceId: string | null = null;
let traceResetTimer: ReturnType<typeof setTimeout> | null = null;
/** ⭐ 业务代码可在「一次用户操作开始时」调用它(如点击处理器入口) */
function beginTrace() {
currentTraceId = createTraceContext().traceId;
if (traceResetTimer) clearTimeout(traceResetTimer);
// ⭐ 2 秒后「重置」(避免「两个不相关的操作共享 trace」)
traceResetTimer = setTimeout(() => { currentTraceId = null; }, 2000);
}
window.fetch = async function observedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
const route = normalizeRoute(url);
const priority = getPriority(url, init);
// ⭐ ① 生成 span(同源才注入,避免 CORS 预检)
const sameOrigin = (() => { try { return new URL(url, location.href).origin === location.origin; } catch { return false; } })();
const trace = createTraceContext();
const traceId = currentTraceId ?? trace.traceId;
const spanId = trace.spanId;
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (injectTrace && sameOrigin) {
// ⭐⭐ 注入 traceparent(W3C 标准)→ 后端能把它接到自己的 trace 上
headers.set('traceparent', formatTraceparent({ traceId, spanId, sampled: true }));
}
// ⭐ ② 超时(用 AbortController 与「外部 signal」合并)
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs);
const signal = mergeSignals(init?.signal ?? null, timeoutController.signal);
// ⭐ ③ 计时开始
const start = performance.now();
let attempt = Number(headers.get('x-retry-attempt') ?? 0);
try {
const response = await originalFetch(input, { ...init, headers, signal });
const elapsed = performance.now() - start;
// ⭐ ④ 从响应头读回「后端采样决策」(半路改采样,见 trace 那节)
const serverSampled = (() => {
const tp = response.headers.get('traceparent');
return tp ? parseTraceparent(tp)?.sampled : undefined;
})();
// ⭐ ⑤ 拿「分段耗时」(⚠️ 可能拿不到:跨域未暴露 / 缓存命中)
const timing = (() => {
const entries = performance.getEntriesByName(url, 'resource') as PerformanceResourceTiming[];
const last = entries[entries.length - 1];
return last ? decompose(last) : undefined;
})();
const event: RequestEvent = {
kind: 'request',
traceId,
spanId,
method,
route,
ok: response.ok,
status: response.status,
errorKind: response.ok ? undefined : classifyRequestError(null, response.status),
totalMs: Math.round(elapsed),
timing,
page: getPage(),
release,
priority,
attempt,
transferSize: (() => {
const e = performance.getEntriesByName(url, 'resource') as PerformanceResourceTiming[];
const last = e[e.length - 1];
return last?.transferSize || undefined;
})(),
ts: Date.now(),
};
// ⭐ ⑥ 采样上报(⭐ 失败/慢请求必报)
const should = serverSampled === false ? false : shouldReport(event, sampling);
if (should) report([event]);
void attempt;
return response;
} catch (err) {
const elapsed = performance.now() - start;
const errorKind = classifyRequestError(err, null);
// ⚠️ 「用户主动取消」不算错误(⭐ 否则「离开页面」会被统计成「大量错误」)
if (errorKind === 'aborted' && (init?.signal?.aborted || (err as { name?: string })?.name === 'AbortError')) {
// ⭐ 但如果「是我们自己的超时」→ 要报(那就是真问题)
const isOurTimeout = timeoutController.signal.aborted;
if (!isOurTimeout) throw err;
}
const event: RequestEvent = {
kind: 'request',
traceId,
spanId,
method,
route,
ok: false,
status: null,
errorKind: timeoutController.signal.aborted ? 'timeout' : errorKind,
totalMs: Math.round(elapsed),
page: getPage(),
release,
priority,
attempt,
ts: Date.now(),
};
// ⭐ 失败必报
report([event]);
throw err;
} finally {
clearTimeout(timeoutId);
}
};
/** ⭐ 卸载时恢复原始 fetch(便于测试/HMR) */
return {
restore: () => { window.fetch = originalFetch; },
beginTrace,
report,
};
}
/** ⭐ 合并「外部 signal」与「超时 signal」(两者任一 abort 都 abort) */
function mergeSignals(a: AbortSignal | null, b: AbortSignal): AbortSignal {
if (!a) return b;
if (a.aborted) return a;
const controller = new AbortController();
const onAbort = (e: Event) => {
controller.abort((e.target as AbortSignal).reason);
};
a.addEventListener('abort', onAbort, { once: true });
b.addEventListener('abort', onAbort, { once: true });
return controller.signal;
}
/** ⭐⭐ URL 归一化:把「动态片段」替换成模板(避免高基数 + 脱敏) */
export function defaultNormalizeRoute(url: string): string {
try {
const u = new URL(url, location.href);
const path = u.pathname
// ⭐ UUID / 数字 id / 长 hash → 模板占位
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:uuid')
.replace(/\/\d+/g, '/:id')
.replace(/\/[0-9a-f]{16,}/gi, '/:hash');
// ⚠️ 只保留「白名单参数的 key」,不保留值(⭐ 避免把 token/邮箱上报出去)
const safeKeys = ['page', 'pageSize', 'sort', 'order', 'status'];
const params = [...u.searchParams.keys()].filter((k) => safeKeys.includes(k));
return `${u.pathname === path ? path : path}${params.length ? `?${params.map((k) => `${k}=:v`).join('&')}` : ''}`;
} catch {
return 'unknown';
}
}
/** ============ 6. 与 RUM 关联(⭐ 把「请求慢」和「页面指标差」串起来) ============ */
export function linkRequestToRum(event: RequestEvent, rumEvent: { name: string; traceId?: string }) {
// ⭐ 思路:RUM 的 LCP/INP 也带上「当时正在进行的请求的 traceId」→
// 于是「LCP 慢」能一键跳到「那次的接口调用链」
return { ...rumEvent, traceId: event.traceId };
}
/** ⭐⭐ 实践做法:
* ① 在「首屏关键请求」发出时「记下 traceId」
* ② LCP 上报时「带上这个 traceId」
* ③ 看板上就能「从 LCP 下钻到接口」
*/
let criticalRequestTraceId: string | null = null;
export function markCriticalRequest(traceId: string) { criticalRequestTraceId = traceId; }
export function getCriticalRequestTraceId() { return criticalRequestTraceId; }
/** ============ 7. 接线(应用入口) ============ */
// main.tsx
import { installFetchObservability, defaultNormalizeRoute } from './request-observability';
const observability = installFetchObservability({
endpoint: '/api/telemetry/requests',
release: __APP_VERSION__,
getPage: () => location.pathname,
normalizeRoute: defaultNormalizeRoute,
// ⭐ 首屏关键请求「报得多」
getPriority: (url) => {
if (/\/api\/(me|config|home)/.test(url)) return 'critical';
if (/\/api\/analytics/.test(url)) return 'background';
return 'normal';
},
// ⭐ 采样:正常 5%、慢(>2s)或失败「必报」、关键 50%
sampleRate: 0.05,
criticalSampleRate: 0.5,
slowThresholdMs: 2000,
injectTrace: true,
});
// ⭐ 在「用户操作的入口」开始一次 trace(让「一次点击的 3 个请求」共享 traceId)
document.addEventListener('click', () => observability.beginTrace(), { capture: true, passive: true });
⭐⭐⭐ 这个实现里的六个「工程关键点」:
① 【URL 归一化(`route`)】⭐ 最容易忽略但最重要
直接把 URL 上报会有两个问题:
· **高基数**(`/api/order/12345` 与 `/api/order/67890` 是「两个不同的指标」)→
看板上「一万个接口」,无法聚合
· ⚠️ **敏感信息泄漏**(URL 里可能有 token/邮箱/手机号)
✅ 归一成「路由模板」(`/api/order/:id`)+ **只上报白名单参数的 key**。
② 【「用户主动取消」不算错误】⭐
用户「切页面」会 abort 请求——如果不区分,看板上会出现
「大量 aborted 错误」→ 掩盖真实问题。
⚠️ 但「我们自己的超时 abort」**要报**(那是真问题)。
③ 【采样分级】⭐
「失败必报 + 慢请求必报 + 关键请求高采样 + 其余低采样」——
否则要么「数据太多」(打爆监控),要么「关键信号被采样掉」。
④ 【`sendBeacon` / `keepalive`】⭐
页面卸载时的数据必须用 `sendBeacon` 或 `fetch(..., { keepalive: true })` 才能发出。
⑤ 【`traceparent` 注入】⭐
这是「前后端可观测性打通」的关键——没有它,你只能「猜」慢在哪。
⚠️ 但要注意「同源才注入」(避免 CORS 预检)。
⑥ 【上报绝不阻塞业务】
`report()` 里的 fetch 用 `.catch(() => {})`(⭐ 上报失败**绝不影响业务请求**),
并且「批量 + 采样」降低上报本身的成本。
五、看板与 SLO:数据要变成「决策」
⭐⭐⭐ 四个「值得做」的看板(从最有价值到次要):
① 【关键接口的「分位数 + 错误率」趋势】
X 轴:时间;Y 轴:P50/P95/P99 + 错误率
⭐ 并按「版本 / 网络类型 / 页面」分组 → 「哪个版本把它搞慢了」
⭐ 这是「最能提前发现问题」的看板
② 【「慢请求」的归因分解(Top N)】
⭐ 把「最慢的 20 个请求」按「分段占比」排列 →
「这些慢请求里,60% 是 TTFB 大」→ 直接指向服务端
→ 这是「排查对照表」的落地形态
③ 【「错误」按类型 + 影响面】
「4xx / 5xx / 网络 / 超时」各占多少 + ⭐ 「受影响会话数」
⚠️ 「次数」不够,「受影响的用户/会话」才有意义
④ 【「重试与放大」】
⭐ 「重试占比」与「重试后成功率」——
「重试占比突然升高」往往预示「服务端在抖」(在「用户投诉」之前就能发现)
⭐⭐ SLO 怎么写(示例):
· 「商品详情接口:P95 < 800ms,错误率 < 0.5%」
· 「下单流程端到端成功率 > 99%」
· ⭐ 「首屏关键请求(/api/home)P95 < 500ms」
⭐ 注意:「前端可观测性」的 SLO 应该「以用户视角」定(不是「服务端处理时间」)。
六、本篇特有的坑
// ① 直接上报「完整 URL」(高基数 + 敏感信息泄漏)⭐
report({ url: '/api/order/12345?token=abc' }); // ❌
// ✅ 归一成 `/api/order/:id`,参数只留 key
// ② 把「用户主动取消」算成错误(看板上「大量 aborted」掩盖真问题)
// ✅ 区分「外部 signal abort」(忽略)与「我们自己的超时」(上报)
// ③ 「全量上报」(把监控打爆 / 上报本身成了性能问题)
// ✅ 分级采样(失败/慢必报 + 其余低采样)
// ④ 上报「阻塞业务」(await 上报)
await report(...); // ❌ 慢上报会拖慢业务
// ✅ 上报「不 await + catch 静默」
// ⑤ 页面卸载时「上报丢失」(没用 sendBeacon/keepalive)
// ✅ `navigator.sendBeacon` 或 `fetch(..., { keepalive: true })`
// ⑥ 跨域资源「拿不到分段耗时」但以为「没有耗时」
// ⚠️ 跨域未设 `Timing-Allow-Origin` 时 `domainLookupStart` 等为 0
// ✅ 判断 `crossOriginLimited` 并「只上报可得的部分」
// ⑦ 「traceparent 注入」引发 CORS 预检(跨域接口)
// ✅ 只对「同源请求」注入;或后端允许该头
// ⑧ 「前后端采样不一致」(后端采了、前端没报 → 链路断裂)
// ✅ 用「响应头回传采样决策」(`traceparent` 或 `traceresponse`)
// ⑨ 把「敏感参数」放进 trace/上报(⭐ 会被跨系统传递并长期保存)
// ✅ 只放 id;脱敏(手机号/邮箱做 hash)
// ⑩ 「平均耗时」当指标(被快请求拉低,掩盖长尾)
// ✅ 分位数(P50/P95/P99,见 5.11)
// ⑪ 「只看次数不看影响面」(一个用户重试 100 次 vs 100 个用户各失败 1 次)
// ✅ 「受影响会话数」与「错误率」
// ⑫ 「首屏关键请求」没有单独标记(无法判断「它慢了会不会影响 LCP」)
// ✅ 标 `priority: 'critical'` + 与 RUM 关联(带上 traceId)
// ⑬ 「重试」没有计数(无法发现「服务在抖」)
// ✅ 每次重试带 `attempt` 并上报
// ⑭ 上报里「没有版本号」(无法判断「是这次发布搞慢的」)
// ✅ 带 `release`(见 8.9 的灰度/版本)
// ⑮ 「把所有 fetch 都包装」但漏了:
// · `XMLHttpRequest`(老代码/上传)
// · `navigator.sendBeacon`(本身是上报)
// · Service Worker 里的 fetch(⭐ 见 10.11)
// · 第三方 SDK 自己发的请求(⚠️ 无法包装)
// ✅ 至少「在 API 封装层统一采集」(比「全局包装」更可控,见 7.1)
// ⑯ 「上报接口本身」也被观测(无限递归/噪声)
// ✅ 排除「上报端点」
// ⑰ 「隐私合规」:上报里带了「可识别到个人」的信息
// ✅ 用户 id 用「内部匿名 id」+ URL 脱敏 + 「尊重 Do-Not-Track/同意状态」
// ⑱ 「采样率」固定(高峰期数据爆炸/低峰期样本不足)
// ⭐ 可以「服务端下发采样率」(动态调整)
// ⑲ 「只看前端指标」不看「后端 span」(各自为政)
// ✅ 用 trace id 打通;⭐ 看板上「一键跳转」
// ⑳ 「没有告警」(数据只躺在看板上)
// ✅ 对「P95 恶化 30%」「错误率 > 1%」设告警(并且 ⭐ 告警要能定位到版本/接口)
// ⑮ 的完整处理(在「API 封装层」统一采集,而不是「全局包装 fetch」)
// ⭐ 好处:可控(能拿到「业务语义」,如「这是哪个业务的请求」)
export function createObservedClient(options: {
baseURL: string;
observe: (e: { route: string; ms: number; ok: boolean; status?: number; errorKind?: string; traceId: string }) => void;
}) {
return async function request<T>(
route: string, // ⭐ 业务侧传「路由模板」,天然无高基数
init?: RequestInit & { timeoutMs?: number }
): Promise<T> {
const traceId = crypto.randomUUID().replace(/-/g, '');
const start = performance.now();
try {
const res = await fetch(`${options.baseURL}${route}`, {
...init,
headers: {
...init?.headers,
// ⭐ 注入 traceparent(同源/SDK 内部即可)
traceparent: `00-${traceId}-${traceId.slice(0, 16)}-01`,
},
});
options.observe({ route, ms: performance.now() - start, ok: res.ok, status: res.status, traceId });
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
return res.json() as Promise<T>;
} catch (err) {
options.observe({
route,
ms: performance.now() - start,
ok: false,
errorKind: (err as { status?: number })?.status ? 'http' : 'network',
traceId,
});
throw err;
}
};
}
七、面试延伸
- 「为什么需要前端的请求层可观测性?后端日志不够吗?」
⭐ 不够,因为后端看不到四类信息:① ⭐ 用户视角的总耗时——后端日志说「服务端处理 120ms」,但用户等了 1.2 秒,剩下 1 秒在哪(DNS/TCP/TLS、请求排队、弱网传输、前端解析与渲染)只有前端能拼出来;② 重试与放大——后端看到「3 次相同请求」,不知道「是前端自动重试还是用户点了 3 次」,而前端重试风暴是「服务端被打挂」的常见原因;③ 取消与竞态——后端看到「请求被 abort」,不知道「是用户离开页面还是前端丢弃了过期结果」;④ ⭐ 业务上下文——「这个失败发生在哪个页面、哪个操作、哪个版本、哪个用户分层」。一句话:后端日志回答「服务端做了什么」,请求层可观测性回答「用户经历了什么、为什么」。 -
「一个慢请求,你怎么定位它是谁的锅?」
⭐ 把耗时拆成可归因的段(用PerformanceResourceTiming):排队 + DNS + TCP + TLS + 请求发送 + 服务端处理(TTFB)+ 响应下载 + 前端处理。排查对照表:dnsMs大 → DNS(加dns-prefetch);tcpMs+tlsMs大 → 建连(preconnect);queueMs大 → ⭐ 浏览器并发限制(同域 6 个)或被 Service Worker 拦截;⭐ttfbMs大 → 服务端处理慢(让它去看自己的 span);downloadMs大 → 响应体太大/压缩没开/弱网;⭐ 「总耗时大但分段都不大」→ 前端处理慢(与 RUM 的 INP 关联,见 5.11);「同一接口被调多次」→ 重复请求(组件重挂/依赖不稳/无缓存)。⚠️ 注意跨域资源需要Timing-Allow-Origin才能拿到这些细节(否则是 0)。 -
「
traceparent是什么?为什么要用它?」
它是 W3C Trace Context 标准的分布式追踪头,格式00-<traceId 32hex>-<spanId 16hex>-<flags 2hex>(如00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)。⭐ 前端注入它,后端把它作为自己的 trace 的 parent → 于是「前端上报」「后端日志」「APM」三方的数据能串成一条链路,你才能回答「用户点支付时那次 3.2 秒的慢,到底是前端排队、网关还是数据库」。两个关键细节:① ⭐ 采样要一致——如果「后端采了样、前端没上报」链路就断了,✅ 实践是「由后端决定采样,通过响应头回传,前端据此决定是否上报」;② ⚠️ CORS——自定义头traceparent会触发预检请求,所以要「只对同源请求注入」或让后端允许该头。 -
「上报策略该怎么设计?」
五条:① ⭐ 分级采样——失败必报 + 慢请求(超阈值)必报 + 关键请求高采样 + 其余低采样(否则要么「数据太多打爆监控」,要么「关键信号被采样掉」);② ⭐ 批量——攒够一批(或定时)再发(降低上报请求数);③ ⭐sendBeacon/keepalive——页面卸载时的数据必须这样才能发出;④ ⭐ 上报绝不阻塞业务——不await、.catch(() => {})、并且排除上报端点本身(避免递归);⑤ ⭐ 脱敏与合规——URL 归一成路由模板(/api/order/:id)、只上报白名单参数的 key、用户 id 用匿名 id、尊重用户的同意状态。另外:「采样率可以由服务端下发」(动态调整,应对高峰期数据爆炸)。 -
「为什么『直接上报 URL』是个坑?」
两个原因:① ⭐ 高基数(High cardinality)——/api/order/12345与/api/order/67890在监控里是两个不同的指标 → 看板上会出现「一万个接口」,无法聚合、无法设阈值、存储成本爆炸;✅ 必须归一成「路由模板」(/api/order/:id,把 UUID/数字/hash 替换成占位符);② ⭐ 敏感信息泄漏——URL 里可能有 token、邮箱、手机号、搜索关键词,而「可观测性数据」会被跨系统传递并长期保存(进日志、进 APM、进数据仓库)→ ⚠️ 一旦泄漏影响面很大。✅ 做法是「只上报白名单参数的 key(不报值)」+「用户标识用匿名 id」。 -
「『用户主动取消』和『超时』为什么要区分?」
因为它们意义完全相反:① ⭐ 用户主动取消(切页面、点了别的按钮 →AbortController.abort())是「正常行为」,如果把它算成错误 → 看板上会出现「大量 aborted 错误」,掩盖真实问题,甚至让告警误报;② ⭐ 「我们自己的超时」(setTimeout到点后 abort)是「真实问题」——说明「请求太慢了」,必须上报。实现要点:在 catch 里判断「是init.signal.aborted(外部取消)还是timeoutController.signal.aborted(我们的超时)」,前者忽略、后者上报。⭐ 顺带:「取消」也意味着「服务端可以不继续算」——所以「取消率」本身也是个有价值的指标(反映「用户等不及」)。 -
「可观测性的数据怎么看才有用?」
四个看板 + SLO:① ⭐ 关键接口的「分位数 + 错误率」趋势(按版本/网络/页面分组)——最能提前发现问题(「哪个版本把它搞慢了」);② ⭐ 「慢请求的归因分解 Top N」——把「最慢的 20 个请求」按「分段占比」排列(「60% 是 TTFB 大」→ 直接指向服务端),这是「排查对照表」的落地形态;③ 「错误按类型 + 影响面」——⚠️ 「受影响会话数」比「错误次数」有意义(一个用户重试 100 次 ≠ 100 个用户各失败一次);④ ⭐ 「重试占比与重试后成功率」——「重试占比突然升高」往往预示「服务端在抖」,在用户投诉之前就能发现。SLO 要「以用户视角」定(如「商品详情接口 P95 < 800ms」,而不是「服务端处理时间」)。 -
「如果让你从零搭一套请求层可观测性,怎么做?」
五步:① ⭐ 采集点放在「API 封装层」而不是「全局包装 fetch」——好处是「能拿到业务语义」(知道「这是哪个业务的请求」),且不会漏掉 XHR/SDK 自身的请求,也不会把「上报接口」也包进去(见 7.1);② ⭐ 归一化 URL + 分类错误 + 注入traceparent(这是「可归因」的基础);③ ⭐ 分级采样 + 批量 +sendBeacon(保证「关键信号不丢」且「不打爆监控」);④ ⭐ 与 RUM 关联——「首屏关键请求」记下 traceId,LCP 上报时带上它 → 看板上能「从 LCP 下钻到接口调用链」(见 5.11);⑤ 看板 + SLO + 告警(并且告警要能定位到版本/接口)。落地顺序建议:「先做『错误 + 慢请求 + trace id』(成本最低、收益最大)→ 再做分段耗时 → 最后做看板与 SLO」。
一句话速记
请求层可观测性回答「用户经历了什么、为什么」(后端只能回答「服务端做了什么」);⭐ 耗时必须拆成可归因的段:
排队 + DNS + TCP + TLS + 发送 + 服务端处理(TTFB) + 下载 + 前端处理(来自PerformanceResourceTiming,⚠️ 跨域需要Timing-Allow-Origin才能拿到细节)——排查对照表:queueMs大 → 浏览器并发限制/SW;ttfbMs大 → 服务端;downloadMs大 → 响应体/压缩/弱网;总耗时大但分段都不大 → 前端解析与渲染;⭐ 用traceparent(W3C,00-<32hex>-<16hex>-<01>)把前后端串起来(⚠️ 采样要一致 → 用「响应头回传采样决策」;⚠️ 同源才注入避免 CORS 预检);⭐ 上报五条纪律:分级采样(失败/慢必报 + 关键高采样 + 其余低采样)、批量、sendBeacon/keepalive、绝不阻塞业务(不 await + catch 静默 + 排除上报端点)、脱敏;⭐ 两个最容易犯的坑:直接上报 URL(高基数导致无法聚合 + 敏感信息泄漏 → 必须归一成/api/order/:id且参数只留 key)、把「用户主动取消」算成错误(掩盖真问题 → 必须区分「外部 abort」与「我们的超时 abort」);看四个看板:关键接口分位数+错误率(按版本/网络分组)→ 慢请求归因 Top N → 错误按影响会话数 → 重试占比(预示服务在抖);与 RUM 关联(首屏关键请求的 traceId 带到 LCP 上,实现「从指标下钻到调用链」)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!