Skip to main content

fast_telemetry/export/text/
dogstatsd.rs

1use crate::{
2    Counter, Distribution, DynamicCounter, DynamicDistribution, DynamicGauge, DynamicGaugeI64,
3    DynamicHistogram, DynamicLabelSet, Gauge, GaugeF64, Histogram, LabelEnum, LabeledCounter,
4    LabeledGauge, LabeledHistogram, exp_buckets::ExpBucketsSnapshot,
5};
6use std::fmt::{Display, Write as _};
7
8/// Trait for exporting a metric in DogStatsD format.
9///
10/// Format: `metric.name:value|type|#tag1:value1,tag2:value2`
11///
12/// Types:
13/// - `c` - counter (increment)
14/// - `g` - gauge (point-in-time value)
15/// - `d` - distribution (percentile-capable: p50/p95/p99 in Datadog)
16pub trait DogStatsDExport {
17    /// Export this metric to the output string in DogStatsD format.
18    ///
19    /// - `output`: String buffer to append to (one line per metric, newline-terminated)
20    /// - `name`: The metric name (with prefix already applied)
21    /// - `tags`: Additional tags to append (e.g., `&[("env", "prod")]`)
22    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]);
23}
24
25fn push_display(output: &mut String, value: impl Display) {
26    let _ = write!(output, "{}", value);
27}
28
29fn write_gauge_f64_value(output: &mut String, value: f64) {
30    if value.fract() == 0.0 {
31        push_display(output, value as i64);
32        return;
33    }
34
35    let start_len = output.len();
36    let _ = write!(output, "{:.6}", value);
37    while output.ends_with('0') {
38        output.pop();
39    }
40    if output.ends_with('.') {
41        output.pop();
42    }
43    if output.len() == start_len {
44        output.push('0');
45    }
46}
47
48/// Helper to append tags in DogStatsD format: `|#tag1:value1,tag2:value2`
49fn append_tags(output: &mut String, tags: &[(&str, &str)]) {
50    if !tags.is_empty() {
51        output.push_str("|#");
52        for (i, (k, v)) in tags.iter().enumerate() {
53            if i > 0 {
54                output.push(',');
55            }
56            output.push_str(k);
57            output.push(':');
58            output.push_str(v);
59        }
60    }
61}
62
63/// Helper to append tags with an additional label prepended.
64fn append_tags_with_label(
65    output: &mut String,
66    label_name: &str,
67    label_value: &str,
68    tags: &[(&str, &str)],
69) {
70    output.push_str("|#");
71    output.push_str(label_name);
72    output.push(':');
73    output.push_str(label_value);
74    for (k, v) in tags {
75        output.push(',');
76        output.push_str(k);
77        output.push(':');
78        output.push_str(v);
79    }
80}
81
82fn append_tags_with_dynamic_label_pairs(
83    output: &mut String,
84    labels: &[(String, String)],
85    tags: &[(&str, &str)],
86) {
87    output.push_str("|#");
88    let mut first = true;
89    for (k, v) in labels {
90        if !first {
91            output.push(',');
92        }
93        first = false;
94        output.push_str(k);
95        output.push(':');
96        output.push_str(v);
97    }
98    for (k, v) in tags {
99        if !first {
100            output.push(',');
101        }
102        first = false;
103        output.push_str(k);
104        output.push(':');
105        output.push_str(v);
106    }
107}
108
109fn append_tags_with_dynamic_labels(
110    output: &mut String,
111    labels: &DynamicLabelSet,
112    tags: &[(&str, &str)],
113) {
114    append_tags_with_dynamic_label_pairs(output, labels.pairs(), tags);
115}
116
117#[doc(hidden)]
118pub fn __write_dogstatsd(
119    output: &mut String,
120    name: &str,
121    value: impl Display,
122    metric_type: &str,
123    tags: &[(&str, &str)],
124) {
125    output.push_str(name);
126    output.push(':');
127    push_display(output, value);
128    output.push('|');
129    output.push_str(metric_type);
130    append_tags(output, tags);
131    output.push('\n');
132}
133
134#[doc(hidden)]
135pub fn __write_dogstatsd_with_label(
136    output: &mut String,
137    name: &str,
138    value: impl Display,
139    metric_type: &str,
140    label_name: &str,
141    label_value: &str,
142    tags: &[(&str, &str)],
143) {
144    output.push_str(name);
145    output.push(':');
146    push_display(output, value);
147    output.push('|');
148    output.push_str(metric_type);
149    append_tags_with_label(output, label_name, label_value, tags);
150    output.push('\n');
151}
152
153#[doc(hidden)]
154pub fn __write_dogstatsd_dynamic(
155    output: &mut String,
156    name: &str,
157    value: impl Display,
158    metric_type: &str,
159    labels: &DynamicLabelSet,
160    tags: &[(&str, &str)],
161) {
162    output.push_str(name);
163    output.push(':');
164    push_display(output, value);
165    output.push('|');
166    output.push_str(metric_type);
167    append_tags_with_dynamic_labels(output, labels, tags);
168    output.push('\n');
169}
170
171#[doc(hidden)]
172pub fn __write_dogstatsd_dynamic_pairs(
173    output: &mut String,
174    name: &str,
175    value: impl Display,
176    metric_type: &str,
177    labels: &[(String, String)],
178    tags: &[(&str, &str)],
179) {
180    output.push_str(name);
181    output.push(':');
182    push_display(output, value);
183    output.push('|');
184    output.push_str(metric_type);
185    append_tags_with_dynamic_label_pairs(output, labels, tags);
186    output.push('\n');
187}
188
189/// Append a `|d` sample with optional sample_rate.
190///
191/// Format: `name:value|d[|@rate]` — tags appended by the caller afterward.
192fn write_distribution_sample(output: &mut String, name: &str, value: u64, count: u64) {
193    output.push_str(name);
194    output.push(':');
195    push_display(output, value);
196    output.push_str("|d");
197    if count > 1 {
198        // sample_rate = 1/count tells the agent to multiply this sample by count
199        output.push_str("|@");
200        let _ = write!(output, "{}", 1.0 / count as f64);
201    }
202}
203
204/// Write DogStatsD `|d` distribution lines from an [`ExpBucketsSnapshot`].
205///
206/// Emits one line per non-zero bucket using the bucket midpoint as the
207/// representative value and `@sample_rate` to encode the bucket count.
208/// This allows Datadog to compute p50/p95/p99 percentiles.
209#[doc(hidden)]
210pub fn __write_dogstatsd_distribution(
211    output: &mut String,
212    name: &str,
213    snap: &ExpBucketsSnapshot,
214    tags: &[(&str, &str)],
215) {
216    for (value, count) in snap.iter_samples() {
217        write_distribution_sample(output, name, value, count);
218        append_tags(output, tags);
219        output.push('\n');
220    }
221}
222
223/// Write DogStatsD `|d` distribution lines with dynamic labels.
224#[doc(hidden)]
225pub fn __write_dogstatsd_distribution_dynamic(
226    output: &mut String,
227    name: &str,
228    snap: &ExpBucketsSnapshot,
229    labels: &DynamicLabelSet,
230    tags: &[(&str, &str)],
231) {
232    for (value, count) in snap.iter_samples() {
233        write_distribution_sample(output, name, value, count);
234        append_tags_with_dynamic_labels(output, labels, tags);
235        output.push('\n');
236    }
237}
238
239fn write_dogstatsd_distribution_dynamic_pairs(
240    output: &mut String,
241    name: &str,
242    snap: &ExpBucketsSnapshot,
243    labels: &[(String, String)],
244    tags: &[(&str, &str)],
245) {
246    for (value, count) in snap.iter_samples() {
247        write_distribution_sample(output, name, value, count);
248        append_tags_with_dynamic_label_pairs(output, labels, tags);
249        output.push('\n');
250    }
251}
252
253/// Write DogStatsD `|d` distribution lines for the **delta** between the current
254/// snapshot and previously-stored bucket counts.
255///
256/// `previous` is a `[u64; 65]` array: indices 0..64 are positive buckets,
257/// index 64 is the zero-count.  Updated in-place to the current values.
258#[doc(hidden)]
259pub fn __write_dogstatsd_distribution_delta(
260    output: &mut String,
261    name: &str,
262    current: &ExpBucketsSnapshot,
263    previous: &mut [u64; 65],
264    tags: &[(&str, &str)],
265) {
266    for (i, &cur) in current.positive.iter().enumerate() {
267        let delta = cur.saturating_sub(previous[i]);
268        previous[i] = cur;
269        if delta > 0 {
270            let value = ExpBucketsSnapshot::bucket_midpoint(i);
271            write_distribution_sample(output, name, value, delta);
272            append_tags(output, tags);
273            output.push('\n');
274        }
275    }
276    let zero_delta = current.zero_count.saturating_sub(previous[64]);
277    previous[64] = current.zero_count;
278    if zero_delta > 0 {
279        write_distribution_sample(output, name, 0, zero_delta);
280        append_tags(output, tags);
281        output.push('\n');
282    }
283}
284
285/// Write DogStatsD `|d` distribution delta lines with dynamic labels.
286#[doc(hidden)]
287pub fn __write_dogstatsd_distribution_delta_dynamic(
288    output: &mut String,
289    name: &str,
290    current: &ExpBucketsSnapshot,
291    previous: &mut [u64; 65],
292    labels: &DynamicLabelSet,
293    tags: &[(&str, &str)],
294) {
295    for (i, &cur) in current.positive.iter().enumerate() {
296        let delta = cur.saturating_sub(previous[i]);
297        previous[i] = cur;
298        if delta > 0 {
299            let value = ExpBucketsSnapshot::bucket_midpoint(i);
300            write_distribution_sample(output, name, value, delta);
301            append_tags_with_dynamic_labels(output, labels, tags);
302            output.push('\n');
303        }
304    }
305    let zero_delta = current.zero_count.saturating_sub(previous[64]);
306    previous[64] = current.zero_count;
307    if zero_delta > 0 {
308        write_distribution_sample(output, name, 0, zero_delta);
309        append_tags_with_dynamic_labels(output, labels, tags);
310        output.push('\n');
311    }
312}
313
314/// Write DogStatsD `|d` distribution delta lines with dynamic labels as borrowed pairs.
315#[doc(hidden)]
316pub fn __write_dogstatsd_distribution_delta_dynamic_pairs(
317    output: &mut String,
318    name: &str,
319    current: &ExpBucketsSnapshot,
320    previous: &mut [u64; 65],
321    labels: &[(String, String)],
322    tags: &[(&str, &str)],
323) {
324    for (i, &cur) in current.positive.iter().enumerate() {
325        let delta = cur.saturating_sub(previous[i]);
326        previous[i] = cur;
327        if delta > 0 {
328            let value = ExpBucketsSnapshot::bucket_midpoint(i);
329            write_distribution_sample(output, name, value, delta);
330            append_tags_with_dynamic_label_pairs(output, labels, tags);
331            output.push('\n');
332        }
333    }
334    let zero_delta = current.zero_count.saturating_sub(previous[64]);
335    previous[64] = current.zero_count;
336    if zero_delta > 0 {
337        write_distribution_sample(output, name, 0, zero_delta);
338        append_tags_with_dynamic_label_pairs(output, labels, tags);
339        output.push('\n');
340    }
341}
342
343impl DogStatsDExport for Counter {
344    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
345        output.push_str(name);
346        output.push(':');
347        push_display(output, self.sum());
348        output.push_str("|c");
349        append_tags(output, tags);
350        output.push('\n');
351    }
352}
353
354impl DogStatsDExport for Gauge {
355    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
356        output.push_str(name);
357        output.push(':');
358        push_display(output, self.get());
359        output.push_str("|g");
360        append_tags(output, tags);
361        output.push('\n');
362    }
363}
364
365impl DogStatsDExport for GaugeF64 {
366    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
367        output.push_str(name);
368        output.push(':');
369        // Format with up to 6 decimal places, trimming trailing zeros.
370        write_gauge_f64_value(output, self.get());
371        output.push_str("|g");
372        append_tags(output, tags);
373        output.push('\n');
374    }
375}
376
377impl DogStatsDExport for Histogram {
378    /// Export histogram as count + sum metrics.
379    ///
380    /// DogStatsD distributions expect raw samples, not pre-aggregated buckets.
381    /// We export `name.count` and `name.sum` as counters instead.
382    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
383        output.push_str(name);
384        output.push_str(".count:");
385        push_display(output, self.count());
386        output.push_str("|c");
387        append_tags(output, tags);
388        output.push('\n');
389
390        output.push_str(name);
391        output.push_str(".sum:");
392        push_display(output, self.sum());
393        output.push_str("|c");
394        append_tags(output, tags);
395        output.push('\n');
396    }
397}
398
399impl DogStatsDExport for Distribution {
400    /// Export distribution as `|d` samples for Datadog percentile computation.
401    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
402        let snap = self.buckets_snapshot();
403        __write_dogstatsd_distribution(output, name, &snap, tags);
404    }
405}
406
407impl<L: LabelEnum> DogStatsDExport for LabeledCounter<L> {
408    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
409        for (label, count) in self.iter() {
410            output.push_str(name);
411            output.push(':');
412            push_display(output, count);
413            output.push_str("|c");
414            append_tags_with_label(output, L::LABEL_NAME, label.variant_name(), tags);
415            output.push('\n');
416        }
417    }
418}
419
420impl<L: LabelEnum> DogStatsDExport for LabeledGauge<L> {
421    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
422        for (label, value) in self.iter() {
423            output.push_str(name);
424            output.push(':');
425            push_display(output, value);
426            output.push_str("|g");
427            append_tags_with_label(output, L::LABEL_NAME, label.variant_name(), tags);
428            output.push('\n');
429        }
430    }
431}
432
433impl<L: LabelEnum> DogStatsDExport for LabeledHistogram<L> {
434    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
435        for (label, _buckets, sum, count) in self.iter() {
436            let variant = label.variant_name();
437
438            output.push_str(name);
439            output.push_str(".count:");
440            push_display(output, count);
441            output.push_str("|c");
442            append_tags_with_label(output, L::LABEL_NAME, variant, tags);
443            output.push('\n');
444
445            output.push_str(name);
446            output.push_str(".sum:");
447            push_display(output, sum);
448            output.push_str("|c");
449            append_tags_with_label(output, L::LABEL_NAME, variant, tags);
450            output.push('\n');
451        }
452    }
453}
454
455impl DogStatsDExport for DynamicCounter {
456    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
457        self.visit_series(|labels, count| {
458            __write_dogstatsd_dynamic_pairs(output, name, count, "c", labels, tags);
459        });
460    }
461}
462
463impl DogStatsDExport for DynamicGauge {
464    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
465        self.visit_series(|labels, value| {
466            output.push_str(name);
467            output.push(':');
468            write_gauge_f64_value(output, value);
469            output.push_str("|g");
470            append_tags_with_dynamic_label_pairs(output, labels, tags);
471            output.push('\n');
472        });
473    }
474}
475
476impl DogStatsDExport for DynamicGaugeI64 {
477    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
478        self.visit_series(|labels, value| {
479            __write_dogstatsd_dynamic_pairs(output, name, value, "g", labels, tags);
480        });
481    }
482}
483
484impl DogStatsDExport for DynamicHistogram {
485    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
486        let count_name = format!("{}.count", name);
487        let sum_name = format!("{}.sum", name);
488        self.visit_series(|labels, series| {
489            __write_dogstatsd_dynamic_pairs(output, &count_name, series.count(), "c", labels, tags);
490            __write_dogstatsd_dynamic_pairs(output, &sum_name, series.sum(), "c", labels, tags);
491        });
492    }
493}
494
495impl DogStatsDExport for DynamicDistribution {
496    /// Export distribution as `|d` samples per label set for Datadog percentile computation.
497    fn export_dogstatsd(&self, output: &mut String, name: &str, tags: &[(&str, &str)]) {
498        self.visit_series(|labels, _count, _sum, snap| {
499            write_dogstatsd_distribution_dynamic_pairs(output, name, &snap, labels, tags);
500        });
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::DogStatsDExport;
507    use crate::{Counter, Distribution, DynamicCounter, DynamicDistribution, Gauge, Histogram};
508
509    #[test]
510    fn test_dogstatsd_counter() {
511        let counter = Counter::new(4);
512        counter.inc();
513        counter.inc();
514
515        let mut output = String::new();
516        counter.export_dogstatsd(&mut output, "test.counter", &[]);
517
518        assert_eq!(output, "test.counter:2|c\n");
519    }
520
521    #[test]
522    fn test_dogstatsd_counter_with_tags() {
523        let counter = Counter::new(4);
524        counter.add(100);
525
526        let mut output = String::new();
527        counter.export_dogstatsd(
528            &mut output,
529            "test.counter",
530            &[("env", "prod"), ("host", "web01")],
531        );
532
533        assert_eq!(output, "test.counter:100|c|#env:prod,host:web01\n");
534    }
535
536    #[test]
537    fn test_dogstatsd_gauge() {
538        let gauge = Gauge::new();
539        gauge.set(42);
540
541        let mut output = String::new();
542        gauge.export_dogstatsd(&mut output, "test.gauge", &[]);
543
544        assert_eq!(output, "test.gauge:42|g\n");
545    }
546
547    #[test]
548    fn test_dogstatsd_gauge_with_tags() {
549        let gauge = Gauge::new();
550        gauge.set(-10);
551
552        let mut output = String::new();
553        gauge.export_dogstatsd(&mut output, "memory.used", &[("region", "us-east")]);
554
555        assert_eq!(output, "memory.used:-10|g|#region:us-east\n");
556    }
557
558    #[test]
559    fn test_dogstatsd_histogram() {
560        let histogram = Histogram::new(&[10, 100], 4);
561        histogram.record(5);
562        histogram.record(50);
563        histogram.record(500);
564
565        let mut output = String::new();
566        histogram.export_dogstatsd(&mut output, "latency", &[]);
567
568        assert!(output.contains("latency.count:3|c\n"));
569        assert!(output.contains("latency.sum:555|c\n"));
570    }
571
572    #[test]
573    fn test_dogstatsd_histogram_with_tags() {
574        let histogram = Histogram::new(&[100], 4);
575        histogram.record(50);
576        histogram.record(150);
577
578        let mut output = String::new();
579        histogram.export_dogstatsd(&mut output, "latency", &[("service", "api")]);
580
581        assert!(output.contains("latency.count:2|c|#service:api\n"));
582        assert!(output.contains("latency.sum:200|c|#service:api\n"));
583    }
584
585    #[test]
586    fn test_dogstatsd_distribution() {
587        let dist = Distribution::new(4);
588        dist.record(100);
589        dist.record(200);
590        dist.record(300);
591
592        let mut output = String::new();
593        dist.export_dogstatsd(&mut output, "latency", &[]);
594
595        assert!(output.contains("latency:96|d\n"));
596        assert!(output.contains("latency:192|d\n"));
597        assert!(output.contains("latency:384|d\n"));
598    }
599
600    #[test]
601    fn test_dogstatsd_distribution_with_tags() {
602        let dist = Distribution::new(4);
603        dist.record(50);
604        dist.record(150);
605
606        let mut output = String::new();
607        dist.export_dogstatsd(&mut output, "latency", &[("service", "api")]);
608
609        assert!(output.contains("latency:48|d|#service:api\n"));
610        assert!(output.contains("latency:192|d|#service:api\n"));
611    }
612
613    #[test]
614    fn test_dogstatsd_distribution_empty() {
615        let dist = Distribution::new(4);
616
617        let mut output = String::new();
618        dist.export_dogstatsd(&mut output, "latency", &[]);
619
620        assert!(output.is_empty());
621    }
622
623    #[test]
624    fn test_dogstatsd_distribution_sample_rate() {
625        let dist = Distribution::new(4);
626        dist.record(100);
627        dist.record(100);
628        dist.record(100);
629
630        let mut output = String::new();
631        dist.export_dogstatsd(&mut output, "latency", &[]);
632
633        let line = output.lines().next().expect("should have a line");
634        assert!(line.starts_with("latency:96|d|@"));
635        assert!(line.contains("|@0.3333333333333333"));
636    }
637
638    #[test]
639    fn test_dogstatsd_dynamic_counter() {
640        let counter = DynamicCounter::new(4);
641        counter.add(&[("endpoint", "ep1"), ("method", "GET")], 3);
642
643        let mut output = String::new();
644        counter.export_dogstatsd(&mut output, "requests", &[("env", "prod")]);
645
646        assert!(output.contains("requests:3|c|#"));
647        assert!(output.contains("endpoint:ep1"));
648        assert!(output.contains("method:GET"));
649        assert!(output.contains("env:prod"));
650    }
651
652    #[test]
653    fn test_dogstatsd_dynamic_distribution() {
654        let dist = DynamicDistribution::new(4);
655        dist.record(&[("endpoint", "ep1")], 100);
656        dist.record(&[("endpoint", "ep1")], 100);
657
658        let mut output = String::new();
659        dist.export_dogstatsd(&mut output, "latency", &[("env", "prod")]);
660
661        assert!(output.contains("latency:96|d|@0.5|#endpoint:ep1,env:prod"));
662    }
663}