Spring Boot 3.5 + OpenTelemetry 实践笔记(2026)
目录
背景#
在 2026 年,虽然 Spring Boot 4 已经发布,但大多数企业项目(包括我当前的工作项目)仍基于 Spring Boot 3.5,且没有立即升级到 Spring Boot 4 的计划。就我这次接入来说,Spring Boot 3.5 + Java 25 提供的 OpenTelemetry 自动配置已经覆盖了很多常见场景,剩下的主要是按项目需要做取舍。
本文以一个三服务微服务项目(hello-service → user-service + greeting-service)为例,记录我在 Spring Boot 3.5 下接入 OTel 的过程。项目代码完全开源,更适合作为一个可对照的 demo,而不是放之四海而皆准的模板。
实践思路:这个示例先不使用 Javaagent。 主要考虑是想把接入路径尽量放在 Spring Boot 自身的自动配置和 Micrometer Observation 上,这样更容易观察 AOT / Native Image 场景里的行为,也少一层运行时黑盒。对这个 demo 来说,这样的取舍已经够用。
- 先从 Agentless 方案试起,利用 Micrometer 实现原生可观测性(参见 Micrometer Observation Documentation)
- 尽量少写接入代码,优先让 Spring Boot 自动配置接管
- 启用 Java 25 Virtual Threads,观察它在 I/O 场景下的表现(参见 Project Loom)
- 使用 JFR(Java Flight Recorder)持续性能分析,补全可观测性的第四个信号
- 用 ArchUnit harness 固化关键约束
项目代码:github.com/meirongdev/springboot3.5-otel
一、项目架构与快速开始#
项目结构#
本项目是一个三服务微服务架构,展示完整的 OpenTelemetry 可观测性方案:
springboot3.5-otel/
├── hello-service/ # 编排服务 (:8080),调用 user-service 和 greeting-service
├── user-service/ # 用户服务 (:8081),H2 + Spring Data JDBC + Flyway
├── greeting-service/ # 多语言问候服务 (:8082)
├── shared/ # 共享模块,包含 OTel 配置和 Logback appender
├── arch-tests/ # ArchUnit 架构规则测试
├── compose.yaml # Grafana LGTM + 内部 otel-collector
├── grafana/ # Grafana 仪表板和自动配置
└── scripts/ # 验证脚本和 JFR 管理工具
快速开始#
# 1. 克隆项目
git clone https://github.com/meirongdev/springboot3.5-otel.git
cd springboot3.5-otel
# 2. 构建、启动并验证完整 demo(服务 + Collector + LGTM)
make verify-otel
# 3. 访问 Grafana 仪表板
# http://localhost:3000 (默认账号 admin/admin)
# 4. 触发一笔级联请求,观察 metrics / traces / logs
curl http://localhost:8080/api/1
先看哪两个 Dashboard#
- Services Overview:按
service_name聚合 request rate / error rate / average latency,适合先确认三服务的 RED 指标都在。 - Logs & Traces:同页展示 Log Volume、Tempo 最近 traces 和 Loki 原始日志,适合确认“有流量之后日志与追踪是否都活着”。
技术栈#
- 运行时 & 框架:Java 25, Spring Boot 3.5.0
- 构建 & CI:Gradle 9.4.1 (Kotlin DSL), GitHub Actions
- 可观测性:Micrometer Tracing + OTel Bridge, OTLP Export, Logback + OTel Appender, JFR
- 数据 & HTTP:H2, Spring Data JDBC, Flyway, Spring RestClient
- 测试 & 质量:JUnit 5, Pact (contract testing), ArchUnit, JaCoCo, Spotless, Error Prone
二、尽量少写手动接入代码——Spring Boot 3.5 自动配置详解#
核心依赖#
在 Spring Boot 3.4 / 3.5 中,只要以下依赖在 classpath 上,许多常见的 OTel 配置基本不需要手动代码:
// shared/build.gradle.kts
api("io.micrometer:micrometer-tracing-bridge-otel")
api("io.opentelemetry:opentelemetry-exporter-otlp")
api("io.micrometer:micrometer-registry-otlp")
api("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:2.13.0-alpha")
implementation("org.springframework.boot:spring-boot-starter-aop") // for @Observed
自动配置覆盖范围#
Spring Boot 3.5 自动配置覆盖范围(参考 Spring Boot Actuator Tracing Docs):
| 功能 | 自动配置类 | 触发条件 |
|---|---|---|
| JVM 指标(CPU / 内存 / 线程 / GC) | JvmMetricsAutoConfiguration |
micrometer-core 在 classpath |
| Traces OTLP 导出 | OtlpTracingAutoConfiguration |
management.otlp.tracing.endpoint 配置 |
| Metrics OTLP 导出 | OtlpMetricsExportAutoConfiguration |
management.otlp.metrics.export.url 配置 |
| Logs OTLP 导出 | OtlpLoggingAutoConfiguration |
management.otlp.logging.endpoint 配置 |
| Async 上下文传播 | Virtual Threads + ContextPropagationAutoConfiguration |
spring.threads.virtual.enabled=true |
@Observed 切面 |
ObservationAutoConfiguration |
management.observations.annotations.enabled=true |
| Native Image 支持 | OpenTelemetryRuntimeHints |
io.micrometer:micrometer-tracing-bridge-otel 在 classpath |
这一节的观察:JVM 指标 bean、SdkLoggerProvider、线程池上下文传播这些配置,在这个示例里都可以先交给 Spring Boot 自动配置处理。
一段需要额外补上的手动代码:Logback Appender 桥接#
重要:Spring Boot 3.5 不会自动将 Spring 管理的 OpenTelemetry bean 安装到 Logback appender。这也是我这次接入里唯一保留下来的手动桥接代码:
@Component
public class OtelLogAppenderInstaller {
@Autowired(required = false)
private OpenTelemetry openTelemetry;
@PostConstruct
void install() {
if (openTelemetry != null) {
OpenTelemetryAppender.install(openTelemetry);
}
}
}
YAML 配置要点#
遵循 OTel Semantic Conventions 设置 Resource Attributes:
management:
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
metrics:
export:
url: http://localhost:4318/v1/metrics
step: 10s
logging:
endpoint: http://localhost:4318/v1/logs # ← Spring Boot 3.4+ 新增
tracing:
sampling:
probability: 1.0
observations:
annotations:
enabled: true
# 通过配置定义 Resource Attributes
opentelemetry:
resource-attributes:
service.name: ${spring.application.name}
service.namespace: springboot3.5-otel
service.version: 1.0.0
deployment.environment: production
完整的 application.yaml 参考见 Section 七。
三、Agent vs Agentless、Collector 与采样策略#
完成了基础配置后,在实际生产中还需要做出一些关键的架构决策。
Agent vs Agentless 选择#
在实践中选择 Agent(opentelemetry-javaagent)还是 Agentless(Micrometer + SDK)有明确权衡:
-
Agentless(本项目当前采用):
- 优点:与 Spring Boot 的自动配置、AOT/Native Image 兼容、启动更快、依赖更少、可控性高
- 缺点:对第三方库自动覆盖较少,需要少量手动埋点或依赖轻量的 auto-instrumentation bridge
-
Agent(opentelemetry-javaagent):
- 优点:自动化覆盖广、快速试验、无需修改源码即可获得 traces
- 缺点:可能影响 AOT/Native Image、与 Spring Boot 自动配置冲突、增加运行时开销
我的取舍:如果服务本身还比较容易改代码,我会先从 Agentless(Micrometer/Observation)试起;只有在短期诊断或遗留服务难以改动时,才把 javaagent 当成补充手段。
Collector 建议#
如果团队希望把采样、批处理、脱敏统一放在一层处理,可以考虑增加一个 OpenTelemetry Collector 作为缓冲/处理层:
- Collector 提供批量(batch)、重试(retry)、TLS、认证和 attribute processors(用于 PII 清洗)
- Collector 支持 tail-based / dynamic sampling,便于在高流量下针对性采样
在 Spring Boot 中,把 management.otlp.* 指向本地 Collector(例如 http://localhost:4318),让应用保持轻量,所有复杂处理移到 Collector。像本仓库这样,代码仓库里保留 localhost 形式的默认值,再由 Docker Compose 在运行时覆写成 http://otel-collector:4318/...,就能同时兼顾本地开发和容器拓扑。
另一个很容易忽略的点是:dashboard 维度最好优先使用 service.name / service_name,尽量不要依赖 job。job 常常跟抓取配置绑定,一旦换成 Collector 或调整 scrape 拓扑就可能漂移;而 service.name 来自资源属性,更适合作为跨环境的服务主键。
采样与隐私上的一些注意点#
- 避免把 PII(用户 ID、email、手机号)直接写入 span/metric 标签;使用 Collector 的 attributes processor 或应用层过滤器进行脱敏
- 避免高基数标签(如 userId)出现在 metrics,改用维度聚合或 histogram
- 如果当前 OTel 指标链路在 Prometheus 侧只暴露
+Infbucket,不要在 overview panel 里强依赖histogram_quantile(...);像本仓库一样,先用平均延迟保证 dashboard 持续有值 - 为 Logs & Traces 类 dashboard 增加低基数的请求完成日志(
method/path/status/durationMs),避免面板只剩启动日志 - 在高流量端点使用动态采样或尾采样(tail-based sampling)以保证高错误率请求被捕获
四、Java 25 Virtual Threads & Native Image#
一行开关启用虚拟线程#
Spring Boot 3.2+ 支持 Virtual Threads(Project Loom),只需:
spring:
threads:
virtual:
enabled: true
效果:
- Tomcat 请求处理线程 → 全部切换为虚拟线程
@Async方法 → 自动使用虚拟线程 executor- Micrometer Tracing 上下文传播 → 自动在虚拟线程间正确传播
Native Image (GraalVM) 支持#
Spring Boot 3.5 的 OTel 自动配置在当前版本里已经能较好配合 AOT;如果你对启动时间和内存比较敏感,也可以继续尝试 GraalVM Native Image:
# 构建 Native Image
./gradlew nativeCompile
注意事项:
- Native Image 适合对启动时间和内存敏感的场景(如 Serverless、容器化微服务)
- 使用 Native Image 时,建议配合
runtime-hints确保反射调用正常(Spring Boot Starter OTLP 已内置) - JFR 在 Native Image 中:GraalVM 25 已正式支持 JFR in Native Image,CPU、内存分配、GC 等核心事件均可采集。若对 JFR 事件覆盖范围有极致要求(如锁竞争细节、自定义 JFR 事件),标准 JVM 模式仍是更完整的选择;但日常持续性能分析在 Native Image 下已可正常使用
HelloService 中的 @Async 方法无需任何修改,自动获得 Virtual Threads 加持:
@Async
@Observed(name = "hello.service.getHelloAsync", contextualName = "getHelloAsync")
public CompletableFuture<HelloController.HelloResponse> getHelloAsync(Long userId, String acceptLanguage) {
return CompletableFuture.completedFuture(getHello(userId, acceptLanguage));
}
@Async方法需要返回void、Future、ListenableFuture或CompletableFuture,不能返回普通对象——否则调用方拿到的是null,异步执行结果被静默丢弃。
五、Log Correlation — Traces × Logs 完整联动#
配置好 Traces 和 Metrics 后,下一步是让 Logs 也与 Traces 关联。在 Spring Boot 3.5 中,日志与 trace 的关联是全自动的。
日志输出方式说明#
本文采用 OTLP push 方式:OpenTelemetryAppender 把日志经 OTLP 直接推送到 Collector → Loki,与 Traces、Metrics 走同一条数据通道,适合可观测性信号统一由 OTel Collector 管理的 demo 和 Grafana LGTM 栈。
如果你的日志后端是 OpenSearch / Elasticsearch,通常采用另一种架构:Spring Boot 内建 ECS 结构化输出写到文件,由 Fluent Bit 等采集器负责投递。这两种架构面向不同的基础设施栈,选型取决于团队已有的日志平台,而不是技术优劣之分。
配置日志格式#
本文使用 OTLP push 方式。在 application.yaml 中设置包含 traceId/spanId 的日志格式:
logging:
pattern:
level: “%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]”
%X{traceId}和%X{spanId}是 Micrometer Tracing 写入 MDC 的 key 名称(camelCase)。如果使用 Spring Boot 内建的 ECS encoder 输出文件,同一个 MDC key 会被自动映射为 ECS 规范的点分隔字段名trace.id和span.id,两者表示同一条数据,只是格式不同。
实际日志输出(带 Trace ID)#
INFO [hello-service,4bf92f3577b34da6a3ce929d0e0e4736,00f067aa0ba902b7] HelloService : getHello called
INFO [user-service,4bf92f3577b34da6a3ce929d0e0e4736,abc123def456789a] UserService : getUser(1)
INFO [greeting-service,4bf92f3577b34da6a3ce929d0e0e4736,def789abc123456b] GreetingController : GET /api/greetings
同一请求跨三个服务的日志共享同一个 traceId,在 Grafana Loki 中可以直接用 trace ID 过滤。
如果还需要把 tenantId、requestId 这类业务标识也自动出现在所有服务的日志中,可以考虑使用 Baggage + MDC 关联,这样通常不需要在每个服务里手动 MDC.put():
management:
tracing:
baggage:
remote-fields: [x-tenant-id, x-request-id] # W3C baggage header 传播
correlation:
fields: [x-tenant-id, x-request-id] # 自动注入 MDC
详细 Baggage 配置与 Filter 示例见《Spring Boot 3.5 Tracing 实践记录》的"Baggage:跨服务传播业务上下文"章节。
但只靠启动日志很难让 dashboard 持续有数据。本仓库在 shared 模块里加了一个共享的 request completion filter,每次 HTTP 请求都会输出 method/path/status/durationMs。因为这条日志仍然发生在当前请求上下文里,所以 traceId/spanId 会继续出现在日志中,Loki 与 Tempo 也就能同时出现”刚刚那一笔请求”的数据。
Logback Appender 配置#
配合上面 Section 2 中的 OtelLogAppenderInstaller,还需要在 logback-spring.xml 中声明 appender:
<?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>
<!-- 记录代码位置(类名、方法名、行号) -->
<captureCodeAttributes>true</captureCodeAttributes>
<captureMarkerAttribute>true</captureMarkerAttribute>
<captureLoggerContext>true</captureLoggerContext>
</appender>
<!-- AsyncAppender:避免 OTel 网络 I/O 阻塞业务线程 -->
<appender name=”AsyncOpenTelemetry” class=”ch.qos.logback.classic.AsyncAppender”>
<appender-ref ref=”OpenTelemetry”/>
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold>
<maxFlushTime>1000</maxFlushTime>
<includeCallerData>false</includeCallerData>
</appender>
<root level=”INFO”>
<appender-ref ref=”CONSOLE”/>
<appender-ref ref=”AsyncOpenTelemetry”/>
</root>
</configuration>
六、持续性能分析 — JFR(第四个信号)#
Traces、Metrics、Logs 三个信号已经提供了完整的可观测性,但 Java 还有一个原生第四个信号:Profiles(持续性能分析)。Java Flight Recorder (JFR) 补全了这一环。
为什么选 JFR 而非 Pyroscope Agent?#
| 维度 | JFR | Pyroscope Agent |
|---|---|---|
| 依赖 | JDK 内置,零额外依赖 | 需要下载 agent JAR |
| 开销 | <2% CPU(profile 配置) | 低,但需要额外进程 |
| 容器兼容性 | 完全兼容(无 native 权限需求) | 需要额外容器和端口 |
| 数据格式 | JFR 原生 | JFR / async-profiler |
| 维护成本 | 零(随 JDK 更新) | 需要维护 agent 版本 |
Java 25 的 JFR 已经非常成熟,支持 CPU、内存分配、锁竞争、I/O 等多维 profiling,无需引入额外基础设施。
Docker Compose 配置#
通过 JDK_JAVA_OPTIONS 环境变量启用 JFR 持续录制,无需修改 Dockerfile:
services:
hello-service:
environment:
# JFR Profiling — JDK 内置,零额外依赖
- JDK_JAVA_OPTIONS=-XX:StartFlightRecording=name=production,maxsize=200m,maxage=2h,settings=profile
-XX:MaxRAMPercentage=75 -XX:+UseG1GC -XX:MaxGCPauseMillis=200
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10M
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/
volumes:
- ./logs/hello-service:/logs
Dockerfile — 简洁干净#
不需要注入任何 agent,只运行 JAR:
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
RUN mkdir -p /logs && chown -R app:app /logs
COPY --from=builder /build/hello-service/build/libs/*.jar app.jar
USER app
EXPOSE 8080
# JVM 配置通过 JDK_JAVA_OPTIONS 环境变量注入(Docker Compose 管理)
ENTRYPOINT ["java", "-jar", "app.jar"]
JFR 管理命令#
# 使用 Make 命令管理 JFR
make jfr-check # 查看活动录制
make jfr-dump # 导出当前录制
make jfr-stop # 停止录制
make jfr-analyze # 分析最新 JFR 文件
make jfr-flame # 生成火焰图
# 或直接使用 jcmd
jcmd <pid> JFR.check
jcmd <pid> JFR.dump name=production filename=/logs/recording.jfr
生产环境配置上的一些取舍#
- 录制参数:
maxsize=200m,maxage=2h控制磁盘用量 - settings=profile:比 default 采集更多事件,开销仍 <2% CPU
- GC 日志:通过
-Xlog:gc*记录 GC 事件,配合 JFR 分析 - Heap Dump:
-XX:+HeapDumpOnOutOfMemoryError自动在 OOM 时生成 dump
七、生产环境配置参考#
前面各节介绍了分散的配置要点,这里提供生产环境配置的完整参考(以 hello-service 为例):
spring:
application:
name: hello-service
threads:
virtual:
enabled: true # Java 25 Virtual Threads
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
metrics:
export:
url: http://localhost:4318/v1/metrics
step: 10s
logging:
endpoint: http://localhost:4318/v1/logs # Logs OTLP export
tracing:
sampling:
probability: 1.0 # 开发环境 100% 采样
observations:
annotations:
enabled: true # 启用 @Observed 注解
opentelemetry:
resource-attributes:
service.name: ${spring.application.name}
service.namespace: springboot3.5-otel
service.version: 1.0.0
deployment.environment: production
logging:
level:
com.example: debug
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
生产环境配置建议#
- 采样率:生产环境
management.tracing.sampling.probability应设为0.1或更低 - 环境变量覆盖:Docker 环境通过环境变量覆盖 endpoint(
MANAGEMENT_OTLP_TRACING_ENDPOINT等) - 日志级别:生产环境建议
com.example设为info或warn - Collector 部署:在生产中部署 OTel Collector,将 endpoint 指向 Collector 而非直接发送到后端
一次冷启动验证后的观测证据#
在本地一次 docker compose up -d --build --wait + make verify-otel 验证中:
- Prometheus 返回了按
service_name聚合的 3 个http_server_requests_*服务序列 - Loki 最近 15 分钟查询返回了 50 条 stream,覆盖
hello-service、user-service、greeting-service - Tempo search 返回了 20 条 traces,Grafana Tempo datasource 查询返回了 1 个 data frame
这也是为什么当前仓库里的 Services Overview 与 Logs & Traces 两个 dashboard 在冷启动后打一小段真实流量就能同时出现 metrics / logs / traces,而不是只剩空面板或启动日志。
八、Harness Engineering — 用 ArchUnit 固化 OTel 约束#
如果想让这些约定别轻易漂移,仅靠文档往往不够。项目里用 ArchUnit 执行下面这类 OTel 相关规则:
// 本文示例里的约束:禁止手动构建 OTel SDK provider
@ArchTest
static final ArchRule noManualOtelSdkConstruction =
noClasses()
.should()
.dependOnClassesThat()
.haveFullyQualifiedName("io.opentelemetry.sdk.logs.SdkLoggerProvider")
.orShould()
.dependOnClassesThat()
.haveFullyQualifiedName("io.opentelemetry.sdk.trace.SdkTracerProvider")
.orShould()
.dependOnClassesThat()
.haveFullyQualifiedName("io.opentelemetry.sdk.metrics.SdkMeterProvider")
.because("Spring Boot 3.5 auto-configures the OTel SDK");
这条规则在 CI 中自动执行,违规代码通常会在 ./gradlew build 阶段被及时拦下。
完整质量门控体系#
./gradlew build
├─ spotlessCheck → Google Java Format (2-space)
├─ Error Prone → 编译期静态分析
├─ test → 单元 + 集成 + Pact contract testing
├─ jacocoTestCoverageVerification → 60% 最低覆盖率
└─ ArchUnit → 架构规则 + OTel 约束
九、总结#
| 维度 | 本文示例里的做法 (Agentless) |
|---|---|
| 接入理念 | 零 Agent,内置库级埋点(Observation API) |
| Native Image | 原生支持,一行 AOT 编译 |
| JVM 指标 | 自动(JvmMetricsAutoConfiguration) |
| Logs OTLP 导出 | management.otlp.logging.endpoint |
| Logback appender 安装 | logback-spring.xml + OtelLogAppenderInstaller |
| 异步上下文传播 | Virtual Threads + ContextPropagationAutoConfiguration |
| 性能分析 | JFR(JDK 内置,JFR Receiver 摄取) |
| OTel SDK 使用约定 | ArchUnit 禁止手动构建 provider |
我的这轮实践里更在意的是:把额外接入代码控制在比较小的范围内。本文示例里保留的一处手动代码是 OtelLogAppenderInstaller(用于桥接 Logback appender),其余大多交给 YAML 配置和 Spring Boot 自动配置处理。项目中的 Java 代码(如 @Observed 注解)更多是在表达业务意图,而不是堆基础设施样板。
参考资料#
- Spring Boot 3.5 Reference - Observability
- Spring Boot 3.4 Release Notes - OTel Logging
- OpenTelemetry Java Instrumentation - Logback
- Java Flight Recorder (JFR)
- Project Loom - Virtual Threads
- 项目源码 springboot3.5-otel
- Spring Boot 3.5 Tracing 实践笔记:从接入到生产 — 从本文 demo 提炼的一份 tracing 记录,覆盖 Kafka/Redis/DB 组件接入、采样策略、PII 处理,后端无关
- 日志开发实践整理 — 日志内容层面的约定(记什么、怎么写),与日志基础设施选型无关
- Spring Boot 3.4+ 结构化日志实践:ECS + OpenSearch — 以 OpenSearch 为日志后端、文件采集路径的 ECS 结构化日志方案(与本文的 OTLP push 方案属于不同架构栈)