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

Traces, Spans, and the Service Map

trace คือ journey ทั้งหมดของ request หนึ่งตัวตั้งแต่ต้นจนจบ ประกอบขึ้นจาก span ที่เรียงตัวเป็น tree แบบ parent/child และ Datadog สร้าง Service Map ขึ้นมาโดยอ่าน structure ของ tree นั้นตรง ๆ จาก traffic จริง ไม่ใช่จากไดอะแกรมที่ใครวาดไว้เอง

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 ที่ไล่กลับมาที่นี่ในที่สุด

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 discount

manual span ตรงนี้กลายเป็น child ของ span ที่ active อยู่ตอนที่ process_order รัน มักเป็น root span web.request หรือ manual span อีกตัวที่ซ้อนอยู่ข้างบน auto-instrumentation กับ manual span ไม่ใช่เทคนิคที่แข่งกัน แต่รวมกันเป็น 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 tree ไปเป็น Service Map

ทุกข้อความเรื่อง 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

src/fake-trace-agent.js
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

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 ได้ตั้งแต่แรก

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.py

span จริงที่จับได้ 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 สถานการณ์แบบนี้โดยเฉพาะ

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 จริง ๆ

🐛 หา bug ในโค้ดนี้

app/set_service.py
# 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"

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 เล็ก ๆ สองต้นแทนที่จะเป็นต้นเดียวที่ต่อกัน แบบเงียบ ๆ

  • Auto-instrumentation เทียบกับ manual span — auto-instrumentation (พิสูจน์ด้านบน: express.request, express.middleware, tcp.connect, dns.lookup โผล่มาหมดโดยไม่มีโค้ด app แม้แต่บรรทัดเดียว) ให้ coverage กว้าง ๆ ฟรี ๆ แต่ trace ที่ได้จะมี framework internal เยอะจนรก (ห้า express.middleware span สำหรับ 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 เก็บอะไรในที่สุด
  • “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 ด้วยมือไม่ตรงกัน ค่าเดิมจะหายไป” ไม่ใช่ — พิสูจน์ด้านบน: ค่าที่ตั้งด้วยมือชนะบน field service แต่ค่า DD_SERVICE เดิมยังอยู่บนทุก span เป็น _dd.base_service พร้อม _dd.svc_src ที่บันทึกไว้ว่าถูก override ด้วยมือ
  • “error จะทำเครื่องหมายแค่ span ที่ throw จริง ๆ เท่านั้น” ไม่ใช่ — พิสูจน์ด้านบน: request ของ Node ที่ fail ลึกใน Express middleware แสดง error: 1 ทั้งบน middleware span ตัวนั้น และ บน root span express.request ของทั้ง request
อะไรที่บอกว่า span หนึ่งเป็น root span ของ trace
ความต่างหลักระหว่าง auto-instrumentation กับ manual span คืออะไร
Service Map ถูกสร้างขึ้นจากอะไรจริง ๆ
ทำไม Service Map ถึงแม่นยำตลอดเวลาโดยไม่มีใครต้องมาคอย update เอง
Python app รัน DD_SERVICE=env-configured-service ddtrace-run python app.py แล้วตัว app เองตั้ง ddtrace.config.service = "manual-configured-service" ตอน import time span จริงที่จับได้พกชื่อ service อะไร
เกิดอะไรขึ้นจริง ๆ เมื่อโค้ด Python ที่ AI generate เรียก tracer.configure(service="checkout-worker") กับ ddtrace==4.15.2 ที่ pin ไว้