Spring Production-Ready
15/26
Bài 15 / 26~12 phútObservability & Production-readyMiễn phí lượt xem

Observation API — đo một lần, ra cả metric lẫn span

Thay vì gắn timer rồi lại gắn span riêng, Observation API cho một lần khai báo sinh ra cả hai. @Observed, ObservationRegistry và context propagation.

TL;DR: Gắn Timer thủ công để đo latency, mở Span thủ công để trace, ghi log start/end riêng — ba đoạn code cho cùng một sự kiện, lệch nhau ngay lần refactor đầu tiên. Observation API gộp cả ba: khai báo một lần "có việc đáng quan sát đang diễn ra" qua ObservationRegistry, các handler đã đăng ký (metric, tracing) tự nghe và tự dựng Timer/Span. lowCardinalityKeyValue lên cả metric lẫn trace; highCardinalityKeyValue chỉ lên trace — lời giải cho câu hỏi "muốn biết userId mà không được gắn vào metric" ở bài trước. @Observed gọn hơn nhưng vẫn đi qua AOP proxy, nên self-call bị bỏ qua.

Ba lần đo cho một sự kiện là ba chỗ phải nhớ sửa mỗi khi logic đổi. Bài này giải thích cơ chế Observation API dùng để gộp chúng lại thành một điểm khai báo duy nhất, cùng cái giá phải trả nếu quên đăng ký đúng cách.

1. Ba lần đo cho một sự kiện, và chúng lệch nhau

TaskService.completeTask của TaskFlow đánh dấu một task hoàn thành rồi gọi notificationService báo cho thành viên liên quan. Team vận hành cần ba thứ: latency P99 lên dashboard (bài trước đã đo), một span biết bước nào trong chuỗi gọi notification chậm, và một dòng log start/end để on-call dò lúc nửa đêm. Cách quen thuộc: viết riêng ba đoạn code cho ba nhu cầu.

Java
public void completeTask(Long taskId) {
    Timer.Sample sample = Timer.start(meterRegistry);
    Span span = tracer.nextSpan().name("task.complete").start();
    log.info("task.complete start taskId={}", taskId);

    try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
        Task task = taskRepository.findById(taskId).orElseThrow();
        task.markCompleted();
        notificationService.notifyCompleted(task);
    } finally {
        span.end();
        sample.stop(meterRegistry.timer("task.complete"));
        log.info("task.complete end taskId={}", taskId);
    }
}

Chạy đúng — cho tới sáu tháng sau, một dev thêm nhánh xử lý task đã hoàn thành từ trước, để tránh gọi notification hai lần. Chỗ tự nhiên nhất để đặt guard clause là ngay đầu method, trước khi Timer bắt đầu chạy:

Thử đoán

Nếu guard clause được đặt trước đoạn Timer.start(...): metric, span và log của những lần gọi bị skip sẽ ra sao? Viết dự đoán cụ thể cho từng cái trước khi đọc tiếp.

Java
public void completeTask(Long taskId) {
    Task task = taskRepository.findById(taskId).orElseThrow();
    if (task.isCompleted()) {
        log.info("task.complete skip taskId={} already-done", taskId);
        return;                              // them moi - Timer va Span chua kip chay
    }

    Timer.Sample sample = Timer.start(meterRegistry);
    Span span = tracer.nextSpan().name("task.complete").start();
    // ... phan con lai giu nguyen nhu tren
}

Dòng log skip vẫn chạy — dev nhớ thêm nó vì đó đúng là thứ anh ta đang debug. Nhưng TimerSpan thì im lặng: không gì báo cho biết một nhánh xử lý hoàn chỉnh vừa chạy mà chẳng ai đo. Vài tuần sau, báo cáo P99 bỗng "tốt bất thường" — vì các request vào nhánh skip không lọt vào mẫu số. Không ai bất cẩn: ba cơ chế đo tách rời là ba chỗ cùng phải nhớ update mỗi lần logic đổi, xác suất bỏ sót tăng theo số lần refactor.

2. Một Observation, nhiều handler cùng nghe

