2893 字
14 分钟
记一次引入OTel + Elastic APM Server后的Empty span issue排查

1. Issue#

开启OTel Trace跟踪事务后,因为项目中有用到ElasticSearch,考虑到适配方面的因素,我又引入了Elastic APM Server作为Trace的可视化UI展示项目的链路追踪。

本以为是一次平平无奇的选型,没想到在查看Trace后意外的发现 HTTP 所有事务均没有挂载到span节点,全都是空心span

具体表现指标为:

transaction.span_count.started:0
transaction.span_count.dropped:0

怎么说呢,现在觉得自己挺蠢的,说来惭愧,被LLM推动着走,没有及时注意到有了OTel后为什么还要使用APM Agent的问题。

后续最好将基本概念掌握了再推进,好在浪费的时间也不多。

首先说一下结论:既然选择了OTel,那就应该让 OTel 通过 OTLP 上报到 Elastic APM Server,而不是再在中间插入一个 APM Java Agent传递到Elastic APM Server,这就是这个问题最核心的根源。

还是记录一下排查的过程吧。

在开始Troubleshooting之前,先给出我pom.xml中有用的信息:

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<opentelemetry-instrumentation.version>2.9.0-alpha</opentelemetry-instrumentation.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Tracing -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
</dependencies>

micrometer-tracing-bridge-otel提供MicrometerOTel 桥接,提供 Tracer/Span

当然,仅仅靠这个是无法完成output的,后面还要有OTLP依赖,不过当时的我就只配了这个依赖,选择的链路是:

OTel -> APM Java Agent-> Elastic APM Server

但实际上应该选用的链路是:

OTel->OTLP->Elastic APM Server

这个暂且不表,记录下我到底是怎么才意识到链路的转换。

所以说,让我不看文档在这里瞎费劲。

2. Troubleshooting#

POST /approval/instance/page接口为例,需要先确认一下到底是什么情况。

2.1 数据库是否真的有参与?#

MyBatis 打印了 SQL 与返回行数,说明数据库调用确实发生。

正常来说jdbc应该会被APM Agent自动劫持并且带上span标记以记录耗时,但是我的span很明显并没有挂载上这个指标,为什么呢?

2.2 Elastic APM Server的Agent是否正常运行?#

检查 JVM 启动参数,发现包含:

  • -javaagent:elastic-apm-agent-1.55.3.jar
  • 多个 -Delastic.apm.*(含 trace_methodsspan_min_duration=0ms 等)

基本上想到的JVM启动参数都填进去了,但其实这个时候我回过神来就觉得很不对劲,这么多参数在JVM启动时注入,这玩意儿在我眼里非常的黑箱+不可控。

2.3 查看APM Agent的日志#

JVM启动带上日志参数,重新启动:

-Delastic.apm.log_level=TRACE
-Delastic.apm.log_file=/tmp/elastic-apm.log

关注点:

  • HTTP 事务是否创建
  • span 是否创建
  • span 是否挂在 HTTP 事务下

