diff --git a/datadog-opentelemetry/tests/integration_tests/opentelemetry_api.rs b/datadog-opentelemetry/tests/integration_tests/opentelemetry_api.rs index d998bfc..bcd500b 100644 --- a/datadog-opentelemetry/tests/integration_tests/opentelemetry_api.rs +++ b/datadog-opentelemetry/tests/integration_tests/opentelemetry_api.rs @@ -191,6 +191,7 @@ async fn test_remote_config_sampling_rates() { r##"{ "path": "datadog/2/APM_TRACING/1234/config", "msg": { + "id": "42", "lib_config": { "tracing_sampling_rules": [ { diff --git a/dd-trace/src/configuration/configuration.rs b/dd-trace/src/configuration/configuration.rs index 2179897..c379b6c 100644 --- a/dd-trace/src/configuration/configuration.rs +++ b/dd-trace/src/configuration/configuration.rs @@ -14,7 +14,7 @@ use crate::configuration::sources::{ CompositeConfigSourceResult, CompositeSource, ConfigKey, ConfigSourceOrigin, }; use crate::log::LevelFilter; -use crate::{dd_error, dd_warn}; +use crate::{dd_error, dd_warn, telemetry}; /// Different types of remote configuration updates that can trigger callbacks #[derive(Debug, Clone)] @@ -191,6 +191,24 @@ impl ConfigurationValueProvider for ConfigItemRef } } +/// A trait for providing configuration data for telemetry reporting. +/// +/// This trait standardizes how configuration items expose their current state +/// as `ddtelemetry::data::Configuration` payloads for telemetry collection. +/// It enables the configuration system to report configuration values, their +/// origins, and associated metadata to Datadog. +pub trait ConfigurationProvider { + /// Returns a telemetry configuration object representing the current state of this + /// configuration item. + /// + /// # Parameters + /// + /// - `config_id`: Optional identifier for remote configuration scenarios. When provided, this + /// ID is included in the returned `Configuration` to track which remote configuration is + /// responsible for the current value. + fn get_configuration(&self, config_id: Option) -> Configuration; +} + /// A trait for converting configuration values to their string representation for telemetry. /// /// This trait is used to serialize configuration values into strings that can be sent @@ -288,14 +306,16 @@ impl ConfigItem { ConfigSourceOrigin::Default } } +} +impl ConfigurationProvider for ConfigItem { /// Gets a Configuration object used as telemetry payload - fn get_configuration(&self) -> Configuration { + fn get_configuration(&self, config_id: Option) -> Configuration { Configuration { name: self.name.to_string(), value: self.value().get_configuration_value(), origin: self.source().into(), - config_id: None, + config_id, } } } @@ -387,14 +407,18 @@ impl ConfigItemWithOverride { ConfigItemRef::Ref(self.config_item.value()) } } +} +impl ConfigurationProvider + for ConfigItemWithOverride +{ /// Gets a Configuration object used as telemetry payload - fn get_configuration(&self) -> Configuration { + fn get_configuration(&self, config_id: Option) -> Configuration { Configuration { name: self.config_item.name.to_string(), value: self.value().get_configuration_value(), origin: self.source().into(), - config_id: None, + config_id, } } } @@ -970,33 +994,33 @@ impl Config { Self::builder_with_sources(&CompositeSource::default_sources()) } - pub fn get_telemetry_configuration(&self) -> Vec { + pub fn get_telemetry_configuration(&self) -> Vec<&dyn ConfigurationProvider> { vec![ - self.service.get_configuration(), - self.env.get_configuration(), - self.version.get_configuration(), - self.global_tags.get_configuration(), - self.agent_host.get_configuration(), - self.trace_agent_port.get_configuration(), - self.trace_agent_url.get_configuration(), - self.dogstatsd_agent_host.get_configuration(), - self.dogstatsd_agent_port.get_configuration(), - self.dogstatsd_agent_url.get_configuration(), - self.trace_sampling_rules.get_configuration(), - self.trace_rate_limit.get_configuration(), - self.enabled.get_configuration(), - self.log_level_filter.get_configuration(), - self.trace_stats_computation_enabled.get_configuration(), - self.telemetry_enabled.get_configuration(), - self.telemetry_log_collection_enabled.get_configuration(), - self.telemetry_heartbeat_interval.get_configuration(), - self.trace_propagation_style.get_configuration(), - self.trace_propagation_style_extract.get_configuration(), - self.trace_propagation_style_inject.get_configuration(), - self.trace_propagation_extract_first.get_configuration(), - self.remote_config_enabled.get_configuration(), - self.remote_config_poll_interval.get_configuration(), - self.datadog_tags_max_length.get_configuration(), + &self.service, + &self.env, + &self.version, + &self.global_tags, + &self.agent_host, + &self.trace_agent_port, + &self.trace_agent_url, + &self.dogstatsd_agent_host, + &self.dogstatsd_agent_port, + &self.dogstatsd_agent_url, + &self.trace_sampling_rules, + &self.trace_rate_limit, + &self.enabled, + &self.log_level_filter, + &self.trace_stats_computation_enabled, + &self.telemetry_enabled, + &self.telemetry_log_collection_enabled, + &self.telemetry_heartbeat_interval, + &self.trace_propagation_style, + &self.trace_propagation_style_extract, + &self.trace_propagation_style_inject, + &self.trace_propagation_extract_first, + &self.remote_config_enabled, + &self.remote_config_poll_interval, + &self.datadog_tags_max_length, ] } @@ -1118,14 +1142,18 @@ impl Config { *self.trace_propagation_extract_first.value() } - pub fn update_sampling_rules_from_remote(&self, rules_json: &str) -> Result<(), String> { + pub fn update_sampling_rules_from_remote( + &self, + rules_json: &str, + config_id: Option, + ) -> Result<(), String> { // Parse the JSON into SamplingRuleConfig objects let rules: Vec = serde_json::from_str(rules_json) .map_err(|e| format!("Failed to parse sampling rules JSON: {e}"))?; // If remote config sends empty rules, clear remote config to fall back to local rules if rules.is_empty() { - self.clear_remote_sampling_rules(); + self.clear_remote_sampling_rules(config_id); } else { self.trace_sampling_rules.set_override_value( ParsedSamplingRules { rules }, @@ -1136,6 +1164,8 @@ impl Config { self.remote_config_callbacks.lock().unwrap().notify_update( &RemoteConfigUpdate::SamplingRules(self.trace_sampling_rules().to_vec()), ); + + telemetry::notify_configuration_update(&self.trace_sampling_rules, config_id); } Ok(()) @@ -1150,12 +1180,14 @@ impl Config { } } - pub fn clear_remote_sampling_rules(&self) { + pub fn clear_remote_sampling_rules(&self, config_id: Option) { self.trace_sampling_rules.unset_override_value(); self.remote_config_callbacks.lock().unwrap().notify_update( &RemoteConfigUpdate::SamplingRules(self.trace_sampling_rules().to_vec()), ); + + telemetry::notify_configuration_update(&self.trace_sampling_rules, config_id); } /// Add a callback to be called when sampling rules are updated via remote configuration @@ -1980,7 +2012,7 @@ mod tests { let rules_json = serde_json::to_string(&new_rules).unwrap(); config - .update_sampling_rules_from_remote(&rules_json) + .update_sampling_rules_from_remote(&rules_json, None) .unwrap(); // Callback should be called with the new rules @@ -1991,7 +2023,7 @@ mod tests { *callback_called.lock().unwrap() = false; callback_rules.lock().unwrap().clear(); - config.clear_remote_sampling_rules(); + config.clear_remote_sampling_rules(None); // Callback should be called with fallback rules (empty in this case since no env/code rules // set) @@ -2114,7 +2146,7 @@ mod tests { let remote_rules_json = r#"[{"sample_rate": 0.8, "service": "remote-service", "provenance": "remote"}]"#; config - .update_sampling_rules_from_remote(remote_rules_json) + .update_sampling_rules_from_remote(remote_rules_json, None) .unwrap(); // Verify remote rules override local rules @@ -2128,7 +2160,7 @@ mod tests { // 3. Remote config sends empty array [] let empty_remote_rules_json = "[]"; config - .update_sampling_rules_from_remote(empty_remote_rules_json) + .update_sampling_rules_from_remote(empty_remote_rules_json, None) .unwrap(); // Empty remote rules automatically fall back to local rules @@ -2141,7 +2173,7 @@ mod tests { // 4. Verify explicit clearing still works (for completeness) // Since we're already on local rules, clear should keep us on local rules - config.clear_remote_sampling_rules(); + config.clear_remote_sampling_rules(None); // Should remain on local rules assert_eq!(config.trace_sampling_rules().len(), 1); @@ -2394,7 +2426,7 @@ mod tests { r#"[{"sample_rate":0.5,"service":"web-api","name":null,"resource":null,"tags":{},"provenance":"customer"}]"# ).unwrap(); - let configuration = &config.trace_sampling_rules.get_configuration(); + let configuration = &config.trace_sampling_rules.get_configuration(None); assert_eq!(configuration.origin, ConfigurationOrigin::EnvVar); // Converting configuration value to json helps with comparison as serialized properties may @@ -2410,7 +2442,7 @@ mod tests { .trace_sampling_rules .set_override_value(expected_rc.clone(), ConfigSourceOrigin::RemoteConfig); - let configuration_after_rc = &config.trace_sampling_rules.get_configuration(); + let configuration_after_rc = &config.trace_sampling_rules.get_configuration(None); assert_eq!( configuration_after_rc.origin, ConfigurationOrigin::RemoteConfig @@ -2423,7 +2455,7 @@ mod tests { // Reset ConfigItemRc RC previous value config.trace_sampling_rules.unset_override_value(); - let configuration = &config.trace_sampling_rules.get_configuration(); + let configuration = &config.trace_sampling_rules.get_configuration(None); assert_eq!(configuration.origin, ConfigurationOrigin::EnvVar); assert_eq!( ParsedSamplingRules::from_str(&configuration.value).unwrap(), diff --git a/dd-trace/src/configuration/mod.rs b/dd-trace/src/configuration/mod.rs index 2c1db7b..b7ad096 100644 --- a/dd-trace/src/configuration/mod.rs +++ b/dd-trace/src/configuration/mod.rs @@ -7,5 +7,6 @@ pub mod remote_config; mod sources; pub use configuration::{ - Config, ConfigBuilder, RemoteConfigUpdate, SamplingRuleConfig, TracePropagationStyle, + Config, ConfigBuilder, ConfigurationProvider, RemoteConfigUpdate, SamplingRuleConfig, + TracePropagationStyle, }; diff --git a/dd-trace/src/configuration/remote_config.rs b/dd-trace/src/configuration/remote_config.rs index 249564d..6d25e8e 100644 --- a/dd-trace/src/configuration/remote_config.rs +++ b/dd-trace/src/configuration/remote_config.rs @@ -187,6 +187,7 @@ where /// See: https://github.com/DataDog/dd-go/blob/prod/remote-config/apps/rc-schema-validation/schemas/apm-tracing.json #[derive(Debug, Clone, Deserialize)] struct ApmTracingConfig { + id: String, lib_config: LibConfig, // lib_config is a required property } @@ -842,7 +843,8 @@ impl ProductHandler for ApmTracingHandler { let rules_json = serde_json::to_string(&rules_value) .map_err(|e| anyhow::anyhow!("Failed to serialize sampling rules: {}", e))?; - match config.update_sampling_rules_from_remote(&rules_json) { + match config.update_sampling_rules_from_remote(&rules_json, Some(tracing_config.id)) + { Ok(()) => { crate::dd_debug!( "RemoteConfigClient: Applied sampling rules from remote config" @@ -859,7 +861,7 @@ impl ProductHandler for ApmTracingHandler { crate::dd_debug!( "RemoteConfigClient: APM tracing config received but tracing_sampling_rules is null" ); - config.clear_remote_sampling_rules(); + config.clear_remote_sampling_rules(Some(tracing_config.id)); } } else { crate::dd_debug!( @@ -1085,6 +1087,7 @@ mod tests { #[test] fn test_apm_tracing_config_parsing() { let json = r#"{ + "id": "42", "lib_config": { "tracing_sampling_rules": [ { @@ -1112,6 +1115,7 @@ mod tests { fn test_apm_tracing_config_full_schema() { // Test parsing a more complete configuration let json = r#"{ + "id": "42", "lib_config": { "tracing_sampling_rules": [ { @@ -1300,7 +1304,7 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/apm-tracing-sampling/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), // base64 encoded APM config + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), // base64 encoded APM config }, ]), client_configs: Some(vec![ @@ -1340,7 +1344,7 @@ mod tests { cached_files[0].path, "datadog/2/APM_TRACING/apm-tracing-sampling/config" ); - assert_eq!(cached_files[0].length, 124); + assert_eq!(cached_files[0].length, 140); assert_eq!(cached_files[0].hashes.len(), 1); assert_eq!(cached_files[0].hashes[0].algorithm, "sha256"); @@ -1416,7 +1420,7 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/test-config/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), }, ]), client_configs: Some(vec![ @@ -1645,7 +1649,7 @@ mod tests { // Test processing config - this should not panic for valid JSON let config = Arc::new(Config::builder().build()); - let config_json = r#"{"lib_config": {"tracing_sampling_rules": [{"sample_rate": 0.5, "service": "test"}]}}"#; + let config_json = r#"{"id": "42", "lib_config": {"tracing_sampling_rules": [{"sample_rate": 0.5, "service": "test"}]}}"#; // This should succeed let result = handler.process_config(config_json, &config); @@ -1670,7 +1674,7 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/config1/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZS0xIn1dfX0=".to_string(), + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZS0xIn1dfX0=".to_string(), }, ]), client_configs: Some(vec![ @@ -1697,11 +1701,11 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/config2/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjc1LCAic2VydmljZSI6ICJ0ZXN0LXNlcnZpY2UtMiJ9XX19".to_string(), + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJpZCI6IjQyIiwgInRyYWNpbmdfc2FtcGxpbmdfcnVsZXMiOiBbeyJzYW1wbGVfcmF0ZSI6IDAuNzUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZS0yIn1dfX0=".to_string(), }, TargetFile { path: "datadog/2/APM_TRACING/config3/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjI1LCAic2VydmljZSI6ICJ0ZXN0LXNlcnZpY2UtMiJ9XX19".to_string(), + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJpZCI6IjQyIiwgInRyYWNpbmdfc2FtcGxpbmdfcnVsZXMiOiBbeyJzYW1wbGVfcmF0ZSI6IDAuMjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZS0yIn1dfX0=".to_string(), }, ]), client_configs: Some(vec![ @@ -1773,7 +1777,7 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/good_config/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjUsICJzZXJ2aWNlIjogInRlc3Qtc2VydmljZSJ9XX19".to_string(), }, ]), client_configs: Some(vec![ @@ -1875,7 +1879,7 @@ mod tests { target_files: Some(vec![ TargetFile { path: "datadog/2/APM_TRACING/test-sampling/config".to_string(), - raw: "eyJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjc1LCAic2VydmljZSI6ICJ0ZXN0LWFwcC1zZXJ2aWNlIn1dfX0=".to_string(), // base64 encoded sampling rules + raw: "eyJpZCI6ICI0MiIsICJsaWJfY29uZmlnIjogeyJ0cmFjaW5nX3NhbXBsaW5nX3J1bGVzIjogW3sic2FtcGxlX3JhdGUiOiAwLjc1LCAic2VydmljZSI6ICJ0ZXN0LWFwcC1zZXJ2aWNlIn1dfX0=".to_string(), // base64 encoded sampling rules }, ]), client_configs: Some(vec![ @@ -1915,7 +1919,7 @@ mod tests { #[test] fn test_deserialize_tracing_sampling_rules_null() { - let config_json = r#"{"lib_config": {"tracing_sampling_rules": null}}"#; + let config_json = r#"{"id": "42", "lib_config": {"tracing_sampling_rules": null}}"#; let tracing_config: ApmTracingConfig = serde_json::from_str(config_json).expect("Json should be parsed"); @@ -1929,7 +1933,7 @@ mod tests { #[test] fn test_deserialize_tracing_sampling_rules_missing() { - let config_json = r#"{"lib_config": {}}"#; + let config_json = r#"{"id": "42", "lib_config": {}}"#; let tracing_config: ApmTracingConfig = serde_json::from_str(config_json).expect("Json should be parsed"); diff --git a/dd-trace/src/telemetry.rs b/dd-trace/src/telemetry.rs index 393107f..15cad97 100644 --- a/dd-trace/src/telemetry.rs +++ b/dd-trace/src/telemetry.rs @@ -9,11 +9,11 @@ use std::{ use anyhow::Error; use ddtelemetry::{ - data, + data::{self, Configuration}, worker::{self, TelemetryWorkerHandle}, }; -use crate::{dd_error, dd_info, Config}; +use crate::{configuration::ConfigurationProvider, dd_debug, dd_error, dd_info, dd_warn, Config}; static TELEMETRY: OnceLock>> = OnceLock::new(); @@ -24,6 +24,8 @@ trait TelemetryHandle: Sync + Send + 'static + Any { stack_trace: Option, ) -> Result<(), anyhow::Error>; + fn add_configuration(&mut self, configuration: Configuration) -> Result<(), anyhow::Error>; + fn send_start(&self, config: Option<&Config>) -> Result<(), anyhow::Error>; fn send_stop(&self) -> Result<(), anyhow::Error>; @@ -41,14 +43,20 @@ impl TelemetryHandle for TelemetryWorkerHandle { self.add_log(message.clone(), message, data::LogLevel::Error, stack_trace) } + fn add_configuration(&mut self, config_item: Configuration) -> Result<(), anyhow::Error> { + self.try_send_msg(worker::TelemetryActions::AddConfig(config_item)) + } + fn send_start(&self, config: Option<&Config>) -> Result<(), anyhow::Error> { if let Some(config) = config { config .get_telemetry_configuration() .into_iter() - .for_each(|config_item| { - self.try_send_msg(worker::TelemetryActions::AddConfig(config_item)) - .ok(); + .for_each(|config_provider| { + self.try_send_msg(worker::TelemetryActions::AddConfig( + config_provider.get_configuration(None), + )) + .ok(); }); } @@ -165,26 +173,65 @@ fn add_log_error_inner>( handle.add_error_log(message.into(), stack).ok(); } +pub fn notify_configuration_update( + config_provider: &dyn ConfigurationProvider, + config_id: Option, +) { + notify_configuration_update_inner(config_provider, config_id, &TELEMETRY); +} + +fn notify_configuration_update_inner( + config_provider: &dyn ConfigurationProvider, + config_id: Option, + telemetry_cell: &OnceLock>>, +) { + let Some(telemetry) = telemetry_cell.get() else { + return; + }; + let Ok(mut telemetry) = telemetry.lock() else { + return; + }; + if !telemetry.enabled { + return; + } + let Some(handle) = telemetry.handle.as_mut() else { + return; + }; + + if let Err(err) = handle.add_configuration(config_provider.get_configuration(config_id)) { + dd_warn!("Telemetry: error sending configuration item {err}"); + } else { + dd_debug!("Telemetry: configuration update sent sucessfully"); + } +} + #[cfg(test)] mod tests { use ddtelemetry::data; use crate::{ + configuration::ConfigurationProvider, dd_debug, dd_error, dd_warn, - telemetry::{add_log_error_inner, init_telemetry_inner, TelemetryHandle, TELEMETRY}, + telemetry::{ + add_log_error_inner, init_telemetry_inner, notify_configuration_update_inner, + TelemetryHandle, TELEMETRY, + }, Config, }; use std::{any::Any, sync::OnceLock}; - #[derive(Clone)] struct TestTelemetryHandle { pub logs: Vec<(String, data::LogLevel, Option)>, + pub configurations: Vec, } impl TestTelemetryHandle { fn new() -> Self { - TestTelemetryHandle { logs: vec![] } + TestTelemetryHandle { + logs: vec![], + configurations: vec![], + } } } @@ -199,6 +246,14 @@ mod tests { Ok(()) } + fn add_configuration( + &mut self, + configuration: data::Configuration, + ) -> Result<(), anyhow::Error> { + self.configurations.push(configuration); + Ok(()) + } + fn send_start(&self, _config: Option<&Config>) -> Result<(), anyhow::Error> { Ok(()) } @@ -212,6 +267,33 @@ mod tests { } } + struct MockConfigurationProvider { + name: String, + value: String, + origin: data::ConfigurationOrigin, + } + + impl MockConfigurationProvider { + fn new(origin: data::ConfigurationOrigin) -> Self { + MockConfigurationProvider { + name: "DD_SERVICE".to_string(), + value: "test".to_string(), + origin, + } + } + } + + impl ConfigurationProvider for MockConfigurationProvider { + fn get_configuration(&self, config_id: Option) -> data::Configuration { + data::Configuration { + name: self.name.clone(), + value: self.value.clone(), + origin: self.origin.clone(), + config_id, + } + } + } + #[test] fn test_add_log_error_telemetry_disabled() { let config = Config::builder().set_telemetry_enabled(false).build(); @@ -355,4 +437,75 @@ mod tests { assert!(!logs.iter().any(|(msg, _, _)| msg == "This is an debug")); assert!(!logs.iter().any(|(msg, _, _)| msg == "This is an warn")); } + + #[test] + fn test_notify_configuration_update() { + let config = Config::builder().build(); + let telemetry_cell = OnceLock::new(); + init_telemetry_inner( + &config, + Some(Box::new(TestTelemetryHandle::new())), + &telemetry_cell, + ); + + let mock_provider = MockConfigurationProvider::new(data::ConfigurationOrigin::EnvVar); + let config_id = Some("config-42".to_string()); + + notify_configuration_update_inner(&mock_provider, config_id.clone(), &telemetry_cell); + + let t = telemetry_cell.get().unwrap().lock().unwrap(); + let handle = t + .handle + .as_ref() + .unwrap() + .as_any() + .downcast_ref::() + .expect("Handle should be TestTelemetryHandle"); + + assert_eq!(handle.configurations.len(), 1); + + let sent_config = &handle.configurations[0]; + assert_eq!(sent_config.name, "DD_SERVICE"); + assert_eq!(sent_config.value, "test"); + assert_eq!(sent_config.origin, data::ConfigurationOrigin::EnvVar); + assert_eq!(sent_config.config_id, config_id); + } + + #[test] + fn test_notify_configuration_update_telemetry_disabled() { + let config = Config::builder().set_telemetry_enabled(false).build(); + let telemetry_cell = OnceLock::new(); + init_telemetry_inner( + &config, + Some(Box::new(TestTelemetryHandle::new())), + &telemetry_cell, + ); + + let mock_provider = MockConfigurationProvider::new(data::ConfigurationOrigin::EnvVar); + + notify_configuration_update_inner(&mock_provider, None, &telemetry_cell); + + let t = telemetry_cell.get().unwrap().lock().unwrap(); + let handle = t + .handle + .as_ref() + .unwrap() + .as_any() + .downcast_ref::() + .expect("Handle should be TestTelemetryHandle"); + + // Should not send configuration when telemetry is disabled + assert_eq!(handle.configurations.len(), 0); + } + + #[test] + fn test_notify_configuration_update_no_handle() { + let telemetry_cell = OnceLock::new(); + // Don't initialize telemetry - no handle should be present + + let mock_provider = MockConfigurationProvider::new(data::ConfigurationOrigin::Default); + + // Should not panic when no telemetry is initialized + notify_configuration_update_inner(&mock_provider, None, &telemetry_cell); + } }