Observation API đảo ngược mô hình trên. Thay vì tự tay gọi Timer, tự tay gọi Span, tự tay ghi log, bạn khai báo đúng một câu: "có một việc đáng quan sát đang diễn ra, tên là task.complete". ObservationRegistry — registry trung tâm chứa các ObservationHandler đã đăng ký — tự gọi từng handler đúng thời điểm trong vòng đời: bắt đầu, kết thúc, có lỗi. Handler đo metric nghe được thì tự dựng Timer; handler tracing nghe được thì tự dựng Span. Bạn khai báo sự kiện, framework quyết định ai lắng nghe.

flowchart TD
    OBS[Observation task.complete - mot lan khai bao] --> METRIC[Metric handler]
    OBS --> TRACE[Tracing handler]
    METRIC --> OUT1[Tu dung Timer, ghi vao Prometheus]
    TRACE --> OUT2[Tu dung Span, xuat ve OTLP]

Guard clause giờ chỉ còn đúng một chỗ để đặt: bên trong hay bên ngoài lambda được observe. Đặt ngoài — task bị skip hoàn toàn, không observation nào được tạo, và điều đó nhất quán trên cả metric lẫn trace. Không còn khả năng một cơ chế nhớ, một cơ chế quên.

3. ObservationRegistryobserve() — API mức thấp

Java
private final ObservationRegistry observationRegistry;

public void completeTask(Long taskId) {
    Observation.createNotStarted("task.complete", observationRegistry)
        .observe(() -> {
            Task task = taskRepository.findById(taskId).orElseThrow();
            if (task.isCompleted()) {
                return;
            }
            task.markCompleted();
            notificationService.notifyCompleted(task);
        });
}

createNotStarted tạo một Observation ở trạng thái chưa chạy — chỉ có tên và registry. .observe(() -> ...) mới thực sự vận hành: start, mở scope (code lồng bên trong tự biết mình đang nằm trong observation cha), chạy lambda, bắt exception nếu có, đóng scope, rồi stop. Mỗi handler đã đăng ký tự chuyển các lifecycle event đó thành hành động riêng — Timer.Sample tự start rồi stop, Span tự start rồi end — mà code nghiệp vụ không hề biết Timer hay Span tồn tại.

4. Vì sao taskId không lên metric mà vẫn thấy được ở trace?

Bài trước đã dạy: gắn một định danh unbounded — userId, taskId, orderId, thứ mà mỗi bản ghi một giá trị khác nhau — làm tag của một Timer là sai, vì mỗi giá trị mới sinh một series mới trong Prometheus, làm bộ nhớ time-series phình vô hạn. Nhưng khi debug một request cụ thể, bạn lại cần biết chính xác taskId nào gây chậm. Observation API giải quyết căng thẳng đó bằng hai method tách biệt trên cùng một object — tự điền trước khi xem đáp án:

Java
Observation.createNotStarted("task.complete", observationRegistry)
    /* TODO: outcome (vd "success") va taskId - gia tri nao di lowCardinalityKeyValue,
       gia tri nao di highCardinalityKeyValue? */
    .observe(() -> {
        // logic xu ly task
    });
Tự điền trước khi xem đáp án

Có hai giá trị cần gắn: outcome (chỉ nhận success/failure/skip) và taskId (mỗi task một giá trị khác nhau). Giá trị nào đi vào lowCardinalityKeyValue, giá trị nào đi vào highCardinalityKeyValue? Đối chiếu quy tắc cardinality đã học ở bài trước trước khi chọn.

Đáp án:

Java
Observation.createNotStarted("task.complete", observationRegistry)
    .lowCardinalityKeyValue("outcome", outcome)
    .highCardinalityKeyValue("taskId", String.valueOf(taskId))
    .observe(() -> {
        // logic xu ly task
    });

outcome — tập giá trị hữu hạn nhỏ — vào lowCardinalityKeyValue; taskId — unbounded, mỗi task một giá trị — vào highCardinalityKeyValue. Gắn ngược lại tái tạo đúng lỗi cardinality bài trước, chỉ khác chỗ gọi.

flowchart TD
    LOW[lowCardinalityKeyValue] --> BOTH[Tag tren CA metric va span]
    HIGH[highCardinalityKeyValue] --> ONLY[Tag CHI tren span]

