Spring Boot 3.5 接 OpenTelemetry:尽量少写手动接入,把四个信号串起来(2026)
目录
背景#
我当前的工作项目还跑在 Spring Boot 3.5 上,没有升到 4 的打算。就这次接入来说,Spring Boot 3.5 + Java 25 提供的 OpenTelemetry 自动配置(auto-configuration)已经覆盖了很多常见场景,剩下的主要是按项目需要做取舍。这篇按 traces、metrics、logs、profiles 四类数据(OpenTelemetry 把它们统称 signal)讲怎么串起来。
例子是一个三服务微服务项目(hello-service → user-service + greeting-service),记录我在这次接入里做的取舍。代码开源,仓库链接在下面,适合当一个可对照的 demo。
这个示例先不使用 Javaagent,主要考虑是想把接入路径尽量放在 Spring Boot 自身的自动配置和 Micrometer Observation 上,这样更容易观察 AOT / Native Image 场景里的行为,也少一层运行时黑盒。对这个 demo 来说,这样的取舍已经够用。
项目代码:github.com/meirongdev/springboot3.5-otel
项目架构与快速开始#
四节是把这套先跑起来:目录怎么分、怎么启动、起来之后先看哪两个 Dashboard、底下用了哪些组件。
项目结构#
三个服务的目录结构:
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 自动配置详解#
依赖加上之后自动配置能覆盖到哪里是这一节的主线,中间单独拎出来的 Logback 桥接是它没管到、必须手写的一段,最后是 YAML 里要盯住的几项。
核心依赖#
在 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 |
一段需要额外补上的手动代码: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 见「生产环境配置参考」一节。
Agent vs Agentless、Collector 与采样策略#
接下来三件事按顺序排:要不要挂 javaagent、要不要在应用和后端之间加一层 Collector、采样和 PII 在哪一层处理。
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 模式仍是更完整的选择;但日常 profiling 在 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,异步执行结果被静默丢弃。
Traces 与 Logs 的关联#
在 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 等采集器负责投递。这两种架构面向不同的基础设施栈,选型取决于团队已有的日志平台,而不是技术优劣之分。
配置日志格式#
在 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:接完之后,PII 和采样怎么管的「Baggage:跨服务传播业务上下文」一节。
但只靠启动日志很难让 dashboard 持续有数据。本仓库在 shared 模块里加了一个共享的 request completion filter,每次 HTTP 请求都会输出 method/path/status/durationMs。因为这条日志仍然发生在当前请求上下文里,所以 traceId/spanId 会继续出现在日志中,Loki 与 Tempo 也就能同时出现“刚刚那一笔请求”的数据。
Logback Appender 配置#
配合上面「一段需要额外补上的手动代码」那节里的 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 做 profiling#
第四个信号是 profiles,也就是对运行中的程序做采样(profiling)。这个 demo 用 JDK 自带的 JFR 采,不额外装 agent,先看选型对比,再看 compose、Dockerfile 和手工 dump 的用法。
为什么选 JFR 而非 Pyroscope Agent?#
| 维度 | JFR | Pyroscope Agent |
|---|---|---|
| 依赖 | JDK 内置,零额外依赖 | 需要下载 agent JAR |
| 开销 | 较低(profile 配置) | 低,但需要额外进程 |
| 容器兼容性 | 完全兼容(无 native 权限需求) | 需要额外容器和端口 |
| 数据格式 | JFR 原生 | JFR / async-profiler |
| 维护成本 | 零(随 JDK 更新) | 需要维护 agent 版本 |
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 采集更多事件,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,而不是只剩空面板或启动日志。
用 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");
这条规则跟着 ./gradlew build 一起跑,违反了这条测试就红。
Quality Gates 清单#
./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 配置和自动配置。项目里的 Java 代码(如 @Observed 注解)更多在表达业务意图,不是基础设施样板。
相关文章#
这四篇都是 Spring Boot 应用接 OpenTelemetry。(一)(二)在同一个 Spring Boot 3.5 项目上,从整体接入到两个信号各自的细节,(三)是从那个 demo 提炼出来的一份 tracing 记录;(四)换到 Spring Boot 4.1,看官方 starter 接管了哪些活、哪些还留给应用。
- Spring Boot 3.5 接 OpenTelemetry:尽量少写手动接入,把四个信号串起来(2026)(本篇)
- Spring Boot 应用的 Metrics 埋点:自动埋点覆盖了什么,什么时候要自己埋(2026) — 埋点写法:
@Observed、手动 Observation API、高低基数字段的边界与命名规范 - Spring Boot 3.5 Tracing:接完之后,PII 和采样怎么管 — tracing 接完之后的事:常见组件接入、上下文传播边界、PII 处理、Logback appender 的完整配置
- Spring Boot 4 的 OpenTelemetry 自动配置边界 — 4.1 的官方 starter 覆盖到哪:三个自动配置类、仍要自己装的 Logback appender、跑在应用之外的 profiles
参考资料#
- 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.4+ 结构化日志实践:ECS + OpenSearch — 以 OpenSearch 为日志后端、文件采集路径的 ECS 结构化日志方案(与本文的 OTLP push 方案属于不同架构栈)