Distributed tracing ข้าม message bus — ตามออเดอร์เดียวเป็นเทรซเดียว
บทที่แล้วคุณรัน ClickStack แล้วได้ trace แรก จาก endpoint place-order ของ Ordering — server span หนึ่งอันกับ EF Core DB span ลูกของมัน ทั้งหมดนั้นเกิดใน process เดียว แต่ platform Order จริงของ #8 ไม่ได้อยู่ process เดียว ออเดอร์หนึ่งใบ confirm ที่ Ordering แล้ววิ่งข้าม transactional outbox ผ่าน RabbitMQ ไปโผล่ที่ Kitchen (เปิด ticket) และ Delivery (จอง assignment) — และหลัง #8 แยก Delivery ออกเป็น service เดี่ยว การเดินทางนั้นก็ข้าม 2 process กับ1 broker จริงๆ
นี่คือบทที่เป็นหัวใจของทั้งคอร์ส: จบบทนี้ ออเดอร์ #4821 ที่ตีสามในบท1 จะไม่ใช่ความลึกลับสี่ก้อนที่ไม่โยงกันอีกต่อไป — มันจะเป็น เส้นทางเดียว ที่ไล่ดูได้ตั้งแต่ confirm จนถึง delivery assignment ในหน้า waterfall เดียว
code ในบทนี้ instrument platform Order ตัวเดิมที่ #8 สร้าง (Ordering/Kitchen/Delivery/Payment บน WolverineFx outbox + RabbitMQ) — repo kaen-food-ordering (กำลังจัดทำ) เราไม่แตะ business logic ของ handler เลย: ConfirmOrderHandler กับ WhenOrderConfirmed เหมือนกันเป๊ะทั้งก่อนและหลังแยก Delivery เราแค่เพิ่ม ชั้นสังเกตการณ์ คร่อมมัน — manual span หนึ่งอันบนฝั่ง Ordering แล้วปล่อยให้ Wolverine ส่งต่อ trace context ข้าม broker ให้เอง
Trace กับ Span คืออะไร — พูดด้วยภาษา .NET
หัวข้อที่มีชื่อว่า “Trace กับ Span คืออะไร — พูดด้วยภาษา .NET”TraceTraceเส้นทางของ1 request ผ่านระบบ ประกอบด้วยหนึ่งสแปนหรือมากกว่าArchitecture คือเส้นทางของ หนึ่ง request/หนึ่งหน่วยงานที่ไหลผ่านระบบ ประกอบด้วย SpanSpanหนึ่งหน่วยงาน/operation ในเทรซ มี name/timestamps/attributes/status/kind และ span contextArchitecture หนึ่งอันหรือมากกว่าเรียงเป็นต้นไม้ตามความสัมพันธ์ parent-child 1 span คือ1 operation ที่มีจุดเริ่ม-จบ — เช่น “รับ HTTP request”, “query DB”, “จัดการ integration event หนึ่งใบ”
จุดสำคัญสำหรับคนเขียน .NET: OpenTelemetry ไม่ได้บังคับให้คุณเรียน API ใหม่ มันแม็ปกับของที่มีใน framework อยู่แล้วใน System.Diagnostics (S17, S3):
- ActivitySourceActivitySourceAPI ใน System.Diagnostics ของ .NET ที่ทำหน้าที่เป็น Tracer สร้าง Activity (=สแปน); ต้อง AddSource ชื่อนี้ไม่งั้น telemetry ถูกทิ้งเงียบArchitecture ≈ Tracer — โรงงานที่สร้าง span
Activity≈ Span — ตัว span จริงๆ (ชื่อ class ใน .NET คือActivityด้วยเหตุผลทางประวัติศาสตร์ แต่คิดว่ามันคือ span ได้เลย)ActivityKind≈ Span Kind
1 span พก field เหล่านี้ติดตัว (S3): name, parent span id, timestamps (เริ่ม/จบ), span context (trace id + span id + trace flags — ตัวที่ทำให้ span รู้ว่าตัวเองอยู่ในเทรซไหน), attributes (key-value เชิง domain เช่น order.customer_id), events (จุดเวลาที่มีความหมายภายใน span), links (โยงไป span อื่นที่ไม่ใช่ parent ตรงๆ — จำคำนี้ไว้ เดี๋ยวมันเป็นตัวเอกตอนท้ายบท), status, และ kind
2 field สุดท้ายมีค่าที่ตายตัว จำไว้ให้แม่น (S3):
- Status =
Unset(ค่าเริ่มต้น) ·Ok(ยืนยันว่าสำเร็จอย่างชัดแจ้ง) ·Error(พัง) — โดยปกติปล่อยเป็นUnsetแล้วตั้งErrorเฉพาะตอน fail - Kind =
Internal(งานใน process) ·Server(รับ request เข้า) ·Client(ยิง request ออก เช่น HttpClient ไป AcmePay) ·Producer(ส่ง message เข้า queue) ·Consumer(รับ message จาก queue) — 3 kind หลังคือกุญแจของการ trace ข้าม RabbitMQ
Context propagation — traceparent เดินบนสายยังไง
หัวข้อที่มีชื่อว่า “Context propagation — traceparent เดินบนสายยังไง”คำถามหัวใจ: Ordering กับ Delivery เป็นคนละ process (คนละเครื่องได้ด้วย) แล้ว span ที่ Delivery รู้ได้ยังไง ว่าตัวเองเป็นลูกของเทรซที่เริ่มจาก Ordering? คำตอบคือ Context PropagationContext Propagationการส่ง trace context (W3C traceparent) ข้ามขอบเขต process/broker เช่นฝังใน AMQP message properties ของ RabbitMQArchitecture — การส่ง span context (trace id + span id ของ parent) ข้ามขอบเขต process โดยฝากไปกับตัว request/message เอง (S7)
มาตรฐานที่ OpenTelemetry ใช้เป็น propagator เริ่มต้นคือ W3C Trace Context (บวก Baggage) — ซึ่งนิยาม HTTP header ชื่อ traceparent หน้าตาแบบนี้ (S8):
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 │ │ │ │ version trace-id (32 hex) parent span-id trace-flags (16 hex) (01 = sampled)รูปแบบคือ 00-<trace-id 32 hex>-<span-id 16 hex>-01 เมื่อ service A ยิง HTTP ไป service B, SDK จะ inject traceparent ลงใน header อัตโนมัติ; ฝั่ง B จะ extract มันออกมาแล้วสร้าง span ใหม่ที่มี parent เป็น span id ตัวนั้น — 2 span จึงอยู่ trace id เดียวกัน ต่อกันเป็นเส้นเดียว
แล้วข้าม RabbitMQ ล่ะ? หลักการเดียวกันเป๊ะ แต่แทนที่จะเป็น HTTP header, traceparent/tracestate ถูกฝังลงใน AMQP message properties ของ message ตอน publish แล้วถูกดึงกลับออกมาตอน consume (S7, S13) message bus จึงกลายเป็นแค่ “ท่อ” ที่พา trace context ข้ามไปด้วย — producer, broker, consumer อยู่ในเทรซเดียวกันได้
Manual span แรกบน Order flow
หัวข้อที่มีชื่อว่า “Manual span แรกบน Order flow”Auto-instrumentation จากบท2 ให้ span ของ HTTP กับ DB มาฟรี — แต่มันไม่รู้จักคำว่า “order” นี่คือ honesty spine เส้น (D): auto-instrumentation เป็นพื้น ไม่ใช่เพดาน span ที่มีความหมายเชิง domain คุณต้องเขียนเอง
เราใช้ DiagnosticConfig static holder ตัวเดียวกับที่ปูไว้ (ActivitySource เป็น static อายุยืน ชื่อต้องตรงกับ AddSource เป๊ะ ไม่งั้น telemetry ถูกทิ้งเงียบ):
using System.Diagnostics;using System.Diagnostics.Metrics;
public static partial class DiagnosticConfig // partial — บท4 จะเติม instrument ของ metric เข้า class เดียวกันนี้{ public const string ServiceName = "ordering-service"; public const string ActivitySourceName = ServiceName; public const string MeterName = ServiceName;
// long-lived statics — สร้างครั้งเดียว ใช้ทั้ง app public static readonly ActivitySource ActivitySource = new(ActivitySourceName); public static readonly Meter Meter = new(MeterName);}แล้วเปิด span รอบ business action ที่มีความหมาย — ตอนรับคำสั่ง place order:
using var activity = DiagnosticConfig.ActivitySource.StartActivity("place-order");activity?.SetTag("order.customer_id", req.CustomerId);activity?.SetTag("order.item_count", req.Items.Count);// ... business logic วางออเดอร์ตามปกติ ...สังเกตสองอย่างที่พลาดง่าย:
activity?.เป็น null-conditional เสมอ —StartActivityคืนnullได้เมื่อ ไม่มี listener (เช่นตอนรัน unit test ที่ไม่ได้ตั้ง OTel หรือตอน span นี้ถูก sample ทิ้ง) code ที่เขียนactivity.SetTag(...)ตรงๆ จะพังด้วยNullReferenceExceptionในบาง environmentactivity?.SetTag(...)จึงเป็น idiom ที่ต้อง รักษา ไว้ทุกที่- tag เป็น
customer_idกับitem_count— ไม่ใช่ PII เราใส่ id กับตัวนับ (debug ได้ละเอียด, trace รับ cardinality สูงได้สบาย) แต่ ห้ามใส่ email/เบอร์โทร/ที่อยู่/เลขบัตร ลง attribute เด็ดขาด — span/log ถูกส่งออกไปนอกระบบไปที่ sink อันนี้คือสะพานตรงไปหาบทเรื่อง data-leakage ของ #18: id ใส่ได้เพื่อ debuggability, ข้อมูลอ่อนไหวดิบใส่ไม่ได้
AddSource("ordering-service") ในการ wiring ต้องตรงกับ ActivitySourceName เป๊ะ และ AddSource("Wolverine") ต้องมีเพื่อเก็บ span ของ Wolverine ถ้าชื่อไม่ตรง OTel จะ ทิ้ง span นั้นเงียบๆ ไม่มี error ไม่มี warning — คุณจะนั่งงงว่าทำไม trace หายไปครึ่งเส้น นี่คือ bug อันดับหนึ่งของมือใหม่ OTel
Wolverine ส่งต่อ context ข้าม broker ให้ — โดยแทบไม่ต้องแตะ
หัวข้อที่มีชื่อว่า “Wolverine ส่งต่อ context ข้าม broker ให้ — โดยแทบไม่ต้องแตะ”ข่าวดีที่สุดของบทนี้: การ trace ข้าม RabbitMQ คุณไม่ต้องเขียน inject/extract เอง เพราะ platform ของ #8 ใช้ WolverineFx ซึ่งมี OTel ในตัว สองอย่างที่ต้องรู้ (S35, S36):
AddSource("Wolverine")ในการ wiring ฝั่ง tracing → เก็บ span ของ Wolverine เอง (span ตอน handle/send/receive message)- Wolverine ส่งต่อ correlation/parent context ผ่าน
Envelopeของมันเองข้าม transport — ตอน publishOrderConfirmedIntegrationEventผ่าน outbox, Wolverine เขียน trace context ลงใน message properties; ตอน consumer อีก process รับ message มัน extract context นั้นกลับมาแล้ว สร้าง span ที่ต่อเทรซเดิม โดยไม่ต้องตั้งค่าอะไรเพิ่ม
ตั้งแต่ Wolverine 1.7 การปล่อย OTel span สำหรับ message ที่วิ่ง ภายใน process เดียวกัน (local queue) ถูก ปิดเป็นค่าเริ่มต้น เพื่อลด noise — ส่วนที่ข้าม transport จริง (เช่น RabbitMQ) ยังทำงาน เป้าหมายของบทนี้คือ hop ข้าม broker จึงไม่กระทบ แต่ถ้าคุณอยาก trace hop ใน process ด้วย ต้องเปิด flag เพิ่ม (S35)
Producer: ConfirmOrderHandler (ฝั่ง Ordering)
หัวข้อที่มีชื่อว่า “Producer: ConfirmOrderHandler (ฝั่ง Ordering)”handler นี้คือ root ของเทรซฝั่ง business — และมันคือ code #8 เป๊ะ ไม่แก้อะไรเพื่อ observability เลย:
public class ConfirmOrderHandler{ public async Task Handle(ConfirmOrder cmd, IDbContextOutbox<OrderingDbContext> outbox) { var order = await outbox.DbContext.Orders.FindAsync(cmd.OrderId); order.Confirm(); // event ออกทาง outbox — Wolverine จะแนบ trace context ลง message properties ให้เอง await outbox.PublishAsync( new OrderConfirmedIntegrationEvent(order.Id.Value, DateTimeOffset.UtcNow)); // state + event atomic; flush ออก broker หลัง commit สำเร็จ await outbox.SaveChangesAndFlushMessagesAsync(); }}Consumer: WhenOrderConfirmed (ฝั่ง Delivery — service เดี่ยว)
หัวข้อที่มีชื่อว่า “Consumer: WhenOrderConfirmed (ฝั่ง Delivery — service เดี่ยว)”นี่คือจุดที่เทรซ กลับมาต่อ ในอีก process — และย้ำอีกครั้ง: code นี้ เหมือนเดิมทุกตัวอักษรทั้งก่อนและหลังแยก Delivery สิ่งเดียวที่เปลี่ยนคือตอนนี้เทรซพาดผ่าน2 process กับ broker หนึ่งตัว:
public class WhenOrderConfirmed{ public async Task Handle(OrderConfirmedIntegrationEvent evt, DeliveryDbContext db) { // idempotent guard ของ #17 — เรา reuse เป็น "บริบทของเทรซ" ไม่ได้สอน resilience ใหม่ if (await db.Assignments.AnyAsync(a => a.OrderId == evt.OrderId)) return; db.Assignments.Add(Assignment.Create(evt.OrderId)); await db.SaveChangesAsync(); }}idempotent guard บรรทัดแรกเป็นของ #17 (resilience) เราไม่ได้ derive มันใหม่ — เราแค่ สังเกต มัน: ในหน้า waterfall คุณจะเห็น span ของ WhenOrderConfirmed ที่ถ้า guard คืนเร็ว (duplicate) span จะสั้นจู๋ ต่างจากตัวที่ทำงานจริง — observability ทำให้ behavior ของ guard มองเห็นได้
แผนภาพ: ออเดอร์เดียว เทรซเดียว ข้าม broker
หัวข้อที่มีชื่อว่า “แผนภาพ: ออเดอร์เดียว เทรซเดียว ข้าม broker”flowchart TB
subgraph ORD["process: ordering-service"]
A["span: place-order<br/>(Internal) — manual<br/>order.customer_id, order.item_count"]
B["span: ConfirmOrderHandler<br/>(Consumer/Internal) — Wolverine"]
C["span: publish OrderConfirmed<br/>(Producer) — Wolverine"]
A --> B --> C
end
subgraph BUS["RabbitMQ (broker)"]
Q["message properties พก<br/>traceparent + tracestate"]
end
subgraph DEL["process: delivery-service (แยกออกมา)"]
E["span: receive OrderConfirmed<br/>(Consumer) — Wolverine"]
F["span: WhenOrderConfirmed<br/>(Internal)"]
G["span: EF Core INSERT Assignment<br/>(Client) — auto"]
E --> F --> G
end
C -->|"inject context"| Q
Q -->|"extract context → trace id เดิม"| E
classDef ord fill:#0e7490,stroke:#155e75,color:#f0fdff;
classDef bus fill:#a21caf,stroke:#701a75,color:#fdf4ff;
classDef del fill:#0f766e,stroke:#134e4a,color:#f0fdfa;
class A,B,C ord;
class Q bus;
class E,F,G del;
คำบรรยายภาพ: ออเดอร์หนึ่งใบเดินจาก manual span place-order ที่ ordering-service ผ่าน handler ที่ publish event ออก outbox โดย Wolverine แนบ traceparent ลง message properties ของ RabbitMQ ฝั่ง delivery-service (คนละ process หลังแยก service) ดึง context กลับออกมาแล้วสร้าง span ต่อในเทรซ trace id เดียวกัน — ผลคือ waterfall เส้นเดียวที่ครอบทั้ง2 process กับ broker hop ตรงกลาง
⚠️ ความจริงที่ต้องรันจริงถึงจะฟันธง: parent-child หรือ span link?
หัวข้อที่มีชื่อว่า “⚠️ ความจริงที่ต้องรันจริงถึงจะฟันธง: parent-child หรือ span link?”นี่คือจุดที่คอร์สต้องซื่อสัตย์ที่สุด เรารัน Docker/ClickStack ในบทนี้ไม่ได้ ดังนั้นเราจะอธิบาย กลไก ให้แม่น แต่จะ ไม่ อ้างรายละเอียดของ screenshot ที่ยืนยันเองไม่ได้
สิ่งที่ยืนยันได้จากเอกสาร:
- กลไกทำงานแน่นอน: context ถูกส่งข้าม hop จริง (Wolverine
Envelopeพา parent context ไปกับ message) → consumer ต่อเทรซเดิม ได้ ไม่ใช่เทรซใหม่ที่ขาดจากกัน (S36, S7)
สิ่งที่ต้องระวังการฟันธง — 2 span ฝั่ง consumer (Kitchen + Delivery) จะเกาะกับ producer แบบไหน? มีสองแบบที่ต่างกัน:
- แบบ parent-child — consumer span เป็น ลูกโดยตรง ของ producer span นี่คือ model ที่
Envelopeของ Wolverine พา parent context ไปให้ตรงๆ → ใน waterfall จะเห็น consumer ห้อยใต้ producer เหมือนกิ่งเดียวกัน (S36) - แบบ span link — consumer span เป็น root ของเทรซตัวเอง แต่แนบ link กลับไปหา producer span นี่คือ ค่าเริ่มต้นของ OTel messaging semantic conventions เพราะ message หนึ่งใบอาจถูก fan-out/batch ไปหลาย consumer ความสัมพันธ์แบบ link จึงสื่อความ async ได้ตรงกว่า (S13)
เอกสารของ Wolverine ชี้ว่า Envelope พา parent context ข้าม transport (โน้มไปทาง parent-child) ส่วน OTel messaging convention ตั้ง span link เป็นค่าเริ่มต้น สองอย่างนี้ให้ waterfall หน้าตาต่างกัน และตัวไหนชนะขึ้นกับ version Wolverine + การตั้งค่าจริง คอร์สนี้จึง ไม่ ยืนยันว่า screenshot ของคุณจะออกมาแบบไหนเป๊ะ ตอนคุณรันจริง (บท6 จะพาสำรวจ HyperDX) ให้เปิด trace ดูเองว่า consumer span ห้อยใต้ producer (parent-child) หรือ แยกเทรซ + มี link — ทั้งสองแบบคือ “ถูก” แค่เล่าเรื่องต่างมุม และทั้งคู่ก็ยัง trace ข้าม broker ได้ตามเป้า (S13, S36)
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เปลี่ยนออเดอร์ที่เคยเป็น “ความลึกลับสี่ก้อน” ให้เป็น เทรซเดียว: Distributed TracingDistributed Tracingการตามหนึ่งเทรซข้ามหลาย service/process ผ่านการส่งต่อ context (ชัดที่สุดหลังแยก Delivery ออกเป็น service เดี่ยว)Process คือการตามหนึ่งเทรซข้ามหลาย process ด้วยการส่งต่อ context (context propagation) — trace context (traceparent แบบ W3C 00-<trace-id>-<span-id>-01) ถูก inject ลง AMQP message properties ตอน publish แล้ว extract กลับตอน consume; span บน .NET คือ Activity ที่สร้างจาก ActivitySource (≈ Tracer) พก status/kind/attributes/links; เราเขียน manual span place-order ด้วย activity?. null-conditional และ tag เป็น id/count ไม่ใช่ PII; และ Wolverine ส่งต่อ context ข้าม RabbitMQ ให้เองผ่าน Envelope — handler ConfirmOrderHandler/WhenOrderConfirmed เหมือนเดิมเป๊ะทั้งก่อน/หลังแยก Delivery เปลี่ยนแค่เทรซตอนนี้พาดข้าม process จริง จุดที่ต้องซื่อสัตย์: consumer span จะเกาะแบบ parent-child (model Envelope) หรือ span link (ค่าเริ่มต้น OTel messaging) ต้องรันจริงถึงจะฟันธง — เราไม่เดาแทนคุณ
บทหน้าเราออกจากโลกของ trace ต่อเส้น ไปสู่ metrics — ตัวเลขสรุปรวมแบบ RED และกับดัก cardinality ที่ทำให้บิลระเบิด (ทำไม order.id ต้องเป็น span attribute ไม่ใช่ metric label — บทนี้ปูไว้แล้ว บทหน้าจะอธิบายว่าทำไม)
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- OpenTelemetry — Traces (เอกสารทางการ, เข้าถึง 2026-07-22) — นิยาม span: name/parent-id/span context/attributes/events/links/status/kind; Status = Unset/Ok/Error; Kind = Internal/Server/Client/Producer/Consumer
- OpenTelemetry — Context Propagation (เอกสารทางการ, เข้าถึง 2026-07-22) — การส่ง span context ข้ามขอบเขต process; propagator เริ่มต้น = W3C TraceContext (+Baggage)
- W3C Trace Context (Recommendation, 2021-11-23; orig. 2020-02-06) — รูปแบบ
traceparent00-<trace-id 32hex>-<span-id 16hex>-01เช่น00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - OpenTelemetry Semantic Conventions — Messaging spans (semconv v1.43.0, เข้าถึง 2026-07-22, Status: Development) — messaging span; span link เป็นค่าเริ่มต้น สำหรับความสัมพันธ์ producer↔consumer (ยังเป็น Development — ถือว่า experimental)
- ClickHouse blog — Logging, Metrics, and Distributed Tracing in .NET with OpenTelemetry and ClickStack (2026-06-03) — pattern
DiagnosticConfigstatic holder +StartActivity+activity?.SetTag - WolverineFx — Logging & OpenTelemetry (เอกสารทางการ, เข้าถึง 2026-07-22) —
AddSource("Wolverine")เก็บ handler/send/receive span; internal-message OTel ปิดโดยค่าเริ่มต้นตั้งแต่ 1.7 - WolverineFx — Message header/correlation propagation (เอกสารทางการ, เข้าถึง 2026-07-22) —
Envelopeพา correlation/parent context ข้าม transport ให้ consumer ต่อเทรซเดิม
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3อะไรทำให้เทรซหนึ่งเส้น 'ต่อกันได้' ข้าม RabbitMQ จาก Ordering ไป Delivery ที่เป็นคนละ process?