基于 OpenTelemetry 构建 Gatsby、Zustand 与 C# API Gateway 的统一可观测性链路


一个前端性能问题工单摆在了桌面上:用户反馈在我们的 Gatsby 应用中,点击“加载仪表盘”后,页面会卡顿 5-8 秒才显示数据。前端团队通过 Chrome DevTools 分析,发现一个对 /api/v1/dashboard/main 的请求 TTFB (Time To First Byte) 耗时过长。然而,当把这个请求信息丢给后端团队时,他们检查了 Dashboard 微服务的日志和指标,发现在那个时间点,服务的 P99 延迟依然低于 200ms,并且没有任何错误日志。皮球被踢给了中间件团队,他们负责维护基于 C# 和 YARP 构建的 API Gateway。他们也表示,从网关的访问日志看,请求只是做了简单的路由转发,自身开销在 10ms 以内。

就这样,一个简单的问题变成了一场跨团队的“悬案”。问题出在哪里?是用户网络环境?CDN?还是网关到后端微服务之间的内部网络?或者,是某个被遗忘的、处在调用链中间的认证服务出现了间歇性缓慢?这种割裂的、基于日志和孤立指标的排障方式,效率极其低下。我们需要一种能将从用户浏览器中的点击操作,到 API Gateway 的路由,再到后端服务的完整处理过程串联起来的视图。

我们的目标是构建一个统一的、贯穿前端和后端的分布式追踪系统。技术选型没有太多争议,OpenTelemetry 是目前业界跨语言、跨平台可观测性的事实标准。挑战在于如何将它无缝地集成到我们现有的技术栈中:一个 Gatsby 构建的静态/服务端渲染应用,使用 Zustand 管理复杂的前端状态,后端则是一个 C# 8 编写的、基于 YARP 的高性能 API Gateway。

第一步:搭建可观测性后端与 API Gateway 探针

在真正开始深入代码之前,我们需要一个地方来接收和可视化追踪数据。在真实项目中,这可能是 Jaeger、Zipkin,或者商业化的产品。为了本地调试,一个简单的 OpenTelemetry Collector 配合 Jaeger 就足够了。

# docker-compose.yml
version: '3.8'
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.87.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317" # OTLP gRPC
      - "4318:4318" # OTLP HTTP
    depends_on:
      - jaeger

  jaeger:
    image: jaegertracing/all-in-one:1.49
    ports:
      - "16686:16686" # Jaeger UI
      - "14268:14268" # Jaeger Collector Thrift

Collector 的配置很简单,接收 OTLP 协议的数据,然后导出到 Jaeger。

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
      http:

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [jaeger]

接下来是改造 C# API Gateway。我们使用 YARP 作为反向代理,它本身构建于 ASP.NET Core 之上,这让集成 OpenTelemetry .NET SDK 变得非常直接。

首先,添加必要的 NuGet 包:

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol

然后,在 Program.cs 中配置 OpenTelemetry。这里的关键是:

  1. 定义一个 ResourceBuilder 来标识服务身份,这是可观测性的基础。
  2. 启用对 ASP.NET Core (入站请求) 和 HttpClient (出站请求,YARP 转发时使用) 的自动插桩。
  3. 配置 OTLP Exporter 将数据发送到我们刚才启动的 Collector。
// Program.cs of the C# API Gateway
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// 1. 配置 YARP 代理
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

// 2. 配置 OpenTelemetry
const string serviceName = "api-gateway";
const string serviceVersion = "1.0.0";

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: serviceName, serviceVersion: serviceVersion))
    .WithTracing(tracing => tracing
        .AddSource(serviceName) // 添加自定义 Source
        .AddAspNetCoreInstrumentation(options =>
        {
            // 过滤掉不关心的监控端点
            options.Filter = (httpContext) => !httpContext.Request.Path.Value.Contains("/_health");
        })
        .AddHttpClientInstrumentation(options =>
        {
            // 可以在这里丰富出站请求的 Span
        })
        .AddOtlpExporter(opt =>
        {
            // 配置 OTLP gRPC exporter
            opt.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]);
        }));

