ข้าม​ไป​ยัง​เนื้อหา

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 ตัวอย่าง

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 เท่านั้น ที่​ห้าม​พลาด

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)

ชื่อ Meter ต้อง​ตรง​กับ AddMeter เป๊ะๆ ไม่​งั้น​เงียบ​หาย

เหมือน​กฎ AddSource ของ​บท3 — ถ้า​ชื่อ​ที่​ให้ Meter ตอน new(...) ไม่​ตรง​กับ​ที่​ประกาศ​ใน .AddMeter(...) ตอน wire OTel เป๊ะๆ metric จะ​ถูก​ทิ้ง​เงียบ ไม่มี error ไม่มี​เตือน และ​ของ Wolverine ต้อง​ใช้ wildcard เพราะ meter จริง​ชื่อ Wolverine:{AppName}AddMeter("Wolverine*") จับ​ได้ ส่วน AddMeter("Wolverine") เปล่าๆ จับ​ไม่​ได้​เลย​สัก​ตัว (S35)

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
gauge แบบ sync มี​เงื่อนไข TFM — ใช้ CreateObservableGauge ไว้​ก่อน

Meter.CreateGauge<T> แบบ synchronous มี​อยู่​จริง แต่ มี​ให้​ใช้​หรือ​ไม่​ขึ้น​กับ version System.Diagnostics.DiagnosticSource / .NET TFM ที่​คุณ target สำหรับ​ค่า​ทรง gauge บน platform Order (ความ​ลึก​คิว, จำนวนออเดอร์​ที่ active) ให้​ยึด CreateObservableGauge ไว้​ก่อน — มัน​พก​พา​ข้าม version ได้​ปลอดภัย​กว่า และ callback-per-export ก็​ตรง​กับ​ธรรมชาติ​ของ​ค่าที่​ต้อง poll อยู่​แล้ว

รู้​ว่า​มี 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
RED + Saturation = four golden signals

ตำรา 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 โดย​ไม่​ต้อง​ท่อง​สูตร​ใหม่

ก่อน​จะ​เขียน 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) ยัง​ต้อง​เขียน​เอง

messaging semconv ยัง Development — attribute ของ RabbitMQ ยัง​ทดลอง

semantic conventions ฝั่ง messaging ยัง​มี​สถานะ Status: Development ที่ semconv 1.43.0 (ต่าง​จาก HTTP/Database ที่ Stable แล้ว) (S11, S13) แปล​ว่า​ชื่อ attribute ของ metric ฝั่ง RabbitMQ (เช่น​ชื่อ queue, routing key) ยัง​ไม่ final อาจ​เปลี่ยน​ได้ ใช้ได้ แต่​ให้​ถือว่า​เป็น experimental อย่า​ฝัง dashboard/alert ที่พึ่ง​ชื่อ​พวก​นี้​แบบ​ตายตัว​จน​แก้​ยาก

มา​ถึง​หัวใจ​ของ​บท 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 label
DiagnosticConfig.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 ไม่ใช่​ตอน​เปิด​บิล

ห้าม​ใส่ PII ดิบ​ลง attribute — สะพาน​เชื่อม #18 data-leakage

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) ไม่ใช่​ตัวตน​จริง​ของ​เขา

รวม​ทุก​อย่าง​เข้า DiagnosticConfig static holder ตัว​เดิม — Meter และ instrument ต้อง​เป็น static อายุ​ยืน (สร้าง​ครั้ง​เดียว อยู่​ตลอด​อายุ app) เหมือน ActivitySource:

using System.Diagnostics.Metrics;
// (ต่อจาก บท3) เพิ่ม instrument ให้ DiagnosticConfig เดิม — MeterName/Meter ประกาศไว้แล้วในบท3
public 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 ได้ฟรี
🔁 อย่า​ผสม exporter สอง​แบบ (ย้ำ​จาก​บท2)

บท2 wire exporter ด้วย .UseOtlpExporter() ครั้ง​เดียว ที่​ครอบ​ทั้ง traces/metrics/logs อยู่​แล้ว การ​เพิ่ม metric ใน​บท​นี้ ไม่​ต้อง​แตะ exporter อีก — แค่ .AddMeter(...) เพิ่ม​ใน .WithMetrics(...) อย่า​เผลอ​ไป​เติม .AddOtlpExporter() แบบ​ต่อ​สัญญาณ​ซ้อน​กับ .UseOtlpExporter() (เชื่อ​ว่า​จะ throw) — เลือก​ทรง​ใด​ทรง​หนึ่ง ไม่​ปน

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 ของออเดอร์​ที่​พัง​ได้​ใน​คลิก​เดียว


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

ทำไม `order_id` ต้องเป็น span attribute ไม่ใช่ metric label?