前端运行时性能监控:Web Vitals 指标的采集、聚合与告警架构设计
一、"用户说卡"但不知道为什么卡
一个国际化电商平台上线后,客服收到了大量"页面很卡"的反馈。后端监控一切正常——API 延迟 P99 在 200ms 以内,错误率 0.1%。但用户确实在投诉。问题出在哪里?用户端的性能数据完全缺失——没有前端性能监控,后端日志只能看到"请求成功返回"的时间点,看不到浏览器渲染的时间、交互响应的时间、视觉稳定性的变化。
前端性能监控不是"又一个看板",而是"解决后端监控盲区的必要补充"。Web Vitals 标准化的三个核心指标(LCP、FID/INP、CLS)覆盖了加载性能、交互响应和视觉稳定性,是前端性能的数据基石。
二、Web Vitals 的采集架构
flowchart TD
A[浏览器: web-vitals.js] –> B[采集原始指标]
B –> C{本地聚合: 每 5 秒批量上报}
C –> D[上报端点: POST /api/vitals]
D –> E[数据清洗: 过滤异常值]
E –> F[写入时序数据库]
F –> G[聚合计算: P75/P95/P99]
G –> H[Grafana 看板]
G –> I[告警规则: P95 > 阈值]
I –> J[Slack/钉钉通知]
2.1 客户端采集实现
// web-vitals 采集器:封装标准 API 并做批量上报
import { onCLS, onFCP, onLCP, onINP, onTTFB } from 'web-vitals';
interface VitalMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
timestamp: number;
navigationType: string;
}
class WebVitalsCollector {
private buffer: VitalMetric[] = [];
private flushInterval = 5000; // 5 秒批量上报
private endpoint = '/api/vitals';
private sampleRate = 0.1; // 10% 采样率(大流量场景)
constructor(options?: { sampleRate?: number }) {
if (options?.sampleRate !== undefined) {
this.sampleRate = options.sampleRate;
}
}
start(): void {
// 采样控制:避免全量上报
if (Math.random() > this.sampleRate) return;
// 核心指标采集
onCLS(this.handleMetric.bind(this));
onLCP(this.handleMetric.bind(this));
onINP(this.handleMetric.bind(this));
onFCP(this.handleMetric.bind(this));
onTTFB(this.handleMetric.bind(this));
// 定时批量上报
setInterval(() => this.flush(), this.flushInterval);
// 页面关闭前强制上报
window.addEventListener('beforeunload', () => {
this.flush(true); // 使用 sendBeacon 保证送达
});
}
private handleMetric(metric: VitalMetric): void {
this.buffer.push(metric);
}
private flush(useBeacon = false): void {
if (this.buffer.length === 0) return;
const payload = JSON.stringify({
metrics: this.buffer,
url: window.location.pathname,
userAgent: navigator.userAgent.substring(0, 100), // 截断
effectiveType: (navigator as any).connection?.effectiveType,
});
this.buffer = [];
if (useBeacon) {
navigator.sendBeacon(this.endpoint, payload);
} else {
fetch(this.endpoint, {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {
// 上报失败不阻塞业务,静默处理
});
}
}
}
2.2 服务端聚合与存储
// 指标聚合服务:接收客户端上报,写时序数据库
type MetricsIngestor struct {
influx *influxdb.Client
aggregator *WindowAggregator
}
type WindowAggregator struct {
mu sync.Mutex
windows map[string]*MetricsWindow
interval time.Duration // 聚合窗口: 30 秒
}
type MetricsWindow struct {
StartTime time.Time
Metrics map[string]*MetricStats // key: metric_name
}
type MetricStats struct {
Count int
Sum float64
Values []float64 // 用于计算分位数
Min float64
Max float64
}
func (a *WindowAggregator) Add(metric VitalMetric, pageURL string) {
a.mu.Lock()
defer a.mu.Unlock()
key := pageURL + ":" + metric.Name
window, ok := a.windows[key]
if !ok {
window = &MetricsWindow{
StartTime: time.Now(),
Metrics: make(map[string]*MetricStats),
}
a.windows[key] = window
}
stats, ok := window.Metrics[metric.Name]
if !ok {
stats = &MetricStats{Min: math.MaxFloat64}
window.Metrics[metric.Name] = stats
}
stats.Count++
stats.Sum += metric.Value
stats.Values = append(stats.Values, metric.Value)
stats.Min = math.Min(stats.Min, metric.Value)
stats.Max = math.Max(stats.Max, metric.Value)
}
// 窗口到期时计算分位数并写入 InfluxDB
func (a *WindowAggregator) Flush() []AggregatedPoint {
a.mu.Lock()
defer a.mu.Unlock()
var points []AggregatedPoint
now := time.Now()
for key, window := range a.windows {
if now.Sub(window.StartTime) < a.interval {
continue
}
for name, stats := range window.Metrics {
sort.Float64s(stats.Values)
p75 := stats.Values[int(float64(len(stats.Values))*0.75)]
p95 := stats.Values[int(float64(len(stats.Values))*0.95)]
p99 := stats.Values[int(float64(len(stats.Values))*0.99)]
points = append(points, AggregatedPoint{
Metric: name,
PageURL: key,
P75: p75,
P95: p95,
P99: p99,
Avg: stats.Sum / float64(stats.Count),
Count: stats.Count,
Time: window.StartTime,
})
}
delete(a.windows, key)
}
return points
}
三、告警规则的设计
# Grafana 告警规则示例
alerting:
rules:
– name: LCP P95 超过阈值
condition: avg_over_time(lcp_p95[5m]) > 2500
severity: warning
message: "页面 {{ $labels.page }} 的 LCP P95 超过 2.5s"
– name: CLS P95 超过阈值
condition: avg_over_time(cls_p95[5m]) > 0.25
severity: critical
message: "页面 {{ $labels.page }} 的 CLS P95 超过 0.25"
– name: INP P75 超过阈值
condition: avg_over_time(inp_p75[5m]) > 200
severity: warning
message: "交互延迟 P75 超过 200ms"
# 阈值参考(Google Web Vitals 标准)
thresholds:
lcp: { good: 2500, poor: 4000 } # 毫秒
fid: { good: 100, poor: 300 } # 毫秒
inp: { good: 200, poor: 500 } # 毫秒
cls: { good: 0.1, poor: 0.25 } # 无单位
fcp: { good: 1800, poor: 3000 } # 毫秒
ttfb: { good: 800, poor: 1800 } # 毫秒
告警建议使用 P75/P95 而非平均值——平均值会被极端慢的用户拉高,也可能被大量极快用户拉低,掩盖真正的体验问题。
四、边界与权衡
采样的成本控制:日活 100 万的产品全量上报每天产生 500GB+ 时序数据。10% 采样率在不影响统计显著性的前提下,将成本降到 50GB/天。对于差异分析(如按地区/设备维度),20% 采样率已经足够精确。
web-vitals 的准确性边界:onINP 需要用户交互才能触发——无交互的页面(如纯展示页)不会有 INP 值。不要对无 INP 的页面设置交互延迟告警。
sendBeacon 的限制:sendBeacon 发送的数据有大小限制(64KB),报文过大时浏览器会拒绝发送。如果指标批量过多,需要分批发送或使用 fetch + keepalive。
隐私合规:采集用户设备信息(userAgent、connection type)可能涉及 GDPR/PIPL 合规。建议只采集聚合统计需要的字段,不存原始 IP 地址。
五、总结
前端运行时性能监控的架构核心是"客户端采集 + 批量上报 + 时序存储 + 分位数聚合"。三个核心指标(LCP 衡量加载、INP 衡量交互、CLS 衡量稳定)覆盖了用户体验的完整维度。
实施路径:先接入 web-vitals 库做 5-10% 采样的基础采集 → 在时序数据库中存 30 天数据 → 搭建 Grafana 看板展示核心指标的 P75/P95 趋势 → 最后配置告警规则(从 LCP > 4s 的宽松阈值开始,逐步收紧)。前端的性能问题是真实的——在用户投诉之前发现它们,比接到投诉后再排查有价值得多。




