前端可观测性体系:从埋点乱象到 OpenTelemetry 统一的数据治理实践
你的前端有 47 种埋点 SDK、3 套监控面板、2 个告警系统——但出了 bug 你还是靠用户截图排查。可观测性不是埋点数量的竞赛,而是数据链路的工程。
一、场景痛点:埋点乱象与可观测性荒漠
我们团队的前端项目,埋点经历了三个阶段:
阶段一:手动埋点。每个页面onclick里加trackEvent('button_click'),散落在 200+ 个组件中。产品经理要改一个埋点,前端改 5 个文件。
阶段二:无痕埋点。引入自动采集 SDK,DOM 全量代理。结果:每天 2 亿条事件,其中 80% 是无意义的 hover 和 scroll。存储成本飙升,但真正有用的数据反而被淹没了。
阶段三:混乱期。3 个业务团队各自选了不同的 SDK(Sensors、GA、自研),同一事件有 3 个不同名字。告警配置在 Grafana、PagerDuty、自研平台上各有一套。出了 bug,你需要同时看 3 个面板才能拼出完整链路。
根本问题:可观测性不是"采集更多数据",而是"让数据之间有因果链路"。一个按钮点击 → 一个 API 请求 → 一个错误响应 → 一个白屏,这四个事件在当前系统中是割裂的——没有 trace_id 把它们串起来。
二、底层机制:OpenTelemetry 前端可观测性的完整链路
2.1 可观测性三支柱在前端的映射
2.2 Trace Context 传播:把前后端串起来
传统前端监控只看前端数据。但用户的白屏问题,可能是后端接口超时导致的。没有 trace_id,前端和后端是两个孤立的世界。
OpenTelemetry 的 W3C TraceContext 规范解决了这个问题:
前端发起请求时: 1. OTel SDK 生成 trace-id: 0af7651916cd43dd8448eb211c80319c 2. 生成 span-id: b7ad6b7169203331 (前端操作 Span) 3. 将 traceparent 头注入 HTTP 请求: traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 后端收到请求时: 4. 从 traceparent 提取 trace-id 5. 创建新的 span-id: c532c40904413351 (后端处理 Span) 6. 两者的 trace-id 相同 → 在 Jaeger 中关联显示 结果: 用户点击 → API 请求 → 后端处理 → DB 查询 全链路在一个 Trace 视图中2.3 前端性能指标的采集
Core Web Vitals(LCP、FID、CLS)是前端可观测性的基础指标。但生产环境中还需要补充业务指标:
| 指标类别 | 指标 | 采集方式 | 告警阈值 |
|---|---|---|---|
| 加载性能 | LCP (最大内容绘制) | PerformanceObserver | > 2.5s |
| 交互响应 | INP (交互到下一次绘制) | PerformanceObserver | > 200ms |
| 视觉稳定 | CLS (累积布局偏移) | PerformanceObserver | > 0.1 |
| API 性能 | P90 响应时间 | fetch interceptor | > 3s |
| 错误率 | JS Error Rate | Error Boundary + global handler | > 0.5% |
| 业务指标 | 关键路径完成率 | 自定义 Span | < 95% |
三、生产级代码实现
3.1 OpenTelemetry 前端 SDK 初始化与配置
/** * 前端 OpenTelemetry SDK 初始化 * * 组件: * 1. TracerProvider: 链路追踪(W3C TraceContext 传播) * 2. MeterProvider: 指标采集(Web Vitals + 业务指标) * 3. LogProcessor: 结构化日志(Error Stack + 上下文) * 4. Resource: 统一服务标识(避免多团队各自命名) */ import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { LoggerProvider } from '@opentelemetry/sdk-logs'; import { Resource } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { ZoneContextManager } from '@opentelemetry/context-zone'; import { W3CTraceContextPropagator } from '@opentelemetry/core'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'; import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request'; import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction'; // ========== 统一 Resource: 所有团队的公共标识 ========== const commonResource = new Resource({ [ATTR_SERVICE_NAME]: 'frontend-web', [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || 'unknown', 'deployment.environment': process.env.NODE_ENV || 'development', 'team.owner': 'frontend-core', // 前端特有属性 'browser.platform': navigator.platform, 'browser.user_agent': navigator.userAgent, }); // ========== TraceProvider: 链路追踪 ========== const traceExporter = new OTLPTraceExporter({ url: `${process.env.OTEL_COLLECTOR_URL}/v1/traces`, headers: { 'X-Source': 'frontend-web', }, }); const tracerProvider = new WebTracerProvider({ resource: commonResource, spanProcessors: [ // 生产环境: BatchSpanProcessor(批量发送,减少网络开销) new BatchSpanProcessor(traceExporter, { maxQueueSize: 1000, maxExportBatchSize: 100, scheduledDelayMillis: 5000, // 5秒批量发送 exportTimeoutMillis: 30000, }), ], }); // 设置 W3C TraceContext 传播器(关键!让 trace-id 跨前后端) tracerProvider.register({ contextManager: new ZoneContextManager(), propagator: new W3CTraceContextPropagator(), }); // ========== MeterProvider: 指标采集 ========== const metricExporter = new OTLPMetricExporter({ url: `${process.env.OTEL_COLLECTOR_URL}/v1/metrics`, }); const meterProvider = new MeterProvider({ resource: commonResource, readers: [ // PeriodicMetricReader: 每 30s 聚合上报 new PeriodicMetricReader({ exporter: metricExporter, exportIntervalMillis: 30000, }), ], }); const meter = meterProvider.getMeter('frontend-web'); // ========== 自定义指标 ========== // API 响应时间 const apiLatencyHistogram = meter.createHistogram('http.client.request.duration', { description: '前端 API 请求响应时间', unit: 'ms', }); // JS Error 计数 const jsErrorCounter = meter.createCounter('js.error.count', { description: 'JS 错误计数', }); // 关键路径完成率 const criticalPathCounter = meter.createCounter('business.critical_path.completed', { description: '关键业务路径完成计数', }); const criticalPathAttemptCounter = meter.createCounter('business.critical_path.attempted', { description: '关键业务路径尝试计数', }); // ========== 自动 Instrumentation ========== registerInstrumentations({ tracerProvider, meterProvider, instrumentations: [ // fetch API 自动追踪 new FetchInstrumentation({ ignoreUrls: [ /\/otel-collector/, // 忽略 OTel 自身的上报请求 /\/analytics/, // 忽略分析 SDK 的上报 ], propagateTraceHeaderUrls: [ /\/api\//, // 只对业务 API 传播 trace header ], applyCustomAttributesOnSpan: (span, request, response) => { // 给 Span 添加业务属性 const url = new URL(request.url); span.setAttribute('http.route', url.pathname); span.setAttribute('api.name', url.pathname.split('/').slice(1, 3).join('/')); // 记录指标 const latency = response.headers.get('x-response-time'); if (latency) { apiLatencyHistogram.record(parseFloat(latency), { 'http.route': url.pathname, 'http.method': request.method || 'GET', }); } }, }), // XMLHttpRequest 自动追踪 new XMLHttpRequestInstrumentation({ ignoreUrls: [/\/otel-collector/], propagateTraceHeaderUrls: [/\/api\//], }), // 页面加载性能 new DocumentLoadInstrumentation(), // 用户交互(点击、输入)自动追踪 new UserInteractionInstrumentation({ // 只追踪关键交互,避免噪声 eventNames: ['click', 'submit'], }), ], });3.2 前端 Error Boundary 与结构化日志
/** * React Error Boundary + OpenTelemetry 日志 * * 前端错误处理的三个层次: * 1. 全局 window.onerror: 捕获未处理的 JS 错误 * 2. React ErrorBoundary: 捕获组件渲染错误 * 3. 业务层 try-catch: 捕获 API 调用失败 * * 所有层次的错误都通过 OTel Logs 统一上报, * 并关联到当前活跃的 Trace */ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { context, trace, logs } from '@opentelemetry/api'; import { SeverityNumber } from '@opentelemetry/api-logs'; const tracer = trace.getTracer('frontend-web', '1.0.0'); const logger = logs.getLogger('frontend-web', '1.0.0'); interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; componentName?: string; // 组件名,用于错误定位 } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } class ObservabilityErrorBoundary 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 { // 关联到当前活跃的 Trace(如果有的话) const activeSpan = tracer.startSpan( `react.error.${this.props.componentName || 'unknown'}`, { attributes: { 'error.type': error.name, 'error.message': error.message, 'react.component_stack': errorInfo.componentStack, 'react.component_name': this.props.componentName || 'unknown', }, } ); activeSpan.setStatus({ code: 2, message: error.message }); // ERROR status activeSpan.recordException(error); activeSpan.end(); // 上报结构化日志 logger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: `React ErrorBoundary: ${error.message}`, attributes: { 'error.type': error.name, 'error.stack': error.stack, 'react.component_stack': errorInfo.componentStack, 'react.component_name': this.props.componentName || 'unknown', // 关联 trace_id,让日志和 trace 串起来 'trace_id': activeSpan.spanContext().traceId, 'span_id': activeSpan.spanContext().spanId, }, }); // 计数指标 jsErrorCounter.add(1, { 'error.type': error.name, 'source': 'error_boundary', 'component': this.props.componentName || 'unknown', }); } render(): ReactNode { if (this.state.hasError) { return this.props.fallback || ( <div style={{ padding: '20px', textAlign: 'center' }}> <h2>页面加载异常</h2> <p>请刷新页面重试,如持续异常请联系技术支持</p> <button onClick={() => window.location.reload()}> 刷新页面 </button> </div> ); } return this.props.children; } } // ========== 全局错误处理器 ========== function setupGlobalErrorHandler(): void { // window.onerror: 未捕获的 JS 错误 window.addEventListener('error', (event: ErrorEvent) => { const span = tracer.startSpan('js.error.global', { attributes: { 'error.type': 'UncaughtError', 'error.message': event.message, 'error.filename': event.filename, 'error.lineno': event.lineno, 'error.colno': event.colno, }, }); span.setStatus({ code: 2, message: event.message }); span.end(); logger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: `Uncaught JS Error: ${event.message}`, attributes: { 'error.filename': event.filename, 'error.lineno': event.lineno, 'error.colno': event.colno, 'trace_id': span.spanContext().traceId, }, }); jsErrorCounter.add(1, { 'error.type': 'UncaughtError', 'source': 'global_handler', }); // 阻止默认控制台输出(避免日志和 OTel 重复) // event.preventDefault(); // 生产环境可开启 }); // window.onunhandledrejection: Promise 未处理的 rejection window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { const reason = event.reason; const error = reason instanceof Error ? reason : new Error(String(reason)); const span = tracer.startSpan('js.error.unhandled_rejection', { attributes: { 'error.type': 'UnhandledRejection', 'error.message': error.message, }, }); span.setStatus({ code: 2, message: error.message }); span.end(); logger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: `Unhandled Promise Rejection: ${error.message}`, attributes: { 'error.stack': error.stack || 'no stack available', 'trace_id': span.spanContext().traceId, }, }); jsErrorCounter.add(1, { 'error.type': 'UnhandledRejection', 'source': 'global_handler', }); }); }3.3 业务关键路径追踪
/** * 业务关键路径追踪 * * 将用户操作从"点击按钮"到"看到结果"的完整链路 * 用一个 Trace 串联起来 * * 示例: "提交订单" 关键路径 * Span 1: user.click.submit_order (前端) * Span 2: http.client.request /api/orders (前端 → API) * Span 3: http.server.request /api/orders (后端) * Span 4: db.query insert_orders (后端 → DB) * Span 5: user.render.order_success (前端渲染) */ async function trackCriticalPath<T>( pathName: string, fn: () => Promise<T>, attributes?: Record<string, string> ): Promise<T> { // 创建关键路径的根 Span const span = tracer.startSpan(`business.critical_path.${pathName}`, { attributes: { 'business.path_name': pathName, ...attributes, }, }); // 记录尝试次数 criticalPathAttemptCounter.add(1, { 'path_name': pathName }); try { // 在 Span 上下文中执行业务逻辑 const result = await context.with( trace.setSpan(context.active(), span), fn ); // 成功 span.setStatus({ code: 1 }); // OK criticalPathCounter.add(1, { 'path_name': pathName }); return result; } catch (error) { const e = error instanceof Error ? error : new Error(String(error)); span.setStatus({ code: 2, message: e.message }); // ERROR span.recordException(e); span.setAttribute('error.type', e.name); // 日志 logger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: `Critical path failed: ${pathName} - ${e.message}`, attributes: { 'business.path_name': pathName, 'error.type': e.name, 'trace_id': span.spanContext().traceId, }, }); throw error; } finally { span.end(); } } // ========== 使用示例: 提交订单关键路径 ========== async function submitOrder(orderData: OrderData): Promise<OrderResult> { return trackCriticalPath('submit_order', async () => { // Step 1: 校验(子 Span) const validationSpan = tracer.startSpan('order.validate', { attributes: { 'order.items_count': String(orderData.items.length) }, }); const validationResult = await validateOrder(orderData); validationSpan.end(); if (!validationResult.valid) { throw new BusinessError('订单校验失败', validationResult.errors); } // Step 2: API 调用(自动被 FetchInstrumentation 创建子 Span) const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderData), }); if (!response.ok) { throw new ApiError(`订单提交失败: ${response.status}`, response.status); } const result = await response.json(); // Step 3: 渲染成功页面(子 Span) const renderSpan = tracer.startSpan('order.render_success'); await renderOrderSuccess(result); renderSpan.end(); return result; }, { 'order.total_amount': String(orderData.totalAmount), 'order.payment_method': orderData.paymentMethod, }); }3.4 OTel Collector 配置:前后端关联
# ============================================================ # OTel Collector 配置: 前端数据接收 + 前后端 Trace 关联 # ============================================================ receivers: # 前端 OTLP HTTP 接收 otlp/frontend: protocols: http: endpoint: 0.0.0.0:4318 # CORS 配置: 允许浏览器直接上报 cors: allowed_origins: - "https://app.example.com" - "https://staging.example.com" allowed_headers: - "Authorization" - "X-Source" # 后端 OTLP gRPC 接收 otlp/backend: protocols: grpc: endpoint: 0.0.0.0:4317 processors: # 前后端 Trace 关联处理器 trace/from_frontend: # 自动关联相同 trace_id 的前后端 Span # 无需额外配置,W3C TraceContext 已保证 trace_id 传播 # 日志脱敏: 移除 PII log/sanitize: # 移除用户邮箱、手机号等敏感信息 attributes: - key: "user.email" action: "delete" - key: "user.phone" action: "delete" - key: "user.id" action: "hash" # hash 处理而非删除,保留统计能力 # 指标聚合: 前端指标预聚合减少存储 metric/frontend_aggregate: # P90 API 延迟 → 减少原始 Histogram 数据量 transforms: - include: "http.client.request.duration" action: "aggregate" aggregation_temporality: "CUMULATIVE" # 批量处理 batch: timeout: 10s send_batch_size: 1024 send_batch_max_size: 2048 exporters: # Jaeger: Trace 存储 otlp/jaeger: endpoint: jaeger:4317 tls: insecure: true # Prometheus: 指标存储 prometheus: endpoint: "0.0.0.0:8889" namespace: "frontend" # Loki: 日志存储 otlphttp/loki: endpoint: "http://loki:3100/loki/api/v1/push" default_labels_enabled: exporter: false service: pipelines: traces: receivers: [otlp/frontend, otlp/backend] processors: [batch] exporters: [otlp/jaeger] metrics: receivers: [otlp/frontend, otlp/backend] processors: [metric/frontend_aggregate, batch] exporters: [prometheus] logs: receivers: [otlp/frontend, otlp/backend] processors: [log/sanitize, batch] exporters: [otlphttp/loki]四、边界分析:前端可观测性的四个权衡
4.1 采集量 vs 存储成本的平衡
全量采集所有用户交互,每天 2 亿条 Span,存储成本约 $500/月。但如果只采集 5% 的会话,关键路径的覆盖率可能不够。
我们的做法:
- 全量采集关键路径(提交订单、支付等)→ 100% 覆盖,约 50 万条/天
- 采样采集普通交互(页面浏览、搜索等)→ 5% 采样,约 10 万条/天
- 错误会话全量保留 → 错误前后 30 秒的所有 Span 都保留
- 总存储成本降至 $50/月,关键路径覆盖率 100%
4.2 上报延迟 vs 页面性能
OTel SDK 的上报请求和业务请求共享网络资源。如果上报过于频繁(每秒一次),会影响 API 请求的响应速度。
解决方案:
BatchSpanProcessor:5 秒批量上报一次,而非每个 Span 立即上报Beacon API:页面卸载时用navigator.sendBeacon()发送剩余数据,不阻塞页面关闭- 上报请求忽略自身追踪(
ignoreUrls: [/\/otel-collector/]),避免递归
4.3 ErrorBoundary vs 全局错误处理
React ErrorBoundary 只能捕获组件渲染阶段的错误,事件处理函数中的错误不会被 ErrorBoundary 捕获。两者必须互补:
| 错误来源 | 捕获方式 | 占比(实测) |
|---|---|---|
| 组件渲染错误 | ErrorBoundary | ~15% |
| 事件处理错误 | 全局 onerror | ~35% |
| API 调用错误 | 业务层 catch | ~40% |
| Promise rejection | onunhandledrejection | ~10% |
4.4 trace_id 在 SSR 中的传播
Next.js SSR 场景中,服务端渲染的 HTML 已经带有一个 trace_id。前端 hydration 后的新 Span 需要继承这个 trace_id,而不是新建一个。
// SSR: 从服务端注入的 trace_id 继承 function getSSRTraceContext(): { traceId: string; spanId: string } | null { // Next.js __NEXT_DATA__ 中注入的 trace context const nextData = (window as any).__NEXT_DATA__; if (nextData?.traceContext) { return nextData.traceContext; } return null; } // 初始化时恢复 SSR trace context const ssrContext = getSSRTraceContext(); if (ssrContext) { // 将 SSR trace context 作为前端根 Span 的 parent const rootSpan = tracer.startSpan('frontend.hydrate', { attributes: { 'ssr.trace_id': ssrContext.traceId }, }); // ... 后续操作都在这个 Span 的上下文中 }五、总结
前端可观测性的本质不是"多埋几个点",而是"让数据之间有因果链路"。从散落的 47 种埋点 SDK 到统一的 OpenTelemetry,核心收益不是数据量的增加,而是数据之间可关联、可追溯、可解释。
三条核心原则:
- 统一 Resource 是治理的起点:所有团队必须使用相同的 service.name 和属性命名规范。
button_click和btn_click是两个不同的事件——这对分析来说是灾难。先定规范,再采集。 - Trace Context 传播是前后端关联的关键:没有 trace_id,前端白屏和后端超时是两个孤立的事件。有了 trace_id,它们是一条链路上的因果关系。W3C TraceContext 规范 + FetchInstrumentation 自动注入,一步到位。
- 采样策略比全量采集更有效:关键路径全量、普通交互采样、错误会话全保留。存储成本降 90%,关键覆盖率不降。可观测性的 ROI 是覆盖率/成本,不是数据量/成本。
一句话总结:你的前端 bug 不需要更多数据来排查,它需要的是把散落的数据串成一条链路。OpenTelemetry 做的就是这件事。