var app = builder.Build();

app.MapReverseProxy();

app.Run();

这里的配置有一个值得注意的细节:AddHttpClientInstrumentation。当 YARP 收到一个请求并将其转发到下游微服务时,它内部使用的就是 HttpClient。OpenTelemetry 的这个插桩库会自动捕获这次出站调用,创建一个新的 Span,并将其作为入站请求 Span 的子 Span。更重要的是,它会自动将追踪上下文(即 traceparenttracestate HTTP 头)注入到发往下游的请求中。这就完成了链路在后端的自动传递。

为了验证,我们可以启动一个简单的下游服务,同样配置好 OpenTelemetry,然后发起一个请求到 API Gateway。在 Jaeger UI 中,我们应该能看到一条包含两个 Span 的链路:一个来自 api-gateway,另一个来自下游服务。这证明了后端的链路传递是正常的。

第二步:攻坚前端,在 Gatsby 和 Zustand 中植入追踪

前端的追踪要复杂得多。它不仅涉及 HTTP 请求,还涉及用户交互、组件渲染和状态管理。

首先,安装 OpenTelemetry JavaScript 相关的库:

npm install @opentelemetry/api @opentelemetry/sdk-trace-web @opentelemetry/instrumentation @opentelemetry/instrumentation-fetch @opentelemetry/context-zone @opentelemetry/exporter-trace-otlp-http

我们需要创建一个专门的文件来初始化和配置 Web Tracer Provider。

// src/utils/tracing.js

import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const resource = new Resource({
  [SemanticResourceAttributes.SERVICE_NAME]: 'gatsby-frontend-app',
  [SemanticResourceAttributes.SERVICE_VERSION]: '1.2.0',
});

// 使用 OTLP HTTP Exporter 将数据发送到 Collector
const collectorExporter = new OTLPTraceExporter({
  url: 'http://localhost:4318/v1/traces', // 指向 OTel Collector 的 HTTP endpoint
});

const provider = new WebTracerProvider({ resource });

provider.addSpanProcessor(new BatchSpanProcessor(collectorExporter, {
  // 为演示目的,设置较短的导出间隔
  scheduledDelayMillis: 500,
}));

// 必须使用 ZoneContextManager 来处理异步上下文
provider.register({
  contextManager: new ZoneContextManager(),
});

registerInstrumentations({
  instrumentations: [
    // 自动为 Fetch API 创建 Spans
    // 并自动注入 W3C Trace Context headers
    new FetchInstrumentation({
      // 我们可以过滤掉一些不关心的请求
      ignoreUrls: [/localhost:8000\/sockjs-node/],
      // 也可以在这里为 Span 添加额外属性
      propagateTraceHeaderCorsUrls: [
        new RegExp('http://localhost:5000'), // API Gateway 的地址
      ]
    }),
  ],
});

export const tracer = provider.getTracer('gatsby-frontend-tracer');

初始化逻辑完成后,我们需要在 Gatsby 应用的入口处调用它。一个好的地方是 gatsby-browser.js,它会在客户端代码加载时执行。

// gatsby-browser.js

// 仅在浏览器环境中初始化
export const onClientEntry = () => {
  if (typeof window !== 'undefined') {
    require('./src/utils/tracing');
  }
};

到目前为止,我们已经实现了对 fetch 请求的自动追踪。当组件调用 fetch('/api/...') 时,FetchInstrumentation 会自动创建一个 Span,并将 traceparent 头注入请求,我们的 C# API Gateway 就能接收到并继续传递链路。但这只解决了链路的一半。用户是如何触发这个 fetch 请求的?通常是通过点击按钮。这个点击事件,以及它引发的状态变化,也应该成为链路的一部分。

这就是集成 Zustand 的关键所在。Zustand 本身没有 OpenTelemetry 的插桩,我们需要手动创建。一个优雅的方式是编写一个 Zustand 中间件。

