Spring Boot 3.5 Tracing 实践记录:从接入到生产观察
目录
前言#
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-web 或 webflux |
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 结合后,每条日志都会带上当前请求的 traceId 和 spanId,排障时可以在日志系统里直接用 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 的 traceId 和 spanId 写入 MDC(key 名为 camelCase:traceId、spanId)。Logback 的 pattern 可以通过 %X{traceId} 引用,如果日志后端使用 ECS 结构化输出,ECS encoder 会自动将这两个 MDC key 映射为 trace.id 和 span.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)自动完成跨服务传播。RestClient、WebClient、RestTemplate 发出的出站请求会自动注入当前 span 信息,接收方服务会自动提取并关联到同一条 trace,无需任何代码。
Baggage:跨服务传播业务上下文#
traceparent 只传播 trace/span ID,不携带业务数据。当需要把 tenantId、requestId、userId 这类标识随 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-Id 和 X-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 传播到所有下游,身份证号、手机号等不能放 |
| 适合放的内容 | tenantId、requestId、featureFlag、userId(脱敏后) |
| 不适合放的内容 | 高频变化的业务数据(放 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(取决于数据分类)
高基数问题:userId、orderId 这类字段如果放进 Micrometer metrics 的 tag(lowCardinalityKeyValues),会导致时序数据库的 series 数量爆炸。放在 span attribute 里是可以的(每个 span 是独立事件),但放进 @Observed 的 lowCardinalityKeyValues 时要明确区分。
// 正确: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 失败的唯一实用路径。
参考资料#
- Spring Boot Reference: Tracing
- Spring Boot Reference: Baggage
- Spring Boot Reference: Observability
- Micrometer Observation Documentation
- Spring Kafka Reference: Micrometer Observation
- OpenTelemetry Semantic Conventions
- OpenTelemetry Collector: Tail Sampling Processor
- OpenTelemetry Java Instrumentation: Logback Appender
- ArchUnit User Guide
- Spring Boot 3.5 + OpenTelemetry 最佳实践(Demo 项目)
- 日志开发规范与最佳实践