Skip to main content

datadog_agent_config/
env_reader.rs

1//! Builds the typed configuration base from environment variables.
2//!
3//! The Datadog Agent reads configuration by iterating its table of known keys
4//! and, for each, looking up that key's exact environment variable names, never by scanning the
5//! environment or splitting on a separator. This module mirrors that: [`DATADOG_ENV_KEYS`] is
6//! generated from the vendored schema, and [`apply_datadog_env`] looks up each key's real names,
7//! decodes the first non-empty value into the JSON shape the schema declares (see
8//! [`crate::env_decode`]), and writes it at the key's nested path.
9//!
10//! Each value is decoded and written directly to the nested path the typed deserializer reads.
11//! Precedence relative to the config file is controlled by the caller through `overwrite`.
12
13use std::collections::HashMap;
14
15use serde_json::{Map, Value};
16
17use crate::env_decode::{decode, EnvDecode};
18use crate::generated::env_keys::DATADOG_ENV_KEYS;
19
20/// One modeled configuration key and how to read it from the environment.
21#[derive(Clone, Copy, Debug)]
22pub struct EnvKey {
23    /// The environment variable names bound to this key, highest priority first. The first one
24    /// set to a non-empty value supplies the key's value, matching the Agent's per-key lookup; see
25    /// [`EnvSnapshot`] for how case is matched.
26    pub env_vars: &'static [&'static str],
27    /// The nested path the typed deserializer reads (`["dogstatsd", "port"]`).
28    pub path: &'static [&'static str],
29    /// How to decode the raw string into the schema-declared JSON shape.
30    pub decode: EnvDecode,
31}
32
33/// Reads every modeled Datadog key from the environment and writes decoded values into `base`.
34///
35/// Applies the canonical proxy variables (`HTTP_PROXY`/`HTTPS_PROXY`) as well. When `overwrite` is
36/// true an environment value replaces whatever is already at its path (environment wins over the
37/// file); when false it fills only an absent path (the file wins).
38///
39/// # Errors
40///
41/// Returns a message naming the environment variable when its value is malformed for the key's
42/// declared shape. At startup this aborts the boot rather than silently dropping the value.
43pub fn apply_datadog_env(base: &mut Value, overwrite: bool) -> Result<(), String> {
44    apply_env_snapshot(base, EnvSnapshot::capture(), overwrite)
45}
46
47/// Reads every modeled Datadog key from explicitly provided environment variable name/value pairs.
48///
49/// Identical to [`apply_datadog_env`] except for where the variables come from. Tests use this to
50/// avoid depending on the ambient process environment.
51///
52/// # Errors
53///
54/// Returns a message naming the environment variable when its value is malformed for the key's
55/// declared shape.
56pub fn apply_datadog_env_vars(
57    base: &mut Value, vars: impl IntoIterator<Item = (String, String)>, overwrite: bool,
58) -> Result<(), String> {
59    apply_env_snapshot(base, EnvSnapshot::from_vars(vars), overwrite)
60}
61
62fn apply_env_snapshot(base: &mut Value, env: EnvSnapshot, overwrite: bool) -> Result<(), String> {
63    let Some(root) = base.as_object_mut() else {
64        return Ok(());
65    };
66    for key in DATADOG_ENV_KEYS {
67        apply_one(root, &env, key.env_vars, key.path, key.decode, overwrite)?;
68    }
69    apply_proxy_env(root, &env, overwrite);
70    Ok(())
71}
72
73/// The nested path of every modeled Datadog key.
74///
75/// The merge that layers the Agent config stream over the base uses these to tell a schema section
76/// (an intermediate object it descends into) from a leaf (a value it replaces wholesale), so a
77/// map-typed leaf is never key-unioned across sources.
78pub fn datadog_leaf_paths() -> impl Iterator<Item = &'static [&'static str]> {
79    DATADOG_ENV_KEYS.iter().map(|key| key.path)
80}
81
82/// Reads one key from the environment and writes it into `root`.
83///
84/// The public entry for callers with a runtime-built key set (the Saluki-only model), which cannot
85/// use the `'static` [`EnvKey`] table. Same semantics as [`apply_datadog_env`] for a single key.
86///
87/// # Errors
88///
89/// Returns a message when the environment value is malformed for `decode`.
90pub fn apply_env_at_path(
91    base: &mut Value, env_vars: &[&str], path: &[&str], decode: EnvDecode, overwrite: bool,
92) -> Result<(), String> {
93    let Some(root) = base.as_object_mut() else {
94        return Ok(());
95    };
96    let env = EnvSnapshot::capture();
97    apply_one(root, &env, env_vars, path, decode, overwrite)
98}
99
100/// The canonical proxy variables that carry no `DD_` prefix.
101///
102/// `HTTP_PROXY` and `HTTPS_PROXY` are honored by the Datadog Agent but are not declared by the
103/// vendored schema (they are wired only by the Agent's bespoke proxy handling), so they are not in
104/// [`DATADOG_ENV_KEYS`] and are injected here by hand. They are a fallback below the schema's
105/// `DD_PROXY_HTTP`/`DD_PROXY_HTTPS`: when the `DD_`-prefixed form is set, the canonical form is
106/// ignored, matching the Agent's ordering. Canonical `NO_PROXY` is intentionally unsupported.
107///
108/// The canonical names are matched case-insensitively through [`EnvSnapshot`], so the common
109/// lowercase Unix convention (`http_proxy`/`https_proxy`) is honored alongside the uppercase form.
110fn apply_proxy_env(root: &mut Map<String, Value>, env: &EnvSnapshot, overwrite: bool) {
111    const PROXY: &[(&str, &str, &[&str])] = &[
112        ("DD_PROXY_HTTP", "HTTP_PROXY", &["proxy", "http"]),
113        ("DD_PROXY_HTTPS", "HTTPS_PROXY", &["proxy", "https"]),
114    ];
115    for (dd_var, canonical_var, path) in PROXY {
116        // The DD_-prefixed form is handled by the generated table and wins; only fill from the
117        // canonical form when the DD_ form is unset.
118        if env.contains(dd_var) {
119            continue;
120        }
121        if let Some((_, raw)) = env.lookup(&[canonical_var]) {
122            write_at_path(root, path, Value::String(raw.to_owned()), overwrite);
123        }
124    }
125}
126
127/// Reads, decodes, and writes one key.
128fn apply_one(
129    root: &mut Map<String, Value>, env: &EnvSnapshot, env_vars: &[&str], path: &[&str], how: EnvDecode, overwrite: bool,
130) -> Result<(), String> {
131    let Some((name, raw)) = env.lookup(env_vars) else {
132        return Ok(());
133    };
134    let value = decode(raw, how).map_err(|msg| format!("environment variable `{name}` ({}): {msg}", path.join(".")))?;
135    write_at_path(root, path, value, overwrite);
136    Ok(())
137}
138
139/// A snapshot of the non-empty process environment, with exact-case lookup and a case-insensitive
140/// fallback.
141///
142/// The Agent looks each bound name up with a case-sensitive `os.LookupEnv`, so an exact-case match
143/// wins. ADP also accepts other case spellings (`dd_dogstatsd_port` for `DD_DOGSTATSD_PORT`), which
144/// apply only when no bound name is set in its exact case.
145struct EnvSnapshot {
146    /// Non-empty values keyed by exact variable name.
147    exact: HashMap<String, String>,
148    /// The first-seen non-empty variable (name, value) for each lowercase name.
149    folded: HashMap<String, (String, String)>,
150}
151
152impl EnvSnapshot {
153    /// Captures the process environment, dropping empty values.
154    fn capture() -> Self {
155        Self::from_vars(std::env::vars())
156    }
157
158    /// Builds a snapshot from environment variable pairs.
159    ///
160    /// An empty string counts as unset, matching the Agent (`os.LookupEnv` plus an emptiness
161    /// check).
162    fn from_vars(vars: impl IntoIterator<Item = (String, String)>) -> Self {
163        let mut exact = HashMap::new();
164        let mut folded = HashMap::new();
165        for (name, value) in vars {
166            if value.is_empty() {
167                continue;
168            }
169            folded
170                .entry(name.to_lowercase())
171                .or_insert_with(|| (name.clone(), value.clone()));
172            exact.entry(name).or_insert(value);
173        }
174        Self { exact, folded }
175    }
176
177    /// Returns the actual name and value of the variable that supplies `names` (highest priority
178    /// first).
179    ///
180    /// Tries every name exactly, in order, then every name case-insensitively, in order. Among case
181    /// variants of one name, the first variable captured wins.
182    fn lookup(&self, names: &[&str]) -> Option<(&str, &str)> {
183        names
184            .iter()
185            .find_map(|name| self.exact.get_key_value(*name))
186            .map(|(name, value)| (name.as_str(), value.as_str()))
187            .or_else(|| {
188                names
189                    .iter()
190                    .find_map(|name| self.folded.get(&name.to_lowercase()))
191                    .map(|(name, value)| (name.as_str(), value.as_str()))
192            })
193    }
194
195    /// Whether any case variant of `name` is set to a non-empty value.
196    fn contains(&self, name: &str) -> bool {
197        self.folded.contains_key(&name.to_lowercase())
198    }
199}
200
201/// Writes `value` at `path`, creating intermediate objects. When `overwrite` is false, an existing
202/// value at the full path is left in place (the file wins).
203fn write_at_path(root: &mut Map<String, Value>, path: &[&str], value: Value, overwrite: bool) {
204    let Some((leaf, sections)) = path.split_last() else {
205        return;
206    };
207    if !overwrite && path_present(root, path) {
208        return;
209    }
210    let mut current = root;
211    for segment in sections {
212        let node = current
213            .entry((*segment).to_string())
214            .or_insert_with(|| Value::Object(Map::new()));
215        if !node.is_object() {
216            *node = Value::Object(Map::new());
217        }
218        current = node.as_object_mut().expect("node was just ensured to be an object");
219    }
220    current.insert((*leaf).to_string(), value);
221}
222
223/// Returns whether a leaf already exists at `path`, walking object nodes from `root`.
224fn path_present(root: &Map<String, Value>, path: &[&str]) -> bool {
225    let Some((first, rest)) = path.split_first() else {
226        return false;
227    };
228    let mut current = match root.get(*first) {
229        Some(v) => v,
230        None => return false,
231    };
232    for segment in rest {
233        match current.get(*segment) {
234            Some(v) => current = v,
235            None => return false,
236        }
237    }
238    true
239}
240
241#[cfg(test)]
242mod tests {
243    use serde_json::json;
244
245    use super::*;
246
247    // Process environment is global; serialize the tests that mutate it. Each test removes the
248    // variables it sets so they do not leak into sibling tests.
249    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
250
251    fn at<'a>(base: &'a Value, path: &[&str]) -> Option<&'a Value> {
252        let mut cur = base;
253        for seg in path {
254            cur = cur.get(seg)?;
255        }
256        Some(cur)
257    }
258
259    #[test]
260    fn decodes_scalars_and_lists_to_shape() {
261        let _guard = ENV_MUTEX.lock().unwrap();
262        std::env::set_var("DD_DOGSTATSD_PORT", "9125");
263        std::env::set_var("DD_API_KEY", "00000");
264        std::env::set_var("DD_DOGSTATSD_TAGS", "env:prod team:core");
265
266        let mut base = json!({});
267        apply_datadog_env(&mut base, true).unwrap();
268
269        std::env::remove_var("DD_DOGSTATSD_PORT");
270        std::env::remove_var("DD_API_KEY");
271        std::env::remove_var("DD_DOGSTATSD_TAGS");
272
273        // Integer decodes to a JSON number; a numeric-looking API key stays a string (the schema
274        // says `api_key` is a string); a string list splits into a real array.
275        assert_eq!(at(&base, &["dogstatsd_port"]), Some(&json!(9125)));
276        assert_eq!(at(&base, &["api_key"]), Some(&json!("00000")));
277        assert_eq!(at(&base, &["dogstatsd_tags"]), Some(&json!(["env:prod", "team:core"])));
278    }
279
280    #[test]
281    fn writes_at_nested_path() {
282        let _guard = ENV_MUTEX.lock().unwrap();
283        std::env::set_var("DD_APM_ERROR_TPS", "12.5");
284
285        let mut base = json!({});
286        apply_datadog_env(&mut base, true).unwrap();
287        std::env::remove_var("DD_APM_ERROR_TPS");
288
289        assert_eq!(at(&base, &["apm_config", "errors_per_second"]), Some(&json!(12.5)));
290    }
291
292    #[test]
293    fn precedence_overwrite_versus_fill() {
294        let _guard = ENV_MUTEX.lock().unwrap();
295        std::env::set_var("DD_DOGSTATSD_PORT", "9125");
296
297        let mut env_wins = json!({ "dogstatsd_port": 8125 });
298        apply_datadog_env(&mut env_wins, true).unwrap();
299        assert_eq!(at(&env_wins, &["dogstatsd_port"]), Some(&json!(9125)));
300
301        let mut file_wins = json!({ "dogstatsd_port": 8125 });
302        apply_datadog_env(&mut file_wins, false).unwrap();
303        assert_eq!(at(&file_wins, &["dogstatsd_port"]), Some(&json!(8125)));
304
305        std::env::remove_var("DD_DOGSTATSD_PORT");
306    }
307
308    #[test]
309    fn env_var_names_are_matched_case_insensitively() {
310        let env = EnvSnapshot::from_vars([
311            ("dd_dogstatsd_port".to_string(), "9125".to_string()),
312            ("Dd_Api_Key".to_string(), "00000".to_string()),
313        ]);
314
315        assert_eq!(env.lookup(&["DD_DOGSTATSD_PORT"]), Some(("dd_dogstatsd_port", "9125")));
316        assert_eq!(env.lookup(&["DD_API_KEY"]), Some(("Dd_Api_Key", "00000")));
317    }
318
319    fn vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
320        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
321    }
322
323    fn apply_vars(pairs: &[(&str, &str)]) -> Result<Value, String> {
324        let mut base = json!({});
325        apply_datadog_env_vars(&mut base, vars(pairs), true)?;
326        Ok(base)
327    }
328
329    #[test]
330    fn the_first_alias_in_declared_order_wins() {
331        // `DD_APM_MAX_EPS` is declared before `DD_MAX_EPS`, whatever order the environment lists them in.
332        for pairs in [
333            [("DD_APM_MAX_EPS", "100"), ("DD_MAX_EPS", "200")],
334            [("DD_MAX_EPS", "200"), ("DD_APM_MAX_EPS", "100")],
335        ] {
336            let base = apply_vars(&pairs).unwrap();
337            assert_eq!(at(&base, &["apm_config", "max_events_per_second"]), Some(&json!(100.0)));
338        }
339
340        let base = apply_vars(&[("DD_MAX_EPS", "200")]).unwrap();
341        assert_eq!(at(&base, &["apm_config", "max_events_per_second"]), Some(&json!(200.0)));
342    }
343
344    #[test]
345    fn an_empty_first_alias_falls_through_to_the_next() {
346        let base = apply_vars(&[("DD_APM_MAX_EPS", ""), ("DD_MAX_EPS", "200")]).unwrap();
347        assert_eq!(at(&base, &["apm_config", "max_events_per_second"]), Some(&json!(200.0)));
348    }
349
350    #[test]
351    fn an_exact_case_name_beats_a_different_case_duplicate() {
352        // The Agent's case-sensitive lookup sees only the exact spelling.
353        for pairs in [
354            [("dd_dogstatsd_port", "2"), ("DD_DOGSTATSD_PORT", "1")],
355            [("DD_DOGSTATSD_PORT", "1"), ("dd_dogstatsd_port", "2")],
356        ] {
357            let base = apply_vars(&pairs).unwrap();
358            assert_eq!(at(&base, &["dogstatsd_port"]), Some(&json!(1)));
359        }
360    }
361
362    #[test]
363    fn an_exact_case_later_alias_beats_a_different_case_earlier_alias() {
364        let base = apply_vars(&[("dd_apm_max_eps", "100"), ("DD_MAX_EPS", "200")]).unwrap();
365        assert_eq!(at(&base, &["apm_config", "max_events_per_second"]), Some(&json!(200.0)));
366    }
367
368    #[test]
369    fn the_decode_error_names_the_variable_that_was_read() {
370        let error = apply_vars(&[("DD_MAX_EPS", "fast")]).unwrap_err();
371        assert!(error.starts_with("environment variable `DD_MAX_EPS` (apm_config.max_events_per_second): "));
372
373        // A case-insensitive match is reported under the name it was set with.
374        let error = apply_vars(&[("dd_max_eps", "fast")]).unwrap_err();
375        assert!(error.starts_with("environment variable `dd_max_eps` (apm_config.max_events_per_second): "));
376    }
377
378    #[test]
379    fn agent_integer_and_list_spellings_decode_to_shape() {
380        let base = apply_vars(&[
381            ("DD_DOGSTATSD_PORT", "0x10"),
382            ("DD_APM_OBFUSCATION_ELASTICSEARCH_KEEP_VALUES", r#"["cr-a","cr-b"]"#),
383        ])
384        .unwrap();
385        assert_eq!(at(&base, &["dogstatsd_port"]), Some(&json!(16)));
386        assert_eq!(
387            at(&base, &["apm_config", "obfuscation", "elasticsearch", "keep_values"]),
388            Some(&json!(["cr-a", "cr-b"]))
389        );
390    }
391
392    #[test]
393    fn canonical_proxy_is_a_fallback_below_dd_form() {
394        let _guard = ENV_MUTEX.lock().unwrap();
395
396        // Canonical form alone fills the slot.
397        std::env::set_var("HTTP_PROXY", "http://canonical:3128");
398        let mut base = json!({});
399        apply_datadog_env(&mut base, true).unwrap();
400        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://canonical:3128")));
401
402        // The DD_ form wins when both are set.
403        std::env::set_var("DD_PROXY_HTTP", "http://prefixed:3128");
404        let mut base = json!({});
405        apply_datadog_env(&mut base, true).unwrap();
406        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://prefixed:3128")));
407
408        std::env::remove_var("HTTP_PROXY");
409        std::env::remove_var("DD_PROXY_HTTP");
410    }
411
412    #[test]
413    fn lowercase_proxy_env_is_honored() {
414        let env = EnvSnapshot::from_vars([("http_proxy".to_string(), "http://lower:3128".to_string())]);
415        let mut base = json!({});
416        apply_proxy_env(base.as_object_mut().unwrap(), &env, true);
417
418        assert_eq!(at(&base, &["proxy", "http"]), Some(&json!("http://lower:3128")));
419    }
420
421    #[test]
422    fn malformed_value_propagates() {
423        let _guard = ENV_MUTEX.lock().unwrap();
424        std::env::set_var("DD_DOGSTATSD_PORT", "not-a-number");
425        let mut base = json!({});
426        let result = apply_datadog_env(&mut base, true);
427        std::env::remove_var("DD_DOGSTATSD_PORT");
428        assert!(result.is_err());
429    }
430}