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::sync::Arc;
5
6use agent_data_plane_config::{Live, SalukiConfiguration};
7use arc_swap::ArcSwap;
8use datadog_agent_config::{DatadogConfiguration, TranslateErrors};
9use saluki_config::dynamic::ConfigUpdate;
10use saluki_config::{ConfigurationError, GenericConfiguration};
11use serde::Deserialize;
12use serde_json::Value;
13use snafu::Snafu;
14use tokio::sync::{mpsc, watch};
15use tracing::{debug, warn};
16
17use crate::saluki_only::SalukiOnly;
18use crate::source::SourceTree;
19use crate::translators::DatadogTranslator;
20
21/// An error building the translated configuration from the raw sources.
22#[derive(Debug, Snafu)]
23pub enum Error {
24    /// The configuration value could not be read from the raw configuration map.
25    #[snafu(context(false), display("{source}"))]
26    Source {
27        /// The underlying configuration error.
28        source: ConfigurationError,
29    },
30
31    /// A source model could not be deserialized from the merged configuration value.
32    #[snafu(context(false), display("{source}"))]
33    Deserialize {
34        /// The underlying deserialization error.
35        source: serde_json::Error,
36    },
37
38    /// The Datadog Agent closed the configuration stream before sending the initial snapshot.
39    #[snafu(display("configuration stream closed before the initial snapshot"))]
40    StreamClosed,
41
42    /// The typed base could not be built from the file and environment.
43    #[snafu(display("failed to build the configuration base: {message}"))]
44    Base {
45        /// What went wrong reading the file, parsing YAML, or decoding an environment variable.
46        message: String,
47    },
48
49    /// Translating the sources into the model failed on one or more keys.
50    #[snafu(display("{source}"))]
51    Translate {
52        /// Every translation error recorded.
53        source: TranslateErrors,
54    },
55
56    /// The fully merged configuration resolved no usable Datadog API key.
57    #[snafu(display(
58        "no Datadog API key is configured: set `api_key` in the Datadog Agent's configuration, or \
59         `DD_API_KEY` in the environment. Every payload is authenticated with this key, so nothing can \
60         be submitted without one"
61    ))]
62    MissingApiKey,
63}
64
65type Result<T> = std::result::Result<T, Error>;
66
67/// The runtime configuration, translated from the raw sources and kept current.
68///
69/// The configuration system is the single owner of the Datadog Agent's `ConfigUpdate` stream. It
70/// folds each update onto the local source base to build the typed [`SalukiConfiguration`] directly,
71/// and forwards the same update to a legacy [`GenericConfiguration`] compatibility map so
72/// un-migrated components can still read by key. The current configuration lives in an [`ArcSwap`]
73/// cell so readers load a whole, self-consistent version with no lock, while the update task
74/// replaces it in one atomic store.
75pub struct ConfigurationSystem {
76    raw_map: GenericConfiguration,
77    current: Arc<ArcSwap<SalukiConfiguration>>,
78    // Fired once after each accepted update so live views wake and re-project. Shared with the
79    // update task via `Arc` because `watch::Sender` is not `Clone` and both the system (to mint
80    // views) and the task (to notify) need it.
81    tick: Arc<watch::Sender<()>>,
82}
83
84impl ConfigurationSystem {
85    /// Connected authority: takes ownership of the Datadog Agent's config stream, forwards each
86    /// update to the compatibility map, and builds the typed model directly from the stream folded
87    /// onto the local `base` (file + environment).
88    ///
89    /// Blocks for the first authoritative snapshot and is the strict startup gate: a snapshot that
90    /// never arrives, cannot be deserialized, or fails translation aborts the boot. `async` because
91    /// the update task requires a Tokio runtime; keeping that requirement visible here avoids a
92    /// panic deep inside `tokio::spawn`.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the stream closes before the first snapshot, or the initial configuration
97    /// cannot be deserialized or translated.
98    pub(crate) async fn connected(
99        mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>,
100        compat_map: GenericConfiguration, base: SourceTree,
101    ) -> Result<Self> {
102        // The first stream message is the authoritative initial snapshot.
103        let first = agent_rx.recv().await.ok_or(Error::StreamClosed)?;
104
105        // Fold it into the accumulating Agent layer and forward it to the compat map, then wait for
106        // the compat map to apply it so `raw_map()` is populated before any consumer reads it.
107        let mut agent = SourceTree::empty();
108        fold(&mut agent, &first);
109        forward(&compat_tx, first).await;
110        compat_map.ready().await;
111
112        // Startup is the strict gate: this is the first, authoritative Agent snapshot, so any error
113        // fails the boot and we never run on bad config. At runtime (see `agent_loop`) the same
114        // check instead rejects the offending update and keeps the last-known-good configuration,
115        // because a runtime update must never take the system down.
116        let config = translate_authoritative(&base.overlay(&agent))?;
117
118        let current = Arc::new(ArcSwap::from_pointee(config));
119        // The initial receiver is dropped immediately; `send_replace` works with zero receivers, and
120        // each live view subscribes its own receiver from the sender.
121        let (tick, _) = watch::channel(());
122        let tick = Arc::new(tick);
123
124        tokio::spawn(agent_loop(
125            agent_rx,
126            compat_tx,
127            base,
128            agent,
129            Arc::clone(&current),
130            Arc::clone(&tick),
131        ));
132
133        Ok(Self {
134            raw_map: compat_map,
135            current,
136            tick,
137        })
138    }
139
140    /// Installs a static configuration without an update task.
141    ///
142    /// Live views retain their initial values because this system sends no update notifications.
143    pub(crate) fn standalone(compat_map: GenericConfiguration, config: SalukiConfiguration) -> Self {
144        let current = Arc::new(ArcSwap::from_pointee(config));
145        let (tick, _) = watch::channel(());
146        Self {
147            raw_map: compat_map,
148            current,
149            tick: Arc::new(tick),
150        }
151    }
152
153    /// Returns a live view of the given projection of the current configuration. Narrow further with
154    /// [`Live::project`]. This is the only way a consumer subscribes to runtime updates.
155    pub fn live<T>(&self, project: impl for<'a> Fn(&'a SalukiConfiguration) -> &'a T + Send + Sync + 'static) -> Live<T>
156    where
157        T: Clone + PartialEq + 'static,
158    {
159        Live::new_dynamic(Arc::clone(&self.current), self.tick.subscribe(), project)
160    }
161
162    /// Loads the current translated configuration.
163    ///
164    /// The returned guard pins one whole version; a concurrent refresh never tears the read.
165    pub fn config(&self) -> arc_swap::Guard<Arc<SalukiConfiguration>> {
166        self.current.load()
167    }
168
169    /// Returns a shared handle to the current-configuration cell for readers that load it
170    /// independently.
171    pub fn current_handle(&self) -> Arc<ArcSwap<SalukiConfiguration>> {
172        Arc::clone(&self.current)
173    }
174
175    /// Returns the raw source map for consumers that read configuration by key.
176    pub fn raw_map(&self) -> GenericConfiguration {
177        self.raw_map.clone()
178    }
179}
180
181/// Owns the Datadog Agent config stream for the life of the process: validates each update against
182/// the typed model, commits it on success, and forwards it to the by-key configuration view. Ends
183/// when the stream closes.
184///
185/// Each update is processed individually (no burst collapse) so a rejection can be attributed to the
186/// exact update that caused it. Updates are infrequent, so re-translating per update is cheap.
187async fn agent_loop(
188    mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>, base: SourceTree,
189    mut agent: SourceTree, current: Arc<ArcSwap<SalukiConfiguration>>, tick: Arc<watch::Sender<()>>,
190) {
191    while let Some(update) = agent_rx.recv().await {
192        // Validate-then-commit: fold onto a tentative copy of the Agent layer and drive the typed
193        // model from it. Only a fully successful update advances the committed layer, so a rejected
194        // value never lingers to re-poison a later merge.
195        let mut tentative = agent.clone();
196        fold(&mut tentative, &update);
197        match translate_authoritative(&base.overlay(&tentative)) {
198            Ok(config) => {
199                agent = tentative;
200                current.store(Arc::new(config));
201                tick.send_replace(());
202                debug!("Applied configuration update.");
203            }
204            Err(e) => warn!(
205                error = %e,
206                "Rejected configuration update; keeping the last-known-good typed configuration. The \
207                 compatibility map still receives this update, so an un-migrated component may act on \
208                 a value the typed model rejected."
209            ),
210        }
211        // The compatibility map receives every update faithfully, whether or not the typed path
212        // accepted it: un-migrated components keep the Agent's permissive behavior during migration.
213        // The updater owns the receiver; if it is gone, no un-migrated component is reading the
214        // by-key view, so dropping the forward is fine.
215        forward(&compat_tx, update).await;
216    }
217}
218
219/// Folds one update into the accumulating Agent layer.
220///
221/// `Snapshot` replaces the layer; `Partial` applies one (possibly dotted) key, the same handling the
222/// `saluki-config` updater uses, so this layer applies Agent updates the same way as the compatibility
223/// view.
224///
225/// Each setting's provenance is retained, which is what lets a later update that demotes a value to
226/// an Agent default stop shadowing the local value it had been overriding.
227fn fold(agent: &mut SourceTree, update: &ConfigUpdate) {
228    match update {
229        ConfigUpdate::Snapshot(settings) => *agent = SourceTree::from_settings(settings),
230        ConfigUpdate::Partial(setting) => agent.set(setting),
231    }
232}
233
234/// Forwards one update to the compatibility map's updater.
235async fn forward(compat_tx: &mpsc::Sender<ConfigUpdate>, update: ConfigUpdate) {
236    let _ = compat_tx.send(update).await;
237}
238
239/// Deserializes and translates merged source values, rejecting partially translated configuration.
240///
241/// # Errors
242///
243/// Returns an error if either source model cannot be deserialized or any key fails translation.
244pub(crate) fn translate_strict(merged: &SourceTree) -> Result<SalukiConfiguration> {
245    let Sources { datadog, saluki } = deserialize_sources(&merged.to_value())?;
246    let (config, errors) = translate(&datadog, &saluki, merged);
247    if let Some(errors) = errors {
248        return Err(Error::Translate { source: errors });
249    }
250    Ok(config)
251}
252
253/// Translates merged sources that are authoritative for the running process, rejecting a
254/// configuration ADP cannot run on.
255///
256/// This is [`translate_strict`] plus [`validate`]. Use it where the merged sources are complete: the
257/// Datadog Agent's snapshot layered over the local base, or the local base alone in standalone mode.
258///
259/// # Errors
260///
261/// Returns an error if translation fails, or if the translated configuration fails validation.
262pub(crate) fn translate_authoritative(merged: &SourceTree) -> Result<SalukiConfiguration> {
263    let config = translate_strict(merged)?;
264    validate(&config)?;
265    Ok(config)
266}
267
268/// Checks the invariants a configuration must satisfy for this process to do useful work.
269///
270/// Translation alone cannot make these checks. It converts one key at a time and every schema key
271/// has a default, so a setting the operator never supplied is indistinguishable from one they did
272/// until the whole merged configuration is in hand.
273///
274/// Apply this only to an authoritative configuration. The local snapshot
275/// [`LoadedConfiguration::load`][crate::LoadedConfiguration::load] produces is incomplete by design:
276/// under the Datadog Agent the API key arrives over the configuration stream, so a local-only
277/// snapshot legitimately has none, and CLI subcommands read that snapshot without ever submitting a
278/// payload.
279///
280/// # Errors
281///
282/// Returns [`Error::MissingApiKey`] if no usable API key resolved. Every payload ADP submits is
283/// authenticated with this key, so an empty one turns each flush into a rejected request that the
284/// forwarder then retries. Failing here names the cause once instead of leaving an operator to infer
285/// it from a stream of authentication failures.
286pub(crate) fn validate(config: &SalukiConfiguration) -> Result<()> {
287    // A blank key is as unusable as an absent one, and a padded key is a typo we should name rather
288    // than send.
289    if config.shared.endpoints.api_key.trim().is_empty() {
290        return Err(Error::MissingApiKey);
291    }
292
293    Ok(())
294}
295
296// TODO: A map/array-valued schema leaf is replaced wholesale when any source (file, environment, or
297// the Agent config stream) supplies it. Verify this is the intended semantic for the remote Agent
298// config stream: ADP is that stream's first consumer, so the correct behavior for a stream update to
299// a map-shaped setting may not have been defined yet.
300
301/// The sources deserialized from the merged configuration value, separated by source authority.
302struct Sources {
303    datadog: DatadogConfiguration,
304    saluki: SalukiOnly,
305}
306
307/// Deserializes both source models from the merged configuration value.
308///
309/// The source models use ordinary serde-compatible field types, so deserializing from
310/// `serde_json::Value` preserves the values. Both read the canonical nested shape: the local base
311/// is built that way by the schema-driven environment readers, and the Datadog Agent's stream
312/// delivers dotted keys that are nested on arrival.
313fn deserialize_sources(merged: &Value) -> Result<Sources> {
314    let saluki = SalukiOnly::deserialize(merged)?;
315    let datadog = DatadogConfiguration::deserialize(merged)?;
316    Ok(Sources { datadog, saluki })
317}
318
319/// Translates the Datadog and Saluki-only sources into one [`SalukiConfiguration`], returning every
320/// error recorded while converting an individual Datadog value.
321///
322/// The Datadog `drive` feeds every supported key to a `DatadogTranslator`; a value that cannot be
323/// converted leaves its field at the model default and records an error. The Saluki-only values
324/// then seed their disjoint destinations, which cannot fail. The returned configuration is always
325/// complete: every valid value is present, and every invalid one holds its default.
326///
327/// `sources` is the same merged layer the models were deserialized from. The translator consults it
328/// for provenance, which a deserialized source model cannot supply: a schema key with a default is
329/// always present, so its value alone cannot say whether an input set it explicitly.
330fn translate(
331    datadog: &DatadogConfiguration, saluki: &SalukiOnly, sources: &SourceTree,
332) -> (SalukiConfiguration, Option<TranslateErrors>) {
333    let (mut config, errors) = DatadogTranslator::new(datadog, sources).translate();
334    saluki.seed(&mut config);
335    (config, errors)
336}
337
338#[cfg(test)]
339mod tests {
340    use std::time::Duration;
341
342    use agent_data_plane_config::domains::dogstatsd::OriginTagCardinality;
343    use agent_data_plane_config::shared::V3SeriesMode;
344    use agent_data_plane_config::Provenance;
345    use agent_data_plane_config::{Live, SalukiConfiguration};
346    use datadog_agent_config::DatadogConfiguration;
347    use saluki_config::dynamic::{ConfigSetting, ConfigUpdate, Provenance as StreamProvenance};
348    use saluki_config::ConfigurationLoader;
349    use serde_json::{json, Value};
350    use tokio::sync::mpsc;
351
352    use super::{
353        translate, translate_authoritative, translate_strict, ConfigurationSystem, Error, SalukiOnly, SourceTree,
354    };
355
356    /// API key the connected-system fixture puts in the local base.
357    ///
358    /// An authoritative configuration must resolve one (see [`super::validate`]), and putting it in
359    /// the base rather than in a streamed snapshot keeps it in place across the snapshot replacements
360    /// these tests exercise.
361    const TEST_API_KEY: &str = "test-api-key";
362
363    /// Builds a standalone system whose authority is the local sources (`file` + `env`).
364    ///
365    /// Translates without validating so a test can state only the setting it is exercising. The
366    /// production standalone path validates; `loaded.rs` covers that.
367    async fn standalone_system(
368        file: Option<Value>, env: Option<&[(String, String)]>,
369    ) -> Result<ConfigurationSystem, Error> {
370        let (compat_map, _) = ConfigurationLoader::for_tests(file, env, false).await;
371        let base = SourceTree::all_explicit(compat_map.as_typed::<Value>().expect("base extracts"));
372        let config = translate_strict(&base)?;
373        Ok(ConfigurationSystem::standalone(compat_map, config))
374    }
375
376    /// Builds a connected system whose base is `base` and whose authority is the returned Agent
377    /// stream. The initial (empty) snapshot is queued before the system blocks on it, so the caller
378    /// gets back a stream ready for `Partial`/`Snapshot` updates.
379    ///
380    /// `base` is given an [`api_key`][TEST_API_KEY] unless it states its own, so a caller varying
381    /// some unrelated setting need not restate what validation requires.
382    async fn connected_system(mut base: Value) -> (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([])).await.unwrap();
387        if let Some(base) = base.as_object_mut() {
388            base.entry("api_key").or_insert(json!(TEST_API_KEY));
389        }
390        let base = SourceTree::all_explicit(base);
391        let system = ConfigurationSystem::connected(agent_rx, compat_tx, compat_map, base)
392            .await
393            .expect("system builds");
394        (system, agent_tx)
395    }
396
397    /// Polls the current configuration until `predicate` holds, failing if it never does.
398    async fn await_config(system: &ConfigurationSystem, what: &str, predicate: impl Fn(&SalukiConfiguration) -> bool) {
399        tokio::time::timeout(Duration::from_secs(2), async {
400            while !predicate(&system.config()) {
401                tokio::time::sleep(Duration::from_millis(5)).await;
402            }
403        })
404        .await
405        .unwrap_or_else(|_| panic!("timed out waiting for {what}"));
406    }
407
408    #[tokio::test]
409    async fn startup_current_reflects_translation() {
410        let system = standalone_system(Some(json!({ "log_level": "warn", "dogstatsd_port": 9125 })), None)
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    #[test]
420    fn boolean_use_v3_api_series_enabled_is_normalized() {
421        let sources = SourceTree::all_explicit(json!({
422            "use_v3_api": {
423                "series": {
424                    "enabled": true
425                }
426            }
427        }));
428
429        let config = translate_strict(&sources).expect("a boolean V3 series mode should translate");
430
431        assert_eq!(config.shared.metrics_encoding.v3_series_mode, V3SeriesMode::Enabled);
432    }
433
434    #[tokio::test]
435    async fn connected_stream_translates_metrics_v3_routing_configuration() {
436        let (system, agent_tx) = connected_system(json!({
437            "data_plane": {
438                "metrics": {
439                    "v3": {
440                        "series": {
441                            "enabled": true
442                        }
443                    }
444                }
445            }
446        }))
447        .await;
448
449        assert_eq!(
450            system.config().shared.metrics_encoding.v3_series_mode,
451            V3SeriesMode::DatadogOnly
452        );
453
454        agent_tx
455            .send(ConfigUpdate::snapshot([
456                ConfigSetting::explicit("serializer_compressor_kind", json!("zstd")),
457                ConfigSetting::explicit("serializer_experimental_use_v3_api.compression_level", json!(7)),
458                ConfigSetting::explicit(
459                    "serializer_experimental_use_v3_api.series.endpoints",
460                    json!(["https://app.us3.datadoghq.com"]),
461                ),
462                ConfigSetting::explicit("use_v2_api.series", json!(false)),
463                ConfigSetting::explicit("use_v3_api.series.enabled", json!("false")),
464                // The Agent sends an object-valued setting whole, and these entry keys contain dots.
465                ConfigSetting::explicit(
466                    "use_v3_api.series.endpoints",
467                    json!({ "https://app.datadoghq.com": "true" }),
468                ),
469                ConfigSetting::explicit("observability_pipelines_worker.metrics.enabled", json!(true)),
470                ConfigSetting::explicit(
471                    "observability_pipelines_worker.metrics.url",
472                    json!("https://opw.example.com"),
473                ),
474                ConfigSetting::explicit("observability_pipelines_worker.metrics.use_v3_api.series", json!(true)),
475            ]))
476            .await
477            .unwrap();
478
479        await_config(&system, "the streamed metrics V3 routing configuration", |config| {
480            config.shared.metrics_encoding.v3_series_mode == V3SeriesMode::Disabled
481                && config
482                    .shared
483                    .metrics_encoding
484                    .v3_series_endpoint_modes
485                    .get("https://app.datadoghq.com")
486                    == Some(&V3SeriesMode::Enabled)
487        })
488        .await;
489
490        let config = system.config();
491        let metrics = &config.shared.metrics_encoding;
492        assert!(!metrics.use_v2_series_api);
493        assert_eq!(metrics.v3_api.compression_level, 7);
494        assert_eq!(metrics.v3_api.series.endpoints, vec!["https://app.us3.datadoghq.com"]);
495        let opw = &config.shared.endpoints.opw_intake;
496        assert!(opw.enabled);
497        assert_eq!(opw.url, "https://opw.example.com");
498        assert!(opw.use_v3_series);
499    }
500
501    #[tokio::test]
502    async fn nested_datadog_key_reaches_the_model() {
503        // Sources deliver the Agent's canonical nested shape, which is what the Datadog
504        // deserializer reads. A string list supplied as one space-separated string (the form an
505        // environment variable carries) is still split on whitespace at the leaf.
506        let system = standalone_system(
507            Some(json!({
508                "autoscaling": {
509                    "failover": {
510                        "enabled": true,
511                        "metrics": "container.memory.usage container.cpu.usage",
512                    }
513                }
514            })),
515            None,
516        )
517        .await
518        .expect("system builds");
519        let config = system.config();
520
521        assert!(config.shared.autoscaling_failover.enabled);
522        assert_eq!(
523            config.shared.autoscaling_failover.metrics,
524            vec!["container.memory.usage".to_string(), "container.cpu.usage".to_string()]
525        );
526    }
527
528    #[tokio::test]
529    async fn nested_saluki_only_key_seeds_the_model() {
530        let system = standalone_system(Some(json!({ "data_plane": { "standalone_mode": true } })), None)
531            .await
532            .expect("system builds");
533
534        assert!(system.config().control.standalone_mode);
535    }
536
537    #[tokio::test]
538    async fn a_flattened_spelling_of_a_nested_key_is_not_read() {
539        // Nothing translates `autoscaling_failover_enabled` into the nested slot: resolving an
540        // environment variable to its canonical path is the environment readers' job, and they do it
541        // before a value ever reaches this point. A flattened key arriving from any other source is
542        // simply not a key the model knows.
543        let system = standalone_system(Some(json!({ "autoscaling_failover_enabled": true })), None)
544            .await
545            .expect("system builds");
546
547        assert!(!system.config().shared.autoscaling_failover.enabled);
548    }
549
550    #[tokio::test]
551    async fn load_fails_on_translation_invalid_startup_config() {
552        // Startup is the strict gate: a value figment accepts but the model rejects fails the load,
553        // so the process never boots on bad config.
554        let result = standalone_system(Some(json!({ "dogstatsd_tag_cardinality": "bogus" })), None).await;
555
556        assert!(matches!(result, Err(Error::Translate { .. })));
557    }
558
559    #[tokio::test]
560    async fn negative_dogstatsd_workers_count_is_rejected_at_startup() {
561        let result = standalone_system(Some(json!({ "dogstatsd_workers_count": -1 })), None).await;
562
563        let Err(error) = result else {
564            panic!("negative worker count should fail the startup translation gate");
565        };
566        assert!(matches!(error, Error::Translate { .. }));
567        assert!(error.to_string().contains("dogstatsd_workers_count"));
568        assert!(error.to_string().contains("greater than or equal to 0"));
569    }
570
571    #[test]
572    fn zero_otlp_trace_interner_size_is_rejected() {
573        // Component builders used to discover this after translation. Reject zero before publishing
574        // an invalid typed model.
575        let sources = SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": 0 } } }));
576        let error = translate_strict(&sources).expect_err("zero trace interner size should fail translation");
577
578        assert!(matches!(error, Error::Deserialize { .. }));
579        assert!(error.to_string().contains("value of bytes must be greater than zero"));
580    }
581
582    #[test]
583    fn oversized_otlp_trace_interner_size_is_rejected() {
584        let sources =
585            SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": "2GiB" } } }));
586        let error = translate_strict(&sources).expect_err("oversized trace interner should fail translation");
587
588        assert!(matches!(error, Error::Deserialize { .. }));
589        assert!(error.to_string().contains("must not exceed 1073741824 bytes"));
590    }
591
592    #[test]
593    fn positive_otlp_trace_interner_size_is_accepted() {
594        let sources =
595            SourceTree::all_explicit(json!({ "otlp_config": { "traces": { "string_interner_size": "512KiB" } } }));
596        let config = translate_strict(&sources).expect("positive trace interner size should translate");
597
598        assert_eq!(config.domains.otlp.traces.string_interner_size.get(), 512 * 1024);
599    }
600
601    #[test]
602    fn invalid_metric_tag_value_allowlist_is_rejected_before_publication() {
603        let sources = SourceTree::all_explicit(json!({
604            "metric_tag_value_allowlist": [
605                { "metric_prefix": "requests.", "tag_name": "customer_id" },
606                { "metric_prefix": "requests.api.", "tag_name": "customer_id" }
607            ]
608        }));
609        let error = translate_strict(&sources).expect_err("overlapping allow-list prefixes should fail translation");
610
611        assert!(matches!(error, Error::Deserialize { .. }));
612        assert!(error.to_string().contains("overlapping metric prefixes"));
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(Some(json!({ "dogstatsd_log_file_max_size": 10485760 })), None)
621            .await
622            .expect("numeric byte size boots");
623
624        assert_eq!(system.config().domains.dogstatsd.debug_log.log_file_max_size, 10485760);
625    }
626
627    #[test]
628    fn a_configuration_without_an_api_key_is_rejected() {
629        // Nothing ADP submits is accepted without a key, so the authoritative gate names the cause
630        // rather than letting every flush fail authentication. Translation alone accepts this: the
631        // schema default for `api_key` is the empty string, so nothing is missing to translate.
632        let sources = SourceTree::all_explicit(json!({}));
633        translate_strict(&sources).expect("an absent API key still translates");
634
635        let error = translate_authoritative(&sources).expect_err("an absent API key should fail the gate");
636
637        assert!(matches!(error, Error::MissingApiKey));
638        assert!(error.to_string().contains("api_key"));
639    }
640
641    #[test]
642    fn a_blank_api_key_is_rejected() {
643        // An explicitly blank key is as unusable as an absent one, and whitespace is a typo worth
644        // reporting rather than submitting.
645        for key in ["", "   ", "\t\n"] {
646            let sources = SourceTree::all_explicit(json!({ "api_key": key }));
647
648            assert!(
649                matches!(translate_authoritative(&sources), Err(Error::MissingApiKey)),
650                "{key:?} should not count as an API key"
651            );
652        }
653
654        let sources = SourceTree::all_explicit(json!({ "api_key": TEST_API_KEY }));
655        assert_eq!(
656            TEST_API_KEY,
657            translate_authoritative(&sources)
658                .expect("a real key passes the gate")
659                .shared
660                .endpoints
661                .api_key
662        );
663    }
664
665    #[tokio::test]
666    async fn an_update_that_blanks_the_api_key_is_rejected_keeping_last_known_good() {
667        // Validation covers runtime updates too: an update that leaves the process unable to submit
668        // anything is rejected like any other invalid one, and the working key stays in place.
669        let (system, agent_tx) = connected_system(json!({ "log_level": "warn" })).await;
670        assert_eq!(TEST_API_KEY, system.config().shared.endpoints.api_key);
671
672        agent_tx
673            .send(ConfigUpdate::Partial(ConfigSetting::explicit("api_key", json!(""))))
674            .await
675            .unwrap();
676        agent_tx
677            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
678                "log_level",
679                json!("error"),
680            )))
681            .await
682            .unwrap();
683
684        await_config(&system, "the later valid update to take effect", |c| {
685            c.control.logging.level == "error"
686        })
687        .await;
688        assert_eq!(TEST_API_KEY, system.config().shared.endpoints.api_key);
689    }
690
691    #[tokio::test]
692    async fn standalone_loads_scalars_written_in_any_form_the_agent_casts() {
693        // The Agent reads a setting by casting whatever its configuration holds to the accessor's
694        // type, so a boolean written where the schema declares a string, or a quoted integer, is a
695        // configuration it accepts. Each must reach the typed model instead of aborting the strict
696        // startup gate.
697        let system = standalone_system(
698            Some(json!({
699                "use_v3_api": { "series": { "enabled": true } },
700                "dogstatsd_port": "8126",
701            })),
702            None,
703        )
704        .await
705        .expect("scalars in Agent-castable forms boot");
706
707        assert_eq!(
708            system.config().shared.metrics_encoding.v3_series_mode,
709            V3SeriesMode::Enabled
710        );
711        assert_eq!(system.config().domains.dogstatsd.listeners.port, 8126);
712    }
713
714    #[tokio::test]
715    async fn translation_invalid_update_is_rejected_keeping_last_known_good() {
716        let (system, agent_tx) =
717            connected_system(json!({ "log_level": "warn", "dogstatsd_tag_cardinality": "high" })).await;
718        assert_eq!(
719            system.config().domains.dogstatsd.origin.tag_cardinality,
720            OriginTagCardinality::High
721        );
722
723        // Send a translation-invalid update, then a valid update to a different field. Updates are
724        // processed in order, so once the second is observed the first has already been handled.
725        agent_tx
726            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
727                "dogstatsd_tag_cardinality",
728                json!("bogus"),
729            )))
730            .await
731            .unwrap();
732        agent_tx
733            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
734                "log_level",
735                json!("error"),
736            )))
737            .await
738            .unwrap();
739
740        await_config(&system, "the later valid update to take effect", |c| {
741            c.control.logging.level == "error"
742        })
743        .await;
744        // The invalid update was rejected whole: the field keeps its last-known-good value rather
745        // than falling back to a default, and the later valid update still applied.
746        assert_eq!(
747            system.config().domains.dogstatsd.origin.tag_cardinality,
748            OriginTagCardinality::High
749        );
750    }
751
752    #[tokio::test]
753    async fn invalid_metric_tag_value_allowlist_update_keeps_last_known_good() {
754        let initial_allowlist = json!([{
755            "metric_prefix": "requests.",
756            "tag_name": "customer_id",
757            "values": ["customer-1"]
758        }]);
759        let (system, agent_tx) = connected_system(json!({
760            "log_level": "warn",
761            "metric_tag_value_allowlist": initial_allowlist
762        }))
763        .await;
764
765        agent_tx
766            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
767                "metric_tag_value_allowlist",
768                json!([
769                    { "metric_prefix": "requests.", "tag_name": "customer_id" },
770                    { "metric_prefix": "requests.api.", "tag_name": "customer_id" }
771                ]),
772            )))
773            .await
774            .unwrap();
775        agent_tx
776            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
777                "log_level",
778                json!("error"),
779            )))
780            .await
781            .unwrap();
782
783        await_config(&system, "the later valid update to take effect", |config| {
784            config.control.logging.level == "error"
785        })
786        .await;
787        let config = system.config();
788        assert_eq!(config.domains.dogstatsd.tag_value_allowlist.len(), 1);
789        assert_eq!(
790            config.domains.dogstatsd.tag_value_allowlist[0].metric_prefix,
791            "requests."
792        );
793        assert_eq!(config.domains.dogstatsd.tag_value_allowlist[0].values, ["customer-1"]);
794    }
795
796    #[tokio::test]
797    async fn converges_to_latest_value_under_burst() {
798        let (system, agent_tx) = connected_system(json!({ "log_level": "info" })).await;
799
800        let burst = [
801            "warn", "error", "debug", "trace", "info", "warn", "error", "debug", "trace", "info", "warn", "error",
802            "debug", "trace", "info", "warn", "error", "debug", "trace",
803        ];
804        for (i, level) in burst.iter().enumerate() {
805            agent_tx
806                .send(ConfigUpdate::Partial(ConfigSetting::explicit(
807                    "log_level",
808                    json!(level),
809                )))
810                .await
811                .unwrap();
812            // Interleave a translation-invalid update mid-burst, then correct it. The invalid value
813            // is rejected whole (last-known-good retained) rather than wedging the task, so the
814            // baseline keeps converging on the latest valid value regardless of the transient bad one.
815            if i == burst.len() / 2 {
816                agent_tx
817                    .send(ConfigUpdate::Partial(ConfigSetting::explicit(
818                        "dogstatsd_tag_cardinality",
819                        json!("bogus"),
820                    )))
821                    .await
822                    .unwrap();
823                agent_tx
824                    .send(ConfigUpdate::Partial(ConfigSetting::explicit(
825                        "dogstatsd_tag_cardinality",
826                        json!("high"),
827                    )))
828                    .await
829                    .unwrap();
830            }
831        }
832        let final_level = "error";
833        agent_tx
834            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
835                "log_level",
836                json!(final_level),
837            )))
838            .await
839            .unwrap();
840
841        await_config(
842            &system,
843            "the current configuration to converge to the final value",
844            |c| c.control.logging.level == final_level,
845        )
846        .await;
847        assert_eq!(system.config().control.logging.level, final_level);
848    }
849
850    // Issue #1965: the Core Agent streams every setting it knows about, including the ones nobody
851    // configured, so its schema defaults must not overwrite the local file.
852    #[tokio::test]
853    async fn a_defaulted_agent_value_does_not_erase_a_local_one() {
854        let (system, agent_tx) = connected_system(json!({ "dd_url": "https://vector.example.com" })).await;
855
856        agent_tx
857            .send(ConfigUpdate::snapshot([ConfigSetting::new(
858                "dd_url",
859                json!("https://app.datadoghq.com"),
860                StreamProvenance::Default,
861            )]))
862            .await
863            .unwrap();
864        // A later unrelated update is observable, so once it lands the snapshot above has been handled.
865        agent_tx
866            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
867                "log_level",
868                json!("error"),
869            )))
870            .await
871            .unwrap();
872        await_config(&system, "the trailing update to take effect", |c| {
873            c.control.logging.level == "error"
874        })
875        .await;
876
877        let dd_url = &system.config().shared.endpoints.dd_url;
878        assert!(dd_url.is_explicit());
879        assert_eq!(dd_url.value, "https://vector.example.com");
880    }
881
882    #[tokio::test]
883    async fn demoting_an_agent_value_to_a_default_reveals_the_local_value() {
884        let (system, agent_tx) = connected_system(json!({ "dd_url": "https://vector.example.com" })).await;
885
886        // The Agent takes over the setting.
887        agent_tx
888            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
889                "dd_url",
890                json!("https://app.datadoghq.eu"),
891            )))
892            .await
893            .unwrap();
894        await_config(&system, "the Agent override to take effect", |c| {
895            c.shared.endpoints.dd_url.value == "https://app.datadoghq.eu"
896        })
897        .await;
898
899        // The operator removes it from the Agent's configuration, so the Agent now reports its own
900        // default. The Agent layer must stop shadowing the local value rather than pinning the value
901        // it last held.
902        agent_tx
903            .send(ConfigUpdate::Partial(ConfigSetting::new(
904                "dd_url",
905                json!("https://app.datadoghq.com"),
906                StreamProvenance::Default,
907            )))
908            .await
909            .unwrap();
910
911        await_config(&system, "the local value to be revealed again", |c| {
912            c.shared.endpoints.dd_url.value == "https://vector.example.com"
913        })
914        .await;
915        assert!(system.config().shared.endpoints.dd_url.is_explicit());
916    }
917
918    #[tokio::test]
919    async fn a_snapshot_replaces_the_agent_layer() {
920        let (system, agent_tx) = connected_system(json!({})).await;
921
922        agent_tx
923            .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
924                "dogstatsd_port",
925                json!(9125),
926            )]))
927            .await
928            .unwrap();
929        await_config(&system, "the first snapshot to take effect", |c| {
930            c.domains.dogstatsd.listeners.port == 9125
931        })
932        .await;
933
934        // A snapshot is the producer's complete state, so a setting it omits is no longer set and the
935        // port returns to the schema default rather than lingering at 9125.
936        agent_tx
937            .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
938                "log_level",
939                json!("error"),
940            )]))
941            .await
942            .unwrap();
943
944        await_config(&system, "the replacing snapshot to take effect", |c| {
945            c.control.logging.level == "error"
946        })
947        .await;
948        assert_eq!(system.config().domains.dogstatsd.listeners.port, 8125);
949    }
950
951    #[tokio::test]
952    async fn a_live_view_wakes_when_only_provenance_changes() {
953        let (system, agent_tx) = connected_system(json!({})).await;
954        let mut dd_url = system.live(|c| &c.shared.endpoints.dd_url);
955        // Nothing has set the URL, so it is the schema default the Agent supplies.
956        assert_eq!(dd_url.provenance, Provenance::Default);
957        assert_eq!(dd_url.value, "https://app.datadoghq.com");
958
959        // The same URL, now deliberately chosen. The value is unchanged, so only provenance can carry
960        // the fact that it became an override.
961        agent_tx
962            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
963                "dd_url",
964                json!("https://app.datadoghq.com"),
965            )))
966            .await
967            .unwrap();
968
969        let updated = tokio::time::timeout(Duration::from_secs(2), dd_url.changed())
970            .await
971            .expect("the view observes a provenance-only change");
972        assert_eq!(updated.provenance, Provenance::Explicit);
973        assert_eq!(updated.value, "https://app.datadoghq.com");
974    }
975
976    #[tokio::test]
977    async fn live_view_observes_debug_log_update() {
978        let (system, agent_tx) = connected_system(json!({ "dogstatsd_metrics_stats_enable": false })).await;
979        let mut view = system.live(|c| &c.domains.dogstatsd.debug_log);
980        assert!(!view.metrics_stats_enable);
981
982        agent_tx
983            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
984                "dogstatsd_metrics_stats_enable",
985                json!(true),
986            )))
987            .await
988            .unwrap();
989
990        let updated = tokio::time::timeout(Duration::from_secs(2), view.changed())
991            .await
992            .expect("view observes the debug-log update");
993        assert!(updated.metrics_stats_enable);
994        // `Deref` reflects the value returned by the last `changed`.
995        assert!(view.metrics_stats_enable);
996    }
997
998    #[tokio::test]
999    async fn field_view_wakes_on_its_field() {
1000        // Projecting straight to a single field needs no schema change and no central registration:
1001        // the granularity is chosen at the call site.
1002        let (system, agent_tx) = connected_system(json!({ "dogstatsd_metrics_stats_enable": false })).await;
1003        let mut stats = system.live(|c| &c.domains.dogstatsd.debug_log.metrics_stats_enable);
1004        assert!(!*stats);
1005
1006        agent_tx
1007            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1008                "dogstatsd_metrics_stats_enable",
1009                json!(true),
1010            )))
1011            .await
1012            .unwrap();
1013
1014        let updated = tokio::time::timeout(Duration::from_secs(2), stats.changed())
1015            .await
1016            .expect("field view observes its field's update");
1017        assert!(updated);
1018        assert!(*stats);
1019    }
1020
1021    #[tokio::test]
1022    async fn fixed_view_never_changes() {
1023        let mut view: Live<bool> = Live::new_fixed(true);
1024        assert!(*view);
1025        // A fixed view never resolves, so this bound is deterministic rather than timing-dependent.
1026        assert!(tokio::time::timeout(Duration::from_millis(100), view.changed())
1027            .await
1028            .is_err());
1029    }
1030
1031    #[tokio::test]
1032    async fn live_views_reflect_startup_configuration() {
1033        let system = standalone_system(Some(json!({ "dogstatsd_metrics_stats_enable": true })), None)
1034            .await
1035            .expect("system builds");
1036        let config = system.config();
1037
1038        let debug_log = system.live(|c| &c.domains.dogstatsd.debug_log);
1039        assert_eq!(&*debug_log, &config.domains.dogstatsd.debug_log);
1040
1041        let prefix_filter = system.live(|c| &c.domains.dogstatsd.prefix_filter);
1042        assert_eq!(&*prefix_filter, &config.domains.dogstatsd.prefix_filter);
1043
1044        let multi_region_failover = system.live(|c| &c.domains.multi_region_failover);
1045        assert_eq!(&*multi_region_failover, &config.domains.multi_region_failover);
1046    }
1047
1048    #[test]
1049    fn translate_small_map_through_witness_and_seed() {
1050        // A small raw source map exercising a scalar conversion, an enum parse, a duration parse, the
1051        // raw endpoint inputs, and one seeded Saluki-only field.
1052        let sources = SourceTree::all_explicit(json!({
1053            "api_key": "abc",
1054            "dd_url": "https://custom.example.com",
1055            "dogstatsd_port": 9125,
1056            "dogstatsd_tag_cardinality": "high",
1057            "expected_tags_duration": "15s",
1058            "provider_kind": "gke-autopilot",
1059            "eks_fargate": true,
1060            "kubernetes_kubelet_nodename": "fargate-node",
1061            "cluster_name": "fargate-cluster",
1062            "telemetry": { "dogstatsd_origin": true },
1063            "dogstatsd_tcp_port": 8126,
1064        }));
1065        let value = sources.to_value();
1066        let datadog: DatadogConfiguration = serde_json::from_value(value.clone()).expect("datadog source deserializes");
1067        let saluki: SalukiOnly = serde_json::from_value(value).expect("saluki-only source deserializes");
1068
1069        let (config, errors) = translate(&datadog, &saluki, &sources);
1070        assert!(errors.is_none(), "translation of a valid map records no error");
1071
1072        // Driven scalar conversion: i64 -> u16.
1073        assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
1074        // Driven enum parse.
1075        assert_eq!(
1076            config.domains.dogstatsd.origin.tag_cardinality,
1077            OriginTagCardinality::High
1078        );
1079        // Driven `format: duration` parse: a Go duration string becomes a `Duration`.
1080        assert_eq!(config.shared.tags.expected_tags_duration, Duration::from_secs(15));
1081        // Shared deployment inputs used to derive static tags for both DogStatsD and OTLP.
1082        assert_eq!(config.shared.static_tags.provider_kind, "gke-autopilot");
1083        assert!(config.shared.static_tags.eks_fargate);
1084        assert_eq!(config.shared.static_tags.kubernetes_kubelet_nodename, "fargate-node");
1085        assert_eq!(config.shared.static_tags.cluster_name, "fargate-cluster");
1086        // Driven bool in a nested Datadog section.
1087        assert!(config.domains.dogstatsd.telemetry.origin_breakdown);
1088        // Raw endpoint inputs: carried through without selecting a primary endpoint here.
1089        assert_eq!(config.shared.endpoints.api_key, "abc");
1090        assert!(config.shared.endpoints.dd_url.is_explicit());
1091        assert_eq!(config.shared.endpoints.dd_url.value, "https://custom.example.com");
1092        // Seeded Saluki-only field.
1093        assert_eq!(config.domains.dogstatsd.listeners.tcp_port, 8126);
1094    }
1095
1096    /// Datadog-defined aggregation keys reach their typed model fields through the witness translator.
1097    #[test]
1098    fn datadog_aggregation_keys_reach_the_model() {
1099        let sources = SourceTree::all_explicit(json!({
1100            "dogstatsd_expiry_seconds": 60,
1101            "dogstatsd_flush_incomplete_buckets": true,
1102        }));
1103        let value = sources.to_value();
1104        let datadog: DatadogConfiguration = serde_json::from_value(value.clone()).expect("datadog source deserializes");
1105        let saluki: SalukiOnly = serde_json::from_value(value).expect("saluki-only source deserializes");
1106
1107        let (config, errors) = translate(&datadog, &saluki, &sources);
1108        assert!(errors.is_none(), "translation of a valid map records no error");
1109
1110        let aggregation = &config.domains.dogstatsd.aggregation;
1111        assert_eq!(aggregation.counter_expiry_seconds, Some(60));
1112        assert!(aggregation.flush_open_windows);
1113    }
1114}