SpringBoot4系列24 - 使用Actuator与OpenTelemetry可观测性

上个月支付网关出了一次故障,客服那边的投诉先进来,我这边才反应过去翻日志。日志有,但散在四个服务实例里,想回答「昨晚十点支付接口慢在哪」,我得先找到慢的判断依据,再去四个实例里捞请求日志,最后把两次跨服务调用的日志按请求对上号。这三件事分别对应指标、链路、日志,工程上叫可观测性三件套。复盘的时候我意识到,排障有个更早的阶段:问题还没发生时,系统就该把三类信号持续吐出去,指标给告警提供阈值,链路给定位提供调用路径,日志给复盘提供细节。

Spring Boot 对这件事的答案是 Actuator 暴露端点、Micrometer 统一指标门面、OpenTelemetry 负责把链路和日志信号按 OTLP 协议导出。Boot 4.1 在这层动了不少手术:模块拆分、属性前缀改名、starter 重组,我从网上找来的 3.x 配置片段有一半直接抄过来不生效。所以这篇我把三件套在 Spring Boot 4.1.1 上完整接了一遍:starter 坐标取证、端点暴露、自定义指标、OTLP 三信号导出到本地假收集器、traceId 贯穿日志,端到端验证脚本的每个数字都在正文里对得上。

端点与暴露

动手接线的第一件事,是把三个 starter 的坐标钉下来。我翻了本地 Maven 仓库的制品和 spring-boot-dependencies 4.1.1 的 POM,抄录如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
spring-boot-starter-actuator 4.1.1
  ├─ spring-boot-starter-micrometer-metrics
  ├─ spring-boot-actuator-autoconfigure
  ├─ spring-boot-health
  ├─ micrometer-observation 1.17.1
  └─ micrometer-jakarta9 1.17.1

spring-boot-starter-opentelemetry 4.1.1
  ├─ spring-boot-micrometer-tracing-opentelemetry
  ├─ spring-boot-opentelemetry
  ├─ micrometer-registry-otlp 1.17.1
  ├─ micrometer-tracing-bridge-otel 1.7.1
  └─ opentelemetry-exporter-otlp 1.62.0

actuator 的 starter 坐标没改名。挺有意思的是 OpenTelemetry 这次有了专属 starter,Boot 3 时代要手动凑四个依赖,4.1 一个坐标全带上。版本号一个都不用写,micrometer 1.17.1、micrometer-tracing 1.7.1、opentelemetry 1.62.0 三条版本线都在 Boot BOM 里管理。

模块拆分是 Boot 4 的大动作。OpenTelemetry 相关自动配置从 spring-boot-actuator-autoconfigure 里整体搬了出去,我在 actuator-autoconfigure 的 AutoConfiguration.imports 里搜了一遍,已经找不到任何 OTel 类。新家有两处:spring-boot-opentelemetry 模块(包名 org.springframework.boot.opentelemetry.autoconfigure)管 OpenTelemetrySdk 和日志导出;spring-boot-micrometer-tracing-opentelemetry 模块(包名 org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure)管链路导出,里面有 OpenTelemetryTracingAutoConfiguration 和 OtlpTracingAutoConfiguration 两个入口类。你要是还按 3.x 的包名去翻源码,直接换这两个包名找。

工程的依赖声明只有四个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

webmvc 提供宿主接口,actuator 带出端点体系,opentelemetry starter 接通链路与日志的 OTLP 导出,prometheus registry 补上 Pull 模式的刮擦端点,四个依赖正好覆盖三件套的两个出口方向:Push 走 OTLP,Pull 走 prometheus。这里我顺带提醒一句,Boot 没有 prometheus 专属 starter,最后这一行要手动加。

starter 加完还不算完,默认只暴露 health 一个端点,这个默认值来自 spring-boot-actuator-autoconfigure 的配置元数据:management.endpoints.web.exposure.include 默认值就是 [‘health’]。演示工程我打开了四个:

1
2
3
4
5
6
7
8
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always

