From 162b6129421cb23c20ef7aa82c775751b4c43549 Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Thu, 15 Oct 2026 16:57:21 -0400 Subject: [PATCH 1/4] Support space-separated DD_APM_FEATURES values Split DD_APM_FEATURES on commas, or on spaces when no comma is present, matching the Go agent's comma_then_space_separated env parser. Entries are trimmed and empty entries dropped, so a documented value like 'error_rare_sample_tracer_drop enable_otlp_compute_top_level_by_span_kind' now enables both features instead of one unmatchable entry. DD_PROXY_NO_PROXY keeps comma-only splitting; the divergence from the Go agent's comma+space NO_PROXY parsing is documented at the field. --- .../src/deserializers/helpers.rs | 25 ++++++++++ .../datadog-agent-config/src/sources/env.rs | 47 +++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/datadog-agent-config/src/deserializers/helpers.rs b/crates/datadog-agent-config/src/deserializers/helpers.rs index 058c0ce1..55fea7e1 100644 --- a/crates/datadog-agent-config/src/deserializers/helpers.rs +++ b/crates/datadog-agent-config/src/deserializers/helpers.rs @@ -175,6 +175,31 @@ where .collect()) } +/// Deserialize a string into a list of entries, choosing exactly one delimiter: +/// if the string contains a comma, split on commas only; otherwise, split on the +/// literal space character (not whitespace in general, so tabs stay inside entries). +/// Each entry is trimmed and empty entries are dropped, all on the Rust side. +/// This is deliberately NOT full Go parity: Go's `comma_then_space_separated` +/// (pkg/config/helper/env.go) keeps empty entries. +pub fn deserialize_comma_then_space_separated_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: String = String::deserialize(deserializer)?; + let parts: Vec<&str> = if s.contains(',') { + s.split(',').collect() + } else { + s.split(' ').collect() + }; + Ok(parts + .iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect()) +} + pub fn deserialize_key_value_pair_array_to_hashmap<'de, D>( deserializer: D, ) -> Result, D::Error> diff --git a/crates/datadog-agent-config/src/sources/env.rs b/crates/datadog-agent-config/src/sources/env.rs index 83fbe7d6..fd3fe89e 100644 --- a/crates/datadog-agent-config/src/sources/env.rs +++ b/crates/datadog-agent-config/src/sources/env.rs @@ -10,9 +10,10 @@ use crate::{ additional_endpoints::deserialize_additional_endpoints, apm_replace_rule::deserialize_apm_replace_rules, deserialize_apm_filter_tags, deserialize_array_from_comma_separated_string, - deserialize_key_value_pairs, deserialize_option_lossless, - deserialize_optional_bool_from_anything, deserialize_optional_string, - deserialize_string_or_int, deserialize_trace_propagation_style, deserialize_with_default, + deserialize_comma_then_space_separated_string, deserialize_key_value_pairs, + deserialize_option_lossless, deserialize_optional_bool_from_anything, + deserialize_optional_string, deserialize_string_or_int, deserialize_trace_propagation_style, + deserialize_with_default, log_level::LogLevel, logs_additional_endpoints::{LogsAdditionalEndpoint, deserialize_logs_additional_endpoints}, merge_hashmap, merge_option, merge_option_to_value, merge_string, merge_vec, @@ -65,6 +66,8 @@ pub struct EnvConfig { /// @env `DD_PROXY_NO_PROXY` /// /// Specify hosts the Agent should connect to directly, bypassing the proxy. + // Comma-only splitting, which diverges from Go: the Go agent parses NO_PROXY + // with comma+space via strings.FieldsFunc and drops empty entries. #[serde(deserialize_with = "deserialize_array_from_comma_separated_string")] pub proxy_no_proxy: Vec, /// @env `DD_HTTP_PROTOCOL` @@ -231,7 +234,7 @@ pub struct EnvConfig { #[serde(deserialize_with = "deserialize_option_lossless")] pub apm_config_compression_level: Option, /// @env `DD_APM_FEATURES` - #[serde(deserialize_with = "deserialize_array_from_comma_separated_string")] + #[serde(deserialize_with = "deserialize_comma_then_space_separated_string")] pub apm_features: Vec, /// @env `DD_APM_ADDITIONAL_ENDPOINTS` /// @@ -939,7 +942,7 @@ mod tests { jail.set_env("DD_APM_CONFIG_COMPRESSION_LEVEL", "2"); jail.set_env( "DD_APM_FEATURES", - "enable_otlp_compute_top_level_by_span_kind,enable_stats_by_span_kind", + "enable_otlp_compute_top_level_by_span_kind enable_stats_by_span_kind", ); jail.set_env("DD_APM_ADDITIONAL_ENDPOINTS", "{\"https://trace.agent.datadoghq.com\": [\"apikey2\", \"apikey3\"], \"https://trace.agent.datadoghq.eu\": [\"apikey4\"]}"); jail.set_env( @@ -1263,6 +1266,40 @@ mod tests { }); } + #[test] + fn test_apm_features_comma_then_space_separated_from_env() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + + let inputs_and_expected = [ + ("a b", vec!["a".to_string(), "b".to_string()]), + ("a,b", vec!["a".to_string(), "b".to_string()]), + // Comma wins as delimiter, space stays inside the entry. + ("a,b c", vec!["a".to_string(), "b c".to_string()]), + // Trimming plus empty-entry dropping. + (" a ,, b ", vec!["a".to_string(), "b".to_string()]), + // Split on the literal space char, then trim. + (" a b ", vec!["a".to_string(), "b".to_string()]), + ("", vec![]), + (" ", vec![]), + // Tab is not a delimiter; it stays inside the entry. + ("a\tb", vec!["a\tb".to_string()]), + ]; + + for (input, expected) in inputs_and_expected { + jail.set_env("DD_APM_FEATURES", input); + + let mut config: Config = Config::default(); + EnvConfigSource + .load(&mut config) + .expect("Failed to load config"); + + assert_eq!(config.apm_features, expected, "input: {input:?}"); + } + Ok(()) + }); + } + #[test] fn test_dogstatsd_config_defaults_to_none() { figment::Jail::expect_with(|jail| { From 15c69f87d8068e09e8e9c6df09d3a5131bc6f7be Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Thu, 15 Oct 2026 17:07:28 -0400 Subject: [PATCH 2/4] Accept delimited strings for apm_config.features in datadog.yaml A string value in the YAML features setting was silently dropped to an empty list. It is now split with the same comma-then-space rules as the DD_APM_FEATURES env var (comma wins as delimiter when present, otherwise spaces split; entries are trimmed and empties dropped). Native YAML lists keep working as before, and non-string values still fall back to an empty list with a warning. --- .../src/deserializers/helpers.rs | 48 +++++++- .../datadog-agent-config/src/sources/yaml.rs | 103 +++++++++++++++++- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/crates/datadog-agent-config/src/deserializers/helpers.rs b/crates/datadog-agent-config/src/deserializers/helpers.rs index 55fea7e1..acfa21ea 100644 --- a/crates/datadog-agent-config/src/deserializers/helpers.rs +++ b/crates/datadog-agent-config/src/deserializers/helpers.rs @@ -188,16 +188,60 @@ where D: Deserializer<'de>, { let s: String = String::deserialize(deserializer)?; + Ok(split_comma_then_space(&s)) +} + +fn split_comma_then_space(s: &str) -> Vec { let parts: Vec<&str> = if s.contains(',') { s.split(',').collect() } else { s.split(' ').collect() }; - Ok(parts + parts .iter() .map(|part| part.trim().to_string()) .filter(|part| !part.is_empty()) - .collect()) + .collect() +} + +/// Deserialize `apm_config.features` from YAML, accepting two shapes: +/// - a native list, whose string elements are used as-is +/// - a string, split with the same comma-then-space rules as `DD_APM_FEATURES` +/// (comma wins as delimiter when present, otherwise the literal space +/// character splits; entries are trimmed and empty entries are dropped) +/// +/// Any other shape, or a non-string list element, is logged and ignored. +/// This diverges from the Go agent, whose YAML path does not comma-split +/// strings for this setting. +pub fn deserialize_comma_then_space_separated_string_or_list<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match Value::deserialize(deserializer)? { + Value::Array(items) => Ok(items + .into_iter() + .filter_map(|item| match item { + Value::String(s) => Some(s), + other => { + warn!( + "Failed to parse features: expected a string element, got: {}, ignoring", + other + ); + None + } + }) + .collect()), + Value::String(s) => Ok(split_comma_then_space(&s)), + other => { + warn!( + "Failed to parse features: expected a list or a string, got: {}, ignoring", + other + ); + Ok(Vec::new()) + } + } } pub fn deserialize_key_value_pair_array_to_hashmap<'de, D>( diff --git a/crates/datadog-agent-config/src/sources/yaml.rs b/crates/datadog-agent-config/src/sources/yaml.rs index b8f10d4c..633314cd 100644 --- a/crates/datadog-agent-config/src/sources/yaml.rs +++ b/crates/datadog-agent-config/src/sources/yaml.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, path::PathBuf}; use crate::{ Config, ConfigError, ConfigExtension, ConfigSource, ProcessingRule, TracePropagationStyle, additional_endpoints::deserialize_additional_endpoints, deserialize_apm_replace_rules, + deserialize_comma_then_space_separated_string_or_list, deserialize_key_value_pair_array_to_hashmap, deserialize_option_lossless, deserialize_optional_bool_from_anything, deserialize_optional_string, deserialize_processing_rules, deserialize_string_or_int, deserialize_trace_propagation_style, @@ -173,7 +174,7 @@ pub struct ApmConfig { pub obfuscation: Option, #[serde(deserialize_with = "deserialize_option_lossless")] pub compression_level: Option, - #[serde(deserialize_with = "deserialize_with_default")] + #[serde(deserialize_with = "deserialize_comma_then_space_separated_string_or_list")] pub features: Vec, #[serde(deserialize_with = "deserialize_additional_endpoints")] pub additional_endpoints: HashMap>, @@ -1166,6 +1167,106 @@ otlp_config: }); } + #[test] + fn test_apm_features_comma_then_space_separated_from_yaml() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + + // (yaml features value, expected Vec) + let inputs_and_expected = [ + ("a,b", vec!["a".to_string(), "b".to_string()]), + ("a b", vec!["a".to_string(), "b".to_string()]), + // Comma wins as delimiter, space stays inside the entry. + ("a,b c", vec!["a".to_string(), "b c".to_string()]), + // Trimming plus empty-entry dropping. + (" a , b ", vec!["a".to_string(), "b".to_string()]), + ("", vec![]), + // Tab is not a delimiter; it stays inside the entry. + ("a\tb", vec!["a\tb".to_string()]), + ]; + + for (value, expected) in inputs_and_expected { + jail.create_file( + "datadog.yaml", + &format!("apm_config:\n features: {value:?}"), + )?; + + let mut config: Config = Config::default(); + let yaml_config_source = YamlConfigSource { + path: Path::new("datadog.yaml").to_path_buf(), + }; + yaml_config_source + .load(&mut config) + .expect("Failed to load config"); + + assert_eq!(config.apm_features, expected, "value: {value:?}"); + } + + Ok(()) + }); + } + + #[test] + fn test_apm_features_yaml_list_still_supported() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "datadog.yaml", + r#" +apm_config: + features: + - "feature_one" + - "feature_two" +"#, + )?; + + let mut config: Config = Config::default(); + let yaml_config_source = YamlConfigSource { + path: Path::new("datadog.yaml").to_path_buf(), + }; + yaml_config_source + .load(&mut config) + .expect("Failed to load config"); + + assert_eq!( + config.apm_features, + vec!["feature_one".to_string(), "feature_two".to_string()] + ); + + Ok(()) + }); + } + + #[test] + fn test_apm_features_env_overrides_yaml() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "datadog.yaml", + r#" +apm_config: + features: "yaml_feature" +"#, + )?; + jail.set_env("DD_APM_FEATURES", "env_feature"); + + let mut config: Config = Config::default(); + let yaml_config_source = YamlConfigSource { + path: Path::new("datadog.yaml").to_path_buf(), + }; + yaml_config_source + .load(&mut config) + .expect("Failed to load yaml config"); + crate::EnvConfigSource + .load(&mut config) + .expect("Failed to load env config"); + + assert_eq!(config.apm_features, vec!["env_feature".to_string()]); + + Ok(()) + }); + } + #[test] fn test_yaml_dogstatsd_config() { figment::Jail::expect_with(|jail| { From 56df2e9f45eaec185f9c6248fc4a3b82b877ba07 Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Fri, 16 Oct 2026 16:51:48 -0400 Subject: [PATCH 3/4] docs: document mixed-type array partial-preserve in apm features deserializer --- crates/datadog-agent-config/src/deserializers/helpers.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/datadog-agent-config/src/deserializers/helpers.rs b/crates/datadog-agent-config/src/deserializers/helpers.rs index acfa21ea..7a5af231 100644 --- a/crates/datadog-agent-config/src/deserializers/helpers.rs +++ b/crates/datadog-agent-config/src/deserializers/helpers.rs @@ -210,6 +210,12 @@ fn split_comma_then_space(s: &str) -> Vec { /// (comma wins as delimiter when present, otherwise the literal space /// character splits; entries are trimmed and empty entries are dropped) /// +/// A native list with mixed element types is partially preserved: string +/// elements are kept and non-string elements are logged and skipped. This +/// diverges from `deserialize_with_default` and the Go agent, whose all-or- +/// nothing `[]string` YAML unmarshal drops the entire list when any element +/// is not a string. +/// /// Any other shape, or a non-string list element, is logged and ignored. /// This diverges from the Go agent, whose YAML path does not comma-split /// strings for this setting. From 2b743603b2ea2862d80e288ca9966a77bf6f27d1 Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Fri, 16 Oct 2026 17:00:33 -0400 Subject: [PATCH 4/4] fix: drop apm_config.features list entirely when a YAML element is not a string, matching the Go agent --- .../src/deserializers/helpers.rs | 37 ++++++++++--------- .../datadog-agent-config/src/sources/yaml.rs | 31 ++++++++++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/datadog-agent-config/src/deserializers/helpers.rs b/crates/datadog-agent-config/src/deserializers/helpers.rs index 7a5af231..f0dee862 100644 --- a/crates/datadog-agent-config/src/deserializers/helpers.rs +++ b/crates/datadog-agent-config/src/deserializers/helpers.rs @@ -210,11 +210,11 @@ fn split_comma_then_space(s: &str) -> Vec { /// (comma wins as delimiter when present, otherwise the literal space /// character splits; entries are trimmed and empty entries are dropped) /// -/// A native list with mixed element types is partially preserved: string -/// elements are kept and non-string elements are logged and skipped. This -/// diverges from `deserialize_with_default` and the Go agent, whose all-or- -/// nothing `[]string` YAML unmarshal drops the entire list when any element -/// is not a string. +/// A native list with mixed element types is rejected entirely: any +/// non-string element is logged and the whole list falls back to `[]`. +/// This mirrors the Go agent, where `GetStringSlice` goes through +/// `cast.ToStringSlice`, which returns an empty slice when any element of +/// the list is not a string (the error is swallowed, without a warning). /// /// Any other shape, or a non-string list element, is logged and ignored. /// This diverges from the Go agent, whose YAML path does not comma-split @@ -226,19 +226,22 @@ where D: Deserializer<'de>, { match Value::deserialize(deserializer)? { - Value::Array(items) => Ok(items - .into_iter() - .filter_map(|item| match item { - Value::String(s) => Some(s), - other => { - warn!( - "Failed to parse features: expected a string element, got: {}, ignoring", - other - ); - None + Value::Array(items) => { + let mut features = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::String(s) => features.push(s), + other => { + warn!( + "Failed to parse features: expected a string element, got: {}, dropping the entire list", + other + ); + return Ok(Vec::new()); + } } - }) - .collect()), + } + Ok(features) + } Value::String(s) => Ok(split_comma_then_space(&s)), other => { warn!( diff --git a/crates/datadog-agent-config/src/sources/yaml.rs b/crates/datadog-agent-config/src/sources/yaml.rs index 633314cd..8ca5f49d 100644 --- a/crates/datadog-agent-config/src/sources/yaml.rs +++ b/crates/datadog-agent-config/src/sources/yaml.rs @@ -1237,6 +1237,37 @@ apm_config: }); } + #[test] + fn test_apm_features_yaml_mixed_type_list_dropped_entirely() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "datadog.yaml", + r#" +apm_config: + features: + - "feature_one" + - 2 + - "feature_two" +"#, + )?; + + let mut config: Config = Config::default(); + let yaml_config_source = YamlConfigSource { + path: Path::new("datadog.yaml").to_path_buf(), + }; + yaml_config_source + .load(&mut config) + .expect("Failed to load config"); + + // Mirrors the Go agent: GetStringSlice goes through cast.ToStringSlice, + // which returns an empty slice when any element is not a string. + assert_eq!(config.apm_features, Vec::::new()); + + Ok(()) + }); + } + #[test] fn test_apm_features_env_overrides_yaml() { figment::Jail::expect_with(|jail| {