1use std::fmt;
9
10use crate::metrics::MetricKind;
11use crate::observe::{Event, Observer};
12
13pub trait DogStatsdClient: Send + Sync + 'static {
18 fn increment(&self, name: &str, value: f64, tags: &[(String, String)]);
20 fn histogram(&self, name: &str, value: f64, tags: &[(String, String)]);
22 fn distribution(&self, name: &str, value: f64, tags: &[(String, String)]);
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum ObservationMetricType {
29 Histogram,
31 Distribution,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DatadogOptions {
38 pub observation_metric_type: ObservationMetricType,
40 pub namespace: Option<String>,
43}
44
45impl DatadogOptions {
46 pub fn new(observation_metric_type: ObservationMetricType) -> Self {
48 DatadogOptions {
49 observation_metric_type,
50 namespace: None,
51 }
52 }
53
54 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
56 self.namespace = Some(namespace.into());
57 self
58 }
59}
60
61pub const DEFAULT_NAMESPACE: &str = "dialcache";
63pub const METRIC_NAME_MAX_LENGTH: usize = 200;
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DatadogError {
69 InvalidNamespace(String),
71 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
93pub 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
119pub 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
141pub 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 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 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}