Skip to main content

18.6 — 5. Observability — bộ ba

Summary

Ba trụ cột — logs (chuyện gì đã xảy ra), metrics (xu hướng ra sao), traces (request đi qua đâu và tốn thời gian ở chặng nào) — chỉ có giá trị thật khi chúng nối được với nhau. Thấy p99 tăng trên biểu đồ metric mà không nhảy thẳng sang trace của một request chậm, rồi từ trace sang log của đúng span đó, thì bạn vẫn đang đoán. Sợi dây nối cả ba là trace id, và nó phải có mặt trong mọi dòng log. Hai điều quan trọng nữa: đừng bao giờ dùng giá trị trung bình — nó che lấp hoàn toàn trải nghiệm của nhóm người dùng khổ nhất, hãy dùng p95/p99; và với microservices, observability phải có trước khi tách service, không phải làm sau (bài 18.2).

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích vai trò từng trụ cột và cách chúng bổ sung nhau.
  • Cấu hình OpenTelemetry đầy đủ cho một service .NET.
  • Nối log với trace bằng trace id.
  • Chọn chiến lược sampling phù hợp với chi phí.
  • Đọc p95/p99 thay vì trung bình.

Nội dung bài học​

18.6.1 — Ba trụ cột và vai trò​

Trả lời câu hỏiDạng dữ liệuChi phí lưu
LogsChuyện gì đã xảy ra với request này?Sự kiện rời rạcCao
MetricsXu hướng và sức khoẻ hệ thống ra sao?Số tổng hợp theo thời gianThấp
TracesRequest đi qua đâu, chậm ở chặng nào?Cây spanTrung bình

Cách dùng thực tế khi có sự cố, theo đúng thứ tự:

  1. Metric báo động: p99 của /api/orders tăng từ 200ms lên 3s.
  2. Trace cho thấy chặng nào chậm: 2,8s nằm ở lời gọi Inventory Service.
  3. Log của span đó cho biết vì sao: "Connection pool exhausted".

Thiếu một trụ cột là mất một bước. Chỉ có log thì bạn không biết có sự cố cho tới khi người dùng phàn nàn. Chỉ có metric thì biết có sự cố nhưng không biết ở đâu.

18.6.2 — Cấu hình OpenTelemetry​

builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService(
serviceName: "crm-sales",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString())
.AddAttributes([
new("deployment.environment", builder.Environment.EnvironmentName)
]))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
{
// Bỏ qua health check — chúng chiếm phần lớn trace mà không có giá trị
o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health");
o.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation(o => o.SetDbStatementForText = true)
.AddSource("MassTransit")
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Crm.Sales")
.AddOtlpExporter());

Bốn chi tiết đáng chú ý:

  • Lọc health check. Kubernetes gọi /health vài giây một lần trên mỗi pod; không lọc thì chúng chiếm phần lớn dữ liệu trace mà không mang thông tin gì.
  • SetDbStatementForText = true ghi câu SQL vào span. Rất hữu ích để tìm truy vấn chậm, nhưng cân nhắc dữ liệu nhạy cảm trước khi bật trên production.
  • AddRuntimeInstrumentation cung cấp số liệu GC, thread pool và exception — thường là nơi tìm thấy nguyên nhân gốc của độ trễ bất thường.
  • deployment.environment cho phép lọc staging khỏi production trên cùng một backend.

18.6.3 — Nối log với trace​

Đây là mắt xích quan trọng nhất, và cũng là thứ hay thiếu nhất.

builder.Host.UseSerilog((context, services, config) => config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithSpan() // thêm TraceId và SpanId vào MỌI dòng log
.WriteTo.Console(new CompactJsonFormatter()));

Enrich.WithSpan() (gói Serilog.Enrichers.Span) tự động thêm TraceId và SpanId. Kết quả:

{
"@t": "2026-09-24T10:15:32.123Z",
"@l": "Error",
"@m": "Không tạo được đơn hàng cho khách 8f3a...",
"TraceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"SpanId": "00f067aa0ba902b7",
"CustomerId": "8f3a...",
"service.name": "crm-sales"
}

