Skip to main content

agent_data_plane_config_system/
system.rs

1//! [`ConfigurationSystem`]: the runtime configuration, translated from the raw sources and kept
2//! current as the Datadog Agent streams updates.
3
4use std::future::Future;
5use std::sync::{Arc, PoisonError, RwLock};
6use std::time::Duration;
7
8use agent_data_plane_config::{Live, SalukiConfiguration};
9use arc_swap::ArcSwap;
10use datadog_agent_config::{DatadogConfiguration, TranslateErrors};
11use saluki_config::dynamic::ConfigUpdate;
12use serde::Deserialize;
13use serde_json::Value;
14use snafu::Snafu;
15use tokio::sync::{mpsc, watch, Mutex};
16use tokio::time::timeout;
17use tracing::{debug, warn};
18
19use crate::saluki_only::SalukiOnly;
20use crate::source::SourceTree;
21use crate::translators::DatadogTranslator;
22
23// Timeout for waiting for the initial configuration snapshot from the Datadog Agent.
24//
25// This is separately from any timeout related to _connected_ to the Datadog Agent and establishing the configuration
26// update stream in the first place.
27const INITIAL_CONFIG_SNAPSHOT_TIMEOUT_SECS: u64 = 15;
28const INITIAL_CONFIG_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(INITIAL_CONFIG_SNAPSHOT_TIMEOUT_SECS);
29
30/// An error building the translated configuration from the merged sources.
31#[derive(Debug, Snafu)]
32pub enum Error {
33    /// A source model could not be deserialized from the merged configuration value.
34    #[snafu(context(false), display("{source}"))]
35    Deserialize {
36        /// The underlying deserialization error.
37        source: serde_json::Error,
38    },
39
40    /// The Datadog Agent closed the configuration stream before sending the initial snapshot.
41    #[snafu(display("configuration stream closed before the initial snapshot"))]
42    StreamClosed,
43
44    /// The Datadog Agent configuration stream closed after the initial snapshot, so no further
45    /// configuration updates can be applied.
46    #[snafu(display("configuration stream closed; no further configuration updates can be applied"))]
47    UpdateStreamClosed,
48
49    /// Timed out waiting for the initial snapshot from the Datadog Agent configuration stream.
50    #[snafu(display("timed out waiting for the initial snapshot ({INITIAL_CONFIG_SNAPSHOT_TIMEOUT_SECS} seconds)"))]
51    SnapshotTimeOut,
52
53    /// The typed base could not be built from the file and environment.
54    #[snafu(display("failed to build the configuration base: {message}"))]
55    Base {
56        /// What went wrong reading the file, parsing YAML, or decoding an environment variable.
57        message: String,
58    },
59
60    /// Translating the sources into the model failed on one or more keys.
61    #[snafu(display("{source}"))]
62    Translate {
63        /// Every translation error recorded.
64        source: TranslateErrors,
65    },
66
67    /// The fully merged configuration resolved no usable Datadog API key.
68    #[snafu(display(
69        "no Datadog API key is configured: set `api_key` in the Datadog Agent's configuration, or \
70         `DD_API_KEY` in the environment. Every payload is authenticated with this key, so nothing can \
71         be submitted without one"
72    ))]
73    MissingApiKey,
74}
75
76type Result<T> = std::result::Result<T, Error>;
77
78/// The runtime configuration, translated from the merged sources and kept current.
79///
80/// The configuration system is the single owner of the Datadog Agent's `ConfigUpdate` stream. It
81/// folds each update onto the local source base and builds the typed [`SalukiConfiguration`] from
82/// the result. The current configuration lives in an [`ArcSwap`] cell so readers load a whole,
83/// self-consistent version with no lock, while the update task replaces it in one atomic store.
84pub struct ConfigurationSystem {
85    current: Arc<ArcSwap<SalukiConfiguration>>,
86    // The merged sources the current configuration was translated from, kept so consumers that work
87    // a key at a time have a by-key view carrying provenance. The update task holds the write lock
88    // while it replaces both, so a reader never sees sources that `current` does not match.
89    sources: Arc<RwLock<Arc<SourceTree>>>,
90    // Fired once after each accepted update so live views wake and re-project. Shared with the
91    // update task via `Arc` because `watch::Sender` is not `Clone` and both the system (to mint
92    // views) and the task (to notify) need it.
93    tick: Arc<watch::Sender<()>>,
94}
95
96impl ConfigurationSystem {
97    /// Connected authority: takes ownership of the Datadog Agent's config stream and builds the typed
98    /// model from the stream folded onto the local `base` (file + environment).
99    ///
100    /// Blocks for the first authoritative snapshot and is the strict startup gate: a snapshot that
101    /// never arrives, cannot be deserialized, or fails translation aborts the boot.
102    ///
103    /// Returns the system together with the [`ConfigurationUpdates`] that apply each later update.
104    /// No update after the first snapshot is applied until the caller runs them.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the stream closes before the first snapshot, or the initial configuration
109    /// cannot be deserialized or translated.
110    pub(crate) async fn connected(
111        mut agent_rx: mpsc::Receiver<ConfigUpdate>, base: SourceTree,
112    ) -> Result<(Self, ConfigurationUpdates)> {
113        // The first stream message is the authoritative initial snapshot. There is no timeout on this
114        // wait by design: if the Datadog Agent never sends the snapshot, startup blocks here forever.
115        saluki_antithesis::reachable!("config readiness wait entered");
116        let first = match timeout(INITIAL_CONFIG_SNAPSHOT_TIMEOUT, agent_rx.recv()).await {
117            Ok(None) => {
118                saluki_antithesis::unreachable!("config stream closed before the initial snapshot");
119                return Err(Error::StreamClosed);
120            }
121            Ok(Some(first)) => first,
122            Err(_) => return Err(Error::SnapshotTimeOut),
123        };
124        saluki_antithesis::sometimes!(true, "config readiness signal received");
125
126        let mut agent = SourceTree::empty();
127        fold(&mut agent, &first);
128
129        // Startup is the strict gate: this is the first, authoritative Agent snapshot, so any error
130        // fails the boot and we never run on bad config. At runtime (see `ConfigurationUpdates`) the
131        // same check instead rejects the offending update and keeps the last-known-good
132        // configuration, because a runtime update must never take the system down.
133        let merged = base.overlay(&agent);
134        let config = translate_authoritative(&merged)?;
135
136        let current = Arc::new(ArcSwap::from_pointee(config));
137        let sources = Arc::new(RwLock::new(Arc::new(merged)));
138        // The initial receiver is dropped immediately; `send_replace` works with zero receivers, and
139        // each live view subscribes its own receiver from the sender.
140        let (tick, _) = watch::channel(());
141        let tick = Arc::new(tick);
142
143        let updates = ConfigurationUpdates {
144            state: Arc::new(Mutex::new(UpdateState {
145                agent_rx,
146                base,
147                agent,
148                current: Arc::clone(&current),
149                sources: Arc::clone(&sources),
150                tick: Arc::clone(&tick),
151            })),
152        };
153
154        Ok((Self { current, sources, tick }, updates))
155    }
156
157    /// Installs a static configuration without an update task.
158    ///
159    /// Live views retain their initial values because this system sends no update notifications.
160    pub(crate) fn standalone(config: SalukiConfiguration, sources: SourceTree) -> Self {
161        let current = Arc::new(ArcSwap::from_pointee(config));
162        let (tick, _) = watch::channel(());
163        Self {
164            current,
165            sources: Arc::new(RwLock::new(Arc::new(sources))),
166            tick: Arc::new(tick),
167        }
168    }
169
170    /// Returns a live view of the given projection of the current configuration. Narrow further with
171    /// [`Live::project`]. This is the only way a consumer subscribes to runtime updates.
172    pub fn live<T>(&self, project: impl for<'a> Fn(&'a SalukiConfiguration) -> &'a T + Send + Sync + 'static) -> Live<T>
173    where
174        T: Clone + PartialEq + 'static,
175    {
176        Live::new_dynamic(Arc::clone(&self.current), self.tick.subscribe(), project)
177    }
178
179    /// Loads the current translated configuration.
180    ///
181    /// The returned guard pins one whole version; a concurrent refresh never tears the read.
182    pub fn config(&self) -> arc_swap::Guard<Arc<SalukiConfiguration>> {
183        self.current.load()
184    }
185
186    /// Returns a shared handle to the current-configuration cell for readers that load it
187    /// independently.
188    pub fn current_handle(&self) -> Arc<ArcSwap<SalukiConfiguration>> {
189        Arc::clone(&self.current)
190    }
191
192    /// Returns a callback that reads the current merged sources as JSON.
193    ///
194    /// This is the raw source view: the values every input supplied, untranslated, including keys the
195    /// typed model does not carry. It reflects the last update the typed path accepted, so it is
196    /// consistent with the running configuration.
197    pub fn raw_snapshot(&self) -> Arc<dyn Fn() -> Value + Send + Sync> {
198        let sources = Arc::clone(&self.sources);
199        Arc::new(move || load_sources(&sources).to_value())
200    }
201
202    /// Loads the merged sources the current configuration was translated from.
203    pub(crate) fn sources(&self) -> Arc<SourceTree> {
204        load_sources(&self.sources)
205    }
206}
207
208fn load_sources(sources: &RwLock<Arc<SourceTree>>) -> Arc<SourceTree> {
209    Arc::clone(&sources.read().unwrap_or_else(PoisonError::into_inner))
210}
211
212/// Applies each configuration update from the Datadog Agent after the initial snapshot.
213///
214/// [`LoadedConfiguration::run`][crate::LoadedConfiguration::run] returns this once the system holds
215/// the initial snapshot. No later update is applied until the caller calls [`run`][Self::run].
216/// Until then, updates stay queued in the Agent configuration stream.
217#[must_use = "configuration updates from the Datadog Agent are never applied unless the updates are run"]
218pub struct ConfigurationUpdates {
219    state: Arc<Mutex<UpdateState>>,
220}
221
222impl ConfigurationUpdates {
223    /// Applies each update from the Datadog Agent until the configuration stream closes.
224    ///
225    /// Each call continues from where the previous call stopped, because the stream and the
226    /// accepted configuration persist between calls. Thus, a caller can run this again after a call
227    /// fails or panics. If a call panicked while it applied an update, that update is lost.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Error::UpdateStreamClosed`] when the configuration stream closes. The stream does
232    /// not reopen, so each later call fails in the same way at once.
233    pub fn run(&self) -> impl Future<Output = Result<()>> + Send + 'static {
234        let state = Arc::clone(&self.state);
235        async move {
236            // A panic does not poison this lock, so a later call takes over the state as the panic left it.
237            let mut state = state.lock_owned().await;
238            state.apply_updates().await
239        }
240    }
241}
242
243/// The state that [`ConfigurationUpdates`] keeps between runs.
244struct UpdateState {
245    agent_rx: mpsc::Receiver<ConfigUpdate>,
246    base: SourceTree,
247    // The accumulated Agent layer of the last accepted update.
248    agent: SourceTree,
249    current: Arc<ArcSwap<SalukiConfiguration>>,
250    sources: Arc<RwLock<Arc<SourceTree>>>,
251    tick: Arc<watch::Sender<()>>,
252}
253
254impl UpdateState {
255    /// Validates each update from the Datadog Agent config stream against the typed model and commits
256    /// it on success. Ends when the stream closes.
257    ///
258    /// Each update is processed individually (no burst collapse) so a rejection can be attributed to
259    /// the exact update that caused it. Updates are infrequent, so re-translating per update is cheap.
260    async fn apply_updates(&mut self) -> Result<()> {
261        while let Some(update) = self.agent_rx.recv().await {
262            // Validate-then-commit: fold onto a tentative copy of the Agent layer and drive the typed
263            // model from it. Only a fully successful update advances the committed layer, so a rejected
264            // value never lingers to re-poison a later merge.
265            let mut tentative = self.agent.clone();
266            fold(&mut tentative, &update);
267            let merged = self.base.overlay(&tentative);
268            match translate_authoritative(&merged) {
269                Ok(config) => {
270                    self.agent = tentative;
271                    {
272                        let mut sources = self.sources.write().unwrap_or_else(PoisonError::into_inner);
273                        *sources = Arc::new(merged);
274                        self.current.store(Arc::new(config));
275                    }
276                    self.tick.send_replace(());
277                    debug!("Applied configuration update.");
278                }
279                Err(e) => warn!(
280                    error = %e,
281                    "Rejected configuration update; keeping the last-known-good typed configuration."
282                ),
283            }
284        }
285
286        Err(Error::UpdateStreamClosed)
287    }
288}
289
290/// Folds one update into the accumulating Agent layer.
291///
292/// `Snapshot` replaces the layer; `Partial` applies one (possibly dotted) key.
293///
294/// Each setting's provenance is retained, which is what lets a later update that demotes a value to
295/// an Agent default stop shadowing the local value it had been overriding.
296fn fold(agent: &mut SourceTree, update: &ConfigUpdate) {
297    match update {
298        ConfigUpdate::Snapshot(settings) => *agent = SourceTree::from_settings(settings),
299        ConfigUpdate::Partial(setting) => agent.set(setting),
300    }
301}
302
303/// Deserializes and translates merged source values, rejecting partially translated configuration.
304///
305/// # Errors
306///
307/// Returns an error if either source model cannot be deserialized or any key fails translation.
308pub(crate) fn translate_strict(merged: &SourceTree) -> Result<SalukiConfiguration> {
309    let Sources { datadog, saluki } = deserialize_sources(&merged.to_value())?;
310    let (config, errors) = translate(&datadog, &saluki, merged);
311    if let Some(errors) = errors {
312        return Err(Error::Translate { source: errors });
313    }
314    Ok(config)
315}
316
317/// Translates merged sources that are authoritative for the running process, rejecting a
318/// configuration ADP cannot run on.
319///
320/// This is [`translate_strict`] plus [`validate`]. Use it where the merged sources are complete: the
321/// Datadog Agent's snapshot layered over the local base, or the local base alone in standalone mode.
322///
323/// # Errors
324///
325/// Returns an error if translation fails, or if the translated configuration fails validation.
326pub(crate) fn translate_authoritative(merged: &SourceTree) -> Result<SalukiConfiguration> {
327    let config = translate_strict(merged)?;
328    validate(&config)?;
329    Ok(config)
330}
331
332/// Checks the invariants a configuration must satisfy for this process to do useful work.
333///
334/// Translation alone cannot make these checks. It converts one key at a time and every schema key
335/// has a default, so a setting the operator never supplied is indistinguishable from one they did
336/// until the whole merged configuration is in hand.
337///
338/// Apply this only to an authoritative configuration. The local snapshot
339/// [`LoadedConfiguration::load`][crate::LoadedConfiguration::load] produces is incomplete by design:
340/// under the Datadog Agent the API key arrives over the configuration stream, so a local-only
341/// snapshot legitimately has none, and CLI subcommands read that snapshot without ever submitting a
342/// payload.
343///
344/// # Errors
345///
346/// Returns [`Error::MissingApiKey`] if no usable API key resolved. Every payload ADP submits is
347/// authenticated with this key, so an empty one turns each flush into a rejected request that the
348/// forwarder then retries. Failing here names the cause once instead of leaving an operator to infer
349/// it from a stream of authentication failures.
350pub(crate) fn validate(config: &SalukiConfiguration) -> Result<()> {
351    // A blank key is as unusable as an absent one, and a padded key is a typo we should name rather
352    // than send.
353    if config.shared.endpoints.api_key.trim().is_empty() {
354        return Err(Error::MissingApiKey);
355    }
356
357    Ok(())
358}
359
360// TODO: A map/array-valued schema leaf is replaced wholesale when any source (file, environment, or
361// the Agent config stream) supplies it. Verify this is the intended semantic for the remote Agent
362// config stream: ADP is that stream's first consumer, so the correct behavior for a stream update to
363// a map-shaped setting may not have been defined yet.
364
365/// The sources deserialized from the merged configuration value, separated by source authority.
366struct Sources {
367    datadog: DatadogConfiguration,
368    saluki: SalukiOnly,
369}
370
371/// Deserializes both source models from the merged configuration value.
372///
373/// The source models use ordinary serde-compatible field types, so deserializing from
374/// `serde_json::Value` preserves the values. Both read the canonical nested shape: the local base
375/// is built that way by the schema-driven environment readers, and the Datadog Agent's stream
376/// delivers dotted keys that are nested on arrival.
377fn deserialize_sources(merged: &Value) -> Result<Sources> {
378    let saluki = SalukiOnly::deserialize(merged)?;
379    let datadog = DatadogConfiguration::deserialize(merged)?;
380    Ok(Sources { datadog, saluki })
381}
382
383/// Translates the Datadog and Saluki-only sources into one [`SalukiConfiguration`], returning every
384/// error recorded while converting an individual Datadog value.
385///
386/// The Datadog `drive` feeds every supported key to a `DatadogTranslator`; a value that cannot be
387/// converted leaves its field at the model default and records an error. The Saluki-only values
388/// then seed their disjoint destinations, which cannot fail. The returned configuration is always
389/// complete: every valid value is present, and every invalid one holds its default.
390///
391/// `sources` is the same merged layer the models were deserialized from. The translator consults it
392/// for provenance, which a deserialized source model cannot supply: a schema key with a default is
393/// always present, so its value alone cannot say whether an input set it explicitly.
394fn translate(
395    datadog: &DatadogConfiguration, saluki: &SalukiOnly, sources: &SourceTree,
396) -> (SalukiConfiguration, Option<TranslateErrors>) {
397    let (mut config, errors) = DatadogTranslator::new(datadog, sources).translate();
398    saluki.seed(&mut config);
399    (config, errors)
400}
401
402#[cfg(test)]
403mod tests {
404    use std::collections::HashSet;
405    use std::sync::Arc;
406    use std::time::Duration;
407
408    use agent_data_plane_config::domains::dogstatsd::{MetricPrefixRule, OriginTagCardinality};
409    use agent_data_plane_config::shared::V3SeriesMode;
410    use agent_data_plane_config::Provenance;
411    use agent_data_plane_config::{Live, SalukiConfiguration};
412    use datadog_agent_config::{apply_datadog_env_vars, DatadogConfiguration};
413    use futures::StreamExt as _;
414    use saluki_config::dynamic::{ConfigSetting, ConfigUpdate, Provenance as StreamProvenance};
415    use serde_json::{json, Value};
416    use tokio::sync::mpsc;
417
418    use super::{
419        translate, translate_authoritative, translate_strict, ConfigurationSystem, Error, SalukiOnly, SourceTree,
420    };
421
422    /// API key the connected-system fixture puts in the local base.
423    ///
424    /// An authoritative configuration must resolve one (see [`super::validate`]), and putting it in
425    /// the base rather than in a streamed snapshot keeps it in place across the snapshot replacements
426    /// these tests exercise.
427    const TEST_API_KEY: &str = "test-api-key";
428
429    /// Builds a standalone system whose authority is the local sources.
430    ///
431    /// Translates without validating so a test can state only the setting it is exercising. The
432    /// production standalone path validates; `loaded.rs` covers that.
433    fn standalone_system(file: Value) -> Result<ConfigurationSystem, Error> {
434        let base = SourceTree::all_explicit(file);
435        let config = translate_strict(&base)?;
436        Ok(ConfigurationSystem::standalone(config, base))
437    }
438
439    /// Builds a connected system whose base is `base` and whose authority is the returned Agent
440    /// stream. The initial (empty) snapshot is queued before the system blocks on it, so the caller
441    /// gets back a stream ready for `Partial`/`Snapshot` updates.
442    ///
443    /// `base` is given an [`api_key`][TEST_API_KEY] unless it states its own, so a caller varying
444    /// some unrelated setting need not restate what validation requires.
445    async fn connected_system(mut base: Value) -> (ConfigurationSystem, mpsc::Sender<ConfigUpdate>) {
446        let (agent_tx, agent_rx) = mpsc::channel(100);
447        agent_tx.send(ConfigUpdate::snapshot([])).await.unwrap();
448        if let Some(base) = base.as_object_mut() {
449            base.entry("api_key").or_insert(json!(TEST_API_KEY));
450        }
451        let base = SourceTree::all_explicit(base);
452        let (system, updates) = ConfigurationSystem::connected(agent_rx, base)
453            .await
454            .expect("system builds");
455        tokio::spawn(updates.run());
456        (system, agent_tx)
457    }
458
459    /// Polls the current configuration until `predicate` holds, failing if it never does.
460    async fn await_config(system: &ConfigurationSystem, what: &str, predicate: impl Fn(&SalukiConfiguration) -> bool) {
461        tokio::time::timeout(Duration::from_secs(2), async {
462            while !predicate(&system.config()) {
463                tokio::time::sleep(Duration::from_millis(5)).await;
464            }
465        })
466        .await
467        .unwrap_or_else(|_| panic!("timed out waiting for {what}"));
468    }
469
470    #[tokio::test]
471    async fn startup_current_reflects_translation() {
472        let system = standalone_system(json!({ "log_level": "warn", "dogstatsd_port": 9125 })).expect("system builds");
473        let config = system.config();
474
475        assert_eq!(config.control.logging.level, "warn");
476        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
477    }
478
479    #[test]
480    fn boolean_use_v3_api_series_enabled_is_normalized() {
481        let sources = SourceTree::all_explicit(json!({
482            "use_v3_api": {
483                "series": {
484                    "enabled": true
485                }
486            }
487        }));
488
489        let config = translate_strict(&sources).expect("a boolean V3 series mode should translate");
490
491        assert_eq!(config.shared.metrics_encoding.v3_series_mode, V3SeriesMode::Enabled);
492    }
493
494    #[test]
495    fn compound_v3_series_endpoint_modes_are_rejected_at_startup() {
496        for mode in [json!(["true"]), json!({ "enabled": true })] {
497            let sources = SourceTree::all_explicit(json!({
498                "use_v3_api": { "series": { "endpoints": { "https://app.datadoghq.com": mode } } }
499            }));
500
501            assert!(translate_strict(&sources).is_err());
502        }
503    }
504
505    #[tokio::test]
506    async fn malformed_v3_endpoint_update_keeps_last_known_good_and_recovers() {
507        let (system, agent_tx) = connected_system(json!({
508            "dogstatsd_port": 9125,
509            "use_v3_api": { "series": { "endpoints": { "https://app.datadoghq.com": true } } }
510        }))
511        .await;
512
513        agent_tx
514            .send(ConfigUpdate::snapshot([
515                ConfigSetting::explicit("dogstatsd_port", json!(9999)),
516                ConfigSetting::explicit(
517                    "use_v3_api.series.endpoints",
518                    json!({ "https://app.datadoghq.com": ["false"] }),
519                ),
520            ]))
521            .await
522            .unwrap();
523        agent_tx
524            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
525                "log_level",
526                json!("error"),
527            )))
528            .await
529            .unwrap();
530        await_config(&system, "the update following the rejected snapshot", |config| {
531            config.control.logging.level == "error"
532        })
533        .await;
534
535        assert_eq!(system.config().domains.dogstatsd.listeners.port, 9125);
536        assert_eq!(
537            system.config().shared.metrics_encoding.v3_series_endpoint_modes["https://app.datadoghq.com"],
538            V3SeriesMode::Enabled
539        );
540
541        agent_tx
542            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
543                "use_v3_api.series.endpoints",
544                json!({ "https://app.datadoghq.com": false }),
545            )))
546            .await
547            .unwrap();
548        await_config(&system, "the corrected endpoint mode", |config| {
549            config.shared.metrics_encoding.v3_series_endpoint_modes["https://app.datadoghq.com"]
550                == V3SeriesMode::Disabled
551        })
552        .await;
553    }
554
555    // Regression: the Agent streams `DD_ADDITIONAL_ENDPOINTS` as the raw JSON string it stores, and the
556    // map deserializer rejected it, so the startup gate failed and ADP restarted in a loop.
557    #[tokio::test]
558    async fn connected_startup_accepts_json_encoded_map_settings() {
559        let (agent_tx, agent_rx) = mpsc::channel(1);
560        agent_tx
561            .send(ConfigUpdate::snapshot([
562                ConfigSetting::explicit(
563                    "additional_endpoints",
564                    json!(r#"{"https://app.datadoghq.com": ["second-org-key"]}"#),
565                ),
566                ConfigSetting::explicit(
567                    "use_v3_api.series.endpoints",
568                    json!(r#"{"https://app.datadoghq.com": "true"}"#),
569                ),
570            ]))
571            .await
572            .unwrap();
573        let base = SourceTree::all_explicit(json!({ "api_key": TEST_API_KEY }));
574
575        // The updates only include later updates. This test does not need them, so it never runs them.
576        let (system, _updates) = ConfigurationSystem::connected(agent_rx, base)
577            .await
578            .expect("startup accepts the streamed JSON strings");
579
580        let config = system.config();
581        assert_eq!(
582            config.shared.endpoints.additional_endpoints["https://app.datadoghq.com"],
583            ["second-org-key"]
584        );
585        assert_eq!(
586            config.shared.metrics_encoding.v3_series_endpoint_modes["https://app.datadoghq.com"],
587            V3SeriesMode::Enabled
588        );
589    }
590
591    // The Agent streams `null` for an empty list, including one produced by ordinary loading, such as
592    // `histogram_aggregates: []` in YAML. Its accessors read the null as empty, so startup must too.
593    #[tokio::test]
594    async fn connected_startup_reads_null_collections_as_empty() {
595        let (agent_tx, agent_rx) = mpsc::channel(1);
596        agent_tx
597            .send(ConfigUpdate::snapshot([
598                ConfigSetting::explicit("histogram_aggregates", Value::Null),
599                ConfigSetting::explicit("proxy.no_proxy", Value::Null),
600                ConfigSetting::explicit("additional_endpoints", Value::Null),
601            ]))
602            .await
603            .unwrap();
604        let base = SourceTree::all_explicit(json!({ "api_key": TEST_API_KEY }));
605
606        let (system, _updates) = ConfigurationSystem::connected(agent_rx, base)
607            .await
608            .expect("startup accepts streamed null collections");
609
610        let config = system.config();
611        assert!(config.shared.metrics_encoding.histogram.aggregates.is_empty());
612        assert!(config.shared.endpoints.proxy.no_proxy.is_empty());
613        assert!(config.shared.endpoints.additional_endpoints.is_empty());
614    }
615
616    // Regression: the Agent streams `DD_APM_ANALYZED_SPANS` as the raw JSON string it stores, and the
617    // map deserializers rejected it, so the startup gate failed and ADP restarted in a loop.
618    #[tokio::test]
619    async fn connected_startup_accepts_json_encoded_event_extraction_settings() {
620        let (agent_tx, agent_rx) = mpsc::channel(1);
621        agent_tx
622            .send(ConfigUpdate::snapshot([
623                ConfigSetting::explicit(
624                    "apm_config.analyzed_spans",
625                    json!(r#"{"test-service|operation": 0.75}"#),
626                ),
627                ConfigSetting::explicit(
628                    "apm_config.analyzed_rate_by_service",
629                    json!(r#"{"legacy-service": 0.5}"#),
630                ),
631            ]))
632            .await
633            .unwrap();
634        let base = SourceTree::all_explicit(json!({ "api_key": TEST_API_KEY }));
635
636        // The updates only include later updates. This test does not need them, so it never runs them.
637        let (system, _updates) = ConfigurationSystem::connected(agent_rx, base)
638            .await
639            .expect("startup accepts the streamed JSON strings");
640
641        let config = system.config();
642        assert_eq!(
643            config.domains.traces.analyzed_spans_by_service["test-service"]["operation"],
644            0.75
645        );
646        assert_eq!(config.domains.traces.analyzed_rate_by_service["legacy-service"], 0.5);
647    }
648
649    #[tokio::test]
650    async fn null_update_clears_a_collection_rather_than_restoring_its_default() {
651        let (system, agent_tx) = connected_system(json!({
652            "additional_endpoints": { "https://app.datadoghq.com": ["second-org-key"] },
653            "histogram_aggregates": ["max"]
654        }))
655        .await;
656        assert!(!system.config().shared.endpoints.additional_endpoints.is_empty());
657
658        for key in ["additional_endpoints", "histogram_aggregates"] {
659            agent_tx
660                .send(ConfigUpdate::Partial(ConfigSetting::explicit(key, Value::Null)))
661                .await
662                .unwrap();
663        }
664        await_config(&system, "the cleared collections", |config| {
665            config.shared.endpoints.additional_endpoints.is_empty()
666                && config.shared.metrics_encoding.histogram.aggregates.is_empty()
667        })
668        .await;
669    }
670
671    #[tokio::test]
672    async fn connected_stream_translates_metrics_v3_routing_configuration() {
673        let (system, agent_tx) = connected_system(json!({
674            "data_plane": {
675                "metrics": {
676                    "v3": {
677                        "series": {
678                            "enabled": true
679                        }
680                    }
681                }
682            }
683        }))
684        .await;
685
686        assert_eq!(
687            system.config().shared.metrics_encoding.v3_series_mode,
688            V3SeriesMode::DatadogOnly
689        );
690
691        agent_tx
692            .send(ConfigUpdate::snapshot([
693                ConfigSetting::explicit("serializer_compressor_kind", json!("zstd")),
694                ConfigSetting::explicit("serializer_experimental_use_v3_api.compression_level", json!(7)),
695                ConfigSetting::explicit("use_v2_api.series", json!(false)),
696                ConfigSetting::explicit("use_v3_api.series.enabled", json!("false")),
697                // The Agent sends an object-valued setting whole, and these entry keys contain dots.
698                ConfigSetting::explicit(
699                    "use_v3_api.series.endpoints",
700                    json!({ "https://app.datadoghq.com": "true" }),
701                ),
702                ConfigSetting::explicit("observability_pipelines_worker.metrics.enabled", json!(true)),
703                ConfigSetting::explicit(
704                    "observability_pipelines_worker.metrics.url",
705                    json!("https://opw.example.com"),
706                ),
707                ConfigSetting::explicit("observability_pipelines_worker.metrics.use_v3_api.series", json!(true)),
708            ]))
709            .await
710            .unwrap();
711
712        await_config(&system, "the streamed metrics V3 routing configuration", |config| {
713            config.shared.metrics_encoding.v3_series_mode == V3SeriesMode::Disabled
714                && config
715                    .shared
716                    .metrics_encoding
717                    .v3_series_endpoint_modes
718                    .get("https://app.datadoghq.com")
719                    == Some(&V3SeriesMode::Enabled)
720        })
721        .await;
722
723        let config = system.config();
724        let metrics = &config.shared.metrics_encoding;
725        assert!(!metrics.use_v2_series_api);
726        assert_eq!(metrics.v3_api.compression_level, 7);
727        let opw = &config.shared.endpoints.opw_intake;
728        assert!(opw.enabled);
729        assert_eq!(opw.url, "https://opw.example.com");
730        assert!(opw.use_v3_series);
731    }
732
733    #[tokio::test]
734    async fn raw_snapshot_reflects_streamed_updates() {
735        let (system, agent_tx) = connected_system(json!({})).await;
736        let snapshot = system.raw_snapshot();
737        let cloned = Arc::clone(&snapshot);
738
739        assert_eq!(snapshot().pointer("/dogstatsd_port"), None);
740
741        agent_tx
742            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
743                "dogstatsd_port",
744                json!(9125),
745            )))
746            .await
747            .unwrap();
748
749        tokio::time::timeout(Duration::from_secs(2), async {
750            while cloned().pointer("/dogstatsd_port") != Some(&json!(9125)) {
751                tokio::time::sleep(Duration::from_millis(5)).await;
752            }
753        })
754        .await
755        .expect("timed out waiting for the raw snapshot to reflect the update");
756    }
757
758    #[tokio::test]
759    async fn raw_snapshot_keeps_keys_the_typed_model_does_not_carry() {
760        // The raw view is for diagnosing a configuration, so it must show the unsupported keys an
761        // input supplied. They are pruned from the typed model by construction, which is why the view
762        // reads the sources rather than the model.
763        let (system, agent_tx) = connected_system(json!({ "dogstatsd_stats_buffer": 42 })).await;
764        let snapshot = system.raw_snapshot();
765
766        assert_eq!(snapshot().pointer("/dogstatsd_stats_buffer"), Some(&json!(42)));
767
768        agent_tx
769            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
770                "config_id",
771                json!("abc"),
772            )))
773            .await
774            .unwrap();
775
776        tokio::time::timeout(Duration::from_secs(2), async {
777            while snapshot().pointer("/config_id") != Some(&json!("abc")) {
778                tokio::time::sleep(Duration::from_millis(5)).await;
779            }
780        })
781        .await
782        .expect("timed out waiting for the streamed unsupported key");
783    }
784
785    #[tokio::test]
786    async fn nested_datadog_key_reaches_the_model() {
787        // Sources deliver the Agent's canonical nested shape, which is what the Datadog
788        // deserializer reads. A string list supplied as one space-separated string (the form an
789        // environment variable carries) is still split on whitespace at the leaf.
790        let system = standalone_system(json!({
791            "autoscaling": {
792                "failover": {
793                    "enabled": true,
794                    "metrics": "container.memory.usage container.cpu.usage",
795                }
796            }
797        }))
798        .expect("system builds");
799        let config = system.config();
800
801        assert!(config.shared.autoscaling_failover.enabled);
802        assert_eq!(
803            config.shared.autoscaling_failover.metrics,
804            vec!["container.memory.usage".to_string(), "container.cpu.usage".to_string()]
805        );
806    }
807
808    #[tokio::test]
809    async fn unset_autoscaling_failover_keeps_its_schema_defaults() {
810        // Nothing is set here, so both fields must come back as the schema defaults; the component
811        // layer no longer supplies fallbacks of its own.
812        let system = standalone_system(json!({})).expect("system builds");
813        let config = system.config();
814
815        assert!(!config.shared.autoscaling_failover.enabled);
816        assert_eq!(
817            config.shared.autoscaling_failover.metrics,
818            vec!["container.memory.usage".to_string(), "container.cpu.usage".to_string()]
819        );
820    }
821
822    #[tokio::test]
823    async fn nested_saluki_only_key_seeds_the_model() {
824        let system = standalone_system(json!({ "data_plane": { "standalone_mode": true } })).expect("system builds");
825
826        assert!(system.config().control.standalone_mode);
827    }
828
829    #[tokio::test]
830    async fn experimental_metric_allowlists_seed_the_model() {
831        let policies = json!({
832            "https://primary.example.com": ["allowed.metric"],
833            "https://secondary.example.com": []
834        });
835        let system = standalone_system(json!({
836            "experimental": {
837                "metrics_endpoint_routing": {
838                    "metric_allowlist": policies.clone(),
839                    "metric_prefix_allowlist": { "https://primary.example.com": ["billing."] }
840                }
841            }
842        }))
843        .expect("system builds");
844
845        assert_eq!(
846            serde_json::to_value(&system.config().domains.metrics_endpoint_routing.metric_allowlists).unwrap(),
847            policies
848        );
849        assert_eq!(
850            system
851                .config()
852                .domains
853                .metrics_endpoint_routing
854                .metric_prefix_allowlists,
855            std::collections::HashMap::from([(
856                "https://primary.example.com".to_string(),
857                vec!["billing.".to_string()]
858            )])
859        );
860    }
861
862    #[tokio::test]
863    async fn empty_experimental_sections_preserve_default_routing() {
864        for source in [
865            json!({}),
866            json!({ "experimental": {} }),
867            json!({ "experimental": { "metrics_endpoint_routing": {} } }),
868            json!({ "experimental": { "metrics_endpoint_routing": { "metric_allowlist": {} } } }),
869            json!({ "experimental": { "metrics_endpoint_routing": { "metric_prefix_allowlist": {} } } }),
870        ] {
871            let system = standalone_system(source).expect("system builds");
872            assert!(system
873                .config()
874                .domains
875                .metrics_endpoint_routing
876                .metric_allowlists
877                .is_empty());
878            assert!(system
879                .config()
880                .domains
881                .metrics_endpoint_routing
882                .metric_prefix_allowlists
883                .is_empty());
884        }
885    }
886
887    #[tokio::test]
888    async fn a_flattened_spelling_of_a_nested_key_is_not_read() {
889        // Nothing translates `autoscaling_failover_enabled` into the nested slot: resolving an
890        // environment variable to its canonical path is the environment readers' job, and they do it
891        // before a value ever reaches this point. A flattened key arriving from any other source is
892        // simply not a key the model knows.
893        let system = standalone_system(json!({ "autoscaling_failover_enabled": true })).expect("system builds");
894
895        assert!(!system.config().shared.autoscaling_failover.enabled);
896    }
897
898    #[tokio::test]
899    async fn load_fails_on_translation_invalid_startup_config() {
900        // Startup is the strict gate: a value the sources carry but the model rejects fails the load,
901        // so the process never boots on bad config.
902        let result = standalone_system(json!({ "dogstatsd_tag_cardinality": "bogus" }));
903
904        assert!(matches!(result, Err(Error::Translate { .. })));
905    }
906
907    #[tokio::test]
908    async fn negative_dogstatsd_workers_count_is_rejected_at_startup() {
909        let result = standalone_system(json!({ "dogstatsd_workers_count": -1 }));
910
911        let Err(error) = result else {
912            panic!("negative worker count should fail the startup translation gate");
913        };
914        assert!(matches!(error, Error::Translate { .. }));
915        assert!(error.to_string().contains("dogstatsd_workers_count"));
916        assert!(error.to_string().contains("greater than or equal to 0"));
917    }
918
919    #[test]
920    fn zero_otlp_trace_interner_size_is_rejected() {
921        // Component builders used to discover this after translation. Reject zero before publishing
922        // an invalid typed model.
923        let sources = SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": 0 } } }));
924        let error = translate_strict(&sources).expect_err("zero trace interner size should fail translation");
925
926        assert!(matches!(error, Error::Deserialize { .. }));
927        assert!(error.to_string().contains("value of bytes must be greater than zero"));
928    }
929
930    #[test]
931    fn oversized_otlp_trace_interner_size_is_rejected() {
932        let sources =
933            SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": "2GiB" } } }));
934        let error = translate_strict(&sources).expect_err("oversized trace interner should fail translation");
935
936        assert!(matches!(error, Error::Deserialize { .. }));
937        assert!(error.to_string().contains("must not exceed 1073741824 bytes"));
938    }
939
940    #[test]
941    fn positive_otlp_trace_interner_size_is_accepted() {
942        let sources =
943            SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": "512KiB" } } }));
944        let config = translate_strict(&sources).expect("positive trace interner size should translate");
945
946        assert_eq!(config.domains.otlp.traces.string_interner_size.get(), 512 * 1024);
947    }
948
949    #[test]
950    fn invalid_metric_tag_value_allowlist_is_rejected_before_publication() {
951        let sources = SourceTree::all_explicit(json!({
952            "metric_tag_value_allowlist": [
953                { "metric_prefix": "requests.", "tag_name": "customer_id" },
954                { "metric_prefix": "requests.api.", "tag_name": "customer_id" }
955            ]
956        }));
957        let error = translate_strict(&sources).expect_err("overlapping allow-list prefixes should fail translation");
958
959        assert!(matches!(error, Error::Deserialize { .. }));
960        assert!(error.to_string().contains("overlapping metric prefixes"));
961    }
962
963    #[tokio::test]
964    async fn standalone_loads_numeric_byte_size() {
965        // A byte-size setting documented as accepting a bare integer (`10485760`) rather than a
966        // string (`"10MB"`) must not abort the strict startup gate. The typed model normalizes it,
967        // and the translator resolves it to the same byte count.
968        let system =
969            standalone_system(json!({ "dogstatsd_log_file_max_size": 10485760 })).expect("numeric byte size boots");
970
971        assert_eq!(system.config().domains.dogstatsd.debug_log.log_file_max_size, 10485760);
972    }
973
974    /// Builds a standalone system from a configuration file value overlaid with environment variables.
975    ///
976    /// Applies the production environment reader to an explicit variable list instead of the process
977    /// environment, so the test needs no environment mutation.
978    fn standalone_system_with_env(mut file: Value, env: &[(&str, &str)]) -> Result<ConfigurationSystem, Error> {
979        let vars = env.iter().map(|(name, value)| (name.to_string(), value.to_string()));
980        apply_datadog_env_vars(&mut file, vars, true).expect("environment decodes");
981        standalone_system(file)
982    }
983
984    #[tokio::test]
985    async fn standalone_reads_log_file_max_size_from_numbers_strings_and_environment() {
986        // A YAML integer reaches the translator as its decimal string, a string keeps the Agent's unit
987        // grammar, and an environment variable arrives as the raw string. All three must land in the
988        // typed model as an explicit value.
989        let cases = [
990            (
991                "number",
992                standalone_system(json!({ "log_file_max_size": 10_485_760 })),
993                10_485_760,
994            ),
995            (
996                "string",
997                standalone_system(json!({ "log_file_max_size": "10MB" })),
998                10_485_760,
999            ),
1000            (
1001                "environment",
1002                standalone_system_with_env(json!({}), &[("DD_LOG_FILE_MAX_SIZE", "0x10KB")]),
1003                16_384,
1004            ),
1005        ];
1006        for (source, system, expected) in cases {
1007            let system = system.unwrap_or_else(|error| panic!("{source} boots: {error}"));
1008            let file_max_size = &system.config().control.logging.file_max_size;
1009            assert!(file_max_size.is_explicit(), "{source}");
1010            assert_eq!(file_max_size.value, expected, "{source}");
1011        }
1012
1013        // An environment variable overrides the file value, as it does for every other key.
1014        let system = standalone_system_with_env(
1015            json!({ "dogstatsd_log_file_max_size": "1KB" }),
1016            &[("DD_DOGSTATSD_LOG_FILE_MAX_SIZE", "5KB")],
1017        )
1018        .expect("environment size boots");
1019        assert_eq!(system.config().domains.dogstatsd.debug_log.log_file_max_size, 5_120);
1020    }
1021
1022    #[test]
1023    fn a_configuration_without_an_api_key_is_rejected() {
1024        // Nothing ADP submits is accepted without a key, so the authoritative gate names the cause
1025        // rather than letting every flush fail authentication. Translation alone accepts this: the
1026        // schema default for `api_key` is the empty string, so nothing is missing to translate.
1027        let sources = SourceTree::all_explicit(json!({}));
1028        translate_strict(&sources).expect("an absent API key still translates");
1029
1030        let error = translate_authoritative(&sources).expect_err("an absent API key should fail the gate");
1031
1032        assert!(matches!(error, Error::MissingApiKey));
1033        assert!(error.to_string().contains("api_key"));
1034    }
1035
1036    #[test]
1037    fn a_blank_api_key_is_rejected() {
1038        // An explicitly blank key is as unusable as an absent one, and whitespace is a typo worth
1039        // reporting rather than submitting.
1040        for key in ["", "   ", "\t\n"] {
1041            let sources = SourceTree::all_explicit(json!({ "api_key": key }));
1042
1043            assert!(
1044                matches!(translate_authoritative(&sources), Err(Error::MissingApiKey)),
1045                "{key:?} should not count as an API key"
1046            );
1047        }
1048
1049        let sources = SourceTree::all_explicit(json!({ "api_key": TEST_API_KEY }));
1050        assert_eq!(
1051            TEST_API_KEY,
1052            translate_authoritative(&sources)
1053                .expect("a real key passes the gate")
1054                .shared
1055                .endpoints
1056                .api_key
1057        );
1058    }
1059
1060    #[tokio::test]
1061    async fn an_update_that_blanks_the_api_key_is_rejected_keeping_last_known_good() {
1062        // Validation covers runtime updates too: an update that leaves the process unable to submit
1063        // anything is rejected like any other invalid one, and the working key stays in place.
1064        let (system, agent_tx) = connected_system(json!({ "log_level": "warn" })).await;
1065        assert_eq!(TEST_API_KEY, system.config().shared.endpoints.api_key);
1066
1067        agent_tx
1068            .send(ConfigUpdate::Partial(ConfigSetting::explicit("api_key", json!(""))))
1069            .await
1070            .unwrap();
1071        agent_tx
1072            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1073                "log_level",
1074                json!("error"),
1075            )))
1076            .await
1077            .unwrap();
1078
1079        await_config(&system, "the later valid update to take effect", |c| {
1080            c.control.logging.level == "error"
1081        })
1082        .await;
1083        assert_eq!(TEST_API_KEY, system.config().shared.endpoints.api_key);
1084    }
1085
1086    #[tokio::test]
1087    async fn standalone_loads_scalars_written_in_any_form_the_agent_casts() {
1088        // The Agent reads a setting by casting whatever its configuration holds to the accessor's
1089        // type, so a boolean written where the schema declares a string, or a quoted integer, is a
1090        // configuration it accepts. Each must reach the typed model instead of aborting the strict
1091        // startup gate.
1092        let system = standalone_system(json!({
1093            "use_v3_api": { "series": { "enabled": true } },
1094            "dogstatsd_port": "8126",
1095        }))
1096        .expect("scalars in Agent-castable forms boot");
1097
1098        assert_eq!(
1099            system.config().shared.metrics_encoding.v3_series_mode,
1100            V3SeriesMode::Enabled
1101        );
1102        assert_eq!(system.config().domains.dogstatsd.listeners.port, 8126);
1103    }
1104
1105    #[tokio::test]
1106    async fn translation_invalid_update_is_rejected_keeping_last_known_good() {
1107        let (system, agent_tx) =
1108            connected_system(json!({ "log_level": "warn", "dogstatsd_tag_cardinality": "high" })).await;
1109        assert_eq!(
1110            system.config().domains.dogstatsd.origin.tag_cardinality,
1111            OriginTagCardinality::High
1112        );
1113
1114        // Send a translation-invalid update, then a valid update to a different field. Updates are
1115        // processed in order, so once the second is observed the first has already been handled.
1116        agent_tx
1117            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1118                "dogstatsd_tag_cardinality",
1119                json!("bogus"),
1120            )))
1121            .await
1122            .unwrap();
1123        agent_tx
1124            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1125                "log_level",
1126                json!("error"),
1127            )))
1128            .await
1129            .unwrap();
1130
1131        await_config(&system, "the later valid update to take effect", |c| {
1132            c.control.logging.level == "error"
1133        })
1134        .await;
1135        // The invalid update was rejected whole: the field keeps its last-known-good value rather
1136        // than falling back to a default, and the later valid update still applied.
1137        assert_eq!(
1138            system.config().domains.dogstatsd.origin.tag_cardinality,
1139            OriginTagCardinality::High
1140        );
1141    }
1142
1143    #[tokio::test]
1144    async fn invalid_metric_tag_value_allowlist_update_keeps_last_known_good() {
1145        let initial_allowlist = json!([{
1146            "metric_prefix": "requests.",
1147            "tag_name": "customer_id",
1148            "values": ["customer-1"]
1149        }]);
1150        let (system, agent_tx) = connected_system(json!({
1151            "log_level": "warn",
1152            "metric_tag_value_allowlist": initial_allowlist
1153        }))
1154        .await;
1155
1156        agent_tx
1157            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1158                "metric_tag_value_allowlist",
1159                json!([
1160                    { "metric_prefix": "requests.", "tag_name": "customer_id" },
1161                    { "metric_prefix": "requests.api.", "tag_name": "customer_id" }
1162                ]),
1163            )))
1164            .await
1165            .unwrap();
1166        agent_tx
1167            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1168                "log_level",
1169                json!("error"),
1170            )))
1171            .await
1172            .unwrap();
1173
1174        await_config(&system, "the later valid update to take effect", |config| {
1175            config.control.logging.level == "error"
1176        })
1177        .await;
1178        let config = system.config();
1179        assert_eq!(config.domains.dogstatsd.tag_value_allowlist.len(), 1);
1180        assert_eq!(
1181            config.domains.dogstatsd.tag_value_allowlist[0].metric_prefix,
1182            "requests."
1183        );
1184        assert_eq!(config.domains.dogstatsd.tag_value_allowlist[0].values, ["customer-1"]);
1185    }
1186
1187    #[tokio::test]
1188    async fn converges_to_latest_value_under_burst() {
1189        let (system, agent_tx) = connected_system(json!({ "log_level": "info" })).await;
1190
1191        let burst = [
1192            "warn", "error", "debug", "trace", "info", "warn", "error", "debug", "trace", "info", "warn", "error",
1193            "debug", "trace", "info", "warn", "error", "debug", "trace",
1194        ];
1195        for (i, level) in burst.iter().enumerate() {
1196            agent_tx
1197                .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1198                    "log_level",
1199                    json!(level),
1200                )))
1201                .await
1202                .unwrap();
1203            // Interleave a translation-invalid update mid-burst, then correct it. The invalid value
1204            // is rejected whole (last-known-good retained) rather than wedging the task, so the
1205            // baseline keeps converging on the latest valid value regardless of the transient bad one.
1206            if i == burst.len() / 2 {
1207                agent_tx
1208                    .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1209                        "dogstatsd_tag_cardinality",
1210                        json!("bogus"),
1211                    )))
1212                    .await
1213                    .unwrap();
1214                agent_tx
1215                    .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1216                        "dogstatsd_tag_cardinality",
1217                        json!("high"),
1218                    )))
1219                    .await
1220                    .unwrap();
1221            }
1222        }
1223        let final_level = "error";
1224        agent_tx
1225            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1226                "log_level",
1227                json!(final_level),
1228            )))
1229            .await
1230            .unwrap();
1231
1232        await_config(
1233            &system,
1234            "the current configuration to converge to the final value",
1235            |c| c.control.logging.level == final_level,
1236        )
1237        .await;
1238        assert_eq!(system.config().control.logging.level, final_level);
1239    }
1240
1241    // Issue #1965: the Core Agent streams every setting it knows about, including the ones nobody
1242    // configured, so its schema defaults must not overwrite the local file.
1243    #[tokio::test]
1244    async fn a_defaulted_agent_value_does_not_erase_a_local_one() {
1245        let (system, agent_tx) = connected_system(json!({ "dd_url": "https://vector.example.com" })).await;
1246
1247        agent_tx
1248            .send(ConfigUpdate::snapshot([ConfigSetting::new(
1249                "dd_url",
1250                json!("https://app.datadoghq.com"),
1251                StreamProvenance::Default,
1252            )]))
1253            .await
1254            .unwrap();
1255        // A later unrelated update is observable, so once it lands the snapshot above has been handled.
1256        agent_tx
1257            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1258                "log_level",
1259                json!("error"),
1260            )))
1261            .await
1262            .unwrap();
1263        await_config(&system, "the trailing update to take effect", |c| {
1264            c.control.logging.level == "error"
1265        })
1266        .await;
1267
1268        let dd_url = &system.config().shared.endpoints.dd_url;
1269        assert!(dd_url.is_explicit());
1270        assert_eq!(dd_url.value, "https://vector.example.com");
1271    }
1272
1273    #[tokio::test]
1274    async fn demoting_an_agent_value_to_a_default_reveals_the_local_value() {
1275        let (system, agent_tx) = connected_system(json!({ "dd_url": "https://vector.example.com" })).await;
1276
1277        // The Agent takes over the setting.
1278        agent_tx
1279            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1280                "dd_url",
1281                json!("https://app.datadoghq.eu"),
1282            )))
1283            .await
1284            .unwrap();
1285        await_config(&system, "the Agent override to take effect", |c| {
1286            c.shared.endpoints.dd_url.value == "https://app.datadoghq.eu"
1287        })
1288        .await;
1289
1290        // The operator removes it from the Agent's configuration, so the Agent now reports its own
1291        // default. The Agent layer must stop shadowing the local value rather than pinning the value
1292        // it last held.
1293        agent_tx
1294            .send(ConfigUpdate::Partial(ConfigSetting::new(
1295                "dd_url",
1296                json!("https://app.datadoghq.com"),
1297                StreamProvenance::Default,
1298            )))
1299            .await
1300            .unwrap();
1301
1302        await_config(&system, "the local value to be revealed again", |c| {
1303            c.shared.endpoints.dd_url.value == "https://vector.example.com"
1304        })
1305        .await;
1306        assert!(system.config().shared.endpoints.dd_url.is_explicit());
1307    }
1308
1309    #[tokio::test]
1310    async fn the_retained_sources_follow_accepted_updates_only() {
1311        // The retained sources are the by-key view of the same configuration `current` holds, so an
1312        // update the typed path rejected must not appear in them. `check_compatibility` is the reader
1313        // that shows it: it classifies keys the typed model does not carry.
1314        let (system, agent_tx) = connected_system(json!({})).await;
1315        let all_pipelines = HashSet::new();
1316        system
1317            .check_compatibility(&all_pipelines)
1318            .expect("nothing unsupported is set yet");
1319
1320        // The cardinality value fails translation, so the whole snapshot is rejected and the
1321        // unsupported key it also carries never reaches the sources.
1322        agent_tx
1323            .send(ConfigUpdate::snapshot([
1324                ConfigSetting::explicit("heroku_dyno", json!(true)),
1325                ConfigSetting::explicit("dogstatsd_tag_cardinality", json!("bogus")),
1326            ]))
1327            .await
1328            .unwrap();
1329        agent_tx
1330            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1331                "log_level",
1332                json!("error"),
1333            )))
1334            .await
1335            .unwrap();
1336        await_config(&system, "the update following the rejected snapshot", |config| {
1337            config.control.logging.level == "error"
1338        })
1339        .await;
1340        system
1341            .check_compatibility(&all_pipelines)
1342            .expect("a rejected update does not reach the sources");
1343
1344        agent_tx
1345            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1346                "heroku_dyno",
1347                json!(true),
1348            )))
1349            .await
1350            .unwrap();
1351        agent_tx
1352            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1353                "log_level",
1354                json!("warn"),
1355            )))
1356            .await
1357            .unwrap();
1358        await_config(&system, "the accepted unsupported key", |config| {
1359            config.control.logging.level == "warn"
1360        })
1361        .await;
1362        system
1363            .check_compatibility(&all_pipelines)
1364            .expect_err("an accepted update reaches the sources");
1365    }
1366
1367    #[tokio::test]
1368    async fn a_snapshot_replaces_the_agent_layer() {
1369        let (system, agent_tx) = connected_system(json!({})).await;
1370
1371        agent_tx
1372            .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
1373                "dogstatsd_port",
1374                json!(9125),
1375            )]))
1376            .await
1377            .unwrap();
1378        await_config(&system, "the first snapshot to take effect", |c| {
1379            c.domains.dogstatsd.listeners.port == 9125
1380        })
1381        .await;
1382
1383        // A snapshot is the producer's complete state, so a setting it omits is no longer set and the
1384        // port returns to the schema default rather than lingering at 9125.
1385        agent_tx
1386            .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
1387                "log_level",
1388                json!("error"),
1389            )]))
1390            .await
1391            .unwrap();
1392
1393        await_config(&system, "the replacing snapshot to take effect", |c| {
1394            c.control.logging.level == "error"
1395        })
1396        .await;
1397        assert_eq!(system.config().domains.dogstatsd.listeners.port, 8125);
1398    }
1399
1400    #[tokio::test]
1401    async fn a_live_view_wakes_when_only_provenance_changes() {
1402        let (system, agent_tx) = connected_system(json!({})).await;
1403        let mut dd_url = system.live(|c| &c.shared.endpoints.dd_url);
1404        // Nothing has set the URL, so it is the schema default the Agent supplies.
1405        assert_eq!(dd_url.provenance, Provenance::Default);
1406        assert_eq!(dd_url.value, "https://app.datadoghq.com");
1407
1408        // The same URL, now deliberately chosen. The value is unchanged, so only provenance can carry
1409        // the fact that it became an override.
1410        agent_tx
1411            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1412                "dd_url",
1413                json!("https://app.datadoghq.com"),
1414            )))
1415            .await
1416            .unwrap();
1417
1418        let updated = tokio::time::timeout(Duration::from_secs(2), dd_url.changed())
1419            .await
1420            .expect("the view observes a provenance-only change");
1421        assert_eq!(updated.provenance, Provenance::Explicit);
1422        assert_eq!(updated.value, "https://app.datadoghq.com");
1423    }
1424
1425    #[tokio::test]
1426    async fn live_view_observes_debug_log_update() {
1427        let (system, agent_tx) = connected_system(json!({ "dogstatsd_metrics_stats_enable": false })).await;
1428        let mut view = system.live(|c| &c.domains.dogstatsd.debug_log);
1429        assert!(!view.metrics_stats_enable);
1430
1431        agent_tx
1432            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1433                "dogstatsd_metrics_stats_enable",
1434                json!(true),
1435            )))
1436            .await
1437            .unwrap();
1438
1439        let updated = tokio::time::timeout(Duration::from_secs(2), view.changed())
1440            .await
1441            .expect("view observes the debug-log update");
1442        assert!(updated.metrics_stats_enable);
1443        // `Deref` reflects the value returned by the last `changed`.
1444        assert!(view.metrics_stats_enable);
1445    }
1446
1447    #[tokio::test]
1448    async fn field_view_wakes_on_its_field() {
1449        // Projecting straight to a single field needs no schema change and no central registration:
1450        // the granularity is chosen at the call site.
1451        let (system, agent_tx) = connected_system(json!({ "dogstatsd_metrics_stats_enable": false })).await;
1452        let mut stats = system.live(|c| &c.domains.dogstatsd.debug_log.metrics_stats_enable);
1453        assert!(!*stats);
1454
1455        agent_tx
1456            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1457                "dogstatsd_metrics_stats_enable",
1458                json!(true),
1459            )))
1460            .await
1461            .unwrap();
1462
1463        let updated = tokio::time::timeout(Duration::from_secs(2), stats.changed())
1464            .await
1465            .expect("field view observes its field's update");
1466        assert!(updated);
1467        assert!(*stats);
1468    }
1469
1470    #[tokio::test]
1471    async fn live_metric_filter_follows_current_and_legacy_precedence() {
1472        // Regression: clearing `metric_filterlist` must restore the legacy list and match mode.
1473        let (system, agent_tx) = connected_system(json!({})).await;
1474        let mut metric_filter = system.live(|c| &c.domains.dogstatsd.metric_filter);
1475        assert!(metric_filter.values.is_empty());
1476
1477        agent_tx
1478            .send(ConfigUpdate::snapshot([
1479                ConfigSetting::explicit("metric_filterlist", json!(["current.duration.max"])),
1480                ConfigSetting::explicit("metric_filterlist_match_prefix", json!(false)),
1481                ConfigSetting::explicit("statsd_metric_blocklist", json!(["legacy.duration"])),
1482                ConfigSetting::explicit("statsd_metric_blocklist_match_prefix", json!(true)),
1483            ]))
1484            .await
1485            .unwrap();
1486
1487        let current = tokio::time::timeout(Duration::from_secs(2), metric_filter.changed())
1488            .await
1489            .expect("view observes the current filterlist taking precedence");
1490        assert_eq!(current.values, vec!["current.duration.max".to_string()]);
1491        assert!(!current.match_prefix);
1492
1493        agent_tx
1494            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1495                "metric_filterlist",
1496                json!([]),
1497            )))
1498            .await
1499            .unwrap();
1500
1501        let legacy = tokio::time::timeout(Duration::from_secs(2), metric_filter.changed())
1502            .await
1503            .expect("view observes the fallback to the legacy blocklist");
1504        assert_eq!(legacy.values, vec!["legacy.duration".to_string()]);
1505        assert!(legacy.match_prefix);
1506
1507        agent_tx
1508            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1509                "metric_filterlist",
1510                json!(["current.duration.avg"]),
1511            )))
1512            .await
1513            .unwrap();
1514
1515        let restored = tokio::time::timeout(Duration::from_secs(2), metric_filter.changed())
1516            .await
1517            .expect("view observes the current filterlist shadowing the legacy blocklist again");
1518        assert_eq!(restored.values, vec!["current.duration.avg".to_string()]);
1519        assert!(!restored.match_prefix);
1520    }
1521
1522    #[tokio::test]
1523    async fn live_metric_filter_tracks_prefix_rules() {
1524        let (system, agent_tx) = connected_system(json!({})).await;
1525        let mut metric_filter = system.live(|c| &c.domains.dogstatsd.metric_filter);
1526        assert!(metric_filter.prefix_rules.is_empty());
1527
1528        agent_tx
1529            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1530                "metric_filterlist_prefix",
1531                json!([
1532                    {
1533                        "prefix": "service.",
1534                        "except_prefix": ["service.keep."],
1535                        "except_exact": ["service.keep.exact"],
1536                    }
1537                ]),
1538            )))
1539            .await
1540            .unwrap();
1541
1542        let updated = tokio::time::timeout(Duration::from_secs(2), metric_filter.changed())
1543            .await
1544            .expect("view observes metric_filterlist_prefix updates");
1545        assert_eq!(
1546            updated.prefix_rules,
1547            vec![MetricPrefixRule {
1548                prefix: "service.".to_string(),
1549                except_prefix: vec!["service.keep.".to_string()],
1550                except_exact: vec!["service.keep.exact".to_string()],
1551            }]
1552        );
1553    }
1554
1555    #[tokio::test]
1556    async fn fixed_view_never_changes() {
1557        let mut view: Live<bool> = Live::new_fixed(true);
1558        assert!(*view);
1559        // A fixed view never resolves, so this bound is deterministic rather than timing-dependent.
1560        assert!(tokio::time::timeout(Duration::from_millis(100), view.changed())
1561            .await
1562            .is_err());
1563    }
1564
1565    #[tokio::test]
1566    async fn live_stream_yields_each_change() {
1567        let (system, agent_tx) = connected_system(json!({})).await;
1568        let mut levels = Box::pin(system.live(|c| &c.control.logging.level).into_stream());
1569
1570        for level in ["debug", "warn"] {
1571            agent_tx
1572                .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1573                    "log_level",
1574                    json!(level),
1575                )))
1576                .await
1577                .unwrap();
1578
1579            let updated = tokio::time::timeout(Duration::from_secs(2), levels.next())
1580                .await
1581                .expect("the stream observes the log level update");
1582            assert_eq!(updated.as_deref(), Some(level));
1583        }
1584    }
1585
1586    #[tokio::test]
1587    async fn fixed_live_stream_never_yields_or_ends() {
1588        let mut values = Box::pin(Live::new_fixed(true).into_stream());
1589        // Like `changed`, the stream of a fixed view never resolves, so this bound is deterministic.
1590        assert!(tokio::time::timeout(Duration::from_millis(100), values.next())
1591            .await
1592            .is_err());
1593    }
1594
1595    #[tokio::test]
1596    async fn live_views_reflect_startup_configuration() {
1597        let system = standalone_system(json!({ "dogstatsd_metrics_stats_enable": true })).expect("system builds");
1598        let config = system.config();
1599
1600        let debug_log = system.live(|c| &c.domains.dogstatsd.debug_log);
1601        assert_eq!(&*debug_log, &config.domains.dogstatsd.debug_log);
1602
1603        let prefix_filter = system.live(|c| &c.domains.dogstatsd.prefix_filter);
1604        assert_eq!(&*prefix_filter, &config.domains.dogstatsd.prefix_filter);
1605
1606        let multi_region_failover = system.live(|c| &c.domains.multi_region_failover);
1607        assert_eq!(&*multi_region_failover, &config.domains.multi_region_failover);
1608    }
1609
1610    #[test]
1611    fn translate_small_map_through_witness_and_seed() {
1612        // A small raw source map exercising a scalar conversion, an enum parse, a duration parse, the
1613        // raw endpoint inputs, and one seeded Saluki-only field.
1614        let sources = SourceTree::all_explicit(json!({
1615            "api_key": "abc",
1616            "dd_url": "https://custom.example.com",
1617            "dogstatsd_port": 9125,
1618            "dogstatsd_tag_cardinality": "high",
1619            "expected_tags_duration": "15s",
1620            "provider_kind": "gke-autopilot",
1621            "eks_fargate": true,
1622            "kubernetes_kubelet_nodename": "fargate-node",
1623            "cluster_name": "fargate-cluster",
1624            "telemetry": { "dogstatsd_origin": true },
1625            "dogstatsd_tcp_port": 8126,
1626        }));
1627        let value = sources.to_value();
1628        let datadog: DatadogConfiguration = serde_json::from_value(value.clone()).expect("datadog source deserializes");
1629        let saluki: SalukiOnly = serde_json::from_value(value).expect("saluki-only source deserializes");
1630
1631        let (config, errors) = translate(&datadog, &saluki, &sources);
1632        assert!(errors.is_none(), "translation of a valid map records no error");
1633
1634        // Driven scalar conversion: i64 -> u16.
1635        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
1636        // Driven enum parse.
1637        assert_eq!(
1638            config.domains.dogstatsd.origin.tag_cardinality,
1639            OriginTagCardinality::High
1640        );
1641        // Driven `format: duration` parse: a Go duration string becomes a `Duration`.
1642        assert_eq!(config.shared.tags.expected_tags_duration, Duration::from_secs(15));
1643        // Shared deployment inputs used to derive static tags for both DogStatsD and OTLP.
1644        assert_eq!(config.shared.static_tags.provider_kind, "gke-autopilot");
1645        assert!(config.shared.static_tags.eks_fargate);
1646        assert_eq!(config.shared.static_tags.kubernetes_kubelet_nodename, "fargate-node");
1647        assert_eq!(config.shared.static_tags.cluster_name, "fargate-cluster");
1648        // Driven bool in a nested Datadog section.
1649        assert!(config.domains.dogstatsd.telemetry.origin_breakdown);
1650        // Raw endpoint inputs: carried through without selecting a primary endpoint here.
1651        assert_eq!(config.shared.endpoints.api_key, "abc");
1652        assert!(config.shared.endpoints.dd_url.is_explicit());
1653        assert_eq!(config.shared.endpoints.dd_url.value, "https://custom.example.com");
1654        // Seeded Saluki-only field.
1655        assert_eq!(config.domains.dogstatsd.listeners.tcp_port, 8126);
1656    }
1657
1658    /// Datadog-defined aggregation keys reach their typed model fields through the witness translator.
1659    #[test]
1660    fn datadog_aggregation_keys_reach_the_model() {
1661        let sources = SourceTree::all_explicit(json!({
1662            "dogstatsd_expiry_seconds": 60,
1663            "dogstatsd_flush_incomplete_buckets": true,
1664        }));
1665        let value = sources.to_value();
1666        let datadog: DatadogConfiguration = serde_json::from_value(value.clone()).expect("datadog source deserializes");
1667        let saluki: SalukiOnly = serde_json::from_value(value).expect("saluki-only source deserializes");
1668
1669        let (config, errors) = translate(&datadog, &saluki, &sources);
1670        assert!(errors.is_none(), "translation of a valid map records no error");
1671
1672        let aggregation = &config.domains.dogstatsd.aggregation;
1673        assert_eq!(aggregation.counter_expiry_seconds, Some(60));
1674        assert!(aggregation.flush_open_windows);
1675    }
1676}