
一句话:前端错误监控的核心是「把『压缩后的一行报错』还原成『能定位到源码的行为链路』」——所以四件事缺一不可:全局捕获(含跨域脚本)→ 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 挂了」或「页面崩了」
八、面试延伸
- 「前端错误监控要采集哪些错误?怎么捕获?」
五类,每一类都要单独接:① 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/扩展——⚠️ 用「忽略规则」过滤(否则污染错误率),但 ⭐ 要把「被忽略的数量」也计数(避免「过滤过头」而失明)。 -
「为什么生产环境的错误堆栈无法定位?怎么解决?」
因为 生产代码被压缩混淆——堆栈是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(生成但不加注释)+ 上传后从产物删除。 -
「跨域脚本的错误为什么只显示
Script error.?」
因为浏览器出于安全,对「跨域加载的脚本」抛出的错误,会把「message / 文件名 / 行号列号全部清空」,只剩Script error.(⭐⭐ 而 CDN 部署是绝大多数项目的方式,所以这是极常见的盲区)。解决需要两个条件同时满足:① 脚本标签加crossorigin="anonymous";② CDN 返回Access-Control-Allow-Origin。⚠️⚠️ 但要非常小心:加了crossorigin会改变请求模式(带Origin头,进入 CORS 流程)——如果 CDN 没配好 CORS,脚本会直接加载失败(⭐ 这是「上线后白屏」的经典事故)。所以「加crossorigin」与「配 CORS」必须同时上线。⭐ 另外:即使拿不到信息,也应该单独统计Script error.的数量(它是一个「CORS 没配好」的信号)。 -
「错误怎么分组去重?」
靠指纹(fingerprint),包含:① ⭐ 错误类型 + 归一化后的消息——⚠️ 必须把动态内容替换掉(UUID →<uuid>、数字 →<n>、URL →<url>、引号内容 →<str>),否则「每条错误都带不同的 id」→ 每个都是「新问题」;② ⭐ 堆栈的「关键帧」——取「最深的、属于我们代码的」几帧(过滤node_modules/框架内部帧——它们是「结果」不是「原因」)+ 去掉行号列号(不同构建会变);③ 页面/路由(同一个错误在不同页面算「不同问题」有时更有用);④ 可选:框架版本。除了分组,还要「限流」:⚠️ 一个死循环能「每秒报 1000 次」→ 必须「同一指纹在会话内只报一次完整详情,后续只累加计数」,并加 「错误循环检测」(单位时间内超过阈值 → 抑制上报并单独报一条「疑似循环」)。 -
「面包屑(Breadcrumb)是什么?为什么重要?」
它是「错误发生之前,用户干了什么」的时间线——⭐ 这是「监控数据」与「能行动的数据」之间的关键桥梁。采集内容:① 路由变化(pushState/popstate);② 点击(⭐ 只记「元素描述」不记「输入内容」——隐私 + 噪声);③ 网络请求(与 7.13 的请求层配合,记method route status ms);④ console(⚠️ 只记 warn/error,且要防递归——因为我们自己的上报也可能console.error)。实现要点:用环形缓冲(只留最近 20~30 条)+ ⭐ 「元素描述」不要带用户内容(用data-track/aria-label做「人类可读描述」)。价值:⭐ 「用户说『点了没反应』」→ 面包屑显示「点了哪个按钮 → 发了哪个请求 → 请求 500」→ 直接定位。 -
「Session Replay(录屏)值得做吗?要注意什么?」
价值很高(⭐「看一遍用户的真实操作」比「读一百行堆栈」有效),但风险也高。原理:① 记录「初始 DOM 快照」;② 之后用MutationObserver记录「增量变更」;③ 记录输入与鼠标/滚动;④ 回放时按时间轴重放(⭐ 因为只存增量,一个 10 分钟会话可能只有几十 KB)。⚠️⚠️ 最重要的是隐私合规:① ⭐ 默认遮蔽——input[type=password]以及所有 input/textarea 默认打码;② ⭐ 白名单式——只录「标了data-replay="allow"」的区域(而不是「黑名单排除」,因为「黑名单总会漏」);③ ⭐ 只对「有错误的会话」录制(既省成本又降风险);④ 明确写进隐私政策 + 提供关闭入口。⭐ 判断:「录屏数据泄露」的后果远大于「错误数据泄露」——所以「要么不做,要么做到白名单式默认遮蔽」。 -
「错误监控的告警怎么设计?」
四条纪律:① ⭐ 按「问题」告警,不按「事件」——❌「错误数 > 100 就告警」(一个死循环能触发几百条);✅「新增的问题(首次出现的指纹)」+「已有问题的『影响面』显著上升」;② ⭐ 按「影响面」分级——「影响 > 5% 会话」立刻告警、「0.1%~5%」日报、「< 0.1%」只进看板(⭐ 判据是「受影响用户/会话数」而不是「次数」);③ ⭐ 「新版本引入的问题」单独告警——这是「最有行动价值」的一类:发布后 5 分钟内「新增指纹」→ 立刻告警 → 回滚是最快的修复(✅ 做法是「release 维度的对比」);④ ⭐ 告警必须能定位——包含「错误信息 + 受影响页面 + release + 一条回放链接」(⚠️「服务端错误率上升了」这种告警无法行动)。另外:「SDK 自身的健康监控」——⭐ 如果上报接口挂了,你会「看不到任何错误」(最危险的静默),所以要有「1 小时内上报量为 0 → 告警」。 -
「监控数据里的隐私问题怎么处理?」
五条:① ⭐ 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、属性白名单、尊重同意状态。



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