发现:

  • HTTP 事务确实有创建(startTransaction 'POST /approval/instance/page'

  • trace_methods 触发的业务方法 开启了独立事务

  • 同一时刻存在 OTel Context激活(OTelBridgeContext

Terminal window
// APM Agent 创建了 HTTP 事务
... startTransaction 'POST /approval/instance/page' 00-6c2a8f...-2c68c0...-01
// 但紧接着同一请求线程里 OTel 上下文被激活,traceId没变,spanId悄然变更:从2c68c0变成了c256a2
// 从这一刻起,线程的当前父级变成了 OTel c256a2,APM Agent开启的2c68c0不再活跃。
... Activating OTelBridgeContext[{opentelemetry-trace-span-key=SdkSpan{traceId=6c2a8f..., spanId=c256a2..., kind=SERVER}}]
// Agent代理的trace_methods找不到自己开启的2c68c0,于是触发了独立事务,traceId HTTP 事务不同。
// 主要体现在'POST /approval/instance/page'与'ApprovalInstanceServiceImpl#getApprovalInstancesPage'
... startTransaction 'ApprovalInstanceServiceImpl#getApprovalInstancesPage' 00-ad40a3...-5b8b09...-01

上面的内容里面,Https事务以及trace_methods事务的跟踪都是APM Agent创建的,中间夹杂着一条OTelBridgeContext,看对应的traceId可以发现,OTelAPM Agent在获取同一个traceId的上下文。

APM AgentThreadLocalContextOTel 也用 ThreadLocalContext(通过 ContextStorage),两者都想当”当前活跃的 parent span,于是后激活的 OTel Context 覆盖了 APM Agent 的Context

也就是说,其实OTelAPM Agent本身就已经分道扬镳了,OTel加的span不会挂载在APM Agent开启的独立事务上,后续OTel挂载的,以及APM Agenttrace_method开启的span,虽然身处同一个线程,但都**没有机会挂载回 APM Agent 最开始创建的那个 HTTP 事务的上下文(Context)里,所以最终从APM Server的看到的事务里面永远只能看到一个空心span

后面APM Agent自动读取jdbc之类的级别的内容自然一样挂载在OTelspanId下方,跟OTel最后一齐被忽略了,有采集,但是完全没有推送到Elastic APM Server

我们可以愉快地确定APM Agent本身与OTel间产生了矛盾,直接去问一问LLM即可获得我应该启用OTLP的结论。

而且项目里面的跟踪完全是基于OTel,下面是Trace Aspect的示例:

/**
* 事务监控切面
* 使用 AOP 统一管理(耗时、异常分类、Trace标签)
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class TransactionMonitorAspect {
private final Tracer tracer; // 自动注入基于micrometer的trace
91 collapsed lines
// 慢事务阈值 (毫秒),可配置化
private static final long SLOW_TX_THRESHOLD = 1000L;
@Around("@annotation(transactional)")
public Object auditTransaction(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable {
long start = System.currentTimeMillis();
String methodSignature = joinPoint.getSignature().toShortString();
boolean isSuccess = false;
// 获取当前的 Span (Micrometer 会自动关联当前线程的 Span)
Span currentSpan = tracer.currentSpan();
try {
// --- 执行业务逻辑 (Spring 事务代理会在此处介入) ---
Object result = joinPoint.proceed();
isSuccess = true;
return result;
} catch (Throwable ex) {
// --- 异常处理逻辑 ---
handleException(currentSpan, ex);
throw ex; // 必须抛出,否则事务不会回滚
} finally {
// --- 耗时统计逻辑 ---
long duration = System.currentTimeMillis() - start;
// 往链路追踪系统(Jaeger)里打标签
if (currentSpan != null) {
tagSpan(currentSpan, isSuccess);
}
// 打印日志
logTransaction(methodSignature, duration, isSuccess);
}
}
/**
* 处理异常并分类
* Spring 的 DataAccessException 体系是处理 DB 错误的行业标准
*/
private void handleException(Span span, Throwable ex) {
if (span == null) return;
span.error(ex); // 标记 Span 为红色错误状态
String errorType = "BIZ_ERROR"; // 默认为业务错误
// 判断是否为数据库层面的系统错误
if (ex instanceof DataAccessException) {
if (ex instanceof ConcurrencyFailureException) {
// 死锁、乐观锁冲突、获取连接超时
errorType = "DB_CONCURRENCY";
} else {
// SQL语法错误、约束冲突、连接断开等
errorType = "DB_SYSTEM";
}
}
// 添加有助于排查的 Tag
span.tag("tx.error_type", errorType);
span.tag("tx.exception", ex.getClass().getSimpleName());
}
/**
* 往 Span 中添加业务元数据
*/
private void tagSpan(Span span, boolean isSuccess) {
if (!isSuccess) {
// 如果想在 Jaeger 搜索栏里通过 tag 搜失败的事务,可以加这个
span.tag("tx.status", "rollback");
}
}
/**
* 打印日志策略
* 生产环境通常不开 DEBUG,所以只有慢事务或失败事务才会出现在日志里
*/
private void logTransaction(String method, long duration, boolean isSuccess) {
if (!isSuccess) {
// 失败时记录 Error
log.error("TX_ROLLBACK | Method: {} | Cost: {}ms", method, duration);
return;
}
if (duration > SLOW_TX_THRESHOLD) {
// 慢事务记录 Warn
log.warn("TX_SLOW | Method: {} | Cost: {}ms", method, duration);
} else {
// 正常事务记录 Debug (生产环境通常忽略)
log.debug("TX_COMMIT | Method: {} | Cost: {}ms", method, duration);
}
}
}

2.4 在切换到OTLP推送之前……#

嗯,通过上面的日志,然后我去OTel那边查了一下,发现APM Agent自动获取的东西还真不少。

entryAPM AgentOTel SDK(无 agent)备注
HTTP 入口自动依赖框架自动配置(Micrometer/OTel)Spring Boot 框架下可以自动获取
JDBC自动需要手动opentelemetry-jdbc 包装 DataSource
线程池/异步自动需要手动/库支持TraceAwareExecutor
Redis/HTTP Client自动需对应 instrumentation取决于库与接入方式
自定义业务 span可配置/注解SDK 手动创建业务侧更可控
Context Propagation自动需 SDK + 正确拦截器需确保 header 注入/提取

最后的Context Propagation在没有明确需求的时候我决定不介入(这个主要是跨进程的跟踪,用于微服务),RedisHTTP Client也应该是按需注入。

HTTP Client的跟踪主要解决的是如果项目里面调了某个服务的API,以HTTP GET / POST请求的形式获取内容,需要观测其可靠性以及返回时间的时候使用。

补充我已经实现的TraceAwareExecutor:

@Component
@RequiredArgsConstructor
public class TraceAwareExecutor {
private final Tracer tracer;
/**
* 通用带Tracer跟踪并发执行器
*/
public <K, V, R> List<R> executeParallel(Map<K, V> inputs, BiFunction<V, K, R> action) {
Span parentSpan = tracer.currentSpan();
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<StructuredTaskScope.Subtask<R>> tasks = inputs.entrySet().stream()
.map(entry -> scope.fork(() -> {
// 进行span注入处理,否则子线程的每一次执行其spanTag都会成为一个单独的tag统计,而不是在并发内作为分支统计
try (Tracer.SpanInScope ws = tracer.withSpan(parentSpan)) {
return action.apply(entry.getValue(), entry.getKey());
}
}))
.toList();
scope.join();
scope.throwIfFailed();
return tasks.stream().map(StructuredTaskScope.Subtask::get).toList();
} catch (Exception e) {
// 抛出去让调用方处理
throw new RuntimeException("Parallel execution failed", e);
}
}
}

