构建从 Vue.js 到 Java 微服务的全链路追踪:手动埋点与上下文传播实践


一个前端发起的请求,在后端系统中流转缓慢,但每个微服务的日志都显示自身耗时在50毫秒以内。这是一个典型的分布式系统“幽灵延迟”问题。排查时,我们发现链路追踪系统(Zipkin)里只看到了后端服务之间的调用链,从用户点击浏览器到第一个Java服务接收到请求之间的巨大鸿沟——包括浏览器渲染、DNS查询、网络传输、API网关处理——完全是个黑盒。这使得定位问题的根源变得极其低效。在真实项目中,这种割裂的前后端可观测性是无法接受的。

我们的目标是打通这“最后一公里”,将追踪的起点从第一个后端服务前移到用户的浏览器。初步构想是引入一个前端APM(应用性能监控)解决方案,但多数方案过于笨重,侵入性强,并且可能与现有的技术栈产生兼容性问题。一个更务实的方案是:手动为前端应用进行轻量级埋点,使其能够生成并传播符合W3C Trace Context标准的追踪头,让后端Brave/Zipkin生态无缝衔接。

技术选型与架构决策

  1. 后端追踪框架: 选择 Spring Cloud Sleuth 搭配 Brave。在较新的Spring Boot版本中,官方推荐直接整合 OpenTelemetry。但为了更好地控制底层细节并展示其工作原理,我们选择Brave,它作为Zipkin的官方Java客户端,成熟且稳定。我们将手动配置它,以确保它能正确解析W3C Trace Context头。
  2. 前端HTTP客户端: Axios。它是Vue.js生态中事实上的标准,其拦截器(Interceptor)机制为我们注入追踪逻辑提供了完美的切入点。
  3. 追踪上下文标准: W3C Trace Context。这是行业标准,格式为 traceparent 头。它确保了不同语言、不同框架实现的追踪系统可以互操作。后端Brave原生支持,我们只需要在前端正确地生成它。
  4. 整体流程:
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-idparent-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前缀
        },
      },
    },
  },
};

第三步:验证端到端追踪

现在,是时候将所有部分连接起来并验证结果了。

  1. 启动Zipkin:
    docker run -d -p 9411:9411 openzipkin/zipkin
  2. 启动Java服务: 分别启动 user-serviceorder-service
  3. 启动Vue应用:
    npm run serve
  4. 执行操作: 打开浏览器访问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 123

    order-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:

    1. get /user/:id (来自 user-service)

    2. 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)开始时生成一个sessionIdsessionTraceId,并在会话期间的所有操作中,都使用这个ID作为Trace ID,而每次具体操作则生成新的Span ID。这样,我们就可以在Zipkin中通过一个ID串联起某个用户在一段时间内的所有行为,这对于分析用户行为路径和排查复杂问题非常有价值。


  目录