OpenTelemetry:trace、log 和 metric 的统一标准

1. 概述

当系统拆分为多个微服务时,一个请求可能会经过五个、十个不同的进程。当出现故障时,你需要知道请求去了哪里、在哪一步变慢了,以及哪些日志属于这个请求。OpenTelemetry (OTel) 是一个开放标准,正好解决了这个问题。它统一了如何收集 trace、metric 和 log 的方式,然后将它们发送到任何后端。

读完这篇文章后,你将理解 OTel 的核心概念以及它为什么受关注。你还会学到如何将 OTel 集成到 Spring Boot 3 应用中、运行 Collector、为 instrumentation 部分编写测试,以及何时不应该使用它。

2. OpenTelemetry 是什么?

OpenTelemetry 是一个属于 CNCF 的开源项目。它定义了如何创建、描述和传输遥测数据。OTel 不是一个数据存储或可视化界面。那部分由 Jaeger、Prometheus、Grafana Tempo/Loki 或商业服务等后端来处理。

2.1. 三种信号 (signals)

  • Trace:一个请求的完整路径。每个 trace 由多个 span 组成,每个 span 代表一个工作单元,例如“调用 PaymentService”或“查询 orders 表”。Span 包含开始时间、结束时间和各种属性。
  • Metric:随时间汇总的数值,比如每秒请求数、p99 延迟或失败订单数。
  • Log:离散的事件记录。OTel 会在日志中加入 traceId 和 spanId,这样你可以从一条日志跳转到它的完整 trace。

最大的价值在于相关性。三种信号共享相同的上下文和资源描述,例如 service.name。

2.2. 主要组件

组件 作用
API 供代码创建 span 和 metric 的接口,不依赖特定供应商
SDK API 的具体实现:采样、批处理、导出
OTLP 数据传输协议 (gRPC 或 HTTP/protobuf)
Collector 独立进程,接收、处理和转发遥测数据
Semantic conventions 属性命名约定,如 http.request.method、db.system…
Instrumentation 为流行框架自动生成遥测数据的库或 agent

2.3. 上下文传播

要让不同服务中的 span 连接成一个 trace,上下文必须随请求传播。对于 HTTP,OTel 默认使用 W3C Trace Context 标准,通过 traceparent 头传递:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

这个头有四部分:version、traceId、父 span 的 spanId 和采样标志。接收请求的服务读取这个头,并创建属于同一 trace 的子 span。

3. 为什么 OpenTelemetry 正在受关注?

  • 厂商中立:你只需 instrument 一次代码。想更换后端时,只需修改 exporter 配置或 Collector,无需改代码。
  • 统一旧标准:OTel 是通过合并 OpenTracing 和 OpenCensus 而来。社区不再需要在两个方向之间选择。
  • 三种信号的统一标准:以前每种数据类型通常有自己的 agent 和格式。
  • 广泛的生态系统:许多框架、云提供商和 observability 工具都支持 OTLP。Spring Boot 3 也通过 Micrometer 集成了它。
  • 灵活的 Collector:你可以在基础设施层过滤敏感数据、采样和路由到多个后端,无需修改应用。

4. 在 Spring Boot 3 中应用 OpenTelemetry

Spring Boot 3 使用 Micrometer Observation 作为抽象层。你编写一个 observation,Micrometer 会同时生成 metric(计时器)和 span。通过 OTel 的 bridge 来处理导出部分。

4.1. 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- Micrometer Tracing 到 OpenTelemetry 的桥接 -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <!-- 通过 OTLP 导出 trace -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <!-- 通过 OTLP 导出 metric -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-otlp</artifactId>
    </dependency>
    <!-- 用于测试 -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-observation-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

你不需要手动指定版本。Spring Boot 通过 BOM 管理 Micrometer 和 OpenTelemetry 库的版本。

4.2. 配置

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0   # 开发:采样 100%;生产环境应该降低
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
    metrics:
      export:
        url: http://localhost:4318/v1/metrics
        step: 30s

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

