前端可观测性体系:从埋点乱象到 OpenTelemetry 统一的数据治理实践
前端可观测性体系从埋点乱象到 OpenTelemetry 统一的数据治理实践你的前端有 47 种埋点 SDK、3 套监控面板、2 个告警系统——但出了 bug 你还是靠用户截图排查。可观测性不是埋点数量的竞赛而是数据链路的工程。一、场景痛点埋点乱象与可观测性荒漠我们团队的前端项目埋点经历了三个阶段阶段一手动埋点。每个页面onclick里加trackEvent(button_click)散落在 200 个组件中。产品经理要改一个埋点前端改 5 个文件。阶段二无痕埋点。引入自动采集 SDKDOM 全量代理。结果每天 2 亿条事件其中 80% 是无意义的 hover 和 scroll。存储成本飙升但真正有用的数据反而被淹没了。阶段三混乱期。3 个业务团队各自选了不同的 SDKSensors、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 VitalsLCP、FID、CLS是前端可观测性的基础指标。但生产环境中还需要补充业务指标指标类别指标采集方式告警阈值加载性能LCP (最大内容绘制)PerformanceObserver 2.5s交互响应INP (交互到下一次绘制)PerformanceObserver 200ms视觉稳定CLS (累积布局偏移)PerformanceObserver 0.1API 性能P90 响应时间fetch interceptor 3s错误率JS Error RateError 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 ComponentErrorBoundaryProps, 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 trackCriticalPathT( pathName: string, fn: () PromiseT, attributes?: Recordstring, string ): PromiseT { // 创建关键路径的根 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): PromiseOrderResult { 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 请求的响应速度。解决方案BatchSpanProcessor5 秒批量上报一次而非每个 Span 立即上报Beacon API页面卸载时用navigator.sendBeacon()发送剩余数据不阻塞页面关闭上报请求忽略自身追踪ignoreUrls: [/\/otel-collector/]避免递归4.3 ErrorBoundary vs 全局错误处理React ErrorBoundary 只能捕获组件渲染阶段的错误事件处理函数中的错误不会被 ErrorBoundary 捕获。两者必须互补错误来源捕获方式占比实测组件渲染错误ErrorBoundary~15%事件处理错误全局 onerror~35%API 调用错误业务层 catch~40%Promise rejectiononunhandledrejection~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 做的就是这件事。