陌上人如玉
公子世无双

8.7 前端错误监控与可观测性

8.7 前端错误监控与可观测性

一句话:前端错误监控的核心是「把『压缩后的一行报错』还原成『能定位到源码的行为链路』」——所以四件事缺一不可:全局捕获(含跨域脚本)→ Sourcemap 还原 → 指纹分组去重 → 面包屑/上下文(用户当时在干什么)。

📌 与 7.13 请求层可观测性 的分工:7.13 是「每个接口调用」;本篇是「整体错误与可观测体系(JS 错误、资源错误、框架错误、录屏、告警)」。

一、错误的五种来源(缺一类就是「盲区」)

⭐⭐⭐ 前端错误的五个来源(每一类都要单独接):

① 【JS 运行时错误】
   `window.addEventListener('error', ...)` 能捕获「同步代码」里的未捕获错误
   ⚠️ 但它**不会**捕获:
     · Promise 里的错误(→ 见 ②)
     · React 组件渲染里的错误(→ 见 ④,React 会「吞掉」并交给 errorBoundary)
     · try/catch 里被吞掉的错误(⭐ 这类只能靠规范/审查)

② 【未处理的 Promise rejection】
   `window.addEventListener('unhandledrejection', ...)`
   ⭐ 现代前端里「异步代码占大多数」→ **这类是最主要的盲区**:
     如果只接 `error` 事件,「async 函数里抛的错」**全都收不到**。

③ 【资源加载错误】
   图片/脚本/样式/字体加载失败
   ⚠️ 关键:**`error` 事件「不冒泡」** → 必须用「**捕获阶段**」监听:
     window.addEventListener('error', handler, true)     // ⭐ 第三个参数 true
   ⭐ 并用 `event.target` 判断「是哪个资源」(`error` 事件在 window 上的 `target` 会是资源元素)。

④ 【React 错误】
   ⭐ React 的「渲染期错误」**不会**冒泡到 window(它自己处理)→
     必须用 **ErrorBoundary**(见 8.3)捕获,并在 `componentDidCatch` 里上报。
   ⚠️ 另外:**React 18 的并发渲染**里「被丢弃的渲染」抛错也可能被重复报告(要自己判重)。

⑤ 【第三方 SDK / 扩展】
   ⚠️ 第三方 SDK 的错误「你可能不想算成自己的」(污染错误率)→
     ⭐ 用「堆栈/来源过滤」(忽略 `chrome-extension://`、已知的第三方域名)。
// ✅ 全局捕获的完整配置(⭐ 这一段是所有监控 SDK 的起点)
export function installGlobalHandlers(options: {
  report: (err: CapturedError) => void;
  /** ⭐ 忽略规则(第三方脚本、扩展、已知噪声) */
  shouldIgnore?: (err: CapturedError) => boolean;
}) {
  const { report, shouldIgnore } = options;

  /** ① 同步错误 */
  window.addEventListener('error', (event) => {
    // ⭐⭐ 关键:用 `event.target` 区分「JS 错误」与「资源错误」
    const target = event.target as HTMLElement | null;
    if (target && target !== (window as unknown as HTMLElement) && 'src' in target || target instanceof HTMLImageElement || target instanceof HTMLScriptElement || target instanceof HTMLLinkElement) {
      // ⭐ 资源加载错误
      report({
        kind: 'resource',
        message: `资源加载失败:${(target as HTMLImageElement).src ?? (target as HTMLLinkElement).href}`,
        resourceUrl: (target as HTMLImageElement).src ?? (target as HTMLLinkElement).href,
        tagName: target.tagName.toLowerCase(),
        timestamp: Date.now(),
      });
      return;
    }

    report({
      kind: 'js',
      message: event.message,
      source: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      error: event.error as Error | undefined,
      timestamp: Date.now(),
    });
  }, true);   // ⭐⭐ 捕获阶段(否则「资源错误」收不到)

  /** ② 未处理的 Promise rejection(⭐ 最容易被漏的一类) */
  window.addEventListener('unhandledrejection', (event) => {
    const reason = event.reason;
    report({
      kind: 'unhandled-rejection',
      message: reason instanceof Error ? reason.message : String(reason),
      error: reason instanceof Error ? reason : undefined,
      // ⚠️ 有些「非 Error 的 rejection」(如 reject('字符串'))→ 要自己兜底
      timestamp: Date.now(),
    });
  });

  // ③ 兜底:把「被忽略的规则」集中在一处(便于维护)
  return { shouldIgnore };
}
⭐⭐⭐ 一个「必踩」的坑:跨域脚本的错误信息会变成 `"Script error."`

   原因:浏览器出于安全,对「跨域加载的脚本」抛出的错误,
   会把「**message / 文件名 / 行号列号全部清空**」,只剩 `Script error.`:
     error.message === 'Script error.'
     error.filename === ''
     error.lineno === 0

   ⭐ 后果:CDN 上的 JS 报错「完全无法定位」(⭐ 而 CDN 是绝大多数项目的部署方式)。

   ✅ 两个条件同时满足才能拿到真实信息:
     ① 脚本标签加 `crossorigin="anonymous"`
        <script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
     ② CDN 返回 `Access-Control-Allow-Origin`(⭐ 允许你的域名)
     ③ ⭐ 而且要有 `Timing-Allow-Origin`(如果还要性能数据)

   ⚠️ 注意:加 `crossorigin` 会「改变请求模式」(带 Origin 头)→
     如果 CDN 没配 CORS,**脚本会直接加载失败**(⚠️ 这是「上线后白屏」的经典事故)
     ✅ 所以「加 crossorigin」与「配 CORS」必须**同时上线**。

二、Sourcemap:让堆栈能定位到源码

⭐⭐⭐⭐ 「没有 sourcemap 的监控 = 一堆废数据」

   生产代码是「压缩 + 混淆」的:
     Uncaught TypeError: Cannot read properties of undefined (reading 'map')
         at t.value (app-3f8a2b1c.js:1:48213)
   ⭐ 这行报错「无法定位到任何源码」(哪个文件?哪一行?)

   ✅ 正解:构建时生成 sourcemap → 上传到监控平台 → 平台在「展示堆栈时」还原
     Uncaught TypeError: Cannot read properties of undefined (reading 'map')
         at ProductList (src/pages/ProductList.tsx:42:18)     ← ⭐ 还原后