health 的 show-details 打开后,磁盘、线程池、liveness 这些组件明细会出现在响应里。我启动应用访问 /actuator/health,返回 status UP,components 里 diskSpace 带着总容量 484949618688 字节、剩余 441346002944 字节。生产环境按需收敛,health 明细可能泄露内部结构,配合 management.endpoints.web.exposure.exclude 或独立管理端口(management.server.port)做隔离都是常规做法。

prometheus 端点是文本刮擦格式,注册它的是 spring-boot-micrometer-metrics 模块里的 PrometheusMetricsExportAutoConfiguration。我刮了一次,59 个 TYPE 行,JVM 内存、Tomcat 线程池、磁盘、执行器指标全部在列。metrics 端点则是 JSON 格式的指标目录,先访问 /actuator/metrics 列出全部指标名,再访问 /actuator/metrics/<指标名> 读具体取值,这个两级结构我后面读自定义指标时会用到。

端点先暴露再谈安全。prometheus 文本和 metrics 目录都会完整展示内部指标结构,health 明细里的路径、容量属于环境信息。这三类端点放进生产之前,至少要做一层隔离:管理流量与业务流量分开,端口的来源限制到运维网段。工程里有 Spring Security 的话,端点权限用角色收口到运维角色;容器部署时把管理端口映射到内部网络,不经过业务负载均衡。演示工程为了验证方便我全部打开了,安全边界你自己按环境收紧。基础端点就位,下一步把业务信号接进来。

自定义指标

示例业务我选了订单域,五个接口:下单、查询、处理、慢请求、故意失败。慢请求固定睡 800 毫秒,失败接口抛 IllegalStateException,这两个是专门留出来观测慢样本和错误路径的。OrderService 里我注册了两个自定义指标:

1
2
3
4
5
6
7
8
9
this.createdCounter = Counter.builder("orders.placed")
        .description("累计创建的订单数")
        .tag("channel", "web")
        .register(registry);
this.processingTimer = Timer.builder("orders.processing")
        .description("订单处理耗时")
        .tag("outcome", "ok")
        .publishPercentiles(0.5, 0.95)
        .register(registry);

指标命名用点分小写,Micrometer 会在各导出口做命名映射:prometheus 端点里点号换成下划线,Counter 自动补 _total 后缀,Timer 自动带 _seconds 单位。所以 orders.placed 在 prometheus 里叫 orders_placed_total,orders.processing 叫 orders_processing_seconds。两套名字容易搞混:在 actuator 的 metrics 端点里读数用原始名,在 prometheus 文本里 grep 用映射后的名,两套名字对应同一个指标。

Counter 在下单时 increment 一次,Timer 用 record 包住处理逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public Order process(long id) {
    get(id);
    return processingTimer.record(() ->
            Observation.createNotStarted("orders.process", observations)
                    .lowCardinalityKeyValue("order.stage", "process")
                    .observe(() -> {
                        sleep(300);
                        return markPaid(id);
                    }));
}

计时之外,处理逻辑还套了一层 Observation,这是我有意为之的。Observation 是 Micrometer 的统一观测 API,一次调用同时产出两个东西:指标里的耗时记录,链路里的一个子 Span。这是 Micrometer Tracing 路线的核心设计:业务代码面向 Observation 编程,指标和 trace 自动分流到各自的导出器,换存储后端不用改业务代码。lowCardinalityKeyValue 只放 stage 这类低基数值,你要是把订单号放进去,标签基数会把存储撑爆,这是自定义指标最常见的事故源。

验证脚本建了两笔订单、处理了一次,然后从 actuator 读回取值:

1
2
3
4
5
6
7
GET /actuator/metrics/orders.placed
→ {"name":"orders.placed","measurements":[{"statistic":"COUNT","value":2.0}],
   "availableTags":[{"tag":"channel","values":["web"]}]}

GET /actuator/metrics/orders.processing
→ {"name":"orders.processing","baseUnit":"seconds",
   "measurements":[{"statistic":"COUNT","value":1.0},{"statistic":"TOTAL_TIME","value":0.3028589}]}

orders.placed 计数 2.0,对上两笔订单。orders.processing 记了 1 个样本,总耗时 0.3028589 秒,对上 process 接口里 300 毫秒的 sleep 加业务开销。JSON 里的 statistic 有 COUNT、TOTAL_TIME、MAX 三种,分位数要等 prometheus 端点里看。

