15.8 - Production Monitoring
Tóm tắt: Giám sát có ba trụ cột trả lời ba câu hỏi khác nhau: metric trả lời "có gì sai không" (rẻ, tổng hợp, cảnh báo được), log trả lời "sai cái gì" (chi tiết, đắt khi nhiều), trace trả lời "sai ở đâu trong chuỗi gọi". Thiếu trụ cột nào cũng khiến điều tra sự cố chậm hơn nhiều lần. OpenTelemetry là lý do để bắt đầu ngay hôm nay: nó là chuẩn chung, nên bạn gắn thiết bị đo một lần rồi đổi backend (Application Insights, Grafana, Datadog) mà không sửa code. Và một nguyên tắc quan trọng hơn mọi công cụ: cảnh báo quá nhiều tệ hơn không có cảnh báo — khi 50 cảnh báo mỗi ngày đều là báo động giả, không ai còn đọc cái thứ 51 là cái thật.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân biệt ba trụ cột và dùng đúng cho từng mục đích.
- Cấu hình OpenTelemetry cho ứng dụng .NET.
- Chọn chỉ số đáng cảnh báo.
- Thiết kế cảnh báo không gây mệt mỏi.
- Chuẩn bị những gì cần có trước khi sự cố xảy ra.
Nội dung bài học
15.8.1 — Ba trụ cột
| Trả lời | Chi phí | Dùng để | |
|---|---|---|---|
| Metric | "Có gì sai không?" | Thấp | Cảnh báo, bảng điều khiển |
| Log | "Sai cái gì?" | Cao khi nhiều | Điều tra chi tiết |
| Trace | "Sai ở đâu?" | Trung bình | Tìm nút thắt trong chuỗi gọi |
Sai lầm phổ biến là chỉ có log. Log rất chi tiết nhưng đắt để lưu, chậm để truy vấn, và không cảnh báo được hiệu quả — bạn không thể đặt cảnh báo "p99 latency tăng" từ log text.
Sai lầm ngược lại là chỉ có metric: bạn biết có gì đó sai nhưng không biết là gì.
Trace là trụ cột hay bị bỏ qua nhất, và nó tiết kiệm nhiều thời gian nhất khi có nhiều service: thay vì đọc log của bốn hệ thống rồi ghép thủ công theo thời gian, bạn thấy một biểu đồ cho biết 2 giây nằm ở đâu.
15.8.2 — OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocolbuilder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService("crm-api", serviceVersion: Assembly.GetExecutingAssembly()
.GetName().Version?.ToString())
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = builder.Environment.EnvironmentName,
["host.name"] = Environment.MachineName,
}))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
{
o.RecordException = true;
o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation(o => o.SetDbStatementForText = true)
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Microsoft.EntityFrameworkCore")
.AddOtlpExporter());Bốn chi tiết:
o.Filter loại health check khỏi trace. Probe mỗi 10 giây là 8.640 trace mỗi ngày cho mỗi instance — toàn nhiễu, và bạn trả tiền cho chúng.
SetDbStatementForText = true đưa câu SQL vào trace. Rất hữu ích để tìm truy vấn chậm, nhưng cẩn thận: tham số có thể chứa dữ liệu cá nhân. Với dữ liệu nhạy cảm, để false.
AddRuntimeInstrumentation cho metric của GC, thread pool, exception — những thứ giải thích "vì sao chậm" khi truy vấn và mạng đều bình thường.
AddOtlpExporter dùng giao thức chuẩn, nên đổi backend chỉ là đổi endpoint:
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317Giá trị lớn nhất của OpenTelemetry là không khoá vào nhà cung cấp: gắn thiết bị đo một lần, đổi từ Application Insights sang Grafana mà không sửa code.
Metric nghiệp vụ là thứ chỉ bạn biết cần đo:
public sealed class OrderMetrics
{
private readonly Counter<long> _created;
private readonly Histogram<double> _value;
public OrderMetrics(IMeterFactory factory)
{
var meter = factory.Create("Crm.Orders");
_created = meter.CreateCounter<long>("orders.created");
_value = meter.CreateHistogram<double>("orders.value", unit: "VND");
}
public void RecordCreated(decimal value, string channel)
{
_created.Add(1, new KeyValuePair<string, object?>("channel", channel));
_value.Record((double)value);
}
}Cẩn thận với cardinality: nhãn có giá trị không giới hạn (user id, order id) làm số chuỗi metric bùng nổ và có thể làm sập hệ thống giám sát. Nhãn phải có tập giá trị hữu hạn và nhỏ.
15.8.3 — Correlation ID
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Activity.Current?.TraceId.ToString()
?? Guid.NewGuid().ToString();
context.TraceIdentifier = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Correlation-ID"] = correlationId;
return Task.CompletedTask;
});
await next(context);
}
});Với OpenTelemetry, TraceId đã là correlation id — nó tự lan truyền qua HttpClient và message bus. Nhưng vẫn nên trả nó về client: người dùng báo lỗi kèm X-Correlation-ID là bạn tìm ra toàn bộ dấu vết trong vài giây.
Nhớ dùng OnStarting vì header không ghi được sau khi response đã bắt đầu (bài 8.2).
Và đưa TraceId vào phản hồi lỗi (bài 8.8).
15.8.4 — Cảnh báo cái gì
Nguyên tắc: chỉ cảnh báo thứ cần người xử lý ngay. Mọi thứ khác là bảng điều khiển.
Bốn chỉ số vàng (Google SRE):
| Chỉ số | Ngưỡng mẫu |
|---|---|
| Latency | p99 > 2s trong 5 phút |
| Traffic | Giảm > 50% so với cùng giờ tuần trước |
| Errors | Tỷ lệ 5xx > 1% trong 5 phút |
| Saturation | CPU > 85% hoặc bộ nhớ > 90% trong 10 phút |
Thêm những cái riêng của .NET và của nghiệp vụ:
- Job Hangfire thất bại > 10 (bài 14.7)
- Dead-letter queue co message (bai 14.9)
- Ty le retry tang gap doi (bai 14.10)
- Chung chi TLS het han trong 14 ngay (bai 15.6)
- Dia > 80% (bai 15.6)
- Ty le trung cache giam dot ngot (bai 14.2)Ba quy tắc về cảnh báo:
1. Dùng p99, không dùng trung bình. Trung bình che giấu vấn đề: 95% request 50ms và 5% request 10 giây cho trung bình 550ms — trông ổn, nhưng 5% người dùng đang chịu 10 giây.
2. Cảnh báo trên xu hướng, không trên một điểm. "p99 > 2s" một lần có thể là GC pause. "p99 > 2s trong 5 phút liên tục" là vấn đề thật.
3. Mỗi cảnh báo phải có hành động. Nếu người nhận không biết làm gì, đó không phải cảnh báo — đó là thông tin, và nó thuộc về bảng điều khiển.
Mệt mỏi vì cảnh báo là rủi ro thật: khi 50 cảnh báo mỗi ngày đều là báo động giả, không ai đọc cái thứ 51 — và đó là cái thật. Ít cảnh báo nhưng mỗi cái đáng tin tốt hơn nhiều so với cảnh báo mọi thứ.
15.8.5 — Chuẩn bị trước sự cố
Những thứ này phải có trước khi bạn cần, vì lúc đang có sự cố thì quá muộn:
Bảng điều khiển đọc được trong 30 giây: tỷ lệ lỗi, p50/p95/p99, số request, tài nguyên. Không phải 40 biểu đồ — bốn biểu đồ trả lời "hệ thống có khoẻ không".
Runbook cho mỗi cảnh báo:
## Cảnh báo: tỷ lệ 5xx cao
1. Kiểm tra bảng điều khiển: lỗi tập trung ở endpoint nào?
2. Kiểm tra trace gần nhất có lỗi: exception gì?
3. Kiểm tra phụ thuộc: database, Redis, dịch vụ ngoài còn khoẻ?
4. Kiểm tra lần deploy gần nhất: có trùng thời điểm không?
5. Neu do deploy: rollback bang ./deploy.sh <tag-cu>Runbook biến một sự cố lúc 3 giờ sáng từ "tìm hiểu từ đầu" thành "làm theo danh sách".
Chẩn đoán .NET trong container:
# Cai cong cu vao container dang chay
docker exec crm-api dotnet-counters monitor --process-id 1 \
--counters System.Runtime,Microsoft.AspNetCore.Hosting
# Dump khi nghi rò rỉ bộ nhớ
docker exec crm-api dotnet-gcdump collect -p 1 -o /tmp/dump.gcdump
docker cp crm-api:/tmp/dump.gcdump ./Nhưng image chiseled không có shell (bài 15.2), nên chuẩn bị sẵn một cách: hoặc một biến thể image có công cụ chẩn đoán, hoặc dựa hoàn toàn vào metric và trace.
Ba chỉ số .NET đáng theo dõi thường xuyên:
| Chỉ số | Cảnh báo khi |
|---|---|
dotnet.thread_pool.queue.length | Tăng liên tục — thread pool starvation (bài 6.1) |
dotnet.gc.pause.time | Chiếm > 5% thời gian — áp lực bộ nhớ |
dotnet.gc.heap.total_allocated | Tăng đều không giảm — rò rỉ |
Hàng đầu đáng chú ý: hàng đợi thread pool dài gần như luôn nghĩa là có .Result hoặc .Wait() ở đâu đó trong đường chạy request.
15.8.6 — Rà lại code của bạn
Danh sách rà soát giám sát
- Có đủ ba trụ cột: metric, log, trace.
- Dùng OpenTelemetry để không khoá vào một nhà cung cấp.
- Health check bị lọc khỏi trace và log.
- SetDbStatementForText tắt nếu tham số truy vấn chứa dữ liệu nhạy cảm.
- Có metric nghiệp vụ, không chỉ metric kỹ thuật.
- Nhãn metric có tập giá trị hữu hạn; không dùng id làm nhãn.
- Correlation id được trả về client và có trong mọi phản hồi lỗi.
- Cảnh báo dựa trên p99, không dựa trên trung bình.
- Cảnh báo dựa trên xu hướng nhiều phút, không trên một điểm.
- Mỗi cảnh báo có hành động rõ ràng; phần còn lại nằm ở bảng điều khiển.
- Có bảng điều khiển đọc được trong 30 giây.
- Có runbook cho mỗi cảnh báo.
- Đã chuẩn bị cách chẩn đoán .NET trong container trước khi cần.
Bài tập áp dụng
Bài 1 — Trung bình che giấu sự cố
Tạo tải với 95% request nhanh và 5% request 10 giây. So sánh cảnh báo dựa trên trung bình và dựa trên p99.
Tiêu chí hoàn thành: bạn tính được cả hai con số trước khi chạy, và giải thích được vì sao trung bình không thể phát hiện loại vấn đề này.
Gợi ý và lời giải — Bài 1
Gợi ý. Tính trung bình của 95 lần 50 ms và 5 lần 10.000 ms.
Lời giải — tính trước:
95 request × 50 ms = 4.750 ms
5 request × 10.000 ms = 50.000 ms
-----------
Tổng = 54.750 ms cho 100 request
Trung bình = 547 ms
p50 = 50 ms
p95 = 50 ms
p99 = 10.000 ms# Cảnh báo dựa trên trung bình
- alert: DoTreCao
expr: rate(http_duration_sum[5m]) / rate(http_duration_count[5m]) > 1
# 0,547 s < 1 s -> KHÔNG kích hoạt# Cảnh báo dựa trên p99
- alert: DoTreDuoiCao
expr: histogram_quantile(0.99, rate(http_duration_bucket[5m])) > 2
# 10 s > 2 s -> KÍCH HOẠTTrong khi đó, 5% người dùng đang chờ 10 giây — với 10.000 request mỗi giờ, đó là 500 người mỗi giờ có trải nghiệm không chấp nhận được.
Vì sao trung bình không thể phát hiện loại vấn đề này. Vì trung bình là một phép gộp có mất mát: nó nén toàn bộ phân bố thành một con số, và thông tin về hình dạng phân bố bị mất vĩnh viễn.
Ba phân bố hoàn toàn khác nhau cho cùng một trung bình:
A: mọi request 547 ms -> đều đặn, tệ với tất cả mọi người
B: 95% × 50 ms + 5% × 10.000 ms -> tốt với hầu hết, thảm hoạ với một số
C: 50% × 10 ms + 50% × 1.084 ms -> hai nhóm người dùng rõ rệt
Trung bình cả ba = 547 msTừ con số 547 ms, bạn không thể biết mình đang ở trường hợp nào — và ba trường hợp cần ba cách xử lý khác nhau.
Thêm vào đó, trung bình bị pha loãng theo lưu lượng:
100 request/giây, 5 request chậm -> trung bình 547 ms
10.000 request/giây, 5 request chậm -> trung bình 55 msCùng số lượng người dùng bị ảnh hưởng, nhưng trung bình gần như không đổi. Nghĩa là cảnh báo dựa trên trung bình càng vô dụng khi hệ thống càng lớn — đúng lúc bạn cần nó nhất.
Percentile nói gì:
| Nghĩa | |
|---|---|
| p50 (trung vị) | Nửa số request nhanh hơn con số này |
| p95 | 95% nhanh hơn; 1 trên 20 request chậm hơn |
| p99 | 1 trên 100 request chậm hơn |
| p99.9 | 1 trên 1.000 |
Nên cảnh báo trên percentile nào? Câu trả lời không phải "càng cao càng tốt":
p50: quá rộng lượng — vấn đề với 49% người dùng không kích hoạt
p95: mặc định tốt cho hầu hết dịch vụ
p99: cho dịch vụ quan trọng, hoặc khi mỗi request đều đáng giá
p99.9: chỉ khi lưu lượng rất lớn; dưới 10.000 request/phút thì
p99.9 được tính từ quá ít mẫu nên nhiễu và hay báo giảVà một điểm ít được nhắc: với người dùng thực hiện nhiều request, p99 của request trở thành trải nghiệm phổ biến. Một trang gọi 20 API và mỗi lời gọi có p99 là 10 giây, thì xác suất trang đó có ít nhất một lời gọi chậm là 1 - 0,99²⁰ ≈ 18%. Gần một phần năm lượt tải trang bị ảnh hưởng bởi một percentile nghe có vẻ hiếm.
Cấu hình histogram trong .NET:
builder.Services.AddOpenTelemetry().WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddView("http.server.request.duration", new ExplicitBucketHistogramConfiguration
{
Boundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
})
.AddPrometheusExporter());Ranh giới bucket quyết định độ chính xác của percentile. Ranh giới mặc định của OpenTelemetry đã hợp lý cho HTTP; nếu tự đặt, hãy dày ở vùng bạn quan tâm:
Dịch vụ p95 khoảng 50 ms -> cần nhiều bucket trong khoảng 10–200 ms
Dịch vụ p95 khoảng 2 s -> cần nhiều bucket trong khoảng 0,5–5 sVà quan trọng hơn cả percentile: đo theo từng endpoint và cảnh báo trên tỷ lệ lỗi.
# p95 theo từng route — một endpoint chậm không bị pha loãng bởi các endpoint nhanh
histogram_quantile(0.95,
sum by (http_route, le) (rate(http_server_request_duration_seconds_bucket[5m])))# Tỷ lệ lỗi — thường là chỉ số quan trọng nhất
sum(rate(http_server_requests_total{http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_requests_total[5m])) > 0.01Gộp mọi endpoint vào một con số cũng là một phép pha loãng: một endpoint quan trọng chậm hẳn có thể bị che bởi một endpoint health check nhanh được gọi liên tục.
Bốn chỉ số nên cảnh báo, theo thứ tự ưu tiên:
1. Tỷ lệ lỗi -> người dùng không làm được việc
2. p95 độ trễ -> người dùng làm được nhưng khó chịu
3. Độ sâu hàng đợi -> công việc đang dồn lại
4. Sắp cạn tài nguyên -> sắp có sự cốBa chỉ số đầu là triệu chứng mà người dùng cảm nhận được; chỉ số thứ tư là dấu hiệu sớm. Đừng cảnh báo trên CPU, bộ nhớ hay số lượng pod — chúng là nguyên nhân tiềm năng, không phải vấn đề, và cảnh báo trên chúng là nguồn chính của báo động giả.
Bài 2 — Cardinality bùng nổ
Thêm user_id làm nhãn cho một metric, tạo tải với 10.000 người dùng khác nhau, và quan sát số chuỗi metric cùng mức tiêu thụ bộ nhớ của hệ thống giám sát.
Tiêu chí hoàn thành: bạn tính được số chuỗi metric từ số nhãn và số giá trị, và biết quy tắc quyết định cái gì được làm nhãn.
Gợi ý và lời giải — Bài 2
Gợi ý. Mỗi tổ hợp giá trị nhãn là một chuỗi thời gian riêng.
Lời giải:
private static readonly Counter<long> _requests =
_meter.CreateCounter<long>("http.requests");
_requests.Add(1,
new KeyValuePair<string, object?>("route", route),
new KeyValuePair<string, object?>("status", status),
new KeyValuePair<string, object?>("user_id", userId)); // <- vấn đềTính số chuỗi:
Số chuỗi = tích của số giá trị phân biệt của MỖI nhãn
route: 50 endpoint
status: 8 mã trạng thái
user_id: 10.000 người dùng
------------------
50 × 8 × 10.000 = 4.000.000 chuỗiBốn triệu chuỗi thời gian cho một metric.
curl -s localhost:8080/metrics | wc -l4021847Bộ nhớ Prometheus: ~3 KB mỗi chuỗi -> 4M chuỗi ≈ 12 GB
Ứng dụng .NET: khoảng 300 byte mỗi chuỗi -> ≈ 1,2 GB
Endpoint /metrics: ~400 MB mỗi lần scrape
Thời gian scrape: từ 50 ms lên 45 s -> vượt timeout, mất dữ liệu
Truy vấn Grafana: timeoutCách nó phát triển: không tuyến tính mà theo tích. Thêm một nhãn có 100 giá trị không cộng 100, mà nhân với 100:
route × status = 400 chuỗi
route × status × tenant(50) = 20.000 chuỗi
route × status × tenant × user(10.000) = 200.000.000 chuỗiVà một nhãn không giới hạn — user_id, request_id, url đầy đủ — khiến số chuỗi tăng mãi theo thời gian, không bao giờ ổn định. Đây là khác biệt quan trọng: một nhãn có 10.000 giá trị cố định là vấn đề về dung lượng; một nhãn không giới hạn là vấn đề không có đáy.
Quy tắc quyết định cái gì được làm nhãn:
Một nhãn chỉ hợp lệ khi tập giá trị của nó là HỮU HẠN, ĐÃ BIẾT, và KHÔNG TĂNG theo lưu lượng.
Ba điều kiện, và cả ba đều phải đúng:
| Nhãn | Hữu hạn | Đã biết | Không tăng theo lưu lượng | Kết luận |
|---|---|---|---|---|
http_route (mẫu) | Có (~50) | Có | Có | Hợp lệ |
status_code | Có (~8) | Có | Có | Hợp lệ |
method | Có (~7) | Có | Có | Hợp lệ |
tenant_id | Có (~50) | Có | Chậm | Hợp lệ nếu ít tenant |
user_id | Không | Không | Có | Không |
request_id | Không | Không | Có | Không |
url đầy đủ | Không | Không | Có | Không |
error_message | Không | Không | Có | Không |
sql_query | Không | Không | Có | Không |
Hai dòng cuối đáng chú ý vì chúng trông "hữu hạn" — số loại lỗi và số câu truy vấn trong code là hữu hạn. Nhưng thông điệp lỗi thường chứa giá trị động ("Lead 12345 không tồn tại") và SQL chứa tham số đã nội suy, nên trên thực tế chúng không giới hạn.
url đầy đủ là lỗi phổ biến nhất, vì nó dễ nhầm với route:
url: /leads/123, /leads/124, /leads/125, ... -> không giới hạn
route: /leads/{id} -> đúng 1 giá trị// SAI
new KeyValuePair<string, object?>("url", context.Request.Path.Value)
// ĐÚNG — dùng MẪU route, không phải đường dẫn thật
new KeyValuePair<string, object?>("http.route", context.GetEndpoint()?
.Metadata.GetMetadata<RouteEndpoint>()?.RoutePattern.RawText)Thông tin có cardinality cao đi đâu? Không phải không dùng được — nó thuộc về log và trace, không thuộc về metric:
Metric: "bao nhiêu" và "nhanh chậm thế nào" -> cardinality thấp, lưu mãi, truy vấn nhanh
Log: "chuyện gì đã xảy ra" -> cardinality cao, giữ vài tuần
Trace: "một request cụ thể đi qua đâu" -> cardinality rất cao, lấy mẫu// user_id vào LOG, không vào metric
_logger.LogInformation("Người dùng {UserId} gọi {Route}", userId, route);
// user_id vào TRACE
Activity.Current?.SetTag("user.id", userId);
// Metric chỉ giữ chiều cardinality thấp
_requests.Add(1,
new KeyValuePair<string, object?>("http.route", route),
new KeyValuePair<string, object?>("status", status));Với cách này, khi p99 tăng bạn vẫn tìm được người dùng nào bị ảnh hưởng — qua trace, bằng cách lọc các span chậm. Bạn không mất thông tin, chỉ để nó ở đúng chỗ.
Đặt giới hạn phòng thủ trong .NET:
.AddView("http.server.request.duration", new MetricStreamConfiguration
{
TagKeys = ["http.route", "http.response.status_code", "http.request.method"],
CardinalityLimit = 2000,
})TagKeys là danh sách cho phép: mọi tag khác bị loại bỏ, kể cả khi một thư viện nào đó thêm vào. CardinalityLimit là trần cứng — vượt qua, các chuỗi mới được gộp vào một chuỗi overflow thay vì làm sập hệ thống.
Phát hiện cardinality cao:
# 10 metric có nhiều chuỗi nhất
topk(10, count by (__name__)({__name__=~".+"}))
# Nhãn nào gây bùng nổ cho một metric
count(count by (user_id) (http_requests_total))# Nhanh và không cần Prometheus
curl -s localhost:8080/metrics | grep -oP '^[a-z_]+(?=\{)' | sort | uniq -c | sort -rn | head 4021847 http_requests_total <- rõ ràng có vấn đề
1240 http_request_duration_bucket
52 dotnet_gc_collections_totalĐặt cảnh báo để không bao giờ gặp lại:
- alert: CardinalityCao
expr: count by (__name__)({__name__=~".+"}) > 10000
for: 15m
annotations:
summary: "Metric {{ $labels.__name__ }} có {{ $value }} chuỗi"Và với Prometheus, chặn ở ngay bước thu thập như một lớp cuối:
scrape_configs:
- job_name: crm-api
sample_limit: 50000 # từ chối scrape nếu vượt
label_limit: 20
label_value_length_limit: 200sample_limit khiến một lần scrape hỏng thay vì làm sập cả Prometheus — thà mất dữ liệu của một dịch vụ còn hơn mất toàn bộ hệ thống giám sát đúng lúc đang có sự cố.
Bài 3 — Trace tìm nút thắt
Bật trace cho một endpoint gọi database và một API ngoài. Thêm độ trễ giả 2 giây vào API ngoài và xác nhận trace chỉ ra ngay chỗ đó.
Tiêu chí hoàn thành: bạn nêu được trace trả lời được câu hỏi gì mà log và metric không trả lời được, và biết cấu hình lấy mẫu hợp lý.
Gợi ý và lời giải — Bài 3
Gợi ý. Một request chậm 3 giây. Log cho bạn biết nó chậm ở đâu không?
Lời giải — cấu hình:
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("crm-api", serviceVersion: version))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
{
o.RecordException = true;
o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation(o => o.RecordException = true)
.AddSqlClientInstrumentation(o =>
{
o.SetDbStatementForText = true; // ghi câu SQL — cẩn thận dữ liệu nhạy cảm
o.RecordException = true;
})
.AddSource("Crm.*")
.AddOtlpExporter());private static readonly ActivitySource _source = new("Crm.LeadService");
public async Task<BaoCaoDto> TaoBaoCaoAsync(int id, CancellationToken ct)
{
using var activity = _source.StartActivity("TaoBaoCao");
activity?.SetTag("lead.id", id);
var lead = await _db.Leads.FirstAsync(l => l.Id == id, ct);
var diem = await _scoringApi.TinhDiemAsync(lead.Email, ct); // API ngoài
activity?.SetTag("diem", diem);
return new BaoCaoDto(lead.Name, diem);
}Trace của một request chậm:
POST /leads/123/bao-cao 2.847 ms ████████████████████
├─ TaoBaoCao 2.841 ms ███████████████████░
│ ├─ SELECT Leads WHERE Id = @p0 12 ms █
│ ├─ HTTP POST scoring-api.example.com/score 2.812 ms ███████████████████░
│ │ http.status_code: 200
│ │ http.retry_count: 0
│ └─ SELECT Configs WHERE Key = @p0 8 ms ░
└─ Serialize response 4 ms ░Nhìn một lần là thấy: 98,8% thời gian nằm ở lời gọi API ngoài. Không phải database, không phải serialize, không phải code của bạn.
Trace trả lời được câu hỏi gì mà log và metric không:
| Câu hỏi | Metric | Log | Trace |
|---|---|---|---|
| Có bao nhiêu request chậm? | Có | Khó | Có |
| Endpoint nào chậm nhất? | Có | Khó | Có |
| Chuyện gì xảy ra ở một request cụ thể? | Không | Có | Có |
| Thời gian của MỘT request đi đâu? | Không | Không | Có |
| Request này gọi những dịch vụ nào? | Không | Không | Có |
| Lời gọi nào chờ lời gọi nào? | Không | Không | Có |
| Có N+1 không? | Không | Khó | Có |
Bốn dòng in đậm là vùng mà chỉ trace trả lời được, và lý do nằm ở bản chất của từng loại dữ liệu:
Metric: đã GỘP — mất thông tin về từng request
Log: rời rạc — mỗi dòng độc lập, không có quan hệ cha-con
Trace: giữ CẤU TRÚC — mỗi span biết cha của nó và thời gian của nóCụ thể với một request 3 giây:
Metric nói: "p99 là 3 s" -> biết có vấn đề, không biết ở đâu
Log nói: "bắt đầu lúc 08:14:22.1, kết thúc lúc 08:14:25.2"
-> biết tổng thời gian, phải tự ghép các dòng log để đoán
Trace nói: "2.812 ms ở lời gọi scoring-api"
-> biết chính xác, không cần đoánVà N+1 hiện ra rất rõ trong trace — nó là mẫu hình mà mắt nhận ra ngay:
GET /customers 1.240 ms
├─ SELECT Customers 18 ms
├─ SELECT Leads WHERE CustomerId = @p0 4 ms
├─ SELECT Leads WHERE CustomerId = @p0 4 ms
├─ SELECT Leads WHERE CustomerId = @p0 4 ms
│ ... (lặp 497 lần nữa)Metric chỉ thấy "endpoint này chậm". Log thì có 500 dòng SQL giống nhau, dễ bị cuộn qua. Trace hiển thị chúng thành một khối trực quan không thể bỏ qua.
Cấu hình lấy mẫu: ghi 100% trace là quá tốn với hệ thống lưu lượng lớn.
// Lấy mẫu theo tỷ lệ — đơn giản, nhưng bỏ sót đúng thứ bạn cần
.SetSampler(new TraceIdRatioBasedSampler(0.1)) // 10%Vấn đề của lấy mẫu theo tỷ lệ: nó chọn ngẫu nhiên, nên 90% trace của request chậm và request lỗi cũng bị bỏ — trong khi đó chính là những trace duy nhất bạn cần.
Lấy mẫu thông minh — giữ hết lỗi và request chậm:
public class BoLayMauUuTien : Sampler
{
private readonly Sampler _macDinh = new TraceIdRatioBasedSampler(0.05);
public override SamplingResult ShouldSample(in SamplingParameters p)
{
// Luôn giữ nếu cha đã được giữ — nếu không, trace bị đứt đoạn
if (p.ParentContext.TraceFlags.HasFlag(ActivityTraceFlags.Recorded))
return new SamplingResult(SamplingDecision.RecordAndSample);
// Luôn giữ đường code quan trọng
var route = p.Tags?.FirstOrDefault(t => t.Key == "http.route").Value?.ToString();
if (route is not null && route.StartsWith("/api/thanh-toan"))
return new SamplingResult(SamplingDecision.RecordAndSample);
return _macDinh.ShouldSample(p);
}
}Nhưng cách tốt nhất là tail-based sampling — quyết định sau khi trace hoàn tất, khi đã biết nó chậm hay lỗi:
# OpenTelemetry Collector
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: giu-loi
type: status_code
status_code: { status_codes: [ERROR] }
- name: giu-cham
type: latency
latency: { threshold_ms: 1000 }
- name: phan-con-lai
type: probabilistic
probabilistic: { sampling_percentage: 5 }Giữ: 100% trace lỗi
100% trace chậm hơn 1 giây
5% phần còn lạiĐây là cấu hình nên hướng tới: bạn giữ đúng những trace có giá trị chẩn đoán, và trả chi phí lưu trữ cho 5% phần còn lại để có cơ sở so sánh.
Cái giá: collector phải giữ mọi span trong bộ nhớ suốt decision_wait để chờ trace hoàn tất, nên nó cần nhiều RAM hơn và phải định tuyến sao cho mọi span của một trace đến cùng một instance collector.
Nối log với trace — bước cuối để ba trụ cột thật sự dùng được cùng nhau:
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeScopes = true;
o.IncludeFormattedMessage = true;
});{"timestamp":"2026-09-25T08:14:22Z","level":"Error","message":"Gọi scoring API thất bại",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}Với trace_id trong mỗi dòng log, quy trình điều tra trở thành:
1. Cảnh báo: p99 tăng
2. Mở trace chậm -> thấy 98% thời gian ở scoring-api
3. Bấm trace_id -> lọc log -> thấy "circuit breaker mở lúc 08:12"
4. Nguyên nhân rõ ràng trong 3 phútKhông có liên kết đó, bước 3 là tìm kiếm thủ công trong hàng triệu dòng log bằng dấu thời gian — và đó là phần tốn nhiều thời gian nhất của mọi cuộc điều tra.
Tự kiểm tra
Ba trụ cột giám sát trả lời những câu hỏi nào?
Metric trả lời có gì sai không, rẻ và cảnh báo được. Log trả lời sai cái gì, chi tiết nhưng đắt khi nhiều. Trace trả lời sai ở đâu trong chuỗi gọi. Chỉ có log là sai lầm phổ biến vì log không cảnh báo hiệu quả được; chỉ có metric thì biết có vấn đề mà không biết là gì.
Vì sao nên dùng OpenTelemetry?
Nó là chuẩn chung nên bạn gắn thiết bị đo một lần rồi đổi backend giữa Application Insights, Grafana hay Datadog mà không sửa code, chỉ đổi endpoint. Đó là lý do không bị khoá vào một nhà cung cấp.
Vì sao phải lọc health check khỏi trace?
Probe mỗi 10 giây là khoảng 8.640 trace mỗi ngày cho mỗi instance, toàn nhiễu và bạn trả tiền cho chúng. Cùng lý do với việc hạ mức log cho health check.
Cardinality trong metric là gì và vì sao nguy hiểm?
Là số chuỗi metric khác nhau sinh ra từ tổ hợp nhãn. Nhãn có giá trị không giới hạn như user id hay order id làm số chuỗi bùng nổ và có thể làm sập hệ thống giám sát. Nhãn phải có tập giá trị hữu hạn và nhỏ.
Vì sao cảnh báo theo p99 thay vì trung bình?
Trung bình che giấu vấn đề: 95 phần trăm request nhanh và 5 phần trăm mất 10 giây cho trung bình trông ổn, trong khi 5 phần trăm người dùng đang chịu trải nghiệm rất tệ. p99 phản ánh đúng phần đuôi.
Vì sao cảnh báo quá nhiều tệ hơn không có cảnh báo?
Khi phần lớn cảnh báo là báo động giả, không ai còn đọc chúng, kể cả cái thật. Nguyên tắc là chỉ cảnh báo thứ cần người xử lý ngay và mỗi cảnh báo phải có hành động rõ ràng; phần còn lại thuộc về bảng điều khiển.
Kết luận
Ba điều đáng nhớ nhất:
- Ba trụ cột trả lời ba câu khác nhau. Chỉ có log là không đủ để cảnh báo.
- OpenTelemetry để không khoá vào nhà cung cấp — đo một lần, đổi backend tự do.
- Cảnh báo phải có hành động. Quá nhiều cảnh báo là không có cảnh báo nào.
Tham khảo
- OpenTelemetry for .NET
- .NET observability with OpenTelemetry
- Google SRE — Monitoring distributed systems
- .NET diagnostic tools
Điều hướng
- Bài trước: 15.7 — Azure Deployment
- Bài tiếp theo: 15.9 — Kubernetes Intro (Awareness Level)
- Về module: Trang mục lục