前言#

Distributed Tracing 要解决的核心问题是:一次请求经过多个服务、多个组件,出了问题之后能在哪里、花了多少时间、失败在哪一层。Spring Boot 3.5 + Micrometer Tracing + OTel Bridge 的组合,让绝大多数 span 的生成和传播不需要任何手动代码。

本文分两部分:前半部分把 tracing 跑起来(依赖、配置、自定义埋点、组件接入、上下文传播),后半部分覆盖生产环境的关键决策(Agent 选型、采样策略、PII 处理、规范守护)。


一、自动配置覆盖了什么#

在开始写任何代码之前,先了解 Spring Boot 3.5 开箱即得的范围,避免重复造轮子。

场景 自动生成的 Span 触发条件
HTTP 入站请求 http.server.request spring-boot-starter-webwebflux
RestClient / RestTemplate 出站 http.client.request micrometer-tracing 在 classpath
WebClient 出站 http.client.request spring-boot-starter-webflux
JDBC / Spring Data JPA db.query datasource-micrometer-spring-boot(需额外依赖,见 §五)
Redis(Lettuce) db.redis spring-data-redis + tracing bridge
Kafka producer messaging.publish spring-kafka + observation 开关(见 §五)
Kafka consumer messaging.receive / messaging.process spring-kafka + observation 开关(见 §五)
@Async / TaskExecutor 上下文传播(视 executor 配置) 虚拟线程或显式配置 decorator,见 §六
@Observed 方法 自定义命名 span spring-boot-starter-aop + management.observations.annotations.enabled=true

结论:HTTP、RestClient/WebClient、Redis、@Observed 开箱即得;JDBC 和 Kafka 需要额外依赖或配置开关;@Async 的上下文传播取决于 executor 类型。


二、最小依赖与 YAML 配置#

依赖#

// build.gradle.kts
dependencies {
    // Micrometer Tracing + OTel Bridge:核心,缺一不可
    implementation("io.micrometer:micrometer-tracing-bridge-otel")

    // OTLP 导出:把 span 发给 Collector 或后端
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")

    // @Observed 注解支持
    implementation("org.springframework.boot:spring-boot-starter-aop")
}

micrometer-tracing-bridge-otel 的版本由 Spring Boot BOM 管理,不需要手动指定。

Spring Boot 4.0 说明:Spring Boot 4.0 引入了 spring-boot-starter-opentelemetry,将核心 OTel 依赖打包为单一 starter,简化了接入配置。如果你已经在 4.0+ 上,可以考虑使用该 starter;3.5 及以下版本仍使用本节的方式。参见 Spring Boot Reference: Tracing

YAML 配置#

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0          # 开发环境全量采样,生产环境见 §八
  observations:
    annotations:
      enabled: true             # 开启 @Observed 切面
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
  opentelemetry:
    resource-attributes:
      service.name: ${spring.application.name}
      service.version: ${project.version:unknown}
      deployment.environment: ${spring.profiles.active:dev}

实际 Span 输出示例#

一次 POST /orders 请求在后端看到的 span(JSON 格式,字段已简化):

{
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "parentSpanId": null,
  "name": "POST /orders",
  "kind": "SERVER",
  "startTimeUnixNano": 1712462400000000000,
  "durationNano": 45200000,
  "attributes": {
    "http.request.method": "POST",
    "url.path": "/orders",
    "http.response.status_code": 201,
    "server.address": "order-service"
  },
  "status": { "code": "OK" }
}

同一请求触发的子 span(DB 查询):

{
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "a1b2c3d4e5f60001",
  "parentSpanId": "00f067aa0ba902b7",
  "name": "SELECT order",
  "kind": "CLIENT",
  "attributes": {
    "db.system": "postgresql",
    "db.operation.name": "SELECT",
    "db.collection.name": "order"
  }
}

同一个 traceId 将两个 span 串联,这是 tracing 的核心价值:在后端按 traceId 查询可以看到完整的调用树。


三、Logback Appender:让日志和 Trace 关联#

Tracing 和 Logging 结合后,每条日志都会带上当前请求的 traceIdspanId,排障时可以在日志系统里直接用 trace ID 过滤出一次请求的所有日志。

一段需要额外补上的手动代码#

Spring Boot 3.5 不会自动将 Spring 管理的 OpenTelemetry bean 安装到 Logback appender,需要一段手动代码。