Từ một trace trong Jaeger, bạn truy vấn TraceId = "4bf92f..." và lấy toàn bộ log của request đó xuyên mọi service. Không có trường này, bạn phải dò theo thời gian và tên người dùng — chậm và hay sai.

Ghi log có cấu trúc, không phải chuỗi nối:

// SAI — không truy vấn được theo CustomerId
_logger.LogError($"Không tạo được đơn cho khách {customerId}, tổng {total}");

// ĐÚNG — CustomerId và Total thành trường có thể lọc
_logger.LogError("Không tạo được đơn cho khách {CustomerId}, tổng {Total}", customerId, total);

Dạng đúng cho phép truy vấn "mọi lỗi của khách hàng X trong 7 ngày" bằng một câu lọc. Dạng sai chỉ cho phép tìm chuỗi văn bản.

18.6.4 — Metric: p99, không phải trung bình​

var meter = new Meter("Crm.Sales");

// Histogram — ghi PHÂN PHỐI, tính được phân vị
var orderDuration = meter.CreateHistogram<double>(
"crm.order.creation.duration",
unit: "ms",
description: "Thời gian tạo đơn hàng");

using var _ = new DurationRecorder(orderDuration, tags: [
new("order.type", order.Type.ToString())
]);

Vì sao trung bình vô dụng:

1000 request: 990 cai 50ms, 10 cai 5000ms
Trung binh = 99,5ms -> bieu do trong "binh thuong"
p99 = 5000ms -> 1% người dùng đợi 5 GIÂY

10 người dùng đó gặp trải nghiệm tệ và họ là những người sẽ gọi điện phàn nàn. Biểu đồ trung bình không hề cho thấy điều đó.

Chỉ số nên có cho mỗi service:

LoạiVí dụ
REDRate (request/giây), Errors (%), Duration (p50/p95/p99)
Tài nguyênCPU, bộ nhớ, connection pool đang dùng
Nghiệp vụSố đơn tạo/phút, giá trị đơn trung bình
Hàng đợiĐộ sâu queue, tuổi message cũ nhất

Nhóm nghiệp vụ hay bị bỏ qua nhưng phát hiện sự cố nhanh nhất: "số đơn tạo mỗi phút giảm về 0" là tín hiệu rõ ràng hơn mọi chỉ số kỹ thuật, và nó bắt được cả những lỗi mà hệ thống vẫn trả HTTP 200.

Cardinality là cái bẫy tốn tiền:

// SAI — mỗi customer một chuỗi metric riêng -> hàng triệu chuỗi
tags: [new("customer.id", customerId.ToString())]

// ĐÚNG — số lượng giá trị hữu hạn
tags: [new("customer.tier", customer.Tier.ToString())]

Cardinality cao làm backend metric phình dung lượng và chậm truy vấn — đây là nguyên nhân phổ biến của hoá đơn quan sát tăng vọt ngoài dự kiến.

18.6.5 — Sampling​

Lưu 100% trace trên hệ thống lớn rất tốn. Ba chiến lược:

Chiến lượcCách hoạt độngĐánh đổi
Head-basedQuyết định ngay ở request đầu tiênRẻ, nhưng có thể bỏ sót trace lỗi
Tail-basedQuyết định sau khi trace hoàn tấtGiữ được mọi trace lỗi và chậm, tốn tài nguyên hơn
Luôn lấy lỗiLấy mẫu thấp cho request thành công, 100% cho lỗiCân bằng thực dụng
.WithTracing(t => t
.SetSampler(new TraceIdRatioBasedSampler(0.1)) // 10% request thành công
.AddAspNetCoreInstrumentation());

Với hệ thống vừa phải, cứ lưu 100% — đơn giản hơn và chi phí chưa đáng kể. Chỉ tối ưu khi hoá đơn thật sự thành vấn đề. Tối ưu sớm ở đây làm bạn mất đúng những trace cần nhất vào lúc cần nhất.

18.6.6 — Rà lại code của bạn​