慢接口的 800 毫秒落在框架自带的 http.server.requests 指标里。每个 HTTP 请求框架默认都记一行,method、uri、outcome、status 四个标签起步,异常类名自动进 exception 标签。我按路由模板和结果过滤后读回:

1
2
3
4
5
GET /actuator/metrics/http.server.requests?tag=uri:/api/orders/{id}/slow&tag=outcome:SUCCESS
→ COUNT 1.0, TOTAL_TIME 0.8111099, tags: method=GET, status=200

GET /actuator/metrics/http.server.requests?tag=uri:/api/orders/{id}/fail&tag=outcome:SERVER_ERROR
→ COUNT 1.0, TOTAL_TIME 0.0059243, tags: exception=IllegalStateException, status=500

慢请求 811 毫秒、失败请求 5.9 毫秒加 500 状态。失败路径不用业务代码埋点,framework 层的观测已经把错误类别记全了。指标端点的查询参数里那个 {id} 是路由模板,不是展开后的订单号,我第一次就拿着展开后的订单号去查,白忙一场,这个坑后面细说。

注入 MeterRegistry 前后还有一层全局加工的机会。management.observations.key-values.region=cn-east 这类配置给全部观测挂公共标签,机房、版本号这类每实例一个值的属性用它带出去;代码侧的等价物是 MeterRegistryCustomizer,在指标注册前统一加 common tags 或设置计量规则。公共标签和业务标签的边界要想清楚:region 放标签,订单号放 trace 属性,前者每实例一个值,后者每请求一个值,混着用会把存储和查询都拖垮。

编程接口之外还有注解路线。@Observed、@Timed、@Counted 三个注解打在方法上,配合 management.observations.annotations.enabled=true 开启注解扫描,方法耗时与调用次数自动进指标。这里藏着一个静默失效的坑:注解扫描靠 AOP 实现,spring-boot-starter-aspectj(或 aspectjweaver)得先进依赖,只开属性不加依赖时注解不报错也不生效。我的用法是注解适合快速给存量方法补观测,编程式 Observation 适合需要动态键值的场景,两种写法产出同一种指标,后端无差别对待。

OTLP 链路导出

指标、链路、日志三个信号在 Boot 4.1 里有三套属性前缀,这件事我是从三个 jar 的 spring-configuration-metadata.json 里各抄了一份才敢下结论的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
management:
  tracing:
    sampling:
      probability: 1.0
  opentelemetry:
    resource-attributes:
      service.name: springboot4-observability
      service.namespace: springboot4-bucket
    tracing:
      export:
        otlp:
          endpoint: http://127.0.0.1:4318/v1/traces
        schedule-delay: 2s
    logging:
      export:
        otlp:
          endpoint: http://127.0.0.1:4318/v1/logs
  otlp:
    metrics:
      export:
        url: http://127.0.0.1:4318/v1/metrics
        step: 5s

链路前缀是 management.opentelemetry.tracing.export.otlp.endpoint,Boot 3 的 management.otlp.tracing.endpoint 降级成兼容别名。日志前缀 management.opentelemetry.logging.export.otlp.endpoint,旧别名同理。指标前缀没动,还是 management.otlp.metrics.export.url,因为指标导出走 Micrometer 的 OtlpMeterRegistry,和 OTel SDK 的 SdkMeterProvider 完全无关。官方文档明确说过:Boot 不创建 SdkMeterProvider bean,OTel API 产生的指标不会被导出。还有个我差点栽进去的细节:三行 endpoint 都要写到 /v1/traces、/v1/metrics、/v1/logs 完整路径,OTel 导出器对信号专属 endpoint 不自动补路径,只写 host 会一个包都发不出去。

resource-attributes 两个键值得单独说。service.name 是后端里区分服务的唯一线索,OTLP 包里没有它,所有数据都会混在一起。这个属性同时喂两个体系:OTel 的 Resource bean 和 Micrometer OtlpMeterRegistry 的 resource attributes,我启动后翻日志,确认信息直接就在里面:

1
2
3
INFO i.m.c.i.p.PushMeterRegistry - Publishing metrics for OtlpMeterRegistry
  every 5s to http://127.0.0.1:4318/v1/metrics
  with resource attributes {service.name=springboot4-observability, service.namespace=springboot4-bucket}

环境变量也能进:OTEL_SERVICE_NAME 和 OTEL_RESOURCE_ATTRIBUTES 会被 OpenTelemetryEnvironmentVariableEnvironmentPostProcessor 读入并与属性合并,配置属性优先。management.opentelemetry.enabled 设 false 相当于 OTEL_SDK_DISABLED,全部信号退回 no-op 实现,context 传播保留。采样比例 management.tracing.sampling.probability 我设了 1.0,这是演示用法,生产按 0.1 左右权衡导出量,配合 schedule-delay 控制批量节奏。

https://static.xiongneng.me/observability-otlp-export-components-20260922062313.png

三条导出链路的组件关系如上图:业务代码只面对 MeterRegistry、ObservationRegistry、SdkLoggerProvider 三个门面,出口侧四个导出器各管一段,属性前缀的三套体系对应图中三段箭头。

导出目标不是现成的收集器,我本机没有 OpenTelemetry Collector,所以验证脚本先用 Python 起了一个假收集器,代码不到 50 行,收 POST 计数不解析 protobuf:

1
2
3
4
5
6
7
8
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length) if length else b""
        rec = state.setdefault(self.path, {"count": 0, "bytes": 0})
        rec["count"] += 1
        rec["bytes"] += length
        # 各路径计数实时写文件,供验证脚本读取

它只做一件事:按请求路径记条数和字节数,回 200。端到端验证跑完,假收集器的接收记录是这样:

1
2
3
/v1/traces  count=3  bytes=11105
/v1/logs    count=2  bytes=802
/v1/metrics count=3  bytes=36732

三个信号都有真实流量落地。traces 3 次导出对应建单、处理、慢请求、失败这几条请求链路的批量上报,schedule-delay 压到 2 秒,每个请求的 HTTP Span 加业务 Observation 产出的 orders.process 子 Span 都在里面;metrics 3 次对应 5 秒一个导出周期;logs 2 次是两笔订单的 OTLP 日志信号。字节数都在涨,导出路径实打实通了。

以 process 接口为例看一次请求在链路里的形状。GET /api/orders/1/process 进来,框架先开一个 HTTP 服务端 Span,名字是路由模板,带 method、uri、status、outcome 属性;业务代码里的 orders.process Observation 挂在这个父 Span 下面,300 毫秒的处理时长就是它和父 Span 的时间差。收集器侧拿到的每个批次里,Span 之间靠 traceId 和 parentSpanId 两个字段构成树,接进 Jaeger 或 Grafana Tempo 这类后端,就能看到一次请求从入口到业务处理的完整轨道。慢请求在轨道上一眼可见:父 Span 拉出 811 毫秒,子 Span 缺席说明慢在框架层与控制器之间,子 Span 长说明慢在业务内部,这就是链路信号对定位的实际贡献。

https://static.xiongneng.me/observability-request-observation-sequence-20260922062313.png

把时序展开成上图:一次下单请求同步经过的每个环节都有信号产出,三路导出按各自的批量窗口异步落地,底部一行是本轮验证的真实计数。

日志关联 traceId

日志信号在 Boot 4.1 里有个官方声明的边界,我原样抄在这里:SdkLoggerProvider bean 会自动创建,OTLP 日志导出器也接好了,但应用日志不会自动桥接进去。SLF4J 的每一行日志默认不会变成 OTel LogRecord 发出去。要让 /v1/logs 有流量,两条路:引入 OTel 的 logback appender 桥接(io.opentelemetry.instrumentation 系,版本线归 instrumentation BOM 管,不在 Boot BOM 里),或者业务代码直接用 OTel Logger API 发射。演示工程我选了后者,零额外依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class OtlpLogBridge {

    private final Logger otelLogger;

    public OtlpLogBridge(ObjectProvider<SdkLoggerProvider> loggerProvider) {
        SdkLoggerProvider sdk = loggerProvider.getIfAvailable();
        this.otelLogger = sdk != null
                ? sdk.loggerBuilder("com.xncoding.observability.orders").build()
                : LoggerProvider.noop().get("com.xncoding.observability.orders");
    }

    public void emitOrderPlaced(long id, String customer) {
        otelLogger.logRecordBuilder()
                .setSeverity(Severity.INFO)
                .setBody("订单已创建(OTLP 日志信号)id=" + id + " customer=" + customer)
                .setAttribute(AttributeKey.longKey("order.id"), id)
                .setContext(Context.current())
                .emit();
    }
}