稳定性说明opentelemetry-logback-appender-1.0 的包名含 appender.v1_0,当前仍标注为 alpha,API 可能在后续版本变动。在升级 opentelemetry-instrumentation 版本时需要关注 release notes,确认 appender 接口无破坏性变更。参见 OpenTelemetry Java Instrumentation: Logback

@Component
public class OtelLogAppenderInstaller {

    @Autowired(required = false)
    private OpenTelemetry openTelemetry;

    @PostConstruct
    void install() {
        if (openTelemetry != null) {
            OpenTelemetryAppender.install(openTelemetry);
        }
    }
}

logback-spring.xml#

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
  <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

  <appender name="OpenTelemetry"
            class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
    <captureExperimentalAttributes>true</captureExperimentalAttributes>
    <!-- 开启后,SLF4J Fluent API 的 addKeyValue() 字段会作为 log attributes 一起转发 -->
    <captureKeyValuePairAttributes>true</captureKeyValuePairAttributes>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="OpenTelemetry"/>
  </root>
</configuration>

traceId / spanId 的来源#

Micrometer Tracing 在每次 observation 开始时把当前 span 的 traceIdspanId 写入 MDC(key 名为 camelCase:traceIdspanId)。Logback 的 pattern 可以通过 %X{traceId} 引用,如果日志后端使用 ECS 结构化输出,ECS encoder 会自动将这两个 MDC key 映射为 trace.idspan.id(点分隔 ECS 规范字段)。


四、自定义埋点:四种场景#

自动配置覆盖基础设施层,业务逻辑层需要按需补充。以下四种方式覆盖日常所有埋点需求。

场景一:@Observed 注解——最低成本#

适合服务层方法,无需修改方法体,AOP 自动创建 span:

@Service
public class OrderService {

    @Observed(
        name = "order.create",               // span 名称,建议用点分隔的层级结构
        contextualName = "createOrder",       // 在 APM UI 里显示的友好名称
        lowCardinalityKeyValues = {
            "order.channel", "web"            // 低基数维度,适合出现在 span attribute 和 metrics label
        }
    )
    public Order createOrder(CreateOrderRequest request) {
        // 方法体不变
        return orderRepository.save(new Order(request));
    }
}

lowCardinalityKeyValues 会同时出现在 span attribute 和 Micrometer metrics 的 tag 里,我一般只放低基数值(枚举、状态码、渠道名),尽量不放 orderId 这类高基数字段。

场景二:手动 Observation.start()——控制 span 边界#

适合需要精确控制 span 开始和结束时机的场景,例如包裹一段跨越多个方法调用的业务流程:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final ObservationRegistry registry;

    public PaymentResult processPayment(String orderId, BigDecimal amount) {
        Observation observation = Observation.createNotStarted("payment.process", registry)
            .lowCardinalityKeyValue("payment.channel", "alipay")
            .start();

        try (Observation.Scope scope = observation.openScope()) {
            PaymentResult result = doPayment(orderId, amount);
            return result;
        } catch (Exception e) {
            observation.error(e);   // 记录异常,span status 自动设为 ERROR
            throw e;
        } finally {
            observation.stop();
        }
    }
}

场景三:给当前 span 加业务维度#

适合在已有 span 里追加可检索的业务字段,不创建新 span。

优先用 Micrometer Observation API(后端无关,可按低/高基数分别控制是否写入 metrics):

@Service
@RequiredArgsConstructor
public class InventoryService {

    private final ObservationRegistry registry;

    public void deductStock(String orderId, String skuId, int quantity) {
        Observation current = registry.getCurrentObservation();
        if (current != null) {
            // highCardinalityKeyValue:只写入 span,不写入 metrics(避免高基数撑爆时序数据库)
            current.highCardinalityKeyValue("order.id", orderId);
            current.highCardinalityKeyValue("sku.id", skuId);
        }
        inventoryRepository.deduct(skuId, quantity);
    }
}

直接用 OTel Span API(需要时才用,例如当前上下文里没有 Observation,或需要写入 OTel 特有字段):

import io.opentelemetry.api.trace.Span;

Span.current().setAttribute("inventory.quantity.requested", quantity);