// src/store/traceMiddleware.js
import { trace, context } from '@opentelemetry/api';

const tracer = trace.getTracer('zustand-middleware-tracer');

export const traceMiddleware = (config) => (set, get, api) => {
  const originalSet = api.setState;

  // 包装 setState 方法
  api.setState = (partial, replace) => {
    // 检查是否在活动的 Span 上下文中
    const activeSpan = trace.getSpan(context.active());
    if (!activeSpan) {
      // 如果没有活动 Span,直接调用原始 setState
      return originalSet(partial, replace);
    }
    
    // 如果存在活动 Span,创建一个子 Span 来记录这次状态更新
    const spanName = typeof partial === 'function' 
      ? 'zustand.setState.function' 
      : `zustand.setState.object`;

    return tracer.startActiveSpan(spanName, (span) => {
      try {
        const result = originalSet(partial, replace);
        span.setAttribute('zustand.action.keys', Object.keys(partial).join(','));
        span.setStatus({ code: 1 }); // OK
        return result;
      } catch (e) {
        span.recordException(e);
        span.setStatus({ code: 2, message: e.message }); // ERROR
        throw e;
      } finally {
        span.end();
      }
    });
  };

  return config(set, get, api);
};

这个中间件重写了 setState 方法。在调用真正的 setState 之前,它会检查当前是否存在一个活动的 OpenTelemetry Span。如果存在(例如,我们稍后会在点击事件处理器中创建一个 Span),它就会创建一个名为 zustand.setState 的子 Span,从而将状态更新与用户操作关联起来。

现在,在我们的 Zustand store 定义中应用这个中间件。

// src/store/dashboardStore.js
import { create } from 'zustand';
import { traceMiddleware } from './traceMiddleware';

export const useDashboardStore = create(
  traceMiddleware((set) => ({
    isLoading: false,
    data: null,
    error: null,

    fetchDashboardData: async () => {
      set({ isLoading: true, error: null }); // <-- 这次 setState 将被追踪
      try {
        const response = await fetch('http://localhost:5000/api/v1/dashboard/main');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        set({ isLoading: false, data }); // <-- 这次也会被追踪
      } catch (error) {
        set({ isLoading: false, error: error.message }); // <-- 错误状态也会被追踪
      }
    },
  }))
);

最后一步,我们需要在触发操作的 UI 组件中创建顶层的 Span。

// src/components/DashboardLoader.jsx
import React from 'react';
import { trace } from '@opentelemetry/api';
import { useDashboardStore } from '../store/dashboardStore';

const tracer = trace.getTracer('react-component-tracer');

const DashboardLoader = () => {
  const { isLoading, data, fetchDashboardData } = useDashboardStore();

  const handleLoadClick = () => {
    // 这是追踪的起点:用户交互
    tracer.startActiveSpan('dashboard.load_button.click', (span) => {
      span.setAttribute('component.name', 'DashboardLoader');
      
      // 在这个 Span 的上下文中调用 action
      // 之后的所有操作(setState, fetch)都会成为它的子 Span
      fetchDashboardData().finally(() => {
        span.end(); // 确保在异步操作完成后结束 Span
      });
    });
  };

  return (
    <div>
      <button onClick={handleLoadClick} disabled={isLoading}>
        {isLoading ? '加载中...' : '加载仪表盘'}
      </button>
      {/* ... render data or error ... */}
    </div>
  );
};

export default DashboardLoader;

tracer.startActiveSpan 是这里的核心。它创建了一个新的 Span,并将其设置为当前活动的上下文。在这个回调函数内部执行的所有被自动或手动插桩的代码,都会自动成为这个新 Span 的子节点。这样,fetchDashboardData 的调用,以及它内部的 setfetch 操作,就都和 dashboard.load_button.click 这个根 Span 关联起来了。

第三步:串联与验证

现在,我们把所有部分都运行起来:Gatsby 开发服务器、C# API Gateway、下游模拟服务,以及 Docker 中的 Collector 和 Jaeger。