各配置组的含义:

  • spring.application.name 用作 service.name。这是后端用来区分不同服务的最重要属性。
  • management.tracing.sampling.probability 决定了记录 trace 的比例。
  • 两个 otlp 端点指向通过 OTLP/HTTP 的 Collector,默认端口是 4318。
  • logging.pattern.level 将 MDC 中的 traceId 和 spanId 注入到每一行日志中。这是将日志与 trace 关联的最简单方式。

4.3. 使用 Observation API 对业务逻辑进行 instrumentation

进入 controller 的 HTTP 请求和通过 RestClient/WebClient 的调用已由 Spring 自动 instrument。你只需手动添加重要的业务步骤:

public record Order(String id, String paymentMethod, long amount) {}
@FunctionalInterface
public interface PaymentService {
    String charge(Order order);
}
@Service
public class CheckoutService {

    private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);

    private final ObservationRegistry registry;
    private final PaymentService paymentService;

    public CheckoutService(ObservationRegistry registry, PaymentService paymentService) {
        this.registry = registry;
        this.paymentService = paymentService;
    }

    public String checkout(Order order) {
        return Observation.createNotStarted("order.checkout", registry)
                .lowCardinalityKeyValue("payment.method", order.paymentMethod())
                .highCardinalityKeyValue("order.id", order.id())
                .observe(() -> {
                    log.info("Processing order {}", order.id());
                    return paymentService.charge(order);
                });
    }
}

代码中需要注意的几点:

  • Observation.createNotStarted("order.checkout", registry) 创建一个名为 order.checkout 的 observation。
  • lowCardinalityKeyValue 添加一个基数较低的属性(CARD、CASH、WALLET)。这个属性出现在 metric 和 span 中。
  • highCardinalityKeyValue 添加一个基数非常高的属性,如订单号。它仅附加到 span,不会膨胀 metric。
  • observe(...) 打开 span、测量时间、记录异常(如果有)、然后当 supplier 返回时关闭 span。

运行时,日志会像这样:

 INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a1b2c3d4e5f60718] ... Processing order A-1001

后端会收到两样东西:

  • 一个 order.checkout span,嵌套在请求的 HTTP span 下面。
  • 一个 order.checkout timer metric,按 payment.method 分类。从这个 metric 你可以计算调用次数、总时间和最大时间。

提示:在 Spring 代码中优先使用 Observation API,而不是直接调用 io.opentelemetry.api.trace.Tracer。这样你的代码就不会依赖特定的 tracing 库。一次测量同时产生 metric 和 trace。

4.4. 替代方案:Java agent

如果你不想修改代码或添加依赖,可以使用 OpenTelemetry Java agent。Agent 会自动 instrument 许多流行的库,如 JDBC、HTTP client 和 Kafka:

export OTEL_SERVICE_NAME=order-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1

java -javaagent:./opentelemetry-javaagent.jar -jar order-service.jar

Agent 适合用于旧应用或当运维团队想集中控制 instrumentation 的情况。折衷之处是你对细节的控制会变少,启动时间增加,agent 像一个“黑盒子”修改字节码。

提示:不要同时运行 Java agent 和 Micrometer bridge 来收集相同类型的信号。你会收到重复的 span 或上下文混乱。

5. 运行 OpenTelemetry Collector

