OpenTelemetry: chuẩn chung cho trace, log và metric

1. Tổng quan

Khi hệ thống tách thành nhiều service, một request có thể đi qua năm, mười process khác nhau. Lúc có lỗi, bạn cần biết request đã đi đâu, chậm ở bước nào và log nào thuộc về request đó. OpenTelemetry (OTel) là chuẩn mở giải quyết đúng việc này. Nó thống nhất cách thu thập trace, metric và log, rồi gửi chúng tới bất kỳ backend nào.

Đọc xong bài này, bạn sẽ hiểu các khái niệm cốt lõi của OTel và lý do nó được quan tâm. Bạn cũng sẽ biết cách gắn OTel vào một ứng dụng Spring Boot 3, chạy Collector, viết test cho phần instrumentation và nhận ra khi nào không nên dùng nó.

2. OpenTelemetry là gì?

OpenTelemetry là một dự án mã nguồn mở thuộc CNCF. Nó định nghĩa cách tạo, mô tả và truyền tải dữ liệu telemetry. OTel không phải là nơi lưu trữ hay giao diện xem dữ liệu. Phần đó do các backend như Jaeger, Prometheus, Grafana Tempo/Loki hoặc các dịch vụ thương mại đảm nhận.

2.1. Ba loại tín hiệu (signals)

  • Trace: hành trình của một request. Mỗi trace gồm nhiều span, mỗi span là một đơn vị công việc, ví dụ “gọi PaymentService” hay “query bảng orders”. Span có thời gian bắt đầu, thời gian kết thúc và các attribute.
  • Metric: số liệu tổng hợp theo thời gian, như số request mỗi giây, độ trễ p99 hay số đơn hàng thất bại.
  • Log: bản ghi sự kiện rời rạc. OTel gắn thêm traceId và spanId vào log, nhờ đó bạn nhảy được từ một dòng log sang đúng trace của nó.

Giá trị lớn nhất nằm ở sự liên kết. Cả ba tín hiệu dùng chung ngữ cảnh và chung mô tả tài nguyên, ví dụ service.name.

2.2. Các thành phần chính

Thành phần Vai trò
API Interface để code tạo span và metric, không phụ thuộc vendor
SDK Hiện thực API: sampling, batching, export
OTLP Giao thức truyền dữ liệu (gRPC hoặc HTTP/protobuf)
Collector Process độc lập nhận, xử lý và chuyển tiếp telemetry
Semantic conventions Quy ước đặt tên attribute như http.request.method, db.system…
Instrumentation Thư viện hoặc agent tự động tạo telemetry cho framework phổ biến

2.3. Context propagation

Để các span ở nhiều service ghép được thành một trace, ngữ cảnh phải đi theo request. Với HTTP, OTel mặc định dùng chuẩn W3C Trace Context qua header traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Header này có bốn phần: version, traceId, spanId của span cha và cờ sampling. Service nhận request đọc header này và tạo span con thuộc cùng trace.

3. Vì sao OpenTelemetry đang được quan tâm?

  • Trung lập với vendor: bạn instrument code một lần. Muốn đổi backend thì chỉ sửa cấu hình exporter hoặc Collector, không phải sửa code.
  • Hợp nhất các chuẩn cũ: OTel ra đời từ việc gộp OpenTracing và OpenCensus. Cộng đồng không còn phải chọn giữa hai hướng.
  • Một chuẩn cho cả ba tín hiệu: trước đây mỗi loại dữ liệu thường có một agent và một định dạng riêng.
  • Hệ sinh thái rộng: nhiều framework, cloud provider và công cụ observability hỗ trợ OTLP. Spring Boot 3 cũng tích hợp qua Micrometer.
  • Collector linh hoạt: bạn lọc dữ liệu nhạy cảm, lấy mẫu và định tuyến tới nhiều backend ở tầng hạ tầng, không cần đụng vào ứng dụng.

4. Áp dụng OpenTelemetry vào Spring Boot 3

Spring Boot 3 dùng Micrometer Observation làm lớp trừu tượng. Bạn viết một observation, Micrometer sinh ra cả metric (timer) lẫn span. Bridge sang OTel đảm nhận phần export.