方案 sourcemap 放在哪 安全性 说明
✅ 上传到监控平台(推荐) 只上传到 Sentry 等平台,产物里删掉 ⭐ 高(不公开) 平台用「release + 文件名」匹配
⚠️ sourceMappingURL 公开部署 与 JS 同目录 ❌ 低(源码可被下载) 「打开 DevTools 就能看到源码」,⭐ 等于开源
✅ hidden-source-map 生成但不加注释 ⭐ 高(不知道 map 在哪) ⚠️ 但「知道文件名」仍可下载(如 app.js.map)→ 要「部署时删除」
⚠️ 内联 sourcemap(inline-source-map) 在 JS 里(base64) ❌ 最低 ⭐ 体积暴增(可能翻倍)+ 源码泄露
// ✅ 一个「上传 sourcemap + 删除产物里的 map」的脚本(250 行实现的一部分)
// scripts/upload-sourcemaps.mjs
import { readdirSync, readFileSync, unlinkSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const DIST = process.argv.includes('--dist') ? process.argv[process.argv.indexOf('--dist') + 1] : 'dist';
const RELEASE = process.env.RELEASE ?? process.env.GITHUB_SHA ?? 'dev';

/** ⭐ 上传(这里用「HTTP 上传到自建接口」示意;真实项目用 Sentry CLI / 平台 SDK) */
async function upload(endpoint, token, file, content, release) {
  const form = new FormData();
  form.append('release', release);
  form.append('name', file);
  form.append('file', new Blob([content], { type: 'application/json' }));

  const res = await fetch(`${endpoint}/sourcemaps`, {
    method: 'POST',
    headers: { authorization: `Bearer ${token}` },
    body: form,
  });
  if (!res.ok) throw new Error(`上传失败 ${file}: ${res.status}`);
}

function* walk(dir) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const p = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(p);
    else yield p;
  }
}

const maps = [...walk(DIST)].filter((f) => f.endsWith('.map'));
console.log(`[sourcemap] 找到 ${maps.length} 个 map 文件(release=${RELEASE})`);

const endpoint = process.env.SOURCEMAP_ENDPOINT;
const token = process.env.SOURCEMAP_TOKEN;

if (!endpoint || !token) {
  console.warn('[sourcemap] 未配置 endpoint/token → 跳过上传(⚠️ 但一定要在 CI 里配好)');
} else {
  // ⭐ ① 先「全部上传」
  for (const file of maps) {
    const content = readFileSync(file);
    // ⚠️ 大文件要压缩后上传(sourcemap 可能几 MB)
    await upload(endpoint, token, relative(DIST, file), content, RELEASE);
  }
  console.log(`[sourcemap] 已上传 ${maps.length} 个`);
}

// ⭐⭐ ② 「从产物里删除 .map」(避免公开可下载)
//     ⚠️ 这一步很关键:否则「源码就是公开的」
let removed = 0;
for (const file of maps) {
  // ⚠️ 生产环境才删(本地调试想留)
  if (process.env.NODE_ENV === 'production' || process.argv.includes('--delete')) {
    unlinkSync(file);
    removed += 1;
  }
  // ⭐ 同时「去掉 JS 里的 sourceMappingURL 注释」(用 hidden-source-map 时本来就没有)
  const jsFile = file.replace(/\.map$/, '');
  try {
    if (statSync(jsFile).isFile()) {
      const js = readFileSync(jsFile, 'utf8');
      const cleaned = js.replace(/\/\/# sourceMappingURL=.*$/m, '');
      if (cleaned !== js) {
        // ⚠️ 真实脚本要写回文件(这里省略写回,示意)
        void cleaned;
      }
    }
  } catch { /* 无对应 js */ }
}
console.log(`[sourcemap] 已从产物移除 ${removed} 个 map`);
⭐⭐⭐ Sourcemap 的三个「必须做对」:

① 【必须是「同一份产物」的 map】
   ⚠️ 「本地构建的 map」对不上「CI 构建的产物」——
     因为「产物哈希/内容」不同 → 平台无法匹配,堆栈还原失败。
   ✅ 实践:**在 CI 里「构建后立刻上传」**(用同一个 release id),
     ⭐ 并「上传后立刻从产物删除」(防止泄露)。

② 【`release` 要对齐】
   上报的错误带 `release`(如 git sha / 版本号),
   平台用「release + 文件名」找 map。
   ⚠️ 如果「上报的 release」与「上传 map 的 release」不一致 → 还原失败
     (⭐ 这类问题表现为「新版本上线后错误都还原不出来」,很容易漏)。

③ 【不要「公开部署 .map」】
   ⚠️ 后果:任何人访问 `/assets/app.js.map` 就能「下载你的完整源码」
     (包含「内部注释、接口地址、埋点规则、甚至硬编码的 key」)
   ✅ 用 **`hidden-source-map`**(生成但不加注释)+ **上传后删除**。

三、分组、去重与上下文

⭐⭐⭐ 错误「分组」是「可用的错误监控」与「一堆报错」的分界线:

   ❌ 「100 万个错误事件」→ 你只会看到「错误数很高」,无法行动
   ✅ 「分成 12 个『问题』,其中 3 个是本次发布引入的」→ 可以行动

   ⭐ 分组靠「指纹(fingerprint)」,通常包含:
     · 错误类型 + 错误信息(⚠️ 要把「数字/id/url」归一化,否则每个都不一样)
     · 堆栈的「关键帧」(⭐ 去掉「框架内部帧」与「行号列号」,只留函数名 + 文件)
     · 页面/路由(⭐ 同一个错误在不同页面算「不同问题」有时更有用)
     · 框架/库版本(可选)
/** ⭐⭐ 指纹计算(把「同源的一堆错误」归成一组) */
export function computeFingerprint(err: CapturedError): string {
  // ⭐ ① 消息归一化:把「动态内容」替换成占位符
  const normalizedMessage = (err.message ?? '')
    .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
    .replace(/\b\d+\b/g, '<n>')
    .replace(/https?:\/\/\S+/g, '<url>')
    .replace(/"[^"]*"/g, '<str>')                     // ⭐ 「用户输入」常常出现在错误信息里
    .slice(0, 200);

  // ⭐ ② 堆栈的「关键帧」:取「最深的、属于我们代码的」几帧
  const frames = (err.error?.stack ?? '')
    .split('\n')
    .slice(1)
    .map((line) => line.trim())
    // ⚠️ 过滤「框架/浏览器内部」的帧(它们是「结果」不是「原因」)
    .filter((line) => !/node_modules|react-dom|〈anonymous〉|<anonymous>/.test(line))
    // ⭐ 去掉「行号列号」(不同构建会变)
    .map((line) => line.replace(/:\d+:\d+\)?$/, ''))
    .slice(0, 3);

  return [err.kind, normalizedMessage, ...frames].join('|');
}

