数据埋点与监控
1. 数据埋点基础
什么是埋点
在用户交互关键节点采集行为数据,用于分析用户行为、改进产品、数据驱动决策。本质是在应用代码中嵌入采集逻辑,将用户「做了什么、在哪儿做的、怎么做」记录为结构化事件。
埋点的价值
| 维度 | 价值 | 典型指标 |
|---|---|---|
| 用户行为分析 | 转化漏斗、用户路径、留存率 | 点击率、页面跳出率、次日留存 |
| 产品迭代依据 | 功能使用率、A/B 测试、版本效果 | 功能渗透率、实验组转化差异 |
| 技术监控 | 性能数据、错误分布、稳定性 | JS 错误率、API 成功率、LCP |
埋点 vs 监控
| 维度 | 埋点 (Tracking) | 监控 (Monitoring) |
|---|---|---|
| 关注对象 | 业务行为和用户行为 | 技术指标和系统状态 |
| 数据特性 | 有语义的业务事件 | 时序数值指标 |
| 典型数据 | 按钮点击、页面浏览、下单 | JS Error、API 耗时、CPU 内存 |
| 下游用途 | 产品决策、增长分析 | 告警、故障排查、SLA 保障 |
| 互补关系 | 埋点发现产品问题 → 监控定位技术根因 |
2. 埋点方案对比
三种方案对比
| 维度 | 代码埋点 | 可视化埋点 | 全埋点/无埋点 |
|---|---|---|---|
| 精度 | 最高,精确控制每个事件 | 较高,依赖 DOM 结构 | 较低,事件噪声多 |
| 开发成本 | 高,每事件需手动编码 | 中,后台圈选配置 | 低,接入 SDK 即生效 |
| 灵活性 | 最灵活,可携带任意上下文 | 中等,只能采集可见元素 | 低,采集已有事件不能自定义 |
| 数据量 | 可控,按需采集 | 较大,圈选范围决定 | 最大,全量采集所有交互 |
| 适用阶段 | 全阶段,尤其是成熟产品 | 快速验证期 | 产品早期/探索期 |
| 维护成本 | 需随业务迭代维护 | 需维护圈选规则 | 低,SDK 升级即可 |
| 典型工具 | 自建/神策 SDK | GrowingIO | 全埋点 SDK |
代码埋点(重点)
事件设计三要素
interface TrackingEvent {
event_name: string; // 事件名(必填)
event_params: Record; // 事件属性(随事件变化)
user_properties: Record; // 用户属性(可选,描述用户状态)
}
命名规范
// snake_case + 层级化
page_view // 页面浏览
product_click // 通用商品点击
product_detail_view // 商品详情浏览
product_add_to_cart // 加入购物车
order_submit // 提交订单
order_pay_success // 支付成功
order_pay_fail // 支付失败
// 参数同理,避免大小写混用
{ "source": "homepage_recommend" }
参数设计原则
- 通用参数(自动采集):
user_id,device_id,os,browser,app_version,page_url,session_id,timestamp - 事件特有参数:按业务需求定义,遵循最小化原则,不采集无用字段
埋点 SDK 封装原则
// 不直接调底层 API,通过封装层隔离
class Tracker {
track(event: string, params?: Record<string, any>) {}
pageView(url: string, referrer?: string) {}
click(element: string, extra?: Record<string, any>) {}
}
3. 埋点核心概念
事件模型
interface TrackEvent {
event: string; // 事件名,如 "product_detail_view"
properties: { // 事件属性
product_id: string;
price: number;
source: string; // 来源页面/渠道
};
user_traits?: { // 用户属性(非每次必传)
is_vip: boolean;
level: number;
};
timestamp: number; // 事件发生时间(毫秒时间戳)
session_id: string; // 会话 ID
uuid: string; // 事件唯一 ID,用于去重
}
通用参数自动采集
function getCommonParams(): Record<string, any> {
return {
user_id: getUserId(),
device_id: getDeviceId(), // 设备指纹/持久化 ID
os: navigator.platform,
browser: getBrowser(), // ua 解析
app_version: __APP_VERSION__, // 构建时注入
page_url: location.href,
referrer: document.referrer,
session_id: getSessionId(), // 会话持久
timestamp: Date.now(),
screen: `${screen.width}x${screen.height}`,
language: navigator.language,
};
}
用户标识与关联
匿名 -> 注册打通流程
用户打开 App → device_id 标识匿名用户
用户注册/登录 → 服务端关联 device_id + user_id
绑定后后续事件携带 user_id,历史匿名数据可归因
多端用户统一
// 注册时服务端建立映射表
{
"user_id": "u_10086",
"identities": [
{ "type": "device_id", "value": "device_abc123" }, // 手机端
{ "type": "device_id", "value": "device_xyz789" }, // PC 端
{ "type": "email", "value": "user@example.com" },
{ "type": "phone", "value": "138xxxx1234" }
]
}
用户标识方案对比
| 方案 | 持久性 | 跨域 | 用户可清除 | 适用场景 |
|---|---|---|---|---|
| Cookie | 中(可设过期) | 不可跨域 | 是 | Web 端基础方案 |
| localStorage | 永久(除非清除) | 不跨域 | 是 | SPA/固定域名 |
| FingerprintJS | 较高 | 不跨域 | 难 | 反作弊/补充方案 |
| 服务端登录态 | 取决于 Token 策略 | 可打通 | 受控 | 已登录用户 |
| IDFA/OAID | 系统级 | 跨应用 | 可重置 | 移动端 |
曝光埋点
IntersectionObserver 实现
class ExposureTracker {
private observer: IntersectionObserver;
private reported = new Set<string>(); // 去重
constructor(threshold = 0.5) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-track-id');
if (id && !this.reported.has(id)) {
this.reported.add(id);
Tracker.track('element_exposure', {
element_id: id,
visible_ratio: Math.round(entry.intersectionRatio * 100),
});
}
}
});
},
{ threshold: [0, 0.25, 0.5, 0.75, 1] } // 多阈值精确控制
);
}
observe(el: Element) {
this.observer.observe(el);
}
disconnect() {
this.observer.disconnect();
}
}
列表滚动曝光去重
// Set 记录已上报元素 id,保证同元素只上报一次曝光
// 对于无限滚动列表:每次新加载的 item 设置 data-track-id
// 离开页面时重置:Session 级别曝光去重 vs 永久去重
class SessionDedupeExposure extends ExposureTracker {
constructor() {
super();
// sessionStorage 可跨页面持久化同一 session 的去重记录
this.reported = new Set(
JSON.parse(sessionStorage.getItem('track_exposure') || '[]')
);
}
protected report(id: string, ratio: number) {
super.report(id, ratio);
sessionStorage.setItem(
'track_exposure',
JSON.stringify([...this.reported])
);
}
}
虚拟列表中的曝光处理
// 虚拟列表只渲染可见 DOM,IntersectionObserver 天然适配
// 注意:回收 DOM 时 target 会被复用,需在元素复用前重置 data-track-id
// 使用 MutationObserver 监听 DOM 复用并重新 observe
function onVirtualItemMounted(el: HTMLElement, itemId: string) {
el.setAttribute('data-track-id', `item_${itemId}`);
exposureTracker.observe(el);
}
页面埋点
PV / UV
// PV:每次页面加载/路由切换上报 page_view 事件
// UV:服务端基于 user_id / device_id 去重
// SPA 路由切换
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function usePageView() {
const location = useLocation();
useEffect(() => {
Tracker.track('page_view', {
page_url: location.pathname + location.search,
page_title: document.title,
referrer: document.referrer || '',
});
}, [location]);
}
页面停留时长
class PageStayTracker {
private enterTime: number;
constructor() {
this.enterTime = Date.now();
this.setupVisibilityChange();
this.setupBeforeUnload();
}
private setupVisibilityChange() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 切换到后台,记录已停留时长但不上报
this.pauseTiming();
} else {
// 返回前台,恢复计时
this.resumeTiming();
}
});
}
private setupBeforeUnload() {
window.addEventListener('beforeunload', () => {
const duration = Date.now() - this.enterTime;
Tracker.track('page_leave', {
page_url: location.href,
duration_ms: duration,
});
});
}
}
页面深度(滚动百分比)
let maxScroll = 0;
const SCROLL_DEPTH_THRESHOLDS = [25, 50, 75, 90, 100];
window.addEventListener('scroll', throttle(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const percent = Math.min(100, Math.round((scrollTop / docHeight) * 100));
// 仅记录达到的最大深度,避免重复上报
SCROLL_DEPTH_THRESHOLDS.forEach((threshold) => {
if (percent >= threshold && maxScroll < threshold) {
maxScroll = threshold;
Tracker.track('scroll_depth', { depth: threshold });
}
});
}, 200));
4. 埋点 SDK 设计
核心架构
初始化配置
↓
事件入队(Queue → localStorage 兜底)
↓
批量 flush(定时/定量触发)
↓
sendBeacon / fetch keepalive 上报
↓
服务端接收 → Kafka / ClickHouse
上报方式对比
| 方式 | 页面卸载可靠 | 请求类型 | Payload 限制 | 特点 |
|---|---|---|---|---|
sendBeacon |
是 | POST | 64KB | 不阻塞卸载,浏览器保证送达 |
fetch keepalive |
是 | 任意 | 64KB(同 sendBeacon) | 可控制 headers/cors |
<img> 1x1 |
否(可能被阻断) | GET | URL 长度限制(~2KB) | 跨域兼容最好,无需 CORS |
| XHR | 否(异步可能被取消) | 任意 | 无 | 传统方案,逐步淘汰 |
// sendBeacon 最佳实践:页面卸载兜底
function sendBeacon(data: Record<string, any>) {
const url = 'https://t.example.com/collect';
const blob = new Blob([JSON.stringify(data)], { type: 'text/plain' });
const sent = navigator.sendBeacon(url, blob);
if (!sent) {
// sendBeacon 失败降级到 fetch keepalive
fetch(url, {
method: 'POST',
body: JSON.stringify(data),
keepalive: true,
credentials: 'include',
}).catch(() => {});
}
}
上报策略
批量合并
const BATCH_SIZE = 10; // 10 条或 5 秒触发上报
const FLUSH_INTERVAL = 5000;
const eventQueue: TrackEvent[] = [];
function enqueue(event: TrackEvent) {
eventQueue.push(event);
if (eventQueue.length >= BATCH_SIZE) {
flush();
}
}
// 定时 flush
setInterval(() => {
if (eventQueue.length > 0) flush();
}, FLUSH_INTERVAL);
async function flush() {
if (eventQueue.length === 0) return;
const batch = eventQueue.splice(0);
try {
await fetch('/api/track', {
method: 'POST',
body: JSON.stringify({ events: batch, common: getCommonParams() }),
headers: { 'Content-Type': 'application/json' },
});
} catch {
// 失败放回队列(也可能丢,权衡)
eventQueue.unshift(...batch);
}
}
页面卸载立即上报
['beforeunload', 'pagehide', 'visibilitychange'].forEach((event) => {
window.addEventListener(event, () => {
if (eventQueue.length > 0) {
sendBeacon({ events: eventQueue.splice(0) });
}
});
});
离线缓存
const OFFLINE_KEY = 'track_offline_queue';
const MAX_QUEUE_SIZE = 200; // 防止撑爆 localStorage
function saveToOffline(event: TrackEvent) {
try {
const queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]');
if (queue.length >= MAX_QUEUE_SIZE) {
queue.shift(); // 队首丢弃
}
queue.push(event);
localStorage.setItem(OFFLINE_KEY, JSON.stringify(queue));
} catch {
// localStorage 满时清空
localStorage.removeItem(OFFLINE_KEY);
}
}
function flushOffline() {
const queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]');
if (queue.length === 0) return;
localStorage.removeItem(OFFLINE_KEY);
// 批量上报离线事件
flushQueue(queue);
}
// 网络恢复时重试
window.addEventListener('online', flushOffline);
抽样上报
function shouldSample(rate: number): boolean {
// rate: 0~1,0.1 表示 10% 概率上报
return Math.random() < rate;
}
// 高频事件(mousemove/scroll 等)抽样处理
// 非关键 PV/曝光等场景也可抽样
Tracker.track = (event: string, params: any, sampleRate = 1) => {
if (!shouldSample(sampleRate)) return;
// ... 正常上报
};
数据压缩
// 字段缩写映射:减少 payload 体积
const FIELD_MAP: Record<string, string> = {
event_name: 'en',
event_params: 'ep',
user_properties: 'up',
timestamp: 'ts',
session_id: 'sid',
page_url: 'url',
};
function compress(event: TrackEvent): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(event)) {
result[FIELD_MAP[key] || key] = value;
}
return result;
}
SDK 核心代码结构
interface TrackerConfig {
appId: string;
serverUrl: string;
batchSize?: number;
flushInterval?: number;
enableOffline?: boolean;
sampleRate?: number;
}
class TrackerSDK {
private config: TrackerConfig;
private queue: TrackEvent[] = [];
private commonParams: Record<string, any>;
private userId: string | null = null;
constructor(config: TrackerConfig) {
this.config = {
batchSize: 10,
flushInterval: 5000,
enableOffline: true,
sampleRate: 1,
...config,
};
this.commonParams = this.initCommonParams();
this.setupPeriodicFlush();
this.setupUnloadBeacon();
this.flushOfflineIfNeeded();
}
private initCommonParams(): Record<string, any> {
return {
app_id: this.config.appId,
device_id: getDeviceId(),
session_id: generateSessionId(),
os: navigator.platform,
browser: navigator.userAgent,
screen: `${screen.width}x${screen.height}`,
language: navigator.language,
timestamp: Date.now(),
};
}
track(event: string, params?: Record<string, any>) {
if (Math.random() >= (this.config.sampleRate ?? 1)) return;
const eventObj: TrackEvent = {
uuid: crypto.randomUUID(),
event,
properties: { ...params },
timestamp: Date.now(),
session_id: this.commonParams.session_id,
...(this.userId && this.commonParams.user_traits),
};
this.queue.push(eventObj);
if (this.queue.length >= (this.config.batchSize ?? 10)) {
this.flush();
}
}
private async flush() {
if (this.queue.length === 0) return;
const batch = this.queue.splice(0);
const payload = {
events: batch,
common: this.commonParams,
};
try {
// 低优先级 flush 走 requestIdleCallback
requestIdleCallback(() => {
navigator.sendBeacon(
this.config.serverUrl,
new Blob([JSON.stringify(payload)], { type: 'text/plain' })
);
});
} catch {
if (this.config.enableOffline) {
this.saveToOffline(batch);
}
}
}
private setupPeriodicFlush() {
setInterval(() => this.flush(), this.config.flushInterval);
}
private setupUnloadBeacon() {
window.addEventListener('beforeunload', () => {
if (this.queue.length > 0) {
const payload = { events: this.queue.splice(0), common: this.commonParams };
navigator.sendBeacon(
this.config.serverUrl,
new Blob([JSON.stringify(payload)], { type: 'text/plain' })
);
}
});
}
setUserId(id: string) {
this.userId = id;
// 匿名 -> 登录关联专用事件
this.track('user_login', { user_id: id });
}
}
5. 埋点平台与工具
主流平台对比
| 平台 | 类型 | 核心能力 | 收费模式 | 适合场景 |
|---|---|---|---|---|
| 神策数据 | 国内 SaaS/私有化 | 全埋点 + 用户画像 + 分析模型 | 按量计费 | 中大型企业,需私有化部署 |
| GrowingIO | 国内 SaaS | 无埋点 + 增长分析 + 智能运营 | 按量/企业版 | 增长驱动型产品 |
| 友盟+ | 国内 SaaS | 免费基础分析 + 阿系生态集成 | 免费/高级版 | 中小产品,快速接入 |
| 火山引擎 | 国内 SaaS | 增长分析 + A/B 实验 + 智能运营 | 按量计费 | 字节生态/多云架构 |
| Google Analytics 4 | 国际 SaaS | 事件模型 + 机器学习 + 跨设备 | 免费 | 出海产品/全球用户 |
| Mixpanel | 国际 SaaS | 产品分析 + 漏斗/留存 + AI 分析 | 按量免费 + 付费 | 产品驱动型团队 |
| Amplitude | 国际 SaaS | 行为分析 + 预测 + 实验 | 按量付费 | 数据成熟度高产品 |
| Segment | 国际 CDP | 数据管道 + 多工具分发路由 | 按量计费 | 多下游工具统一入口 |
私有化 vs SaaS
| 维度 | SaaS | 私有化部署 |
|---|---|---|
| 数据安全 | 数据在第三方服务器 | 数据完全自控 |
| 成本 | 按量付费,前期低 | 服务器 + 运维 + 许可证,前期高 |
| 维护 | 无需维护 | 需专职运维,版本升级 |
| 合规 | 需签署 DPA/数据协议 | 适合金融/政务等强合规行业 |
| 灵活性 | 产品功能受限 | 可深度定制,二次开发 |
数据治理
事件字典 Schema 定义
# schema/events/product_detail_view.yaml
event_name: product_detail_view
description: 商品详情页浏览
version: 1.0.0
owner: @team-growth
properties:
product_id:
type: string
required: true
description: 商品 ID
price:
type: number
required: true
description: 商品当前价格(分)
source:
type: string
required: false
enum: [homepage_recommend, search, category, external_share]
description: 来源渠道
埋点验收流程
开发 → 测试环境验证事件 schema 完整性
→ 数据校验:必填字段非空、枚举值合法、类型正确
→ 联调环境确认数据落库
→ 预发环境回归验证
→ 上线后实时监控事件量波动
数据质量监测
// 检测字段缺失率异常
if (missingRate > threshold) {
alert('商品详情浏览量异常下降', eventName);
}
// 检测枚举值异常
if (unknownSourceRate > 0.05) {
alert('source 字段非法值占比过高', eventName);
}
// PV 波动告警
if (Math.abs(currentPV - avgPV) / avgPV > 0.3) {
alert('页面 PV 异常波动', pageUrl);
}
6. 前端监控体系
三层监控
┌────────────────────────────────────┐
│ 业务监控 │
│ 埋点异常 / API 成功率 / PV 波动 │
├────────────────────────────────────┤
│ 性能监控 │
│ Web Vitals / 自定义指标 / 资源瀑布图 │
├────────────────────────────────────┤
│ 错误监控 │
│ JS Error / Promise Rejection / 资源│
│ 加载 / 接口异常 │
└────────────────────────────────────┘
错误监控详解
window.onerror(全局 JS 错误)
window.onerror = (message, source, lineno, colno, error) => {
// message: 错误信息
// source: 出错文件 URL
// lineno/colno: 行/列号
// error: Error 对象
Reporter.error({
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack,
page_url: location.href,
timestamp: Date.now(),
});
return true; // 阻止默认错误处理
};
// 跨域脚本需要 crossorigin 属性才能获取完整堆栈
// <script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
// 否则 onerror 只能拿到 "Script error." 无堆栈信息
unhandledrejection(未捕获 Promise 异常)
window.addEventListener('unhandledrejection', (event) => {
Reporter.error({
type: 'unhandled_rejection',
reason: event.reason?.toString(),
stack: event.reason?.stack,
page_url: location.href,
timestamp: Date.now(),
});
// 阻止上报系统处理,但已记录
event.preventDefault();
});
React ErrorBoundary
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback?: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
Reporter.error({
type: 'react_error',
message: error.message,
stack: error.stack,
component_stack: info.componentStack,
page_url: location.href,
timestamp: Date.now(),
});
}
render() {
if (this.state.hasError) {
return this.props.fallback || <h2>页面出错了,请刷新重试</h2>;
}
return this.props.children;
}
}
// 使用
<ErrorBoundary>
<ProductDetail />
</ErrorBoundary>
资源加载错误
// 方案一:元素 onerror(推荐,可精确定位)
document.querySelectorAll('img, script, link[rel="stylesheet"]').forEach((el) => {
el.addEventListener('error', (e) => {
Reporter.error({
type: 'resource_error',
resource_url: (e.target as HTMLElement).src || (e.target as HTMLElement).href,
tag_name: (e.target as HTMLElement).tagName,
page_url: location.href,
});
});
});
// 方案二:window 捕获阶段(覆盖动态加载的资源)
window.addEventListener('error', (e) => {
const target = e.target as HTMLElement;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
Reporter.error({
type: 'resource_error',
resource_url: target.src || (target as any).href,
tag_name: target.tagName,
});
}
}, true); // 捕获阶段
接口错误(axios 拦截器)
import axios from 'axios';
const api = axios.create({ baseURL: '/api', timeout: 10000 });
// 响应拦截器
api.interceptors.response.use(
(response) => {
// 业务错误码
const { code, message } = response.data;
if (code !== 0 && code !== 200) {
Reporter.error({
type: 'api_biz_error',
api: response.config.url,
code,
message,
params: response.config.params,
duration_ms: Date.now() - (response.config as any)._startTime,
});
}
return response;
},
(error) => {
// 网络错误/超时
const info = {
type: 'api_network_error',
api: error.config?.url,
status: error.response?.status,
message: error.message, // "Network Error" / "timeout of 10000ms exceeded"
duration_ms: Date.now() - (error.config as any)._startTime,
};
Reporter.error(info);
// 区分处理
if (error.code === 'ECONNABORTED') {
// 超时
} else if (!error.response) {
// 网络断开
} else if (error.response.status >= 500) {
// 服务端 5xx
}
return Promise.reject(error);
}
);
// 请求拦截器注入开始时间
api.interceptors.request.use((config) => {
(config as any)._startTime = Date.now();
return config;
});
Source Map 还原
flowchart LR
A[构建] --> B[生成 .js + .map]
B --> C{部署}
C --> D[CDN: .js]
C --> E[Sentry/自建: .map]
D --> F[浏览器报错]
F --> G[Sentry 接收]
E --> G
G --> H[还原为源码堆栈]
关键要点
// 1. webpack/vite 构建生成 Source Map
// webpack: devtool: 'hidden-source-map' # 不输出 source map 到 bundle 末尾
// vite: build.sourcemap = 'hidden'
// 2. 上传到 Sentry(使用 sentry-cli 或 webpack plugin)
// sentry-cli releases files <version> upload-sourcemaps ./dist --rewrite
// 3. 私密 Source Map 措施
// - 不上传到 CDN 公网
// - 上传到 Sentry 私有服务器或内网服务
// - 或通过 token 鉴权访问(Nginx 限制 referer/IP)
// - 构建产物移除 .map 文件后部署到 CDN
Sentry 原理与使用
import * as Sentry from '@sentry/react';
// 初始化
Sentry.init({
dsn: 'https://xxx@sentry.example.com/1',
environment: process.env.NODE_ENV,
release: `my-app@${__COMMIT_HASH__}`, // 关联源码映射
tracesSampleRate: 0.1, // 性能追踪采样率
replaysSessionSampleRate: 0.1, // Session Replay 采样
replaysOnErrorSampleRate: 1.0, // 出错时全量回放
// 自定义指纹去重
beforeSend(event) {
// 聚合同类错误
const fingerprint = [event.exception?.values?.[0]?.type || 'unknown'];
if (event.request?.url) {
fingerprint.push(event.request.url.split('?')[0]);
}
event.fingerprint = fingerprint;
return event;
},
});
// Breadcrumbs:自动记录用户操作轨迹
// 用户点击、路由变化、网络请求、console.log 等自动记录
// 错误发生时附带 breadcrumbs 帮助回溯现场
// 手动添加 breadcrumb
Sentry.addBreadcrumb({
category: 'ui.click',
message: '用户点击了立即购买按钮',
level: 'info',
});
// 手动捕获异常
try {
riskyOperation();
} catch (e) {
Sentry.captureException(e);
}
Sentry 核心功能
| 功能 | 说明 |
|---|---|
| 错误捕获 | 自动捕获全局错误、Promise 异常、React 错误 |
| Breadcrumbs | 记录用户操作序列,还原出错前操作轨迹 |
| Release 关联 | 绑定 commit hash/version,精确还原源码行 |
| 指纹去重 | 按堆栈/消息/自定义规则计算 hash,合并同类事件 |
| 性能追踪 | Tracing 自动记录页面加载/API 请求/fetch 调用 |
| Session Replay | 录屏回放用户操作全过程(Canvas/iframe/input 脱敏) |
| 告警规则 | 错误率上升、新错误出现、自定义条件触发通知 |
7. 性能监控深入
RUM(Real User Monitoring)
PerformanceObserver 采集 Web Vitals
class WebVitalsCollector {
private metrics: Record<string, number> = {};
constructor() {
this.observeLCP();
this.observeCLS();
this.observeFCP();
this.observeTTFB();
this.observeINP();
}
private observeLCP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
this.metrics.LCP = last.startTime;
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
private observeCLS() {
let clsValue = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
this.metrics.CLS = clsValue;
});
observer.observe({ type: 'layout-shift', buffered: true });
}
private observeFCP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
this.metrics.FCP = entries[0]?.startTime;
});
observer.observe({ type: 'paint', buffered: true });
}
private observeTTFB() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
this.metrics.TTFB = entries[0]?.responseStart;
});
observer.observe({ type: 'navigation', buffered: true });
}
private observeINP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
// INP 取所有交互的最大值或 P75
const delay = (entry as any).processingStart - entry.startTime;
this.metrics.INP = Math.max(this.metrics.INP || 0, delay);
});
});
observer.observe({ type: 'first-input', buffered: true });
}
report() {
// 页面离开时上报所有指标
window.addEventListener('beforeunload', () => {
navigator.sendBeacon('/api/vitals', JSON.stringify(this.metrics));
});
}
}
RUM 聚合分析维度
| 维度 | 分组依据 | 价值 |
|---|---|---|
| 设备 | mobile / desktop / tablet | 识别低端设备性能短板 |
| 地域 | 按 ISP/CDN 节点 | CDN 覆盖问题定位 |
| 网络 | 4G / 3G / WiFi | 弱网优化决策 |
| 版本 | app_version 分组 | 版本回归监控 |
RUM vs Lab Data
| 维度 | RUM (PerformanceObserver) | Lab Data (Lighthouse) |
|---|---|---|
| 数据来源 | 真实用户 | 模拟环境 |
| 样本量 | 全量/大样本 | 单次或少量 |
| 环境覆盖 | 各种设备/网络/地域 | 标准化环境 |
| 问题定位 | 定位慢在哪儿 | 定位为什么慢 |
| 监控价值 | 持续监控回归 | CI 质量门禁 |
自定义指标
FMP(首屏有意义内容)
// 在关键元素上标记 data-fmp
// <div data-fmp="hero-banner">...</div>
// <div data-fmp="product-list">...</div>
function observeFMP() {
const fmpEntries: Record<string, number> = {};
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'element') {
const el = (entry as any).element;
if (el?.hasAttribute?.('data-fmp')) {
fmpEntries[el.getAttribute('data-fmp')] = entry.renderTime || entry.loadTime;
}
}
}
});
// 监听 Element Timing API(需提前加上 elementtiming 属性)
observer.observe({ type: 'element', buffered: true });
// 兜底:取首次 DOM 稳定后的时间作为 FMP
requestAnimationFrame(() => {
setTimeout(() => {
const fmp = Math.min(...Object.values(fmpEntries));
Reporter.metric('FMP', fmp);
}, 0);
});
}
TTI(可交互时间)
// TTI 定义:页面进入可稳定交互状态的时间
// 要求:5 秒窗口内无 >50ms 长任务
async function measureTTI() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const duration = entry.duration;
if (duration > 50) {
// 记录长任务,TTI 需向后推
Reporter.metric('long_task', {
start: entry.startTime,
duration,
});
}
}
});
observer.observe({ type: 'longtask', buffered: true });
// 简化版 TTI:以 DOMContentLoaded + 无后续长任务的时间
await new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve(true);
} else {
window.addEventListener('load', () => resolve(true));
}
});
// 等待 5 秒空闲窗口
await new Promise((r) => setTimeout(r, 5000));
// 判断窗口内有无长任务...
// 有则向后推时间窗口
}
Navigation Timing
function getNavigationTiming() {
const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
if (!entry) return {};
return {
dns_lookup: entry.domainLookupEnd - entry.domainLookupStart,
tcp_connect: entry.connectEnd - entry.connectStart,
tls_handshake: entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0,
ttfb: entry.responseStart - entry.requestStart,
dom_interactive: entry.domInteractive - entry.fetchStart,
dom_content_loaded: entry.domContentLoadedEventEnd - entry.fetchStart,
dom_complete: entry.domComplete - entry.fetchStart,
load: entry.loadEventEnd - entry.fetchStart,
redirect_count: entry.redirectCount,
};
}
PerformanceObserver 持续监听
const TYPES = ['resource', 'paint', 'longtask', 'largest-contentful-paint', 'layout-shift', 'element', 'navigation'];
TYPES.forEach((type) => {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
Reporter.metric(`perf_${type}`, {
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
...(entry as any).value && { value: (entry as any).value },
});
}
});
observer.observe({ type, buffered: true });
} catch {
// 不支持的类型跳过
}
});
Dashboard
| 监控维度 | 关键指标 | 告警阈值 | 可视化方式 |
|---|---|---|---|
| 错误率 | JS Error 率 / API 错误率 | > 1% 告警 | 折线图 + 柱状分布 |
| API 延迟 | P50 / P95 / P99 | P99 > 5s 告警 | 分位线 |
| Web Vitals | LCP / CLS / INP | LCP > 4s 告警 | 趋势 + 分布直方图 |
| PV 波动 | page_view 同比 | 下降 > 30% 告警 | 面积图 |
| 资源加载 | 资源错误率 / 加载耗时 | > 5% 告警 | 瀑布图 |
Grafana 典型面板
┌──────────────────────────────────────────────────┐
│ 错误率趋势(最近 7 天) │
│ ╱‾‾╲ ╱‾╲ ╱‾‾╲ │
│ ╱ ╲ ╱ ╲ ╱ ╲ │
│╱ ╲╱ ╲╱ ╲ │
├──────────────────────────────────────────────────┤
│ API P99 分位线(按 API 分组) │
│ /api/order ████████░░░ 1200ms │
│ /api/user ████░░░░░░░ 450ms │
│ /api/search ██████████░ 2100ms │
├──────────────────────────────────────────────────┤
│ LCP 分布(设备维度) │
│ mobile ████████░░ 0-2.5s │ ██░░ 2.5-4s │ ░░ >4s│
│ desktop █████████░ 0-2.5s │ █░░ 2.5-4s │ ░░ >4s│
├──────────────────────────────────────────────────┤
│ 页面 PV 同比(昨日 vs 上周同日) │
│ /home 12,345 ▼ -5.2% │
│ /product 8,765 ▲ +12.3% │
│ /cart 3,210 ▼ -8.7% │
└──────────────────────────────────────────────────┘
告警降级策略
const ALERT_LEVELS = {
critical: { threshold: 0.05, channels: ['sms', 'phone'], cooldown: 300 }, // 5% 错误率
warning: { threshold: 0.01, channels: ['dingtalk', 'email'], cooldown: 600 },
info: { threshold: 0.001, channels: ['daily_report'] },
};
// 降级策略:高负载时降低采样/关闭非关键埋点
function downgradeIfNeeded() {
if (getErrorRate() > 0.05) {
// 非关键事件降低采样率
setSampleRate('non_critical', 0.1);
}
if (getApiP99() > 10000) {
// 关闭非核心埋点
disableTrackers(['scroll_depth', 'hover']);
}
}
8. 数据合规与隐私
GDPR / 个人信息保护法
// 用户数据采集需明确告知并获得同意
// 实现示例:同意管理模块
class ConsentManager {
private consent: Record<string, boolean> = {};
private readonly STORAGE_KEY = 'track_consent';
constructor() {
this.load();
}
private load() {
try {
this.consent = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '{}');
} catch {
this.consent = {};
}
}
getConsent(category: 'essential' | 'analytics' | 'marketing'): boolean {
// 必要类别的默认同意(不影响基本功能)
if (category === 'essential') return true;
return this.consent[category] === true;
}
setConsent(category: string, granted: boolean) {
this.consent[category] = granted;
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.consent));
this.syncToServer(category, granted);
}
revokeAll() {
localStorage.removeItem(this.STORAGE_KEY);
this.consent = {};
this.consent.essential = true; // 必要类不可撤销
}
private syncToServer(category: string, granted: boolean) {
fetch('/api/consent', {
method: 'POST',
body: JSON.stringify({ category, granted, timestamp: Date.now() }),
});
}
}
// 数据最小化原则:只采集需要的字段
// 用户有权查看/删除被采集的数据
// 实现方式:服务端提供 data-export / data-deletion API
Cookie 合规
// Cookie Consent Banner 实现要点
// 1. 分为必要/分析/营销三类
// 2. 用户主动选择同意/拒绝,不能默认同意了
// 3. 记录用户选择持久化
// SameSite 属性影响
// SameSite=Lax (默认)允许顶级导航发送 Cookie,不影响埋点跨站归因
// SameSite=Strict 完全不发送第三方 Cookie,跨站追踪失效
// SameSite=None + Secure 跨站可用但需 HTTPS,适用于多域名跟踪
// 第三方 Cookie 渐被淘汰,替代方案:
// - 第一方 Cookie(CNAME 伪装)
// - Server-Side Tracking(避免浏览器限制)
// - 广告归因改用 Conversion Measurement API
数据脱敏与安全
// 敏感字段脱敏规则
const SENSITIVE_KEYS = ['password', 'id_card', 'phone', 'email', 'token'];
function sanitize(payload: Record<string, any>): Record<string, any> {
const result = { ...payload };
for (const key of Object.keys(result)) {
if (SENSITIVE_KEYS.some((sk) => key.includes(sk))) {
const value = String(result[key]);
if (value.length > 4) {
// 手机号:138****1234
// 邮箱:u***@example.com
// 身份证:110101****5678
result[key] = value.slice(0, 3) + '****' + value.slice(-4);
} else {
delete result[key];
}
}
}
return result;
}
// 数据传输强制 HTTPS/TLS
// 存储加密:服务端对敏感字段 AEAD 加密
// 日志脱敏:日志输出前过滤 SENSITIVE_KEYS 对应值
// 权限控制:数据访问需权限审批,不可导出全量原始数据
匿名化与假名化
| 方案 | 技术 | 可逆 | 法律要求 | 适用场景 |
|---|---|---|---|---|
| 匿名化 | 哈希(单向)+ 删除原始 ID | 不可逆 | GDPR 豁免 | 统计报表/分析 |
| 假名化 | 用户 ID 加盐哈希 或 可逆加密 | 控制方可还原 | 仍属个人数据 | 用户追踪/个性化 |
// 假名化(服务端实现)
// 用户 ID → SHA256(user_id + secret_salt) → 假名 ID
// 可加盐配置不同业务的假名不同,不可跨业务关联
// 数据分析场景:使用假名或匿名数据,不涉及真实用户
// 用户追踪场景:保留关联能力但严格控制访问权限
// GDPR 要求:即使假名化也需告知用户,用户有权要求删除假名化数据
参考:Google Analytics 4 文档 / Sentry 官方文档 / 神策数据埋点规范 / Performance API Spec