React 全栈应用的可观测性:前端错误到后端日志的全链路关联方案
2026/7/7 9:24:06 网站建设 项目流程

React 全栈应用的可观测性:前端错误到后端日志的全链路关联方案

一、生产环境的问题定位困境:前端报错 500,后端日志正常

用户反馈:"点击提交按钮后页面报错了。"你打开 Sentry,看到一条AxiosError: Request failed with status code 500。你打开后端日志,发现 17:32:15 时段有几百条请求记录,但你不知道哪一条是那个用户触发的。

前端和后端的日志是割裂的。前端有 Sentry,后端有 ELK/Grafana,但它们之间没有关联。你只能在两个系统中分别搜索——前端找时间点,后端找相近时间的请求日志,然后猜测哪条是相关的。

这个问题的根源是缺少统一的 Trace ID。在前端发起请求时生成一个唯一 ID,通过 HTTP Header 传递给后端,后端在日志中记录该 ID。这样一个错误就可以从前端到后端全部串联起来。

sequenceDiagram participant FE as React 前端 participant Sentry as Sentry participant API as Next.js API participant BE as 后端服务 participant Grafana as Grafana Loki FE->>FE: 生成 TraceID: abc-123 FE->>API: POST /api/order<br/>X-Trace-ID: abc-123 Note over FE: 同时上报到 Sentry API->>BE: 转发请求 + X-Trace-ID BE->>BE: 处理订单逻辑 BE-->>API: 500 Internal Error API-->>FE: 500 FE->>Sentry: 错误: abc-123 API->>Grafana: 日志: abc-123 ERROR BE->>Grafana: 日志: abc-123 数据库超时 Note over FE,Sentry: 前端: 通过 abc-123 查看完整链路

本文将构建一个 React 全栈应用的全链路可观测性方案,覆盖 Trace ID 生成、前后端关联和错误聚合。

二、Trace ID 的设计原则:UUID 的局限性

最常见的做法是用crypto.randomUUID()生成 Trace ID。这解决了唯一性问题,但忽略了实用性——纯随机的 UUID 在日志中难以搜索和记忆。

更好的设计是分层的 Trace ID:

{trace-id}-{span-id}-{sequence} 例如: t3f8a2-s001-r001
  • trace-id:全局唯一标识这一次用户操作(如页面加载、按钮点击)
  • span-id:标识一次请求或子操作
  • sequence:同一 Span 内的顺序号

但为了简单,对于大多数应用,一个 UUID v4 作为 Trace ID + 序号作为 Span ID 已经足够。关键在于:每个用户操作(点击、页面加载)生成一个新的 Trace ID,这个 Trace ID 随着请求一路传递到后端

三、完整的全链路关联实现

前端 Trace ID 生成与注入:

// lib/tracer.ts let currentTraceId: string | null = null; // 生成追踪 ID export function generateTraceId(): string { return crypto.randomUUID(); } // 为关键用户操作包装 Trace export function withTrace<T>(action: string, fn: () => Promise<T>): Promise<T> { currentTraceId = generateTraceId(); console.info(`[Trace:${currentTraceId}] ${action} started`); try { const result = await fn(); console.info(`[Trace:${currentTraceId}] ${action} completed`); return result; } catch (error) { console.error(`[Trace:${currentTraceId}] ${action} failed`, error); throw error; } } // 获取当前 Trace ID(React Hook) export function useTraceId(): string { return currentTraceId || 'unknown'; }

HTTP 客户端拦截器(Axios):

// lib/api-client.ts import axios from 'axios'; const apiClient = axios.create({ baseURL: '/api', timeout: 10000, }); // 请求拦截器:注入 Trace ID apiClient.interceptors.request.use((config) => { const traceId = getCurrentTraceId(); config.headers['X-Trace-ID'] = traceId; config.headers['X-Span-ID'] = `${traceId}-${Date.now()}`; return config; }); // 响应拦截器:上报错误 apiClient.interceptors.response.use( (response) => response, (error) => { const traceId = error.config?.headers?.['X-Trace-ID'] || 'unknown'; // 发送到前端错误监控 captureError(error, { traceId, url: error.config?.url, method: error.config?.method, status: error.response?.status, }); return Promise.reject(error); } );

