实现从Playwright E2E测试到Jaeger后端链路的端到端追踪关联


一个端到端(E2E)测试失败了。CI/CD流水线亮起红灯,日志里只有一条冰冷的信息:“POST /api/users failed with status 500”。接下来呢?是前端的请求体构造错误,还是API网关出了问题?是某个下游微服务超时,还是数据库连接池耗尽?在复杂的分布式系统中,定位这种问题的根源,往往意味着在海量的日志和指标中捞针,耗时且低效。这就是我们团队之前面临的日常困境。

我们的目标很明确:当一个Playwright E2E测试失败时,必须能立即、精准地拿到与之对应的后端全链路追踪ID。这样,开发者就能直接在Jaeger中打开该次失败请求的完整火焰图,从API入口到数据库查询,所有环节的耗时、错误、日志一目了然。这套机制的核心,是在测试执行层(Playwright)和后端服务层(Micronaut)之间建立一座可靠的上下文桥梁。

技术栈与架构概览

我们的选型基于务实的考量:

  • Micronaut 4: 选择它是因为其基于AOT(Ahead-of-Time)编译,提供了极快的启动速度和更低的内存占用,这在云原生和Serverless环境中是显著优势。其内置的依赖注入和对OpenTelemetry的良好支持,是实现追踪的关键。
  • Playwright: 作为E2E测试框架,其强大的网络拦截能力和稳定的API是注入追踪上下文的基础。
  • Jaeger: 作为OpenTelemetry兼容的分布式追踪系统,其开放性和强大的可视化能力使其成为标准选择。
  • MariaDB: 成熟、稳定、高性能的关系型数据库,足以应对绝大多数业务场景。

整个系统的交互流程如下:

sequenceDiagram
    participant P as Playwright Test
    participant M as Micronaut Service
    participant J as Jaeger Collector
    participant D as MariaDB

    P->>M: 1. 发起HTTP请求 (携带 X-E2E-Trace-ID)
    Note over M: Micronaut HTTP Filter 拦截请求
    M->>M: 2. 读取Header, 将ID设为当前Span的Tag
    M->>J: 3. 正常上报Trace Span (已包含Tag)
    M->>D: 4. 执行数据库操作
    D-->>M: 5. 返回结果
    M-->>J: 6. 上报DB操作的Span
    M-->>P: 7. 返回HTTP响应
    Note over P: 测试断言失败, 捕获并记录 X-E2E-Trace-ID

第一步:配置Micronaut后端以集成Jaeger

一切的起点是让Micronaut服务能够生成追踪数据并上报给Jaeger。这需要引入相应的依赖并进行细致的配置。

1. build.gradle.kts 依赖项

在真实项目中,我们需要精确地管理依赖。这里我们引入Micronaut对OpenTelemetry的封装、Jaeger导出器以及MariaDB驱动。

// build.gradle.kts

dependencies {
    // Micronaut核心
    implementation("io.micronaut:micronaut-http-server-netty")
    implementation("io.micronaut.serde:micronaut-serde-jackson")
    
    // 数据库与数据访问
    implementation("io.micronaut.data:micronaut-data-jdbc")
    implementation("io.micronaut.sql:micronaut-sql-jdbc-hikari")
    runtimeOnly("org.mariadb.jdbc:mariadb-java-client")

    // 可观测性:OpenTelemetry & Jaeger
    implementation("io.micronaut.micrometer:micronaut-micrometer-core")
    implementation("io.micronaut.micrometer:micronaut-micrometer-tracing")
    implementation("io.opentelemetry:opentelemetry-exporter-jaeger")

    // 日志
    runtimeOnly("ch.qos.logback:logback-classic")
}

2. application.yml 核心配置

配置是连接所有组件的胶水。这里的配置需要考虑生产环境,例如采样率和Agent/Collector的地址。一个常见的错误是本地开发时使用全采样,然后忘记在生产环境调整,导致不必要的性能开销和存储压力。

# application.yml

micronaut:
  application:
    name: user-service
  server:
    port: 8080

# 数据源配置
datasources:
  default:
    url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:users_db}
    username: ${DB_USER:user}
    password: ${DB_PASSWORD:password}
    driverClassName: org.mariadb.jdbc.Driver
    schema-generate: CREATE_DROP # 开发环境使用,生产环境严禁

# 追踪配置
tracing:
  enabled: true
  sampler:
    probability: 1.0 # 在生产中应调整为更小的值,例如 0.1

# Jaeger导出器配置
otel:
  traces:
    exporter: jaeger
  exporter:
    jaeger:
      # 通常连接到部署在同一节点或网络中的Jaeger Agent
      # endpoint: http://localhost:14250
      # 如果直接连接到Collector,使用下面的配置
      endpoint: http://jaeger-collector.observability:14268/api/traces

第二步:构建具备追踪能力的业务代码

为了让追踪有意义,我们需要一个简单的业务场景。假设我们有一个用户服务,提供创建和查询用户的接口。

