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::collections::HashSet;
5use std::sync::{Arc, OnceLock};
6
7use agent_data_plane_config::{Live, SalukiConfiguration};
8use arc_swap::ArcSwap;
9use datadog_agent_config::{apply_env_overlay, DatadogConfiguration, EnvOverlayMode, TranslateErrors};
10use saluki_config::dynamic::ConfigUpdate;
11use saluki_config::{upsert, ConfigurationError, GenericConfiguration};
12use serde::Deserialize;
13use serde_json::Value;
14use snafu::Snafu;
15use tokio::sync::{mpsc, watch};
16use tracing::{debug, warn};
17
18use crate::saluki_env_overlay;
19use crate::saluki_only::SalukiOnly;
20use crate::translators::DatadogTranslator;
21
22/// An error building the translated configuration from the raw sources.
23#[derive(Debug, Snafu)]
24pub enum Error {
25    /// The configuration value could not be read from the raw configuration map.
26    #[snafu(context(false), display("{source}"))]
27    Source {
28        /// The underlying configuration error.
29        source: ConfigurationError,
30    },
31
32    /// A source model could not be deserialized from the merged configuration value.
33    #[snafu(context(false), display("{source}"))]
34    Deserialize {
35        /// The underlying deserialization error.
36        source: serde_json::Error,
37    },
38
39    /// The Datadog Agent closed the configuration stream before sending the initial snapshot.
40    #[snafu(display("configuration stream closed before the initial snapshot"))]
41    StreamClosed,
42
43    /// The typed base could not be built from the file and environment.
44    #[snafu(display("failed to build the configuration base: {message}"))]
45    Base {
46        /// What went wrong reading the file, parsing YAML, or decoding an environment variable.
47        message: String,
48    },
49
50    /// Translating the sources into the model failed on one or more keys.
51    #[snafu(display("{source}"))]
52    Translate {
53        /// Every translation error recorded.
54        source: TranslateErrors,
55    },
56}
57
58type Result<T> = std::result::Result<T, Error>;
59
60/// The runtime configuration, translated from the raw sources and kept current.
61///
62/// The configuration system is the single owner of the Datadog Agent's `ConfigUpdate` stream. It
63/// folds each update onto the local source base to build the typed [`SalukiConfiguration`] directly,
64/// and forwards the same update to a legacy [`GenericConfiguration`] compatibility map so
65/// un-migrated components can still read by key. The current configuration lives in an [`ArcSwap`]
66/// cell so readers load a whole, self-consistent version with no lock, while the update task
67/// replaces it in one atomic store.
68pub struct ConfigurationSystem {
69    raw_map: GenericConfiguration,
70    current: Arc<ArcSwap<SalukiConfiguration>>,
71    // Fired once after each accepted update so live views wake and re-project. Shared with the
72    // update task via `Arc` because `watch::Sender` is not `Clone` and both the system (to mint
73    // views) and the task (to notify) need it.
74    tick: Arc<watch::Sender<()>>,
75}
76
77impl ConfigurationSystem {
78    /// Connected authority: takes ownership of the Datadog Agent's config stream, forwards each
79    /// update to the compatibility map, and builds the typed model directly from the stream folded
80    /// onto the local `base` (file + environment).
81    ///
82    /// Blocks for the first authoritative snapshot and is the strict startup gate: a snapshot that
83    /// never arrives, cannot be deserialized, or fails translation aborts the boot. `async` because
84    /// the update task requires a Tokio runtime; keeping that requirement visible here avoids a
85    /// panic deep inside `tokio::spawn`.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the stream closes before the first snapshot, or the initial configuration
90    /// cannot be deserialized or translated.
91    pub(crate) async fn connected(
92        mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>,
93        compat_map: GenericConfiguration, base: Value, overlay: EnvOverlayMode,
94    ) -> Result<Self> {
95        // The first stream message is the authoritative initial snapshot.
96        let first = agent_rx.recv().await.ok_or(Error::StreamClosed)?;
97
98        // Fold it into the accumulating Agent layer and forward it to the compat map, then wait for
99        // the compat map to apply it so `raw_map()` is populated before any consumer reads it.
100        let mut agent = Value::Null;
101        fold(&mut agent, &first);
102        forward(&compat_tx, first).await;
103        compat_map.ready().await;
104
105        // Startup is the strict gate: this is the first, authoritative Agent snapshot, so any error
106        // fails the boot and we never run on bad config. At runtime (see `agent_loop`) the same
107        // check instead rejects the offending update and keeps the last-known-good configuration,
108        // because a runtime update must never take the system down.
109        let merged = deep_merge(base.clone(), agent.clone());
110        let config = translate_strict(&merged, overlay)?;
111
112        let current = Arc::new(ArcSwap::from_pointee(config));
113        // The initial receiver is dropped immediately; `send_replace` works with zero receivers, and
114        // each live view subscribes its own receiver from the sender.
115        let (tick, _) = watch::channel(());
116        let tick = Arc::new(tick);
117
118        tokio::spawn(agent_loop(
119            agent_rx,
120            compat_tx,
121            base,
122            agent,
123            Arc::clone(&current),
124            Arc::clone(&tick),
125            overlay,
126        ));
127
128        Ok(Self {
129            raw_map: compat_map,
130            current,
131            tick,
132        })
133    }
134
135    /// Installs a static configuration without an update task.
136    ///
137    /// Live views retain their initial values because this system sends no update notifications.
138    pub(crate) fn standalone(compat_map: GenericConfiguration, config: SalukiConfiguration) -> Self {
139        let current = Arc::new(ArcSwap::from_pointee(config));
140        let (tick, _) = watch::channel(());
141        Self {
142            raw_map: compat_map,
143            current,
144            tick: Arc::new(tick),
145        }
146    }
147
148    /// Returns a live view of the given projection of the current configuration. Narrow further with
149    /// [`Live::project`]. This is the only way a consumer subscribes to runtime updates.
150    pub fn live<T>(&self, project: impl for<'a> Fn(&'a SalukiConfiguration) -> &'a T + Send + Sync + 'static) -> Live<T>
151    where
152        T: Clone + PartialEq + 'static,
153    {
154        Live::new_dynamic(Arc::clone(&self.current), self.tick.subscribe(), project)
155    }
156
157    /// Loads the current translated configuration.
158    ///
159    /// The returned guard pins one whole version; a concurrent refresh never tears the read.
160    pub fn config(&self) -> arc_swap::Guard<Arc<SalukiConfiguration>> {
161        self.current.load()
162    }
163
164    /// Returns a shared handle to the current-configuration cell for readers that load it
165    /// independently.
166    pub fn current_handle(&self) -> Arc<ArcSwap<SalukiConfiguration>> {
167        Arc::clone(&self.current)
168    }
169
170    /// Returns the raw source map for consumers that read configuration by key.
171    pub fn raw_map(&self) -> GenericConfiguration {
172        self.raw_map.clone()
173    }
174}
175
176/// Owns the Datadog Agent config stream for the life of the process: validates each update against
177/// the typed model, commits it on success, and forwards it to the by-key configuration view. Ends
178/// when the stream closes.
179///
180/// Each update is processed individually (no burst collapse) so a rejection can be attributed to the
181/// exact update that caused it. Updates are infrequent, so re-translating per update is cheap.
182async fn agent_loop(
183    mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>, base: Value, mut agent: Value,
184    current: Arc<ArcSwap<SalukiConfiguration>>, tick: Arc<watch::Sender<()>>, overlay: EnvOverlayMode,
185) {
186    while let Some(update) = agent_rx.recv().await {
187        // Validate-then-commit: fold onto a tentative copy of the Agent layer and drive the typed
188        // model from it. Only a fully successful update advances the committed layer, so a rejected
189        // value never lingers to re-poison a later merge.
190        let mut tentative = agent.clone();
191        fold(&mut tentative, &update);
192        let merged = deep_merge(base.clone(), tentative.clone());
193        match translate_strict(&merged, overlay) {
194            Ok(config) => {
195                agent = tentative;
196                current.store(Arc::new(config));
197                tick.send_replace(());
198                debug!("Applied configuration update.");
199            }
200            Err(e) => warn!(
201                error = %e,
202                "Rejected configuration update; keeping the last-known-good typed configuration. The \
203                 compatibility map still receives this update, so the two representations may now \
204                 differ, so that divergence indicates a defect in the typed configuration system, not \
205                 in the configuration itself."
206            ),
207        }
208        // The compatibility map receives every update faithfully, whether or not the typed path
209        // accepted it: un-migrated components keep the Agent's permissive behavior during migration.
210        // The updater owns the receiver; if it is gone, no un-migrated component is reading the
211        // by-key view, so dropping the forward is fine.
212        forward(&compat_tx, update).await;
213    }
214}
215
216/// Folds one update into the accumulating Agent layer.
217///
218/// `Snapshot` replaces the layer; `Partial` applies one (possibly dotted) key via `upsert`, the same
219/// handling the `saluki-config` updater uses, so the typed model and the compatibility map stay
220/// consistent.
221fn fold(agent: &mut Value, update: &ConfigUpdate) {
222    match update {
223        ConfigUpdate::Snapshot(state) => *agent = state.clone(),
224        ConfigUpdate::Partial { key, value } => {
225            if agent.is_null() {
226                *agent = Value::Object(serde_json::Map::new());
227            }
228            upsert(agent, key, value.clone());
229        }
230    }
231}
232
233/// Forwards one update to the compatibility map's updater.
234async fn forward(compat_tx: &mpsc::Sender<ConfigUpdate>, update: ConfigUpdate) {
235    let _ = compat_tx.send(update).await;
236}
237
238/// Deserializes and translates merged source values, rejecting partially translated configuration.
239///
240/// # Errors
241///
242/// Returns an error if either source model cannot be deserialized or any key fails translation.
243pub(crate) fn translate_strict(merged: &Value, overlay: EnvOverlayMode) -> Result<SalukiConfiguration> {
244    let Sources { datadog, saluki } = deserialize_sources(merged, overlay)?;
245    let (config, errors) = translate(&datadog, &saluki);
246    if let Some(errors) = errors {
247        return Err(Error::Translate { source: errors });
248    }
249    Ok(config)
250}
251
252/// Merges `overlay` onto `base`, with `overlay` winning. Objects merge recursively; every other
253/// value (and any type mismatch) is replaced by the overlay's value.
254//
255// This is the authoritative-Agent merge: the Agent snapshot layered on the local base. The merge is
256// schema-leaf-level, not structural: it recurses through sections (the intermediate path objects a
257// leaf lives under) and replaces at every leaf. A map- or array-typed leaf (for example
258// `additional_endpoints`) is therefore replaced wholesale by whichever source supplies it, never
259// key-unioned across sources. The section-vs-leaf distinction comes from the schema's own leaf
260// paths (see `section_paths`).
261//
262// TODO: A map/array-valued schema leaf is replaced wholesale when any source (file, environment, or
263// the Agent config stream) supplies it. Verify this is the intended semantic for the remote Agent
264// config stream: ADP is that stream's first consumer, so the correct behavior for a stream update to
265// a map-shaped setting may not have been defined yet.
266fn deep_merge(base: Value, overlay: Value) -> Value {
267    merge_at(base, overlay, &mut Vec::new(), section_paths())
268}
269
270// Recurse only where `path` names a schema section; everywhere else the overlay value replaces the
271// base value. This covers leaves (map, array, or scalar) and any unknown key not in the schema. As
272// before, an array or a base/overlay type mismatch falls through to a wholesale replace.
273fn merge_at(base: Value, overlay: Value, path: &mut Vec<String>, sections: &HashSet<Vec<String>>) -> Value {
274    match (base, overlay) {
275        (Value::Object(mut base_map), Value::Object(overlay_map)) => {
276            for (key, overlay_value) in overlay_map {
277                path.push(key.clone());
278                let merged = match base_map.remove(&key) {
279                    Some(base_value) if sections.contains(path.as_slice()) => {
280                        merge_at(base_value, overlay_value, path, sections)
281                    }
282                    _ => overlay_value,
283                };
284                path.pop();
285                base_map.insert(key, merged);
286            }
287            Value::Object(base_map)
288        }
289        (_, overlay) => overlay,
290    }
291}
292
293// Every proper prefix of a modeled leaf path, across both source models. A path in this set is a
294// section the merge recurses into; a path that is a leaf (or unknown) is absent, so the merge
295// replaces there. Built once from the schema's own leaf tables, so it cannot drift from the models.
296fn section_paths() -> &'static HashSet<Vec<String>> {
297    static SECTIONS: OnceLock<HashSet<Vec<String>>> = OnceLock::new();
298    SECTIONS.get_or_init(|| {
299        let mut sections = HashSet::new();
300        let mut add_prefixes = |segments: Vec<String>| {
301            for end in 1..segments.len() {
302                sections.insert(segments[..end].to_vec());
303            }
304        };
305        for path in datadog_agent_config::datadog_leaf_paths() {
306            add_prefixes(path.iter().map(|segment| (*segment).to_string()).collect());
307        }
308        for path in saluki_env_overlay::leaf_paths() {
309            add_prefixes(path.to_vec());
310        }
311        sections
312    })
313}
314
315/// The sources deserialized from the merged configuration value, separated by source authority.
316struct Sources {
317    datadog: DatadogConfiguration,
318    saluki: SalukiOnly,
319}
320
321/// Deserializes both source models from the merged configuration value.
322///
323/// The source models use ordinary serde-compatible field types, so deserializing from
324/// `serde_json::Value` preserves the values.
325///
326/// Environment variables reach the merged value as flat keys (`autoscaling_failover_enabled`), which
327/// neither nested source reads. Each source has its own overlay applied before its deserialize, per
328/// `overlay`: `saluki_env_overlay::apply` for the Saluki-only keys and `apply_env_overlay` for the
329/// Datadog keys. The two cover disjoint key sets, so relocating one source's keys is inert for the
330/// other.
331fn deserialize_sources(merged: &Value, overlay: EnvOverlayMode) -> Result<Sources> {
332    let mut merged = merged.clone();
333    saluki_env_overlay::apply(&mut merged, overlay);
334    let saluki = SalukiOnly::deserialize(&merged)?;
335    apply_env_overlay(&mut merged, overlay);
336    let datadog = DatadogConfiguration::deserialize(&merged)?;
337    Ok(Sources { datadog, saluki })
338}
339
340/// Translates the Datadog and Saluki-only sources into one [`SalukiConfiguration`], returning every
341/// error recorded while converting an individual Datadog value.
342///
343/// The Datadog `drive` feeds every supported key to a `DatadogTranslator`; a value that cannot be
344/// converted leaves its field at the model default and records an error. The Saluki-only values
345/// then seed their disjoint destinations, which cannot fail. The returned configuration is always
346/// complete: every valid value is present, and every invalid one holds its default.
347fn translate(datadog: &DatadogConfiguration, saluki: &SalukiOnly) -> (SalukiConfiguration, Option<TranslateErrors>) {
348    let (mut config, errors) = DatadogTranslator::new(datadog).translate();
349    saluki.seed(&mut config);
350    (config, errors)
351}
352
353#[cfg(test)]
354mod tests {
355    use std::time::Duration;
356
357    use agent_data_plane_config::domains::dogstatsd::OriginTagCardinality;
358    use agent_data_plane_config::{Live, SalukiConfiguration};
359    use datadog_agent_config::{DatadogConfiguration, EnvOverlayMode};
360    use saluki_config::dynamic::ConfigUpdate;
361    use saluki_config::ConfigurationLoader;
362    use serde_json::{json, Value};
363    use tokio::sync::mpsc;
364
365    use super::{deep_merge, translate, translate_strict, ConfigurationSystem, Error, SalukiOnly};
366
367    /// Builds a standalone system whose authority is the local sources (`file` + `env`).
368    async fn standalone_system(
369        file: Option<Value>, env: Option<&[(String, String)]>, overlay: EnvOverlayMode,
370    ) -> Result<ConfigurationSystem, Error> {
371        let (compat_map, _) = ConfigurationLoader::for_tests(file, env, false).await;
372        let base = compat_map.as_typed::<Value>().expect("base extracts");
373        let config = translate_strict(&base, overlay)?;
374        Ok(ConfigurationSystem::standalone(compat_map, config))
375    }
376
377    /// Builds a connected system whose base is `base` and whose authority is the returned Agent
378    /// stream. The initial (empty) snapshot is queued before the system blocks on it, so the caller
379    /// gets back a stream ready for `Partial`/`Snapshot` updates.
380    async fn connected_system(
381        base: Value, overlay: EnvOverlayMode,
382    ) -> (ConfigurationSystem, mpsc::Sender<ConfigUpdate>) {
383        let (agent_tx, agent_rx) = mpsc::channel(100);
384        let (compat_map, compat_tx) = ConfigurationLoader::for_tests(None, None, true).await;
385        let compat_tx = compat_tx.expect("dynamic sender exists");
386        agent_tx.send(ConfigUpdate::Snapshot(json!({}))).await.unwrap();
387        let system = ConfigurationSystem::connected(agent_rx, compat_tx, compat_map, base, overlay)
388            .await
389            .expect("system builds");
390        (system, agent_tx)
391    }
392
393    /// Polls the current configuration until `predicate` holds, failing if it never does.
394    async fn await_config(system: &ConfigurationSystem, what: &str, predicate: impl Fn(&SalukiConfiguration) -> bool) {
395        tokio::time::timeout(Duration::from_secs(2), async {
396            while !predicate(&system.config()) {
397                tokio::time::sleep(Duration::from_millis(5)).await;
398            }
399        })
400        .await
401        .unwrap_or_else(|_| panic!("timed out waiting for {what}"));
402    }
403
404    #[tokio::test]
405    async fn startup_current_reflects_translation() {
406        let system = standalone_system(
407            Some(json!({ "log_level": "warn", "dogstatsd_port": 9125 })),
408            None,
409            EnvOverlayMode::Fallback,
410        )
411        .await
412        .expect("system builds");
413        let config = system.config();
414
415        assert_eq!(config.control.logging.level, "warn");
416        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
417    }
418
419    #[tokio::test]
420    async fn connected_stream_translates_metrics_v3_routing_configuration() {
421        let (system, agent_tx) = connected_system(
422            json!({
423                "data_plane": {
424                    "metrics": {
425                        "v3": {
426                            "series": {
427                                "enabled": true
428                            }
429                        }
430                    }
431                }
432            }),
433            EnvOverlayMode::Fallback,
434        )
435        .await;
436
437        assert_eq!(
438            system.config().shared.metrics_encoding.v3_series_mode.mode,
439            "datadog_only"
440        );
441
442        agent_tx
443            .send(ConfigUpdate::Snapshot(json!({
444                "serializer_compressor_kind": "zstd",
445                "serializer_experimental_use_v3_api": {
446                    "compression_level": 7,
447                    "series": {
448                        "endpoints": ["https://app.us3.datadoghq.com"],
449                        "validate": true,
450                        "use_beta": true,
451                        "beta_route": "/api/intake/metrics/custom/series",
452                        "shadow_sample_rate": 0.25,
453                        "shadow_sites": ["us3.datadoghq.com"]
454                    }
455                },
456                "use_v2_api": {
457                    "series": false
458                },
459                "use_v3_api": {
460                    "series": {
461                        "enabled": "false",
462                        "endpoints": {
463                            "https://app.datadoghq.com": "true"
464                        }
465                    }
466                },
467                "observability_pipelines_worker": {
468                    "metrics": {
469                        "enabled": true,
470                        "url": "https://opw.example.com",
471                        "use_v3_api": {
472                            "series": true
473                        }
474                    }
475                }
476            })))
477            .await
478            .unwrap();
479
480        await_config(&system, "the streamed metrics V3 routing configuration", |config| {
481            config.shared.metrics_encoding.v3_series_mode.mode == "false"
482                && config
483                    .shared
484                    .metrics_encoding
485                    .v3_series_mode
486                    .endpoint_modes
487                    .get("https://app.datadoghq.com")
488                    .is_some_and(|mode| mode == "true")
489        })
490        .await;
491
492        let config = system.config();
493        let metrics = &config.shared.metrics_encoding;
494        assert!(!metrics.use_v2_series_api);
495        assert_eq!(metrics.v3_api.compression_level, 7);
496        assert_eq!(metrics.v3_api.series.endpoints, vec!["https://app.us3.datadoghq.com"]);
497        assert!(metrics.v3_api.series.validate);
498        assert!(metrics.v3_api.series.use_beta);
499        assert_eq!(metrics.v3_api.series.beta_route, "/api/intake/metrics/custom/series");
500        assert_eq!(metrics.v3_api.series.shadow_sample_rate, 0.25);
501        assert_eq!(metrics.v3_api.series.shadow_sites, vec!["us3.datadoghq.com"]);
502
503        let opw = &config.shared.endpoints.opw_intake;
504        assert!(opw.enabled);
505        assert_eq!(opw.url, "https://opw.example.com");
506        assert!(opw.use_v3_series);
507    }
508
509    #[tokio::test]
510    async fn flat_env_key_overlays_onto_nested_datadog_slot() {
511        // A flat, underscore-joined key (the shape an environment variable produces) is relocated
512        // into the nested `autoscaling.failover.*` slot the Datadog deserializer reads. The string
513        // list is split on whitespace.
514        let system = standalone_system(
515            Some(json!({
516                "autoscaling_failover_enabled": true,
517                "autoscaling_failover_metrics": "container.memory.usage container.cpu.usage",
518            })),
519            None,
520            EnvOverlayMode::Fallback,
521        )
522        .await
523        .expect("system builds");
524        let config = system.config();
525
526        assert!(config.shared.autoscaling_failover.enabled);
527        assert_eq!(
528            config.shared.autoscaling_failover.metrics,
529            vec!["container.memory.usage".to_string(), "container.cpu.usage".to_string()]
530        );
531    }
532
533    #[tokio::test]
534    async fn saluki_only_dotted_env_key_seeds_the_model() {
535        // A dotted Saluki-only key set only by environment variable arrives as a flat underscore-joined key
536        // `data_plane_standalone_mode`, which the nested `SalukiOnly` never reads. The overlay must
537        // relocate it so it seeds `control.standalone_mode`.
538        let system = standalone_system(
539            None,
540            Some(&[("DATA_PLANE_STANDALONE_MODE".to_string(), "true".to_string())]),
541            EnvOverlayMode::Fallback,
542        )
543        .await
544        .expect("system builds");
545
546        assert!(system.config().control.standalone_mode);
547    }
548
549    #[tokio::test]
550    async fn disabled_overlay_leaves_flat_env_key_unread() {
551        // With the overlay disabled, the flat key is never relocated, so the nested deserializer
552        // does not see it and the field keeps its default.
553        let system = standalone_system(
554            Some(json!({ "autoscaling_failover_enabled": true })),
555            None,
556            EnvOverlayMode::Disabled,
557        )
558        .await
559        .expect("system builds");
560
561        assert!(!system.config().shared.autoscaling_failover.enabled);
562    }
563
564    #[tokio::test]
565    async fn load_fails_on_translation_invalid_startup_config() {
566        // Startup is the strict gate: a value figment accepts but the model rejects fails the load,
567        // so the process never boots on bad config.
568        let result = standalone_system(
569            Some(json!({ "dogstatsd_tag_cardinality": "bogus" })),
570            None,
571            EnvOverlayMode::Fallback,
572        )
573        .await;
574
575        assert!(matches!(result, Err(Error::Translate { .. })));
576    }
577
578    #[test]
579    fn zero_otlp_trace_interner_size_is_rejected() {
580        // Component builders used to discover this after translation. Reject zero before publishing
581        // an invalid typed model.
582        let error = translate_strict(
583            &json!({ "otlp_config": { "traces": { "string_interner_size": 0 } } }),
584            EnvOverlayMode::Fallback,
585        )
586        .expect_err("zero trace interner size should fail translation");
587
588        assert!(matches!(error, Error::Deserialize { .. }));
589        assert!(error.to_string().contains("value of bytes must be greater than zero"));
590    }
591
592    #[test]
593    fn oversized_otlp_trace_interner_size_is_rejected() {
594        let error = translate_strict(
595            &json!({ "otlp_config": { "traces": { "string_interner_size": "2GiB" } } }),
596            EnvOverlayMode::Fallback,
597        )
598        .expect_err("oversized trace interner should fail translation");
599
600        assert!(matches!(error, Error::Deserialize { .. }));
601        assert!(error.to_string().contains("must not exceed 1073741824 bytes"));
602    }
603
604    #[test]
605    fn positive_otlp_trace_interner_size_is_accepted() {
606        let config = translate_strict(
607            &json!({ "otlp_config": { "traces": { "string_interner_size": "512KiB" } } }),
608            EnvOverlayMode::Fallback,
609        )
610        .expect("positive trace interner size should translate");
611
612        assert_eq!(config.domains.otlp.traces.string_interner_size.get(), 512 * 1024);
613    }
614
615    #[tokio::test]
616    async fn standalone_loads_numeric_byte_size() {
617        // A byte-size setting documented as accepting a bare integer (`10485760`) rather than a
618        // string (`"10MB"`) must not abort the strict startup gate. The typed model normalizes it,
619        // and the translator resolves it to the same byte count.
620        let system = standalone_system(
621            Some(json!({ "dogstatsd_log_file_max_size": 10485760 })),
622            None,
623            EnvOverlayMode::Fallback,
624        )
625        .await
626        .expect("numeric byte size boots");
627
628        assert_eq!(system.config().domains.dogstatsd.debug_log.log_file_max_size, 10485760);
629    }
630
631    #[tokio::test]
632    async fn translation_invalid_update_is_rejected_keeping_last_known_good() {
633        let (system, agent_tx) = connected_system(
634            json!({ "log_level": "warn", "dogstatsd_tag_cardinality": "high" }),
635            EnvOverlayMode::Fallback,
636        )
637        .await;
638        assert_eq!(
639            system.config().domains.dogstatsd.origin.tag_cardinality,
640            OriginTagCardinality::High
641        );
642
643        // Send a translation-invalid update, then a valid update to a different field. Updates are
644        // processed in order, so once the second is observed the first has already been handled.
645        agent_tx
646            .send(ConfigUpdate::Partial {
647                key: "dogstatsd_tag_cardinality".to_string(),
648                value: json!("bogus"),
649            })
650            .await
651            .unwrap();
652        agent_tx
653            .send(ConfigUpdate::Partial {
654                key: "log_level".to_string(),
655                value: json!("error"),
656            })
657            .await
658            .unwrap();
659
660        await_config(&system, "the later valid update to take effect", |c| {
661            c.control.logging.level == "error"
662        })
663        .await;
664        // The invalid update was rejected whole: the field keeps its last-known-good value rather
665        // than falling back to a default, and the later valid update still applied.
666        assert_eq!(
667            system.config().domains.dogstatsd.origin.tag_cardinality,
668            OriginTagCardinality::High
669        );
670    }
671
672    #[tokio::test]
673    async fn converges_to_latest_value_under_burst() {
674        let (system, agent_tx) = connected_system(json!({ "log_level": "info" }), EnvOverlayMode::Fallback).await;
675
676        let burst = [
677            "warn", "error", "debug", "trace", "info", "warn", "error", "debug", "trace", "info", "warn", "error",
678            "debug", "trace", "info", "warn", "error", "debug", "trace",
679        ];
680        for (i, level) in burst.iter().enumerate() {
681            agent_tx
682                .send(ConfigUpdate::Partial {
683                    key: "log_level".to_string(),
684                    value: json!(level),
685                })
686                .await
687                .unwrap();
688            // Interleave a translation-invalid update mid-burst, then correct it. The invalid value
689            // is rejected whole (last-known-good retained) rather than wedging the task, so the
690            // baseline keeps converging on the latest valid value regardless of the transient bad one.
691            if i == burst.len() / 2 {
692                agent_tx
693                    .send(ConfigUpdate::Partial {
694                        key: "dogstatsd_tag_cardinality".to_string(),
695                        value: json!("bogus"),
696                    })
697                    .await
698                    .unwrap();
699                agent_tx
700                    .send(ConfigUpdate::Partial {
701                        key: "dogstatsd_tag_cardinality".to_string(),
702                        value: json!("high"),
703                    })
704                    .await
705                    .unwrap();
706            }
707        }
708        let final_level = "error";
709        agent_tx
710            .send(ConfigUpdate::Partial {
711                key: "log_level".to_string(),
712                value: json!(final_level),
713            })
714            .await
715            .unwrap();
716
717        await_config(
718            &system,
719            "the current configuration to converge to the final value",
720            |c| c.control.logging.level == final_level,
721        )
722        .await;
723        assert_eq!(system.config().control.logging.level, final_level);
724    }
725
726    #[tokio::test]
727    async fn live_view_observes_debug_log_update() {
728        let (system, agent_tx) = connected_system(
729            json!({ "dogstatsd_metrics_stats_enable": false }),
730            EnvOverlayMode::Fallback,
731        )
732        .await;
733        let mut view = system.live(|c| &c.domains.dogstatsd.debug_log);
734        assert!(!view.metrics_stats_enable);
735
736        agent_tx
737            .send(ConfigUpdate::Partial {
738                key: "dogstatsd_metrics_stats_enable".to_string(),
739                value: json!(true),
740            })
741            .await
742            .unwrap();
743
744        let updated = tokio::time::timeout(Duration::from_secs(2), view.changed())
745            .await
746            .expect("view observes the debug-log update");
747        assert!(updated.metrics_stats_enable);
748        // `Deref` reflects the value returned by the last `changed`.
749        assert!(view.metrics_stats_enable);
750    }
751
752    #[tokio::test]
753    async fn field_view_wakes_on_its_field() {
754        // Projecting straight to a single field needs no schema change and no central registration:
755        // the granularity is chosen at the call site.
756        let (system, agent_tx) = connected_system(
757            json!({ "dogstatsd_metrics_stats_enable": false }),
758            EnvOverlayMode::Fallback,
759        )
760        .await;
761        let mut stats = system.live(|c| &c.domains.dogstatsd.debug_log.metrics_stats_enable);
762        assert!(!*stats);
763
764        agent_tx
765            .send(ConfigUpdate::Partial {
766                key: "dogstatsd_metrics_stats_enable".to_string(),
767                value: json!(true),
768            })
769            .await
770            .unwrap();
771
772        let updated = tokio::time::timeout(Duration::from_secs(2), stats.changed())
773            .await
774            .expect("field view observes its field's update");
775        assert!(updated);
776        assert!(*stats);
777    }
778
779    #[tokio::test]
780    async fn fixed_view_never_changes() {
781        let mut view: Live<bool> = Live::new_fixed(true);
782        assert!(*view);
783        // A fixed view never resolves, so this bound is deterministic rather than timing-dependent.
784        assert!(tokio::time::timeout(Duration::from_millis(100), view.changed())
785            .await
786            .is_err());
787    }
788
789    #[tokio::test]
790    async fn live_views_reflect_startup_configuration() {
791        let system = standalone_system(
792            Some(json!({ "dogstatsd_metrics_stats_enable": true })),
793            None,
794            EnvOverlayMode::Fallback,
795        )
796        .await
797        .expect("system builds");
798        let config = system.config();
799
800        let debug_log = system.live(|c| &c.domains.dogstatsd.debug_log);
801        assert_eq!(&*debug_log, &config.domains.dogstatsd.debug_log);
802
803        let prefix_filter = system.live(|c| &c.domains.dogstatsd.prefix_filter);
804        assert_eq!(&*prefix_filter, &config.domains.dogstatsd.prefix_filter);
805
806        let multi_region_failover = system.live(|c| &c.domains.multi_region_failover);
807        assert_eq!(&*multi_region_failover, &config.domains.multi_region_failover);
808    }
809
810    #[test]
811    fn translate_small_map_through_witness_and_seed() {
812        // A small raw Datadog source map exercising a scalar conversion, an enum parse, a
813        // duration parse, and the raw endpoint inputs.
814        let datadog: DatadogConfiguration = serde_json::from_value(json!({
815            "api_key": "abc",
816            "dd_url": "https://custom.example.com",
817            "dogstatsd_port": 9125,
818            "dogstatsd_tag_cardinality": "high",
819            "expected_tags_duration": "15s",
820            "telemetry": { "dogstatsd_origin": true },
821        }))
822        .expect("datadog source deserializes");
823
824        // A small Saluki-only source setting one seeded field.
825        let saluki: SalukiOnly = serde_json::from_value(json!({
826            "dogstatsd_tcp_port": 8126,
827        }))
828        .expect("saluki-only source deserializes");
829
830        let (config, errors) = translate(&datadog, &saluki);
831        assert!(errors.is_none(), "translation of a valid map records no error");
832
833        // Driven scalar conversion: i64 -> u16.
834        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
835        // Driven enum parse.
836        assert_eq!(
837            config.domains.dogstatsd.origin.tag_cardinality,
838            OriginTagCardinality::High
839        );
840        // Driven `format: duration` parse: a Go duration string becomes a `Duration`.
841        assert_eq!(config.shared.tags.expected_tags_duration, Duration::from_secs(15));
842        // Driven bool in a nested Datadog section.
843        assert!(config.domains.dogstatsd.telemetry.origin_breakdown);
844        // Raw endpoint inputs: carried through without resolution (see #1965).
845        assert_eq!(config.shared.endpoints.api_key, "abc");
846        assert_eq!(
847            config.shared.endpoints.dd_url.as_deref(),
848            Some("https://custom.example.com")
849        );
850        // Seeded Saluki-only field.
851        assert_eq!(config.domains.dogstatsd.listeners.tcp_port, 8126);
852    }
853
854    // A map-typed schema leaf (`additional_endpoints`) is replaced wholesale, not key-unioned: the
855    // overlay's map fully supplants the base's rather than the two being merged.
856    #[test]
857    fn merge_replaces_a_map_typed_leaf_wholesale() {
858        let base = json!({ "additional_endpoints": { "https://a.example.com": ["k1"] } });
859        let overlay = json!({ "additional_endpoints": { "https://b.example.com": ["k2"] } });
860
861        let merged = deep_merge(base, overlay);
862
863        assert_eq!(
864            merged,
865            json!({ "additional_endpoints": { "https://b.example.com": ["k2"] } })
866        );
867    }
868
869    // A schema section (`apm_config`) is recursed into: leaves from both sources coexist, and a leaf
870    // set by both sources takes the overlay's value.
871    #[test]
872    fn merge_recurses_into_a_section_and_replaces_leaves_within_it() {
873        let base = json!({ "apm_config": { "compute_stats_by_span_kind": true, "enable_rare_sampler": true } });
874        let overlay = json!({ "apm_config": { "enable_rare_sampler": false } });
875
876        let merged = deep_merge(base, overlay);
877
878        assert_eq!(
879            merged,
880            json!({ "apm_config": { "compute_stats_by_span_kind": true, "enable_rare_sampler": false } })
881        );
882    }
883
884    // A scalar leaf still replaces, and a colliding array leaf is replaced rather than adjoined.
885    #[test]
886    fn merge_replaces_scalar_and_array_leaves() {
887        let base = json!({ "dogstatsd_port": 8125, "dogstatsd_mapper_profiles": ["a"] });
888        let overlay = json!({ "dogstatsd_port": 9125, "dogstatsd_mapper_profiles": ["b"] });
889
890        let merged = deep_merge(base, overlay);
891
892        assert_eq!(
893            merged,
894            json!({ "dogstatsd_port": 9125, "dogstatsd_mapper_profiles": ["b"] })
895        );
896    }
897}