Logs, wide events & การ correlate log↔trace
บท3 คุณตาม หนึ่งออเดอร์ เป็น trace เดียวข้าม RabbitMQ ได้แล้ว บท4 คุณวัดระบบด้วย metric แบบ RED โดยไม่ให้ cardinality ระเบิด แต่ตอนตีสามที่ออเดอร์ #4821 ค้าง คุณไม่ได้อยากดูแค่ “รูปทรงของ latency” — คุณอยากอ่าน ข้อความ ว่าเกิดอะไรขึ้น: “Payment ถูก decline ด้วยเหตุผลอะไร?” นั่นคืองานของ log และคำถามที่บทนี้ตอบคือ: ทำยังไงให้ log บรรทัดนั้นในบริบท Payment คลิกเดียวกระโดด ไปที่ trace ของออเดอร์ที่พังได้ — ข้ามบัส โดยไม่ต้องเดาว่าเป็นออเดอร์เดียวกันไหม
บทนี้ instrument log ของ platform Order ตัวเดิม (Ordering/Kitchen/Delivery/Payment บน WolverineFx outbox + RabbitMQ) — repo kaen-food-ordering (กำลังจัดทำ) เราต่อยอด DiagnosticConfig กับ wiring AddOpenTelemetry() จากบท2–บท4 โดยตรง ไม่เพิ่ม business logic ใหม่ แค่ทำให้ log ของทุก context สัมพันธ์กับ trace และ enrich span ที่มีอยู่แล้วให้เล่าเรื่องได้ครบ
log ใน OTel คืออะไร — มันไม่ใช่แค่ string
หัวข้อที่มีชื่อว่า “log ใน OTel คืออะไร — มันไม่ใช่แค่ string”ในโลกเดิม log คือบรรทัดข้อความที่ปะเข้า file แต่ OpenTelemetryOpenTelemetryมาตรฐานเปิดที่เป็นกลางต่อ vendor สำหรับสร้างและส่ง telemetry; ตัวมันเองไม่ใช่ backend — storage/UI ปล่อยให้เครื่องมืออื่นArchitecture มอง log เป็น LogRecord — โครงสร้างที่มี field ชัดเจน ไม่ใช่ string ก้อนเดียว เอกสารทางการของ OTel นิยาม field หลักไว้ว่า (S5):
- Timestamp — เวลาที่เหตุการณ์เกิด
- SeverityText / SeverityNumber — ระดับความรุนแรง (
Information,Warning,Error…) - Body — ตัวข้อความ
- TraceId / SpanId / TraceFlags — ตัวเชื่อม กับ trace ที่กำลังทำงานอยู่ (หัวใจของบทนี้)
- Attributes — คู่ key-value ที่ค้นหาได้
- Resource — บริบทของแหล่งกำเนิด (เช่น
service.name = payment-service)
จุดสำคัญคือ field สามตัว TraceId / SpanId / TraceFlags — พวกมันคือสายที่ผูก log บรรทัดหนึ่งกลับไปที่ span ที่ปล่อยมันออกมา นี่คือความต่างระหว่าง log ที่ลอยเดี่ยวๆ กับ log ที่เป็นส่วนหนึ่งของ เรื่องเล่าเดียว
Structured vs unstructured: log ที่ Body เป็นข้อความดิบล้วน ("order 4821 failed") ต้องใช้ regex แงะทีหลัง — version ดิบที่ค้นยาก ส่วน Structured LoggingStructured Logginglog ที่เป็น key-value ค้นหาได้ (เช่น {OrderId}) ไม่ใช่ข้อความดิบArchitecture เก็บข้อมูลเป็นคู่ key-value ที่ query ได้ตรงๆ (order.id = "4821", outcome = "declined") OTel แนะนำ structured และเราจะทำแบบนั้น
Logs Bridge เป็นของนักเขียน library — code app ยังใช้ ILogger เหมือนเดิม
หัวข้อที่มีชื่อว่า “Logs Bridge เป็นของนักเขียน library — code app ยังใช้ ILogger เหมือนเดิม”คุณอาจกังวลว่าต้องเรียนรู้ API log ใหม่ทั้งชุด — ไม่ต้อง OTel มีสิ่งที่เรียกว่า Logs Bridge API แต่เอกสารทางการระบุชัดว่ามันมีไว้ให้ นักเขียน logging library ใช้เชื่อม library ของตัวเองเข้ากับ OTel เท่านั้น (S5) — code application ของคุณไม่ควรเรียกมันโดยตรง
สำหรับ .NET แปลว่า: คุณเขียน ILogger เหมือนที่เขียนมาตลอด แล้ว OTel เชื่อม (bridge) output ของ ILogger เข้าระบบเอง เงื่อนไขเดียวคือต้องเปิด provider ฝั่ง logging:
// Program.cs — เปิด OTel logging (ยังจำเป็นแม้จะมี UseOtlpExporter() แล้ว)builder.Logging.AddOpenTelemetry(o =>{ o.IncludeFormattedMessage = true; // เก็บข้อความที่ประกอบเสร็จลง Body o.IncludeScopes = true; // ดึง scope (คู่ key-value ที่ครอบอยู่) เข้าเป็น attribute});ค่า default ของทั้งคู่คือ false — แต่ระวังความเข้าใจผิดที่พบบ่อย: parameter ใน message template ({PaymentId}) ถูกเก็บเป็น attribute ให้อัตโนมัติจาก log state อยู่แล้ว ไม่ได้ขึ้นกับสองสวิตช์นี้ สองสวิตช์นี้คุมคนละเรื่อง: IncludeFormattedMessage คุมว่าจะเก็บ ข้อความที่ประกอบเสร็จ (Body) ที่อ่านออกไหม — ถ้าไม่เปิด Body อาจเหลือแค่ template ดิบ ("Payment {PaymentId} declined") โดยไม่มีค่าที่แทนเข้าไป; ส่วน IncludeScopes คุมว่าคู่ key-value ที่คุณใส่ผ่าน BeginScope(...) จะถูกเก็บเป็น attribute ไหม เปิดทั้งคู่ไว้เป็น good practice — แต่ค่าจาก template คุณได้เป็น attribute ที่ค้นหาได้มาแต่แรกโดยไม่ต้องพึ่งมัน
Correlation อัตโนมัติ — TraceId/SpanId ถูกประทับให้ฟรี
หัวข้อที่มีชื่อว่า “Correlation อัตโนมัติ — TraceId/SpanId ถูกประทับให้ฟรี”นี่คือส่วนที่ทำให้ observability คุ้ม: Log-Trace CorrelationLog-Trace Correlationการประทับ TraceId/SpanId/TraceFlags ลงทุก LogRecord อัตโนมัติจาก span context ปัจจุบัน เพื่อกระโดดจาก log ไปเทรซArchitecture เกิดขึ้นเอง เมื่อ SDK ทำงานอยู่และมี span active (Activity.Current ไม่เป็น null) OTel จะ ประทับ TraceId, SpanId, TraceFlags ปัจจุบันลงบนทุก LogRecord โดยอัตโนมัติ (S5) คุณไม่ต้องส่ง trace id เข้า log เอง ไม่ต้องเขียน code ผูกอะไรเพิ่มเลย
// ภายใน handler ที่มี span active อยู่แล้ว (จากบท3):_logger.LogInformation( "Payment {PaymentId} declined for order {OrderId}", paymentId, orderId);// LogRecord ที่ออกไปมี TraceId/SpanId ของ span ปัจจุบันติดไปด้วย — โดยที่เราไม่ได้เขียนบรรทัดผูกเองผลลัพธ์เชิงปฏิบัติ: ใน HyperDXHyperDXUI ของ ClickStack สำหรับ search (Lucene)/SQL/dashboard/alert/trace waterfall เหนือ store เดียวกัน — บทเดียวที่ผูกกับ sink โดยตั้งใจArchitecture คุณคลิก log บรรทัด “Payment … declined” แล้ว pivot ไปที่ trace ของออเดอร์นั้นได้ทันที เพราะทั้ง2 share TraceId เดียวกัน — และเนื่องจากบท3 ทำให้ trace เดินข้าม RabbitMQ ต่อเนื่อง log ที่เกิดใน Payment จึงผูกกลับไปที่ออเดอร์ต้นทางใน Ordering ได้ ข้ามบัส นี่คือคำตอบของคำถามตอนตีสามที่เปิดบทนี้
นอกจากนี้ {PaymentId} และ {OrderId} ใน template ยังกลายเป็น attribute ที่ query ได้ (ถูกเก็บให้อัตโนมัติจาก log state — ไม่ได้ขึ้นกับ IncludeFormattedMessage/IncludeScopes ตามที่ callout ข้างบนย้ำ) — คุณจึงกรอง log ทั้งหมดของออเดอร์หนึ่งด้วย OrderId:"4821" ได้ นี่คือเหตุผลจริงที่ต้องเขียนแบบ structured ({PaymentId}) แทน string interpolation ดิบ ($"Payment {paymentId} declined" — version ดิบที่ทำให้ค่าจมหายไปใน Body ค้นแยกไม่ได้)
Wide events — enrich span ให้ span เดียวเล่าเรื่องครบ
หัวข้อที่มีชื่อว่า “Wide events — enrich span ให้ span เดียวเล่าเรื่องครบ”log กับ trace correlate กันแล้ว แต่ยังมีอีกก้าว: แทนที่จะกระจายข้อเท็จจริงเชิง domain ไปในหลาย log บรรทัด ให้ เลื่อน มันขึ้นไปเป็น attribute บน span เอง จน span หนึ่งพกทั้งเรื่องไว้ในตัว — นี่คือแนวคิด wide event ที่บท1 พูดถึง (span ที่มี attribute กว้างๆ) ทำให้จับต้องได้:
// enrich span ที่ active อยู่ให้กลายเป็น wide event ที่เล่าเรื่องได้ครบActivity.Current?.SetTag("payment.outcome", "declined");Activity.Current?.SetTag("payment.decline_reason", "insufficient_funds");Activity.Current?.SetTag("order.id", orderId);สังเกต Activity.Current?. — null-conditional เพราะถ้าไม่มี listener หรือไม่มี span active Activity.Current เป็น null (หลักการเดียวกับ activity?. ตอน StartActivity คืน null ในบท3) เมื่อทำแบบนี้ ทีม on-call ดู span ใบเดียว ก็เห็น “ออเดอร์ไหน จ่ายเงินผลอะไร เพราะอะไร” โดยไม่ต้องไล่ log หลายบรรทัดมาต่อกัน
นี่คือ Observability 2.0Observability 2.0แนวคิด 'แหล่งความจริงเดียว' เก็บเป็น wide structured event แล้วอนุมาน metrics/logs/traces ตอนอ่าน (frame ที่ vendor คิด อยู่ในดีเบตที่ยังไม่จบ)Process / wide-event ที่บท1 วางไว้เป็น ดีเบตที่ยังไม่จบ — เก็บข้อเท็จจริงเป็น event กว้างชุดเดียวแล้วอนุมาน metric/log/trace ตอนอ่าน (S46) เราทำ กลไก นี้ให้เห็นจริงในบทนี้ แต่ย้ำจุดยืนเดิม: เราไม่ประกาศว่าฝ่าย wide-event ชนะกรอบสามเสา — เราแค่ให้คุณเห็นว่ามันหน้าตาเป็นยังไงใน code
order.id, payment.outcome, customer_id เป็น attribute ที่ยอดเยี่ยมสำหรับ debug — trace รับ cardinality สูงได้สบาย แต่ ห้ามใส่ PII ดิบ (อีเมล, เบอร์โทร, ที่อยู่, เลขบัตร/PAN) ลง attribute ของ span หรือ log เด็ดขาด attribute เหล่านี้ไหลไปถึง sink และค้นหาได้ ทำให้มันเป็นช่องรั่วข้อมูลได้ทันที (เชื่อมกับบทเรียน data-leakage ของ #18) ใช้ id ที่อ้างอิงกลับได้ ไม่ใช่ ตัวข้อมูลส่วนบุคคล เอง
Baggage — หิ้ว order.id ข้าม context (และคำเตือนความปลอดภัย)
หัวข้อที่มีชื่อว่า “Baggage — หิ้ว order.id ข้าม context (และคำเตือนความปลอดภัย)”บางที span ที่อยู่ลึกๆ ในบริบท Payment อยากรู้ order.id ที่ตั้งไว้ตอนต้นทางใน Ordering — แต่มันอยู่คนละ process หลังบัส วิธีหิ้วค่าแบบนี้ข้ามขอบเขตคือ BaggageBaggageคู่ key-value ที่ส่งไปพร้อม context ข้าม service; ต้องอ่านแล้วคัดลอกเป็น attribute เอง และห้ามใส่ข้อมูลอ่อนไหว (ไม่มีการตรวจ integrity)Architecture: คู่ key-value ที่เดินทางไปพร้อม trace context ผ่าน header (W3C baggage บน HTTP, และฝังใน AMQP message properties ของ RabbitMQ)
// ต้นทาง (Ordering) — แนบ order.id เข้า baggage เพื่อให้เดินทางข้าม contextActivity.Current?.SetBaggage("order.id", order.Id.Value.ToString());ว่า SetBaggage/GetBaggageItem ผ่าน Activity.Current จะ propagate ข้าม HTTP และ โดยเฉพาะข้าม Wolverine/RabbitMQ ได้จริงหรือไม่ ขึ้นกับ propagator ที่ตั้งไว้ + version ของ transport ใน stack ของคุณ — เอกสารรับประกันแค่ กลไก W3C baggage ไม่ได้รับประกันว่า hop ข้าม broker ในระบบของคุณจะพามันไปให้อัตโนมัติ ตรงนี้เป็น build-time-verify: เปิด trace ดูจริงว่า baggage โผล่ที่ปลายทางไหม อย่าถือว่ามันข้ามไปแน่โดยยังไม่ทดสอบ
แต่มีความจริงสองข้อที่ ต้องแม่น เกี่ยวกับ baggage:
1. Baggage ไม่กลายเป็น attribute ให้เอง — คุณต้องอ่านแล้วแปะเอง. ต่างจาก correlation ของ log ที่ประทับ TraceId ให้ฟรี baggage แค่ เดินทางไปถึง ปลายทาง มันไม่ถูกเขียนลง span ปลายทางโดยอัตโนมัติ คุณต้อง อ่านมันแล้ว SetTag เองอย่างชัดแจ้ง:
// ปลายทาง (Payment consumer) — baggage มาถึงแล้ว แต่ต้องอ่าน + แปะเป็น attribute เองvar orderId = Activity.Current?.GetBaggageItem("order.id");Activity.Current?.SetTag("order.id", orderId); // ← งานที่ต้องเขียนเอง ไม่ฟรี2. Baggage ไม่มีการตรวจ integrity และรั่วออกนอกได้. เอกสารทางการเตือนตรงๆ ว่า baggage เดินทางใน HTTP/AMQP header โดย ไม่มีการตรวจสอบความถูกต้อง (no integrity check) และอาจ รั่วไปยัง third party ที่รับ request ต่อได้ (S6) กฎเหล็กจึงเป็น:
baggage คือ header ที่วิ่งไปกับทุก hop ปลายทาง (รวมถึงบริการภายนอกที่คุณเรียกต่อ) มองเห็นได้ และไม่มีอะไรรับประกันว่ามันไม่ถูกแก้ระหว่างทาง อย่าใส่ token, ข้อมูลส่วนบุคคล, หรือความลับใดๆ ลง baggage ใส่ได้เฉพาะ id ที่ไม่อ่อนไหวอย่าง order.id เพื่อ ความสะดวกในการ debug เท่านั้น
เส้นแบ่งที่ต้องแม่น: อะไรฟรี อะไรต้องเขียนเอง
หัวข้อที่มีชื่อว่า “เส้นแบ่งที่ต้องแม่น: อะไรฟรี อะไรต้องเขียนเอง”บทนี้มีสองกลไกที่หน้าตาคล้ายกันแต่ ต้นทุนต่างกันคนละขั้ว — สับสนเมื่อไหร่จะ instrument ผิด:
| กลไก | เกิดขึ้นยังไง | ต้นทุน |
|---|---|---|
ประทับ TraceId/SpanId ลง log (correlation) | อัตโนมัติ — SDK ทำให้จาก Activity.Current | ฟรี (แค่เปิด provider) |
{PaymentId} → attribute ที่ query ได้ | อัตโนมัติจาก log state | ฟรี (ไม่ขึ้นกับ IncludeFormattedMessage/IncludeScopes) |
enrich span เป็น wide event (SetTag) | เขียนเอง ต่อ span | งานที่ต้องลงมือ |
| promote baggage → attribute ปลายทาง | เขียนเอง — อ่าน GetBaggageItem แล้ว SetTag | งานที่ต้องลงมือ |
ย้ำ honesty precision ของบทนี้: การผูก log เข้ากับ trace เป็นของฟรี แต่การเลื่อนข้อเท็จจริงเชิง domain ขึ้น span — และการหิ้ว baggage มาแปะเป็น attribute — เป็น code ที่คุณต้องเขียนเอง นี่คือหน้าตาจริงของเส้นความซื่อสัตย์ข้อ (D) ที่ว่า คุณ debug สิ่งที่ไม่ได้ instrument ไม่ได้: auto-correlation เป็น พื้น ส่วน attribute ที่มีความหมายเชิง domain เป็น เพดาน ที่ต้องปีนเอง
แผนภาพ: จาก log บรรทัดเดียว สู่ trace ทั้งเส้นข้ามบัส
หัวข้อที่มีชื่อว่า “แผนภาพ: จาก log บรรทัดเดียว สู่ trace ทั้งเส้นข้ามบัส”flowchart TD
subgraph PAY["บริบท Payment (คนละ process หลังบัส)"]
L["_logger.LogInformation<br/>Payment {PaymentId} declined for order {OrderId}"]
SDK["OTel SDK: อ่าน Activity.Current"]
LR["LogRecord<br/>Body + Attributes<br/>TraceId + SpanId ← ประทับอัตโนมัติ"]
L --> SDK
SDK -->|"ประทับ TraceId/SpanId ให้ฟรี"| LR
end
LR -->|"ส่งออกผ่าน OTLP"| HX["HyperDX: คลิก log → pivot ไป trace"]
subgraph TR["trace เดียวกัน (TraceId ตรงกัน)"]
O["Ordering: place-order"]
P["Payment: charge (HttpClient)"]
O -->|"traceparent ข้าม RabbitMQ"| P
end
HX -->|"match TraceId เดียวกัน"| TR
classDef free fill:#0e7490,stroke:#155e75,color:#f0fdff;
classDef store fill:#1e293b,stroke:#0f172a,color:#94a3b8;
class LR,SDK free;
class O,P store;
คำบรรยายภาพ: log บรรทัดหนึ่งในบริบท Payment ถูก OTel SDK อ่าน Activity.Current แล้วประทับ TraceId/SpanId ของ span ปัจจุบันลง LogRecord โดยอัตโนมัติ (ส่วนสีฟ้า = ฟรี) เมื่อ LogRecord ถึง HyperDX การมี TraceId ทำให้คลิกเดียว pivot ไปที่ trace ทั้งเส้นของออเดอร์นั้นได้ — ซึ่งเดินข้าม RabbitMQ จาก Ordering มาถึง Payment ต่อเนื่องตั้งแต่บท3 log ก้อนโดดเดี่ยวจึงกลายเป็นจุดเข้าสู่เรื่องเล่าทั้งเส้น
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ทำให้ log เลิกลอยเดี่ยว: log ใน OTel คือ LogRecord ที่มี TraceId/SpanId/TraceFlags เป็น field ในตัว; Logs Bridge เป็นของนักเขียน library ส่วน code app ใช้ ILogger เหมือนเดิม แล้ว OTel bridge ให้; correlation เกิดอัตโนมัติ — SDK ประทับ trace id จาก Activity.Current ให้ฟรี ทำให้ HyperDX pivot จาก log ไป trace ได้ ข้ามบัส; structured logging ({PaymentId}) ทำให้ค่ากลายเป็น attribute ที่ query ได้ โดยอัตโนมัติจาก log state (ไม่ขึ้นกับ IncludeFormattedMessage/IncludeScopes — สองสวิตช์นั้นแค่เพิ่ม Body ที่ประกอบเสร็จ กับคู่ scope); enrich span ด้วย SetTag ให้เป็น wide event ที่เล่าเรื่องครบ; และ baggage หิ้ว order.id ข้าม context ได้ — แต่ต้อง อ่านแล้วแปะเป็น attribute เอง (ไม่ฟรี) และ ห้ามใส่ข้อมูลอ่อนไหว เพราะมันไม่มีการตรวจ integrity และรั่วออกนอกได้ เส้นแบ่ง ฟรี vs ต้องเขียนเอง คือสิ่งที่ต้องแม่นที่สุดจากบทนี้
ตอนนี้คุณมีครบสามสัญญาณที่สัมพันธ์กันแล้ว — trace (บท3), metric (บท4), log (บทนี้) บทหน้าเราจะเลิกดูทีละสัญญาณ แล้วเข้า HyperDX เพื่อ ตั้งคำถามที่ไม่ได้เตรียมไว้ล่วงหน้า (unknown-unknowns) ด้วย Lucene search, ClickHouse SQL และ trace waterfall — บทเดียวของคอร์สที่ผูกกับ sink โดยตั้งใจ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- OpenTelemetry — Logs (เอกสารทางการ, เข้าถึง 2026-07-22) —
LogRecordมี fieldTraceId/SpanId/TraceFlags; correlation กับ trace เกิดอัตโนมัติเมื่อ SDK active; Logs Bridge API มีไว้ให้นักเขียน logging library ไม่ใช่ code app - OpenTelemetry — Baggage (เอกสารทางการ, เข้าถึง 2026-07-22) — baggage หิ้วคู่ key-value ข้าม service; คำเตือนความปลอดภัย: ไม่มีการตรวจ integrity, รั่วไป third party ได้ → ห้ามใส่ข้อมูลอ่อนไหว; ต้องอ่านแล้วคัดลอกเป็น attribute เอง
- ClickHouse blog — Logging, Metrics, and Distributed Tracing in .NET with OpenTelemetry and ClickStack (2026-06-03) — grounding ฝั่ง .NET:
builder.Logging.AddOpenTelemetry(IncludeFormattedMessage/IncludeScopes)+ILoggerstructured logging +Activity.Current?.SetTag - Honeycomb — “Time to version observability? Signs point to yes” (2024-12-02) — wide events: เก็บข้อเท็จจริงเป็น structured event กว้างชุดเดียว แล้วอนุมาน metric/log/trace ตอนอ่าน (แนวคิด Obs 2.0 — ดีเบตที่ยังไม่จบ)
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3อะไรทำให้ log บรรทัดหนึ่งกระโดดไปที่ trace ของมันได้ใน HyperDX (log↔trace correlation)?