highCardinalityKeyValue 只写入当前 span attribute,不会进入 metrics label,适合 orderId、traceId 这类每次请求都不同的值。lowCardinalityKeyValue 同时写入 span 和 metrics,只放枚举级别的低基数值(渠道名、状态码)。优先使用 Micrometer API 保持后端无关性;只有在 Micrometer 无法满足(如需要写入 OTel span event)时才降级到 Span.current()

场景四:异常路径——标记 span 失败状态#

优先用 Micrometer Observation API(适合持有 Observation 引用的场景,如场景二的手动 span):

// observation.error(e) 同时完成:recordException + setStatus(ERROR)
observation.error(e);

在全局异常处理器里用 OTel Span API(持有不到 Observation 引用时的实用路径):

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderProcessingException.class)
    public ResponseEntity<ErrorResponse> handleOrderProcessingException(
            OrderProcessingException e, HttpServletRequest request) {

        Span currentSpan = Span.current();
        currentSpan.recordException(e);                          // 记录完整堆栈到 span events
        currentSpan.setStatus(StatusCode.ERROR, e.getMessage()); // span 状态标记为 ERROR

        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(new ErrorResponse(e.getCode(), e.getMessage()));
    }
}

recordException() 把异常信息写入 span 的 events 列表(包含堆栈),setStatus(ERROR) 让 APM 的错误率统计正确计入这次请求。通常同时调用两者更完整——只调 recordException() 不改变 span status,只调 setStatus(ERROR) 不记录堆栈,取决于 APM 后端的展示逻辑,可能导致 error 视图或 trace 详情不完整。


五、常见组件的 Tracing 接入#

JDBC / Spring Data#

JDBC tracing 不会spring-jdbc 自动生效,需要单独添加 datasource-micrometer-spring-boot 依赖,它会通过 ObservationProxyDataSource 包装 DataSource,拦截每条 SQL 执行并生成 span。参见 Spring Boot Observability: Data Sources

// build.gradle.kts
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.6")

无需额外 YAML,加入依赖后自动配置生效:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orders

自动生成的 span 字段:

{
  "name": "SELECT order",
  "attributes": {
    "db.system": "postgresql",
    "db.operation.name": "SELECT",
    "db.collection.name": "order",
    "db.namespace": "orders"
  }
}

db.statement(完整 SQL)在 datasource-micrometer 中默认不写入 span,因为 SQL 可能包含字面量参数(含 PII)。如确认安全需要开启,通过 DataSourceObservationAutoConfiguration 提供的配置属性控制,而不是 JPA 或 Hibernate 的日志配置——两者是独立的机制。按我目前的偏好,生产环境里仍然更适合保持默认关闭。

Redis#

添加 spring-data-redis(使用 Lettuce 驱动)后自动生效:

implementation("org.springframework.boot:spring-boot-starter-data-redis")

自动生成的 span 字段:

{
  "name": "GET",
  "attributes": {
    "db.system": "redis",
    "db.operation.name": "GET",
    "network.peer.address": "127.0.0.1",
    "network.peer.port": 6379
  }
}

Redis Pipeline 和批量操作会被合并为单个 span,不会为每条命令单独生成 span,避免高并发下 span 数量爆炸。

Kafka#

Spring Kafka 3.x 已内建 Micrometer Observation 支持,无需额外依赖:

implementation("org.springframework.kafka:spring-kafka")

通过 Spring Boot 顶层属性开启(注意:不是 spring.kafka.producer.properties.*,那是原生 Kafka 客户端参数,不是 Spring Kafka 属性):

spring:
  kafka:
    template:
      observation-enabled: true    # KafkaTemplate producer tracing
    listener:
      observation-enabled: true    # MessageListenerContainer consumer tracing

如果需要更精细的控制,也可以通过代码配置:

// Producer:在 KafkaTemplate bean 上设置
@Bean
public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> pf) {
    KafkaTemplate<String, String> template = new KafkaTemplate<>(pf);
    template.setObservationEnabled(true);
    return template;
}

// Consumer:通过 ContainerCustomizer 设置
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
        ConsumerFactory<String, String> cf) {
    ConcurrentKafkaListenerContainerFactory<String, String> factory =
        new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(cf);
    factory.setContainerCustomizer(
        container -> container.getContainerProperties().setObservationEnabled(true));
    return factory;
}

Producer span 字段:

{
  "name": "order-events publish",
  "kind": "PRODUCER",
  "attributes": {
    "messaging.system": "kafka",
    "messaging.operation.name": "publish",
    "messaging.destination.name": "order-events",
    "messaging.kafka.message.offset": 42
  }
}