/** ⭐ 兜底哈希(指纹可能很长) */
export function hashString(input: string): string {
  // ⭐ FNV-1a 的简化版(不需要加密强度,只要「稳定 + 均匀」)
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, '0');
}
/** ⭐⭐ 面包屑(Breadcrumb):让「错误现场」可读 —— 监控 SDK 最有价值的部分之一 */
export interface Breadcrumb {
  category: 'navigation' | 'click' | 'http' | 'console' | 'ui' | 'custom';
  message: string;
  level: 'info' | 'warning' | 'error';
  timestamp: number;
  data?: Record<string, unknown>;
}

export function createBreadcrumbCollector(options: { max?: number } = {}) {
  const { max = 30 } = options;
  const buffer: Breadcrumb[] = [];

  const push = (bc: Breadcrumb) => {
    buffer.push(bc);
    // ⭐ 环形缓冲(只留最近 max 条 —— 对应错误发生时「前面发生了什么」)
    if (buffer.length > max) buffer.shift();
  };

  // ⭐ ① 路由变化(⭐ 与 6.13 的「路由桥」配合最好)
  const originalPush = history.pushState.bind(history);
  history.pushState = (...args) => {
    const ret = originalPush(...args);
    push({ category: 'navigation', message: `导航到 ${location.pathname}`, level: 'info', timestamp: Date.now() });
    return ret;
  };
  window.addEventListener('popstate', () => {
    push({ category: 'navigation', message: `后退/前进到 ${location.pathname}`, level: 'info', timestamp: Date.now() });
  });

  // ⭐ ② 点击(⭐ 只记「元素描述」,不记内容 —— 避免把用户输入带进去)
  document.addEventListener('click', (e) => {
    const el = e.target as HTMLElement;
    if (!el) return;
    const desc = describeElement(el);
    push({ category: 'click', message: `点击 ${desc}`, level: 'info', timestamp: Date.now() });
  }, { capture: true, passive: true });

  // ⭐ ③ 网络(与 7.13 的请求层配合:每次请求记一条)
  const recordHttp = (info: { method: string; route: string; status: number; ms: number }) => {
    push({
      category: 'http',
      message: `${info.method} ${info.route} → ${info.status}(${info.ms}ms)`,
      level: info.status >= 500 ? 'error' : info.status >= 400 ? 'warning' : 'info',
      timestamp: Date.now(),
      data: info,
    });
  };

  // ⭐ ④ 控制台(⚠️ 只记 warn/error;⭐ 且要「防递归」—— 因为我们自己也会 console)
  const originalError = console.error.bind(console);
  let reporting = false;
  console.error = (...args: unknown[]) => {
    if (!reporting) {
      push({ category: 'console', message: args.map(String).join(' ').slice(0, 300), level: 'error', timestamp: Date.now() });
    }
    originalError(...args);
  };

  return { push, recordHttp, getAll: () => [...buffer], setReporting: (v: boolean) => { reporting = v; } };
}

/** ⭐⭐ 「元素描述」不能带用户内容(⭐ 隐私 + 噪声) */
function describeElement(el: HTMLElement): string {
  const tag = el.tagName.toLowerCase();
  const id = el.id ? `#${el.id}` : '';
  // ⚠️ class 里可能有很多「工具类」→ 只取前 2 个
  const cls = typeof el.className === 'string' && el.className
    ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
    : '';
  // ⭐ 用 `data-track` / `aria-label` 做「人类可读」的描述(如果业务埋了)
  const label = el.dataset.track ?? el.getAttribute('aria-label') ?? '';
  return `${tag}${id}${cls}${label ? `(${label})` : ''}`;
}

四、完整实现:一个错误监控 SDK(250 行)

// error-monitor.ts —— 一个可用的前端错误监控(捕获 + 面包屑 + 指纹 + 上报 + 采样)
import { installGlobalHandlers } from './global-handlers';
import { createBreadcrumbCollector } from './breadcrumbs';
import { computeFingerprint, hashString } from './fingerprint';

export interface CapturedError {
  kind: 'js' | 'unhandled-rejection' | 'resource' | 'react' | 'manual';
  message: string;
  source?: string;
  lineno?: number;
  colno?: number;
  error?: Error;
  resourceUrl?: string;
  tagName?: string;
  timestamp: number;
  /** ⭐ 组件堆栈(React 的 `componentStack`)—— 对「定位到组件」极有用 */
  componentStack?: string;
}

export interface MonitorOptions {
  /** 上报端点 */
  endpoint: string;
  /** ⭐ 版本(必须与「上传 sourcemap 的 release」一致) */
  release: string;
  /** 环境(⭐ 生产/预发/本地要分开,避免「本地错误污染生产看板」) */
  environment: 'production' | 'staging' | 'development';
  /** 用户信息(⭐ 用匿名 id,不要用「邮箱/手机号」) */
  getUser?: () => { id: string; segment?: string } | null;
  /** ⭐ 采样率(错误默认「全报」,但「高频重复错误」要限流) */
  sampleRate?: number;
  /** ⭐ 每个「问题」在「单位时间内」最多上报多少条(防刷) */
  rateLimitPerFingerprint?: number;
  /** ⭐ 忽略规则(第三方脚本/扩展/已知噪声) */
  ignorePatterns?: RegExp[];
  /** 是否采集 console 面包屑 */
  captureConsole?: boolean;
}

