一个前端性能问题工单摆在了桌面上:用户反馈在我们的 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。这里的关键是:
- 定义一个
ResourceBuilder
来标识服务身份,这是可观测性的基础。 - 启用对 ASP.NET Core (入站请求) 和 HttpClient (出站请求,YARP 转发时使用) 的自动插桩。
- 配置 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。更重要的是,它会自动将追踪上下文(即 traceparent
和 tracestate
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
的调用,以及它内部的 set
和 fetch
操作,就都和 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) - 这就是问题的根源!
- remote span:
- remote span:
- child span:
zustand.setState.object
(耗时 2ms)
- child span:
通过这个视图,最初那个“悬案”的答案一目了然。延迟的根源既不在前端,也不在 API Gateway 本身,而是在最下游的 dashboard-service
。现在,后端团队可以拿着这个具体的 Trace ID,去精确地排查为什么那一次调用会如此缓慢,是数据库查询慢了,还是某个第三方依赖超时了。问题定位的效率从几天缩短到了几分钟。
局限与未来路径
这个方案虽然解决了核心痛点,但在生产环境中推广,仍有一些现实问题需要考虑。
首先,前端追踪的成本。将每一次点击和状态变更都作为 Span 发送到后端,对于流量大的应用来说,会产生巨大的数据量和相应的存储、处理成本。在真实项目中,必须实施采样策略,例如只对特定用户、特定功能或发生错误时的会话进行完整追踪。OpenTelemetry SDK 本身支持多种采样器。
其次,对业务代码的侵入性。虽然我们通过中间件简化了 Zustand 的追踪,但在组件层面,tracer.startActiveSpan
的手动包裹依然存在。这增加了开发人员的心智负担。未来可以探索通过 Babel 插件或更高阶的抽象来自动化这个过程,使其对业务逻辑更透明。
最后,当前方案只覆盖了追踪(Traces)。一个成熟的可观测性体系还需要指标(Metrics)和日志(Logs)。OpenTelemetry 同样定义了这两者的规范。下一步的迭代应该是,将前端性能指标(如 LCP, FID)、C# 服务的运行时指标(如 GC 次数、线程池状态)以及携带了 TraceID
的结构化日志,全部对接到统一的可观测性平台,实现三者的关联分析。这样,在看到一个慢追踪时,我们能立刻下钻到与之相关的错误日志和当时的系统资源指标,形成更立体的诊断视图。