我们来描绘一下完整的请求流程,以及它在 OpenTelemetry 中应该生成的追踪链路。

sequenceDiagram
    participant User as 用户
    participant Gatsby as Gatsby 应用 (浏览器)
    participant Zustand as Zustand Store
    participant APIGateway as C# API Gateway
    participant Downstream as 下游微服务

    User->>Gatsby: 点击“加载仪表盘”按钮
    Gatsby->>Gatsby: Tracer.startActiveSpan('dashboard.click')
    Gatsby->>Zustand: 调用 fetchDashboardData() Action
    Zustand->>Zustand: Span('zustand.setState'): isLoading=true
    Zustand->>Gatsby: 发起 fetch('/api/...') 请求
    Gatsby->>Gatsby: Span('http.get'): 注入 traceparent header
    Gatsby->>APIGateway: GET /api/v1/dashboard/main
    APIGateway->>APIGateway: Span('GET /api/...'): 读取 traceparent header
    APIGateway->>Downstream: 转发请求 (含 traceparent)
    Downstream->>Downstream: Span('process.dashboard'): 读取 traceparent
    Downstream-->>APIGateway: 返回数据
    APIGateway-->>Gatsby: 返回数据
    Gatsby->>Zustand: 调用 set() 更新数据
    Zustand->>Zustand: Span('zustand.setState'): isLoading=false, data=...
    Gatsby->>Gatsby: Tracer.endSpan('dashboard.click')

当我们在 Jaeger UI 中查找 gatsby-frontend-app 服务的踪迹时,我们会看到一条完整的、层次分明的链路。它不再是孤立的片段,而是一个完整的故事:

  • root span: dashboard.load_button.click (耗时 5.3s)
    • child span: zustand.setState.object (耗时 1ms)
    • child span: HTTP GET (耗时 5.25s) - 这个 Span 由 FetchInstrumentation 自动创建。
      • remote span: GET /api/v1/dashboard/main (在 api-gateway 服务中,耗时 5.2s)
        • remote span: process.dashboard.data (在 dashboard-service 中,耗时 5.1s) - 这就是问题的根源!
    • child span: zustand.setState.object (耗时 2ms)

通过这个视图,最初那个“悬案”的答案一目了然。延迟的根源既不在前端,也不在 API Gateway 本身,而是在最下游的 dashboard-service。现在,后端团队可以拿着这个具体的 Trace ID,去精确地排查为什么那一次调用会如此缓慢,是数据库查询慢了,还是某个第三方依赖超时了。问题定位的效率从几天缩短到了几分钟。

局限与未来路径

这个方案虽然解决了核心痛点,但在生产环境中推广,仍有一些现实问题需要考虑。

首先,前端追踪的成本。将每一次点击和状态变更都作为 Span 发送到后端,对于流量大的应用来说,会产生巨大的数据量和相应的存储、处理成本。在真实项目中,必须实施采样策略,例如只对特定用户、特定功能或发生错误时的会话进行完整追踪。OpenTelemetry SDK 本身支持多种采样器。

其次,对业务代码的侵入性。虽然我们通过中间件简化了 Zustand 的追踪,但在组件层面,tracer.startActiveSpan 的手动包裹依然存在。这增加了开发人员的心智负担。未来可以探索通过 Babel 插件或更高阶的抽象来自动化这个过程,使其对业务逻辑更透明。

最后,当前方案只覆盖了追踪(Traces)。一个成熟的可观测性体系还需要指标(Metrics)和日志(Logs)。OpenTelemetry 同样定义了这两者的规范。下一步的迭代应该是,将前端性能指标(如 LCP, FID)、C# 服务的运行时指标(如 GC 次数、线程池状态)以及携带了 TraceID 的结构化日志,全部对接到统一的可观测性平台,实现三者的关联分析。这样,在看到一个慢追踪时,我们能立刻下钻到与之相关的错误日志和当时的系统资源指标,形成更立体的诊断视图。


  目录