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

DogStatsD กับ Aggregation

custom metric ถูก aggregate ได้ถึงสามครั้ง ระหว่างที่คุณเรียก statsd.increment() กับตอนที่เห็นกราฟ — ใน client library (ไม่บังคับ), ใน DogStatsD server ของ Agent เสมอ (bucket 10 วินาที) และสำหรับ DISTRIBUTION ครั้งที่สามที่ backend โดย merge โครงสร้าง DDSketch — และชั้นไหนเป็นคนคิดเลข ตัดสินว่า percentile น่าเชื่อถือหรือไม่

library ฝั่งแอปสามารถ buffer และ aggregate ก่อนแตะ socket ได้ docs ของเรื่องนี้ (Dogstatsd high throughput): “Client libraries can aggregate metrics on the client side, which reduces the number of messages submitted to the Agent,” และ “Client-side aggregation is available for gauges, counters and sets.” นี่คือผลที่วัดบน wire: เรียก increment 1,000 ครั้งและ gauge 1,000 ครั้ง (ค่า 0..999) จาก datadog 0.54.0 รับด้วย UDP socket ธรรมดาที่นับ datagram

no aggregation, no buffering : datagrams=2000 bytes=72890
first datagram: b'internals.page.views:1|c|#route:home\n'
no aggregation, buffering : datagrams=52 bytes=72890
first datagram: b'internals.page.views:1|c|#route:home\ninternals.page.views:1|c|#route:home\n...'
aggregation + buffering : datagrams=1 bytes=76
first datagram: b'internals.page.views:1000|c|#route:home\ninternals.queue.depth:999|g|#q:jobs\n'

สามโหมด, 2,000 call เท่ากัน Buffering อัดหลายบรรทัดใน datagram เดียว (2000 เหลือ 52) แต่ยังส่งทุกบรรทัด Aggregation ยุบ call ก่อน: หนึ่งบรรทัด counter ที่เป็น ผลรวม (1000|c) และหนึ่งบรรทัด gauge ที่เป็นค่า ล่าสุด (999|g) — รวม 76 byte สังเกตว่า DogStatsd(disable_aggregation=...) ของ datadog 0.54.0 มี default เป็น True (อ่านจาก signature) ดังนั้น Python client แบบ stock ไม่ aggregate ต้องเปิดเอง และ aggregation เลือกตามชนิดด้วย รันซ้ำด้วย call แบบ distribution, histogram และ set (อย่างละ 1,000 ครั้ง, เปิด aggregation):

50 datagrams Counter({'internals.latency.dist': 1000, 'internals.latency': 1000, 'internals.users': 7})

set 1,000 sample (ค่า i % 7) กลายเป็น 7 บรรทัด — หนึ่งต่อ member ที่ไม่ซ้ำ — แต่ distribution 1,000 ตัวและ histogram 1,000 ตัวออกไปทั้งหมดโดยไม่ถูกแตะ ตรงกับ “gauges, counters and sets” ที่ docs ลิสต์ไว้ DISTRIBUTION รวมยอดล่วงหน้าไม่ได้ เพราะจะเสียรูปร่างของข้อมูลที่ percentile ต้องใช้

packet ที่ส่งด้วย nc -u -w0 127.0.0.1 18125: counter increment 5 ครั้ง, gauge 2 ตัว, histogram 3 ค่า, distribution 4 ค่า, set 3 member, counter ที่ sample @0.1 และ timer หนึ่งตัว ผมส่งชุดนี้สองรอบ — รอบแรก (02:17:03) เพื่ออ่านมุมมองฝั่งรับด้วย agent dogstatsd-stats (แสดงเฉพาะ metric ของบทเรียน) รอบสอง (02:18:59) เพื่อ capture payload ที่ถูก flush บน fake intake ใหม่:

Metric | Tags | Count | Last Seen
-----------------------|-------------|-------|-----------------
internals.page.views | route:home | 5 | 2026-09-30 02:17:03 ...
internals.latency.dist | svc:api | 4 | ...
internals.latency | svc:api | 3 | ...
internals.users | | 3 | ...
internals.queue.depth | q:jobs | 2 | ...
internals.timer | svc:api | 1 | ...
internals.sampled | | 1 | ...