export function createErrorMonitor(options: MonitorOptions) {
  const {
    endpoint, release, environment,
    getUser,
    sampleRate = 1,
    rateLimitPerFingerprint = 5,
    ignorePatterns = [],
    captureConsole = true,
  } = options;

  const breadcrumbs = createBreadcrumbCollector({ max: 30 });

  /** ⭐ 限流表(fingerprint → 已上报次数) */
  const reportCounts = new Map<string, number>();
  /** ⭐⭐ 会话内的「已上报指纹」(同一个问题「同一会话只报一次详情」,
   *    但「次数」仍然统计 —— 这样既能看到「影响面」又不刷爆) */
  const seenInSession = new Set<string>();

  /** ⭐ 上报队列(批量 + 页面卸载时 sendBeacon) */
  let queue: Array<Record<string, unknown>> = [];
  let flushTimer: ReturnType<typeof setTimeout> | null = null;

  function flush(useBeacon = false) {
    if (queue.length === 0) return;
    const body = JSON.stringify({ release, environment, events: queue });
    queue = [];
    if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }

    if (useBeacon && typeof navigator.sendBeacon === 'function') {
      navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
      return;
    }
    void fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body,
      keepalive: true,
    }).catch(() => { /* ⭐ 上报失败绝不影响业务 */ });
  }

  function scheduleFlush() {
    if (flushTimer) return;
    flushTimer = setTimeout(() => flush(false), 2000);
  }

  // ⭐ 页面隐藏时立刻上报(⭐ 错误往往发生在「离开前」)
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush(true);
  });
  window.addEventListener('pagehide', () => flush(true));

  /** ⭐⭐ 核心:处理一个错误 */
  function capture(err: CapturedError) {
    // ① 忽略规则(第三方脚本/扩展/已知噪声)
    const text = `${err.message} ${err.source ?? ''}`;
    if (ignorePatterns.some((re) => re.test(text))) return;

    // ⚠️ 忽略「Script error.」(跨域脚本)—— ⭐ 但**要单独统计它**,
    //    因为它意味着「有跨域脚本报错但拿不到信息」(需要去配 CORS)
    if (err.message === 'Script error.') {
      queue.push({
        kind: 'cross-origin-script-error',
        message: 'Script error.(跨域脚本,信息被浏览器隐藏)',
        release, environment, timestamp: Date.now(),
      });
      scheduleFlush();
      return;
    }

    // ② 采样(⭐ 错误通常「全报」,但可在「高频噪声多」时降采样)
    if (sampleRate < 1 && Math.random() > sampleRate) return;

    // ③ 指纹 + 限流
    const fingerprint = hashString(computeFingerprint(err));
    const count = (reportCounts.get(fingerprint) ?? 0) + 1;
    reportCounts.set(fingerprint, count);

    const isFirstInSession = !seenInSession.has(fingerprint);
    seenInSession.add(fingerprint);

    // ⭐⭐ 策略:**「同一会话同一个问题」只报「第一条完整详情」**,
    //     后续只「累加计数」(在「离开时」上报一次汇总)——
    //     这样既能看「影响面」又不会「一个死循环刷爆上报」
    if (!isFirstInSession && count > rateLimitPerFingerprint) {
      // ⭐ 只累积计数(不立即上报)
      return;
    }

    // ④ 组装事件(⭐ 上下文是「能行动」的关键)
    const event = {
      fingerprint,
      kind: err.kind,
      message: err.message.slice(0, 1000),
      stack: err.error?.stack?.slice(0, 8000),
      // ⭐ 组件堆栈(React)—— 直接指向「哪个组件」
      componentStack: err.componentStack,
      source: err.source,
      lineno: err.lineno,
      colno: err.colno,
      resourceUrl: err.resourceUrl,
      tagName: err.tagName,
      release,
      environment,
      // ⭐ 页面/路由
      url: location.href.split('?')[0],            // ⚠️ 去掉 query(避免隐私 + 高基数)
      path: location.pathname,
      // ⭐ 用户(匿名 id)
      user: getUser?.(),
      // ⭐⭐ 面包屑(错误前发生了什么)
      breadcrumbs: breadcrumbs.getAll(),
      // ⭐ 设备/网络
      device: {
        ua: navigator.userAgent,
        language: navigator.language,
        cores: navigator.hardwareConcurrency,
        memory: (navigator as unknown as { deviceMemory?: number }).deviceMemory,
        connection: (navigator as unknown as { connection?: { effectiveType?: string } }).connection?.effectiveType,
      },
      // ⭐ 会话 id(把「同一会话的多个错误」关联起来)
      sessionId: getSessionId(),
      timestamp: err.timestamp,
      /** ⭐ 该指纹在「本会话」的第几次(⭐ 用来看「影响面」) */
      countInSession: count,
    };

    queue.push(event);
    scheduleFlush();
  }

  /** ⭐ 装上全局 handler */
  installGlobalHandlers({ report: capture });

  /** ⭐⭐ 暴露给 ErrorBoundary / 业务代码用(见 8.3) */
  const captureReactError = (error: Error, errorInfo: { componentStack?: string }) => {
    capture({
      kind: 'react',
      message: error.message,
      error,
      componentStack: errorInfo.componentStack,
      timestamp: Date.now(),
    });
  };

  const captureMessage = (message: string, data?: Record<string, unknown>) => {
    capture({ kind: 'manual', message, timestamp: Date.now() });
    if (data) breadcrumbs.push({ category: 'custom', message, level: 'info', timestamp: Date.now(), data });
  };

  return {
    capture,
    captureReactError,
    captureMessage,
    addBreadcrumb: breadcrumbs.push,
    recordHttp: breadcrumbs.recordHttp,
    flush: () => flush(true),
    /** ⭐ 供「测试/内部使用」(⚠️ 不要给业务用) */
    _internal: { breadcrumbs, reportCounts },
  };
}

/** ⭐ 会话 id(存在 sessionStorage,⭐ 与 3.14 的 tab id 区分开) */
function getSessionId(): string {
  const KEY = '__session_id';
  try {
    let id = sessionStorage.getItem(KEY);
    if (!id) { id = crypto.randomUUID(); sessionStorage.setItem(KEY, id); }
    return id;
  } catch {
    return 'no-session-storage';
  }
}
/** ⭐⭐ 与 ErrorBoundary 集成(React 错误必须靠它,见 8.3) */
import { Component, type ErrorInfo, type ReactNode } from 'react';