构造器用 ObjectProvider 注入 SdkLoggerProvider,测试场景关掉 OTel 自动配置时 bean 缺席,退回 no-op 实现不炸启动。emit 按批聚合,management.opentelemetry.logging.export.schedule-delay 默认 1 秒,发射后最多等 1 秒就能在收集器侧看到计数。setContext(Context.current()) 把当前链路上下文挂上去,导出的 LogRecord 自带 traceId,日志和 trace 在后端能直接对上。

控制台日志的 traceId 是另一件事,靠 Micrometer Tracing 写 MDC 完成。链路开启后,请求线程里的业务日志会自动带上 MDC 键值,logging pattern 里显式带出:

1
2
3
logging:
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [traceId=%X{traceId:-} spanId=%X{spanId:-}] %logger{24} - %msg%n"

验证脚本数了一遍,应用日志 19 行带 traceId,抽三条看格式:

1
2
3
19:23:59.471 INFO  [traceId=26f8baf5d5385c1e106bf53998b30e57 spanId=dbc06921d42b4afb] c.x.o.c.OrderController - 订单已创建 id=1 customer=张三
19:24:00.114 INFO  [traceId=7abb96c830ab6e9a09396c873d7c10ca spanId=ef0e0e88ea6070e7] c.x.o.c.OrderController - 开始处理订单 id=1
19:24:02.065 ERROR [traceId= spanId=] o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() ... IllegalStateException: 模拟支付网关不可用

请求线程里的业务日志全部有 traceId 和 spanId,两条日志同一个 traceId 就能判定同一条请求链路,这就是跨实例对账的铁证,排障时先按 traceId 聚合再按时间排序,调用次序一目了然。最后一条 ERROR 是失败接口抛的,traceId 为空,我查了下原因:异常已经抛出请求线程,Tomcat 的错误处理线程没有链路上下文。这行 ERROR 是全脚本唯一的 ERROR,来自故意失败接口,属于验证目标本身。

控制台之外还有第三种出口:logging.structured.json 系列属性能把日志直接格式化成 JSON,traceId 作为字段输出,接入日志平台时比正则解析文本模式省事。三种出口(文本模式、结构化 JSON、OTLP 信号)共享同一个 MDC 与链路上下文,按需选用。

线程池与异步代码是上下文断裂的高发区。@Async 方法进线程池后 traceId 会丢,spring.task.execution.propagate-context=true 让 Boot 自动装配的 AsyncTaskExecutor 把上下文带过去;Reactor 管线对应 spring.reactor.context-propagation=auto。这两处不开,链路一到异步边界就断成两截,日志的 traceId 同步丢失。你要排查异步超时问题,先查这两行配置。

完整案例

整条接线的全貌先看架构图:

https://static.xiongneng.me/observability-architecture-20260922062313.png

把上面的部件串成一次端到端验证。脚本共 11 步:起假收集器、起应用、health 检查、建两笔订单、读自定义指标、读慢请求与失败指标、等导出落地、数日志 traceId、关服务。我给脚本配了五类断言:端点可访问性、指标取值与业务动作对账、prometheus 文本与 metrics 端点同源、OTLP 三信号接收计数、日志 traceId 覆盖率。应用启动耗时 2.889 秒,全部取值来自同一次运行,数字之间互相印证,任何一处对不上都说明接线有问题。