Count คือ จำนวน sample ที่รับต่อ name+tags ก่อน aggregate ใด ๆ: page.views เป็น 5 เพราะมาถึงห้าบรรทัด counter ที่ sample คือ 1 ไม่ใช่ 10 — @0.1 ถูกใช้ภายหลัง (ใน container นี้ Trace Agent ของ Agent เองก็ส่ง telemetry datadog.trace_agent.* เข้า DogStatsD ด้วย คำสั่งที่ไม่ filter จึงรก ให้ filter ด้วย prefix ของคุณ) การส่งรอบแรกมีบรรทัดผิดรูปหนึ่งบรรทัด internals.bad:notanumber|c บรรทัดผิดรูปถูกนับและ log ไม่ได้ถูกทิ้งเงียบ ๆ: Metric Parse Errors: 1 ใน agent status และใน agent.log:

CORE | ERROR | (comp/dogstatsd/server/impl/server.go:693 in errLog) | Dogstatsd: error parsing metric message '"internals.bad:notanumber|c"': could not parse dogstatsd metric values: strconv.ParseFloat: parsing "notanumber": invalid syntax

ทีนี้มุมมองฝั่งส่ง — payload ที่ Agent flush ไป fake intake จริง ถอดรหัสแล้ว (/api/v2/series, application/x-protobuf, zstd, interval: 10 ในทุก series):

internals.page.views rate 0.5 tags [route:home] # 5 increments / 10 s
internals.sampled rate 1.0 # 1 sample at @0.1 = 10 events / 10 s
internals.queue.depth gauge 42.0 tags [q:jobs] # 10 then 42: last value wins
internals.users gauge 2.0 # a, b, a: unique members
internals.timer.count rate 0.1 internals.timer.avg/median/max/95percentile gauge 250.0
internals.latency.count rate 0.3 .avg 20.0 .median 20.0 .max 30.0 .95percentile 30.0

ทุกสิ่งที่บท Phase 3 พูดเรื่องชนิด metric มองเห็นได้เป็นข้อเท็จจริงบน wire counter กลายเป็น rate (จำนวนหารความยาว bucket 10 วินาที: 5 / 10 = 0.5) นี่คือเหตุผลที่หน่วยที่เก็บของ counter เป็นต่อวินาที; sample rate @0.1 ถูก Agent ย้อนกลับ (1 sample / 0.1 / 10 s = 1.0) gauge เก็บค่าล่าสุด; set ยุบเป็นจำนวนที่ไม่ซ้ำ; histogram (และ timer |ms) แตกเป็นห้า series series แรกที่ถอดได้มี timestamp ตรงขอบ 10 วินาที (1790734730) และ payload ไปถึง intake ประมาณ 10 วินาทีหลังส่ง packet — bucket ต้องปิดก่อนถึงจะ flush ได้ และ flush tick ของ aggregator เอง (ประมาณทุก 15 วินาที ดู Agent Architecture) เพิ่มได้อีกเท่าตัว นี่คือพื้นต่ำสุดที่ซื่อสัตย์ของคำว่า “real-time” สำหรับ custom metric

สอง setting จาก agent config --all ล้อมชั้นนี้: dogstatsd_context_expiry_seconds: 20 และ dogstatsd_expiry_seconds: 300 ตัวแรกเห็นได้ใน payload ที่ capture ด้วย: หลัง burst intake ยังได้รับ

internals.page.views rate points [(0.0, 1790734740), (0.0, 1790734750)]
internals.page.views rate points [(0.0, 1790734760)]

