背景#

我当前的工作项目还跑在 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 侧只暴露 +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 模式仍是更完整的选择;但日常 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 接管了哪些活、哪些还留给应用。

  1. Spring Boot 3.5 接 OpenTelemetry:尽量少写手动接入,把四个信号串起来(2026)(本篇)
  2. Spring Boot 应用的 Metrics 埋点:自动埋点覆盖了什么,什么时候要自己埋(2026) — 埋点写法:@Observed、手动 Observation API、高低基数字段的边界与命名规范
  3. Spring Boot 3.5 Tracing:接完之后,PII 和采样怎么管 — tracing 接完之后的事:常见组件接入、上下文传播边界、PII 处理、Logback appender 的完整配置
  4. Spring Boot 4 的 OpenTelemetry 自动配置边界 — 4.1 的官方 starter 覆盖到哪:三个自动配置类、仍要自己装的 Logback appender、跑在应用之外的 profiles

参考资料#