mvn test 有 6 个端到端用例,真实启动 Tomcat 随机端口逐项验证:health 明细可读;创建两笔订单后 orders.placed 计数不低于 2;process 接口计时后 orders.processing 有样本;fail 接口返回 500 且 prometheus 文本里出现 outcome=“SERVER_ERROR” 与 uri="/api/orders/{id}/fail";prometheus 端点含 orders_placed_total 与 http_server_requests_seconds_count;不存在的订单返回 404。测试上下文里用三个属性关掉 OTLP 导出(management.opentelemetry.enabled=false 加两个导出开关),避免没有收集器时的告警噪音。

prometheus 端点共 59 个 TYPE 行,自定义指标的输出长这样:

1
2
3
4
5
6
orders_placed_total{channel="web"} 2.0
orders_processing_seconds{outcome="ok",quantile="0.5"} 0.301989888
orders_processing_seconds{outcome="ok",quantile="0.95"} 0.301989888
orders_processing_seconds_count{outcome="ok"} 1
orders_processing_seconds_sum{outcome="ok"} 0.3028589
orders_processing_seconds_max{outcome="ok"} 0.3028589

完整取值面板如下,四个分区自上而下对应指标读数、prometheus 文本、OTLP 计数与日志抽查:

https://static.xiongneng.me/observability-verify-panel.png

orders_placed_total 2.0 与 metrics 端点读数一致,一个来源两个出口,Pull 和 Push 各走各的。orders_processing 的 p50 和 p95 都是 0.301989888 秒,样本只有一个,两个分位数收敛到同一个值,这是正常现象;慢请求进来自动就能看到 p95 抬升,样本攒到几十个后分位数才呈现梯度。

验证产物我都留了底:验证脚本一条命令跑完整链路,跑完把产物(prometheus 文本、OTLP 接收计数、应用日志)原样存档,换环境复跑一遍就能对比取值。

这套接线的最后一步是换掉假收集器。OpenTelemetry Collector 官方发行版一个容器就能起,OTLP receiver 默认监听 4318,把三行 endpoint 里的 127.0.0.1 换成收集器地址,应用侧一行代码不改。收集器把 metrics 转给 Prometheus、traces 转给 Tempo、logs 转给 Loki,三件套在后端各就各位;假收集器验证过的接线部分(属性前缀、路径、批量节奏、traceId 注入)全部原样生效,替换的只是包的落点。告警规则建在指标上:http_server_requests_seconds_count 按 outcome 分组做增速告警,orders_placed_total 突降说明下单入口出问题,这些规则的取值口径与本文验证脚本读到的数字完全一致。

导出数字本身也挺有意思,我单独拎出来复盘。logs 计数 2 恰好等于建单次数 2,说明两笔订单的 OTLP 日志记录在 1 秒批量窗口里各自成批发出;traces 计数 3 小于请求数 6,是因为 schedule-delay 的批量把多个请求的 Span 合进同一批,收集器侧看到的是批次不是单个请求。你排查「导出量对不上请求数」时先看批量参数,再看采样比例,两个旋钮都在 management.opentelemetry.tracing.export 下面。

避坑指南

下面每一条都是我这次实测亲手踩到或差点踩到的,不是文档摘抄。

坑一,属性前缀三套并存,抄错一个信号全哑。 链路是 management.opentelemetry.tracing.export.otlp.,日志是 management.opentelemetry.logging.export.otlp.,指标是 management.otlp.metrics.export.*。照 3.x 老配置抄 management.otlp.tracing.endpoint,链路导出静默不生效,旧前缀只是兼容别名,文档里已不推荐。第一次接 OTLP 导出扑空,九成是这个原因。判别办法我吃了一次亏才记住:解开目标 jar 看 spring-configuration-metadata.json,别信记忆。

坑二,OTLP endpoint 要写完整信号路径。 只配 http://127.0.0.1:4318,traces 一个包都发不出。通用环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 会被自动追加 /v1/traces,但信号专属属性按字面使用。三行配置都带 /v1/xxx 后缀最稳。

坑三,计数器名以 created 结尾会被吞。 我把 Counter 起名 orders.created,prometheus 端点导出的名字却是 orders_total,created 这一节被 Prometheus 命名约定当成 OpenMetrics 的时间戳后缀处理掉了。后来改名 orders.placed,导出名 orders_placed_total 才正常。

