Team cài Grafana + Prometheus + Jaeger + ELK stack trong một sprint. Ba tháng sau, không ai mở Jaeger vì trace toàn noise không rõ nghĩa. ELK ngốn 40% infra budget. Grafana có 200 dashboard nhưng không alert được đúng thứ cần alert. Đây không phải lỗi công cụ — đây là lỗi thứ tự ưu tiên.
Observability không phải là cài đủ stack. Là biết khi nào cần nhìn vào đâu để tìm ra vấn đề nhanh nhất. Và để làm được điều đó, bạn cần xây đúng thứ tự: metrics trước, structured log sau, distributed trace cuối cùng — không phải ngược lại.
Tại sao thứ tự quan trọng hơn bạn nghĩ
Nhiều team nhìn vào Netflix hay Uber, thấy họ có full stack observability, rồi cố build hết trong một quý. Vấn đề là Netflix có cả team SRE 50 người vận hành đống đó. Bạn thì không.
Budget và bandwidth thực tế buộc bạn phải chọn. Câu hỏi là chọn thứ gì trước để có ROI sớm nhất?
Câu trả lời thực dụng: metrics cho bạn biết có vấn đề không, log cho bạn biết vấn đề là gì, trace cho bạn biết vấn đề ở đâu trong hệ thống phân tán. Nếu service của bạn chưa có metrics, bạn sẽ biết service down qua email khách hàng — chứ không phải alert. Nếu đã có metrics nhưng chưa có structured log, bạn biết có vấn đề nhưng phải grep text log loạn trong terminal để debug. Nếu đã có metrics và log nhưng chưa có trace, bạn vẫn debug được — chỉ là chậm hơn khi bottleneck nằm ở service khác.
Metrics trước — RED method là đủ để bắt đầu
RED method do Tom Wilkie (Grafana Labs) đề xuất: ba metric này là minimum viable observability cho bất kỳ service nào.
- Rate — số request/giây service đang xử lý
- Error — phần trăm request trả về lỗi
- Duration — phân phối latency (P50, P95, P99)
Ba metric này đủ để trả lời câu hỏi: service này có đang hoạt động bình thường không? Nếu Rate đột ngột drop về zero — service chết hoặc upstream không gọi được. Nếu Error rate tăng lên 15% — có bug hoặc dependency nào đó failing. Nếu P99 tăng từ 80ms lên 3.2s — có vấn đề hiệu năng cần điều tra.
Setup Prometheus metrics trong Node.js với prom-client:
const promClient = require("prom-client");
// Khởi tạo registry và default metrics (CPU, memory, event loop lag)
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });
// Counter cho total requests và errors
const httpRequestsTotal = new promClient.Counter({
name: "http_requests_total",
help: "Tổng số HTTP requests theo method, route, status code",
labelNames: ["method", "route", "status_code"],
registers: [register],
});
// Histogram cho latency — đây là metric quan trọng nhất
const httpRequestDuration = new promClient.Histogram({
name: "http_request_duration_seconds",
help: "Latency HTTP request tính bằng giây",
labelNames: ["method", "route", "status_code"],
// Buckets phù hợp cho web API: từ 5ms đến 10s
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register],
});
// Middleware Express để tự động track mọi request
function metricsMiddleware(req, res, next) {
const startTime = Date.now();
res.on("finish", () => {
const duration = (Date.now() - startTime) / 1000;
// Dùng route pattern thay vì full URL — quan trọng để tránh cardinality trap
const route = req.route?.path || req.path || "unknown";
httpRequestsTotal.inc({
method: req.method,
route,
status_code: res.statusCode,
});
httpRequestDuration.observe(
{ method: req.method, route, status_code: res.statusCode },
duration
);
});
next();
}
// Endpoint /metrics cho Prometheus scrape
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});
Sau middleware này, bạn tự động có đủ data để vẽ RED dashboard trong Grafana. P99 query trong PromQL:
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket[5m])) by (route, le)
)
Cardinality trap — bẫy phổ biến nhất khi setup metrics
Cardinality là số lượng time series unique mà Prometheus phải lưu. Mỗi tổ hợp label values tạo ra một time series riêng. Và đây là nơi mọi người hay tự bắn vào chân mình.
Ví dụ sai phổ biến:
// ❌ ĐỪNG làm thế này — user_id tạo cardinality explosion
httpRequestsTotal.inc({
method: req.method,
route: req.path, // /users/12345/orders thay vì /users/:id/orders
user_id: req.user.id, // 1 triệu user = 1 triệu time series chỉ cho metric này
status_code: res.statusCode,
});
Với 100,000 user active, bạn sẽ có 100,000 × số route × số status code = hàng chục triệu time series. Prometheus sẽ OOM và crash trong vài ngày.
Quy tắc: chỉ dùng label với giá trị bounded và nhỏ — method (5-10 giá trị), route (50-200 route), status code (10-20 giá trị). Không bao giờ dùng user_id, request_id, session_id, hay bất kỳ thứ gì có cardinality unbounded.
/users/:id/orders), không phải actual path (/users/12345/orders). Trong Express, dùng req.route?.path. Nếu bạn có custom router, normalize URL trước khi đưa vào label.Structured log — correlation ID là thứ bạn sẽ cảm ơn sau này
Sau khi có metrics để biết có vấn đề, bạn cần log để biết vấn đề là gì. Nhưng console.log("error:", err) không phải structured log — đó là text dump vào terminal.
Structured log có hai tính chất bắt buộc:
- Format JSON — có thể parse và query, không phải grep text
- Correlation ID xuyên suốt — mọi log của một request đều có cùng
trace_id
Setup pino (nhanh nhất trong Node.js ecosystem, P99 logging overhead ~0.2ms so với winston ~1.8ms):
const pino = require("pino");
const { v4: uuidv4 } = require("uuid");
const logger = pino({
level: process.env.LOG_LEVEL || "info",
// Đổi timestamp thành ISO string để ELK và Loki đọc được
timestamp: pino.stdTimeFunctions.isoTime,
// Bỏ pid và hostname nếu chạy trong container (đã có từ k8s metadata)
base: { service: "payment-service", version: process.env.APP_VERSION },
redact: {
// Tuyệt đối không log sensitive fields — GDPR và common sense
paths: [
"req.headers.authorization",
"body.password",
"body.card_number",
"*.cvv",
],
censor: "[REDACTED]",
},
});
// Middleware gán correlation ID cho mỗi request
function correlationMiddleware(req, res, next) {
// Lấy trace_id từ header nếu có (từ API gateway hoặc upstream service)
// hoặc tạo mới nếu đây là request đầu vào hệ thống
const traceId = req.headers["x-trace-id"] || uuidv4();
// Gán vào res.locals để dùng trong toàn request lifecycle
res.locals.traceId = traceId;
// Forward trace_id cho downstream service calls
res.setHeader("x-trace-id", traceId);
// Tạo child logger với trace_id được gắn vào mọi log entry
req.log = logger.child({
trace_id: traceId,
method: req.method,
url: req.url,
});
next();
}
// Ví dụ sử dụng trong route handler
app.post("/payments", async (req, res) => {
req.log.info(
{ amount: req.body.amount, currency: req.body.currency },
"Payment initiated"
);
try {
const result = await processPayment(req.body, req.log);
req.log.info(
{ payment_id: result.id, duration_ms: result.processingTime },
"Payment succeeded"
);
res.json(result);
} catch (err) {
// Error level — sẽ được alert nếu rate tăng cao
req.log.error({ err, amount: req.body.amount }, "Payment failed");
res.status(500).json({ error: "Payment processing failed" });
}
});
Output JSON của mỗi log entry sẽ trông như này:
{
"level": "info",
"time": "2026-05-02T10:23:41.123Z",
"service": "payment-service",
"version": "1.4.2",
"trace_id": "550e8400-e29b-41d4-a716-446655440000",
"method": "POST",
"url": "/payments",
"amount": 299000,
"currency": "VND",
"msg": "Payment initiated"
}
Với trace_id trong mọi log, khi alert trigger, bạn có thể lấy trace_id từ metric label → search log trong Loki/Kibana → thấy toàn bộ flow của request đó trong vài giây. Không có trace_id, bạn phải correlate bằng timestamp — dễ sai, tốn 30 phút.
Distributed trace — chỉ cần khi metrics và log chưa đủ
Hiểu nôm na thì distributed tracing là gắn một “breadcrumb trail” xuyên suốt tất cả service mà một request đi qua — bạn nhìn vào trace, thấy request đó mất 800ms tổng, trong đó: service A tốn 50ms, gọi service B tốn 720ms (trong đó B gọi DB mất 680ms). Bottleneck rõ ràng ngay.
Nhưng tracing chỉ có giá trị khi:
- Bạn đã có metrics và biết có vấn đề latency
- Bạn đã xem log nhưng vẫn không biết latency nằm ở service nào
- Bạn có hệ thống với ≥ 3 service giao tiếp với nhau
(Mình đã bỏ 3 tuần setup Jaeger xịn với full OTEL instrumentation trước khi nhận ra team không có bandwidth đọc trace khi mọi thứ đang vận hành tốt — và khi có incident thì lại không biết filter trace nào relevant.)
Sampling — 100% trace là lãng phí
Với service xử lý 1,000 request/giây, 100% sampling tạo ra 86 triệu trace/ngày. Lưu trữ, index, và query đống đó tốn tiền và thời gian. Trong khi đó, 1% sampling vẫn cho bạn 860,000 trace/ngày — đủ để debug mọi pattern lỗi.
Strategy hợp lý:
- Default: sample 1% request bình thường
- Error-based: sample 100% request trả về 5xx — đây là thứ bạn thực sự cần trace
- Latency-based: sample 100% request có P99 > threshold (ví dụ > 2s)
const { NodeTracerProvider } = require("@opentelemetry/sdk-trace-node");
const {
ParentBasedSampler,
TraceIdRatioBasedSampler,
} = require("@opentelemetry/sdk-trace-base");
// Sampler: 1% random + 100% nếu parent span đã sample
const provider = new NodeTracerProvider({
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(0.01), // 1% base rate
}),
});
provider.register();
Còn để sample 100% khi có error, bạn cần tail-based sampling — Jaeger và Tempo hỗ trợ, nhưng phức tạp hơn. Nếu chưa sẵn sàng vận hành tail sampling, 1% head sampling + 100% error log với trace_id là acceptable.
Budget-aware: khi nào self-host, khi nào dùng managed service
Prometheus + Grafana + Loki: free, self-host trên Kubernetes với khoảng $30-50/tháng cho cluster nhỏ. Đủ cho 90% use case của startup và scaleup giai đoạn đầu.
Datadog: bắt đầu từ $15/host/tháng cho infrastructure monitoring, cộng thêm $0.10/GB log ingestion, $1.70/1,000 trace. Với 20 host + 50GB log/ngày: ~$450/tháng. New Relic tương tự.
Khi nào Datadog/New Relic worth it? Khi:
- Team không có người vận hành Prometheus/Grafana (và không muốn học)
- Cần compliance reports tự động (SOC2, PCI-DSS)
- Muốn correlation tự động giữa APM, log, và infrastructure metric mà không tự build pipeline
Nếu bạn đang ở giai đoạn early, Prometheus + Grafana + Loki + Tempo là stack đủ mạnh và $0 license fee. Khi team lớn hơn và cost vận hành self-hosted stack tốn nhiều giờ engineer hơn giá Datadog — đó là lúc cân nhắc managed.
Khi alert trigger — flow từ metric đến log đến trace
Correlation ID là thứ buộc ba trụ cột lại với nhau. Đây là flow thực tế khi on-call:
1. Alert: Error rate /payments tăng lên 8.3% (ngưỡng: > 2%)
↓ (từ Grafana alert, kèm time range)
2. Prometheus query: lọc request nào failing, tần suất bao nhiêu
↓ (lấy được timestamp và route cụ thể)
3. Loki query: search log trong cùng time range với route đó
[filter: service="payment-service" level="error" url="/payments"]
→ thấy: "Database connection timeout after 5001ms"
→ trace_id: "550e8400-e29b-41d4-a716-446655440000"
↓ (chỉ cần trace nếu vẫn chưa rõ nguồn gốc)
4. Tempo: tìm trace theo trace_id
→ thấy: payment-service → postgres gọi mất 5001ms
→ root cause: connection pool exhausted vì slow query ở bảng transactions
Tổng thời gian từ alert đến root cause: 8-12 phút, thay vì 45-60 phút khi không có correlation. Đây là lý do mình luôn đặt trace_id vào log ngay từ ngày đầu, dù chưa setup Jaeger hay Tempo — vì khi có trace, bạn đã có sẵn key để lookup.
Stack thực tế cho team nhỏ không cần over-engineer: Prometheus + Grafana (metrics + alerting) + Loki (log aggregation) + Tempo (trace nếu cần). Tất cả đều free, deploy bằng grafana/loki-stack Helm chart trong 30 phút.
Cài hết ngay từ đầu nhưng chỉ dùng metrics trong 3 tháng đầu — tốt hơn cài Jaeger rồi không ai biết dùng.