datadog_agent_config/classifier/
classifier.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use super::{ClassifierEntry, DefaultValue, PipelineAffinity, SupportLevel, CLASSIFIER_ENTRIES};
6
7/// Result of classifying a single config key/value pair against the registry.
8pub struct Classification {
9    /// The level at which this config key is supported by Saluki.
10    pub support_level: SupportLevel,
11    /// Whether the value matches the schema default.
12    pub is_default: bool,
13    /// Which pipelines this key's incompatibility warning applies to.
14    pub pipeline_affinity: PipelineAffinity,
15}
16
17/// Classifies the support level of config keys and determines if a value is default.
18///
19/// Only knows about annotated keys (supported + unsupported). Keys not in the registry - whether
20/// ignored, unrecognized, or anything else - return `None` from
21/// [`classify`](Self::classify).
22pub struct ConfigClassifier {
23    lookup: HashMap<&'static str, &'static ClassifierEntry>,
24}
25
26impl ConfigClassifier {
27    /// Builds a classifier from all annotated config keys.
28    pub fn new() -> Self {
29        let mut lookup = HashMap::new();
30
31        for entry in CLASSIFIER_ENTRIES {
32            lookup.insert(entry.yaml_path, entry);
33            for alias in entry.aliases {
34                lookup.insert(alias, entry);
35            }
36        }
37
38        Self { lookup }
39    }
40
41    /// Classifies a single config key/value pair against the registry.
42    ///
43    /// Returns `None` for keys not in the registry (ignored, unrecognized, etc.).
44    pub fn classify(&self, key: &str, value: &Value) -> Option<Classification> {
45        let entry = self.lookup.get(key)?;
46        Some(Classification {
47            support_level: entry.support_level,
48            is_default: is_default_value(entry.default, value),
49            pipeline_affinity: entry.pipeline_affinity,
50        })
51    }
52}
53
54fn is_default_value(default: DefaultValue, value: &Value) -> bool {
55    match default {
56        DefaultValue::Json(default_str) => serde_json::from_str::<Value>(default_str)
57            .map(|default_value| *value == default_value)
58            .unwrap_or(false),
59
60        // Duration defaults are canonicalized to nanoseconds at build time. The Agent transmits
61        // durations as integer nanoseconds (and occasionally as a Go duration string), so normalize
62        // the incoming value the same way before comparing.
63        DefaultValue::DurationNanos(default_ns) => duration_value_as_nanos(value)
64            .map(|value_ns| value_ns == default_ns)
65            .unwrap_or(false),
66
67        DefaultValue::Missing => match value {
68            Value::Null => true,
69            Value::String(s) => s.is_empty(),
70            _ => false,
71        },
72    }
73}
74
75/// Normalizes a duration-typed config value to nanoseconds.
76///
77/// Accepts a JSON number already expressed in nanoseconds (the form the Datadog Agent sends over
78/// the config stream) or a Go duration string (for example, `"10s"`). Returns `None` for any other
79/// shape or an out-of-range/invalid value.
80fn duration_value_as_nanos(value: &Value) -> Option<u64> {
81    match value {
82        Value::Number(n) => n.as_u64(),
83        Value::String(s) => go_duration::parse_duration(s).ok().map(|d| d.as_nanos() as u64),
84        _ => None,
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::classifier::Severity;
92
93    fn classifier() -> ConfigClassifier {
94        ConfigClassifier::new()
95    }
96
97    #[test]
98    fn full_support_keys_not_in_classifier() {
99        // Full-support keys are not actionable at the call site; the classifier omits them.
100        // The call site treats None identically to Full (silently continues).
101        let c = classifier();
102        assert!(c.classify("dogstatsd_port", &Value::Number(9999.into())).is_none());
103        assert!(c.classify("log_payloads", &Value::Bool(true)).is_none());
104    }
105
106    #[test]
107    fn partial_non_default() {
108        let c = classifier();
109        let result = c
110            .classify("min_tls_version", &Value::String("non_default_value".into()))
111            .unwrap();
112        assert_eq!(result.support_level, SupportLevel::Partial);
113    }
114
115    #[test]
116    fn incompatible_non_default() {
117        let c = classifier();
118        let result = c
119            .classify("dogstatsd_stats_buffer", &Value::Number(999.into()))
120            .unwrap();
121        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
122        assert!(!result.is_default);
123    }
124
125    #[test]
126    fn incompatible_default() {
127        let c = classifier();
128        // config_id has schema default "" (empty string)
129        let result = c.classify("config_id", &Value::String("".into())).unwrap();
130        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
131        assert!(result.is_default);
132    }
133
134    #[test]
135    fn not_in_registry_returns_none() {
136        let c = classifier();
137        assert!(c.classify("totally_made_up_key", &Value::Bool(true)).is_none());
138        assert!(c.classify("GUI_host", &Value::String("localhost".into())).is_none());
139    }
140
141    #[test]
142    fn duration_default_null_is_not_default() {
143        let c = classifier();
144        // dogstatsd_packet_buffer_flush_timeout has a duration default (100ms); a null value can't be normalized.
145        let result = c
146            .classify("dogstatsd_packet_buffer_flush_timeout", &Value::Null)
147            .unwrap();
148        assert!(!result.is_default);
149    }
150
151    #[test]
152    fn duration_default_non_duration_string_is_not_default() {
153        let c = classifier();
154        // Neither an empty string nor arbitrary text parses as a duration, so neither matches.
155        assert!(
156            !c.classify("dogstatsd_packet_buffer_flush_timeout", &Value::String("".into()))
157                .unwrap()
158                .is_default
159        );
160        assert!(
161            !c.classify(
162                "dogstatsd_packet_buffer_flush_timeout",
163                &Value::String("something".into())
164            )
165            .unwrap()
166            .is_default
167        );
168    }
169
170    #[test]
171    fn duration_default_matches_go_duration_string() {
172        let c = classifier();
173        // The default is also matched when supplied as a Go duration string rather than nanoseconds.
174        let result = c
175            .classify("dogstatsd_packet_buffer_flush_timeout", &Value::String("100ms".into()))
176            .unwrap();
177        assert!(result.is_default);
178    }
179
180    #[test]
181    fn incompatible_severity_levels() {
182        let c = classifier();
183        let result = c.classify("dogstatsd_stats_buffer", &Value::Number(30.into())).unwrap();
184        assert!(matches!(
185            result.support_level,
186            SupportLevel::Incompatible(Severity::Medium)
187        ));
188    }
189
190    #[test]
191    fn duration_default_as_nanoseconds_is_default() {
192        let c = classifier();
193        // The Agent transmits dogstatsd_packet_buffer_flush_timeout (schema default "100ms") as integer
194        // nanoseconds. The classifier must recognize this as the default and not flag it as an override.
195        let result = c
196            .classify(
197                "dogstatsd_packet_buffer_flush_timeout",
198                &Value::Number(100_000_000i64.into()),
199            )
200            .unwrap();
201        assert!(result.is_default);
202    }
203
204    #[test]
205    fn duration_non_default_nanoseconds_is_not_default() {
206        let c = classifier();
207        // 5ms in nanoseconds is not the 100ms default.
208        let result = c
209            .classify(
210                "dogstatsd_packet_buffer_flush_timeout",
211                &Value::Number(5_000_000i64.into()),
212            )
213            .unwrap();
214        assert!(!result.is_default);
215    }
216
217    #[test]
218    fn is_default_value_by_variant() {
219        // Json: structural equality against the decoded JSON literal.
220        assert!(is_default_value(
221            DefaultValue::Json("\"tlsv1.2\""),
222            &Value::String("tlsv1.2".into())
223        ));
224        assert!(!is_default_value(
225            DefaultValue::Json("\"tlsv1.2\""),
226            &Value::String("tlsv1.3".into())
227        ));
228        assert!(is_default_value(DefaultValue::Json("1"), &Value::Number(1.into())));
229
230        // DurationNanos: matches both the nanosecond number and the equivalent Go duration string.
231        assert!(is_default_value(
232            DefaultValue::DurationNanos(10_000_000_000),
233            &Value::Number(10_000_000_000u64.into())
234        ));
235        assert!(is_default_value(
236            DefaultValue::DurationNanos(10_000_000_000),
237            &Value::String("10s".into())
238        ));
239        assert!(!is_default_value(
240            DefaultValue::DurationNanos(10_000_000_000),
241            &Value::Number(5_000_000_000u64.into())
242        ));
243        assert!(!is_default_value(
244            DefaultValue::DurationNanos(10_000_000_000),
245            &Value::String("nope".into())
246        ));
247
248        // Missing: only null or an empty string counts as "default".
249        assert!(is_default_value(DefaultValue::Missing, &Value::Null));
250        assert!(is_default_value(DefaultValue::Missing, &Value::String("".into())));
251        assert!(!is_default_value(DefaultValue::Missing, &Value::String("x".into())));
252    }
253}