坑四,应用日志不会自动桥接 OTLP。 SdkLoggerProvider 和导出器自动就位,/v1/logs 却一个包都没有。SLF4J 到 OTel 的桥接要引 instrumentation 系的 logback appender,或者像本文用 Logger API 手动发射。以为 starter 一加日志就上云的,等排障时才会发现日志平台是空的。

坑五,日志导出开关的默认值要查清。 management.logging.export.otlp.enabled 默认 true,配上 endpoint 就生效。测试环境没有收集器时会持续告警,我给测试用了三个属性整体关掉 OTel 导出,context 才安静。

坑六,http.server.requests 的 uri 标签是路由模板。 过滤失败请求时查 uri:/api/orders/1/fail 永远查不到,真实标签值是 uri:/api/orders/{id}/fail。metrics 端点的查询参数里传大括号还有二次坑:RestTestClient 把它当 URI 变量展开报错,curl 发裸大括号被 Tomcat 400 拒掉,得用 %7B%7D 编码。我从 prometheus 文本断言,最省事。

坑七,OTel 自动配置类的包名换了地方。 Boot 3 在 org.springframework.boot.actuate.autoconfigure.opentelemetry,4.1 搬到 org.springframework.boot.opentelemetry.autoconfigure 与 org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure。IDE 里按老包名排除自动配置会扑空。

坑八,注入 MeterRegistry 时留意注册的是哪个 registry。 主运行时同时存在 Simple、Prometheus、OTLP 多个 registry,业务代码拿到的 MeterRegistry 是组合视图,写进去各出口都能看到。单独断言某个 registry 的内容时,从对应端点读,别直接看业务代码里的对象。

坑九,Observation 的高基数标签撑爆指标。 order.id 这类每请求一个值的键,放进 lowCardinalityKeyValue 会让指标时间线爆炸。Observation 的键值分高基数低基数两档,订单号这类只能进 trace 的 attribute,指标标签只放 stage、outcome 这类有限枚举。

坑十,OTLP 导出失败不阻断业务。 收集器挂掉时应用照常服务,导出器按批重试后打 WARN(io.opentelemetry.exporter 调到 WARN 级别降噪)。这是可观测性组件的正确姿势,但也意味着信号丢了不会有人报错,收集器的存活要靠平台侧监控。

坑十一,分位数样本太少没有意义。 publishPercentiles(0.5, 0.95) 在样本只有 1 个时两个分位数输出同一个值。看 p95 要么攒样本,要么改用 percentileHistogram 交给后端聚合。

坑十二,prometheus 指标需要手动补依赖。 Boot 没有 prometheus starter,micrometer-registry-prometheus 不在依赖里时,exposure.include 写了 prometheus 也不会有这个端点,Actuator 的端点注册以对应 registry 的存在为前提。

坑十三,测试期记得整体关掉 OTel 导出。 @SpringBootTest 默认继承主配置,三行 endpoint 照样生效,测试期间每个用例都在往不存在的收集器发包。management.opentelemetry.enabled 一个开关退回 no-op,比逐个关导出器干净。

小结

三件套怎么接,我的建议按信号分头走。指标这条线最省心,Micrometer 门面稳定,Counter 和 Timer 声明在业务代码,actuator 端点读数、prometheus 刮擦、OTLP 推送三个出口共享同一份数据,接入成本接近零;链路走 Observation,一个 API 同时产出指标和 Span,业务代码不用关心后端是谁,采样比例和批量节奏按导出量权衡;日志最特殊,traceId 进控制台日志靠 MDC,进 OTLP 靠显式发射,SLF4J 桥接是官方声明不做的部分,接入前先把这条边界弄清楚。从 3.x 迁 4.x 的项目,把属性前缀的三套体系、starter 的模块重组、以 created 结尾的指标名陷阱过一遍,雷区基本就排完了。

没解决的事也直说:假收集器只证明包发到了本机 4318 端口,后端侧我还没接真 Collector,告警规则和可视化面板都停在纸面上。等我把 Collector 加 Prometheus、Tempo、Loki 的组合在自己环境里跑通,用同一套验证脚本对着真后端再测一轮,到时候回来补后端侧的接线实录。

参考链接