Consumer span 字段:

{
  "name": "order-events process",
  "kind": "CONSUMER",
  "attributes": {
    "messaging.system": "kafka",
    "messaging.operation.name": "process",
    "messaging.destination.name": "order-events",
    "messaging.kafka.consumer.group": "order-processor"
  }
}

消费者线程上下文传播:Kafka 消费者在独立线程池里运行,traceId 通过消息 header(W3C traceparent)从 producer 自动传播到 consumer,在 consumer span 里会看到正确的 traceId 和 producer span 作为父级。如果消息是从外部系统(不带 traceparent header)发来的,consumer 会自动生成一个新的根 span。


六、上下文传播边界#

Tracing 上下文(traceId/spanId)需要跨线程、跨服务传播,不同场景的处理方式不同。

跨服务传播(自动)#

Spring Boot 通过 W3C TraceContext(traceparent header)自动完成跨服务传播。RestClientWebClientRestTemplate 发出的出站请求会自动注入当前 span 信息,接收方服务会自动提取并关联到同一条 trace,无需任何代码。

Baggage:跨服务传播业务上下文#

traceparent 只传播 trace/span ID,不携带业务数据。当需要把 tenantIdrequestIduserId 这类标识随 trace 一起传到下游服务时,我更倾向于使用 Baggage——它通过 W3C baggage header 与 traceparent 同步传播,下游无需改动 HTTP 调用代码即可读取。

配置 Baggage 字段(在 application.yaml 里声明,Spring Boot 自动完成传播和 MDC 注入):

management:
  tracing:
    baggage:
      remote-fields:          # 通过 W3C baggage header 传播到所有下游服务
        - x-tenant-id
        - x-request-id
      correlation:
        fields:               # 自动注入 MDC,每条日志都会带上这些字段
          - x-tenant-id
          - x-request-id

配置后,只需在入口 Filter 写入一次,后续跨服务传播和日志注入全部自动完成:

import io.micrometer.tracing.BaggageField;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantContextFilter extends OncePerRequestFilter {

    private static final BaggageField TENANT_ID  = BaggageField.create("x-tenant-id");
    private static final BaggageField REQUEST_ID = BaggageField.create("x-request-id");

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String tenantId  = request.getHeader("X-Tenant-Id");
        String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
                .filter(s -> !s.isBlank())
                .orElse(UUID.randomUUID().toString());

        // 写入 Baggage:Spring Boot 自动通过 W3C baggage header 传播到下游
        // 同时因为 correlation.fields 配置,自动注入 MDC,日志立即可见
        TENANT_ID.updateValue(tenantId);
        REQUEST_ID.updateValue(requestId);

        response.setHeader("X-Request-Id", requestId);
        chain.doFilter(request, response);
        // 无需手动 MDC.remove():Micrometer 在 Observation 结束时自动清理
    }
}

下游服务不需要任何额外代码——X-Tenant-IdX-Request-Id 通过 baggage header 自动到达,Spring Boot 自动提取并写入 MDC。如果需要在业务代码里读取 baggage 值:

// 读取当前上下文的 baggage 值
String tenantId  = BaggageField.getByName("x-tenant-id").getValue();
String requestId = BaggageField.getByName("x-request-id").getValue();

Baggage 使用边界

规则 说明
控制字段数量和大小 baggage 进入 HTTP header,所有字段合计通常限制在 8KB 以内
不放 PII baggage 会随 trace 传播到所有下游,身份证号、手机号等不能放
适合放的内容 tenantIdrequestIdfeatureFlaguserId(脱敏后)
不适合放的内容 高频变化的业务数据(放 span attribute)、大块 payload

虚拟线程与 @Async#

开启虚拟线程后,ContextPropagationAutoConfiguration 会注册上下文传播支持:

spring:
  threads:
    virtual:
      enabled: true

但这不代表所有异步场景都自动传播。官方文档的措辞是"context propagation is supported",不是“所有执行器都无需配置”。具体表现取决于 executor 类型:

  • Spring 管理的 AsyncTaskExecutor(虚拟线程模式):Spring Boot 在 spring.threads.virtual.enabled=true 时会自动创建基于虚拟线程的 AsyncTaskExecutor,配合 ContextPropagationAutoConfiguration@Async 方法的上下文传播通常可以工作
  • 自定义 ThreadPoolTaskExecutor:无论是否开启虚拟线程,都需要显式配置 ContextPropagatingTaskDecorator,否则上下文不会传播

