diff --git a/crates/datadog-agent-config/apm_replace_rule.rs b/crates/datadog-agent-config/apm_replace_rule.rs index 41b1359..66ae378 100644 --- a/crates/datadog-agent-config/apm_replace_rule.rs +++ b/crates/datadog-agent-config/apm_replace_rule.rs @@ -28,7 +28,7 @@ impl<'de> Visitor<'de> for StringOrReplaceRulesVisitor { match serde_json::from_str::(value) { Ok(_) => Ok(value.to_string()), Err(e) => { - tracing::error!("Invalid JSON string for APM replace rules: {}", e); + tracing::warn!("Invalid JSON string for APM replace rules: {}", e); Ok(String::new()) } } @@ -46,7 +46,7 @@ impl<'de> Visitor<'de> for StringOrReplaceRulesVisitor { match serde_json::to_string(&rules) { Ok(json) => Ok(json), Err(e) => { - tracing::error!("Failed to convert YAML rules to JSON: {}", e); + tracing::warn!("Failed to convert YAML rules to JSON: {}", e); Ok(String::new()) } } @@ -59,12 +59,18 @@ pub fn deserialize_apm_replace_rules<'de, D>( where D: Deserializer<'de>, { - let json_string = deserializer.deserialize_any(StringOrReplaceRulesVisitor)?; + let json_string = match deserializer.deserialize_any(StringOrReplaceRulesVisitor) { + Ok(s) => s, + Err(e) => { + tracing::warn!("Failed to deserialize APM replace rules: {e}, ignoring"); + return Ok(None); + } + }; match parse_rules_from_string(&json_string) { Ok(rules) => Ok(Some(rules)), Err(e) => { - tracing::error!("Failed to parse APM replace rule, ignoring: {}", e); + tracing::warn!("Failed to parse APM replace rule, ignoring: {}", e); Ok(None) } } diff --git a/crates/datadog-agent-config/env.rs b/crates/datadog-agent-config/env.rs index 8068593..8460f8c 100644 --- a/crates/datadog-agent-config/env.rs +++ b/crates/datadog-agent-config/env.rs @@ -15,7 +15,7 @@ use crate::{ deserialize_optional_bool_from_anything, deserialize_optional_duration_from_microseconds, deserialize_optional_duration_from_seconds, deserialize_optional_duration_from_seconds_ignore_zero, deserialize_optional_string, - deserialize_string_or_int, deserialize_trace_propagation_style, + deserialize_string_or_int, deserialize_trace_propagation_style, deserialize_with_default, flush_strategy::FlushStrategy, log_level::LogLevel, logs_additional_endpoints::{LogsAdditionalEndpoint, deserialize_logs_additional_endpoints}, @@ -43,6 +43,7 @@ pub struct EnvConfig { /// /// Minimum log level of the Datadog Agent. /// Valid log levels are: trace, debug, info, warn, and error. + #[serde(deserialize_with = "deserialize_with_default")] pub log_level: Option, /// @env `DD_FLUSH_TIMEOUT` @@ -398,6 +399,7 @@ pub struct EnvConfig { /// @env `DD_SERVERLESS_FLUSH_STRATEGY` /// /// The flush strategy to use for AWS Lambda. + #[serde(deserialize_with = "deserialize_with_default")] pub serverless_flush_strategy: Option, /// @env `DD_ENHANCED_METRICS` /// @@ -716,6 +718,249 @@ mod tests { processing_rule::{Kind, ProcessingRule}, }; + /// Comprehensive test: every DD_ env var is set. Non-string fields get + /// invalid values and must fall back to defaults; string fields get + /// non-default values and must be preserved. + /// + /// The field count is derived from the source file so the test will fail + /// automatically when a field is added to `EnvConfig` without a + /// corresponding entry in the arrays below. + #[test] + #[allow(clippy::too_many_lines)] + fn test_all_env_fields_wrong_type_fallback_to_default() { + // Non-string fields → invalid values that exercise graceful fallback. + let invalid_non_string_env_vars: &[(&str, &str)] = &[ + // Numeric + ("DD_FLUSH_TIMEOUT", "not_a_number"), + ("DD_COMPRESSION_LEVEL", "not_a_number"), + ("DD_LOGS_CONFIG_COMPRESSION_LEVEL", "not_a_number"), + ("DD_APM_CONFIG_COMPRESSION_LEVEL", "not_a_number"), + ("DD_METRICS_CONFIG_COMPRESSION_LEVEL", "not_a_number"), + ("DD_CAPTURE_LAMBDA_PAYLOAD_MAX_DEPTH", "not_a_number"), + ("DD_DOGSTATSD_SO_RCVBUF", "not_a_number"), + ("DD_DOGSTATSD_BUFFER_SIZE", "not_a_number"), + ("DD_DOGSTATSD_QUEUE_SIZE", "not_a_number"), + ( + "DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_MAX_RECV_MSG_SIZE_MIB", + "not_a_number", + ), + ("DD_OTLP_CONFIG_METRICS_DELTA_TTL", "not_a_number"), + ( + "DD_OTLP_CONFIG_TRACES_PROBABILISTIC_SAMPLER_SAMPLING_PERCENTAGE", + "not_a_number", + ), + // Bool + ("DD_SKIP_SSL_VALIDATION", "not_a_bool"), + ("DD_LOGS_CONFIG_USE_COMPRESSION", "not_a_bool"), + ( + "DD_APM_CONFIG_OBFUSCATION_HTTP_REMOVE_QUERY_STRING", + "not_a_bool", + ), + ( + "DD_APM_CONFIG_OBFUSCATION_HTTP_REMOVE_PATHS_WITH_DIGITS", + "not_a_bool", + ), + ("DD_TRACE_PROPAGATION_EXTRACT_FIRST", "not_a_bool"), + ("DD_TRACE_PROPAGATION_HTTP_BAGGAGE_ENABLED", "not_a_bool"), + ("DD_TRACE_AWS_SERVICE_REPRESENTATION_ENABLED", "not_a_bool"), + ("DD_ENHANCED_METRICS", "not_a_bool"), + ("DD_LAMBDA_PROC_ENHANCED_METRICS", "not_a_bool"), + ("DD_CAPTURE_LAMBDA_PAYLOAD", "not_a_bool"), + ("DD_COMPUTE_TRACE_STATS_ON_EXTENSION", "not_a_bool"), + ("DD_SERVERLESS_APPSEC_ENABLED", "not_a_bool"), + ("DD_API_SECURITY_ENABLED", "not_a_bool"), + ("DD_OTLP_CONFIG_TRACES_ENABLED", "not_a_bool"), + ( + "DD_OTLP_CONFIG_TRACES_SPAN_NAME_AS_RESOURCE_NAME", + "not_a_bool", + ), + ("DD_OTLP_CONFIG_IGNORE_MISSING_DATADOG_FIELDS", "not_a_bool"), + ("DD_OTLP_CONFIG_METRICS_ENABLED", "not_a_bool"), + ( + "DD_OTLP_CONFIG_METRICS_RESOURCE_ATTRIBUTES_AS_TAGS", + "not_a_bool", + ), + ( + "DD_OTLP_CONFIG_METRICS_INSTRUMENTATION_SCOPE_METADATA_AS_TAGS", + "not_a_bool", + ), + ( + "DD_OTLP_CONFIG_METRICS_HISTOGRAMS_SEND_COUNT_SUM_METRICS", + "not_a_bool", + ), + ( + "DD_OTLP_CONFIG_METRICS_HISTOGRAMS_SEND_AGGREGATION_METRICS", + "not_a_bool", + ), + ("DD_OTLP_CONFIG_LOGS_ENABLED", "not_a_bool"), + ( + "DD_OBSERVABILITY_PIPELINES_WORKER_LOGS_ENABLED", + "not_a_bool", + ), + ("DD_SERVERLESS_LOGS_ENABLED", "not_a_bool"), + ("DD_LOGS_ENABLED", "not_a_bool"), + // Enum + ("DD_LOG_LEVEL", "invalid_level_999"), + ("DD_SERVERLESS_FLUSH_STRATEGY", "[[[invalid"), + // Duration + ("DD_SPAN_DEDUP_TIMEOUT", "not_a_number"), + ("DD_API_KEY_SECRET_RELOAD_INTERVAL", "not_a_number"), + ("DD_APPSEC_WAF_TIMEOUT", "not_a_number"), + ("DD_API_SECURITY_SAMPLE_DELAY", "not_a_number"), + // JSON + ("DD_ADDITIONAL_ENDPOINTS", "not_json{{"), + ("DD_APM_ADDITIONAL_ENDPOINTS", "not_json{{"), + ("DD_LOGS_CONFIG_PROCESSING_RULES", "not_json{{"), + ("DD_LOGS_CONFIG_ADDITIONAL_ENDPOINTS", "not_json{{"), + ("DD_APM_REPLACE_TAGS", "not_json{{"), + // Comma/space-separated and key:value + ("DD_PROXY_NO_PROXY", ""), + ("DD_SERVICE_MAPPING", "no-colon-here"), + ("DD_APM_FEATURES", ""), + ("DD_APM_FILTER_TAGS_REQUIRE", ""), + ("DD_APM_FILTER_TAGS_REJECT", ""), + ("DD_APM_FILTER_TAGS_REGEX_REQUIRE", ""), + ("DD_APM_FILTER_TAGS_REGEX_REJECT", ""), + ("DD_TAGS", "no-colon"), + ("DD_OTLP_CONFIG_TRACES_SPAN_NAME_REMAPPINGS", "no-colon"), + ("DD_TRACE_PROPAGATION_STYLE", "invalid_style"), + ("DD_TRACE_PROPAGATION_STYLE_EXTRACT", "invalid_style"), + ]; + + // String fields → valid non-default values to prove they survive + // alongside broken non-string fields. + let string_env_vars: &[(&str, &str)] = &[ + ("DD_SITE", "custom-site.example.com"), + ("DD_API_KEY", "test-api-key-12345"), + ("DD_PROXY_HTTPS", "https://proxy.example.com"), + ("DD_HTTP_PROTOCOL", "http1"), + ("DD_TLS_CERT_FILE", "/opt/ca-cert.pem"), + ("DD_DD_URL", "https://custom-metrics.example.com"), + ("DD_URL", "https://custom-app.example.com"), + ("DD_ENV", "test_env"), + ("DD_SERVICE", "test_service"), + ("DD_VERSION", "v1.0.0"), + ( + "DD_LOGS_CONFIG_LOGS_DD_URL", + "https://custom-logs.example.com", + ), + ( + "DD_OBSERVABILITY_PIPELINES_WORKER_LOGS_URL", + "https://opw.example.com", + ), + ("DD_APM_DD_URL", "https://custom-apm.example.com"), + ("DD_STATSD_METRIC_NAMESPACE", "testns"), + ( + "DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT", + "0.0.0.0:4318", + ), + ( + "DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT", + "0.0.0.0:4317", + ), + ("DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_TRANSPORT", "tcp"), + ("DD_OTLP_CONFIG_METRICS_TAG_CARDINALITY", "orchestrator"), + ("DD_OTLP_CONFIG_METRICS_HISTOGRAMS_MODE", "distributions"), + ( + "DD_OTLP_CONFIG_METRICS_SUMS_CUMULATIVE_MONOTONIC_MODE", + "to_delta", + ), + ( + "DD_OTLP_CONFIG_METRICS_SUMS_INITIAL_CUMULATIV_MONOTONIC_VALUE", + "keep", + ), + ("DD_OTLP_CONFIG_METRICS_SUMMARIES_MODE", "noquantiles"), + ( + "DD_API_KEY_SECRET_ARN", + "arn:aws:secretsmanager:us-east-1:123:secret:key", + ), + ("DD_KMS_API_KEY", "kms-encrypted-key"), + ( + "DD_API_KEY_SSM_ARN", + "arn:aws:ssm:us-east-1:123:parameter/key", + ), + ("DD_APPSEC_RULES", "/opt/custom-rules.json"), + ]; + + // Programmatic guard: count `pub ` fields in the EnvConfig struct from + // the source file. If a field is added without updating the arrays + // above, this assertion will fail. + let source = include_str!("env.rs"); + let struct_start = source + .find("pub struct EnvConfig") + .expect("EnvConfig not found in source"); + let struct_body = &source[struct_start..]; + let struct_end = struct_body + .find("\n}\n") + .expect("EnvConfig closing brace not found"); + let field_count = struct_body[..struct_end].matches("\n pub ").count(); + assert_eq!( + invalid_non_string_env_vars.len() + string_env_vars.len(), + field_count, + "Field count mismatch: EnvConfig has {field_count} fields but test covers {}. \ + Add new fields to invalid_non_string_env_vars or string_env_vars.", + invalid_non_string_env_vars.len() + string_env_vars.len() + ); + + figment::Jail::expect_with(|jail| { + jail.clear_env(); + + for (key, value) in invalid_non_string_env_vars { + jail.set_env(key, value); + } + for (key, value) in string_env_vars { + jail.set_env(key, value); + } + + let mut config = Config::default(); + // This MUST succeed — no single field should crash the whole config + EnvConfigSource + .load(&mut config) + .expect("load must not fail when env vars have wrong types"); + + // Build expected: string fields have their non-default values, + // all non-string fields stay at defaults. + let mut expected = Config::default(); + // String fields (merge_string! → Config String) + expected.site = "custom-site.example.com".to_string(); + expected.api_key = "test-api-key-12345".to_string(); + expected.dd_url = "https://custom-metrics.example.com".to_string(); + expected.url = "https://custom-app.example.com".to_string(); + expected.logs_config_logs_dd_url = "https://custom-logs.example.com".to_string(); + expected.observability_pipelines_worker_logs_url = + "https://opw.example.com".to_string(); + expected.apm_dd_url = "https://custom-apm.example.com".to_string(); + expected.api_key_secret_arn = + "arn:aws:secretsmanager:us-east-1:123:secret:key".to_string(); + expected.kms_api_key = "kms-encrypted-key".to_string(); + expected.api_key_ssm_arn = "arn:aws:ssm:us-east-1:123:parameter/key".to_string(); + // Option fields (merge_option! → Config Option) + expected.proxy_https = Some("https://proxy.example.com".to_string()); + expected.http_protocol = Some("http1".to_string()); + expected.tls_cert_file = Some("/opt/ca-cert.pem".to_string()); + expected.env = Some("test_env".to_string()); + expected.service = Some("test_service".to_string()); + expected.version = Some("v1.0.0".to_string()); + expected.statsd_metric_namespace = Some("testns".to_string()); + expected.otlp_config_receiver_protocols_http_endpoint = + Some("0.0.0.0:4318".to_string()); + expected.otlp_config_receiver_protocols_grpc_endpoint = + Some("0.0.0.0:4317".to_string()); + expected.otlp_config_receiver_protocols_grpc_transport = Some("tcp".to_string()); + expected.otlp_config_metrics_tag_cardinality = Some("orchestrator".to_string()); + expected.otlp_config_metrics_histograms_mode = Some("distributions".to_string()); + expected.otlp_config_metrics_sums_cumulative_monotonic_mode = + Some("to_delta".to_string()); + expected.otlp_config_metrics_sums_initial_cumulativ_monotonic_value = + Some("keep".to_string()); + expected.otlp_config_metrics_summaries_mode = Some("noquantiles".to_string()); + expected.appsec_rules = Some("/opt/custom-rules.json".to_string()); + + assert_eq!(config, expected); + Ok(()) + }); + } + #[test] #[allow(clippy::too_many_lines)] fn test_merge_config_overrides_with_environment_variables() { diff --git a/crates/datadog-agent-config/mod.rs b/crates/datadog-agent-config/mod.rs index 2071af9..6fc858a 100644 --- a/crates/datadog-agent-config/mod.rs +++ b/crates/datadog-agent-config/mod.rs @@ -20,7 +20,7 @@ use serde_json::Value; use std::path::Path; use std::time::Duration; use std::{collections::HashMap, fmt}; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use crate::{ apm_replace_rule::deserialize_apm_replace_rules, @@ -524,7 +524,7 @@ where match Value::deserialize(deserializer)? { Value::String(s) => Ok(Some(s)), other => { - error!( + warn!( "Failed to parse value, expected a string, got: {}, ignoring", other ); @@ -548,7 +548,7 @@ where } Value::Number(n) => Ok(Some(n.to_string())), _ => { - error!("Failed to parse value, expected a string or an integer, ignoring"); + warn!("Failed to parse value, expected a string or an integer, ignoring"); Ok(None) } } @@ -568,7 +568,7 @@ where Some(value) => match deserialize_bool_from_anything(value) { Ok(bool_result) => Ok(Some(bool_result)), Err(e) => { - error!("Failed to parse bool value: {}, ignoring", e); + warn!("Failed to parse bool value: {}, ignoring", e); Ok(None) } }, @@ -582,7 +582,7 @@ fn parse_key_value_tag(tag: &str) -> Option<(String, String)> { if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { Some((parts[0].to_string(), parts[1].to_string())) } else { - error!( + warn!( "Failed to parse tag '{}', expected format 'key:value', ignoring", tag ); @@ -626,7 +626,7 @@ where where E: serde::de::Error, { - error!( + warn!( "Failed to parse tags: expected string in format 'key:value', got number {}, ignoring", value ); @@ -637,7 +637,7 @@ where where E: serde::de::Error, { - error!( + warn!( "Failed to parse tags: expected string in format 'key:value', got number {}, ignoring", value ); @@ -648,7 +648,7 @@ where where E: serde::de::Error, { - error!( + warn!( "Failed to parse tags: expected string in format 'key:value', got number {}, ignoring", value ); @@ -659,7 +659,7 @@ where where E: serde::de::Error, { - error!( + warn!( "Failed to parse tags: expected string in format 'key:value', got boolean {}, ignoring", value ); @@ -689,7 +689,13 @@ pub fn deserialize_key_value_pair_array_to_hashmap<'de, D>( where D: Deserializer<'de>, { - let array: Vec = Vec::deserialize(deserializer)?; + let array: Vec = match Vec::deserialize(deserializer) { + Ok(v) => v, + Err(e) => { + warn!("Failed to deserialize tags array: {e}, ignoring"); + return Ok(HashMap::new()); + } + }; let mut map = HashMap::new(); for s in array { if let Some((key, val)) = parse_key_value_tag(&s) { @@ -754,46 +760,84 @@ where match Option::::deserialize(deserializer) { Ok(value) => Ok(value), Err(e) => { - error!("Failed to deserialize optional value: {}, ignoring", e); + warn!("Failed to deserialize optional value: {}, ignoring", e); Ok(None) } } } +/// Gracefully deserialize any field, falling back to `T::default()` on error. +/// +/// This ensures that a single field with the wrong type never fails the entire +/// struct extraction. Works for any `T` that implements `Deserialize + Default`: +/// - `Option` defaults to `None` +/// - `Vec` defaults to `[]` +/// - `HashMap` defaults to `{}` +/// - Structs with `#[derive(Default)]` use their default +pub fn deserialize_with_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + match T::deserialize(deserializer) { + Ok(value) => Ok(value), + Err(e) => { + warn!("Failed to deserialize field: {}, using default", e); + Ok(T::default()) + } + } +} + pub fn deserialize_optional_duration_from_microseconds<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { - Ok(Option::::deserialize(deserializer)?.map(Duration::from_micros)) + match Option::::deserialize(deserializer) { + Ok(opt) => Ok(opt.map(Duration::from_micros)), + Err(e) => { + warn!("Failed to deserialize duration (microseconds): {e}, ignoring"); + Ok(None) + } + } } pub fn deserialize_optional_duration_from_seconds<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { - struct DurationVisitor; - impl serde::de::Visitor<'_> for DurationVisitor { - type Value = Option; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "a duration in seconds (integer or float)") - } - fn visit_u64(self, v: u64) -> Result { - Ok(Some(Duration::from_secs(v))) - } - fn visit_i64(self, v: i64) -> Result { - if v < 0 { - error!("Failed to parse duration: negative durations are not allowed, ignoring"); - return Ok(None); + // Deserialize into a generic Value first to avoid propagating type errors, + // then try to extract a duration from it. + match Value::deserialize(deserializer) { + Ok(Value::Number(n)) => { + if let Some(u) = n.as_u64() { + Ok(Some(Duration::from_secs(u))) + } else if let Some(i) = n.as_i64() { + if i < 0 { + warn!("Failed to parse duration: negative durations are not allowed, ignoring"); + Ok(None) + } else { + Ok(Some(Duration::from_secs(i as u64))) + } + } else if let Some(f) = n.as_f64() { + if f < 0.0 { + warn!("Failed to parse duration: negative durations are not allowed, ignoring"); + Ok(None) + } else { + Ok(Some(Duration::from_secs_f64(f))) + } + } else { + warn!("Failed to parse duration: unsupported number format, ignoring"); + Ok(None) } - self.visit_u64(u64::try_from(v).expect("positive i64 to u64 conversion never fails")) } - fn visit_f64(self, v: f64) -> Result { - if v < 0f64 { - error!("Failed to parse duration: negative durations are not allowed, ignoring"); - return Ok(None); - } - Ok(Some(Duration::from_secs_f64(v))) + Ok(Value::Null) => Ok(None), + Ok(other) => { + warn!("Failed to parse duration: expected number, got {other}, ignoring"); + Ok(None) + } + Err(e) => { + warn!("Failed to deserialize duration: {e}, ignoring"); + Ok(None) } } - deserializer.deserialize_any(DurationVisitor) } // Like deserialize_optional_duration_from_seconds(), but return None if the value is 0 @@ -814,14 +858,20 @@ where D: Deserializer<'de>, { use std::str::FromStr; - let s: String = String::deserialize(deserializer)?; + let s: String = match String::deserialize(deserializer) { + Ok(s) => s, + Err(e) => { + warn!("Failed to deserialize trace propagation style: {e}, ignoring"); + return Ok(Vec::new()); + } + }; Ok(s.split(',') .filter_map( |style| match TracePropagationStyle::from_str(style.trim()) { Ok(parsed_style) => Some(parsed_style), Err(e) => { - error!("Failed to parse trace propagation style: {e}, ignoring"); + warn!("Failed to parse trace propagation style: {e}, ignoring"); None } }, @@ -1477,18 +1527,25 @@ pub mod tests { serde_json::from_str::("{}").expect("failed to parse JSON"), Value { duration: None } ); - serde_json::from_str::(r#"{"duration":-1}"#) - .expect_err("should have failed parsing"); + // Negative and non-integer values gracefully fall back to None + assert_eq!( + serde_json::from_str::(r#"{"duration":-1}"#).expect("should not fail"), + Value { duration: None } + ); assert_eq!( serde_json::from_str::(r#"{"duration":1000000}"#).expect("failed to parse JSON"), Value { duration: Some(Duration::from_secs(1)) } ); - serde_json::from_str::(r#"{"duration":-1.5}"#) - .expect_err("should have failed parsing"); - serde_json::from_str::(r#"{"duration":1.5}"#) - .expect_err("should have failed parsing"); + assert_eq!( + serde_json::from_str::(r#"{"duration":-1.5}"#).expect("should not fail"), + Value { duration: None } + ); + assert_eq!( + serde_json::from_str::(r#"{"duration":1.5}"#).expect("should not fail"), + Value { duration: None } + ); } #[test] diff --git a/crates/datadog-agent-config/service_mapping.rs b/crates/datadog-agent-config/service_mapping.rs index 5b13398..6c293e4 100644 --- a/crates/datadog-agent-config/service_mapping.rs +++ b/crates/datadog-agent-config/service_mapping.rs @@ -9,7 +9,13 @@ pub fn deserialize_service_mapping<'de, D>( where D: Deserializer<'de>, { - let s: String = String::deserialize(deserializer)?; + let s: String = match String::deserialize(deserializer) { + Ok(s) => s, + Err(e) => { + tracing::warn!("Failed to deserialize service mapping: {e}, ignoring"); + return Ok(HashMap::new()); + } + }; let map = s .split(',') @@ -22,7 +28,7 @@ where if let (Some(service), Some(to_map)) = (service, to_map) { Some((service.trim().to_string(), to_map.trim().to_string())) } else { - tracing::error!("Failed to parse service mapping '{}', expected format 'service:mapped_service', ignoring", pair.trim()); + tracing::warn!("Failed to parse service mapping '{}', expected format 'service:mapped_service', ignoring", pair.trim()); None } }) diff --git a/crates/datadog-agent-config/yaml.rs b/crates/datadog-agent-config/yaml.rs index 1be32bd..dbb831c 100644 --- a/crates/datadog-agent-config/yaml.rs +++ b/crates/datadog-agent-config/yaml.rs @@ -9,7 +9,7 @@ use crate::{ deserialize_optional_duration_from_seconds, deserialize_optional_duration_from_seconds_ignore_zero, deserialize_optional_string, deserialize_processing_rules, deserialize_string_or_int, deserialize_trace_propagation_style, - flush_strategy::FlushStrategy, log_level::LogLevel, + deserialize_with_default, flush_strategy::FlushStrategy, log_level::LogLevel, logs_additional_endpoints::LogsAdditionalEndpoint, merge_hashmap, merge_option, merge_option_to_value, merge_string, merge_vec, service_mapping::deserialize_service_mapping, }; @@ -32,6 +32,7 @@ pub struct YamlConfig { pub site: Option, #[serde(deserialize_with = "deserialize_optional_string")] pub api_key: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub log_level: Option, #[serde(deserialize_with = "deserialize_option_lossless")] @@ -41,6 +42,7 @@ pub struct YamlConfig { pub compression_level: Option, // Proxy + #[serde(deserialize_with = "deserialize_with_default")] pub proxy: ProxyConfig, // nit: this should probably be in the endpoints section #[serde(deserialize_with = "deserialize_optional_string")] @@ -68,9 +70,11 @@ pub struct YamlConfig { pub tags: HashMap, // Logs + #[serde(deserialize_with = "deserialize_with_default")] pub logs_config: LogsConfig, // APM + #[serde(deserialize_with = "deserialize_with_default")] pub apm_config: ApmConfig, #[serde(deserialize_with = "deserialize_service_mapping")] pub service_mapping: HashMap, @@ -87,6 +91,7 @@ pub struct YamlConfig { pub trace_propagation_http_baggage_enabled: Option, // Metrics + #[serde(deserialize_with = "deserialize_with_default")] pub metrics_config: MetricsConfig, // DogStatsD @@ -101,6 +106,7 @@ pub struct YamlConfig { pub dogstatsd_queue_size: Option, // OTLP + #[serde(deserialize_with = "deserialize_with_default")] pub otlp_config: Option, // AWS Lambda @@ -112,6 +118,7 @@ pub struct YamlConfig { pub serverless_logs_enabled: Option, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub logs_enabled: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub serverless_flush_strategy: Option, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub enhanced_metrics: Option, @@ -144,7 +151,9 @@ pub struct YamlConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct ProxyConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub https: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub no_proxy: Option>, } @@ -155,6 +164,7 @@ pub struct ProxyConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct LogsConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub logs_dd_url: Option, #[serde(deserialize_with = "deserialize_processing_rules")] pub processing_rules: Option>, @@ -162,6 +172,7 @@ pub struct LogsConfig { pub use_compression: Option, #[serde(deserialize_with = "deserialize_option_lossless")] pub compression_level: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub additional_endpoints: Vec, } @@ -182,12 +193,15 @@ pub struct MetricsConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct ApmConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub apm_dd_url: Option, #[serde(deserialize_with = "deserialize_apm_replace_rules")] pub replace_tags: Option>, + #[serde(deserialize_with = "deserialize_with_default")] pub obfuscation: Option, #[serde(deserialize_with = "deserialize_option_lossless")] pub compression_level: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub features: Vec, #[serde(deserialize_with = "deserialize_additional_endpoints")] pub additional_endpoints: HashMap>, @@ -213,6 +227,7 @@ impl ApmConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct ApmObfuscation { + #[serde(deserialize_with = "deserialize_with_default")] pub http: ApmHttpObfuscation, } @@ -233,11 +248,15 @@ pub struct ApmHttpObfuscation { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct OtlpConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub receiver: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub traces: Option, // NOT SUPPORTED + #[serde(deserialize_with = "deserialize_with_default")] pub metrics: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub logs: Option, } @@ -245,6 +264,7 @@ pub struct OtlpConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct OtlpReceiverConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub protocols: Option, } @@ -252,9 +272,11 @@ pub struct OtlpReceiverConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct OtlpReceiverProtocolsConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub http: Option, // NOT SUPPORTED + #[serde(deserialize_with = "deserialize_with_default")] pub grpc: Option, } @@ -262,6 +284,7 @@ pub struct OtlpReceiverProtocolsConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct OtlpReceiverHttpConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub endpoint: Option, } @@ -269,7 +292,9 @@ pub struct OtlpReceiverHttpConfig { #[serde(default)] #[allow(clippy::module_name_repetitions)] pub struct OtlpReceiverGrpcConfig { + #[serde(deserialize_with = "deserialize_with_default")] pub endpoint: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub transport: Option, #[serde(deserialize_with = "deserialize_option_lossless")] pub max_recv_msg_size_mib: Option, @@ -283,11 +308,13 @@ pub struct OtlpTracesConfig { pub enabled: Option, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub span_name_as_resource_name: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub span_name_remappings: HashMap, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub ignore_missing_datadog_fields: Option, // NOT SUPORTED + #[serde(deserialize_with = "deserialize_with_default")] pub probabilistic_sampler: Option, } @@ -306,17 +333,22 @@ pub struct OtlpMetricsConfig { pub resource_attributes_as_tags: Option, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub instrumentation_scope_metadata_as_tags: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub tag_cardinality: Option, #[serde(deserialize_with = "deserialize_option_lossless")] pub delta_ttl: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub histograms: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub sums: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub summaries: Option, } #[derive(Debug, PartialEq, Clone, Deserialize, Default)] #[serde(default)] pub struct OtlpMetricsHistograms { + #[serde(deserialize_with = "deserialize_with_default")] pub mode: Option, #[serde(deserialize_with = "deserialize_optional_bool_from_anything")] pub send_count_sum_metrics: Option, @@ -327,13 +359,16 @@ pub struct OtlpMetricsHistograms { #[derive(Debug, PartialEq, Clone, Deserialize, Default)] #[serde(default)] pub struct OtlpMetricsSums { + #[serde(deserialize_with = "deserialize_with_default")] pub cumulative_monotonic_mode: Option, + #[serde(deserialize_with = "deserialize_with_default")] pub initial_cumulative_monotonic_value: Option, } #[derive(Debug, PartialEq, Clone, Deserialize, Default)] #[serde(default)] pub struct OtlpMetricsSummaries { + #[serde(deserialize_with = "deserialize_with_default")] pub mode: Option, } @@ -739,10 +774,158 @@ mod tests { use std::path::Path; use std::time::Duration; - use crate::{flush_strategy::PeriodicStrategy, processing_rule::Kind}; + use crate::{flush_strategy::PeriodicStrategy, log_level::LogLevel, processing_rule::Kind}; use super::*; + /// Comprehensive test: every field in the YAML set to the wrong type. + /// The load MUST succeed, and every field must fall back to its default. + /// + /// When adding a new field to YamlConfig or any nested struct, add an entry + /// here with the wrong type to ensure graceful deserialization is in place. + #[test] + fn test_all_yaml_fields_wrong_type_fallback_to_default() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + // Every field is set to an array [1, 2, 3] which is the wrong type + // for strings, numbers, bools, and nested structs. This exercises + // every deserialize_with handler. + jail.create_file( + "datadog.yaml", + r#" +# Basic fields +site: [1, 2, 3] +api_key: [1, 2, 3] +log_level: [1, 2, 3] +flush_timeout: [1, 2, 3] +compression_level: [1, 2, 3] + +# Proxy (nested) +proxy: + https: [1, 2, 3] + no_proxy: 12345 + +# Endpoints +dd_url: [1, 2, 3] +http_protocol: [1, 2, 3] +tls_cert_file: [1, 2, 3] +skip_ssl_validation: [1, 2, 3] +additional_endpoints: [1, 2, 3] + +# Unified Service Tagging +env: [1, 2, 3] +service: [1, 2, 3] +version: [1, 2, 3] +tags: 12345 + +# Logs (nested) +logs_config: + logs_dd_url: [1, 2, 3] + processing_rules: 12345 + use_compression: [1, 2, 3] + compression_level: [1, 2, 3] + additional_endpoints: 12345 + +# APM (nested) +apm_config: + apm_dd_url: [1, 2, 3] + replace_tags: 12345 + obfuscation: + http: + remove_query_string: [1, 2, 3] + remove_paths_with_digits: [1, 2, 3] + compression_level: [1, 2, 3] + features: 12345 + additional_endpoints: [1, 2, 3] + +service_mapping: [1, 2, 3] +trace_aws_service_representation_enabled: [1, 2, 3] + +# Trace Propagation +trace_propagation_style: [1, 2, 3] +trace_propagation_style_extract: [1, 2, 3] +trace_propagation_extract_first: [1, 2, 3] +trace_propagation_http_baggage_enabled: [1, 2, 3] + +# Metrics (nested) +metrics_config: + compression_level: [1, 2, 3] + +# DogStatsD +dogstatsd_so_rcvbuf: [1, 2, 3] +dogstatsd_buffer_size: [1, 2, 3] +dogstatsd_queue_size: [1, 2, 3] + +# OTLP (deeply nested) +otlp_config: + receiver: + protocols: + http: + endpoint: [1, 2, 3] + grpc: + endpoint: [1, 2, 3] + transport: [1, 2, 3] + max_recv_msg_size_mib: [1, 2, 3] + traces: + enabled: [1, 2, 3] + span_name_as_resource_name: [1, 2, 3] + span_name_remappings: [1, 2, 3] + ignore_missing_datadog_fields: [1, 2, 3] + probabilistic_sampler: + sampling_percentage: [1, 2, 3] + metrics: + enabled: [1, 2, 3] + resource_attributes_as_tags: [1, 2, 3] + instrumentation_scope_metadata_as_tags: [1, 2, 3] + tag_cardinality: [1, 2, 3] + delta_ttl: [1, 2, 3] + histograms: + mode: [1, 2, 3] + send_count_sum_metrics: [1, 2, 3] + send_aggregation_metrics: [1, 2, 3] + sums: + cumulative_monotonic_mode: [1, 2, 3] + initial_cumulative_monotonic_value: [1, 2, 3] + summaries: + mode: [1, 2, 3] + logs: + enabled: [1, 2, 3] + +# AWS Lambda +api_key_secret_arn: [1, 2, 3] +kms_api_key: [1, 2, 3] +serverless_logs_enabled: [1, 2, 3] +logs_enabled: [1, 2, 3] +serverless_flush_strategy: [1, 2, 3] +enhanced_metrics: [1, 2, 3] +lambda_proc_enhanced_metrics: [1, 2, 3] +capture_lambda_payload: [1, 2, 3] +capture_lambda_payload_max_depth: [1, 2, 3] +compute_trace_stats_on_extension: [1, 2, 3] +api_key_secret_reload_interval: [1, 2, 3] +serverless_appsec_enabled: [1, 2, 3] +appsec_rules: [1, 2, 3] +appsec_waf_timeout: [1, 2, 3] +api_security_enabled: [1, 2, 3] +api_security_sample_delay: [1, 2, 3] +"#, + )?; + + let mut config = Config::default(); + let source = YamlConfigSource { + path: PathBuf::from("datadog.yaml"), + }; + // This MUST succeed — no single field should crash the whole config + source + .load(&mut config) + .expect("load must not fail when fields have wrong types"); + + // Every field should be at its default since all values were invalid + assert_eq!(config, Config::default()); + Ok(()) + }); + } + #[test] #[allow(clippy::too_many_lines)] fn test_merge_config_overrides_with_yaml_file() {