Skip to main content

dialcache/
datadog.rs

1//! Datadog (DogStatsD) metric exporter.
2//!
3//! [`DatadogObserver`] publishes every [`Event`] to a caller-supplied
4//! [`DogStatsdClient`] under the metric names, units and tags of the
5//! TypeScript and Go adapters. Transport, buffering, flushing and ownership of
6//! the client stay with the caller.
7
8use std::fmt;
9
10use crate::metrics::MetricKind;
11use crate::observe::{Event, Observer};
12
13/// The subset of the DogStatsD client API the exporter uses.
14///
15/// `tags` are `(name, value)` pairs in wire order. Implementations format
16/// them as `name:value` for their transport.
17pub trait DogStatsdClient: Send + Sync + 'static {
18    /// Add `value` to the counter `name`.
19    fn increment(&self, name: &str, value: f64, tags: &[(String, String)]);
20    /// Record `value` in the histogram `name`.
21    fn histogram(&self, name: &str, value: f64, tags: &[(String, String)]);
22    /// Record `value` in the distribution `name`.
23    fn distribution(&self, name: &str, value: f64, tags: &[(String, String)]);
24}
25
26/// How observations (timers, ages, sizes and ratios) are published.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum ObservationMetricType {
29    /// Agent-side percentiles per host.
30    Histogram,
31    /// Server-side global percentiles.
32    Distribution,
33}
34
35/// Construction options of a [`DatadogObserver`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DatadogOptions {
38    /// Whether observations go to DogStatsD histograms or distributions.
39    pub observation_metric_type: ObservationMetricType,
40    /// Metric-name namespace; unrelated to the cache namespace label.
41    /// `None` selects [`DEFAULT_NAMESPACE`]; an explicit empty string is an error.
42    pub namespace: Option<String>,
43}
44
45impl DatadogOptions {
46    /// Options under the default metric-name namespace, [`DEFAULT_NAMESPACE`].
47    pub fn new(observation_metric_type: ObservationMetricType) -> Self {
48        DatadogOptions {
49            observation_metric_type,
50            namespace: None,
51        }
52    }
53
54    /// Set the metric-name namespace; it must satisfy [`is_valid_namespace`].
55    pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
56        self.namespace = Some(namespace.into());
57        self
58    }
59}
60
61/// The namespace used when [`DatadogOptions::namespace`] is `None`.
62pub const DEFAULT_NAMESPACE: &str = "dialcache";
63/// Datadog's metric name length limit.
64pub const METRIC_NAME_MAX_LENGTH: usize = 200;
65
66/// Construction failures of a [`DatadogObserver`].
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DatadogError {
69    /// The namespace does not match `^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$`.
70    InvalidNamespace(String),
71    /// A metric name would exceed [`METRIC_NAME_MAX_LENGTH`] characters.
72    MetricNameTooLong(String),
73}
74
75impl fmt::Display for DatadogError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            DatadogError::InvalidNamespace(namespace) => write!(
79                f,
80                "Datadog namespace {namespace:?} must start with a letter and contain only \
81                 letters, numbers, underscores, and dot-separated non-empty segments"
82            ),
83            DatadogError::MetricNameTooLong(name) => write!(
84                f,
85                "Datadog metric name {name:?} exceeds the {METRIC_NAME_MAX_LENGTH}-character limit"
86            ),
87        }
88    }
89}
90
91impl std::error::Error for DatadogError {}
92
93/// The metric-name suffix of each kind, appended to the namespace.
94pub fn metric_suffix(kind: MetricKind) -> &'static str {
95    match kind {
96        MetricKind::Request => "request.count",
97        MetricKind::Miss => "miss.count",
98        MetricKind::Disabled => "disabled.count",
99        MetricKind::Error => "error.count",
100        MetricKind::Invalidation => "invalidation.count",
101        MetricKind::Coalesced => "coalesced.count",
102        MetricKind::ShadowValidation => "shadow.count",
103        MetricKind::ShadowValueAge => "shadow.value_age",
104        MetricKind::ShadowMismatchLogging => "shadow_mismatch_logging.count",
105        MetricKind::FutureTimestampOffset => "future_timestamp_offset",
106        MetricKind::StaleRecovery => "stale_recovery.count",
107        MetricKind::StaleRecoveryValueAge => "stale_recovery.value_age",
108        MetricKind::Compression => "compression.count",
109        MetricKind::Get => "get.duration",
110        MetricKind::Fallback => "fallback.duration",
111        MetricKind::Serialization => "serialization.duration",
112        MetricKind::Size => "serialization.size",
113        MetricKind::StoredSize => "stored.size",
114        MetricKind::CompressionRatio => "compression.ratio",
115        MetricKind::CompressionDuration => "compression.duration",
116    }
117}
118
119/// Whether `namespace` matches `^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$`.
120pub fn is_valid_namespace(namespace: &str) -> bool {
121    let mut segments = namespace.split('.');
122    let Some(first) = segments.next() else {
123        return false;
124    };
125    if !first
126        .chars()
127        .next()
128        .is_some_and(|c| c.is_ascii_alphabetic())
129    {
130        return false;
131    }
132    let segment_ok = |segment: &str| {
133        !segment.is_empty()
134            && segment
135                .chars()
136                .all(|c| c.is_ascii_alphanumeric() || c == '_')
137    };
138    segment_ok(first) && segments.all(segment_ok)
139}
140
141/// Publishes DialCache diagnostics to DogStatsD. Counters increment by one
142/// per event; observations use the configured metric type.
143pub struct DatadogObserver {
144    client: Box<dyn DogStatsdClient>,
145    names: Vec<String>,
146    distribution: bool,
147}
148
149impl fmt::Debug for DatadogObserver {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.debug_struct("DatadogObserver")
152            .field("distribution", &self.distribution)
153            .field("names", &self.names)
154            .finish_non_exhaustive()
155    }
156}
157
158impl DatadogObserver {
159    /// Validate the namespace and precompute every metric name. Fails when
160    /// the namespace is invalid or a name would exceed
161    /// [`METRIC_NAME_MAX_LENGTH`].
162    pub fn new(
163        client: impl DogStatsdClient,
164        options: DatadogOptions,
165    ) -> Result<DatadogObserver, DatadogError> {
166        let namespace = options.namespace.as_deref().unwrap_or(DEFAULT_NAMESPACE);
167        if !is_valid_namespace(namespace) {
168            return Err(DatadogError::InvalidNamespace(namespace.to_string()));
169        }
170        let mut names = Vec::with_capacity(MetricKind::ALL.len());
171        for kind in MetricKind::ALL {
172            let name = format!("{namespace}.{}", metric_suffix(kind));
173            if name.chars().count() > METRIC_NAME_MAX_LENGTH {
174                return Err(DatadogError::MetricNameTooLong(name));
175            }
176            names.push(name);
177        }
178        Ok(DatadogObserver {
179            client: Box::new(client),
180            names,
181            distribution: options.observation_metric_type == ObservationMetricType::Distribution,
182        })
183    }
184
185    /// The full metric name of `kind` under this observer's namespace.
186    pub fn metric_name(&self, kind: MetricKind) -> &str {
187        &self.names[kind.index()]
188    }
189}
190
191impl Observer for DatadogObserver {
192    fn observe(&self, event: &Event) {
193        let kind = MetricKind::of(event);
194        let name = self.metric_name(kind);
195        let tags: Vec<(String, String)> = MetricKind::labels(event)
196            .into_iter()
197            .map(|(label, value)| (label.to_string(), value))
198            .collect();
199        if kind.is_counter() {
200            self.client.increment(name, 1.0, &tags);
201        } else if self.distribution {
202            self.client
203                .distribution(name, MetricKind::value(event), &tags);
204        } else {
205            self.client.histogram(name, MetricKind::value(event), &tags);
206        }
207    }
208
209    fn observes_shadow_outcomes(&self) -> bool {
210        true
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn namespace_pattern_matches_the_reference_regex() {
220        for valid in ["dialcache", "app.cache", "A1_.b_2", "a", "x.y.z"] {
221            assert!(is_valid_namespace(valid), "{valid}");
222        }
223        for invalid in [
224            "",
225            "1bad",
226            "has-dash",
227            "two..dots",
228            ".lead",
229            "trail.",
230            "_under",
231            "sp ace",
232            "ünï",
233        ] {
234            assert!(!is_valid_namespace(invalid), "{invalid}");
235        }
236    }
237
238    #[test]
239    fn every_kind_has_a_distinct_suffix() {
240        let suffixes: std::collections::HashSet<&str> =
241            MetricKind::ALL.iter().map(|k| metric_suffix(*k)).collect();
242        assert_eq!(suffixes.len(), MetricKind::ALL.len());
243    }
244}