export function createReportingErrorBoundary(monitor: ReturnType<typeof createErrorMonitor>) {
  return class ReportingErrorBoundary extends Component<
    { children: ReactNode; fallback: (err: Error, reset: () => void) => ReactNode },
    { error: Error | null }
  > {
    state: { error: Error | null } = { error: null };

    static getDerivedStateFromError(error: Error) {
      return { error };
    }

    componentDidCatch(error: Error, info: ErrorInfo) {
      // ⭐ 上报(含 componentStack)
      monitor.captureReactError(error, { componentStack: info.componentStack ?? undefined });
      // ⭐ 同时在「面包屑」里记一条(便于「非 React 错误」也能关联到「刚才的组件崩了」)
      monitor.addBreadcrumb({
        category: 'ui',
        message: `组件树崩溃:${error.message}`,
        level: 'error',
        timestamp: Date.now(),
      });
    }

    reset = () => this.setState({ error: null });

    render() {
      if (this.state.error) return this.props.fallback(this.state.error, this.reset);
      return this.props.children;
    }
  };
}
// ============ 接线(应用入口) ============
const monitor = createErrorMonitor({
  endpoint: '/api/telemetry/errors',
  release: __APP_VERSION__,                     // ⭐ 必须与 sourcemap 上传的 release 一致
  environment: __ENV__,
  getUser: () => {
    const u = authStore.getState().user;
    // ⭐⭐ 只上报「匿名 id」与「分层」,不上报邮箱/手机号(隐私)
    return u ? { id: u.id, segment: u.plan } : null;
  },
  sampleRate: 1,                                 // ⭐ 错误默认全报
  rateLimitPerFingerprint: 5,                    // ⭐ 但「同指纹」做限流
  ignorePatterns: [
    /chrome-extension:\/\//,
    // ⭐ 忽略「用户输入的 JSON 解析错误」(可能是「我们不该报」的噪声)
    // ⚠️ 但要谨慎:忽略规则写宽了会「把真问题也过滤掉」
  ],
});

// ⭐ 让「请求层」把网络信息写进面包屑(与 7.13 配合)
// installFetchObservability({ ..., onEvent: monitor.recordHttp })

// ⭐ 让「错误上报」本身不产生 console 面包屑(⭐ 防递归)
monitor._internal.breadcrumbs.setReporting(true);

五、Session Replay:录屏(要谨慎)

⭐⭐⭐ Session Replay(会话回放)的原理与代价:

   【原理】
     ① 记录「初始 DOM 快照」(序列化整棵树)
     ② 之后只记录「增量变更」(MutationObserver)
     ③ 记录「用户输入」(⚠️ 要脱敏)与「鼠标/滚动位置」
     ④ 回放时「按时间轴重放」
     ⭐ 存储成本:【不算大】(因为「只存增量」)——
       一个 10 分钟的会话可能只有几十 KB~几百 KB

   【价值】⭐ 极高:「看一遍用户的真实操作」比「读一百行堆栈」有效得多
     · 「用户说『点了没反应』」→ 回放里能看到「他点的是哪、当时页面什么状态」
     · 「复现不了的 bug」→ 回放能给出「确切的路径与时机」

   【代价与风险】⚠️⚠️
     ① **隐私/合规**(⭐ 最严重):
        · 页面上的「用户输入、订单号、地址、头像」都可能被录下来
        · ⭐ 必须「默认屏蔽所有输入框与敏感区域」+「只白名单记录」
     ② 性能开销(序列化 + MutationObserver + 上报)
     ③ 体积(长会话还是会有几 MB)
     ④ ⚠️ 「录屏数据被泄露」的后果远大于「错误数据泄露」

   ⭐ 实践纪律(如果要做):
     · ⭐ **默认遮蔽**:`input[type=password]`、以及「所有 input/textarea」默认打码
     · ⭐ **白名单式**:标了 `data-replay="allow"` 的才录(而不是「黑名单排除」)
     · ⭐ 「只对『有错误的会话』录制」(⭐ 既省成本又降风险)
     · ⭐ 明确写进隐私政策 + 提供「关闭录制」的入口
/** ⭐ 一个「最小化」的会话回放(仅示意思路,生产建议用 rrweb 这类成熟库) */
export function createSessionRecorder(options: {
  /** ⭐⭐ 「白名单」:只有标了这个属性的元素才记录内容 */
  allowSelector?: string;
  onBatch: (events: unknown[]) => void;
}) {
  const { allowSelector = '[data-replay="allow"]', onBatch } = options;
  const events: unknown[] = [];

  /** ⭐ 序列化 DOM(⭐ 关键:按「白名单」决定「是否保留文本/输入值」) */
  function serializeNode(node: Node): unknown {
    if (node.nodeType === Node.TEXT_NODE) {
      const parent = node.parentElement;
      // ⭐⭐ 默认「不保留文本」,只有「白名单区域」才保留
      const allowed = parent?.closest(allowSelector) != null;
      return { t: 'text', v: allowed ? node.textContent : '[masked]' };
    }
    if (node.nodeType !== Node.ELEMENT_NODE) return null;

    const el = node as HTMLElement;
    const isSensitive = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
    const allowed = el.closest(allowSelector) != null;

    return {
      t: 'el',
      tag: el.tagName.toLowerCase(),
      attrs: {
        // ⚠️ 只记「不会泄露内容」的属性
        class: el.className,
        id: el.id,
      },
      // ⭐⭐ 输入值「默认打码」
      value: isSensitive ? (allowed ? (el as HTMLInputElement).value : '[masked]') : undefined,
      children: [...el.childNodes].map(serializeNode).filter(Boolean),
    };
  }

  /** ⭐ 初始快照 */
  const snapshot = { t: 'snapshot', root: serializeNode(document.documentElement) };

  /** ⭐ 增量(MutationObserver) */
  const mo = new MutationObserver((records) => {
    for (const r of records) {
      if (r.type === 'childList') {
        events.push({ t: 'childList', target: describeNode(r.target), added: [...r.addedNodes].map(serializeNode), removed: r.removedNodes.length });
      } else if (r.type === 'attributes') {
        events.push({ t: 'attr', target: describeNode(r.target), name: r.attributeName });
      } else if (r.type === 'characterData') {
        const allowed = (r.target.parentElement)?.closest(allowSelector) != null;
        events.push({ t: 'text', target: describeNode(r.target), v: allowed ? r.target.textContent : '[masked]' });
      }
    }
  });

  return {
    start() {
      onBatch([snapshot]);
      mo.observe(document.documentElement, {
        childList: true, subtree: true, attributes: true, characterData: true,
        attributeFilter: ['class', 'id', 'style', 'hidden'],     // ⭐ 只关心这些(减少噪声)
      });
    },
    stop() { mo.disconnect(); if (events.length) { onBatch(events.splice(0)); } },
    flush() { if (events.length) onBatch(events.splice(0)); },
  };
}