Danh sách rà soát observability

  • •Mọi dòng log đều có TraceId và SpanId.
  • •Log ghi có cấu trúc, không nối chuỗi vào thông điệp.
  • •Trace bỏ qua health check để không nhiễu dữ liệu.
  • •Có runtime instrumentation cho GC và thread pool.
  • •Dashboard dùng p95 và p99, không dùng trung bình.
  • •Có ít nhất một chỉ số nghiệp vụ, không chỉ chỉ số kỹ thuật.
  • •Tag của metric có cardinality thấp, không gắn id người dùng.
  • •Trace của request lỗi luôn được giữ, không bị sampling bỏ qua.
  • •Có thuộc tính môi trường để tách staging khỏi production.
  • •Observability đã sẵn sàng trước khi tách service.

Bài tập áp dụng​

Bài 1 — Nối ba trụ cột​

Dựng OpenTelemetry với Jaeger, tạo một lỗi có chủ đích, rồi đi từ metric sang trace sang log chỉ bằng trace id.

Tiêu chí hoàn thành: bạn đi được cả ba chặng, và đo được thời gian chẩn đoán so với cách cũ.

Gợi ý và lời giải — Bài 1

Gợi ý. Ba trụ cột rời rạc là ba công cụ. Nối bằng trace_id thì chúng thành một.

Lời giải — cấu hình:

services:
jaeger:
image: jaegertracing/all-in-one:1.62
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "127.0.0.1:16686:16686" # giao diện
- "127.0.0.1:4317:4317" # OTLP gRPC

prometheus:
image: prom/prometheus:v2.54.1
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml:ro"]
ports: ["127.0.0.1:9090:9090"]

loki:
image: grafana/loki:3.2.0
ports: ["127.0.0.1:3100:3100"]

grafana:
image: grafana/grafana:11.2.0
ports: ["127.0.0.1:3000:3000"]
var otel = builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r
.AddService("crm-leads", serviceVersion: version)
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] = builder.Environment.EnvironmentName,
}));

otel.WithTracing(t => t
.AddAspNetCoreInstrumentation(o => o.RecordException = true)
.AddHttpClientInstrumentation(o => o.RecordException = true)
.AddSqlClientInstrumentation(o => o.SetDbStatementForText = true)
.AddSource("Crm.*")
.AddOtlpExporter());

otel.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Crm.*")
.AddPrometheusExporter());

builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeScopes = true;
o.IncludeFormattedMessage = true;
o.AddOtlpExporter();
});

app.MapPrometheusScrapingEndpoint();

Ba dòng làm cho việc nối hoạt động:

1. ConfigureResource với cùng service.name -> ba trụ cột biết đây là cùng một service
2. IncludeScopes = true -> trace_id xuất hiện trong log
3. RecordException = true -> exception được ghi vào span

Tạo lỗi có chủ đích:

app.MapGet("/api/leads/{id}/bao-cao", async (Guid id, CrmDbContext db, CancellationToken ct) =>
{
var lead = await db.Leads.FirstAsync(l => l.Id == id, ct); // ném nếu không có
return Results.Ok(TaoBaoCao(lead));
});
curl http://localhost:8080/api/leads/00000000-0000-0000-0000-000000000000/bao-cao

Chặng 1 — Metric phát hiện có vấn đề:

sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_seconds_count[5m]))
0.032        <- tỷ lệ lỗi 3,2%, vượt ngưỡng 1%
# Endpoint nào?
topk(5, sum by (http_route) (
rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])))
{http_route="/api/leads/{id}/bao-cao"}   1.84
Metric nói: "endpoint này lỗi"
Metric KHÔNG nói: vì sao

Chặng 2 — Trace cho biết lỗi ở đâu:

Trong Grafana, bấm Explore → Tempo/Jaeger với bộ lọc status = error:

GET /api/leads/{id}/bao-cao                          42 ms  ████  [ERROR]
└─ SELECT TOP(1) ... FROM Leads WHERE Id = @p0 38 ms ███
db.statement: SELECT TOP(1) [l].[Id], ... WHERE [l].[Id] = @__id_0
exception.type: System.InvalidOperationException
exception.message: Sequence contains no elements
exception.stacktrace: at Microsoft.EntityFrameworkCore...

trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
Trace nói: lỗi ở truy vấn database, là InvalidOperationException từ .FirstAsync

Chặng 3 — Log cho bối cảnh đầy đủ:

{service_name="crm-leads"} | json | trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
08:14:22.104 Info  Request starting GET /api/leads/000.../bao-cao
user_id=an@company.com tenant_id=acme
08:14:22.112 Info Nạp lead 00000000-0000-0000-0000-000000000000
08:14:22.146 Error Unhandled exception
InvalidOperationException: Sequence contains no elements
08:14:22.147 Info Request finished 500 in 42.8ms
Log nói: người dùng an@company.com, tenant acme, gọi với id toàn số 0
-> đây là một id không hợp lệ được gửi từ giao diện
-> nguyên nhân có lẽ ở frontend, không ở backend

Ba chặng, ba câu trả lời:

Metric: CÓ vấn đề, ở đâu (endpoint nào), mức độ (3,2%)
Trace: vấn đề Ở CHẶNG NÀO, mất bao lâu, exception gì
Log: BỐI CẢNH — ai, tenant nào, tham số gì

Đo thời gian chẩn đoán:

| Cách | Thời gian | Các bước |
|---|---:|---|
| Không có gì | 2–4 giờ | grep log nhiều service, ghép bằng dấu thời gian |
| Chỉ có log tập trung | 30–60 phút | tìm theo thời gian, đoán request nào |
| **Ba trụ cột có nối** | **3–5 phút** | metric -> trace -> log, bấm ba lần |

Chênh lệch 30–50 lần, và nó tăng theo số service: với 8 service, cách đầu tiên thường không dẫn tới câu trả lời.

Cấu hình nối trong Grafana — bước làm cho "bấm ba lần" hoạt động:

# Loki -> Tempo
datasources:
- name: Loki
type: loki
jsonData:
derivedFields:
- name: TraceID
matcherRegex: '"trace_id":"(\w+)"'
url: '$${__value.raw}'
datasourceUid: tempo
# Tempo -> Loki
- name: Tempo
type: tempo
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: false
tracesToMetrics:
datasourceUid: prometheus
serviceMap:
datasourceUid: prometheus

Không có cấu hình này, ba trụ cột vẫn hoạt động nhưng bạn phải sao chép trace_id bằng tay giữa ba giao diện — và đó là khác biệt giữa 3 phút và 15 phút.

Ba chi tiết dễ bỏ sót:

1. service.name phải nhất quán ở cả ba trụ cột.

.ConfigureResource(r => r.AddService("crm-leads"))    // dùng cho CẢ trace, metric, log

Đặt khác nhau nghĩa là Grafana không nối được.

2. Exception phải được ghi vào span.

.AddAspNetCoreInstrumentation(o => o.RecordException = true)

Và cho exception bắt được trong code:

catch (Exception ex)
{
Activity.Current?.AddException(ex); // .NET 9
Activity.Current?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}

3. db.statement có thể chứa dữ liệu nhạy cảm.

.AddSqlClientInstrumentation(o =>
{
o.SetDbStatementForText = builder.Environment.IsDevelopment(); // chỉ ở dev
})

Câu SQL với tham số đã nội suy có thể chứa email, số điện thoại — và trace thường được lưu ở hệ thống có quyền truy cập rộng hơn database.

Và thêm thuộc tính nghiệp vụ vào span — thứ làm trace hữu ích hơn nhiều:

using var activity = _source.StartActivity("ChotLead");
activity?.SetTag("lead.id", id);
activity?.SetTag("tenant.id", _tenant.Id);
activity?.SetTag("lead.gia_tri", lead.Value.Amount);
Giờ tìm được: "mọi trace của tenant acme mà lead trên 500 triệu và bị lỗi"

Nhưng cẩn thận cardinality: thuộc tính span không bị giới hạn như nhãn metric, nhưng chúng vẫn tốn dung lượng và làm chậm truy vấn nếu quá nhiều.


Bài 2 — So sánh trung bình và p99​

Ghi histogram cho một endpoint, tạo tải có 1% request chậm, và so sánh hai biểu đồ.

