Metrics & กับดัก cardinality
บทที่แล้วคุณตาม หนึ่ง ออเดอร์เป็น trace เดียวข้าม RabbitMQ ได้แล้ว trace เก่งเรื่องตอบคำถาม “ออเดอร์ #4821 เกิดอะไรขึ้นบ้าง เรียงตามเวลา” — แต่มันตอบไม่ได้ว่า “ตอนนี้ทั้ง platform รับออเดอร์กี่ใบต่อวินาที, checkout ช้ากว่าปกติไหม, คิว Kitchen บวมหรือเปล่า” คำถามพวกนี้เป็นเรื่องของ ภาพรวมเชิงสถิติ ไม่ใช่ชีวิตของ request เดียว — และนั่นคืองานของ metrics
บทนี้เราจะวัด platform Order ด้วยกรอบที่ทีม SRE ใช้จริง (RED ฝั่งรับ request, USE ฝั่ง consumer) ผ่าน instrument สี่ทรงของ .NET แล้วเดินให้พ้น กับดักที่แพงที่สุดในทั้งคอร์ส: การเผลอเอา order_id ไปเป็น label ของ metric จนชุด label ระเบิดและบิลพุ่งแบบทวีคูณ
code ในบทนี้ instrument platform Order ตัวเดิมที่ #8 สร้างไว้ (Ordering/Kitchen/Delivery/Payment บน WolverineFx outbox + RabbitMQ) — repo kaen-food-ordering (กำลังจัดทำ) เราต่อยอดจาก DiagnosticConfig static holder ตัวเดียวกับบท3 (class เดียวกันแบบ partial): บท3 วาง ActivitySource/Meter ไว้แล้ว บทนี้เติม instrument (Counter/Histogram/…) เข้ากับ Meter เดิม snippet ทั้งหมดเป็น code สอน — แต่ทุกบรรทัดคัดลอกไปรันได้ตามจริง โดยเฉพาะกฎ low-cardinality label เท่านั้น ที่ห้ามพลาด
Metrics ต่างจาก traces ตรงไหน
หัวข้อที่มีชื่อว่า “Metrics ต่างจาก traces ตรงไหน”trace บันทึก วงจรชีวิตของแต่ละ request — 1 span ต่อ1 operation, ทุกใบแยกกัน TraceTraceเส้นทางของ1 request ผ่านระบบ ประกอบด้วยหนึ่งสแปนหรือมากกว่าArchitecture จึงเก็บรายละเอียดสูงได้ (order_id, customer_id ต่อใบ) แต่ก็แพงถ้าจะเก็บ ทุก ใบ
metrics ตรงกันข้าม: มันคือ ตัวเลขสรุปรวม (aggregate statistics) ที่ถูกรวบยอดไว้แล้ว — “ออเดอร์ต่อวินาที”, “p95 ของ checkout”, “ความลึกของคิว” ไม่ใช่บันทึกของ request ใบใดใบหนึ่ง แต่เป็นภาพสุขภาพของ ทั้งระบบ ที่ราคาถูกและคงที่ไม่ว่าจะมี request กี่ล้านใบ เอกสาร OTel นิยาม metric ว่าเป็น “a measurement of a service captured at runtime” ที่ถูก aggregate (S4)
ใน .NET ประตูสู่ metrics คือ MeterMeterAPI ใน System.Diagnostics.Metrics ที่สร้าง instrument (Counter/UpDownCounter/Histogram/Gauge); ต้อง AddMeter ชื่อนี้Architecture — class ใน System.Diagnostics.Metrics ที่ทำหน้าที่เหมือน โรงงานสร้างเครื่องมือวัด (instrument) คุณสร้าง Meter หนึ่งตัวต่อ service แล้วให้มันผลิต instrument ออกมา (Counter, Histogram, …) OTel ฝั่ง .NET map ตรงๆ: Meter ≈ Meter API ของ OTel, instrument ที่มันสร้าง = OTel instrument (S4, S17)
เหมือนกฎ AddSource ของบท3 — ถ้าชื่อที่ให้ Meter ตอน new(...) ไม่ตรงกับที่ประกาศใน .AddMeter(...) ตอน wire OTel เป๊ะๆ metric จะถูกทิ้งเงียบ ไม่มี error ไม่มีเตือน และของ Wolverine ต้องใช้ wildcard เพราะ meter จริงชื่อ Wolverine:{AppName} — AddMeter("Wolverine*") จับได้ ส่วน AddMeter("Wolverine") เปล่าๆ จับไม่ได้เลยสักตัว (S35)
instrument สี่ทรง — เลือกให้ตรงรูปร่างข้อมูล
หัวข้อที่มีชื่อว่า “instrument สี่ทรง — เลือกให้ตรงรูปร่างข้อมูล”Meter สร้าง instrument ได้หลายทรง แต่ที่ใช้จริงมีสี่ ต่างกันที่ รูปร่างของค่าที่วัด (S4):
| ทรง | ทิศทางค่า | บันทึกเมื่อไหร่ | ตัวอย่างบน platform Order |
|---|---|---|---|
| Counter | ขึ้นอย่างเดียว (monotonic ↑) | .Add(n) ตอนเกิดเหตุ | ออเดอร์ที่ยืนยันแล้วสะสม |
| UpDownCounter | ขึ้นและลงได้ (↑↓) | .Add(±n) ตอนเกิดเหตุ | ออเดอร์ที่กำลัง in-flight / ความลึกคิว |
| Histogram | การกระจายของค่า | .Record(v) ตอนเกิดเหตุ | latency ของ checkout (p50/p95/p99) |
| Gauge / ObservableGauge | ค่าปัจจุบัน ณ ขณะอ่าน | callback ตอน export | ความลึกคิว RabbitMQ ที่ต้อง poll มา |
จุดที่คนสับสนคือ sync vs async:
- Sync (
Counter,UpDownCounter,Histogram) — คุณเรียก.Add()/.Record()ที่จุดเกิดเหตุจริง ใน code เช่นตอนออเดอร์ถูกยืนยันก็.Add(1)ทันที - Async / Observable* (
ObservableGauge,ObservableCounter, …) — คุณให้ callback ไว้ SDK จะเรียกมัน ครั้งเดียวต่อรอบ export เพื่อไปอ่านค่าปัจจุบันมา เหมาะกับค่าที่ “อยู่ที่อื่น” และต้องไป poll มา เช่นความลึกคิวที่ต้องถาม RabbitMQ
Meter.CreateGauge<T> แบบ synchronous มีอยู่จริง แต่ มีให้ใช้หรือไม่ขึ้นกับ version System.Diagnostics.DiagnosticSource / .NET TFM ที่คุณ target สำหรับค่าทรง gauge บน platform Order (ความลึกคิว, จำนวนออเดอร์ที่ active) ให้ยึด CreateObservableGauge ไว้ก่อน — มันพกพาข้าม version ได้ปลอดภัยกว่า และ callback-per-export ก็ตรงกับธรรมชาติของค่าที่ต้อง poll อยู่แล้ว
RED ฝั่งรับ request, USE ฝั่ง consumer
หัวข้อที่มีชื่อว่า “RED ฝั่งรับ request, USE ฝั่ง consumer”รู้ว่ามี instrument อะไรบ้างแล้ว คำถามถัดมาคือ แล้วควรวัดอะไร? อย่าเดาเอง — ใช้กรอบที่ทีม operations ใช้กันจริง
RED MethodRED MethodRate, Errors, Duration — มุมมองเชิง request/service (Tom Wilkie); คู่กับ USE ที่เป็นมุมมองทรัพยากรProcess (Tom Wilkie) เหมาะกับบริการที่ ขับด้วย request — ตรงกับฝั่ง intake ของ Ordering (HTTP endpoint รับออเดอร์) พอดี (S43):
- R — Rate: จำนวน request ต่อวินาที → Counter (
orders.placed) - E — Errors: จำนวน request ที่ล้มเหลวต่อวินาที → Counter แยกด้วย label
outcome - D — Duration: การกระจายของ latency (p50/p95/p99) → Histogram (
checkout.duration)
แต่ครึ่งหลังของ platform เป็น async — Kitchen/Delivery consumer ที่กิน integration event ผ่าน RabbitMQ ไม่ได้ถูก “ขับด้วย request” ตรงๆ ตรงนี้ RED ไม่พอ ใช้ USE Method (Brendan Gregg) ที่มองเชิง ทรัพยากร แทน (S44):
- U — Utilization: consumer ยุ่ง (busy) กี่ % ของเวลา
- S — Saturation: งานที่ค้างรอ — ความลึกคิว / backlog ของ message ที่ยัง unacked (สัญญาณเตือนล่วงหน้าว่ากำลังจะไม่ทัน)
- E — Errors: message ที่ตกลง dead-letter / poison message
ตำรา SRE ของ Google เรียกชุด latency, traffic, errors, saturation ว่า four golden signals (S39) มองอีกมุมมันก็คือ RED (Rate=traffic, Errors, Duration=latency) บวกกับ Saturation จาก USE นั่นเอง สำหรับ platform Order: วัด RED ที่ Ordering intake, แล้วเติม Saturation (ความลึก outbox/คิว, DLQ backlog) ที่ฝั่ง consumer — ครบ golden signals โดยไม่ต้องท่องสูตรใหม่
Wolverine แถม metrics มาให้ฟรี
หัวข้อที่มีชื่อว่า “Wolverine แถม metrics มาให้ฟรี”ก่อนจะเขียน instrument เอง จำไว้ว่า Wolverine ปล่อย metrics ในตัวอยู่แล้ว — แค่ AddMeter("Wolverine*") (wildcard!) ก็เก็บได้ ครอบคลุมสัญญาณ USE ฝั่ง messaging เกือบครบ: wolverine-messages-sent, wolverine-execution-time (Duration ของ handler), wolverine-dead-letter-queue (Errors ฝั่ง consumer), และตัวนับ outbox/inbox (Saturation) (S35) นี่คือหลัก “auto-instrumentation เป็นพื้น ไม่ใช่เพดาน” อีกครั้ง — ของ messaging ได้ฟรีเยอะ แต่ metric ที่มีความหมายเชิง domain (มูลค่าออเดอร์, อัตรา refund) ยังต้องเขียนเอง
semantic conventions ฝั่ง messaging ยังมีสถานะ Status: Development ที่ semconv 1.43.0 (ต่างจาก HTTP/Database ที่ Stable แล้ว) (S11, S13) แปลว่าชื่อ attribute ของ metric ฝั่ง RabbitMQ (เช่นชื่อ queue, routing key) ยังไม่ final อาจเปลี่ยนได้ ใช้ได้ แต่ให้ถือว่าเป็น experimental อย่าฝัง dashboard/alert ที่พึ่งชื่อพวกนี้แบบตายตัวจนแก้ยาก
กับดัก cardinality — บรรทัดเดียวที่ทำบิลระเบิด
หัวข้อที่มีชื่อว่า “กับดัก cardinality — บรรทัดเดียวที่ทำบิลระเบิด”มาถึงหัวใจของบท CardinalityCardinalityจำนวนค่าที่ไม่ซ้ำของ field หนึ่ง; สูง = debug ได้ละเอียดแต่ทำ metric label ระเบิดค่าใช้จ่าย → ใส่ OrderId เป็น span attribute ไม่ใช่ metric labelProcess คือ จำนวนค่าที่ไม่ซ้ำกัน ของ field หนึ่ง channel มี cardinality ต่ำ (web/app/kiosk — ไม่กี่ค่า) ส่วน order_id มี cardinality สูงมาก — ทุกออเดอร์คือค่าใหม่ ไม่ซ้ำเลย
ปัญหาอยู่ที่ metric ถูกเก็บเป็น time series หนึ่งเส้นต่อหนึ่งชุด label ที่ไม่ซ้ำกัน ถ้าคุณเผลอเขียนแบบนี้:
// ❌ version ดิบ — order_id เป็น metric labelDiagnosticConfig.OrdersPlaced.Add(1, new KeyValuePair<string, object?>("order_id", order.Id.Value)); // ระเบิด!ทุกออเดอร์ = time series ใหม่หนึ่งเส้น หนึ่งล้านออเดอร์ = หนึ่งล้านเส้น และถ้าจับคู่กับอีก label ที่มี cardinality สูง จำนวนเส้นจะเป็น ผลคูณ ของทั้งสอง — โตแบบทวีคูณ ทั้งที่เก็บและที่คิดเงิน นี่คือ cardinality explosion และมันคือหนึ่งในสาเหตุอันดับต้นๆ ที่บิล observability พุ่ง (S47, S48)
กฎที่รักษาไว้ตลอดคอร์ส:
high-cardinality → span attribute · low-cardinality → metric label
order.id/customer_idใส่เป็น span attribute (trace รับ cardinality สูงได้สบาย — บท3 ทำไปแล้ว) · เอาเฉพาะ dimension ที่ cardinality ต่ำ (channel,service,status,outcome) มาเป็น metric label
// ✅ ถูกต้อง — label เฉพาะ dimension cardinality ต่ำDiagnosticConfig.OrdersPlaced.Add(1, new KeyValuePair<string, object?>("channel", req.Channel), // web/app/kiosk new KeyValuePair<string, object?>("outcome", "accepted")); // accepted/rejectedนี่คือเส้นความซื่อสัตย์ (C) ของคอร์สที่จับต้องได้ที่สุด: instrumentation มีต้นทุนจริง dashboard หรือ metric ที่ดี ลด MTTR ได้ แต่ metric ที่ออกแบบพลาดกลับ เพิ่ม ต้นทุนแบบทวีคูณ — ต้นทุนนี้ถูกตัดสิน ณ ตอนเขียน instrument ไม่ใช่ตอนเปิดบิล
order_id เป็น high-cardinality ที่ ปลอดภัยและมีประโยชน์ เมื่ออยู่ใน span attribute — มันช่วย debug แต่ อย่าสับสนกับ PII email, เบอร์โทร, ที่อยู่, เลขบัตร (PAN) เป็นข้อมูลอ่อนไหว ห้ามใส่ดิบๆ ลง span attribute หรือ log attribute เด็ดขาด ไม่ว่ามันจะช่วย debug แค่ไหน telemetry ไหลออกไปที่ backend (ClickStack หรือ vendor ไหนก็ตาม) และกลายเป็นช่องรั่วข้อมูลได้ทันที — ตรงกับบทเรียน data-leakage ของ #18 ถ้าต้องอ้างถึงลูกค้า ใช้ id ที่ opaque (customer_id ที่เป็น GUID) ไม่ใช่ตัวตนจริงของเขา
code: DiagnosticConfig ที่โตขึ้น
หัวข้อที่มีชื่อว่า “code: DiagnosticConfig ที่โตขึ้น”รวมทุกอย่างเข้า DiagnosticConfig static holder ตัวเดิม — Meter และ instrument ต้องเป็น static อายุยืน (สร้างครั้งเดียว อยู่ตลอดอายุ app) เหมือน ActivitySource:
using System.Diagnostics.Metrics;
// (ต่อจาก บท3) เพิ่ม instrument ให้ DiagnosticConfig เดิม — MeterName/Meter ประกาศไว้แล้วในบท3public static partial class DiagnosticConfig{ // R — Rate: ออเดอร์ที่รับเข้าสะสม (monotonic ↑) public static readonly Counter<long> OrdersPlaced = Meter.CreateCounter<long>("orders.placed", description: "จำนวนออเดอร์ที่รับเข้า");
// Saturation — ออเดอร์ที่กำลัง in-flight (↑ ตอนรับ, ↓ ตอนจบ) public static readonly UpDownCounter<long> OrdersInFlight = Meter.CreateUpDownCounter<long>("orders.in_flight", description: "ออเดอร์ที่ยังไม่จบ");
// D — Duration: การกระจายของ latency public static readonly Histogram<double> CheckoutDuration = Meter.CreateHistogram<double>("checkout.duration", unit: "ms", description: "เวลาที่ใช้ประมวลผล checkout");
// Saturation ฝั่ง broker — ค่าที่ต้อง poll → ObservableGauge (callback ต่อ export) public static readonly ObservableGauge<long> QueueDepth = Meter.CreateObservableGauge("rabbitmq.queue.depth", () => RabbitQueueProbe.GetDepth("kitchen-queue"), description: "ความลึกคิว Kitchen ที่ยังไม่ถูก consume");}แล้วบันทึกที่จุดเกิดเหตุใน ConfirmOrderHandler — ใส่เฉพาะ label cardinality ต่ำ:
public async Task Handle(ConfirmOrder cmd, IDbContextOutbox<OrderingDbContext> outbox){ var sw = Stopwatch.StartNew(); DiagnosticConfig.OrdersInFlight.Add(1); // เข้า in-flight try { var order = await outbox.DbContext.Orders.FindAsync(cmd.OrderId); order.Confirm(); await outbox.PublishAsync( new OrderConfirmedIntegrationEvent(order.Id.Value, DateTimeOffset.UtcNow)); await outbox.SaveChangesAndFlushMessagesAsync();
// R + label ต่ำ: channel + outcome (ไม่ใช่ order_id!) DiagnosticConfig.OrdersPlaced.Add(1, new KeyValuePair<string, object?>("channel", cmd.Channel), new KeyValuePair<string, object?>("outcome", "accepted")); } catch { // E — Errors: นับด้วย outcome ต่ำ ไม่ใช่ข้อความ exception (cardinality สูง) DiagnosticConfig.OrdersPlaced.Add(1, new KeyValuePair<string, object?>("channel", cmd.Channel), new KeyValuePair<string, object?>("outcome", "rejected")); throw; } finally { DiagnosticConfig.OrdersInFlight.Add(-1); // ออกจาก in-flight DiagnosticConfig.CheckoutDuration.Record(sw.Elapsed.TotalMilliseconds, new KeyValuePair<string, object?>("channel", cmd.Channel)); // D }}และ wire meter เข้า OTel — จำ wildcard ของ Wolverine ให้ดี:
.WithMetrics(m => m .AddMeter(DiagnosticConfig.MeterName) // "ordering-service" — ต้องตรงเป๊ะ .AddMeter("Wolverine*") // wildcard REQUIRED: meter = Wolverine:{AppName} .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation()) // GC, thread pool, CPU ได้ฟรีบท2 wire exporter ด้วย .UseOtlpExporter() ครั้งเดียว ที่ครอบทั้ง traces/metrics/logs อยู่แล้ว การเพิ่ม metric ในบทนี้ ไม่ต้องแตะ exporter อีก — แค่ .AddMeter(...) เพิ่มใน .WithMetrics(...) อย่าเผลอไปเติม .AddOtlpExporter() แบบต่อสัญญาณซ้อนกับ .UseOtlpExporter() (เชื่อว่าจะ throw) — เลือกทรงใดทรงหนึ่ง ไม่ปน
แผนภาพ: cardinality เดินไปทางไหนของแต่ละสัญญาณ
หัวข้อที่มีชื่อว่า “แผนภาพ: cardinality เดินไปทางไหนของแต่ละสัญญาณ”flowchart TD
E["เหตุการณ์: ConfirmOrder<br/>order_id, customer_id, channel, outcome"]
E --> DECIDE{"field นี้ cardinality<br/>สูงหรือต่ำ?"}
DECIDE -->|"สูง: order_id, customer_id"| TRACE["span attribute<br/>(trace รับ high-cardinality ได้)"]
DECIDE -->|"ต่ำ: channel, outcome, status"| METRIC["metric label<br/>(1 time series ต่อ 1 ชุด label)"]
TRACE --> OK1["ไล่ดูรายใบได้<br/>ต้นทุนคงที่ต่อ trace"]
METRIC --> OK2["สรุปรวมราคาถูก<br/>จำนวนเส้นจำกัด"]
BAD["❌ version ดิบ:<br/>order_id เป็น metric label"] --> BOOM["time series ระเบิด<br/>บิลโตแบบทวีคูณ"]
classDef good fill:#0e7490,stroke:#155e75,color:#f0fdff;
classDef bad fill:#7f1d1d,stroke:#450a0a,color:#fecaca;
class TRACE,METRIC,OK1,OK2 good;
class BAD,BOOM bad;
คำบรรยายภาพ: กฎเดียวที่กันบิลระเบิด — ตัดสิน ณ จุดเกิดเหตุว่า field มี cardinality สูงหรือต่ำ ค่าสูง (order_id, customer_id) ไหลไปเป็น span attribute ที่ trace รองรับได้ด้วยต้นทุนคงที่ต่อใบ; ค่าต่ำ (channel, outcome, status) เท่านั้นที่ไปเป็น metric label; เส้นสีแดงคือทางที่ห้ามเดิน — order_id เป็น metric label ทำให้ time series ระเบิดและบิลโตแบบทวีคูณ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”metrics ตอบคนละคำถามกับ trace — มันคือ สถิติสรุปรวมของทั้งระบบ ไม่ใช่ชีวิตของ request เดียว Meter สร้าง instrument สี่ทรง: Counter (นับขึ้น), UpDownCounter (ขึ้นลง), Histogram (การกระจาย/latency), ObservableGauge (ค่าปัจจุบันที่ poll มา — ยึดตัวนี้ไว้ก่อนเพราะ sync Gauge<T> ขึ้นกับ TFM) วัดด้วยกรอบจริง: RED (Rate/Errors/Duration) ฝั่ง Ordering intake, USE (Utilization/Saturation/Errors) ฝั่ง Kitchen/Delivery consumer + RabbitMQ — รวมกันเป็น four golden signals และ Wolverine แถม metrics ฝั่ง messaging มาให้ฟรีผ่าน AddMeter("Wolverine*") (wildcard!) แต่บทเรียนที่แพงที่สุดคือ กับดัก cardinality: order_id/customer_id เป็น span attribute เท่านั้น ไม่ใช่ metric label — และ PII ดิบห้ามลง attribute ใดๆ ทั้ง span และ log (สะพานเชื่อม #18)
บทหน้าเราปิดสามสัญญาณด้วย logs — ปล่อย structured log ผ่าน OTel แล้วให้มัน auto-correlate กับ trace ทุกบรรทัด เพื่อกระโดดจาก log ของ “payment declined” ไปยัง trace ของออเดอร์ที่พังได้ในคลิกเดียว
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- OpenTelemetry — Metrics (เอกสารทางการ, เข้าถึง 2026-07-22) — instrument สี่ทรง (Counter/UpDownCounter/Histogram/Gauge) + async Observable*; metric = aggregate statistics [S4]
- OpenTelemetry — Semantic Conventions (v1.43.0) (2026-07-21) — Status per-area; messaging ยัง Development [S11]
- OpenTelemetry semconv — Messaging spans (semconv v1.43.0, เข้าถึง 2026-07-22) — RabbitMQ/messaging attribute ยัง Status: Development (experimental) [S13]
- Microsoft Learn — .NET observability with OpenTelemetry (2026-07-01) —
Meterของ .NET (System.Diagnostics.Metrics) + instrument ที่มันสร้าง แม็ปตรงกับ Meter API ของ OTel [S17] - WolverineFx — Logging & OpenTelemetry (เข้าถึง 2026-07-22) —
AddMeter("Wolverine*")(wildcard), meter =Wolverine:{AppName};wolverine-messages-sent/-execution-time/-dead-letter-queue[S35] - Google SRE Book Ch.6 — Monitoring Distributed Systems (2016) — four golden signals (latency/traffic/errors/saturation) [S39]
- Grafana — The RED Method (2018-08-03) — Rate/Errors/Duration (Tom Wilkie) สำหรับบริการที่ขับด้วย request [S43]
- Brendan Gregg — The USE Method (method paper ACM Queue 2012) — Utilization/Saturation/Errors เชิงทรัพยากร (ฝั่ง consumer/คิว) [S44]
- Honeycomb — The cost crisis in observability tooling (2024-12-18) — cardinality/ปริมาณดันต้นทุน; sampling เป็นเครื่องมือหยาบ [S47]
- Observability Engineering (O’Reilly) Ch.1 — Cardinality & Dimensionality (2022, 1st ed.) — cardinality สูงดีต่อ debug แต่ระเบิด metric label [S48]
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ทำไม `order_id` ต้องเป็น span attribute ไม่ใช่ metric label?