4.1. Dependency

<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>
    <!-- Cầu nối Micrometer Tracing -> OpenTelemetry -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <!-- Export trace qua OTLP -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <!-- Export metric qua OTLP -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-otlp</artifactId>
    </dependency>
    <!-- Dùng cho test -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-observation-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Bạn không cần ghi version. Spring Boot đã quản lý version của các thư viện Micrometer và OpenTelemetry qua BOM.

4.2. Cấu hình

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0   # dev: lấy mẫu 100%; production nên giảm
  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:-}]"

Ý nghĩa từng nhóm cấu hình:

  • spring.application.name được dùng làm service.name. Đây là attribute quan trọng nhất để backend phân biệt các service.
  • management.tracing.sampling.probability quyết định tỉ lệ trace được ghi lại.
  • Hai endpoint otlp trỏ tới Collector qua OTLP/HTTP, cổng mặc định là 4318.
  • logging.pattern.level chèn traceId và spanId từ MDC vào mỗi dòng log. Đây là cách đơn giản nhất để liên kết log với trace.

4.3. Instrument nghiệp vụ bằng Observation API

Các HTTP request vào controller và các lời gọi qua RestClient/WebClient do Spring tạo đã được instrument tự động. Phần bạn cần tự thêm là các bước nghiệp vụ quan trọng:

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);
                });
    }
}

Điểm cần chú ý trong đoạn code trên:

  • Observation.createNotStarted("order.checkout", registry) tạo một observation tên order.checkout.
  • lowCardinalityKeyValue thêm attribute có ít giá trị (CARD, CASH, WALLET). Attribute này xuất hiện ở cả metric lẫn span.
  • highCardinalityKeyValue thêm attribute có rất nhiều giá trị như mã đơn hàng. Nó chỉ gắn vào span, không làm phình metric.
  • observe(...) mở span, đo thời gian, ghi nhận exception nếu có, rồi đóng span khi supplier trả về.

Khi chạy, log sẽ trông như sau:

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

Phía backend nhận được hai thứ:

  • Một span order.checkout nằm dưới span HTTP của request.
  • Một timer metric order.checkout, chia theo payment.method. Từ metric này bạn tính được số lần gọi, tổng thời gian và thời gian tối đa.

Lưu ý: Ưu tiên Observation API thay vì gọi trực tiếp io.opentelemetry.api.trace.Tracer trong code Spring. Code của bạn sẽ không phụ thuộc vào thư viện tracing cụ thể. Một lần đo cũng cho ra cả metric lẫn trace.

4.4. Phương án thay thế: Java agent

Nếu không muốn sửa code hay thêm dependency, bạn có thể dùng OpenTelemetry Java agent. Agent tự instrument rất nhiều thư viện phổ biến như JDBC, HTTP client và 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 phù hợp với ứng dụng cũ hoặc khi team vận hành muốn kiểm soát instrumentation tập trung. Đánh đổi là bạn khó kiểm soát chi tiết hơn, thời gian khởi động tăng, và agent hoạt động như một “hộp đen” can thiệp bytecode.

Lưu ý: Đừng chạy đồng thời Java agent và bộ Micrometer bridge cho cùng một loại tín hiệu. Bạn sẽ nhận span trùng lặp hoặc context bị lệch.

5. Chạy OpenTelemetry Collector

Ứng dụng nên gửi dữ liệu tới Collector thay vì gửi thẳng tới backend. Collector là nơi bạn batch, lọc và định tuyến dữ liệu. Dưới đây là một cấu hình tối thiểu:

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]

Cấu hình này gồm bốn phần:

  • receivers.otlp nhận dữ liệu qua cả gRPC (4317) và HTTP (4318).
  • attributes/strip-pii xoá attribute customer.email trước khi dữ liệu rời khỏi hạ tầng của bạn.
  • batch gom dữ liệu lại trước khi gửi để giảm số lượng request.
  • Exporter debug in dữ liệu ra console để bạn kiểm tra. Trong môi trường thật, bạn thay nó bằng exporter của backend đang dùng. Các bản Collector cũ dùng tên logging cho exporter này.