Tiêu chí hoàn thành: bạn tính được cả hai trước khi chạy, và chọn được percentile phù hợp cho từng loại dịch vụ.

Gợi ý và lời giải — Bài 2

Gợi ý. 99 request 50 ms và 1 request 10 giây — trung bình là bao nhiêu?

Lời giải — tính trước:

99 request × 50 ms     =  4.950 ms
1 request × 10.000 ms = 10.000 ms
----------
Tổng = 14.950 ms cho 100 request

Trung bình = 149,5 ms
p50 = 50 ms
p95 = 50 ms
p99 = 50 ms <- vẫn 50! vì 1% nằm ở p99 trở lên
p99.5 = 10.000 ms

Chi tiết quan trọng: với đúng 1% request chậm, p99 vẫn là 50 ms. Percentile thứ 99 là giá trị mà 99% request nhanh hơn — và đúng 99% request ở mức 50 ms.

Phải dùng p99.5 hoặc p99.9 mới thấy được 1% đó. Đây là điều hay bị hiểu nhầm.

Với 5% request chậm:

95 × 50 ms     =  4.750 ms
5 × 10.000 ms = 50.000 ms
Trung bình = 547,5 ms
p95 = 50 ms
p99 = 10.000 ms <- giờ p99 mới bắt được

Cấu hình histogram:

otel.WithMetrics(m => m
.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());
# Trung bình
rate(http_server_request_duration_seconds_sum[5m])
/ rate(http_server_request_duration_seconds_count[5m])

# p99
histogram_quantile(0.99,
sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket[5m])))

# p99.9
histogram_quantile(0.999,
sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket[5m])))
Kết quả với 1% chậm:
trung bình: 0.1495
p50: 0.050
p95: 0.050
p99: 0.050
p99.9: 9.87

Vì sao trung bình không dùng được — ba lý do:

1. Nó gộp mất hình dạng phân bố.

A: mọi request 149 ms                     -> đều, tệ với tất cả
B: 99% × 50 ms + 1% × 10.000 ms -> tốt với hầu hết, thảm hoạ với một số
C: 50% × 10 ms + 50% × 289 ms -> hai nhóm rõ rệt

Trung bình cả ba = 149 ms

Ba tình huống cần ba cách xử lý khác nhau, và trung bình không phân biệt được.

2. Nó bị pha loãng theo lưu lượng.

100 req/s, 1 req chậm 10 giây    -> trung bình 149 ms
10.000 req/s, 1 req chậm 10 giây -> trung bình 51 ms

Cùng một người dùng chờ 10 giây, nhưng trung bình gần như không đổi.

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.

3. Với người dùng thực hiện nhiều request, percentile đuôi trở thành trải nghiệm phổ biến.

Một trang gọi 20 API, mỗi API có p99 = 10 giây
Xác suất trang đó có ÍT NHẤT một lời gọi chậm:
1 - 0,99^20 = 18,2%

-> gần một phần năm lượt tải trang bị ảnh hưởng bởi một percentile "hiếm"

Đây là lý do p99 của service quan trọng hơn nhiều so với vẻ ngoài: nó không phải "1% người dùng", nó là "18% lượt tải trang" khi nhân lên.

Chọn percentile cho từng loại dịch vụ:

LoạiPercentileLý do
API nội bộ, lưu lượng thấpp95p99 tính từ quá ít mẫu, nhiễu
API phục vụ người dùngp95 và p99p95 cho xu hướng, p99 cho đuôi
API quan trọng, lưu lượng caop99 và p99.9mỗi request đều đáng
Batch, job nềnp50 và maxthông lượng quan trọng hơn đuôi

Quy tắc về số mẫu:

Để p99 có ý nghĩa: cần ít nhất 1.000 request trong cửa sổ
Để p99.9 có ý nghĩa: cần ít nhất 10.000

Dưới ngưỡng đó, percentile cao dao động mạnh và gây cảnh báo giả.
# Kiểm tra đủ mẫu chưa
sum(rate(http_server_request_duration_seconds_count[5m])) * 300
842        <- chỉ 842 request trong 5 phút -> p99 KHÔNG đáng tin