React 全局错误边界(Error Boundary):

// components/ErrorBoundary.tsx import React from 'react'; import { generateTraceId } from '@/lib/tracer'; interface Props { children: React.ReactNode; fallback?: React.ReactNode; } interface State { hasError: boolean; error: Error | null; } export class ErrorBoundary extends React.Component<Props, State> { state: State = { hasError: false, error: null }; private errorTraceId: string = ''; static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { this.errorTraceId = generateTraceId(); // 上报到错误监控 captureError(error, { traceId: this.errorTraceId, componentStack: errorInfo.componentStack, }); } render() { if (this.state.hasError) { return this.props.fallback || ( <div style={{ padding: 20 }}> <h2>出错了</h2> <p>追踪 ID: <code>{this.errorTraceId}</code></p> <button onClick={() => this.setState({ hasError: false })}> 重试 </button> </div> ); } return this.props.children; } }

后端 Trace Middleware(Next.js API Routes):

// middleware/trace.ts import { NextRequest, NextResponse } from 'next/server'; export function traceMiddleware(req: NextRequest) { const traceId = req.headers.get('x-trace-id') || crypto.randomUUID(); const spanId = req.headers.get('x-span-id') || `${traceId}-root`; const response = NextResponse.next(); // 将 Trace ID 注入到响应头 response.headers.set('X-Trace-ID', traceId); // 存储到 AsyncLocalStorage 供后续使用 traceStore.enterWith({ traceId, spanId }); return response; } // AsyncLocalStorage 用于在请求上下文中传递 Trace ID import { AsyncLocalStorage } from 'async_hooks'; export const traceStore = new AsyncLocalStorage<{ traceId: string; spanId: string; }>(); export function getTraceContext() { const store = traceStore.getStore(); return { traceId: store?.traceId || 'unknown', spanId: store?.spanId || 'unknown', }; }
// lib/logger.ts (后端结构化日志) import { getTraceContext } from '@/middleware/trace'; export const logger = { info(message: string, data?: Record<string, any>) { const { traceId, spanId } = getTraceContext(); console.log(JSON.stringify({ level: 'info', traceId, spanId, message, timestamp: new Date().toISOString(), ...data, })); }, error(message: string, error?: Error, data?: Record<string, any>) { const { traceId, spanId } = getTraceContext(); console.error(JSON.stringify({ level: 'error', traceId, spanId, message, error: error?.message, stack: error?.stack, timestamp: new Date().toISOString(), ...data, })); }, };

四、关联方案的工程代价

隐私合规:Trace ID 本身不包含用户数据,但在日志中关联后可以反推用户行为。需确保日志存储符合数据保护法规(如 GDPR),日志中不记录敏感字段。

性能开销:每个请求增加一个 UUID 生成(~0.01ms)和 AsyncLocalStorage 操作(~0.02ms)。对 99.9% 的应用这不是问题。

不适用场景

  • 前端是纯静态站点(无后端):Tracing 的价值会打折扣
  • 已经使用 OpenTelemetry 等全栈方案:本文的方案是轻量替代,不如 OpenTelemetry 全面
  • 日活 < 100 的 B 端应用:日志量极低,按时间搜索即可

五、总结

全链路可观测性的核心是统一的 Trace ID。在前端生成,通过 HTTP Header 传递到后端,在两端日志中同时记录。当一个错误发生时,从前端 Sentry 到后端 Grafana,你只需要搜索一个 Trace ID。

落地路径:先在前端 Axios 拦截器中注入 Trace ID,后端日志中记录;然后在 Error Boundary 中展示 Trace ID 给用户,方便用户反馈时提供;最后配置 Grafana Loki 按 Trace ID 搜索,实现一键跳转。

少即是多。不需要 OpenTelemetry 的完整部署,一个 UUID + HTTP Header 的方案已经能解决 80% 的问题定位需求。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询