在 Node.js 与 Recoil 应用中实现基于 JWT 传递追踪上下文的全链路监控


一个用户反馈操作缓慢,但日志里没有任何错误。排查开始。后端团队在 Jaeger 中检查了相关 API 的链路,发现服务内部耗时正常,P99 响应时间在 80ms。问题似乎不在后端。前端团队检查了浏览器性能录制,发现在点击按钮后,网络请求直到 1.5 秒后才发出。这中间的空白地带,正是可观测性的深渊。我们有后端的 trace,有前端的 performance profile,但它们是割裂的。我们无法回答一个最基本的问题:从用户点击到数据渲染的完整生命周期中,时间到底消耗在了哪里?

这个场景在真实项目中极其普遍。问题的根源在于前端与后端监控体系的割裂。要构建一个完整的用户体验视图,必须将前端操作与后端服务调用串联在同一条 trace 中。本文将复盘一次完整的技术实践:从零开始,在一个基于 React/Recoil 的前端应用和一个 Node.js 后端服务之间,建立起端到端的分布式追踪。我们将利用 OpenTelemetry 作为标准,通过 JWT 承载用户身份信息来丰富 trace,并最终在 Jaeger 中实现对用户单次操作的全链路可视化。

初步构想与技术选型决策

我们的目标是:当用户在前端触发一个操作时,生成一个全局唯一的 traceId。该 traceId 及其上下文将伴随 API 请求传递到后端。后端服务在接收到请求后,会识别这个 trace 上下文,并将自己产生的 spans 作为子节点挂载到同一个 trace 下。

实现这个目标需要解决几个核心问题:

  1. 追踪标准: 如何在异构的前端和后端环境中生成和传播符合规范的追踪数据?

    • 决策: OpenTelemetry (OTel)。它提供了统一的 API 和 SDK,覆盖了前端 JavaScript 和后端 Node.js,是目前社区的事实标准。
  2. 前端上下文管理: 在 React 单页应用中,如何生成、存储并自动注入 trace 上下文到发出的 HTTP 请求中?

    • 初步想法: 使用 React Context。
    • 最终决策: 我们项目已在使用 Recoil 进行状态管理。利用 Recoil 来管理认证状态(如 JWT)是其常规用法。虽然 OTel 的 ContextManager 能处理大部分 trace 上下文的自动传递,但认证状态与追踪数据的结合点需要我们精细控制。Recoil 将主要负责管理 JWT token,而 OTel Web SDK 将处理 trace 上下文的自动注入。
  3. 上下文跨边界传播: 前端生成的 trace 上下文如何安全、可靠地传递给后端?

    • 方案A: 自定义 HTTP Header,如 X-Trace-Context。这是最直接的方式,但可能被某些网关或代理 stripping。

    • 方案B: 利用现有 Header。W3C Trace Context 规范定义了标准的 traceparenttracestate HTTP headers。这是 OTel 默认的传播方式,也是最佳实践。

    • 方案C: 嵌入 JWT Payload。这个想法很有诱惑力,即将 traceIdspanId 直接编码进 JWT。但很快被否决,因为 JWT 通常具有较长的生命周期,而 trace 和 span 是瞬态的。为每个请求重新签发 JWT 是不可接受的。

    • 最终决策: 采用方案 B,使用标准的 traceparent Header。那么 JWT 的角色是什么?它不再用于传递 trace 上下文,而是用于传递稳定的用户身份信息。在后端,我们可以从 JWT 中解析出 userIdtenantId,并将这些信息作为 tag 附加到后端的 spans 上。这极大地增强了 trace 的业务可读性,使我们能快速筛选出特定用户的所有操作链路。

  4. 后端实现: Node.js 服务如何接收 trace 上下文,并与自身的 trace 连接?

    • 决策: 使用 OpenTelemetry SDK for Node.js。通过其提供的 instrumentations,可以自动为 Express、HTTP 等常用模块创建 spans,并自动处理传入的 traceparent header。
  5. 可视化与存储: 收集到的 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 中检视全链路

操作流程:

  1. 打开前端应用 http://localhost:3000
  2. 输入用户名,点击 “Login”。
  3. 点击 “Fetch Protected Data”。
  4. 打开 Jaeger UI http://localhost:16686
  5. 在 Service 下拉列表中选择 my-frontend-service,点击 “Find Traces”。

你会看到一条名为 ui.operation.fetch-data 的 trace。点开它,一个完整的、跨越前后端的调用链瀑布图将展现在眼前:

  1. 顶层 Span (Root): ui.operation.fetch-data,来自 my-frontend-service。这是我们在 handleFetchData 中手动创建的,它代表了整个用户操作的生命周期。
  2. 子 Span (Frontend): HTTP GET,同样来自 my-frontend-service。这是 OTel FetchInstrumentation 自动创建的,它精确测量了从请求开始到收到响应的完整网络耗时。
  3. 子 Span (Backend): GET /api/data,来自 my-backend-service。这是 OTel Node.js SDK 的 ExpressInstrumentation 自动创建的,它继承了前端传来的 traceId,无缝连接了链路。
  4. 孙子 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 筛选出某个用户在一次会话中的所有操作链路,这对于分析用户行为模式极具价值。


  目录