1. 数据库表结构

一个简单的用户表。

-- DDL for MariaDB
CREATE TABLE IF NOT EXISTS users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

2. Micronaut Data Repository

Micronaut Data通过在编译期生成实现,避免了运行时代理的开销。这里的@Repository注解会自动为我们处理数据库交互的底层实现。

// src/main/java/com/example/repository/UserRepository.java

import io.micronaut.data.annotation.Repository;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import com.example.domain.User;

import java.util.Optional;

@Repository
@JdbcRepository(dialect = Dialect.MARIADB)
public interface UserRepository extends CrudRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

3. 用户服务控制器

这是API的入口。Micronaut的HTTP层会自动被OpenTelemetry工具化,为每个请求创建根Span。

// src/main/java/com/example/controller/UserController.java

import com.example.domain.User;
import com.example.repository.UserRepository;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Optional;

@Controller("/users")
public class UserController {

    private static final Logger LOG = LoggerFactory.getLogger(UserController.class);
    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Post
    public HttpResponse<User> createUser(@Body User user) {
        // 模拟一个潜在的错误,用于测试
        if ("error_user".equals(user.getUsername())) {
            LOG.error("Simulating a failure for user: {}", user.getUsername());
            return HttpResponse.serverError();
        }
        User savedUser = userRepository.save(user);
        LOG.info("User created with id: {}", savedUser.getId());
        return HttpResponse.created(savedUser);
    }

    @Get("/{username}")
    public HttpResponse<User> findUserByUsername(@PathVariable String username) {
        Optional<User> user = userRepository.findByUsername(username);
        return user.map(HttpResponse::ok)
                   .orElse(HttpResponse.notFound());
    }
}

至此,一个标准的、可被追踪的Micronaut服务已经完成。任何对/users端点的调用,都会在Jaeger中生成一条包含HTTP请求信息和JDBC查询信息的链路。但这还不够,我们无法将它与特定的E2E测试关联起来。

第三步:实现核心关联机制

这是整个方案的枢纽:在Playwright中生成一个唯一的测试上下文ID,通过HTTP头传递它,然后在Micronaut中捕获并附加到当前的Trace Span上。

1. Playwright测试端注入自定义Header

我们为Playwright的APIRequestContext创建一个封装,确保每个E2E测试会话都携带一个唯一的ID。这个ID的格式最好是test-run-uuid-test-case-name,便于识别。

// tests/api.helper.ts

import { APIRequestContext, request } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';

// 为整个测试运行生成一个唯一的ID
const testRunId = uuidv4();

export async function createApiContext(testName: string): Promise<APIRequestContext> {
  // 为每个测试用例生成一个特定的追踪ID
  const traceId = `e2e-${testRunId}-${testName.replace(/\s+/g, '-')}`;
  console.log(`[E2E-Trace-ID] for test "${testName}": ${traceId}`);

  const apiContext = await request.newContext({
    baseURL: 'http://localhost:8080',
    extraHTTPHeaders: {
      // 这是我们自定义的头,用于传递追踪上下文
      'X-E2E-Trace-ID': traceId,
      'Content-Type': 'application/json',
    },
  });

  // 在测试清理阶段自动销毁上下文
  // test.afterAll(async () => {
  //   await apiContext.dispose();
  // });
  
  return apiContext;
}

// 在测试失败时,将此ID打印出来是至关重要的
export function getTraceIdFromContext(context: APIRequestContext): string | undefined {
    const headers = context.storageState().then(s => s.origins[0]?.localStorage?.find(item => item.name === '_extraHTTPHeaders'));
    // 这是一种简化的获取方式,实际项目中可能需要更健壮的实现
    // 在Playwright的上下文中直接获取 `extraHTTPHeaders` 并不直接,
    // 最简单的方式是在创建时就将其存储在一个变量中。
    // 为了演示,我们假设可以从某个地方获取到它。
    // 一个更实际的做法是在 createApiContext 返回一个包含 context 和 traceId 的对象。
    return `e2e-trace-id-placeholder`; // 简化演示
}

2. Micronaut端创建HTTP Filter来捕获Header

Micronaut的HTTP Filter是实现跨领域关注点(如认证、日志、追踪)的完美工具。我们将创建一个Filter,它会在请求处理管道的早期执行。

这个Filter的职责是:

  1. 检查是否存在 X-E2E-Trace-ID 头。
  2. 如果存在,获取当前的OpenTelemetry Span
  3. 将这个ID作为tag(在OpenTelemetry中称为Attribute)附加到Span上。
// src/main/java/com/example/filter/E2ETraceFilter.java

import io.micronaut.core.annotation.Order;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.HttpServerFilter;
import io.micronaut.http.filter.ServerFilterChain;
import io.opentelemetry.api.trace.Span;
import org.reactivestreams.Publisher;

