Traces, Spans, and the Service Map
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”trace คือ journey ทั้งหมดของ request หนึ่งตัวตั้งแต่ต้นจนจบ ประกอบขึ้นจาก span ที่เรียงตัวเป็น tree แบบ parent/child และ Datadog สร้าง Service Map ขึ้นมาโดยอ่าน structure ของ tree นั้นตรง ๆ จาก traffic จริง ไม่ใช่จากไดอะแกรมที่ใครวาดไว้เอง
Span: หน่วยย่อยของ trace
หัวข้อที่มีชื่อว่า “Span: หน่วยย่อยของ trace”span แทน work หนึ่งหน่วย — HTTP request หนึ่งตัว, database query หนึ่งครั้ง, function call หนึ่งครั้ง แต่ละ span มี field สำคัญไม่กี่ตัว
- name — ประเภทของ operation เช่น
web.requestหรือpostgres.query - resource — สิ่งที่ทำแบบเจาะจงกว่านั้น เช่น
GET /checkoutหรือSELECT * FROM orders - start กับ duration — span เริ่มเมื่อไหร่และใช้เวลานานแค่ไหน
- ความสัมพันธ์ parent/child —
parent_idที่ชี้ไปที่ span ที่เป็นต้นเหตุให้ span นี้เกิดขึ้น
span ที่ไม่มี parent_id คือ root span — เป็นจุดเริ่มต้นของ trace มักเป็น inbound request ที่ทำให้ทุกอย่างเริ่มทำงาน span ที่เหลือทุกตัวใน trace ไล่สายกลับไปหา root นี้ทีละ parent นั่นคือสิ่งที่ทำให้ span กองหนึ่งกลายเป็น tree
{ "trace_id": "6141ee2f9a4d4b2e8f3c1a90", "span_id": "a1b2c3d4", "parent_id": null, "name": "web.request", "resource": "GET /checkout", "service": "checkout-web", "start": 1699999999000000000, "duration": 45000000}parent_id: null ตรงนี้เองที่ทำให้ span นี้เป็น root — span ทุกตัวที่อยู่ downstream ของตัวเอง (database query ที่ span นี้ trigger, การเรียก payments service และอื่น ๆ) จะมี parent_id ที่ไล่กลับมาที่นี่ในที่สุด
Auto-instrumentation เทียบกับ manual span
หัวข้อที่มีชื่อว่า “Auto-instrumentation เทียบกับ manual span”span ส่วนใหญ่ใน trace โผล่ขึ้นมาเองโดยไม่ต้องเขียน tracing code เลย Datadog tracing library เข้าไป hook กับ framework, HTTP client และ database driver ของคุณ — พอ request เข้ามา, พอมี outbound call ออกไป, พอ query รันขึ้น library จะสร้าง span ให้อัตโนมัติ นี่คือ auto-instrumentation และเป็นเหตุผลที่การติดตั้ง tracer เข้ากับ web app ทั่วไปให้ trace ที่ค่อนข้างสมบูรณ์ตั้งแต่วันแรก
แต่ auto-instrumentation หยุดที่ขอบของ business logic ของคุณเอง ถ้าอยากได้ visibility ของโค้ดบล็อกใดบล็อกหนึ่งโดยเฉพาะ — การคำนวณส่วนลด, step ใน batch job, cache lookup ที่เขียนเอง — ต้องห่อโค้ดส่วนนั้นด้วย manual span
from ddtrace import tracer
def process_order(order): # auto-instrumentation already created a span for the inbound HTTP request; # this manual span adds visibility into a specific block of business logic with tracer.trace("checkout.apply_discount", resource="apply_discount") as span: span.set_tag("order.id", order.id) discount = calculate_discount(order) span.set_tag("discount.amount", discount) return discountmanual span ตรงนี้กลายเป็น child ของ span ที่ active อยู่ตอนที่ process_order รัน มักเป็น root span web.request หรือ manual span อีกตัวที่ซ้อนอยู่ข้างบน auto-instrumentation กับ manual span ไม่ใช่เทคนิคที่แข่งกัน แต่รวมกันเป็น tree เดียวกัน
Service Map: สร้างจาก tree ไม่ใช่วาดเอง
หัวข้อที่มีชื่อว่า “Service Map: สร้างจาก tree ไม่ใช่วาดเอง”span ทุกตัวมี tag service ติดอยู่ — checkout-web, payments-service, inventory-service เมื่อ child ของ span หนึ่งมี tag service ต่างจาก parent นั่นคือ dependency จริงที่สังเกตเห็นระหว่างสอง service ณ ตอนที่ request ข้าม boundary นั้นจริง ๆ
Service Map คือ Datadog อ่าน signal ตัวนี้แหละจากทุก trace ที่ไหลผ่านระบบของคุณ แล้วรวม edge ทั้งหมดของ parent→child, service→service เข้าเป็นกราฟเดียวที่ live อยู่ตลอดเวลา เพราะประกอบขึ้นจากความสัมพันธ์ span จริง ไม่ใช่คนคอยดูแล Service Map จึง update ตัวเองทันทีที่ pattern ของ traffic เปลี่ยน call ใหม่ไปหา downstream โผล่บนแผนที่ทันทีที่เริ่มเกิดขึ้นจริง และ service ที่เลิกใช้แล้วก็หายไปจากแผนที่เมื่อไม่มีใครเรียกอีก เทียบกับไดอะแกรม architecture ที่วาดมือ ซึ่งถูกต้องแค่วันที่วาดแล้วก็เก่าไปในอีกเดือนถัดมา
flowchart LR
A[Root span: web.request service checkout-web] --> B[Span: db.query service checkout-web]
A --> C[Span: http.request service payments-service]
C --> D[Span: db.query service payments-service]
A --> E[Span: http.request service inventory-service]
subgraph SM[Service Map derived from the span tree]
S1[checkout-web] --> S2[payments-service]
S1 --> S3[inventory-service]
end
A -.observed service boundary crossings become.-> SM Span จริง จับมาจาก wire ตรง ๆ
หัวข้อที่มีชื่อว่า “Span จริง จับมาจาก wire ตรง ๆ”ทุกข้อความเรื่อง field ของ span ด้านบนพิสูจน์ได้จริงโดยไม่ต้องมี account Datadog เลย — แค่ชี้ DD_TRACE_AGENT_URL ของ tracer ไปที่ local HTTP server ธรรมดาที่ decode payload msgpack ที่ tracer ส่งมาจริง ๆ แล้ว print ออกมา ไม่มีอะไรหลุดออกจากเครื่องเลย — “agent” คือ Node script ที่ใช้ @msgpack/msgpack ฟังอยู่ที่ 127.0.0.1
const http = require('http');const { decode } = require('@msgpack/msgpack');
const server = http.createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); if (req.url.startsWith('/v0.4/traces')) { const traces = decode(body); // array of traces, each an array of spans for (const trace of traces) for (const span of trace) console.log(JSON.stringify(span)); } res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"rate_by_service":{"service:,env:":1}}'); });});server.listen(18126, '127.0.0.1');รันกับ Express app ที่ instrument ด้วย dd-trace จริง (checkout-web root request span หนึ่งตัวบวก manual child span สำหรับ checkout.apply_discount) ชี้ไปที่ listener ตัวนั้นด้วย DD_TRACE_AGENT_URL=http://127.0.0.1:18126 root span ที่ decode ได้จริง — ตัดให้เหลือ field ที่พูดถึงด้านบน
{ "trace_id": 1716230160703035000, "span_id": 8607871282138202000, "parent_id": 1716230160703035000, "service": "checkout-web", "name": "express.request", "resource": "GET /checkout", "type": "web", "error": 0, "duration": 3979980, "meta": { "http.method": "GET", "http.route": "/checkout", "http.status_code": "200", "component": "express", "span.kind": "server" }, "metrics": { "_dd.measured": 1, "_sampling_priority_v1": 1 }}และ manual span checkout.apply_discount ผลลัพธ์จริง เป็น child ที่อยู่ลึกลงไปหลายชั้นใน Express middleware chain
{ "trace_id": 1716230160703035000, "span_id": 8825565273850650000, "parent_id": 383229711255948900, "service": "checkout-web", "name": "checkout.apply_discount", "resource": "apply_discount", "error": 0, "duration": 19775, "meta": { "order.id": "48213", "_dd.integration": "opentracing" }, "metrics": { "_sampling_priority_v1": 1 }}ทุก field ที่บทนี้อธิบายด้านบนเป็นของจริง ไม่ใช่แค่ตัวอย่างประกอบ — trace_id/span_id เป็นค่า 64-bit จริง (msgpack encode เป็น unsigned integer ไม่ใช่ hex string ที่เติม 0 นำหน้าแบบที่โชว์ไว้ก่อนหน้าเพื่อให้อ่านง่าย) parent_id ไล่สาย child → parent → root จริง duration เป็น nanosecond จริงที่วัดบน Mac เครื่องนี้ และ meta/metrics คือ key/value จริงที่ tracer เติมให้ — meta เก็บ string (tag) ส่วน metrics เก็บตัวเลข สังเกตว่า manual span ตัวนี้ไม่มี field type เลย — tracer.startSpan() ที่ไม่ได้ใส่ option type จะไม่มี field นี้จริง ๆ มีแค่ span จาก auto-instrumentation integration (web, http, db, …) เท่านั้นที่มี type ติดมาให้โดย default
Trace context propagation: ต่อเนื่องข้าม service boundary
หัวข้อที่มีชื่อว่า “Trace context propagation: ต่อเนื่องข้าม service boundary”Service Map ด้านบนสร้างจาก parent/child edge ภายใน trace เดียวกัน แต่ระบบ distributed จริง ๆ จะสานต่อ trace เดิมข้าม network callไปยัง service อื่น — และมันทำงานได้ก็เพราะทั้งสองฝั่งเห็นตรงกันเรื่อง wire format ของ trace_id/span_id กลไกนั้น (DD_TRACE_PROPAGATION_STYLE, header x-datadog-trace-id/x-datadog-parent-id, W3C traceparent/tracestate และสิ่งที่เกิดขึ้นเมื่อสอง service ไม่มี format ที่ตรงกันเลย) พิสูจน์ด้วย HTTP header จริงที่จับมาแล้วใน Connecting Logs and Traces — ไม่ขอพิสูจน์ซ้ำที่นี่เพื่อไม่ให้ซ้ำกับหลักฐานที่นั่น แต่มันคือกลไกที่ทำให้ root span ของ trace ในบทนี้เป็น root span ตัวเดียวกับที่ payments-service ที่อยู่ downstream จะเห็น ซึ่งเป็นสิ่งที่ทำให้ Service Map วาด edge checkout-web → payments-service ได้ตั้งแต่แรก
สองวิธีตั้งชื่อ service — แล้วอันไหนชนะ
หัวข้อที่มีชื่อว่า “สองวิธีตั้งชื่อ service — แล้วอันไหนชนะ”ddtrace-run (Python) อ่าน DD_SERVICE จาก environment แล้ว configure global tracer ด้วยค่านั้นก่อนที่ module ของคุณจะรันด้วยซ้ำ ถ้าโค้ดของคุณเองก็ตั้งชื่อ service เองทีหลังใน process เดียวกัน span จริงที่จับได้จะพกชื่อไหนกันแน่ รัน Flask app ภายใต้ DD_SERVICE=env-configured-service ddtrace-run python app.py โดยตัว app เองตั้งชื่อ service คนละชื่อตอน import time แล้วจับ span จริงด้วยเทคนิค fake-agent เดียวกันด้านบน (ddtrace ส่ง msgpack ไปที่ /v0.5/traces ซึ่งเป็น encoding ที่กระชับกว่า /v0.4/traces ของ Node — string table บวก span ที่อ้างอิงด้วย index — decode ด้วยวิธีเดียวกัน)
$ DD_TRACE_AGENT_URL=http://127.0.0.1:18130 DD_SERVICE=env-configured-service \ ddtrace-run python app.pyspan จริงที่จับได้ field service บนทุกตัวจากทั้งหมด 19 span ที่ run นี้สร้างขึ้น
{ "service": "manual-configured-service", "name": "checkout.apply_discount", "resource": "apply_discount", "meta": { "_dd.base_service": "env-configured-service", "_dd.svc_src": "m", "order.id": "48213" }}การ override ด้วยมือที่รันทีหลังชนะ — ทุก span พก service: "manual-configured-service" ไม่ใช่ค่า DD_SERVICE ที่ ddtrace-run ตั้งไว้ก่อน แต่ ddtrace ไม่ได้ทำค่าเดิมหายไปเงียบ ๆ — มันแปะ _dd.base_service (ชื่อ service ที่ runtime อนุมานไว้ ตรงนี้ยังเป็น env-configured-service) และ _dd.svc_src: "m" (source m = ตั้งด้วยมือ) ให้ทุก span เป็น audit trail จริงของความขัดแย้งแบบนี้เลย ออกแบบมาเพื่อ debug สถานการณ์แบบนี้โดยเฉพาะ
ความหมายของ error span
หัวข้อที่มีชื่อว่า “ความหมายของ error span”span บันทึกความล้มเหลวด้วยสามส่วนที่เกี่ยวกัน ทุกอันเป็น field จริง ไม่ใช่แค่ convention: flag error แบบ boolean-ish (1/0 บน wire) และภายใน meta มี error.type, error.message, error.stack ผลลัพธ์จริงจาก Python app ด้านบน ที่ตั้งใจ raise exception ใน route /checkout-error
{ "name": "__main__.checkout_error", "resource": "/checkout-error", "error": 1, "meta": { "error.type": "builtins.ValueError", "error.message": "card declined", "error.stack": "Traceback (most recent call last):\n File \".../app.py\", line 33, in checkout_error\n raise ValueError(\"card declined\")\nValueError: card declined\n" }}สองอย่างที่น่าสังเกต พิสูจน์ทั้งคู่ด้านบน: อย่างแรก error.stack ใน Python span เป็น Python traceback string จริง ไม่ใช่ JavaScript-style call stack — รูปร่างของ error.stack เป็นแบบ native ตามภาษา มีแค่ชื่อ field เท่านั้นที่ tracer ทุกภาษาใช้ร่วมกัน อย่างที่สอง flag error ไหลขึ้นไปข้างบนจริง — การจับค่า Node ก่อนหน้านี้ในบทนี้แสดงว่า span Express middleware ที่ลึกที่สุดพก error: 1 สำหรับ error ที่ถูก throw แต่ span root express.request GET /checkout-error ของ request เดียวกันนั้นก็พก error: 1 ด้วย — span ที่ fail ตัวเดียวทำให้ ancestor ระดับ request ทั้งหมด error ไปด้วย ซึ่งเป็นเหตุผลที่ error rate ของ service (ที่เห็นบน Service Map) สะท้อนความล้มเหลวที่ตรงไหนก็ได้ใน span tree ของ request นั้น ไม่ใช่แค่ frame ที่ throw จริง ๆ
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”🐛 หา bug ในโค้ดนี้
# AI comment: "Override the service name for this one background worker,# since it should show up separately from the main web service"from ddtrace import tracer
tracer.configure(service="checkout-worker")
def process_job(job): with tracer.trace("worker.process_job") as span: span.set_tag("job.id", job.id) # ... do the work ...เฉลย
ดูสมเหตุสมผลมาก — configure() เป็น entry point ที่ tracer เองใช้สำหรับ runtime setting และ service ก็เป็น concept จริงที่ tracer เข้าใจทุกที่อื่น (DD_SERVICE, span tag, Service Map) แต่มันรันไม่ได้เลย พิสูจน์ด้านบนด้วย ddtrace==4.15.2 ที่ pin ไว้: Tracer.configure() ไม่มี parameter service เลย เลยเกิด TypeError: Tracer.configure() got an unexpected keyword argument 'service' ตั้งแต่ตอน import ก่อนที่ process_job จะได้รันด้วยซ้ำ — นี่ไม่ใช่ option ที่เงียบ ๆ ไม่มีผลแบบ bug logInjection ใน module correlating-signals แต่เป็นการ crash ตรง ๆ ทางแก้ที่พิสูจน์แล้วด้านบนว่าใช้ได้จริงและโผล่บน span ที่จับได้: from ddtrace import config; config.service = "checkout-worker"
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”trace ไม่ใช่ object เดียวที่ใครสักคนประกอบขึ้นมา — มันคือ claim ที่แต่ละ span ประกาศเองแยกกัน ว่าใครเป็น parent ของมัน ไม่มีอะไรใน wire format ที่เป็น “trace” container เลย — msgpack ที่ decode() ด้านบนเป็นแค่ array แบนของ span แต่ละตัวพก trace_id (อ้างว่าตัวเองอยู่ trace ไหน) กับ parent_id (อ้างว่าตัวเองเกิดจาก span ไหน) tree ทั้งหมด รวมถึง Service Map ที่สร้างจาก tree นั้น ล้วนเป็นการประกอบขึ้นใหม่ที่ Datadog ทำทีหลังจาก claim แยกกันเหล่านั้น — นี่คือเหตุผลที่ propagation mismatch (สอง service ที่ header format ไม่ตรงกัน) ไม่ throw error ที่ไหนเลย เพราะ span ของแต่ละฝั่งยัง valid สมบูรณ์ในตัวเอง มันแค่พก claim ที่ไม่ชี้กลับไปหากันแล้ว การประกอบขึ้นใหม่เลยได้ tree เล็ก ๆ สองต้นแทนที่จะเป็นต้นเดียวที่ต่อกัน แบบเงียบ ๆ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Auto-instrumentation เทียบกับ manual span — auto-instrumentation (พิสูจน์ด้านบน:
express.request,express.middleware,tcp.connect,dns.lookupโผล่มาหมดโดยไม่มีโค้ด app แม้แต่บรรทัดเดียว) ให้ coverage กว้าง ๆ ฟรี ๆ แต่ trace ที่ได้จะมี framework internal เยอะจนรก (ห้าexpress.middlewarespan สำหรับ request เดียวในการจับของบทนี้เอง) ส่วน manual span แม่นยำและมีความหมายกับคนอ่าน แลกมาด้วยการที่ต้องมีใครสักคนจำได้ว่าต้องเพิ่มเข้าไป ddtrace.config.service(Python) เทียบกับDD_SERVICE— override ในโค้ด พิสูจน์ด้านบนว่าชนะ environment และมีประโยชน์สำหรับ process เดียวที่ต้องการ identity ต่างออกไปจริง ๆ (background worker ในโค้ดเบสที่เป็น web service เป็นหลัก) แต่ตั้งผิดโดยไม่ตั้งใจได้ง่ายและมองเห็นยากตอน code review ต่างจาก env var ที่โชว์อยู่ใน launch config ของ process เอง- fake trace agent ในเครื่องเทียบกับ Datadog Agent จริง — decode msgpack ตรง ๆ (แบบที่บทนี้ทำ) พิสูจน์ output ของ tracer เองได้โดยไม่ต้องมี infrastructure และไม่มีความเสี่ยงที่จะส่งอะไรไปไหนเลย แต่ข้ามทุกอย่างที่ Agent จริงทำต่อ (rate limiting ของตัวเอง, การคำนวณ stats, retry/buffering) — พิสูจน์ว่า tracer ส่งอะไร ไม่ใช่ว่า Datadog เก็บอะไรในที่สุด
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “trace คือ message เดียวที่ tracer ส่งไป” ไม่ใช่ — พิสูจน์ด้านบน: payload บน wire เป็น array แบนของ span อิสระ แต่ละตัวประกาศ
trace_id/parent_idของตัวเอง “trace” คือการประกอบขึ้นใหม่จาก claim พวกนั้น ไม่ใช่ container ที่มีอยู่บน wire - “
tracer.configure(service=...)คือวิธี override ชื่อ service ใน Python” ไม่ใช่ — พิสูจน์ด้านบนด้วยTypeErrorจริง: parameter ตัวนั้นไม่มีอยู่ในddtrace==4.15.2ที่ pin ไว้ จุด override ที่ใช้ได้จริงคือddtrace.config.service - “ถ้า
DD_SERVICEกับการ override ด้วยมือไม่ตรงกัน ค่าเดิมจะหายไป” ไม่ใช่ — พิสูจน์ด้านบน: ค่าที่ตั้งด้วยมือชนะบน fieldserviceแต่ค่าDD_SERVICEเดิมยังอยู่บนทุก span เป็น_dd.base_serviceพร้อม_dd.svc_srcที่บันทึกไว้ว่าถูก override ด้วยมือ - “error จะทำเครื่องหมายแค่ span ที่ throw จริง ๆ เท่านั้น” ไม่ใช่ — พิสูจน์ด้านบน: request ของ Node ที่ fail ลึกใน Express middleware แสดง
error: 1ทั้งบน middleware span ตัวนั้น และ บน root spanexpress.requestของทั้ง request