React 全栈应用的可观测性:前端错误到后端日志的全链路关联方案 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/orderbr/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-r001trace-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 withTraceT(action: string, fn: () PromiseT): PromiseT { 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 IDReact 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.ComponentProps, 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 MiddlewareNext.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?: Recordstring, 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?: Recordstring, 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% 的问题定位需求。