lowCardinalityKeyValue gắn vào cả metric tag lẫn span tag; highCardinalityKeyValue chỉ gắn vào span, không chạm tới metric. Đây là quy tắc cardinality bài trước, giờ được framework enforce bằng chữ ký method thay vì kỷ luật người viết: hai method tên khác nhau nên không thể gọi nhầm chỗ.

Trace xuất OTLP giữ cả taskId lẫn outcome để tra đúng request khi debug — phần export là chủ đề bài sau. Metric Prometheus chỉ giữ outcome — đủ vẽ tỉ lệ thành công theo thời gian mà không nổ cardinality.

5. @Observed gọn hơn — nhưng tự gọi nội bộ thì mất tác dụng

Viết Observation.createNotStarted(...).observe(...) tay ở mọi method cần đo vẫn là một lượng boilerplate lặp lại. @Observed nén nó thành một annotation:

Java
@Observed(name = "task.complete", contextualName = "task-complete")
public void completeTask(Long taskId) {
    Task task = taskRepository.findById(taskId).orElseThrow();
    if (task.isCompleted()) {
        return;
    }
    task.markCompleted();
    notificationService.notifyCompleted(task);
}

Annotation chạy được nhờ một bean riêng:

Java
@Bean
ObservedAspect observedAspect(ObservationRegistry registry) {
    return new ObservedAspect(registry);
}

ObservedAspect là một Spring AOP aspect bọc quanh method có @Observed, tự gọi Observation.createNotStarted(...).observe(...) giùm bạn. Vì là AOP proxy, quy tắc self-call bypass đã học ở spring-core áp dụng y hệt: proxy chỉ chặn được lời gọi vào bean từ bên ngoài, qua tham chiếu Spring quản lý. Gọi qua this trong cùng class đi thẳng vào object thật, bỏ qua toàn bộ aspect — @Transactional, @PreAuthorize gặp đúng lỗi này, @Observed không ngoại lệ.

🐛 Tìm bug trong code này

@Service
public class TaskService {

  @Observed(name = "task.complete")
  public void completeTask(Long taskId) {
      markDone(taskId);
  }

  public void completeBatch(List<Long> taskIds) {
      taskIds.forEach(this::completeTask);
  }
}

Ba cách sửa giống hệt bài AOP proxy ở spring-core: tách sang bean khác rồi inject vào, tự inject chính mình qua interface (self-injection), hoặc đẩy @Observed lên đúng entry point gọi từ bên ngoài thay vì method nội bộ.

6. ObservationConvention — đặt tên và tag một chỗ duy nhất

Khi một team dùng Observation API ở hàng chục method, tên và tag dễ trôi dạt: người viết task.complete, người khác viết taskComplete, người thứ ba quên gắn outcome. ObservationConvention gom quyết định đặt tên và tag vào một class, thay vì rải rác ở từng lời gọi .observe(...).

Java
class TaskObservationConvention
        implements ObservationConvention<Observation.Context> {

    @Override
    public String getName() {
        return "task.operation";
    }

    @Override
    public boolean supportsContext(Observation.Context context) {
        return true;
    }
}

Đăng ký convention này như một bean, Spring Boot tự áp nó lên ObservationRegistry khi khởi động. Mọi Observation tạo ra sau đó theo đúng tên và cấu trúc tag đã định sẵn, không phụ thuộc từng người viết method có nhớ đúng convention hay không.

7. Cái Boot đã tự đo sẵn, đừng dựng lại

Trước khi tự viết thêm @Observed, kiểm tra Boot đã đo sẵn chưa. Spring Boot tự động instrument HTTP server request tới controller Spring MVC, và HTTP client — nhưng chỉ khi inject RestClient.Builder, RestTemplateBuilder hoặc WebClient.Builder do Boot auto-configure rồi dùng nó tạo instance, chứ không tự gọi hàm khởi tạo trần. Metric sinh ra mang tên http.client.requests. Spring Data repository và R2DBC cũng đã được đo sẵn.

