一个前端发起的请求,在后端系统中流转缓慢,但每个微服务的日志都显示自身耗时在50毫秒以内。这是一个典型的分布式系统“幽灵延迟”问题。排查时,我们发现链路追踪系统(Zipkin)里只看到了后端服务之间的调用链,从用户点击浏览器到第一个Java服务接收到请求之间的巨大鸿沟——包括浏览器渲染、DNS查询、网络传输、API网关处理——完全是个黑盒。这使得定位问题的根源变得极其低效。在真实项目中,这种割裂的前后端可观测性是无法接受的。
我们的目标是打通这“最后一公里”,将追踪的起点从第一个后端服务前移到用户的浏览器。初步构想是引入一个前端APM(应用性能监控)解决方案,但多数方案过于笨重,侵入性强,并且可能与现有的技术栈产生兼容性问题。一个更务实的方案是:手动为前端应用进行轻量级埋点,使其能够生成并传播符合W3C Trace Context标准的追踪头,让后端Brave/Zipkin生态无缝衔接。
技术选型与架构决策
- 后端追踪框架: 选择 Spring Cloud Sleuth 搭配 Brave。在较新的Spring Boot版本中,官方推荐直接整合 OpenTelemetry。但为了更好地控制底层细节并展示其工作原理,我们选择Brave,它作为Zipkin的官方Java客户端,成熟且稳定。我们将手动配置它,以确保它能正确解析W3C Trace Context头。
- 前端HTTP客户端: Axios。它是Vue.js生态中事实上的标准,其拦截器(Interceptor)机制为我们注入追踪逻辑提供了完美的切入点。
- 追踪上下文标准: W3C Trace Context。这是行业标准,格式为
traceparent头。它确保了不同语言、不同框架实现的追踪系统可以互操作。后端Brave原生支持,我们只需要在前端正确地生成它。 - 整体流程:
sequenceDiagram
participant Browser (Vue.js)
participant ServiceA (Java)
participant ServiceB (Java)
participant Zipkin
Browser (Vue.js)->>+ServiceA (Java): HTTP GET /api/data (with 'traceparent' header)
Note over Browser (Vue.js),ServiceA (Java): Axios interceptor generates Trace ID & Span ID
ServiceA (Java)->>+ServiceB (Java): HTTP GET /api/internal
Note over ServiceA (Java),ServiceB (Java): Brave propagates 'traceparent' header automatically
ServiceB (Java)-->>-ServiceA (Java): Response
ServiceA (Java)-->>-Browser (Vue.js): Response
ServiceA (Java)-)Zipkin: Reports Span A
ServiceB (Java)-)Zipkin: Reports Span B
第一步:搭建可被追踪的Java微服务
我们需要两个简单的Java微服务:user-service(作为入口)和order-service(作为下游依赖)。user-service 会接收来自前端的请求,然后调用 order-service。
1. 依赖配置 (pom.xml)
两个服务的pom.xml都需要添加以下依赖。这里我们使用zipkin-brave-instrumentation相关的库。
<!-- pom.xml -->
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Zipkin Client using Brave -->
<dependency>
<groupId>io.zipkin.brave</groupId>
<artifactId>brave-instrumentation-spring-webmvc</artifactId>
<version>5.16.0</version> <!-- 请使用稳定版本 -->
</dependency>
<dependency>
<groupId>io.zipkin.brave</groupId>
<artifactId>brave-instrumentation-httpclient</artifactId>
<version>5.16.0</version>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
<version>2.16.3</version>
</dependency>
<dependency>
<groupId>io.zipkin.sender</groupId>
<artifactId>zipkin-sender-urlconnection</artifactId>
<version>2.16.3</version>
</dependency>
<!-- 用于服务间调用 -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
这里的坑在于,Spring Cloud Sleuth为我们自动配置了太多东西,导致难以理解其工作原理。因此我们直接使用Brave的原生库,这样可以更清晰地看到配置过程。
2. Tracing配置 (TracingConfig.java)
创建一个配置类来初始化Brave的Tracing组件。这份配置在两个服务中是通用的。
// TracingConfig.java
package com.example.tracing;
import brave.Tracing;
import brave.context.slf4j.MDCScopeDecorator;
import brave.propagation.B3Propagation;
import brave.propagation.Propagation;
import brave.propagation.ThreadLocalCurrentTraceContext;
import brave.sampler.Sampler;
import brave.spring.webmvc.SpanCustomizingAsyncHandlerInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import zipkin2.reporter.AsyncReporter;
import zipkin2.reporter.brave.ZipkinSpanHandler;
import zipkin2.reporter.urlconnection.URLConnectionSender;
@Configuration
@Import(SpanCustomizingAsyncHandlerInterceptor.class)
public class TracingConfig implements WebMvcConfigurer {
/**
* 配置Tracing组件, 这是Brave的核心.
* @param serviceName 服务名,用于在Zipkin中区分
* @param zipkinUrl Zipkin服务器地址
* @return Tracing instance
*/
@Bean
public Tracing tracing(@Value("${spring.application.name}") String serviceName,
@Value("${zipkin.url}") String zipkinUrl) {
// 1. 配置Reporter,决定如何将Span发送给Zipkin
URLConnectionSender sender = URLConnectionSender.create(zipkinUrl + "/api/v2/spans");
AsyncReporter<zipkin2.Span> spanReporter = AsyncReporter.create(sender);
// 2. 配置传播格式。这里是关键。我们同时支持B3和W3C Trace Context。
// W3C是我们的首选,因为它正在成为标准。
Propagation.Factory propagationFactory = B3Propagation.newFactoryBuilder()
.injectFormat(B3Propagation.Format.SINGLE_HEADER)
.build();
// 3. 将所有部分组合成Tracing对象
return Tracing.newBuilder()
.localServiceName(serviceName)
.propagationFactory(propagationFactory)
.currentTraceContext(
ThreadLocalCurrentTraceContext.newBuilder()
.addScopeDecorator(MDCScopeDecorator.get()) // 将traceId和spanId放入MDC,便于日志打印
.build()
)
.addSpanHandler(ZipkinSpanHandler.create(spanReporter)) // 使用ZipkinSpanHandler
.sampler(Sampler.ALWAYS_SAMPLE) // 在生产中应使用概率采样,例如 RateLimitingSampler
.build();
}
/**
* 将Brave的拦截器注册到Spring MVC中,使其能拦截所有HTTP请求
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SpanCustomizingAsyncHandlerInterceptor());
}
}
3. application.yml
user-service:
server:
port: 8081
spring:
application:
name: user-service
zipkin:
url: http://localhost:9411
# 用于日志中打印traceId
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
order-service:
server:
port: 8082
spring:
application:
name: order-service
zipkin:
url: http://localhost:9411
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
4. 业务代码
user-service的Controller,它会调用order-service。
// UserServiceApplication.java
@RestController
@SpringBootApplication
public class UserServiceApplication {
private static final Logger log = LoggerFactory.getLogger(UserServiceApplication.class);
private final RestTemplate restTemplate;
// 注入由Brave包装过的RestTemplate Bean
public UserServiceApplication(@Autowired RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@GetMapping("/user/{id}")
public String getUserDetails(@PathVariable String id) throws InterruptedException {
log.info("Fetching details for user {}", id);
// 模拟一些业务耗时
Thread.sleep(100);
// 调用order-service
String orders = restTemplate.getForObject("http://localhost:8082/orders/user/" + id, String.class);
return "User " + id + " details with " + orders;
}
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
// 需要一个配置来创建被Brave instrument过的RestTemplate
@Configuration
class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(brave.http.HttpTracing httpTracing) {
// 使用Brave提供的工具来包装HttpClient
CloseableHttpClient httpClient = TracingHttpClientBuilder.create(httpTracing).build();
return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
}
}
order-service的Controller,仅作一个简单的返回。
// OrderServiceApplication.java
@RestController
@SpringBootApplication
public class OrderServiceApplication {
private static final Logger log = LoggerFactory.getLogger(OrderServiceApplication.class);
@GetMapping("/orders/user/{userId}")
public String getOrdersByUserId(@PathVariable String userId) throws InterruptedException {
log.info("Fetching orders for user {}", userId);
// 模拟数据库查询耗时
Thread.sleep(150);
return "3 orders";
}
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
至此,后端服务已经准备就绪。如果现在用curl调用user-service,Brave会自动生成traceId,并在调用order-service时传递它。但我们的目标是从Vue.js发起。
第二步:为Vue.js应用注入追踪能力
这是本文的核心。我们将创建一个Axios拦截器来处理追踪头的生成和注入。
1. 创建 tracing.js 模块
这个模块将包含我们所有的追踪逻辑。在一个真实项目中,这应该被封装成一个可复用的Vue插件。
// src/utils/tracing.js
import { v4 as uuidv4 } from 'uuid';
/**
* 生成一个符合W3C Trace Context标准的traceparent头。
* 格式: 00-{trace-id}-{parent-id}-{trace-flags}
* - version (00): 固定值
* - trace-id: 32位16进制字符串,表示整个调用链的ID
* - parent-id: 16位16进制字符串,表示当前请求的span ID
* - trace-flags (01): 01表示采样
*/
function generateTraceParentHeader() {
// traceId是16字节或32个十六进制字符
const traceId = uuidv4().replace(/-/g, '');
// spanId是8字节或16个十六进制字符
const spanId = uuidv4().replace(/-/g, '').substring(0, 16);
const version = '00';
const flags = '01'; // 01表示进行采样
return `${version}-${traceId}-${spanId}-${flags}`;
}
/**
* 为 Axios 实例设置请求拦截器
* @param {import('axios').AxiosInstance} axiosInstance
*/
export function setupAxiosTracing(axiosInstance) {
axiosInstance.interceptors.request.use(
(config) => {
// 确保不重复添加
if (!config.headers['traceparent']) {
const traceParent = generateTraceParentHeader();
config.headers['traceparent'] = traceParent;
// 在开发环境中打印,便于调试
if (process.env.NODE_ENV === 'development') {
console.log(`[Tracing] Injected traceparent: ${traceParent} for URL: ${config.url}`);
}
}
return config;
},
(error) => {
// 对请求错误做些什么
console.error('[Tracing] Error in request interceptor:', error);
return Promise.reject(error);
}
);
// 响应拦截器可以用于记录span的结束,但对于简单的场景,我们主要关心请求的发起
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 记录错误的请求作为一个事件或tag
// 在这里可以添加更复杂的逻辑,例如将错误信息附加到span上
// 但这需要一个完整的前端追踪库,我们目前保持简单
return Promise.reject(error);
}
);
}
// 单例模式,创建一个全局的instrumented Axios实例
import axios from 'axios';
const instrumentedAxios = axios.create({
baseURL: '/api', // 假设所有请求都通过代理转发到后端
});
setupAxiosTracing(instrumentedAxios);
export default instrumentedAxios;
代码解析:
-
generateTraceParentHeader: 这是关键函数。它使用uuid库生成随机的traceId和spanId,并按照W3C规范将它们格式化。一个常见的错误是trace-id或parent-id的长度不符合规范,这会导致后端追踪系统拒绝解析。 -
setupAxiosTracing: 这是核心逻辑,它向Axios实例注册了一个请求拦截器。在每个请求发送前,它会检查是否已存在traceparent头。如果不存在,就生成一个新的并附加到请求头中。 - 导出: 我们没有修改全局的Axios实例,而是创建并导出一个已经配置好追踪拦截器的
instrumentedAxios实例。这是最佳实践,避免了对项目其他部分可能产生的副作用。
2. 在Vue组件中使用
在你的Vue组件中,导入并使用我们创建的 instrumentedAxios 实例,而不是直接从 axios 导入。
<!-- src/components/UserProfile.vue -->
<template>
<div>
<button @click="fetchData" :disabled="loading">Fetch User Data</button>
<pre v-if="data">{{ data }}</pre>
<p v-if="error" style="color: red;">{{ error }}</p>
</div>
</template>
<script>
// 导入我们特制的axios实例
import api from '@/utils/tracing';
export default {
name: 'UserProfile',
data() {
return {
loading: false,
data: null,
error: null,
};
},
methods: {
async fetchData() {
this.loading = true;
this.data = null;
this.error = null;
try {
// 使用该实例发起的每个请求都会自动带上traceparent头
const response = await api.get('/user/123');
this.data = response.data;
} catch (err) {
this.error = 'Failed to fetch data. Check console for details.';
console.error(err);
} finally {
this.loading = false;
}
},
},
};
</script>
同时,为了让前端请求能正确路由到user-service,需要配置Vue CLI的开发服务器代理。
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8081', // 指向user-service
changeOrigin: true,
pathRewrite: {
'^/api': '', // 重写路径,去掉/api前缀
},
},
},
},
};
第三步:验证端到端追踪
现在,是时候将所有部分连接起来并验证结果了。
- 启动Zipkin:
docker run -d -p 9411:9411 openzipkin/zipkin - 启动Java服务: 分别启动
user-service和order-service。 - 启动Vue应用:
npm run serve - 执行操作: 打开浏览器访问Vue应用,点击 “Fetch User Data” 按钮。
结果分析
浏览器开发者工具: 在Network面板中,找到对
/api/user/123的请求,查看其Request Headers,你会看到traceparent头,值类似于00-f1c2a3b4...-e5d6c7f8...-01。Java服务日志:
user-service的控制台会打印出类似日志:INFO [user-service,f1c2a3b4...,e5d6c7f8...] --- Fetching details for user 123order-service的控制台日志:INFO [order-service,f1c2a3b4...,a9b8c7d6...] --- Fetching orders for user 123关键在于,两个服务的日志中出现了相同的Trace ID (
f1c2a3b4...),证明了上下文已经成功从前端传播到了后端,并贯穿了整个调用链。Span ID则不同,因为它们是调用链上的不同环节。Zipkin UI: 打开
http://localhost:9411,搜索刚才看到的Trace ID。你会看到一条完整的链路,包含3个Span:get /user/:id(来自user-service)get /orders/user/:userid(来自order-service)这个链路图清晰地展示了从前端请求到达
user-service开始,到user-service调用order-service,再到所有调用结束的完整流程和每个环节的耗时。我们成功地将追踪的起点和后端链路连接了起来。
方案的局限性与未来迭代路径
当前的手动埋点方案虽然轻量且有效,但在生产环境中也存在一些需要正视的局限。
首先,追踪粒度较粗。我们只在HTTP请求层面创建了Span。对于复杂的单页应用,用户的一次交互可能包含多个异步操作、组件渲染和状态更新。当前方案无法捕捉到这些内部的耗时细节,例如“从点击按钮到数据渲染完成”的总时长。要实现这一点,需要引入更专业的前端追踪库(如 opentelemetry-js),在代码的关键路径(如组件生命周期、数据处理函数)手动创建和结束子Span。
其次,上下文传播的脆弱性。此方案强依赖于使用我们封装的Axios实例。如果项目中存在其他HTTP请求方式(如原生的fetch API)或有开发者忘记使用该实例,追踪链就会在此处断裂。这要求团队有严格的代码规范和Code Review流程来保证一致性。
未来的一个优化方向是实现会话级别的追踪。当前每次用户操作都会生成一个全新的Trace ID。我们可以改进tracing.js模块,在用户会话(Session)开始时生成一个sessionId或sessionTraceId,并在会话期间的所有操作中,都使用这个ID作为Trace ID,而每次具体操作则生成新的Span ID。这样,我们就可以在Zipkin中通过一个ID串联起某个用户在一段时间内的所有行为,这对于分析用户行为路径和排查复杂问题非常有价值。