— ศูนย์ของ counter ที่หยุดไปแล้ว — ประมาณช่วง context expiry 20 วินาที แล้วจึงหยุด Agent รายงาน 0 ให้ counter context ที่รู้จักจนกว่าจะหมดอายุ เพื่อให้กราฟเห็นการตกลงมาเป็นศูนย์แทนที่จะเป็นช่องว่าง setting ด้าน batch คือ dogstatsd_buffer_size: 8192, dogstatsd_queue_size: 1024 และ dogstatsd_packet_buffer_flush_timeout: 100ms ส่วน dogstatsd_no_aggregation_pipeline: true (default) เป็นทางออกสำหรับ metric ที่มี timestamp ของตัวเอง — ข้าม bucket และผ่านไปตามที่เป็น

fake intake เดียวกันยังได้รับ endpoint อีกตัว สำหรับ distribution: /api/beta/sketches ไม่ใช่ /api/v2/series payload นั้นเมื่อถอดรหัส สำหรับค่า 10, 20, 30, 1000:

metric internals.latency.dist tags [svc:api]
ts 1790734730 cnt 4 min 10.0 max 1000.0 avg 265.0 sum 1060.0
k = [1487, 1531, 1557, 1784] n = [1, 1, 1, 1]

ไม่มี series .p95 และไม่มีค่าดิบ Agent ส่ง DDSketch: จำนวน, min/max/sum ที่แม่นยำ และรายการเบาบางของ bucket key k พร้อม count n นี่คือสิ่งที่ทำให้คำนวณ percentile ได้ทีหลัง ข้ามทุก host และทุกการจัดกลุ่ม tag บน backend docs พูดตรง ๆ (Distributions): “all raw data collected during the flush interval and the aggregation occurs server-side using Datadog’s DDSketch data structure,” ให้ “globally accurate percentile aggregations (p50, p75, p90, p95, p99 or any percentile of your choosing with up to two decimal points).”

กลไกของ key อยู่ในเปเปอร์ Masson, Rim และ Lee, “DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees”, PVLDB 12(12): 2195-2205, 2019 (doi:10.14778/3352063.3352135) abstract: “rank error guarantees do not preclude arbitrarily large relative errors, and this often occurs in practice when the data is heavily skewed,” และ “several combined sketches must be as accurate as a single sketch of the same data. We present the first fully-mergeable, relative-error quantile sketching algorithm with formal guarantees.” Definition 1: “x̃q is an α-accurate q-quantile if |x̃q − xq| ≤ α·xq.” โครงสร้าง: “Let γ := (1+α)/(1−α)” และค่า x ถูกจัดเข้า “the bucket indexed by ⌈log_γ(x)⌉” ดังนั้น bucket i ครอบ γ^(i-1) < x ≤ γ^i และค่าใน bucket นั้นถูกรายงานเป็น 2γ^i/(γ+1) ซึ่งห่างจากทุกค่าที่เป็นไปได้ในนั้นไม่เกิน α

key ที่ capture ได้สอดคล้องกับสิ่งนี้ ระยะห่างระหว่าง 10 กับ 20 คือ 44 key; ln(2)/ln(γ) เมื่อ α = 1/128 (γ = 1.01575) ได้ 44.4 ระหว่าง 20 กับ 30 คือ 26 key เทียบกับ 25.9 ระหว่าง 30 กับ 1000 คือ 227 เทียบกับ 224.6 — ต่างกันสอง key เพราะ Agent ใช้ logarithm แบบประมาณที่เร็วแทน ln ตรง ๆ และ key offset ต่างจาก ddsketch ผมไม่ได้ reproduce mapping จริงของ Agent และ α = 1/128 เป็นข้อสันนิษฐานของผมจากระยะห่าง ไม่ใช่ค่าที่ docs ระบุ sketch มีจำนวน bucket จำกัด (binLimit ยุบ bucket ต่ำสุด ตามเปเปอร์) ขนาดของมันจึงขึ้นกับ dynamic range ของข้อมูล ไม่ใช่จำนวน sample ที่เติมเข้าไป

เหตุผลที่ต้องมีกลไกทั้งหมดนี้ รันกับ reference implementation (ddsketch 3.0.1, α = 1/128) บนสาม host จำลอง host ละ 20,000 latency:

true p99 of all 60000 values : 1651.166
average of per-host p99 : 889.898 rel.err 46.1%
merged DDSketch p99 : 1659.353 rel.err 0.496% (alpha=0.7812%)
worst rel.err over p1..p99.9 : 0.563%
bins in merged sketch: 640 count 60000.0

การเฉลี่ย p99 ของสาม host ผิด 46% การ merge สาม sketch (sketch.merge(other) ซึ่งบวก count ของ bucket) ผิด 0.5% อยู่ในขอบเขตที่รับประกัน ในทุก quantile ที่ตรวจ นี่คือสิ่งที่ “fully mergeable” ให้: backend merge sketch จาก Agent นับพันตัวและการจัดกลุ่มใด ๆ ได้ แล้วยังตอบ p99:internals.latency.dist{*} ได้

flowchart LR
  APP["App code: statsd.increment / distribution"] --> CL["Client library (optional aggregation and buffering)"]
  CL -->|"UDP or UDS datagrams"| SRV["Agent DogStatsD server (10 s buckets)"]
  SRV -->|"counters, gauges, sets, histogram stats: /api/v2/series"| FW["Forwarder"]
  SRV -->|"distribution: DDSketch: /api/beta/sketches"| FW
  FW --> BE["Backend"]
  BE -->|"sketch merge: any tag grouping"| Q["p99 across all hosts"]
custom metric ถูก aggregate ที่ไหนบ้าง

aggregator ทุกตัวในโซ่ตอบคำถามเดียวกัน — ฉันทิ้งอะไรได้บ้าง? — ด้วยคำตอบต่างกัน client library ทิ้งการซ้ำของค่า counter/gauge/set เดิมได้ Agent ทิ้งได้ทุกอย่างยกเว้นสรุปของ bucket: sum/10 s สำหรับ counter, ค่าล่าสุดสำหรับ gauge, สถิติห้าตัวสำหรับ histogram มีแต่ sketch เท่านั้นที่เก็บรูปร่างไว้พอ (bucket key พร้อม count) ให้ backend ยังคำนวณ percentile ใด ๆ บนการจัดกลุ่ม ใด ๆ ทีหลังได้ เมื่อทิ้งรูปร่างไปแล้วเอากลับมาไม่ได้: gauge p99 ต่อ host แปลงเป็น p99 ของทั้ง fleet ไม่ได้อีกเลย

ทางเลือกข้อดีข้อเสีย
เปิด client-side aggregation2000 call กลายเป็น 1 datagram ขนาด 76 byte; ลดภาระ UDP และ syscall มากบาง client ต้อง opt-in; flush interval เพิ่ม latency; ได้แค่ counter/gauge/set
แค่ client-side buffering52 datagram แทน 2000 โดย semantic ไม่เปลี่ยนทุก sample ยังข้าม socket; datagram หายทีละหลายบรรทัด
HISTOGRAM (h)ถูก: ห้า series ต่อ context ไม่ว่าจะมีกี่ samplepercentile มีเฉพาะต่อ host; การเฉลี่ยข้าม host ผิด (คลาดเคลื่อน 46% ใน demo)
DISTRIBUTION (d)p99 ทั้ง fleet ภายใน relative error ของ sketch, จัดกลุ่ม tag ไหนก็ได้ เลือกทีหลังได้payload แบบ sketch ต่อ context หนักกว่า gauge; บท Phase 3 อธิบายว่ามันสัมพันธ์กับ cardinality และปริมาณ custom metric อย่างไร
sample rate @0.1packet น้อยลง 10 เท่าประมาณการมี noise มากขึ้น; Agent ขยาย count กลับ แต่ event หายากอาจหายไปเลย
dogstatsd_no_aggregation_pipeline (metric ที่มี timestamp)คุม timestamp เองข้าม bucket ทำให้ semantic ของ rate ต่างไป; ใช้เมื่อตั้งใจจริงเท่านั้น
  • “dogstatsd-stats แสดงสิ่งที่ส่งไป Datadog” มันแสดง sample ที่ รับ ต่อ name+tags พิสูจน์ข้างบน: page.views รับ 5 แต่ Agent flush series point เดียวคือ 0.5
  • “ค่าของ counter คือจำนวนที่ฉัน increment” บน wire มันคือ rate: increment 5 ครั้งใน bucket 10 วินาทีถูกเก็บเป็น 0.5 และ @0.1 ถูกขยายกลับ (1.0)
  • “Agent ส่งค่าทันทีที่ฉันเรียก gauge()” พิสูจน์ข้างบน: bucket ต้องปิดและ aggregator ต้อง flush คาดไว้ราว 10 ถึง 20 วินาที บน Mac เครื่องนี้
  • “เฉลี่ย p99 ข้าม host ได้ p99 ของ fleet” วัดได้: relative error 46.1% เทียบกับ 0.496% สำหรับ sketch ที่ merge
  • “client-side aggregation เปิดเป็น default” ใน datadog 0.54.0 disable_aggregation มี default เป็น True ตรวจ client ของคุณ
  • “DISTRIBUTION เก็บเป็นค่าดิบ” payload ข้างบนคือ count, min/max/sum และ bucket key — sketch ไม่มีจุดดิบให้ replay

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