Gắn @Observed chồng lên method đã được Boot đo sinh ra observation trùng: hai entry cho cùng một request. Kiểm tra danh sách đã tự động trước khi thêm annotation của riêng mình.

Pitfall thường gặp

Nhầm 1 — đo quá dày: gắn @Observed lên mọi method riêng lẻ, kể cả helper nội bộ vài dòng. Mỗi Observation tạo overhead thật — dựng Context object, chạy qua toàn bộ handler chain — làm span trace phình ra hàng chục node vô nghĩa.

✅ Observe ở ranh giới có ý nghĩa nghiệp vụ: entry point của service, lời gọi ra ngoài (DB, HTTP, message queue). Helper nội bộ không cần Observation riêng — đã nằm trong scope của observation cha.

Nhầm 2 — tự đẩy việc sang thread khác rồi ngạc nhiên vì context biến mất: Observation propagate theo cơ chế gắn với thread hiện tại — giống MDC đã học ở spring-core, nó không tự nhảy qua một thread mới nếu bạn tự tay submit task vào ExecutorService hay tạo Thread trần.

✅ Thread boundary cần cơ chế truyền context tường minh — Micrometer có ContextSnapshotFactory để chụp và khôi phục context khi nhảy thread. Cùng lớp vấn đề với @Async: bài Async và executor mổ kỹ chuyện context không tự đi theo khi đổi thread.

📚 Deep Dive Spring Reference

Spec / reference chính thức:

Ghi chú: đọc khi cần verify chi tiết version Spring Boot bạn đang chạy.

Liên hệ các bài khác

  • Bài 03 — Micrometer, metric và tag — quy tắc cardinality được nhắc lại và giờ được enforce cơ học qua lowCardinalityKeyValue/highCardinalityKeyValue thay vì chỉ dựa vào kỷ luật viết code.
  • Bài 06 — Tracing và OTLP — phần export span ra collector, nối trace xuyên nhiều service, và gắn trace ID vào log; bài này chỉ tạo ra Observation, bài sau mới nói nó đi đâu.
  • Async và executor — cùng một lớp vấn đề context propagation khi việc bị đẩy sang thread khác, áp dụng cho cả @Async lẫn Observation.

Tóm tắt

  • Ba cơ chế đo tách rời — Timer, Span, log thủ công — là ba chỗ phải nhớ sửa mỗi lần refactor; Observation API gộp thành một điểm khai báo duy nhất.
  • ObservationRegistry phát lifecycle event cho từng ObservationHandler đã đăng ký; code nghiệp vụ không biết Timer hay Span tồn tại.
  • ObservationConvention gom quyết định đặt tên và cấu trúc tag vào một class dùng chung, tránh tên trôi dạt khi nhiều người cùng viết Observation cho các method khác nhau.
  • @Observed không tạo Observation nào mà không báo lỗi gì — nghi ngay self-call qua this trước khi nghi cấu hình sai chỗ khác.
  • Trước khi gắn @Observed cho một lời gọi ra ngoài, tra /actuator/metrics xem Boot đã tự sinh chưa — tránh Observation trùng.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao ba cơ chế đo tách rời (Timer thủ công, Span thủ công, log thủ công) dễ lệch nhau hơn một Observation duy nhất? Giải thích bằng chính guard clause ở đầu bài.
  2. Q2
    ObservationRegistry làm gì khi bạn gọi .observe(() -> ...)? Liệt kê đúng thứ tự các bước vòng đời.
  3. Q3
    Bạn cần biết chính xác userId nào gây một request task.complete chậm, nhưng không được để userId lên metric Prometheus. Method nào của Observation API giải quyết đúng yêu cầu này, và vì sao nó an toàn cho cardinality?
  4. Q4
    Trong đoạn code sau, method nào thực sự tạo ra một Observation khi completeBatch được gọi từ controller?
    public void completeBatch(List<Long> taskIds) {
      taskIds.forEach(this::completeTask);   // completeTask co @Observed
    }
  5. Q5
    Vì sao đăng ký ObservedAspect như một @Bean là bắt buộc để @Observed hoạt động? Điều gì xảy ra nếu quên bean này?

Bài tiếp theo: Tracing và OTLP

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Trace một request xuyên nhiều service