Với lưu lượng thấp, mở rộng cửa sổ thay vì tăng percentile:

histogram_quantile(0.99, sum by (le) (rate(http_..._bucket[30m])))

Đo theo từng route, không gộp:

# SAI — gộp mọi endpoint
histogram_quantile(0.99, sum by (le) (rate(http_server_request_duration_seconds_bucket[5m])))

# ĐÚNG — theo route
histogram_quantile(0.99,
sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket[5m])))
Gộp: health check nhanh (10.000 req/s) pha loãng endpoint báo cáo chậm (2 req/s)
-> p99 tổng trông tốt trong khi endpoint quan trọng rất tệ

Và ranh giới bucket quyết định độ chính xác:

Boundaries: [0.005, 0.01, 0.025, 0.05, ...]

Nếu p99 thật là 0,04 giây:
có bucket 0.025 và 0.05 -> ước lượng khá chính xác

Nếu boundaries là [0.1, 1, 10]:
p99 thật 0,04 rơi vào bucket đầu -> ước lượng rất thô

Đặt ranh giới dày ở vùng bạn quan tâm:

// Dịch vụ có p95 khoảng 50 ms
Boundaries = [0.005, 0.01, 0.02, 0.03, 0.05, 0.075, 0.1, 0.2, 0.5, 1, 5]

Cảnh báo nên đặt trên ba chỉ số, theo thứ tự ưu tiên:

- alert: TyLeLoiCao
expr: |
sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_seconds_count[5m])) > 0.01
for: 5m

- alert: DoTreP95Cao
expr: |
histogram_quantile(0.95, sum by (le, http_route) (
rate(http_server_request_duration_seconds_bucket[5m]))) > 1
for: 10m

- alert: DoTreP99RatCao
expr: |
histogram_quantile(0.99, sum by (le, http_route) (
rate(http_server_request_duration_seconds_bucket[5m]))) > 5
for: 10m

Tỷ lệ lỗi đứng đầu vì nó là thứ người dùng cảm nhận rõ nhất: chậm thì khó chịu, lỗi thì không làm được việc.


Bài 3 — Tìm lỗi cardinality​

Rà mọi tag metric trong dự án và đánh dấu tag nào có thể nhận giá trị không giới hạn.

Tiêu chí hoàn thành: bạn rà được toàn bộ, và biết ba cách đặt giới hạn phòng thủ.

Gợi ý và lời giải — Bài 3

Gợi ý. Số chuỗi metric là tích của số giá trị mỗi nhãn, không phải tổng.

Lời giải — rà soát:

grep -rn "KeyValuePair<string, object?>\|new(\"" --include="*.cs" src/ \
| grep -oP '"\K[a-z_.]+(?=")' | sort -u
http.route
http.method
status_code
tenant_id
user_id <- NGHI NGỜ
lead_id <- NGHI NGỜ
correlation_id <- CHẮC CHẮN SAI
error_message <- CHẮC CHẮN SAI
sql_query <- CHẮC CHẮN SAI
consumer_name
queue_name
# Đếm chuỗi thật
curl -s localhost:8080/metrics | grep -oP '^[a-z_]+(?=\{)' | sort | uniq -c | sort -rn | head
 412048 crm_requests_total          <- rõ ràng có vấn đề
1240 http_server_request_duration_seconds_bucket
52 dotnet_gc_collections_total
# Nhãn nào gây bùng nổ?
curl -s localhost:8080/metrics | grep "^crm_requests_total" | head -3
crm_requests_total{route="/api/leads/{id}",user_id="an@company.com",tenant_id="acme"} 4
crm_requests_total{route="/api/leads/{id}",user_id="binh@company.com",tenant_id="acme"} 12
crm_requests_total{route="/api/leads/{id}",user_id="cuong@abc.vn",tenant_id="globex"} 7

user_id là thủ phạm.

Tính số chuỗi:

route:     50 endpoint
tenant_id: 50 tenant
user_id: 8.000 người dùng
------------------
50 × 50 × 8.000 = 20.000.000 chuỗi

Nhưng chỉ thấy 412.048 — vì không phải mọi tổ hợp đều xuất hiện. Và đó là điểm nguy hiểm: con số tăng theo thời gian, khi ngày càng nhiều tổ hợp được chạm tới.