应用应该将数据发送到 Collector 而不是直接发送到后端。Collector 是批处理、过滤和路由数据的地方。以下是一个最小化配置:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch: {}
  attributes/strip-pii:
    actions:
      - key: customer.email
        action: delete

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [attributes/strip-pii, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

这个配置有四个部分:

  • receivers.otlp 通过 gRPC (4317) 和 HTTP (4318) 接收数据。
  • attributes/strip-pii 在数据离开你的基础设施之前删除 customer.email 属性。
  • batch 在发送前聚合数据,以减少请求数量。
  • debug exporter 将数据打印到控制台供你检查。在生产环境中,用你所使用后端的 exporter 替换它。较旧版本的 Collector 用 logging 作为这个 exporter 的名称。

用 Docker 启动 Collector:

docker run --rm -p 4317:4317 -p 4318:4318 \
  -v "$(pwd)/otel-collector.yaml:/etc/otelcol-contrib/config.yaml" \
  otel/opentelemetry-collector-contrib

调用一次 checkout API,然后查看 Collector 的控制台。你会看到类似这样的 span:

Span #0
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Parent ID      : 9a8b7c6d5e4f3a2b
    Name           : order.checkout
    Attributes:
         -> payment.method: Str(CARD)
         -> order.id: Str(A-1001)

6. 测试 instrumentation 部分

Instrumentation 也是代码,所以也需要测试。TestObservationRegistry 让你在不需要启动 Spring context 或 Collector 的情况下验证 observation:

import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class CheckoutServiceTest {

    @Test
    void whenCheckout_thenObservationIsRecordedWithPaymentMethod() {
        TestObservationRegistry registry = TestObservationRegistry.create();
        PaymentService paymentService = order -> "PAID-" + order.id();
        CheckoutService service = new CheckoutService(registry, paymentService);

        String result = service.checkout(new Order("A-1001", "CARD", 250_000));

        assertThat(result).isEqualTo("PAID-A-1001");
        TestObservationRegistryAssert.assertThat(registry)
                .hasObservationWithNameEqualTo("order.checkout")
                .that()
                .hasLowCardinalityKeyValue("payment.method", "CARD")
                .hasHighCardinalityKeyValue("order.id", "A-1001")
                .hasBeenStarted()
                .hasBeenStopped();
    }
}

这个测试验证了三件事:

  • 业务逻辑结果是正确的。
  • order.checkout observation 已被创建,并带有正确的属性。
  • Observation 已启动并关闭。一个忘记关闭的 span 是生产环境中很难检测到的错误。

7. 常见错误

  • 基数爆炸:将 userId、orderId 或包含参数的 URL 放入 metric 标签会导致 metric 字符串数量激增,存储成本随之增加。这些值应该只附加到 span。
  • 在生产环境采样 100%:数据量和成本随流量线性增长。降低 probability,或在 Collector 使用尾采样来保留错误或慢速的 trace。
  • 异步运行时丢失上下文:使用 @Async、CompletableFuture 或自创的 thread pool 时,上下文不会自动传到新线程。你需要用上下文传播机制(如上下文传播库的 ContextExecutorService)或使用 Spring 管理的执行器(已预配置)。
  • 敏感数据泄露:email、卡号或令牌可能进入属性或日志。在代码中立即过滤它们,再在 Collector 添加一层过滤。
  • 任意命名:不遵循 semantic conventions 使得仪表板和告警在服务间难以重用。
  • 跳过 Collector:直接从应用发送到后端意味着每次切换后端都要重新部署所有服务。

8. 何时不应该使用(或尚不需要)

  • 小型应用、单实例、依赖少:结构化日志加上 Actuator 提供的几个 metric 通常已足够。当没有“分布式”的东西时,分布式 tracing 价值有限。
  • 团队还没有后端和运维人员:OTel 只是管道。如果没人看数据,你只是在多花 CPU、网络和存储费用。
  • 系统对延迟或资源极度敏感:比如嵌入式应用或计时以微秒计的热路径。在广泛启用 instrumentation 之前,先测量开销。
  • 期待 OTel 一次性替代现有所有工具:不同信号和语言的成熟度可能不同。检查你要使用部分的支持状态,并逐步迁移,从 tracing 开始。

9. 总结

OpenTelemetry 通过公共 API、OTLP 协议和 Collector 统一了如何创建和传输 trace、metric 和 log。这样你可以将代码中的 instrumentation 与后端选择分离。

在 Spring Boot 3 中,最简洁的路径包括四步。第一,用 Micrometer Observation 一次测量同时产生 metric 和 span。第二,通过 OTLP 导出到 Collector。第三,在日志中注入 traceId。第四,为重要的 observation 编写测试。同时,从一开始就控制基数、采样和敏感数据。

如果你的系统还很小,不必急着做。当服务数增长、故障排查开始耗费数小时时,OTel 就是值得投入的基础设施。

opentelemetryobservabilityspring-boottracingtech-trends