背景#

在 2026 年,虽然 Spring Boot 4 已经发布,但大多数企业项目(包括我当前的工作项目)仍基于 Spring Boot 3.5,且没有立即升级到 Spring Boot 4 的计划。就我这次接入来说,Spring Boot 3.5 + Java 25 提供的 OpenTelemetry 自动配置已经覆盖了很多常见场景,剩下的主要是按项目需要做取舍。

本文以一个三服务微服务项目(hello-serviceuser-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,尽量不要依赖 jobjob 常常跟抓取配置绑定,一旦换成 Collector 或调整 scrape 拓扑就可能漂移;而 service.name 来自资源属性,更适合作为跨环境的服务主键。

采样与隐私上的一些注意点#

  • 避免把 PII(用户 ID、email、手机号)直接写入 span/metric 标签;使用 Collector 的 attributes processor 或应用层过滤器进行脱敏
  • 避免高基数标签(如 userId)出现在 metrics,改用维度聚合或 histogram
  • 如果当前 OTel 指标链路在 Prometheus 侧只暴露 +Inf bucket,不要在 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 方法需要返回 voidFutureListenableFutureCompletableFuture,不能返回普通对象——否则调用方拿到的是 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.idspan.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 过滤。

如果还需要把 tenantIdrequestId 这类业务标识也自动出现在所有服务的日志中,可以考虑使用 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 设为 infowarn
  • 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-serviceuser-servicegreeting-service
  • Tempo search 返回了 20 条 traces,Grafana Tempo datasource 查询返回了 1 个 data frame

这也是为什么当前仓库里的 Services OverviewLogs & 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 注解)更多是在表达业务意图,而不是堆基础设施样板。


参考资料#