Bộ nhớ Prometheus:  ~3 KB/chuỗi × 412.048 ≈ 1,2 GB
Ứng dụng .NET: ~300 byte/chuỗi ≈ 124 MB
Endpoint /metrics: ~48 MB mỗi lần scrape
Thời gian scrape: từ 50 ms lên 12 giây -> vượt timeout

Ba điều kiện để một nhãn hợp lệ — cần ĐỦ cả ba:

1. HỮU HẠN     — tập giá trị có trần biết trước
2. ĐÃ BIẾT — bạn liệt kê được các giá trị
3. KHÔNG TĂNG theo lưu lượng
NhãnHữu hạnĐã biếtKhông tăngKết luận
http.route (mẫu)CóCóCóHợp lệ
http.methodCóCóCóHợp lệ
status_codeCóCóCóHợp lệ
queue_nameCóCóCóHợp lệ
tenant_idCó (~50)CóChậmHợp lệ nếu ít tenant
user_idKhôngKhôngCóKhông
lead_idKhôngKhôngCóKhông
correlation_idKhôngKhôngCóKhông
error_messageKhôngKhôngCóKhông
sql_queryKhôngKhôngCóKhông

Hai dòng cuối trông "hữu hạn" nhưng không phải:

_demLoi.Add(1, new KeyValuePair<string, object?>("error_message", ex.Message));
ex.Message = "Lead 0192f8a3-... không tồn tại"
-> mỗi lead một thông điệp khác nhau
-> KHÔNG giới hạn
// Sửa — dùng LOẠI lỗi, không dùng thông điệp
_demLoi.Add(1, new KeyValuePair<string, object?>("error_type", ex.GetType().Name));

tenant_id là trường hợp cần cân nhắc:

50 tenant  -> chấp nhận được, và rất hữu ích để so sánh giữa khách hàng
5.000 tenant -> 5.000 × số nhãn khác = quá nhiều

Với nhiều tenant, gom nhóm:

new KeyValuePair<string, object?>("tenant_tier", tenant.Tier)      // "free", "pro", "enterprise"

Ba cách đặt giới hạn phòng thủ:

Cách 1 — danh sách nhãn cho phép ở tầng ứng dụng:

otel.WithMetrics(m => m
.AddView("http.server.request.duration", new MetricStreamConfiguration
{
TagKeys = ["http.route", "http.response.status_code", "http.request.method"],
CardinalityLimit = 2000,
})
.AddView("crm.requests", new MetricStreamConfiguration
{
TagKeys = ["route", "tenant_tier"],
CardinalityLimit = 500,
}));
TagKeys:          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: trần cứng — vượt qua, chuỗi mới gộp vào một chuỗi "overflow"
thay vì làm sập

Cách 2 — giới hạn ở Prometheus:

scrape_configs:
- job_name: crm
sample_limit: 50000 # từ chối scrape nếu vượt
label_limit: 20
label_name_length_limit: 100
label_value_length_limit: 200
metric_relabel_configs:
- source_labels: [user_id]
action: labeldrop # xoá nhãn này nếu lọt qua

sample_limit làm 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 hệ thống giám sát đúng lúc đang có sự cố.

Cách 3 — test chặn ở CI:

[Fact]
public void Metric_khong_duoc_dung_nhan_cardinality_cao()
{
var nhanCam = new[]
{
"user_id", "userId", "lead_id", "leadId", "order_id", "orderId",
"correlation_id", "trace_id", "request_id", "session_id",
"email", "phone", "error_message", "exception_message",
"sql", "query", "url", "path", "ip",
};

var viPham = Directory
.GetFiles(ThuMucSrc(), "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains("obj") && !f.Contains("Tests"))
.SelectMany(f => File.ReadAllLines(f)
.Select((dong, i) => (f, i: i + 1, dong))
.Where(x => x.dong.Contains("KeyValuePair<string, object?>")
&& nhanCam.Any(n => x.dong.Contains($"\"{n}\""))))
.Select(x => $"{Path.GetFileName(x.f)}:{x.i}")
.ToList();

viPham.Should().BeEmpty(
"nhãn metric phải có tập giá trị hữu hạn; " +
"dữ liệu cardinality cao thuộc về LOG và TRACE, không thuộc metric");
}

Nơi đúng cho dữ liệu cardinality cao:

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
_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
_demRequest.Add(1,
new KeyValuePair<string, object?>("route", route),
new KeyValuePair<string, object?>("status", status));

Với cách này bạn không mất thông tin — khi p99 tăng, bạn vẫn tìm được người dùng nào bị ảnh hưởng qua trace. Chỉ là thông tin đó nằm ở đúng chỗ.

Giám sát chính cardinality:

# 10 metric nhiều chuỗi nhất
topk(10, count by (__name__)({__name__=~".+"}))

# Tổng số chuỗi
prometheus_tsdb_head_series
- alert: CardinalityCao
expr: count by (__name__)({__name__=~".+"}) > 10000
for: 15m
annotations:
summary: "Metric {{ $labels.__name__ }} có {{ $value }} chuỗi"

- alert: TongChuoiTang
expr: prometheus_tsdb_head_series > 2000000
for: 30m

Và một cách kiểm tra nhanh, chạy được ngay hôm nay:

curl -s localhost:8080/metrics | wc -l
1240        <- lành mạnh cho một dịch vụ
Dưới 5.000 dòng:    bình thường
5.000–50.000: xem lại
Trên 50.000: có vấn đề cardinality

Chạy lệnh này cho mỗi service trong hệ thống — nó mất một giây và thường tìm ra ít nhất một metric đáng xem lại.

Tự kiểm tra​

Frequently asked questions

Ba trụ cột bổ sung nhau thế nào khi xử lý sự cố?

Metric báo có sự cố, ví dụ p99 tăng đột biến. Trace chỉ ra chặng nào chậm trong chuỗi gọi. Log của span đó cho biết nguyên nhân cụ thể. Thiếu một trụ cột là mất một bước trong chuỗi chẩn đoán.

Vì sao trace id phải có trong mọi dòng log?

Vì đó là sợi dây nối ba trụ cột. Có nó, từ một trace bạn lấy được toàn bộ log của request đó xuyên mọi service bằng một truy vấn. Không có nó, bạn phải dò theo thời gian và tên người dùng, vừa chậm vừa hay sai.

Vì sao không nên dùng giá trị trung bình?

Vì nó che lấp nhóm người dùng khổ nhất. Một nghìn request với chín trăm chín mươi cái 50ms và mười cái 5 giây cho trung bình 99,5ms, trông hoàn toàn bình thường, trong khi 1% người dùng đang đợi 5 giây và chính họ sẽ phàn nàn.

Vì sao chỉ số nghiệp vụ lại phát hiện sự cố nhanh?

Vì nó đo thứ thật sự quan trọng. Số đơn tạo mỗi phút giảm về 0 là tín hiệu rõ ràng hơn mọi chỉ số kỹ thuật, và nó bắt được cả những lỗi mà hệ thống vẫn trả về HTTP 200.

Cardinality cao trong metric gây vấn đề gì?

Mỗi tổ hợp giá trị tag tạo một chuỗi metric riêng, nên gắn id khách hàng vào tag sẽ sinh hàng triệu chuỗi. Backend phình dung lượng, truy vấn chậm, và hoá đơn tăng vọt. Tag nên có số lượng giá trị hữu hạn như hạng khách hàng.

Nên chọn chiến lược sampling nào?

Với hệ thống vừa phải thì lưu 100% vì đơn giản và chi phí chưa đáng kể. Khi cần tối ưu thì dùng tail-based hoặc lấy mẫu thấp cho request thành công nhưng luôn giữ 100% trace lỗi, vì đó chính là những trace cần nhất.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Trace id trong mọi dòng log là thứ biến ba trụ cột rời rạc thành một công cụ.
  2. p99, không phải trung bình. Trung bình che giấu đúng nhóm người dùng đang khổ.
  3. Observability phải có trước khi tách service, không phải làm sau.

Tham khảo​

Điều hướng​