app/checkout_metrics.py
from datadog import DogStatsd
statsd = DogStatsd(host="127.0.0.1", port=8125)
def record_checkout(duration_ms: float, region: str) -> None:
# histogram() gives us p50/p95/p99 across the whole fleet,
# so we can alert on the global p99 in every region.
statsd.histogram("checkout.duration", duration_ms, tags=[f"region:{region}"])
เฉลย

histogram() ถูก aggregate โดย Agent ของแต่ละ host ผลที่ capture จาก intake ข้างบนแสดงสิ่งที่มันสร้าง: internals.latency.count, .avg, .median, .max และ .95percentile เป็น gauge ต่อ host ที่คำนวณจาก sample ที่ Agent ตัวเดียวนั้นเห็นเท่านั้น backend ไม่มีทาง merge มัน และ simulation แสดงว่าทำไมสำคัญ: การเฉลี่ย p99 ของสาม host คลาดจาก p99 จริงของ fleet ถึง 46.1% alert ของ global p99 ที่สร้างบนนี้กำลังวัด “ค่าเฉลี่ยของ p99 ต่อ host” ไม่ใช่ p99 ของประสบการณ์ผู้ใช้ ให้ใช้ distribution() ซึ่งส่ง DDSketch (/api/beta/sketches) ที่ backend merge ได้ (error 0.496% ใน simulation เดียวกัน) บท custom metric ใน Phase 3 คือที่ที่อธิบายด้านต้นทุนของการเลือกนี้

app/checkout_metrics.py
from datadog import DogStatsd
statsd = DogStatsd(host="127.0.0.1", port=8125)
def record_checkout(duration_ms: float, region: str) -> None:
statsd.distribution("checkout.duration", duration_ms, tags=[f"region:{region}"])
Python client ที่ตั้ง disable_aggregation=False และเปิด buffering ส่ง increment 1,000 ครั้งและ gauge 1,000 ค่า (0..999) ใน capture จริง อะไรมาถึง UDP socket?
ใน capture เดียวกัน การเรียก distribution() 1,000 ครั้งและ histogram() 1,000 ครั้งโดยเปิด aggregation ได้กี่บรรทัด?
agent dogstatsd-stats แสดง internals.page.views Count 5 หลัง increment ห้าครั้ง ขณะที่ payload /api/v2/series ที่ถอดรหัสมี rate point เดียวเป็น 0.5 ทำไม?
ข้อใดจริงเกี่ยวกับ payload /api/beta/sketches ที่ถอดรหัสของค่า 10, 20, 30 และ 1000?
ใน simulation สาม host (60,000 ค่า) การเฉลี่ย p99 ของแต่ละ host เทียบกับการ merge DDSketch ให้ relative error ต่อ p99 จริงเท่าไหร่?
หลัง burst ของ counter fake intake ยังได้รับ point ของ internals.page.views ที่เป็น 0.0 อีกราว 20 วินาที setting ไหนอธิบายเรื่องนี้?