Khởi chạy Collector bằng Docker:

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

Gọi thử API checkout, rồi xem console của Collector. Bạn sẽ thấy span tương tự:

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

6. Test phần instrumentation

Instrumentation cũng là code, nên cũng cần được test. TestObservationRegistry cho phép bạn kiểm tra observation mà không cần khởi động Spring context hay Collector:

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();
    }
}

Test này khẳng định ba điều:

  • Kết quả nghiệp vụ vẫn đúng.
  • Observation order.checkout đã được tạo, kèm đúng attribute.
  • Observation đã được mở rồi đóng. Một span quên đóng là lỗi rất khó phát hiện trên production.

7. Lỗi hay gặp

  • Bùng nổ cardinality: đưa userId, orderId hay URL chứa tham số vào metric tag sẽ làm số chuỗi metric tăng vọt và chi phí lưu trữ tăng theo. Những giá trị này chỉ nên gắn vào span.
  • Lấy mẫu 100% trên production: lượng dữ liệu và chi phí tăng tuyến tính theo traffic. Hãy giảm probability, hoặc dùng tail sampling ở Collector để giữ lại các trace lỗi hay trace chậm.
  • Mất context khi chạy bất đồng bộ: với @Async, CompletableFuture hay thread pool tự tạo, context không tự đi theo sang thread mới. Bạn cần bọc executor bằng cơ chế context propagation, ví dụ ContextExecutorService của thư viện context-propagation, hoặc dùng executor do Spring quản lý đã được cấu hình sẵn.
  • Rò rỉ dữ liệu nhạy cảm: email, số thẻ hay token có thể lọt vào attribute hoặc log. Hãy lọc ngay trong code và thêm một lớp lọc nữa ở Collector.
  • Đặt tên tuỳ tiện: không theo semantic conventions khiến dashboard và alert khó dùng lại giữa các service.
  • Bỏ qua Collector: gửi thẳng từ ứng dụng tới backend thì mỗi lần đổi backend bạn phải deploy lại toàn bộ service.

8. Khi nào KHÔNG nên dùng (hoặc chưa cần)

  • Ứng dụng nhỏ, một instance, ít phụ thuộc: log có cấu trúc kèm vài metric từ Actuator thường đã đủ. Distributed tracing gần như không mang lại giá trị khi không có gì “distributed”.
  • Team chưa có backend và người vận hành: OTel chỉ là đường ống. Nếu không ai xem dữ liệu, bạn chỉ trả thêm CPU, network và tiền lưu trữ.
  • Hệ thống cực nhạy về độ trễ hoặc tài nguyên: chẳng hạn ứng dụng nhúng hay các hot path tính bằng micro giây. Hãy đo overhead trước khi bật instrumentation rộng rãi.
  • Mong đợi OTel thay thế toàn bộ công cụ hiện có trong một lần: mức độ chín muồi của từng tín hiệu và từng ngôn ngữ có thể khác nhau. Hãy kiểm tra trạng thái hỗ trợ của phần bạn định dùng và chuyển đổi từng bước, bắt đầu từ tracing.

9. Kết luận

OpenTelemetry thống nhất cách tạo và truyền tải trace, metric, log thông qua API chung, giao thức OTLP và Collector. Nhờ đó bạn tách được phần instrumentation trong code khỏi lựa chọn backend.

Với Spring Boot 3, con đường gọn nhất gồm bốn bước. Thứ nhất, dùng Micrometer Observation để một lần đo cho ra cả metric lẫn span. Thứ hai, export qua OTLP tới Collector. Thứ ba, chèn traceId vào log. Thứ tư, viết test cho các observation quan trọng. Song song với đó, hãy kiểm soát cardinality, sampling và dữ liệu nhạy cảm ngay từ đầu.

Nếu hệ thống của bạn còn nhỏ, chưa cần vội. Khi số service tăng lên và việc truy lỗi bắt đầu tốn hàng giờ, OTel là nền tảng đáng đầu tư.

opentelemetryobservabilityspring-boottracingtech-trends