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.
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:
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.
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 Timer và Span 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. ObservationRegistry và observe() — API mức thấp
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:
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
});
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:
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:
@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:
@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ệ.
@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(...).
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.
Spec / reference chính thức:
- Micrometer Observation — Introduction — định nghĩa gốc của
Observation,ObservationRegistry,ObservationHandler, và cặplowCardinalityKeyValue/highCardinalityKeyValue. - Spring Boot Actuator — Observability — cách Boot tự đăng ký
ObservationConventionnhư bean, và danh sách những gì Boot tự đo sẵn. - Spring Boot Actuator — HTTP Client Metrics — vì sao phải dùng builder auto-configure (
RestClient.Builder,RestTemplateBuilder,WebClient.Builder) đểhttp.client.requestsđược sinh ra.
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/highCardinalityKeyValuethay 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ả
@Asynclẫ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.
ObservationRegistryphát lifecycle event cho từngObservationHandlerđã đăng ký; code nghiệp vụ không biếtTimerhaySpantồn tại.ObservationConventiongom 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.@Observedkhông tạo Observation nào mà không báo lỗi gì — nghi ngay self-call quathistrước khi nghi cấu hình sai chỗ khác.- Trước khi gắn
@Observedcho một lời gọi ra ngoài, tra/actuator/metricsxem Boot đã tự sinh chưa — tránh Observation trùng.
Tự kiểm tra
Q1Vì 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.▸
Ba cơ chế là ba đoạn code độc lập, mỗi đoạn nằm ở một vị trí khác nhau trong method. Khi thêm một guard clause mới (nhánh task đã hoàn thành), người viết chỉ cần nhớ cập nhật đúng CẢ BA chỗ để giữ chúng nhất quán — nhưng không có gì ép buộc điều đó, và log thường là thứ được nhớ thêm nhất vì nó gắn trực tiếp với việc debug đang làm.
Với Observation API, guard clause chỉ còn một chỗ để đặt — trong hay ngoài lambda được observe — nên metric và trace luôn đồng bộ với nhau, vì cả hai cùng đi qua một điểm quyết định duy nhất thay vì ba điểm rời rạc.
Q2ObservationRegistry làm gì khi bạn gọi .observe(() -> ...)? Liệt kê đúng thứ tự các bước vòng đời.▸
Thứ tự: start observation, mở scope, chạy lambda nghiệp vụ, bắt exception nếu lambda ném lỗi, đóng scope, rồi stop observation. Tại mỗi bước, registry gọi lần lượt từng ObservationHandler đã đăng ký — handler đo metric tự start/stop Timer.Sample, handler tracing tự start/end Span.
Mở scope là bước dễ bị bỏ qua khi giải thích: nó cho phép code lồng bên trong lambda — ví dụ một lời gọi HTTP khác — tự động biết mình đang nằm trong observation cha, mà không cần truyền tham chiếu Observation thủ công qua từng lớp gọi.
Q3Bạ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?▸
highCardinalityKeyValue("userId", ...). Giá trị gắn qua method này chỉ đi vào span của trace, không bao giờ chạm tới metric — nên nó không tạo thêm series mới trong Prometheus dù userId là giá trị unbounded.
An toàn vì Micrometer tự tách biệt hai đường đi bằng chữ ký method khác nhau: lowCardinalityKeyValue đi vào cả metric lẫn span, highCardinalityKeyValue chỉ đi vào span. Bạn không thể vô tình gọi nhầm method rồi làm nổ cardinality của metric.
Q4Trong đ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
}
▸
completeBatch được gọi từ controller?public void completeBatch(List<Long> taskIds) {
taskIds.forEach(this::completeTask); // completeTask co @Observed
}Không method nào cả. completeBatch gọi completeTask qua this — lời gọi nội bộ trong cùng một class, đi thẳng vào object thật, bỏ qua CGLIB proxy mà Spring dùng để chặn và kích hoạt @Observed.
Batch chạy xong hoàn toàn bình thường về mặt chức năng, nhưng không có Observation nào được tạo cho từng task — không metric, không span. Đây chính là self-call bypass đã học ở spring-core, áp dụng y hệt cho mọi annotation dựa trên AOP proxy.
Q5Vì 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?▸
@Observed tự nó chỉ là một annotation — dữ liệu metadata, không có hành vi. Hành vi thật (tạo Observation, chạy lifecycle, thông báo handler) nằm trong ObservedAspect, một Spring AOP aspect quét các method có @Observed rồi bọc chúng bằng advice tương ứng.
Nếu quên đăng ký bean ObservedAspect, Spring không có aspect nào để áp dụng — annotation bị bỏ qua hoàn toàn, không lỗi, không cảnh báo. Method vẫn chạy đúng logic nghiệp vụ, chỉ là không có Observation nào được tạo ra, giống hệt việc quên bean bật @EnableAsync cho @Async.
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
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