@Filter("/**")
@Order(-10) // 确保此Filter在其他业务Filter之前执行
public class E2ETraceFilter implements HttpServerFilter {

    private static final String TRACE_ID_HEADER = "X-E2E-Trace-ID";
    private static final String TRACE_ID_TAG = "e2e.trace.id";

    @Override
    public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
        request.getHeaders().get(TRACE_ID_HEADER, String.class).ifPresent(traceId -> {
            // 获取由Micronaut自动创建的当前Span
            Span currentSpan = Span.current();
            if (currentSpan != null && currentSpan.isRecording()) {
                // 将E2E测试的追踪ID作为一个属性附加到当前Span
                // 这样在Jaeger UI中就可以根据这个tag进行搜索
                currentSpan.setAttribute(TRACE_ID_TAG, traceId);
            }
        });
        
        return chain.proceed(request);
    }
}

这个Filter非常轻量,但作用巨大。它无缝地将测试域的上下文信息注入到了可观测性域。e2e.trace.id这个tag将成为我们在Jaeger中大海捞针的灯塔。

第四步:编写并执行关联的E2E测试

现在,我们可以编写一个Playwright测试用例,它使用我们创建的api.helper.ts来发起请求,并模拟一个失败场景。

// tests/user.spec.ts

import { test, expect, APIRequestContext } from '@playwright/test';
import { createApiContext } from './api.helper';

test.describe('User API', () => {
  let apiContext: APIRequestContext;
  let traceId: string;

  test.beforeAll(async ({}, testInfo) => {
    // 更好的做法是将 traceId 和 apiContext 一起管理
    const testName = 'user-creation-flow';
    traceId = `e2e-${Date.now()}-${testName}`;
    console.log(`[E2E-Trace-ID] for test "${testName}": ${traceId}`);

    apiContext = await request.newContext({
        baseURL: 'http://localhost:8080',
        extraHTTPHeaders: {
          'X-E2E-Trace-ID': traceId,
          'Content-Type': 'application/json',
        },
    });
  });

  test.afterAll(async () => {
    await apiContext.dispose();
  });

  test('should create a new user successfully', async () => {
    const response = await apiContext.post('/users', {
      data: {
        username: 'testuser_' + Date.now(),
        email: `test_${Date.now()}@example.com`
      }
    });
    expect(response.status()).toBe(201);
    const body = await response.json();
    expect(body).toHaveProperty('id');
  });

  test('should fail to create a user with a specific name and provide trace id', async () => {
    const response = await apiContext.post('/users', {
      data: {
        username: 'error_user',
        email: '[email protected]'
      }
    });

    try {
      expect(response.status()).toBe(201); // 这个断言会失败
    } catch (error) {
      console.error(`\n--- TEST FAILED ---`);
      console.error(`E2E Test failed as expected.`);
      console.error(`Search for this Trace ID in Jaeger to investigate the backend failure:`);
      console.error(`---> ${traceId} <---`);
      console.error(`-------------------\n`);
      throw error; // 重新抛出错误,让测试状态变为失败
    }
  });
});

当运行这个测试套件时:

  1. 第一个测试会成功通过。
  2. 第二个测试会失败,因为我们期望状态码201,但服务器返回了500。
  3. catch块中,关键的traceId会被打印到CI/CD的控制台日志中。

一个开发者的调试流程就变成了:

  1. 在CI/CD日志中看到测试失败。
  2. 复制输出的 ---> e2e-xxx-xxx <--- ID。
  3. 打开Jaeger UI,在“Tags”搜索框中输入 e2e.trace.id=e2e-xxx-xxx
  4. 立即定位到那唯一的一次失败请求。点击进入,就能看到完整的调用链,发现是UserController中记录了错误日志,并直接返回了500,而数据库层面甚至没有被调用。问题定位时间从几小时缩短到几分钟。

方案的局限性与未来展望

这套机制虽然极大地提升了调试效率,但也并非完美。
首先,当前流程仍需要人工介入(复制粘贴ID)。一个更高级的实现是,在测试失败后,通过CI/CD脚本调用Jaeger的API,自动抓取该trace的JSON数据或截图,并作为测试报告(如Allure Report)的一部分进行展示,从而实现完全自动化的关联。

其次,X-E2E-Trace-ID是一个自定义头。虽然在内部系统中可行,但在需要穿越多层网关和代理的复杂环境中,需要确保这个头不会被中间层 stripping掉。采用标准的W3C Trace Context头(traceparent)进行扩展可能是更具兼容性的方案,但这会增加实现的复杂性,需要在测试端正确地生成和管理trace-idspan-id

最后,这种关联的价值依赖于后端服务有足够完善的追踪埋点。如果一个关键的异步操作、消息队列消费或第三方服务调用没有被正确地传递和记录追踪上下文,那么链路就会在某个地方中断,诊断价值将大打折扣。因此,建立完善的可观测性文化和规范,比工具本身更为重要。


  目录