Log, metric, trace (và profile): mỗi thứ trả lời câu hỏi gì
Bắt đầu chương Observability nhé. shop-app giờ chạy trên Kubernetes với order, payment, inventory rồi, mình nên quan sát nó thế nào?
Câu hỏi gốc của observability là: khi có chuyện, mình trả lời được "chuyện gì, ở đâu, vì sao" mà không cần deploy thêm code không? Để làm được, cần bốn loại tín hiệu, mỗi loại trả lời một câu hỏi khác nhau.
1. Bài toán
Thứ Sáu 20:00, chạy khuyến mãi. Người dùng báo checkout chậm, có lúc lỗi 500. shop-app có 3 service, mỗi service 4 Pod:
Ingress → order-service (4 Pod) → payment-service (4 Pod)
→ inventory-service (4 Pod) → postgres
Thứ đang có: System.out.println rải rác và kubectl logs. Không có dashboard, không có alert. Người phát hiện sự cố là khách hàng.
2. Vấn đề
- 12 Pod, log dạng text tự do. Muốn tìm "đơn 1234 lỗi ở đâu" phải
kubectl logstừng Pod rồi grep. - Không biết "chậm" là chậm bao nhiêu, từ lúc nào, bao nhiêu % request bị.
- Không biết chậm ở order, payment hay inventory, hay ở DB.
- Biết chậm ở payment rồi thì vẫn không biết dòng code nào ăn CPU.
- Pod bị restart thì log cũ mất.
3. Giải pháp: 3 trụ cột + profile
| Tín hiệu | Trả lời câu hỏi | Dạng dữ liệu | Công cụ hay gặp |
|---|---|---|---|
| Metric | Có chuyện gì không? Nặng cỡ nào? (error rate 5%, p99 3s) | Số theo thời gian, đã gộp | Micrometer → Prometheus → Grafana |
| Trace | Chậm/lỗi ở service nào, bước nào? | Cây span của một request | OpenTelemetry → Tempo/Jaeger |
| Log | Chính xác đã xảy ra gì? (exception, giá trị biến) | Sự kiện rời rạc, chi tiết | JSON log → Loki / Elasticsearch |
| Profile | Dòng code nào tốn CPU/memory/lock? | Stack trace lấy mẫu | JFR, async-profiler, Pyroscope |
Monitoring là theo dõi những thứ mình đã biết sẽ hỏng (CPU, disk). Observability là khả năng hỏi những câu chưa biết trước nhờ dữ liệu đủ giàu và nối được với nhau. Chất keo nối chúng là traceId.
4. Code từng bước
Bước 1: Dependency (mỗi service)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
Phần tracing chi tiết đã có ở Distributed tracing, actuator metrics ở Actuator. Bài này tập trung vào log và cách ghép bốn tín hiệu.
Bước 2: Structured JSON log
Spring Boot 3.4+ có sẵn structured logging, không cần thư viện thêm:
# application.yml
spring:
application:
name: order-service
logging:
structured:
format:
console: ecs # hoặc logstash, gelf
level:
root: INFO
com.shop: INFO
org.hibernate.SQL: WARN
management:
tracing:
sampling:
probability: ${TRACE_SAMPLING:0.1}
Một dòng log ra stdout trông như thế này (đã xuống dòng cho dễ đọc):
{"@timestamp":"2026-10-09T20:03:11.482Z","log.level":"ERROR",
"service.name":"order-service","process.thread.name":"http-nio-8080-exec-7",
"log.logger":"com.shop.order.OrderService",
"message":"Payment failed","orderId":"1234","customerTier":"GOLD",
"traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"00f067aa0ba902b7",
"error.type":"java.net.SocketTimeoutException","error.stack_trace":"..."}
Bước 3: Code Java, log có ngữ cảnh
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final PaymentClient paymentClient;
public Order checkout(CheckoutRequest req) {
// Gắn thuộc tính nghiệp vụ vào MDC: mọi dòng log trong request đều mang theo
try (var ignored = MDC.putCloseable("orderId", req.orderId())) {
log.info("Checkout started items={}", req.items().size());
try {
paymentClient.charge(req.orderId(), req.amount());
} catch (PaymentTimeoutException e) {
// Log 1 lần ở nơi xử lý, kèm exception; không log rồi rethrow
log.atError()
.addKeyValue("amount", req.amount())
.setCause(e)
.log("Payment failed");
throw e;
}
return Order.confirmed(req.orderId());
}
}
}
traceId/spanId do Micrometer Tracing tự đặt vào MDC, mình không phải làm gì. orderId mình tự thêm để tìm theo nghiệp vụ.
Bước 4: Kiểm tra
curl -s -X POST localhost:8080/orders -H 'Content-Type: application/json' \
-d '{"orderId":"1234","amount":99.0,"items":[{"sku":"A1","qty":1}]}'
kubectl logs deploy/order-service -n shop | jq 'select(.orderId=="1234")'
# Trong Grafana Loki: tìm toàn bộ log của một request qua cả 3 service
{namespace="shop"} | json | traceId="4bf92f3577b34da6a3ce929d0e0e4736"
5. Cách hoạt động: từ alert tới dòng code
[Metric] Grafana: p99 checkout 3.2s, error 4% ← alert kêu
│ click vào điểm trên đồ thị (exemplar có traceId)
▼
[Trace] Tempo: trace 4bf92f...
order-service POST /orders 3.1s
└ payment-service POST /charge 3.0s ← chậm ở đây
└ HTTP bank-gateway 3.0s timeout
│ "Logs for this span" (lọc theo traceId)
▼
[Log] Loki: ERROR Payment failed orderId=1234 SocketTimeoutException
│ "Profile for this span"
▼
[Profile] Pyroscope: 60% CPU ở Jackson serialize trong RetryInterceptor
Đường ống trên Kubernetes:
Pod stdout ──► node agent (Alloy / Fluent Bit) ──► Loki / Elasticsearch
/actuator/prometheus ◄── Prometheus scrape (pull)
OTLP spans ──► OTel Collector ──► Tempo / Jaeger
6. So sánh: chi phí và cardinality
| Metric | Log | Trace | Profile | |
|---|---|---|---|---|
| Chi phí theo | Số time series (label) | Số byte ghi + index | Số span lưu | Thấp (lấy mẫu ~100Hz) |
| Chi tiết từng request | Không | Có | Có | Không (gộp) |
| Giữ bao lâu (hay gặp) | 13 tháng (downsample) | 7–30 ngày | 3–14 ngày | 7–30 ngày |
| Dùng để alert | Rất hợp | Hạn chế | Không | Không |
| Bẫy | Label userId → nổ series | Log DEBUG trên prod → hóa đơn to | 100% sampling → tốn | Ít |
Cardinality = số giá trị khác nhau. orderId cho vào log thì được (log là sự kiện), cho vào label metric thì mỗi đơn hàng thành một time series riêng, Prometheus sẽ chết. Chi tiết ở bài 19.3.
7. Khi nào nên dùng / không nên dùng
- Log JSON ra stdout, để hạ tầng thu gom. Không ghi file trong container.
- Production để
INFO; bậtDEBUGtạm cho một package qua/actuator/loggers(xem Debug production). - Không log password, token, số thẻ, dữ liệu cá nhân. Mask trước khi log.
- Không dùng log để đếm ("grep đếm số lỗi"): đó là việc của metric.
- Hệ thống nhỏ một service: log JSON + actuator metrics là đủ, chưa cần tracing.
Log level thì mình hay phân vân. Payment trả về "thẻ bị từ chối" thì log ERROR hay WARN nhỉ?
WARN, hoặc thậm chí INFO. Quy tắc dễ nhớ: ERROR là thứ cần người can thiệp. Thẻ bị từ chối là kết quả nghiệp vụ bình thường, hệ thống vẫn làm đúng việc.
| Level | Nghĩa | Ví dụ shop-app |
|---|---|---|
| ERROR | Hệ thống hỏng, cần người xem | Không kết nối được postgres, bug NPE, gọi payment timeout sau hết retry |
| WARN | Bất thường nhưng tự xử lý được | Retry lần 2 mới thành công, circuit breaker mở, config deprecated |
| INFO | Sự kiện nghiệp vụ quan trọng | Đơn tạo xong, thẻ bị từ chối, app start |
| DEBUG | Chi tiết cho dev | Request body, SQL, giá trị trung gian |
| TRACE | Rất chi tiết | Từng vòng lặp; gần như không bật trên prod |
Nếu ERROR bị lạm dụng, dashboard "số dòng ERROR" luôn đỏ và mọi người học cách phớt lờ nó. Đó là lúc lỗi thật bị chìm.
Hai lỗi phổ biến nữa:
- Log-and-rethrow: mỗi tầng catch, log ERROR rồi ném tiếp, một lỗi thành 4 dòng stack trace. Chỉ log ở nơi thực sự xử lý (thường là
@RestControllerAdvice). - Nối chuỗi trong log:
log.debug("x=" + bigObject)vẫn chạytoString()dù DEBUG tắt. Dùng placeholder{}hoặc fluent API.
Log JSON tốn chỗ hơn text mà. Mỗi ngày shop-app ra khoảng 200GB log, sếp hỏi sao hóa đơn Elasticsearch cao vậy. Giảm kiểu gì?
Bạn hiểu gần đúng, JSON có dài hơn một chút, nhưng nén rất tốt và không phải thủ phạm chính. Thủ phạm thường là log những thứ không ai đọc và index mọi field. Đi theo thứ tự này:
1. Đo trước: service nào, logger nào ra nhiều nhất?
Loki: topk(10, sum by (app, logger) (bytes_over_time({namespace="shop"}[1h])))
2. Cắt nguồn:
- Health check / readiness log mỗi 5s × 12 Pod → tắt
- Log request body ở INFO → hạ xuống DEBUG
- Log trong vòng lặp → log 1 dòng tổng kết
3. Lọc ở pipeline (Alloy / Fluent Bit): drop DEBUG, drop log của /actuator/*
4. Lấy mẫu log thành công, giữ 100% log lỗi
5. Phân tầng lưu trữ: 7 ngày nóng (tìm nhanh) → 90 ngày S3 (rẻ, chậm)
6. Loki chỉ index label (app, namespace, level), không index nội dung → rẻ hơn ES nhiều
Câu hỏi nên hỏi với mỗi dòng log: "Lúc sự cố, mình có đọc dòng này không?" Không thì bỏ, hoặc chuyển thành metric (counter rẻ hơn một triệu dòng log "processed item").
Phỏng vấn hỏi về log, metric, trace thì trả lời sao?
Q1. Ba trụ cột của observability là gì, mỗi cái dùng khi nào?
🇻🇳 Tiếng Việt:
Metric là số đã gộp theo thời gian, rẻ, dùng cho dashboard và alert: nó cho biết có chuyện và nặng cỡ nào. Trace theo một request đi qua các service, cho biết chậm hay lỗi ở bước nào. Log là sự kiện chi tiết, cho biết chính xác chuyện gì xảy ra. Nhiều nơi thêm profile làm trụ thứ tư để biết dòng code nào tốn CPU hay memory. Quy trình của em là metric báo động, trace khoanh vùng, log và profile tìm nguyên nhân, nối với nhau bằng traceId.
🇬🇧 English:
Metrics are cheap aggregated numbers over time, used for dashboards and alerting: they tell you something is wrong and how bad it is. Traces follow one request across services and tell you which hop is slow or failing. Logs are detailed events that tell you exactly what happened. Many teams add continuous profiling as a fourth signal to see which code burns CPU or memory. My flow is: metrics alert, traces narrow it down, logs and profiles explain the cause, all linked by the trace ID.
Q2. Structured logging là gì, vì sao nên dùng?
🇻🇳 Tiếng Việt:
Là ghi log dạng key-value, thường là JSON, thay vì câu text tự do. Hệ thống log parse được nên em lọc được theo
orderId,traceId, level, service mà không phải viết regex. Trên Kubernetes em log JSON ra stdout, agent trên node gom về Loki hoặc Elasticsearch. Spring Boot 3.4 hỗ trợ sẵn qualogging.structured.format.console.
🇬🇧 English:
It means logging key-value data, usually JSON, instead of free text. The log backend can parse it, so I can filter by
orderId,traceId, level or service without regex. On Kubernetes I write JSON to stdout and a node agent ships it to Loki or Elasticsearch. Spring Boot 3.4 supports it out of the box vialogging.structured.format.console.
Q3. Làm sao gom log của một request đi qua nhiều service?
🇻🇳 Tiếng Việt:
Dùng distributed tracing: Micrometer Tracing hoặc OpenTelemetry sinh traceId ở service đầu tiên, truyền qua header W3C
traceparent, và đặt traceId vào MDC để mọi dòng log đều có nó. Khi điều tra em tìm theo traceId trong hệ thống log là ra toàn bộ log của request trên mọi service, và từ trace trong Tempo nhảy thẳng sang log.
🇬🇧 English:
Distributed tracing: Micrometer Tracing or OpenTelemetry creates a trace ID at the first service, propagates it in the W3C
traceparentheader, and puts it in the MDC so every log line carries it. When investigating I search the log backend by trace ID to get every log line for that request across all services, and I can jump from a trace in Tempo straight to its logs.
Q4. Cardinality là gì, tại sao quan trọng?
🇻🇳 Tiếng Việt:
Là số giá trị khác nhau của một field. Với metric, mỗi tổ hợp label là một time series riêng, nên label như userId hay orderId làm số series tăng theo số người dùng và làm Prometheus hết memory. Giá trị cardinality cao thì để trong log hoặc trace attribute, còn label metric chỉ dùng giá trị có giới hạn như method, status, route template.
🇬🇧 English:
It's the number of distinct values a field can take. For metrics, every label combination is a separate time series, so labels like userId or orderId grow with your user base and can run Prometheus out of memory. High-cardinality values belong in logs or span attributes; metric labels should be bounded values like method, status, or route template.
Cách nhớ ngắn gọn
- Metric: có chuyện không. Trace: ở đâu. Log: chuyện gì. Profile: dòng code nào.
- traceId là keo dán: MDC → log, exemplar → metric.
- JSON ra stdout, agent gom; không log bí mật.
- ERROR = cần người can thiệp. Log một lần ở nơi xử lý.
- Giá trị cardinality cao: vào log/trace, không vào label metric.
Tự kiểm tra
Muốn alert "tỷ lệ lỗi checkout > 2%", dùng tín hiệu nào?
Metric (counter request theo status). Log và trace không hợp để alert.
Vì sao không ghi log ra file trong container?
Pod restart là mất file, khó gom. Ghi stdout để runtime và agent trên node thu gom.
orderId nên đặt ở log, label metric hay span attribute?
Log (MDC) và span attribute. Không đặt làm label metric vì cardinality không giới hạn.
Thẻ khách bị ngân hàng từ chối: log level nào?
INFO hoặc WARN; không phải ERROR vì hệ thống không hỏng.
Ai đặt traceId vào MDC?
Micrometer Tracing (hoặc OTel agent) tự đặt khi có span hiện tại.