参见 Spring Boot Reference: Observability

传统线程池(需手动配置)#

未使用 Spring 默认 executor 时,需要为 TaskExecutor 显式添加 ContextPropagatingTaskDecorator

@Configuration
public class AsyncConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
        executor.initialize();
        return executor;
    }
}

CompletableFuture 和 Reactor#

CompletableFuture.runAsync() 使用 JVM 公共线程池,ContextPropagatingTaskDecorator 不覆盖这个边界。如果需要在 CompletableFuture 里保留上下文,需要手动传递:

// 在父线程捕获当前上下文
ContextSnapshot snapshot = ContextSnapshotFactory.builder().build().captureAll();

CompletableFuture.runAsync(() -> {
    try (ContextSnapshot.Scope scope = snapshot.setThreadLocals()) {
        // 此处 tracing 上下文已恢复
        log.info("async task"); // 有 traceId
    }
});

Reactor(WebFlux)场景下,Micrometer Context Propagation 库会自动处理 Context 传播,无需手动操作,但需要确保 micrometer-context-propagation 在 classpath 上(Spring Boot Starter WebFlux 已包含)。


七、Agent vs Agentless 选型#

维度 Agentless(Micrometer + OTel Bridge) Agent(opentelemetry-javaagent)
AOT / Native Image 完全兼容 不兼容
Spring Boot 自动配置 深度集成 可能冲突
启动时间 无额外开销 增加 1–3 秒
三方库自动覆盖 依赖 Spring Boot instrumentation 覆盖更广(无需框架支持)
可控性 高(通过 Spring 配置) 低(黑盒字节码增强)
适用场景 新服务、Spring Boot 项目 遗留服务、短期诊断、无法修改代码

推荐:新项目和 Spring Boot 项目默认先用 Agentless(Micrometer)。如果是无法修改源码的遗留服务,或者需要短期诊断某个没有 Spring instrumentation 支持的三方库,再考虑 Agent。


八、Collector 架构与采样策略#

为什么要在应用和后端之间加 Collector#

应用直连 tracing 后端看似简单,但在生产环境里会带来问题:网络抖动导致 span 丢失、后端压力高时应用受阻、无法做批量处理和数据清洗。Collector(OpenTelemetry Collector 或 Grafana Alloy)作为中间缓冲层解决这些问题:

  • 批量与重试:积攒 span 批量发送,网络抖动时自动重试
  • 尾采样:在 Collector 层收集完整 trace 后再决定是否保留(头采样只能基于当前 span 做决策)
  • 属性处理:在数据出口统一做脱敏、字段删除、资源属性注入
  • 多路转发:一份数据同时发给多个后端(如同时送 Tempo 和 Jaeger)

配置方式:应用把 management.otlp.tracing.endpoint 指向本地 Collector,由 Collector 决定如何转发:

management:
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces  # 生产
      # endpoint: http://localhost:4318/v1/traces     # 本地开发

采样策略#

头采样(Head-based):在请求进入时即决定是否采样,开销最低,但无法保留"所有错误请求"——请求出错时可能已经被丢弃。

management:
  tracing:
    sampling:
      probability: 0.1   # 10% 采样率,生产常用起点

尾采样(Tail-based):在 Collector 层收集完整 trace 后再决定是否保留,可以实现"错误请求 100% 保留 + 正常请求 10% 采样"的策略:

# OpenTelemetry Collector config(otel-collector.yaml)
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors-policy
        type: status_code
        status_code: { status_codes: [ERROR] }   # 错误请求全部保留
      - name: slow-policy
        type: latency
        latency: { threshold_ms: 1000 }           # 慢请求(>1s)全部保留
      - name: probabilistic-policy
        type: probabilistic
        probabilistic: { sampling_percentage: 10 } # 其余按 10% 采样

生产采样率建议:没有普遍适用的固定数字。起点是 10%(probability: 0.1),然后根据后端存储成本和排障需求调整。高流量服务(> 1000 RPS)通常需要更低的采样率,低流量服务可以适当提高。无论如何,通过 Collector 的尾采样策略保证错误和慢请求不被丢弃,比单纯调高采样率更有价值。


九、PII 处理:不该进 Span 的数据#

