陌上人如玉
公子世无双

7.13 请求层可观测性

7.13 请求层可观测性

一句话:请求层可观测性要回答「这次请求为什么慢/为什么失败、影响多大、是谁的问题」——所以必须做到三件事:把耗时拆成可归因的几段、把错误分类并带上上下文、用 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;
    }
  };
}

七、面试延伸

  1. 「为什么需要前端的请求层可观测性?后端日志不够吗?」
    ⭐ 不够,因为后端看不到四类信息:① ⭐ 用户视角的总耗时——后端日志说「服务端处理 120ms」,但用户等了 1.2 秒,剩下 1 秒在哪(DNS/TCP/TLS、请求排队、弱网传输、前端解析与渲染)只有前端能拼出来;② 重试与放大——后端看到「3 次相同请求」,不知道「是前端自动重试还是用户点了 3 次」,而前端重试风暴是「服务端被打挂」的常见原因;③ 取消与竞态——后端看到「请求被 abort」,不知道「是用户离开页面还是前端丢弃了过期结果」;④ ⭐ 业务上下文——「这个失败发生在哪个页面、哪个操作、哪个版本、哪个用户分层」。一句话:后端日志回答「服务端做了什么」,请求层可观测性回答「用户经历了什么、为什么」。

  2. 「一个慢请求,你怎么定位它是谁的锅?」
    ⭐ 把耗时拆成可归因的段(用 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)。

  3. 「traceparent 是什么?为什么要用它?」
    它是 W3C Trace Context 标准的分布式追踪头,格式 00-<traceId 32hex>-<spanId 16hex>-<flags 2hex>(如 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01)。⭐ 前端注入它,后端把它作为自己的 trace 的 parent → 于是「前端上报」「后端日志」「APM」三方的数据能串成一条链路,你才能回答「用户点支付时那次 3.2 秒的慢,到底是前端排队、网关还是数据库」。两个关键细节:① ⭐ 采样要一致——如果「后端采了样、前端没上报」链路就断了,✅ 实践是「由后端决定采样,通过响应头回传,前端据此决定是否上报」;② ⚠️ CORS——自定义头 traceparent 会触发预检请求,所以要「只对同源请求注入」或让后端允许该头。

  4. 「上报策略该怎么设计?」
    五条:① ⭐ 分级采样——失败必报 + 慢请求(超阈值)必报 + 关键请求高采样 + 其余低采样(否则要么「数据太多打爆监控」,要么「关键信号被采样掉」);② ⭐ 批量——攒够一批(或定时)再发(降低上报请求数);③ ⭐ sendBeacon / keepalive——页面卸载时的数据必须这样才能发出;④ ⭐ 上报绝不阻塞业务——不 await、.catch(() => {})、并且排除上报端点本身(避免递归);⑤ ⭐ 脱敏与合规——URL 归一成路由模板(/api/order/:id)、只上报白名单参数的 key、用户 id 用匿名 id、尊重用户的同意状态。另外:「采样率可以由服务端下发」(动态调整,应对高峰期数据爆炸)。

  5. 「为什么『直接上报 URL』是个坑?」
    两个原因:① ⭐ 高基数(High cardinality)——/api/order/12345 与 /api/order/67890 在监控里是两个不同的指标 → 看板上会出现「一万个接口」,无法聚合、无法设阈值、存储成本爆炸;✅ 必须归一成「路由模板」(/api/order/:id,把 UUID/数字/hash 替换成占位符);② ⭐ 敏感信息泄漏——URL 里可能有 token、邮箱、手机号、搜索关键词,而「可观测性数据」会被跨系统传递并长期保存(进日志、进 APM、进数据仓库)→ ⚠️ 一旦泄漏影响面很大。✅ 做法是「只上报白名单参数的 key(不报值)」+「用户标识用匿名 id」。

  6. 「『用户主动取消』和『超时』为什么要区分?」
    因为它们意义完全相反:① ⭐ 用户主动取消(切页面、点了别的按钮 → AbortController.abort())是「正常行为」,如果把它算成错误 → 看板上会出现「大量 aborted 错误」,掩盖真实问题,甚至让告警误报;② ⭐ 「我们自己的超时」(setTimeout 到点后 abort)是「真实问题」——说明「请求太慢了」,必须上报。实现要点:在 catch 里判断「是 init.signal.aborted(外部取消)还是 timeoutController.signal.aborted(我们的超时)」,前者忽略、后者上报。⭐ 顺带:「取消」也意味着「服务端可以不继续算」——所以「取消率」本身也是个有价值的指标(反映「用户等不及」)。

  7. 「可观测性的数据怎么看才有用?」
    四个看板 + SLO:① ⭐ 关键接口的「分位数 + 错误率」趋势(按版本/网络/页面分组)——最能提前发现问题(「哪个版本把它搞慢了」);② ⭐ 「慢请求的归因分解 Top N」——把「最慢的 20 个请求」按「分段占比」排列(「60% 是 TTFB 大」→ 直接指向服务端),这是「排查对照表」的落地形态;③ 「错误按类型 + 影响面」——⚠️ 「受影响会话数」比「错误次数」有意义(一个用户重试 100 次 ≠ 100 个用户各失败一次);④ ⭐ 「重试占比与重试后成功率」——「重试占比突然升高」往往预示「服务端在抖」,在用户投诉之前就能发现。SLO 要「以用户视角」定(如「商品详情接口 P95 < 800ms」,而不是「服务端处理时间」)。

  8. 「如果让你从零搭一套请求层可观测性,怎么做?」
    五步:① ⭐ 采集点放在「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 上,实现「从指标下钻到调用链」)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.13 请求层可观测性

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