一个用户反馈操作缓慢,但日志里没有任何错误。排查开始。后端团队在 Jaeger 中检查了相关 API 的链路,发现服务内部耗时正常,P99 响应时间在 80ms。问题似乎不在后端。前端团队检查了浏览器性能录制,发现在点击按钮后,网络请求直到 1.5 秒后才发出。这中间的空白地带,正是可观测性的深渊。我们有后端的 trace,有前端的 performance profile,但它们是割裂的。我们无法回答一个最基本的问题:从用户点击到数据渲染的完整生命周期中,时间到底消耗在了哪里?
这个场景在真实项目中极其普遍。问题的根源在于前端与后端监控体系的割裂。要构建一个完整的用户体验视图,必须将前端操作与后端服务调用串联在同一条 trace 中。本文将复盘一次完整的技术实践:从零开始,在一个基于 React/Recoil 的前端应用和一个 Node.js 后端服务之间,建立起端到端的分布式追踪。我们将利用 OpenTelemetry 作为标准,通过 JWT 承载用户身份信息来丰富 trace,并最终在 Jaeger 中实现对用户单次操作的全链路可视化。
初步构想与技术选型决策
我们的目标是:当用户在前端触发一个操作时,生成一个全局唯一的 traceId。该 traceId 及其上下文将伴随 API 请求传递到后端。后端服务在接收到请求后,会识别这个 trace 上下文,并将自己产生的 spans 作为子节点挂载到同一个 trace 下。
实现这个目标需要解决几个核心问题:
追踪标准: 如何在异构的前端和后端环境中生成和传播符合规范的追踪数据?
- 决策: OpenTelemetry (OTel)。它提供了统一的 API 和 SDK,覆盖了前端 JavaScript 和后端 Node.js,是目前社区的事实标准。
前端上下文管理: 在 React 单页应用中,如何生成、存储并自动注入 trace 上下文到发出的 HTTP 请求中?
- 初步想法: 使用 React Context。
- 最终决策: 我们项目已在使用 Recoil 进行状态管理。利用 Recoil 来管理认证状态(如 JWT)是其常规用法。虽然 OTel 的
ContextManager能处理大部分 trace 上下文的自动传递,但认证状态与追踪数据的结合点需要我们精细控制。Recoil 将主要负责管理 JWT token,而 OTel Web SDK 将处理 trace 上下文的自动注入。
上下文跨边界传播: 前端生成的 trace 上下文如何安全、可靠地传递给后端?
方案A: 自定义 HTTP Header,如
X-Trace-Context。这是最直接的方式,但可能被某些网关或代理 stripping。方案B: 利用现有 Header。W3C Trace Context 规范定义了标准的
traceparent和tracestateHTTP headers。这是 OTel 默认的传播方式,也是最佳实践。方案C: 嵌入 JWT Payload。这个想法很有诱惑力,即将
traceId和spanId直接编码进 JWT。但很快被否决,因为 JWT 通常具有较长的生命周期,而 trace 和 span 是瞬态的。为每个请求重新签发 JWT 是不可接受的。最终决策: 采用方案 B,使用标准的
traceparentHeader。那么 JWT 的角色是什么?它不再用于传递 trace 上下文,而是用于传递稳定的用户身份信息。在后端,我们可以从 JWT 中解析出userId或tenantId,并将这些信息作为 tag 附加到后端的 spans 上。这极大地增强了 trace 的业务可读性,使我们能快速筛选出特定用户的所有操作链路。
后端实现: Node.js 服务如何接收 trace 上下文,并与自身的 trace 连接?
- 决策: 使用 OpenTelemetry SDK for Node.js。通过其提供的
instrumentations,可以自动为 Express、HTTP 等常用模块创建 spans,并自动处理传入的traceparentheader。
- 决策: 使用 OpenTelemetry SDK for Node.js。通过其提供的
可视化与存储: 收集到的 trace 数据发送到哪里?
- 决策: Jaeger。它部署简单,UI 直观,非常适合作为起步的追踪后端。我们将通过 Docker Compose 在本地快速启动一个 Jaeger 实例。
整个架构的数据流如下:
sequenceDiagram
participant User
participant ReactApp as React App (Recoil)
participant OTelWebSDK as OTel Web SDK
participant NodeAPI as Node.js API
participant OTelNodeSDK as OTel Node.js SDK
participant Jaeger
User->>ReactApp: 点击 "获取数据" 按钮
ReactApp->>OTelWebSDK: 创建 Root Span (e.g., "fetch-user-data")
OTelWebSDK->>ReactApp: 生成 traceparent header
ReactApp->>NodeAPI: 发起 API 请求 /api/data (携带 traceparent 和 JWT)
NodeAPI->>OTelNodeSDK: 接收请求, 自动解析 traceparent header
OTelNodeSDK->>NodeAPI: 创建 Server Span, 继承 traceId
NodeAPI->>NodeAPI: 中间件解析 JWT, 获取 userId
NodeAPI->>OTelNodeSDK: 将 userId 作为 tag 添加到当前 Span
NodeAPI->>NodeAPI: 执行业务逻辑 (e.g., 查询数据库)
OTelNodeSDK->>NodeAPI: 自动为下游调用创建 Child Span
NodeAPI-->>ReactApp: 返回 API 响应
ReactApp->>ReactApp: 渲染数据
ReactApp->>OTelWebSDK: 结束 Root Span
OTelWebSDK-->>Jaeger: 异步导出前端 Trace 数据
OTelNodeSDK-->>Jaeger: 异步导出后端 Trace 数据
Jaeger->>Jaeger: 整合数据, 形成完整链路
步骤化实现:代码是核心
1. 启动 Jaeger 实例
在项目根目录下创建一个 docker-compose.yml 文件。这是最快启动一个 All-in-One Jaeger 实例的方式,适用于开发和测试。
# docker-compose.yml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:1.48
container_name: jaeger
ports:
- "6831:6831/udp" # Agent (Thrift UDP)
- "16686:16686" # Jaeger UI
- "14268:14268" # Collector (HTTP)
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP HTTP receiver
environment:
- COLLECTOR_OTLP_ENABLED=true
运行 docker-compose up -d 启动。现在访问 http://localhost:16686 应该能看到 Jaeger UI。
2. 后端 Node.js 服务配置
我们的后端是一个简单的 Express 应用,提供登录和数据查询两个接口。
项目结构:
/backend
- package.json
- server.js
- tracer.js # OTel 配置核心
- auth.js # JWT 相关逻辑
安装依赖:
npm install express jsonwebtoken cors
npm install @opentelemetry/sdk-node @opentelemetry/api \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
tracer.js: OpenTelemetry 初始化
这是整个后端追踪的核心配置文件。在真实项目中,这里的配置会更复杂,例如包含动态采样、多种 exporter 等。
// backend/tracer.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const {
getNodeAutoInstrumentations,
} = require('@opentelemetry/auto-instrumentations-node');
const {
Resource,
} = require('@opentelemetry/resources');
const {
SemanticResourceAttributes,
} = require('@opentelemetry/semantic-conventions');
const { AlwaysOnSampler } = require('@opentelemetry/core');
// 关键点1: 定义服务资源信息
// 这是区分不同服务的标识,在 Jaeger UI 中会显示为 Service Name
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'my-backend-service',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});
// 关键点2: 配置 Exporter
// 指定将 trace 数据发送到哪里。这里我们使用 OTLP HTTP 协议
// 发送到本地 Jaeger 的 4318 端口。
const traceExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
});
// 关键点3: 配置 Sampler
// 在生产环境中,全量采样成本过高。
// 这里为了演示使用 AlwaysOnSampler, 生产环境推荐 TraceIdRatioBasedSampler
const sampler = new AlwaysOnSampler();
const sdk = new NodeSDK({
resource,
sampler,
traceExporter,
// 关键点4: 自动仪表
// 这是 OTel 的强大之处,它会自动为流行的库 (express, http, pg, redis等) 创建 spans。
instrumentations: [getNodeAutoInstrumentations({
// 禁用我们不需要的 instrumentation 来减少开销
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
})],
});
// 优雅地关闭 SDK
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error))
.finally(() => process.exit(0));
});
// 启动 SDK
try {
sdk.start();
console.log('Tracing initialized');
} catch (error) {
console.log('Error initializing tracing', error);
}
module.exports = sdk;
auth.js: JWT 逻辑与 Trace 增强
这里是 JWT 和 Trace 结合的关键。我们定义一个中间件,它不仅验证 JWT,还会从中提取用户信息并附加到当前的 Span。
// backend/auth.js
const jwt = require('jsonwebtoken');
const { trace, context } = require('@opentelemetry/api');
const JWT_SECRET = 'my-super-secret-key-for-demo';
const tracer = trace.getTracer('my-backend-service-auth');
function generateToken(userId) {
return jwt.sign({ userId, role: 'user' }, JWT_SECRET, { expiresIn: '1h' });
}
// 核心:JWT 验证并丰富 Trace 的中间件
function authenticateAndEnrichTrace(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
// 关键点: 将用户信息附加到当前激活的 Span
// OTel 的 auto-instrumentation 已经为我们创建了一个激活的 span
const currentSpan = trace.getSpan(context.active());
if (currentSpan) {
currentSpan.setAttribute('enduser.id', decoded.userId);
currentSpan.setAttribute('enduser.role', decoded.role);
}
next();
} catch (error) {
// 如果 token 无效,我们也记录一个 span event
const currentSpan = trace.getSpan(context.active());
if (currentSpan) {
currentSpan.addEvent('jwt-verification-failed', {
'error.message': error.message,
});
}
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
}
module.exports = {
generateToken,
authenticateAndEnrichTrace,
};
server.js: Express 应用
注意 require('./tracer'); 必须放在所有其他模块引用的最前面,以确保所有模块在被 require 时已经被 OTel patch。
// backend/server.js
// 必须在第一行引入,以便 OTel 能够正确地 instrument 其他模块
require('./tracer');
const express = require('express');
const cors = require('cors');
const { trace } = require('@opentelemetry/api');
const { generateToken, authenticateAndEnrichTrace } = require('./auth');
const app = express();
const port = 4000;
app.use(express.json());
app.use(cors()); // 允许跨域
const tracer = trace.getTracer('my-backend-service-server');
// 登录接口:不要求认证,返回 JWT
app.post('/api/login', (req, res) => {
const { username } = req.body;
// 在真实项目中,这里会有数据库验证
if (username) {
const userId = `user_${username.toLowerCase()}`;
const token = generateToken(userId);
res.json({ token });
} else {
res.status(400).json({ error: 'Username is required' });
}
});
// 受保护的数据接口
app.get('/api/data', authenticateAndEnrichTrace, (req, res) => {
// 这里的 span 会自动作为 Express 中间件 span 的子 span
tracer.startActiveSpan('fetch-data-from-db', (span) => {
// 模拟数据库查询耗时
setTimeout(() => {
span.setAttribute('db.system', 'postgresql');
span.setAttribute('db.statement', 'SELECT * FROM users WHERE id = ?');
span.addEvent('db-query-start');
const data = {
message: `Hello, ${req.user.userId}! This is protected data.`,
timestamp: new Date().toISOString(),
};
span.addEvent('db-query-end');
span.end();
res.json(data);
}, 200); // 模拟 200ms 的数据库延迟
});
});
app.listen(port, () => {
console.log(`Backend server listening at http://localhost:${port}`);
});
启动后端服务: node server.js。
3. 前端 React 应用配置 (Recoil + OTel)
项目结构:
/frontend
- package.json
- src/
- App.js
- state.js # Recoil atoms
- tracing.js # OTel Web 配置核心
- api.js # 封装的 fetch 调用
安装依赖:
npx create-react-app frontend
cd frontend
npm install recoil axios
npm install @opentelemetry/sdk-trace-web @opentelemetry/api \
@opentelemetry/context-zone \
@opentelemetry/instrumentation-fetch \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
src/tracing.js: OTel Web SDK 初始化
前端的 OTel 配置与后端类似,但需要特别注意 contextManager 的选择。ZoneContextManager 能更好地在异步回调中传递上下文,是 SPA 应用的推荐选项。
// frontend/src/tracing.js
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'my-frontend-service',
});
const exporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
});
const provider = new WebTracerProvider({
resource: resource,
});
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
// ZoneContextManager 对于处理 Web 应用中的异步操作非常重要
provider.register({
contextManager: new ZoneContextManager(),
});
// 自动 instrument fetch API,并启用 W3C Trace Context headers
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
// 关键: 告诉 OTel 在 fetch 请求中自动注入 traceparent header
propagateTraceHeaderCorsUrls: [
/http:\/\/localhost:4000\/.*/,
],
}),
],
});
在 src/index.js 的最顶端引入它以确保尽早初始化: import './tracing';
src/state.js: Recoil Atom 管理 JWT
// frontend/src/state.js
import { atom } from 'recoil';
export const jwtTokenState = atom({
key: 'jwtTokenState',
// 从 localStorage 初始化,实现持久化登录
default: localStorage.getItem('jwt_token') || null,
});
src/api.js: 封装的 API 调用
我们创建一个 API client,它从 Recoil state 中读取 JWT 并添加到请求头。
// frontend/src/api.js
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost:4000',
});
// 使用拦截器动态添加 Authorization header
export const setupApiInterceptor = (token) => {
apiClient.interceptors.request.use(
(config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
};
export default apiClient;
src/App.js: 业务组件
这是将所有部分串联起来的地方。组件使用 Recoil state,调用 API,并使用 OTel API 手动创建顶层 Span 来包裹整个用户操作。
// frontend/src/App.js
import React, { useState, useEffect } from 'react';
import {
RecoilRoot,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { trace } from '@opentelemetry/api';
import { jwtTokenState } from './state';
import apiClient, { setupApiInterceptor } from './api';
// 获取一个 tracer 实例
const tracer = trace.getTracer('my-frontend-service-app');
function AuthComponent() {
const [username, setUsername] = useState('alice');
const setJwtToken = useSetRecoilState(jwtTokenState);
const handleLogin = async () => {
try {
const response = await apiClient.post('/api/login', { username });
const token = response.data.token;
localStorage.setItem('jwt_token', token);
setJwtToken(token);
} catch (error) {
console.error('Login failed:', error);
alert('Login failed');
}
};
return (
<div>
<h2>Login</h2>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<button onClick={handleLogin}>Login</button>
</div>
);
}
function DataComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const setJwtToken = useSetRecoilState(jwtTokenState);
const handleFetchData = () => {
setData(null);
setError(null);
// 关键: 创建一个包裹整个操作的 Root Span
tracer.startActiveSpan('ui.operation.fetch-data', async (span) => {
try {
span.setAttribute('component', 'DataComponent');
// FetchInstrumentation 会自动创建子 span 并注入 headers
const response = await apiClient.get('/api/data');
setData(JSON.stringify(response.data, null, 2));
span.setStatus({ code: 1 }); // 1 = OK
span.addEvent('data-fetch-successful');
} catch (err) {
setError(err.response ? err.response.data.error : err.message);
span.setStatus({ code: 2, message: err.message }); // 2 = ERROR
span.recordException(err);
} finally {
// 确保 span 被关闭
span.end();
}
});
};
const handleLogout = () => {
localStorage.removeItem('jwt_token');
setJwtToken(null);
};
return (
<div>
<h2>Protected Data</h2>
<button onClick={handleFetchData}>Fetch Protected Data</button>
<button onClick={handleLogout}>Logout</button>
{data && <pre>{data}</pre>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
</div>
);
}
function MainApp() {
const jwtToken = useRecoilValue(jwtTokenState);
// 当 token 变化时,更新 axios 拦截器
useEffect(() => {
setupApiInterceptor(jwtToken);
}, [jwtToken]);
return (
<div style={{ padding: '20px' }}>
<h1>Full-Stack Tracing Demo</h1>
{jwtToken ? <DataComponent /> : <AuthComponent />}
</div>
);
}
function App() {
return (
<RecoilRoot>
<MainApp />
</RecoilRoot>
);
}
export default App;
现在启动前端应用: npm start。
最终成果:在 Jaeger 中检视全链路
操作流程:
- 打开前端应用
http://localhost:3000。 - 输入用户名,点击 “Login”。
- 点击 “Fetch Protected Data”。
- 打开 Jaeger UI
http://localhost:16686。 - 在 Service 下拉列表中选择
my-frontend-service,点击 “Find Traces”。
你会看到一条名为 ui.operation.fetch-data 的 trace。点开它,一个完整的、跨越前后端的调用链瀑布图将展现在眼前:
- 顶层 Span (Root):
ui.operation.fetch-data,来自my-frontend-service。这是我们在handleFetchData中手动创建的,它代表了整个用户操作的生命周期。 - 子 Span (Frontend):
HTTP GET,同样来自my-frontend-service。这是 OTelFetchInstrumentation自动创建的,它精确测量了从请求开始到收到响应的完整网络耗时。 - 子 Span (Backend):
GET /api/data,来自my-backend-service。这是 OTel Node.js SDK 的ExpressInstrumentation自动创建的,它继承了前端传来的traceId,无缝连接了链路。 - 孙子 Span (Backend):
fetch-data-from-db,来自my-backend-service。这是我们在后端业务代码中手动创建的,用于度量数据库查询的耗时。
点击后端的 span (GET /api/data),在 Tags 标签页中,你会看到我们通过 JWT 中间件添加的属性:enduser.id: user_alice。这证明了我们的身份信息增强策略是成功的。现在,我们不仅知道一次操作的全链路耗时分布,还能精确地将这次操作归因到具体用户。最初的那个“1.5秒的空白地带”问题,现在已经完全透明了。
方案局限性与未来迭代路径
这套方案虽然解决了核心问题,但在生产环境中应用仍有需要考量的点。
首先,采样策略。我们使用了 AlwaysOnSampler,这在流量大的系统中会产生巨大的数据量和性能开销。生产环境必须采用更智能的采样策略,例如基于 TraceIdRatioBasedSampler 的头部采样,或者在后端采用如 OpenTelemetry Collector 实现的尾部采样,只保留那些包含错误或耗时较长的“有趣”的 trace。
其次,前端性能开销。引入 Web OTel SDK 会增加前端包的体积,并带来一定的运行时开销。需要评估其对 Core Web Vitals 的影响,并可能需要定制化打包,只包含必要的 instrumentations。
再次,上下文传播的健壮性。ZoneContextManager 解决了大部分异步场景,但在一些复杂的、跨多个宏任务的交互中(例如,从一个 setTimeout 回调中触发另一个),上下文仍有丢失的风险。对于极其复杂的应用,需要进行充分的测试,或者探索更严格的上下文管理机制。
最后,JWT 与追踪的进一步结合。我们放弃了用 JWT 传递 trace 上下文,但可以反向思考:在登录时,可以生成一个唯一的 sessionId 并放入 JWT。在后端,将此 sessionId 作为 tag 附加到所有 span 上。这样,即使不依赖 traceId,也能在 Jaeger 中通过 sessionId 筛选出某个用户在一次会话中的所有操作链路,这对于分析用户行为模式极具价值。