2.5 实现OTLP的引入以及JDBC的跟踪#

Maven#

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<opentelemetry-instrumentation.version>2.9.0-alpha</opentelemetry-instrumentation.version>
</properties>
<dependencies>
15 collapsed lines
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Tracing -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-jdbc</artifactId>
<version>${opentelemetry-instrumentation.version}</version>
</dependency>
</dependencies>

application.yml#

management:
tracing:
sampling:
probability: ${OTEL_TRACING_SAMPLING_PROBABILITY:0.1} # 采样率,范围0.0~1.0
otlp:
tracing:
endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:http://localhost:8200/v1/traces}
transport: http
headers:
Authorization: ${OTEL_EXPORTER_OTLP_HEADERS_AUTHORIZATION:} # 如果你的Elastic APM Server开启了AUTH

Config#

@Configuration
public class OtelJdbcConfig {
@Bean
public BeanPostProcessor otelDataSourceBeanPostProcessor(ObjectProvider<OpenTelemetry> openTelemetryProvider) {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DataSource dataSource) {
if (bean instanceof OpenTelemetryDataSource) {
return bean;
}
OpenTelemetry openTelemetry = openTelemetryProvider.getIfAvailable();
if (openTelemetry == null) {
return new OpenTelemetryDataSource(dataSource);
}
return new OpenTelemetryDataSource(dataSource, openTelemetry);
}
return bean;
}
};
}
}