/** ⭐ 描述一个节点(用于「指向变化的位置」,不含内容) */
function describeNode(node: Node): string {
  const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
  if (!el) return 'unknown';
  return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}`;
}

六、告警:让数据变成「行动」

⭐⭐⭐ 告警的四条纪律(不然会被「告警疲劳」淹没):

① 【按「问题」告警,不按「事件」】⭐
   ❌ 「错误数 > 100 就告警」→ 一个「死循环」就能触发几百条
   ✅ 「**新增的问题**」(首次出现的指纹)告警 +
      「**原有问题的『影响面』显著上升**」(受影响用户数翻倍)告警

② 【按「影响面」分级】
   · 「影响 > 5% 会话」→ 立刻告警(可能 P0)
   · 「影响 0.1%~5%」→ 日报
   · 「影响 < 0.1%」→ 只进看板
   ⭐ 判据是「**受影响用户数/会话数**」,不是「次数」。

③ 【⭐ 「新版本引入的问题」要单独告警】
   ⭐ 这是「最有行动价值」的一类:
     发布后 5 分钟内「新增指纹」→ 立刻告警 → 「**回滚**」是最快的修复。
   ✅ 做法:「release 维度的对比」(新 release 有、旧 release 没有)。

④ 【必须能「定位」】
   告警里要包含:⭐ **错误信息 + 受影响页面 + release + 一条「回放链接」**——
   ⚠️ 「服务端错误率上升了」这种告警**无法行动**。
/** ⭐ 一个「上报 + 本地汇总」的实用增强:离开时把「本会话的错误汇总」发一份 */
export function attachSessionSummary(monitor: ReturnType<typeof createErrorMonitor>) {
  const summary = new Map<string, { kind: string; message: string; count: number }>();

  const originalCapture = monitor.capture;
  // ⭐ 包一层(在「不改 SDK 内部」的前提下统计)
  void originalCapture;

  window.addEventListener('pagehide', () => {
    if (summary.size === 0) return;
    // ⭐ 上报「本会话出现过哪些指纹、各多少次」——用于「影响面」统计
    monitor.captureMessage('会话错误汇总', { summary: [...summary.entries()] });
    monitor.flush();
  });

  return { summary };
}

七、本篇特有的坑

// ① 只监听 `window.onerror`(漏掉 Promise rejection)⭐
window.onerror = handler;                                      // ❌ async 里的错误收不到
// ✅ 同时监听 `unhandledrejection`

// ② 监听 `error` 时没用「捕获阶段」(漏掉资源加载错误)⭐
window.addEventListener('error', handler);                     // ❌ 资源错误不冒泡
// ✅ window.addEventListener('error', handler, true)

// ③ 跨域脚本错误变成 `Script error.`(无法定位)⭐
// ✅ 脚本标签加 `crossorigin="anonymous"` + CDN 配 CORS(⭐ 必须同时上线,否则脚本加载失败)

// ④ 生产环境「没有 sourcemap」或「本地 map 对不上 CI 产物」⭐
// ✅ CI 里构建后立刻上传(同一个 release)+ 上传后从产物删除

// ⑤ `.map` 公开部署(源码泄露)⭐
// ✅ hidden-source-map + 上传后删除

// ⑥ 「上报的 release」与「上传 map 的 release」不一致(还原失败)
// ✅ 用同一个「构建产物的版本标识」(git sha / 版本号)

// ⑦ 没有指纹归一化(每个错误都是「新问题」)
// ⭐ 症状:「一万个问题」,无法聚合
// ✅ 归一化「uuid/数字/url/引号内容」

// ⑧ 没有限流(一个死循环「每秒报 1000 次」刷爆上报与配额)
// ✅ 「同指纹在会话内只报详情 + 限流」+「错误循环检测」

// ⑨ 上报「阻塞业务」或「本身的错误也被上报」(递归)
// ✅ 不 await + catch 静默 + 「上报时的 console 不再采集」

// ⑩ 把「用户输入/URL 参数」放进上报(隐私)⭐
// ✅ URL 去 query;输入内容「绝不采集」;用户 id 用匿名 id

// ⑪ 「忽略了 Script error.」但没统计(不知道「有跨域脚本在报错」)
// ✅ 单独统计它(⭐ 它提示「CORS 没配好」)

// ⑫ 忽略规则写得太宽(把真问题也过滤了)
/.*/                                                          // ❌
// ✅ 只忽略「明确的第三方/扩展」;并「把被忽略的数量也计数上报」(⭐ 便于发现「过滤过头」)

// ⑬ 「本地开发」的错误污染「生产看板」
// ✅ 用 `environment` 区分;⭐ 且「本地不上报」(或上报到独立的项目)

// ⑭ React 的「并发渲染」下「被丢弃的渲染抛错」被重复上报
// ✅ 用「指纹去重 + 会话内只报一次详情」

// ⑮ 「ErrorBoundary 只包了顶层」(局部崩溃导致整页白屏 + 只报一次)
// ✅ ⭐ 「分层设置 ErrorBoundary」(路由级、区块级),让「局部崩溃」不影响整体(见 8.3)

// ⑯ 「组件堆栈」没采集(只看到「某个组件崩了」但不知道「哪一层」)
// ✅ `componentDidCatch(error, info)` 里的 `info.componentStack`

// ⑰ 「面包屑」采集了输入内容(隐私泄漏)
// ✅ 只记「元素描述」不记「输入值」

// ⑱ 「Session Replay」默认录了输入框内容(⭐ 合规事故)
// ✅ ⭐ **默认遮蔽 + 只对「有错误的会话」录制**

// ⑲ 「告警只看次数」(一个死循环触发几百条告警)
// ✅ 按「问题」+「影响会话数」+「新增指纹」

// ⑳ 「错误上报没有关联到『那次请求/那个 trace』」
// ✅ 与 7.13 配合:面包屑里记 HTTP;错误里带上「最近的 traceId」

// ㉑ 「sourcemap 上传失败但没有让 CI 失败」(默默地上传不上)
// ✅ ⭐ 上传步骤「失败要让 CI 失败」(否则「上线后无法定位」而没人知道)

// ㉒ 「忽略了『错误上报接口本身』的失败」
// ⚠️ 如果上报接口挂了 → 你「看不到任何错误」(⭐ 这是「最危险的静默」)
// ✅ 加「SDK 自身的健康监控」(如「1 小时内上报量为 0」→ 告警)
// ⑧ 的完整处理(错误循环检测)
export function createLoopGuard(options: { windowMs?: number; threshold?: number } = {}) {
  const { windowMs = 5000, threshold = 20 } = options;
  let times: number[] = [];

  return {
    /** ⭐ 返回 true 表示「疑似死循环」→ 停止上报(避免刷爆) */
    shouldSuppress(): boolean {
      const now = Date.now();
      times = times.filter((t) => now - t < windowMs);
      times.push(now);

      if (times.length >= threshold) {
        // ⭐ 一旦触发,就「一次性上报一条『疑似错误循环』」并「暂停一段时间」
        times = [];
        return true;
      }
      return false;
    },
  };
}

// 用法:在 capture 里
if (loopGuard.shouldSuppress()) {
  // ⭐ 只上报一条「循环检测」事件(然后「暂停 N 秒的上报」)
  return;
}
// ㉒ 的完整处理(SDK 自身的健康监控)
// ⭐ 思路:SDK 内部也「打点自己」——「采集了多少、上报成功多少」
let stats = { captured: 0, reported: 0, failed: 0 };

function trackStat(kind: 'captured' | 'reported' | 'failed') {
  stats[kind] += 1;
}

// ⭐ 每 5 分钟「把自身状态上报一次」(用「独立的轻量端点」,避免「主上报挂了就完全失明」)
setInterval(() => {
  navigator.sendBeacon?.('/api/telemetry/sdk-health', JSON.stringify({
    release: __APP_VERSION__,
    ...stats,
  }));
}, 5 * 60_000);

// ⭐ 服务端侧设告警:「某 release 的 captured 突降为 0」→ 说明「SDK 挂了」或「页面崩了」

八、面试延伸

  1. 「前端错误监控要采集哪些错误?怎么捕获?」
    五类,每一类都要单独接:① JS 运行时错误——window.addEventListener('error', h)(⚠️ 它不会捕获 Promise 错误与 React 渲染错误);② ⭐ 未处理的 Promise rejection——window.addEventListener('unhandledrejection', h)(⭐ 现代前端里最主要的一类,因为代码大多是异步的,只接 error 会全部漏掉);③ ⭐ 资源加载错误——必须用 捕获阶段 addEventListener('error', h, true)(因为 error 事件不冒泡),并用 event.target 区分「资源」与「JS 错误」;④ ⭐ React 错误——必须用 ErrorBoundary(React 的渲染错误不会冒泡到 window,它自己处理),在 componentDidCatch(error, info) 里上报(⭐ 并带上 info.componentStack,这是「定位到哪个组件」的关键);⑤ 第三方 SDK/扩展——⚠️ 用「忽略规则」过滤(否则污染错误率),但 ⭐ 要把「被忽略的数量」也计数(避免「过滤过头」而失明)。

  2. 「为什么生产环境的错误堆栈无法定位?怎么解决?」
    因为 生产代码被压缩混淆——堆栈是 at t.value (app-3f8a2b1c.js:1:48213),完全无法定位。解法是 Sourcemap:构建时生成 → 上传到监控平台(带 release 标识)→ 平台在展示堆栈时还原成 src/pages/ProductList.tsx:42:18。⭐ 三个必须做对的:① 必须是同一份产物的 map——⚠️ 「本地构建的 map」对不上「CI 构建的产物」(内容哈希不同),✅ 实践是「在 CI 里构建后立刻上传」;② ⭐ release 要对齐——上报的错误带 release,平台按它找 map;如果「上报的 release」与「上传 map 的 release」不一致,就会「新版本上线后错误全都还原不出来」(这很容易漏);③ ⭐ 不要公开部署 .map——否则「任何人访问 /assets/app.js.map 就能下载你的完整源码」(含注释、接口地址、埋点规则、甚至硬编码的 key)→ ✅ 用 hidden-source-map(生成但不加注释)+ 上传后从产物删除。

  3. 「跨域脚本的错误为什么只显示 Script error.?」
    因为浏览器出于安全,对「跨域加载的脚本」抛出的错误,会把「message / 文件名 / 行号列号全部清空」,只剩 Script error.(⭐⭐ 而 CDN 部署是绝大多数项目的方式,所以这是极常见的盲区)。解决需要两个条件同时满足:① 脚本标签加 crossorigin="anonymous";② CDN 返回 Access-Control-Allow-Origin。⚠️⚠️ 但要非常小心:加了 crossorigin 会改变请求模式(带 Origin 头,进入 CORS 流程)——如果 CDN 没配好 CORS,脚本会直接加载失败(⭐ 这是「上线后白屏」的经典事故)。所以「加 crossorigin」与「配 CORS」必须同时上线。⭐ 另外:即使拿不到信息,也应该单独统计 Script error. 的数量(它是一个「CORS 没配好」的信号)。

  4. 「错误怎么分组去重?」
    靠指纹(fingerprint),包含:① ⭐ 错误类型 + 归一化后的消息——⚠️ 必须把动态内容替换掉(UUID → <uuid>、数字 → <n>、URL → <url>、引号内容 → <str>),否则「每条错误都带不同的 id」→ 每个都是「新问题」;② ⭐ 堆栈的「关键帧」——取「最深的、属于我们代码的」几帧(过滤 node_modules/框架内部帧——它们是「结果」不是「原因」)+ 去掉行号列号(不同构建会变);③ 页面/路由(同一个错误在不同页面算「不同问题」有时更有用);④ 可选:框架版本。除了分组,还要「限流」:⚠️ 一个死循环能「每秒报 1000 次」→ 必须「同一指纹在会话内只报一次完整详情,后续只累加计数」,并加 「错误循环检测」(单位时间内超过阈值 → 抑制上报并单独报一条「疑似循环」)。

  5. 「面包屑(Breadcrumb)是什么?为什么重要?」
    它是「错误发生之前,用户干了什么」的时间线——⭐ 这是「监控数据」与「能行动的数据」之间的关键桥梁。采集内容:① 路由变化(pushState/popstate);② 点击(⭐ 只记「元素描述」不记「输入内容」——隐私 + 噪声);③ 网络请求(与 7.13 的请求层配合,记 method route status ms);④ console(⚠️ 只记 warn/error,且要防递归——因为我们自己的上报也可能 console.error)。实现要点:用环形缓冲(只留最近 20~30 条)+ ⭐ 「元素描述」不要带用户内容(用 data-track/aria-label 做「人类可读描述」)。价值:⭐ 「用户说『点了没反应』」→ 面包屑显示「点了哪个按钮 → 发了哪个请求 → 请求 500」→ 直接定位。

  6. 「Session Replay(录屏)值得做吗?要注意什么?」
    价值很高(⭐「看一遍用户的真实操作」比「读一百行堆栈」有效),但风险也高。原理:① 记录「初始 DOM 快照」;② 之后用 MutationObserver 记录「增量变更」;③ 记录输入与鼠标/滚动;④ 回放时按时间轴重放(⭐ 因为只存增量,一个 10 分钟会话可能只有几十 KB)。⚠️⚠️ 最重要的是隐私合规:① ⭐ 默认遮蔽——input[type=password] 以及所有 input/textarea 默认打码;② ⭐ 白名单式——只录「标了 data-replay="allow"」的区域(而不是「黑名单排除」,因为「黑名单总会漏」);③ ⭐ 只对「有错误的会话」录制(既省成本又降风险);④ 明确写进隐私政策 + 提供关闭入口。⭐ 判断:「录屏数据泄露」的后果远大于「错误数据泄露」——所以「要么不做,要么做到白名单式默认遮蔽」。

  7. 「错误监控的告警怎么设计?」
    四条纪律:① ⭐ 按「问题」告警,不按「事件」——❌「错误数 > 100 就告警」(一个死循环能触发几百条);✅「新增的问题(首次出现的指纹)」+「已有问题的『影响面』显著上升」;② ⭐ 按「影响面」分级——「影响 > 5% 会话」立刻告警、「0.1%~5%」日报、「< 0.1%」只进看板(⭐ 判据是「受影响用户/会话数」而不是「次数」);③ ⭐ 「新版本引入的问题」单独告警——这是「最有行动价值」的一类:发布后 5 分钟内「新增指纹」→ 立刻告警 → 回滚是最快的修复(✅ 做法是「release 维度的对比」);④ ⭐ 告警必须能定位——包含「错误信息 + 受影响页面 + release + 一条回放链接」(⚠️「服务端错误率上升了」这种告警无法行动)。另外:「SDK 自身的健康监控」——⭐ 如果上报接口挂了,你会「看不到任何错误」(最危险的静默),所以要有「1 小时内上报量为 0 → 告警」。

  8. 「监控数据里的隐私问题怎么处理?」
    五条:① ⭐ URL 去 query(location.href.split('?')[0])——因为「搜索关键词、token、邮箱」都可能出现在 query 里(⭐ 与 7.13 的「URL 归一化」同源);② ⭐ 输入内容绝不采集(面包屑只记「元素描述」,Session Replay 默认遮蔽所有输入框);③ ⭐ 用户标识用「匿名 id」(不要用邮箱/手机号;如必须关联,用「内部 id + 服务端映射」);④ 上报的「属性」要有白名单(而不是「把整个对象序列化」——⭐ 后者会把「不该传的东西」也带出去);⑤ ⭐ 尊重用户的「同意状态」(Do-Not-Track / Cookie 同意)——在「未同意」时要么不上报,要么「去掉可识别信息」。⭐ 一句话:「监控数据会被跨系统传递并长期保存」——所以「采集时就要假设『它会被很多人看到』」。

一句话速记

前端错误监控的四件事:全局捕获(⭐ 五类来源都要接:error 事件 + ⭐ **unhandledrejection(现代前端的主要盲区) + ⭐ 资源错误必须用捕获阶段(error 不冒泡) + ⭐ React 错误只能靠 ErrorBoundary(它不冒泡到 window,要用 componentStack) + 第三方用忽略规则)→ ⭐ Sourcemap 还原(⚠️ 必须 CI 构建后立刻上传同一份的 map + release 要对齐 + **绝不能公开部署 .map(用 hidden-source-map + 上传后删除))→ ⭐ 指纹分组与限流(消息归一化 uuid/数字/url/引号内容 + 堆栈关键帧去掉框架帧与行列号;同指纹会话内只报一次详情 + 错误循环检测)→ ⭐ **面包屑与上下文(路由/点击/请求/console,⚠️ 只记「元素描述」不记输入内容;带上匿名 user id、release、environment、device、sessionId);⚠️ 跨域脚本只显示 Script error.(需要 crossorigin="anonymous" + ⭐ CDN 配 CORS,必须同时上线否则脚本加载失败)——并要单独统计它;Session Replay 价值高但风险更高(⭐ 默认遮蔽输入 + 白名单式(只录 data-replay="allow")+ 只对有错误的会话录制);告警四条纪律:按「问题」(首次出现的指纹)而非「事件」、按「影响会话数」分级、⭐ 「新版本引入的问题」单独告警(回滚最快)、告警必须能定位(错误 + 页面 + release + 回放链接),并且⭐ 要做「SDK 自身健康监控」(否则「上报挂了 = 完全失明」);隐私五条:URL 去 query、输入绝不采集、用户用匿名 id、属性白名单、尊重同意状态。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.7 前端错误监控与可观测性

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