前端可观测性实践
前端可观测性是保障用户侧体验的基石。没有可观测性,线上问题就像"黑盒"——用户反馈卡了、白屏了、操作没反应,你却无从查起。前端可观测性的三大支柱:日志(Logging)、指标(Metrics)、链路追踪(Tracing)。
一、为什么前端需要可观测性
1.1 传统排查痛点
传统场景:
用户反馈"页面打不开" → 你问"什么浏览器?什么网络?"
用户说"不知道" → 你登录服务器看 Nginx 日志 → 200 OK
→ 用户说"就是白屏" → 你让他清缓存 → 用户不会操作
→ 放弃
可观测性场景:
Sentry 弹出一条错误 → Error: Failed to load chunk xxx
→ 定位到是新版本 JS 缓存未更新 → 通知运维 CDN 刷新
→ 10 分钟解决
1.2 前端三大支柱各解决什么问题
| 支柱 |
解决的问题 |
问题举例 |
| 日志(Logging) |
用户端发生了什么错误? |
"报错了,但不知道什么错、哪个用户、哪个版本" |
| 指标(Metrics) |
页面性能正常吗? |
"最近 3 天 LCP 从 1.5s 涨到了 4s,是不是发版引入了问题?" |
| 链路追踪(Tracing) |
慢请求卡在哪个环节? |
"API 请求慢,是后端慢还是前端渲染慢?" |
1.3 一次用户请求的可观测性全景
用户操作 → 页面加载 → API 请求 → 数据处理 → 页面渲染
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Logging Performance Network Console Render
(点击事件) (LCP/FID) (API耗时) (错误栈) (卡顿)
Tracing: TraceID=xyz → 贯穿所有环节
Metrics: 聚合为 P50/P95/P99 指标上报
二、前端可观测性三支柱
2.1 日志(Logging)
前端日志 vs 后端日志的区别
| 维度 |
后端日志 |
前端日志 |
| 产生环境 |
可控的服务器 |
不可控的用户浏览器(各种浏览器版本、操作系统、网络) |
| 日志量级 |
可预估,受 QPS 约束 |
不可控,受 UV 和用户行为影响 |
| 采集方式 |
Filebeat 采集文件 |
浏览器上报(Beacon / Fetch) |
| 存储时长 |
7-30 天 |
通常 3-7 天(量太大) |
| PII 风险 |
较低(内网) |
高危(可能包含身份证号等敏感信息) |
| 关键字段 |
traceId, appName, host |
traceId, userId, pageUrl, browser, os, version |
前端日志分级
// logs/logger.ts
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
FATAL = 4,
}
export interface LogEntry {
level: LogLevel;
message: string;
timestamp: number;
traceId?: string;
userId?: string;
pageUrl: string;
browser: { name: string; version: string };
os: { name: string; version: string };
extra?: Record<string, unknown>;
}
日志上报策略
| 策略 |
说明 |
适用场景 |
| 即时上报 |
产生日志立即发送 |
ERROR / FATAL 级别 |
| 批量上报 |
缓存 N 条后一起发送 |
INFO / DEBUG / WARN 级别 |
| 闲时上报 |
requestIdleCallback 触发时发送 |
低优先级的日志 |
| 合并上报 |
同类型错误合并为一条+计数 |
高频重复错误 |
2.2 指标(Metrics)
指标分类
| 类别 |
指标 |
采集方式 |
| 性能指标 |
LCP, INP, CLS, FCP, TTFB |
PerformanceObserver / web-vitals |
| 资源指标 |
JS/CSS/图片加载耗时、缓存命中率 |
Resource Timing API |
| 网络指标 |
API 成功率、耗时、状态码分布 |
fetch/axios 拦截器 |
| 业务指标 |
PV、UV、点击率、转化率 |
埋点系统 |
| 设备指标 |
内存使用、CPU 使用(降级适用) |
performance.memory(仅 Chrome) |
指标聚合方式
原始数据(单次请求耗时 320ms)
│
├→ 上报到监控平台(如 Datadog / 自研)
│ │
│ ├→ 聚合为 P50/P95/P99
│ ├→ 按页面分组求平均
│ └→ 按版本对比
│
└→ 本地预聚合(减少上报量)
│
├→ 每分钟计算一次平均值
└→ 每分钟上报一次聚合值
2.3 链路追踪(Tracing)
前端 Trace 的生命周期
用户点击"提交订单"
│
├→ TraceID 生成(UUID)────→ 注入到所有后续环节
│
├→ Span 1: "click-submit-btn" (10ms)
│ ├── Span 1.1: "validate-form" (5ms)
│ └── Span 1.2: "show-loading" (5ms)
│
├→ Span 2: "api-create-order" (800ms)
│ ├── Span 2.1: "request-queue" (2ms)
│ ├── Span 2.2: "server-process" (790ms)
│ └── Span 2.3: "parse-response" (8ms)
│
└→ Span 3: "render-success-page" (50ms)
├── Span 3.1: "update-state" (10ms)
└── Span 3.2: "dom-update" (40ms)
TraceID 传递机制
// tracing/trace-id.ts
const TRACE_ID_KEY = 'x-trace-id';
/**
* 生成 TraceID
* 前端生成后,通过 HTTP Header 传递给后端,后端再传递给下游
*/
export function generateTraceId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 从 localStorage 获取或生成 TraceID
* 同一个页面生命周期内的所有操作共用同一个 TraceID
*/
export function getOrCreateTraceId(): string {
let traceId = sessionStorage.getItem(TRACE_ID_KEY);
if (!traceId) {
traceId = generateTraceId();
sessionStorage.setItem(TRACE_ID_KEY, traceId);
}
return traceId;
}
三、前端错误监控
3.1 错误分类与捕获
前端错误分类
| 错误类型 |
捕获方式 |
典型场景 |
| JS 运行时错误 |
window.onerror / error 事件 |
TypeError: Cannot read property of undefined |
| Promise 异常 |
unhandledrejection 事件 |
async/await 未 catch |
| 资源加载错误 |
error 事件(Script/Img/Link/Font) |
CDN 挂掉、资源 404 |
| Vue 组件错误 |
Vue.config.errorHandler |
模板渲染报错 |
| React 错误边界 |
componentDidCatch / getDerivedStateFromError |
Hooks 执行异常 |
| HTTP 请求错误 |
Axios/Fetch 拦截器 |
接口 500、超时 |
| 自定义业务错误 |
代码中主动上报 |
支付失败、鉴权过期 |
全局错误捕获
// error/global-error.ts
/**
* 全局错误捕获(防御式:永远不要信任用户的浏览器环境)
*/
export function setupGlobalErrorHandler(report: (error: ErrorInfo) => void): void {
// 1. JS 运行时错误
window.addEventListener('error', (event: ErrorEvent) => {
// 排除资源加载错误(单独处理)
if (event.target !== window && event.target instanceof HTMLElement) {
report({
type: 'RESOURCE_ERROR',
message: `资源加载失败: ${(event.target as HTMLScriptElement).src || (event.target as HTMLLinkElement).href}`,
stack: '',
filename: '',
lineno: 0,
colno: 0,
timestamp: Date.now(),
});
return;
}
report({
type: 'JS_ERROR',
message: event.message,
stack: event.error?.stack || '',
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
timestamp: Date.now(),
});
});
// 2. Promise 异常(必须捕获!)
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
let message = '';
let stack = '';
if (event.reason instanceof Error) {
message = event.reason.message;
stack = event.reason.stack || '';
} else if (typeof event.reason === 'string') {
message = event.reason;
} else {
message = JSON.stringify(event.reason);
}
report({
type: 'PROMISE_ERROR',
message,
stack,
filename: '',
lineno: 0,
colno: 0,
timestamp: Date.now(),
});
});
}
Vue 3 错误处理
// vue3/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { reportError } from './error/reporter';
import { getOrCreateTraceId } from './tracing/trace-id';
const app = createApp(App);
// Vue 全局错误处理器
app.config.errorHandler = (err: unknown, instance: ComponentPublicInstance | null, info: string) => {
reportError({
type: 'VUE_ERROR',
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : '',
// 携带 Vue 组件信息,方便定位
componentName: instance?.$options?.name || instance?.$options?._componentTag || 'Anonymous',
lifecycleHook: info, // 如 'render function', 'setup function'
traceId: getOrCreateTraceId(),
timestamp: Date.now(),
});
};
// 全局 warn 采集(Vue 3 生产环境不输出 warn,但开发环境有用)
app.config.warnHandler = (msg, instance, trace) => {
console.warn('[Vue Warn]', msg, trace);
};
React 错误边界
// react/src/components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { reportError } from '../error/reporter';
import { getOrCreateTraceId } from '../tracing/trace-id';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode; // 自定义降级 UI
componentName?: string; // 当前组件名称(用于定位)
onError?: (error: Error, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* 错误边界组件
*
* ✅ 好:每个路由页面包裹 ErrorBoundary,故障隔离
* ❌ 不好:只在根组件包裹一次,一个子组件崩溃导致整个页面白屏
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
reportError({
type: 'REACT_ERROR',
message: error.message,
stack: error.stack || '',
componentName: this.props.componentName || 'Unknown',
componentStack: errorInfo.componentStack,
traceId: getOrCreateTraceId(),
timestamp: Date.now(),
});
this.props.onError?.(error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
// 提供降级 UI,而不是直接白屏
return this.props.fallback || (
<div style={{ padding: 24, textAlign: 'center' }}>
<h3>页面出错了</h3>
<p>请刷新页面重试,如果问题持续,请联系技术支持</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
重试
</button>
</div>
);
}
return this.props.children;
}
}
// 使用方式:每个路由页面单独包裹
// <Route path="/orders" element={
// <ErrorBoundary componentName="OrderPage">
// <OrderPage />
// </ErrorBoundary>
// } />
HTTP 请求错误拦截
// http/request-interceptor.ts
import axios, { AxiosError } from 'axios';
import { reportError } from '../error/reporter';
import { getOrCreateTraceId } from '../tracing/trace-id';
const http = axios.create({ timeout: 15000 });
// 请求拦截器:注入 TraceID
http.interceptors.request.use((config) => {
config.headers['X-Trace-Id'] = getOrCreateTraceId();
// 记录请求开始时间(用于计算耗时)
(config as any)._startTime = performance.now();
return config;
});
// 响应拦截器:捕获请求错误
http.interceptors.response.use(
(response) => {
const duration = performance.now() - (response.config as any)._startTime || 0;
// 慢请求告警(超过 3s 的上报)
if (duration > 3000) {
reportError({
type: 'SLOW_REQUEST',
message: `请求超时预警: ${response.config.url}`,
extra: { duration: Math.round(duration), url: response.config.url },
timestamp: Date.now(),
});
}
return response;
},
(error: AxiosError) => {
const duration = performance.now() - (error.config as any)._startTime || 0;
if (error.code === 'ECONNABORTED') {
// 请求超时
reportError({
type: 'REQUEST_TIMEOUT',
message: `请求超时: ${error.config?.url}`,
extra: { timeout: error.config?.timeout, url: error.config?.url },
timestamp: Date.now(),
});
} else if (error.response) {
// 服务器返回错误状态码
reportError({
type: 'HTTP_ERROR',
message: `HTTP ${error.response.status}: ${error.config?.url}`,
extra: {
status: error.response.status,
url: error.config?.url,
duration: Math.round(duration),
},
timestamp: Date.now(),
});
} else {
// 网络错误(断网、DNS 解析失败等)
reportError({
type: 'NETWORK_ERROR',
message: `网络异常: ${error.message}`,
extra: { url: error.config?.url },
timestamp: Date.now(),
});
}
return Promise.reject(error);
}
);
export default http;
3.2 Sentry 集成实践
方案对比:Sentry SDK 集成方式
| 集成方式 |
优点 |
缺点 |
推荐场景 |
| @sentry/vue |
开箱即用,自动捕获 Vue 错误 |
包体积 ~30KB gzip |
Vue 3 项目 |
| @sentry/react |
开箱即用,自动捕获 React 错误边界 |
包体积 ~30KB gzip |
React 项目 |
| @sentry/tracing |
自动追踪请求性能 |
增加 ~15KB 体积 |
需要 Tracing 时 |
| @sentry/browser 纯手动 |
体积最小 ~10KB |
需要自己实现所有采集 |
对体积敏感的项目 |
Vue 3 + Sentry 集成
// vue3/src/sentry.ts
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import { createApp } from 'vue';
import { Router } from 'vue-router';
export function setupSentry(app: ReturnType<typeof createApp>, router: Router): void {
// ❌ 不好:明文写 DSN
// Sentry.init({ dsn: 'https://xxx@xxx.ingest.sentry.io/12345' });
// ✅ 好:DSN 从环境变量读取(构建时注入)
Sentry.init({
app,
dsn: process.env.VITE_SENTRY_DSN || '',
environment: process.env.VITE_APP_ENV || 'development',
release: `my-app@${__APP_VERSION__}`, // 从构建工具注入版本号
// 采样率:生产 0.2,开发 1.0
sampleRate: process.env.VITE_APP_ENV === 'production' ? 0.2 : 1.0,
tracesSampleRate: process.env.VITE_APP_ENV === 'production' ? 0.1 : 1.0,
// 集成
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', 'api.example.com', /^\//],
}),
],
// 集成前回调:过滤不需要的错误
beforeSend(event) {
// ❌ 过滤掉"ResizeObserver loop"——这类错误无法处理
if (event?.exception?.values?.[0]?.value?.includes('ResizeObserver loop')) {
return null;
}
// 开发环境不上报
if (process.env.VITE_APP_ENV === 'development') {
return null;
}
return event;
},
// Breadcrumb 回调:过滤敏感信息
beforeBreadcrumb(breadcrumb) {
// 过滤掉包含 token 或 password 的请求 URL
if (
breadcrumb.category === 'xhr' &&
breadcrumb.data?.url &&
/(token|password|secret)/i.test(breadcrumb.data.url)
) {
return null;
}
return breadcrumb;
},
});
}
React + Sentry 集成
// react/src/sentry.ts
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import { createBrowserRouter } from 'react-router-dom';
export function initSentry(): void {
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN || '',
environment: process.env.REACT_APP_ENV || 'development',
release: `my-app@${process.env.REACT_APP_VERSION}`,
// 采样率配置
sampleRate: process.env.REACT_APP_ENV === 'production' ? 0.2 : 1.0,
tracesSampleRate: process.env.REACT_APP_ENV === 'production' ? 0.1 : 1.0,
integrations: [
new BrowserTracing({
// 使用 React Router 集成
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
React.useEffect,
React.useLocation,
React.useNavigationType,
createRoutesFromChildren,
matchRoutes
),
}),
],
// 过滤不需要的错误
beforeSend(event) {
if (event?.exception?.values?.[0]?.value?.includes('ResizeObserver loop')) {
return null;
}
return process.env.REACT_APP_ENV === 'production' ? event : null;
},
});
}
// 使用 Sentry 的 createReduxEnhancer(如果使用 Zustand 则不需要)
// 使用 Sentry 的 withProfiler 包裹需要性能分析的高阶组件
export { Sentry };
3.3 Source Map 上传与安全
Source Map 策略对比
| 策略 |
安全性 |
可调试性 |
说明 |
| 不上传 |
最高 |
无 |
完全看不到源码,但无法定位行号 |
| 上传到 Sentry |
高 |
好 |
Sentry 内部保留,不对外暴露(推荐) |
| 上传到公开 CDN |
极低 |
最好 |
任何人都可以反编译你的源码 |
| 内网保留 |
高 |
需要内网环境 |
适合有内网 Source Map 服务的团队 |
构建配置 Source Map 上传
// vite.config.ts (Vue 3)
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// 生产环境生成 Source Map(但不上传到 CDN)
sourcemap: process.env.VITE_APP_ENV === 'production' ? 'hidden' : true,
},
plugins: [
sentryVitePlugin({
org: 'my-org',
project: 'my-project',
// auth token 从环境变量读取
authToken: process.env.VITE_SENTRY_AUTH_TOKEN,
// 自动上传 Source Map 并在构建完成后删除
sourcemaps: {
assets: './dist/**/*.js.map',
ignore: ['node_modules/**'],
},
// 删除上传后的 Source Map 文件(避免暴露到 CDN)
cleanArtifacts: true,
// 关联 Git Commit
setCommits: {
auto: true,
},
}),
],
});
// Webpack 4/5 (React)
// webpack.config.js
// const { sentryWebpackPlugin } = require('@sentry/webpack-plugin');
// 同理配置
Source Map 安全注意事项
/**
* ❌ 危险做法:Source Map 随 JS 一起部署到 CDN
* - 任何人都可以打开 DevTools → Sources 看到你的源码
* - 导致业务逻辑泄露、API 接口暴露、鉴权逻辑被绕过
*
* ✅ 正确做法:
* 1. 构建时生成 Source Map(hidden 模式)
* 2. 通过 Sentry CLI/Plugin 自动上传到 Sentry
* 3. 构建完成后删除 .map 文件
* 4. 确保 .map 文件不会出现在 CDN 上
*/
// 验证 CDN 上没有 Source Map
// curl -I https://cdn.example.com/js/app.abc123.js.map
// 如果返回 200,说明泄露了,需要立即处理!
3.4 错误去重与聚合
错误分组策略
| 策略 |
说明 |
优点 |
缺点 |
| 消息完全匹配 |
完全相同的 error message 归为一组 |
精度高 |
可能把同源错误分拆 |
| 消息正则归一化 |
将变量替换为占位符后匹配 |
分组准确 |
规则需要维护 |
| 堆栈指纹 |
取堆栈前三帧计算 hash |
精确度高 |
计算成本略高 |
| Sentry 默认策略 |
综合 message + stack + type |
开箱即用 |
定制困难 |
客户端去重
// error/dedup.ts
/**
* 错误去重:同一页面上同一错误只上报一次
*
* ✅ 好:低频错误逐条上报,高频错误合并上报
* ❌ 不好:每个错误都上报(会把监控平台打爆)
*/
export class ErrorDeduplicator {
private reported = new Map<string, { count: number; firstSeen: number }>();
private readonly maxReportPerError = 3; // 同一错误最多上报 3 次
private readonly dedupWindow = 60000; // 去重窗口 1 分钟
private timer: ReturnType<typeof setTimeout> | null = null;
/**
* 生成错误指纹
* 将错误消息中的动态部分(如变量名、时间戳)替换为占位符
*/
private getFingerprint(error: ErrorEvent): string {
let message = error.message;
// 替换常见的变量模式
message = message.replace(/"([^"]+)"/g, '"${value}"');
message = message.replace(/'([^']+)'/g, "'${value}'");
message = message.replace(/`([^`]+)`/g, '`${value}`');
// 替换数字
message = message.replace(/\d+/g, '{number}');
// 取堆栈的前两帧作为指纹(堆栈位置决定错误本质)
const stackLines = (error.stack || '').split('\n').slice(0, 2);
const stackFingerprint = stackLines.join('').replace(/:(\d+):(\d+)/g, '');
return `${message}|${stackFingerprint}`;
}
/**
* 判断是否应该上报
* 返回 true 表示已去重,不需要上报
*/
public shouldDeduplicate(fingerprint: string): boolean {
const now = Date.now();
const record = this.reported.get(fingerprint);
if (!record) {
this.reported.set(fingerprint, { count: 1, firstSeen: now });
return false;
}
// 超过去重窗口,重置计数
if (now - record.firstSeen > this.dedupWindow) {
this.reported.set(fingerprint, { count: 1, firstSeen: now });
return false;
}
record.count++;
if (record.count > this.maxReportPerError) {
// 超过阈值,忽略
return true;
}
return false;
}
}
3.5 错误影响面评估
影响面数据采集
// error/impact.ts
export interface ErrorImpact {
errorId: string; // 错误唯一标识
userId: string; // 受影响用户
sessionId: string; // 会话 ID
browser: string; // 浏览器
os: string; // 操作系统
pageUrl: string; // 发生页面
timestamp: number; // 发生时间
version: string; // 应用版本
}
/**
* 错误影响面分析报告(监控平台生成)
*
* ┌─────────────────────────────────────┐
* │ 错误: TypeError: Cannot read │
* │ properties of undefined │
* ├─────────────────────────────────────┤
* │ 影响用户数: 1,234 │
* │ 发生次数: 4,567 │
* │ 影响版本: v2.3.0, v2.3.1 │
* │ 影响浏览器: Chrome 80%(90例) │
* │ Safari 15%(20例) │
* │ 影响页面: │
* │ /orders (80%) │
* │ /orders/detail (15%) │
* │ /cart (5%) │
* │ 最早发生: 2024-06-01 10:00:00 │
* │ 最近发生: 2024-06-01 10:30:00 │
* └─────────────────────────────────────┘
*/
四、前端性能监控
4.1 Core Web Vitals(核心网页指标)
指标说明
| 指标 |
全称 |
含义 |
良好 |
需改善 |
差 |
| LCP |
Largest Contentful Paint |
最大内容绘制(加载性能) |
≤2.5s |
2.5s-4.0s |
>4.0s |
| INP |
Interaction to Next Paint |
交互到下一次绘制(交互响应) |
≤200ms |
200ms-500ms |
>500ms |
| FID |
First Input Delay |
首次输入延迟(已废弃,用 INP 替代) |
≤100ms |
100ms-300ms |
>300ms |
| CLS |
Cumulative Layout Shift |
累积布局偏移(视觉稳定性) |
≤0.1 |
0.1-0.25 |
>0.25 |
| FCP |
First Contentful Paint |
首次内容绘制 |
≤1.8s |
1.8s-3.0s |
>3.0s |
| TTFB |
Time to First Byte |
首字节时间 |
≤800ms |
800ms-1800ms |
>1800ms |
使用 web-vitals 库自动采集
// performance/web-vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB, Metric } from 'web-vitals';
/**
* 采集并上报 Core Web Vitals
*
* ✅ 好:v3 版本的 onLCP/onINP/onCLS 是 PerformanceObserver 实现
* ❌ 不好:手动轮询检查性能指标(不准确且性能差)
*/
export function reportWebVitals(report: (metric: Metric) => void): void {
// LCP:最大内容渲染时间
onLCP((metric: Metric) => {
report({
...metric,
// 还需要额外信息:哪个元素是 LCP 元素?方便优化
element: (metric as any).attribution?.largestContentfulPaintEntry?.element?.tagName || '',
});
});
// INP(取代 FID):交互延迟
onINP((metric: Metric) => {
report(metric);
});
// CLS:累积布局偏移
onCLS((metric: Metric) => {
report(metric);
});
// FCP:首次内容绘制
onFCP((metric: Metric) => {
report(metric);
});
// TTFB:首字节时间
onTTFB((metric: Metric) => {
report(metric);
});
}
// performance/observer.ts
/**
* PerformanceObserver 是浏览器原生 API
* 用于监听各种性能事件,避免手动轮询
*
* 支持的 entryType:
* - 'largest-contentful-paint': LCP
* - 'first-input': FID/INP
* - 'layout-shift': CLS
* - 'paint': FP, FCP
* - 'navigation': 页面导航性能
* - 'resource': 资源加载性能
* - 'longtask': 长任务(>50ms)
* - 'measure': User Timing API 自定义测量
*/
export function setupPerformanceObservers(metricsCallback: (data: PerformanceEntry) => void): void {
const observerTypes = [
'largest-contentful-paint',
'first-input',
'layout-shift',
'paint',
'navigation',
'longtask',
];
observerTypes.forEach((type) => {
try {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
metricsCallback(entry);
});
});
observer.observe({ type, buffered: true });
} catch (e) {
// 某些浏览器不支持某些 entryType,静默失败
console.warn(`PerformanceObserver 不支持 ${type}`);
}
});
}
4.2 Navigation Timing & Resource Timing
Navigation Timing 关键字段
Navigation Timing(页面加载全流程):
┌─ 重定向 ─┐ ┌─ DNS ─┐ ┌─ TCP ─┐ ┌─ TLS ─┐ ┌─ 请求 ─┐ ┌─ 响应 ─┐ ┌─ DOM ─┐ ┌─ 加载 ─┐
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
startTime │ │ │ │ │ │ │ │ loadEventEnd
redirectStart │ │ │ │ │ │ │ │
redirectEnd DNS TCP TLS request response domInt domComp
(0 无重定向) start start start start start eractive lete
// performance/navigation-timing.ts
export interface NavigationTimingData {
dns: number; // DNS 查询时间
tcp: number; // TCP 连接时间
tls: number; // TLS 握手时间
ttfb: number; // 首字节时间
domParse: number; // DOM 解析时间
domReady: number; // DOM Ready 时间
loadComplete: number; // 页面完全加载时间
}
/**
* 采集页面导航性能数据
*/
export function getNavigationTiming(): NavigationTimingData | null {
const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
if (!entry) return null;
// ❌ 注意:某些浏览器可能返回 0,需要做防御处理
return {
dns: entry.domainLookupEnd - entry.domainLookupStart || 0,
tcp: entry.connectEnd - entry.connectStart || 0,
tls: (entry as any).secureConnectionStart
? entry.connectEnd - (entry as any).secureConnectionStart
: 0,
ttfb: entry.responseStart - entry.requestStart || 0,
domParse: entry.domInteractive - entry.responseEnd || 0,
domReady: entry.domContentLoadedEventEnd - entry.fetchStart || 0,
loadComplete: entry.loadEventEnd - entry.fetchStart || 0,
};
}
Resource Timing 资源分析
// performance/resource-timing.ts
export interface ResourcePerformance {
name: string; // 资源 URL
type: string; // 资源类型
duration: number; // 总耗时
dns: number;
tcp: number;
ttfb: number;
transferSize: number; // 传输大小
protocol: string; // 协议 h2/h3
cacheHit: boolean; // 是否命中缓存
}
/**
* 采集所有资源加载性能
* 用于分析 CDN 缓存命中率、资源加载耗时
*/
export function getResourceTimings(): ResourcePerformance[] {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return entries
.map((entry) => ({
name: entry.name,
type: getResourceType(entry.initiatorType),
duration: Math.round(entry.duration),
dns: Math.round(entry.domainLookupEnd - entry.domainLookupStart),
tcp: Math.round(entry.connectEnd - entry.connectStart),
ttfb: Math.round(entry.responseStart - entry.requestStart),
transferSize: entry.transferSize,
protocol: entry.nextHopProtocol || '',
cacheHit: entry.transferSize === 0 && entry.duration > 0,
}))
// 过滤掉 base64 等内联资源
.filter((r) => !r.name.startsWith('data:'));
}
function getResourceType(initiatorType: string): string {
const map: Record<string, string> = {
script: 'JS',
link: 'CSS',
img: 'Image',
fetch: 'Fetch',
xmlhttprequest: 'XHR',
css: 'CSS (import)',
font: 'Font',
};
return map[initiatorType] || initiatorType;
}
4.3 自定义性能打点(User Timing API)
// performance/user-timing.ts
/**
* User Timing API 用于测量自定义操作耗时
*
* ✅ 好:使用 performance.mark 和 performance.measure
* 不侵入业务代码,PerformanceObserver 统一采集
* ❌ 不好:在业务代码中用 Date.now() 手动计算耗时
* (精度差、侵入性强、无法统一采集)
*/
// 通用测量容器
const perfMarkers = new Map<string, number>();
/**
* 开始测量
*/
export function perfStart(key: string): void {
if (typeof performance === 'undefined' || !performance.mark) return;
performance.mark(`${key}:start`);
perfMarkers.set(key, Date.now());
}
/**
* 结束测量并返回耗时
*/
export function perfEnd(key: string): number | null {
if (typeof performance === 'undefined' || !performance.mark) return null;
try {
performance.mark(`${key}:end`);
performance.measure(key, `${key}:start`, `${key}:end`);
// 通过 PerformanceObserver 采集 measure 事件
const entries = performance.getEntriesByName(key, 'measure');
const lastEntry = entries[entries.length - 1];
return Math.round(lastEntry?.duration || 0);
} catch {
// fallback: 使用 Date.now 计算
const start = perfMarkers.get(key);
if (!start) return null;
return Date.now() - start;
}
}
Vue 3 中自定义性能打点
// vue3/src/hooks/usePerfMeasure.ts
import { onMounted, onUnmounted } from 'vue';
import { perfStart, perfEnd } from '../performance/user-timing';
import { reportMetric } from '../metrics/reporter';
/**
* 组件性能测量 Hook
*
* 用法: usePerfMeasure('OrderList-Fetch');
*/
export function usePerfMeasure(name: string) {
onMounted(() => {
perfStart(name);
});
// 在组件中调用 perfEnd 并上报
return {
done() {
const duration = perfEnd(name);
if (duration !== null) {
reportMetric({
type: 'custom_perf',
name,
value: duration,
timestamp: Date.now(),
});
}
return duration;
},
};
}
// 使用示例
// <script setup lang="ts">
// import { ref } from 'vue';
// import { usePerfMeasure } from '../hooks/usePerfMeasure';
//
// const measure = usePerfMeasure('OrderList-Init');
// const orders = ref([]);
//
// async function loadOrders() {
// const res = await api.getOrders();
// orders.value = res.data;
// measure.done(); // 上报性能数据
// }
// </script>
React Hooks 中自定义性能打点
// react/src/hooks/usePerfMeasure.ts
import { useEffect, useRef } from 'react';
import { reportMetric } from '../metrics/reporter';
interface UsePerfMeasureOptions {
/** 自动在组件卸载时上报 */
autoReport?: boolean;
/** 额外的标签,用于聚合分析 */
tags?: Record<string, string>;
}
/**
* 组件性能测量 Hook
*
* ✅ 好:封装了 start/end 和自动上报逻辑
* ❌ 不好:在每个组件中手动 Date.now() 计算
*/
export function usePerfMeasure(name: string, options: UsePerfMeasureOptions = {}) {
const { autoReport = false, tags = {} } = options;
const startTime = useRef(performance.now());
useEffect(() => {
startTime.current = performance.now();
return () => {
if (autoReport) {
const duration = performance.now() - startTime.current;
reportMetric({
type: 'custom_perf',
name,
value: Math.round(duration),
tags,
timestamp: Date.now(),
});
}
};
}, []); // 只在挂载和卸载时执行
return {
/** 手动结束测量并上报 */
end(): number {
const duration = Math.round(performance.now() - startTime.current);
reportMetric({
type: 'custom_perf',
name,
value: duration,
tags,
timestamp: Date.now(),
});
return duration;
},
/** 重置开始时间 */
reset(): void {
startTime.current = performance.now();
},
};
}
// 使用示例
// function OrderList() {
// const { end } = usePerfMeasure('OrderList-Render', {
// autoReport: true,
// tags: { page: 'orders' },
// });
//
// // 数据加载完成后手动结束
// // end();
//
// return <div>...</div>;
// }
4.4 首屏时间与白屏时间检测
| 指标 |
定义 |
检测方法 |
| 白屏时间 |
从输入 URL 到页面开始显示内容的时间 |
performance.timing.domLoading - fetchStart 或 FCP |
| 首屏时间 |
从输入 URL 到首屏(可视区域)内容完全渲染的时间 |
MutationObserver 检测首屏元素渲染完成 |
| 可交互时间(TTI) |
页面完全可交互的时间 |
Long Task + DOM 变化 |
白屏时间检测
// performance/first-screen.ts
/**
* 首屏时间检测
*
* 原理:使用 MutationObserver 监听 DOM 变化
* 当页面主要元素(如 #app > div)渲染完成后,认为首屏渲染完成
*/
export function measureFirstScreenTime(report: (duration: number) => void): void {
// 排除初始 HTML 中的节点
let isFirstMutation = true;
let startTime = Date.now();
const observer = new MutationObserver((mutations) => {
// 第一次 DOM 变更跳过(通常是 Vue/React 初始化产生的)
if (isFirstMutation) {
isFirstMutation = false;
return;
}
// 检查是否有内容节点被添加
const hasContentNodes = mutations.some((m) =>
Array.from(m.addedNodes).some(
(node) =>
node.nodeType === Node.ELEMENT_NODE &&
(node as Element).children.length > 0 &&
(node as HTMLElement).offsetHeight > 0 // 确保元素可见
)
);
if (hasContentNodes) {
const duration = Date.now() - startTime;
report(duration);
observer.disconnect();
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
});
// 超时保护:10s 后自动断开
setTimeout(() => observer.disconnect(), 10000);
}
五、用户行为监控
5.1 PV/UV 统计
| 统计口径 |
说明 |
实现方式 |
| PV(Page View) |
页面浏览次数,刷新算一次 |
路由变化时上报 |
| UV(Unique Visitor) |
独立访客数,按设备去重 |
Cookie/Storage 存储设备 ID |
| IPV |
增量页面浏览,SPA 路由切换算一次 |
router.afterEach 钩子 |
| 新老用户 |
首次访问 vs 回访 |
localStorage 标记首次访问时间 |
Vue 3 路由统计
// vue3/src/router/stats.ts
import { createRouter } from 'vue-router';
import { reportMetric } from '../metrics/reporter';
export function createStatsRouter(routes: any[]) {
const router = createRouter({ routes });
let pageEnterTime = Date.now();
router.afterEach((to, from) => {
if (!from) {
// 首次进入页面:统计 PV
reportMetric({
type: 'pv',
pageUrl: to.fullPath,
referrer: document.referrer,
userId: getUserId(),
timestamp: Date.now(),
});
pageEnterTime = Date.now();
return;
}
// 页面离开:统计停留时长
const stayDuration = Date.now() - pageEnterTime;
reportMetric({
type: 'page_exit',
pageUrl: from.fullPath,
stayDuration, // 页面停留时长(ms)
timestamp: Date.now(),
});
// 新页面进入:统计 PV
reportMetric({
type: 'pv',
pageUrl: to.fullPath,
referrer: from.fullPath,
timestamp: Date.now(),
});
pageEnterTime = Date.now();
});
return router;
}
React 路由统计
// react/src/hooks/usePageTracking.ts
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { reportMetric } from '../metrics/reporter';
/**
* 页面 PV 和停留时长监听
*/
export function usePageTracking(userId?: string): void {
const location = useLocation();
const pageEnterTime = useRef(Date.now());
const prevPath = useRef('');
useEffect(() => {
const currentPath = location.pathname + location.search;
const now = Date.now();
const stayDuration = now - pageEnterTime.current;
// 上报离开页面的时长
if (prevPath.current) {
reportMetric({
type: 'page_exit',
pageUrl: prevPath.current,
stayDuration,
userId,
timestamp: now,
});
}
// 上报当前页 PV
reportMetric({
type: 'pv',
pageUrl: currentPath,
referrer: prevPath.current || document.referrer,
userId,
timestamp: now,
});
prevPath.current = currentPath;
pageEnterTime.current = now;
}, [location.pathname, location.search]);
}
5.2 用户操作路径分析
操作路径记录
// behavior/user-flow.ts
export interface UserAction {
type: 'click' | 'input' | 'scroll' | 'route_change' | 'api_call';
target?: string; // 操作的元素描述
value?: string; // 输入的内容(脱敏处理)
pageUrl: string;
timestamp: number;
duration?: number;
}
/**
* 用户操作路径记录
*
* 示例输出:
* /home → 点击"订单" → /orders → 点击"查看详情"
* → /orders/123 → 点击"取消订单" → 弹出确认框 → 点击"确认"
* → API: POST /api/orders/123/cancel → 成功
*
* ❌ 注意: 不要记录输入框的原始值(可能包含密码/身份证号)
*/
export class UserFlowTracker {
private actions: UserAction[] = [];
private readonly MAX_ACTIONS = 200; // 单个会话最多记录 200 条
record(action: Omit<UserAction, 'timestamp'>): void {
if (this.actions.length >= this.MAX_ACTIONS) {
// 超过阈值,不允许继续记录(防止内存溢出)
return;
}
this.actions.push({ ...action, timestamp: Date.now() });
}
/**
* 上报用户操作路径
* 通常在页面关闭或触发错误时上报
*/
flush(): void {
if (this.actions.length === 0) return;
reportMetric({
type: 'user_flow',
actions: this.actions,
userId: getUserId(),
timestamp: Date.now(),
});
this.actions = [];
}
}
// 页面关闭前上报
window.addEventListener('beforeunload', () => {
userFlowTracker.flush();
});
5.3 点击热力图
// behavior/heatmap.ts
/**
* 点击热力图数据采集
*
* 原理:全局监听 click 事件,记录点击位置相对于视口的百分比坐标
* 后端聚合后生成热力图
*
* ❌ 注意:
* 1. 不要采集敏感区域(密码框、支付密码输入区)
* 2. 需要做采样(如 1% 的用户开启)
* 3. 坐标使用百分比,避免分辨率差异
*/
export function setupClickHeatmap(enable: boolean, sampleRate: number = 0.01): void {
if (!enable || Math.random() > sampleRate) return;
document.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
// ❌ 跳过敏感区域
if (target.matches('input[type="password"], input[type="tel"]')) {
return;
}
const rect = document.documentElement.getBoundingClientRect();
reportMetric({
type: 'click_heatmap',
x: Math.round((event.clientX / rect.width) * 10000) / 100, // 百分比,保留两位小数
y: Math.round((event.clientY / rect.height) * 10000) / 100,
pageUrl: window.location.pathname,
elementTag: target.tagName,
elementText: target.textContent?.slice(0, 20) || '', // 截取前 20 字符
timestamp: Date.now(),
});
});
}
六、前端埋点体系
6.1 埋点方案对比
| 方案 |
原理 |
优点 |
缺点 |
适用场景 |
| 代码埋点 |
在业务代码中显式调用埋点 API |
精确、灵活、数据质量高 |
开发工作量大、维护成本高 |
核心业务事件 |
| 可视化埋点 |
通过可视化工具圈选元素,自动生成埋点配置 |
无需开发介入、快速上线 |
圈选不精准、不支持动态内容 |
运营活动页面 |
| 全埋点(无埋点) |
自动采集所有用户交互事件 |
接入成本最低、全覆盖 |
数据量巨大、有效数据稀释、无法定义业务语义 |
用户行为分析初期 |
| 声明式埋点 |
通过属性/指令声明需要埋点的元素 |
代码入侵低、语义明确 |
需要框架支持、动态内容处理复杂 |
Vue/React 项目 |
代码埋点 SDK 封装
// tracking/tracker.ts
/**
* 埋点 SDK 封装
*
* 核心职责:
* 1. 统一埋点调用入口
* 2. 自动注入公共参数(userId, traceId, browser 等)
* 3. 数据上报策略管理
* 4. 隐私脱敏
*/
export interface TrackEvent {
eventName: string;
properties?: Record<string, unknown>;
/** 事件时间(自动生成,可覆盖) */
timestamp?: number;
}
class Tracker {
private queue: TrackEvent[] = [];
private isProcessing = false;
private readonly BATCH_SIZE = 10;
private readonly FLUSH_INTERVAL = 5000; // 5s 批量上报一次
private timer: ReturnType<typeof setInterval> | null = null;
/** 公共参数(自动注入的上下文信息) */
private getCommonParams() {
return {
userId: getUserId(),
traceId: getOrCreateTraceId(),
sessionId: getSessionId(),
pageUrl: window.location.href,
referrer: document.referrer,
browser: getBrowserInfo(),
os: getOSInfo(),
screenWidth: window.screen.width,
screenHeight: window.screen.height,
timestamp: Date.now(),
version: __APP_VERSION__,
};
}
/** 上报事件 */
track(eventName: string, properties?: Record<string, unknown>): void {
const event: TrackEvent = {
eventName,
properties: {
...this.getCommonParams(),
...properties,
},
timestamp: Date.now(),
};
this.queue.push(event);
// 达到批量阈值即刻发送
if (this.queue.length >= this.BATCH_SIZE) {
this.flush();
}
// 启动定时器
if (!this.timer) {
this.timer = setInterval(() => this.flush(), this.FLUSH_INTERVAL);
}
}
/** 批量上报 */
private async flush(): Promise<void> {
if (this.queue.length === 0 || this.isProcessing) return;
this.isProcessing = true;
const events = this.queue.splice(0, this.BATCH_SIZE);
try {
// 优先使用 sendBeacon(页面关闭时也能送达)
const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/track', blob);
} else {
// fallback: fetch
await fetch('/api/track', {
method: 'POST',
body: blob,
// ✅ 使用 keepalive 确保页面关闭时请求不中断
keepalive: true,
});
}
} catch {
// 上报失败:重新放回队列
this.queue.unshift(...events);
} finally {
this.isProcessing = false;
}
}
/** 页面关闭时强制同步上报(同步 XHR 确保送达) */
flushOnUnload(): void {
if (this.queue.length === 0) return;
const events = this.queue.splice(0);
const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });
// ❌ 注意:同步 XHR 会阻塞页面关闭,只用于关键事件
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/track', false); // false = 同步
xhr.send(blob);
}
}
export const tracker = new Tracker();
6.2 数据上报策略对比
| 策略 |
实现方式 |
送达率 |
性能影响 |
适用场景 |
| img 标签 |
new Image().src = url |
高 |
低(GET 请求) |
简单事件上报 |
| sendBeacon |
navigator.sendBeacon(url, data) |
最高(页面关闭不影响) |
低(浏览器调度) |
关键事件、页面卸载 |
| Fetch keepalive |
fetch(url, { keepalive: true }) |
高 |
低 |
批量上报 |
| XMLHttpRequest 同步 |
xhr.open('POST', url, false) |
最高 |
阻塞页面关闭 |
最后的保底方案 |
| WebSocket |
长连接推送上报 |
高 |
中(需维护连接) |
实时事件流 |
// tracking/report-strategy.ts
/**
* 上报策略选择
*
* ✅ 推荐优先级:
* 1. sendBeacon(能确保送达,不阻塞页面)
* 2. fetch + keepalive(sendBeacon 不可用时)
* 3. 同步 XHR(最后的保底,会阻塞页面关闭)
*/
export function report(data: Record<string, unknown>, endpoint: string): void {
const payload = JSON.stringify(data);
const blob = new Blob([payload], { type: 'application/json' });
// 策略 1:sendBeacon(最佳)
if (navigator.sendBeacon) {
navigator.sendBeacon(endpoint, blob);
return;
}
// 策略 2:fetch + keepalive
try {
fetch(endpoint, {
method: 'POST',
body: blob,
keepalive: true,
// 不关心响应,fire-and-forget
}).catch(() => {
// fallback 到同步 XHR
fallbackReport(data, endpoint);
});
} catch {
fallbackReport(data, endpoint);
}
}
/** 同步 XHR 保底 */
function fallbackReport(data: Record<string, unknown>, endpoint: string): void {
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, false); // 同步
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(data));
} catch {
// 实在无法上报,丢弃(避免死循环)
console.warn('上报失败:', data);
}
}
6.3 Vue 指令埋点
Vue 3 自定义指令
// vue3/src/directives/v-track.ts
import { Directive, DirectiveBinding } from 'vue';
import { tracker } from '../../tracking/tracker';
/**
* 声明式埋点指令 v-track
*
* 用法:
* <button v-track="{ eventName: 'click_submit', properties: { type: 'order' } }">
* 提交订单
* </button>
*
* ✅ 好:声明式埋点,代码入侵低,埋点和业务逻辑分离
* ❌ 不好:在业务代码中写 tracker.track()(侵入性强,不易维护)
*/
export const vTrack: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { eventName, properties } = binding.value || {};
if (!eventName) {
console.warn('v-track 缺少 eventName');
return;
}
el.addEventListener('click', () => {
tracker.track(eventName, properties);
});
},
};
// main.ts 注册
// import { vTrack } from './directives/v-track';
// app.directive('track', vTrack);
6.4 React 组件埋点
// react/src/components/TrackClick.tsx
import React, { cloneElement, ReactElement } from 'react';
import { tracker } from '../tracking/tracker';
interface TrackProps {
eventName: string;
properties?: Record<string, unknown>;
children: ReactElement;
}
/**
* 声明式埋点组件 TrackClick
*
* 用法:
* <TrackClick eventName="click_submit" properties={{ type: 'order' }}>
* <Button>提交订单</Button>
* </TrackClick>
*
* ✅ 好:埋点和 UI 解耦,通过 props 声明事件
* ❌ 不好:在 onClick 里混合业务逻辑和埋点代码
*/
export function TrackClick({ eventName, properties, children }: TrackProps): ReactElement {
return cloneElement(children, {
onClick: (...args: any[]) => {
// 先执行埋点
tracker.track(eventName, properties);
// 再执行原始 onClick(如果存在)
if (children.props.onClick) {
children.props.onClick(...args);
}
},
});
}
// 高阶组件版本(用于需要批量埋点的场景)
export function withTrack<P extends object>(
WrappedComponent: React.ComponentType<P>,
defaultEventName: string,
defaultProps?: Record<string, unknown>
) {
return function TrackedComponent(props: P & { trackProps?: Record<string, unknown> }) {
const { trackProps, ...rest } = props as any;
return (
<TrackClick
eventName={defaultEventName}
properties={{ ...defaultProps, ...trackProps }}
>
<WrappedComponent {...(rest as P)} />
</TrackClick>
);
};
}
// 使用示例
// const TrackedButton = withTrack(Button, 'click_button', { module: 'order' });
// <TrackedButton type="primary" trackProps={{ action: 'submit' }}>
// 提交
// </TrackedButton>
6.5 隐私脱敏
// tracking/sanitize.ts
/**
* 敏感信息脱敏
*
* ❌ 永远不要上报的字段:
* - 密码(password, pwd, passwd)
* - 身份证号(idCard, id_card)
* - 银行卡号(bankCard, cardNo)
* - 手机号(phone, mobile, tel)
* - Token / Secret Key
*/
const SENSITIVE_KEYS = [
'password', 'pwd', 'passwd', 'secret', 'token',
'idCard', 'id_card', 'idcard', 'certNo', 'cert_no',
'bankCard', 'cardNo', 'card_no',
'phone', 'mobile', 'tel',
];
/**
* 递归脱敏
*/
export function sanitize(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// 检查键名是否敏感
if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k))) {
result[key] = '***SANITIZED***';
continue;
}
// 递归处理嵌套对象
if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = sanitize(value as Record<string, unknown>);
} else if (Array.isArray(value)) {
result[key] = value.map((item) =>
typeof item === 'object' && item !== null
? sanitize(item as Record<string, unknown>)
: item
);
} else {
result[key] = value;
}
}
return result;
}
七、监控告警
7.1 前端监控指标告警
| 指标 |
告警阈值 |
严重级别 |
说明 |
| JS 错误率 |
> 1%(占总 PV) |
Critical |
可能有代码 bug 发布 |
| LCP |
P75 > 3s |
Warning |
加载性能劣化 |
| INP |
P75 > 400ms |
Warning |
交互响应变慢 |
| CLS |
P75 > 0.25 |
Warning |
页面布局不稳 |
| API 成功率 |
< 95% |
Critical |
后端接口大面积报错 |
| 白屏率 |
> 0.5% |
Critical |
页面白屏是最严重的用户体验问题 |
| 资源加载失败率 |
> 0.1% |
Warning |
CDN 或静态资源问题 |
7.2 告警规则设计
// alert/rules.ts
/**
* 前端告警规则定义
*
* 原则:
* 1. Critical 告警发即时通知(钉钉/企微)
* 2. Warning 告警聚合后每天发一次 Report
* 3. 避免"告警疲劳"——只有确实影响用户的指标才告警
*/
export const alertRules = {
// ====== Critical 级别 ======
JS_ERROR_RATE: {
name: 'JS 错误率过高',
severity: 'critical' as const,
condition: (metrics: Metrics) => {
// 窗口期 5 分钟内,错误率超过 1%
const errorRate = metrics.jsErrorCount / metrics.pvCount;
return errorRate > 0.01;
},
message: (metrics: Metrics) =>
`JS 错误率 ${((metrics.jsErrorCount / metrics.pvCount) * 100).toFixed(2)}% (阈值 1%)`,
},
API_ERROR_RATE: {
name: 'API 错误率过高',
severity: 'critical' as const,
condition: (metrics: Metrics) => {
return metrics.apiErrorRate > 0.05; // 5%
},
message: (metrics: Metrics) =>
`API 错误率 ${(metrics.apiErrorRate * 100).toFixed(2)}%`,
},
WHITE_SCREEN_RATE: {
name: '白屏率过高',
severity: 'critical' as const,
condition: (metrics: Metrics) => {
return metrics.whiteScreenRate > 0.005; // 0.5%
},
message: (metrics: Metrics) =>
`白屏率 ${(metrics.whiteScreenRate * 100).toFixed(3)}%`,
},
// ====== Warning 级别 ======
LCP_DEGRADATION: {
name: 'LCP 劣化',
severity: 'warning' as const,
condition: (metrics: Metrics) => {
// LCP P75 超过 3s
return metrics.lcpP75 > 3000;
},
message: (metrics: Metrics) =>
`LCP P75: ${metrics.lcpP75}ms (阈值 3000ms)`,
},
RESOURCE_LOAD_FAILURE: {
name: '资源加载失败率过高',
severity: 'warning' as const,
condition: (metrics: Metrics) => {
return metrics.resourceFailRate > 0.001; // 0.1%
},
message: (metrics: Metrics) =>
`资源加载失败率: ${(metrics.resourceFailRate * 100).toFixed(2)}%`,
},
};
7.3 性能劣化检测
// alert/regression-detector.ts
/**
* 版本性能回归检测
*
* 思路:发新版后,对比新版本和旧版本的性能指标
* 如果新版本的 P75 指标劣化超过 20%,自动触发告警
*/
export function detectRegression(oldVersion: VersionMetrics, newVersion: VersionMetrics): RegressionReport | null {
const regressions: string[] = [];
for (const metric of ['lcp', 'inp', 'cls', 'fcp', 'ttfb'] as const) {
const oldVal = oldVersion[metric];
const newVal = newVersion[metric];
if (!oldVal || !newVal) continue;
const change = ((newVal - oldVal) / oldVal) * 100;
if (change > 20) { // 劣化超过 20%
regressions.push(`${metric}: ${oldVal}ms → ${newVal}ms (${change.toFixed(1)}%)`);
}
}
if (regressions.length === 0) return null;
return {
oldVersion: oldVersion.version,
newVersion: newVersion.version,
regressions,
detectedAt: Date.now(),
};
}
八、监控平台对比
8.1 主流方案对比
| 维度 |
Sentry |
Datadog RUM |
自研方案 |
| 接入成本 |
低(30 分钟可接入) |
低(1 小时可接入) |
高(开发量 2-4 周) |
| 错误监控 |
强大(自动分组、堆栈追踪) |
良好 |
需自建 |
| 性能监控 |
基础(需配合 Tracing) |
全面(RUM + Session Replay) |
需自建 |
| 用户行为 |
基础(Breadcrumb) |
强大(Session Replay 回放用户操作) |
需自建 |
| Source Map |
自动上传和管理 |
支持 |
需自建 |
| 告警能力 |
基础规则 |
强大(可配置复杂条件) |
灵活但需自建 |
| 价格 |
免费额度 5k events/月,之后按量付费 |
贵(按 Session 计费) |
服务器成本低 |
| 数据安全 |
数据在 Sentry 服务器 |
数据在 Datadog 服务器 |
完全可控 |
| 自定义程度 |
中等 |
中等 |
最高 |
| 团队要求 |
无需专门运维 |
无需专门运维 |
需前端 + 后端 + 运维 |
8.2 选型建议
| 团队规模 |
推荐方案 |
理由 |
| 1-5 人小团队 |
Sentry(免费版) |
0 成本接入,开箱即用 |
| 5-20 人团队 |
Sentry(付费版)+ web-vitals |
成本可控,功能足够 |
| 20+ 人团队(有预算) |
Datadog RUM + APM |
全链路打通,端到端分析 |
| 大厂/有自研能力 |
自研 + 开源组件(Sentry SDK 定制) |
数据安全可控,成本低 |
8.3 自研方案架构
自研前端监控系统架构:
用户浏览器
│
├→ 上报 SDK(采集错误 + 性能 + 行为)
│ │
│ ├→ sendBeacon / fetch → 上报网关(Nginx/LB)
│ │
│ ▼
├→ 数据接收服务(Go/Node.js)
│ │
│ ├→ 写入 Kafka(缓冲 + 削峰)
│ │
│ ▼
├→ 流处理(Flink / 实时计算)
│ │
│ ├→ 实时聚合:错误率、P50/P95/P99
│ ├→ 异常检测:突发错误告警
│ └→ 写入 ClickHouse / Elasticsearch
│
├→ 告警引擎
│ ├→ 基于规则(错误率 > 1% 告警)
│ └→ 基于 ML(异常检测)
│
└→ 可视化平台(Grafana / 自建)
├→ 错误看板:趋势、TOP 错误、影响面
├→ 性能看板:LCP/INP/CLS 趋势、版本对比
└→ 业务看板:PV/UV、转化漏斗、用户路径
九、最佳实践
9.1 前端可观测性成熟度模型
| 级别 |
说明 |
具备能力 |
| L0 不可观测 |
没有任何监控 |
用户反馈了才知道出问题 |
| L1 基础监控 |
接入错误监控 |
能知道哪里报错、报错量、影响面 |
| L2 性能监控 |
+ 性能指标采集 |
能知道页面加载慢、交互卡顿 |
| L3 行为分析 |
+ 用户行为采集 |
能还原用户操作路径、分析转化漏斗 |
| L4 全链路 |
+ Tracing + 告警 |
前端请求链路可视化、指标劣化自动告警 |
| L5 主动预防 |
+ ML 异常检测 |
在用户反馈前发现并修复问题 |
9.2 注意事项与踩坑点
1. 数据量爆炸
- 问题:前端 PV 量大,全量上报会把服务器打爆
- 解决:采样上报(生产 10-20%)、批量合并、高峰期降级
2. 上报性能影响
- 问题:上报请求影响页面性能(特别是低端机)
- 解决:使用 sendBeacon(不阻塞主线程)、控制上报频率
- ❌ sendBeacon 不支持自定义 header(Content-Type 固定为 text/plain)
- ✅ 如果不需要自定义 header,优先用 sendBeacon
3. Source Map 泄露
- 问题:.map 文件部署到 CDN 导致源码泄露
- 解决:hidden 模式构建,只上传到 Sentry 不部署到 CDN
4. 跨域错误信息丢失
- 问题:CDN 资源的错误在 window.onerror 中 message = "Script error."
- 解决:CDN 资源加 crossorigin="anonymous" 属性
5. 隐私合规
- 问题:GDPR / 个人信息保护法要求用户知情同意
- 解决:首次访问弹窗获取同意、提供"关闭数据采集"选项
- 上报前脱敏处理(去掉用户 PII 信息)
6. 白屏误报
- 问题:业务逻辑导致的页面空白被误报为"白屏"
- 解决:白屏检测要有超时阈值和重试机制
- 排除用户主动操作导致的白屏(如 SPA 路由加载中)
7. 去重策略
- 问题:同一错误在短时间内重复上报 N 次
- 解决:客户端本地去重(同类错误每分钟最多 3 次)
8. 版本匹配
- 问题:新发布版本的 Source Map 和 JS 版本不匹配
- 解决:构建时在 JS 文件名中注入版本 hash
- Source Map 上传时关联 Git commit
9.3 ✅ 好的实践 vs ❌ 不好的实践
上报策略
// ✅ 好:错误和性能分开通道上报,关键错误即时发送
function uploadError(error: ErrorInfo) {
// 即时上报(高优先级)
navigator.sendBeacon('/api/errors', JSON.stringify(error));
}
function uploadMetrics(metrics: Metric[]) {
// 批量上报(低优先级)
requestIdleCallback(() => {
const blob = new Blob([JSON.stringify({ metrics })], { type: 'application/json' });
navigator.sendBeacon('/api/metrics', blob);
});
}
// ❌ 不好:所有数据都用同步 XHR 上报
// 阻塞主线程,导致页面卡顿
采样策略
// ✅ 好:根据环境 + 事件类型动态采样
function shouldSample(eventType: string): boolean {
if (process.env.APP_ENV === 'development') return true;
if (eventType === 'error') return true; // 错误全量
if (eventType === 'performance') return Math.random() < 0.1; // 性能 10% 采样
if (eventType === 'click') return Math.random() < 0.01; // 点击 1% 采样
return false;
}
// ❌ 不好:所有事件全量上报
// 数据量巨大,服务器成本高,有效信息被稀释
错误处理
// ✅ 好:errorHandler 做兜底,但不吞掉原始错误
app.config.errorHandler = (err, instance, info) => {
reportError(err);
// 仍然 console.error 输出,便于开发调试
console.error(err);
};
// ❌ 不好:全局错误处理吞掉错误,开发者看不到
// app.config.errorHandler = (err) => {
// reportError(err);
// // 没有 console.error,开发环境也看不到错误
// };
防御式编程
// ✅ 好:永远不要信任后端返回的数据
interface UserResponse {
id: number;
name: string;
email?: string;
}
// 使用 Zod 校验后端返回的数据
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email().optional(),
});
async function fetchUser(id: number): Promise<UserResponse> {
const res = await api.get(`/users/${id}`);
const parsed = UserSchema.safeParse(res.data);
if (!parsed.success) {
// 数据格式不对 → 上报 + 抛出语义清晰的错误
reportError({
type: 'DATA_FORMAT_ERROR',
message: `用户数据格式异常: ${parsed.error.message}`,
extra: { rawData: res.data },
});
throw new Error('用户数据异常,请联系技术支持');
}
return parsed.data;
}
// ❌ 不好:直接信任后端数据
// async function fetchUser(id: number) {
// const res = await api.get(`/users/${id}`);
// return res.data; // 如果 res.data 为 null,调用处直接炸
// }
十、生产环境常见问题
10.1 上报接口成为性能瓶颈
现象:上线的第一天,/api/track 接口 QPS 暴增,后端扛不住
原因:全量上报 + 未做采样
解决方案:
1. 增加采样率(生产 10%)
2. 前端聚合后再上报(每分钟聚合一次)
3. 上报接口独立部署(不跟业务接口混在一起)
10.2 跨域 Script Error
现象:window.onerror 拿到的是 "Script error.",没有具体错误信息
原因:跨域加载的 CDN 脚本出错时,浏览器默认不暴露错误详情
解决方案:
<!-- CDN 脚本加 crossorigin 属性 -->
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
// CDN 返回头也需要配置
// Access-Control-Allow-Origin: *
10.3 低端机性能损耗
现象:低端 Android 手机安装了监控 SDK 后,页面加载更慢了
原因:SDK 在低端机上初始化耗时较长
解决方案:
1. 监控 SDK 延迟加载(不阻塞页面渲染)
2. 低端机降级(只采集错误,不采集性能和埋点)
3. 按需加载模块(不用 Tracing 就不加载 Tracing 模块)
10.4 Source Map 泄露事故
现象:某天发现竞品公司的 API 文档里引用了自己产品的 API 接口
原因:.map 文件随 JS 一起部署到 CDN
教训:
1. 构建时使用 hidden-source-map,不部署 .map 文件
2. 部署后扫描 CDN,确认 .map 不存在
3. 如果已泄露,立即在 CDN 上删除 .map 文件
4. 考虑 API 鉴权和接口混淆
十一、面试必背要点
Q:前端可观测性和后端可观测性的核心区别是什么?
后端:受控环境(服务器),日志量可预估,可以采集所有请求
前端:不受控环境(用户浏览器),日志量大不可控,必须采样
需要关注浏览器的兼容性差异
隐私合规(GDPR)是必须考虑的维度
Q:什么是 Core Web Vitals?为什么重要?
Core Web Vitals 是 Google 定义的三个核心用户体验指标:
LCP(加载性能)、INP(交互响应)、CLS(视觉稳定性)
重要原因:
1. 影响 Google 搜索排名(SEO)
2. 直接影响用户留存和转化率
3. 提供统一的性能衡量标准,便于跨团队对齐
Q:sendBeacon 和 fetch 上报有什么区别?
sendBeacon:
- 浏览器调度发送时机,不阻塞页面关闭
- 更适合页面卸载时上报
- 不支持自定义 Header
- 发送大小有限制(通常 64KB)
fetch + keepalive:
- 可以设置自定义 Header
- keepalive 确保页面关闭时请求不中断
- 兼容性比 sendBeacon 差一点点
Q:前端错误上报后如何快速定位问题?
1. Sentry 按错误类型分组,查看发生频率和影响用户数
2. 查看错误堆栈 + Source Map 还原源码位置
3. 查看错误 Breadcrumb(用户操作路径)
4. 查看用户浏览器、OS、版本信息
5. 关联后端日志 TraceID,定位全链路
6. 如果是版本回归,对比上个版本的错误率变化
Q:如何避免上报过多导致服务器崩溃?
1. 采样上报:生产环境 10-20% 采样率
2. 客户端去重:同类错误限制上报次数
3. 批量上报:缓存多条后合并发送
4. 闲时上报:requestIdleCallback / setTimeout 延迟
5. 按优先级分级:错误全量,性能采样,行为低采样
6. 后端限流:Nginx 限流 + Kafka 缓冲
Q:Source Map 应该怎么处理?
1. 构建时生成 hidden-source-map(不暴露到 CDN)
2. 通过 Sentry CLI/Plugin 自动上传到监控平台
3. 构建完成后删除 .map 文件
4. 确保 .map 不会被部署到 CDN(.gitignore + CI/CD 配置)
5. 发版后扫描 CDN 确认无 .map 文件
最后更新:2026/06/29