Span attribute 会在 tracing 后端长期存储,且通常比业务数据库的访问控制宽松。以下数据不能直接写入 span:

  • 手机号、身份证号、银行卡号
  • 明文密码、Token、API Key
  • 完整的用户真实姓名(视合规要求)
  • 未脱敏的完整 userId(取决于数据分类)

高基数问题userIdorderId 这类字段如果放进 Micrometer metrics 的 tag(lowCardinalityKeyValues),会导致时序数据库的 series 数量爆炸。放在 span attribute 里是可以的(每个 span 是独立事件),但放进 @ObservedlowCardinalityKeyValues 时要明确区分。

// 正确:orderId 通过 highCardinalityKeyValue 只进 span,不进 metrics
Observation current = registry.getCurrentObservation();
if (current != null) {
    current.highCardinalityKeyValue("order.id", orderId);
}

// 禁止:动态值(如 userId)作为 lowCardinalityKeyValue,会进入 metrics tag 撑爆时序数据库
// lowCardinalityKeyValues 只放静态枚举值,如 "order.channel", "web"
@Observed(name = "order.create", lowCardinalityKeyValues = {"order.channel", "web"})

Collector 层脱敏:在 OTel Collector 里配置 attributes processor,统一删除或哈希处理敏感字段,比在每个服务里逐个处理更可靠:

# otel-collector.yaml
processors:
  attributes/redact-pii:
    actions:
      - key: user.phone
        action: delete
      - key: user.id
        action: hash     # 保留关联能力,但不暴露原始值

十、ArchUnit:防止规范退化#

团队约定"不手动构建 OTel SDK provider"靠 code review 很难持续执行,用 ArchUnit 在 CI 里自动拦截更可靠:

// arch-tests/build.gradle.kts
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
@AnalyzeClasses(packages = "com.example")
public class OtelArchRules {

    // 禁止手动构建 OTel SDK Provider(Spring Boot 自动配置负责)
    @ArchTest
    static final ArchRule noManualSdkConstruction =
        noClasses()
            .should().dependOnClassesThat()
                .haveFullyQualifiedName("io.opentelemetry.sdk.trace.SdkTracerProvider")
            .orShould().dependOnClassesThat()
                .haveFullyQualifiedName("io.opentelemetry.sdk.logs.SdkLoggerProvider")
            .orShould().dependOnClassesThat()
                .haveFullyQualifiedName("io.opentelemetry.sdk.metrics.SdkMeterProvider")
            .because("Spring Boot 3.5 auto-configures the OTel SDK; manual construction bypasses auto-configuration and creates duplicate providers");

    /**
     * 禁止直接调用 Span.current(),但为 GlobalExceptionHandler 开放白名单。
     *
     * GlobalExceptionHandler 是合理的例外:异常处理器里没有活跃的 Observation 引用,
     * Span.current() 是标记 span 失败状态的唯一实用路径(见 §四 场景四)。
     */
    @ArchTest
    static final ArchRule noDirectSpanCurrentUsage =
        noClasses()
            .that(new DescribedPredicate<JavaClass>("not a GlobalExceptionHandler") {
                @Override
                public boolean test(JavaClass input) {
                    return !input.getSimpleName().endsWith("GlobalExceptionHandler");
                }
            })
            .should(new ArchCondition<JavaClass>("call Span.current()") {
                @Override
                public void check(JavaClass javaClass, ConditionEvents events) {
                    javaClass.getMethodCallsFromSelf().stream()
                        .filter(call ->
                            "io.opentelemetry.api.trace.Span".equals(call.getTargetOwner().getFullName())
                            && "current".equals(call.getName()))
                        .forEach(call -> events.add(SimpleConditionEvent.violated(javaClass,
                            javaClass.getName() + " calls Span.current() at " + call.getSourceCodeLocation())));
                }
            })
            .because("Use ObservationRegistry/Observation API to stay backend-agnostic. " +
                     "Only GlobalExceptionHandler may call Span.current() when no Observation reference is available.");
}

这两条规则在 ./gradlew build 时自动执行。第一条防止手动初始化 OTel SDK;第二条强制使用 Micrometer ObservationRegistry,同时为 GlobalExceptionHandler 开放白名单,因为异常处理器里没有活跃的 Observation 引用,Span.current() 是标记 span 失败的唯一实用路径。


参考资料#