3. 总结#

也没什么好总结的,就是APM AgentOTel的配合问题,但我觉得出这样的问题也好,如果我真的稀里糊涂地用APM Agent+ OTel的方案的话,可能后面还是会踩到坑后转OTLP转发吧。

文档还是很重要,直接上手就容易出这样的乌龙。

至此,该采集的jdbc也采集了,别的自己按需使用OTelSDK提供的方法采集即可。

3.1 修复前图示:#

issue

transaction-details

3.2 修复后图示:#

result

4. 补充ThreadLocal、Context、ContextStorage 概念#

4.1 ThreadLocal(Java 原生机制)#

每个线程独立拥有一份变量副本的存储机制。

// 基本用法
ThreadLocal<String> userId = new ThreadLocal<>();
// 线程A
userId.set("user-123");
userId.get(); // → "user-123"
// 线程B(完全隔离)
userId.get(); // → null

图示:

┌─────────────────────────────────────────────┐
│ JVM │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Thread-A │ │ Thread-B │ │
│ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │userId │ │ │ │userId │ │ │
│ │ │"123" │ │ │ │ null │ │ │
│ │ └───────┘ │ │ └───────┘ │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────┘
↑ 互不干扰,线程安全

4.2 Context#

// Context 是一个在线程内不可变的上下文记录
Context ctx = Context.current();
// 以OpenTelemetry为例,里面装着:
// - 当前 Span(谁是我的父节点?)
// - Baggage(跨服务传递的业务数据)
// - 其他自定义数据
Span span = Span.fromContext(ctx); // 取出当前 Span

它主要就是带着完整的链路信息,让后续的链路知晓前面的所有应该注意到的内容:

HTTP 请求进来
┌─────────────────────────────────────────────────────┐
│ Context: { traceId: "abc", spanId: "001" } │
│ │ │
│ ▼ │
│ Controller.handleRequest() │
│ │ │
│ ▼ 传递 Context │
│ Service.doSomething() │
│ │ │
│ ▼ 继续传递 │
│ Repository.query() ← 这里创建的 span 知道父级是谁 │
└─────────────────────────────────────────────────────┘

4.3 ContextStorage(存放 Context 的地方)#

决定 Context 存在哪里 以及 如何获取当前Context。

// 调用这个时,背后就是 ContextStorage 在工作
Context current = Context.current();

默认实现 = ThreadLocal:

// OpenTelemetry 默认的 ContextStorage 简化版
public class ThreadLocalContextStorage implements ContextStorage {
private static final ThreadLocal<Context> CURRENT = new ThreadLocal<>();
@Override
public Context current() {
Context ctx = CURRENT.get();
return ctx != null ? ctx : Context.root();
}
@Override
public Scope attach(Context toAttach) {
Context previous = CURRENT.get();
CURRENT.set(toAttach);
return () -> CURRENT.set(previous); // 返回时恢复
}
}

4.4 三者关系#

┌────────────────────────────────────────────────────────────┐
│ 应用代码 │
│ │ │
│ ▼ │
│ Context.current() │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ContextStorage(接口) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ ThreadLocalContextStorage(默认实现) │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ ThreadLocal<Context> │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ │ Thread-1: Context{traceId=abc, span=001} │ │ │
│ │ │ Thread-2: Context{traceId=xyz, span=002} │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
记一次引入OTel + Elastic APM Server后的Empty span issue排查
https://blog.astro777.cfd/posts/debug/troubleshooting-an-empty-span-issue/
作者
ASTRO
发布于
2026-01-27
许可协议
CC BY-NC-SA 4.0