Skip to main content

harness/
config.rs

1//! Configuration model and rendering for Datadog Agent configuration.
2//!
3//! Primary focus is currently `DogStatsD` but this is, hopefully, easy to expand
4//! in the future.
5//!
6//! Two variations are sampled, selected by [`ConfigProfile`]. `General` samples
7//! the full feral surface, including keys the Datadog Agent rejects or
8//! interprets differently from ADP, which is correct when ADP runs alone.
9//! `Differential` samples only keys both ADP and the Datadog Agent honor
10//! identically, over value ranges both accept, so the A/B scenario compares two
11//! targets that were actually asked to behave the same way.
12
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use anyhow::Context as _;
17use clap::ValueEnum;
18use rand::distr::{Distribution, StandardUniform};
19use rand::{Rng, RngExt};
20use serde::{Serialize, Serializer};
21
22use crate::rand::Probe;
23
24/// Which `datadog.yaml` variation to sample.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
26pub enum ConfigProfile {
27    /// ADP-only scenario. Sample the full feral surface, including keys the
28    /// Datadog Agent rejects or interprets differently. Correct when ADP is the
29    /// only target under test.
30    General,
31    /// A/B scenario. Sample only keys both ADP and the Datadog Agent honor
32    /// identically, over value ranges both accept, so any divergence in their
33    /// output is a real difference and not a config the two were never asked to
34    /// share.
35    Differential,
36}
37
38impl ConfigProfile {
39    /// Returns `true` for the full feral surface, `false` for the shared subset.
40    fn is_general(self) -> bool {
41        matches!(self, ConfigProfile::General)
42    }
43}
44
45/// A Go `time.Duration`, rendered as a Go duration string (for example `100ms`)
46/// — the form the Agent's duration config keys parse.
47#[derive(Debug, Clone, Copy)]
48struct GoDuration(Duration);
49
50impl Serialize for GoDuration {
51    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
52        serializer.collect_str(&format_args!("{}ms", self.0.as_millis()))
53    }
54}
55
56/// A duration the Agent reads as a plain integer number of seconds (`GetInt`),
57/// rendered as that integer.
58#[derive(Debug, Clone, Copy)]
59struct DurationSeconds(Duration);
60
61impl Serialize for DurationSeconds {
62    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
63        serializer.serialize_u64(self.0.as_secs())
64    }
65}
66
67/// Agent log level.
68///
69/// Pinned to `error`: the quietest level that still logs and a value both
70/// targets parse identically. Louder levels blow Antithesis's per-hour
71/// log-output budget. `off` is intentionally absent — `serde_yaml` renders it as
72/// the bare scalar `off`, which a YAML 1.1 reader decodes as the boolean
73/// `false`, and the Datadog Agent then rejects the level and refuses to boot.
74#[derive(Debug, Clone, Copy, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub(crate) enum LogLevel {
77    /// Errors only — the quietest level that still logs.
78    Error,
79}
80
81/// Tag granularity for origin-detected `DogStatsD` tags.
82#[derive(Debug, Clone, Copy, Serialize)]
83#[serde(rename_all = "lowercase")]
84pub(crate) enum TagCardinality {
85    /// Low-cardinality objects: clusters, hosts, deployments, images. Agent
86    /// default.
87    Low,
88    /// Orchestrator-level: pod (Kubernetes) or task (ECS/Mesos) cardinality.
89    Orchestrator,
90    /// High-cardinality objects: individual containers, request user IDs, etc.
91    High,
92}
93
94impl Distribution<TagCardinality> for StandardUniform {
95    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> TagCardinality {
96        match rng.random_range(0..3u8) {
97            0 => TagCardinality::Low,
98            1 => TagCardinality::Orchestrator,
99            _ => TagCardinality::High,
100        }
101    }
102}
103
104/// The Agent's `DogStatsD` configuration surface. `dogstatsd_socket` is supplied
105/// by the environment; the rest are sampled.
106///
107/// Numeric fields are sampled with [`Probe`] over a per-field range. The
108/// `Option` fields are keys the Datadog Agent rejects (`Incompatible`) or honors
109/// differently from ADP (`Partial`); they are sampled only for [`ConfigProfile::General`]
110/// and omitted from the rendered config otherwise.
111#[allow(clippy::struct_field_names, clippy::struct_excessive_bools)]
112#[derive(Debug, Serialize)]
113pub(crate) struct DogStatsdConfig {
114    /// Unix socket the server listens on. Supplied by the environment.
115    dogstatsd_socket: PathBuf,
116    /// Buffer used to receive statsd packets, in bytes.
117    dogstatsd_buffer_size: u64,
118    /// Bytes for the socket receive buffer (`POSIX`); `0` keeps the OS default.
119    dogstatsd_so_rcvbuf: u64,
120    /// Packets buffered before flushing to the processing queue. ADP rejects
121    /// this key, so it is general-only.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    dogstatsd_packet_buffer_size: Option<u64>,
124    /// Maximum time packets sit in the packet buffer before a flush. ADP rejects
125    /// this key, so it is general-only.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    dogstatsd_packet_buffer_flush_timeout: Option<GoDuration>,
128    /// Internal queue size of the server. ADP rejects this key, so it is
129    /// general-only.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    dogstatsd_queue_size: Option<u64>,
132    /// Number of processing pipelines. ADP rejects this key, so it is
133    /// general-only.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    dogstatsd_pipeline_count: Option<u64>,
136    /// Worker count processing packets. ADP rejects this key, so it is
137    /// general-only.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    dogstatsd_workers_count: Option<u64>,
140    /// Seconds a counter is sampled to `0` after its last value before expiring.
141    /// ADP maps this onto its Saluki `counter_expiry_seconds` rather than the
142    /// Agent's context expiry, so it is general-only.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    dogstatsd_expiry_seconds: Option<DurationSeconds>,
145    /// Seconds a metric context is kept in memory after its last sample. The
146    /// Core Agent folds this into counter zero-emission alongside
147    /// `dogstatsd_expiry_seconds` while ADP holds counter expiry at its default.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    dogstatsd_context_expiry_seconds: Option<DurationSeconds>,
150    /// Maximum entries in the string interner cache.
151    dogstatsd_string_interner_size: u64,
152    /// Max number of metric-mapping results cached by the mapper. ADP differs on
153    /// this key, so it is general-only.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    dogstatsd_mapper_cache_size: Option<u64>,
156    /// Max metrics per payload from the no-aggregation pipeline. ADP rejects this
157    /// key, so it is general-only.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    dogstatsd_no_aggregation_pipeline_batch_size: Option<u64>,
160    /// Tag granularity for origin-detected tags.
161    dogstatsd_tag_cardinality: TagCardinality,
162    /// Listen for non-local UDP traffic (binds `0.0.0.0`).
163    dogstatsd_non_local_traffic: bool,
164    /// Tag metrics with container metadata from the Unix socket peer.
165    dogstatsd_origin_detection: bool,
166    /// Use a client-provided container ID to enrich metrics.
167    dogstatsd_origin_detection_client: bool,
168    /// Let clients opt out of origin detection via cardinality `none`.
169    dogstatsd_origin_optout_enabled: bool,
170    /// Collect basic per-metric statistics (count / last seen). ADP differs on
171    /// this key, so it is general-only.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    dogstatsd_metrics_stats_enable: Option<bool>,
174    /// When an `Entity-ID` is set, skip origin-detection tag enrichment.
175    dogstatsd_entity_id_precedence: bool,
176    /// Enable the no-aggregation pipeline (forward timestamped metrics with
177    /// tagging only).
178    dogstatsd_no_aggregation_pipeline: bool,
179    /// Flush incomplete metric time buckets on shutdown.
180    dogstatsd_flush_incomplete_buckets: bool,
181    /// Automatically adjust the number of processing pipelines. ADP rejects this
182    /// key, so it is general-only.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    dogstatsd_pipeline_autoadjust: Option<bool>,
185    /// Publish `DogStatsD` internal stats as Go expvars. ADP rejects this key, so
186    /// it is general-only.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    dogstatsd_stats_enable: Option<bool>,
189}
190
191impl DogStatsdConfig {
192    /// Sample the `DogStatsD` options from `rng` for `profile`, taking the socket
193    /// from the environment.
194    fn sample<R: Rng + ?Sized>(rng: &mut R, dogstatsd_socket: &Path, profile: ConfigProfile) -> Self {
195        let general = profile.is_general();
196        Self {
197            dogstatsd_socket: dogstatsd_socket.to_path_buf(),
198            dogstatsd_buffer_size: sample_buffer_size(rng, profile),
199            dogstatsd_so_rcvbuf: Probe::new(0, 34_359_738_368).sample(rng),
200            dogstatsd_packet_buffer_size: general.then(|| Probe::new(1, 10_000_000).sample(rng)),
201            dogstatsd_packet_buffer_flush_timeout: general
202                .then(|| GoDuration(Duration::from_millis(Probe::new(1, 86_400_000).sample(rng)))),
203            dogstatsd_queue_size: general.then(|| Probe::new(1, 10_000_000).sample(rng)),
204            dogstatsd_pipeline_count: general.then(|| Probe::new(1, 1_000_000).sample(rng)),
205            dogstatsd_workers_count: general.then(|| Probe::new(0, 1_000_000).sample(rng)),
206            dogstatsd_expiry_seconds: general
207                .then(|| DurationSeconds(Duration::from_secs(Probe::new(0, 31_536_000_000).sample(rng)))),
208            dogstatsd_context_expiry_seconds: general
209                .then(|| DurationSeconds(Duration::from_secs(Probe::new(0, 31_536_000_000).sample(rng)))),
210            dogstatsd_string_interner_size: Probe::new(1, MAX_STRING_INTERNER_ENTRIES).sample(rng),
211            dogstatsd_mapper_cache_size: general.then(|| Probe::new(0, 100_000_000).sample(rng)),
212            dogstatsd_no_aggregation_pipeline_batch_size: general.then(|| Probe::new(1, 10_000_000).sample(rng)),
213            dogstatsd_tag_cardinality: rng.random(),
214            dogstatsd_non_local_traffic: rng.random(),
215            dogstatsd_origin_detection: rng.random(),
216            dogstatsd_origin_detection_client: rng.random(),
217            dogstatsd_origin_optout_enabled: rng.random(),
218            dogstatsd_metrics_stats_enable: general.then(|| rng.random()),
219            dogstatsd_entity_id_precedence: rng.random(),
220            dogstatsd_no_aggregation_pipeline: rng.random(),
221            dogstatsd_flush_incomplete_buckets: rng.random(),
222            dogstatsd_pipeline_autoadjust: general.then(|| rng.random()),
223            dogstatsd_stats_enable: general.then(|| rng.random()),
224        }
225    }
226}
227
228/// Entry-count ceiling for `dogstatsd_string_interner_size`.
229///
230/// ADP and the Core Agent both preallocate the interner at boot, multiplying
231/// the entry count by 512 bytes when `dogstatsd_string_interner_size_bytes` is
232/// unset. The current value will cap out at 4 GiB.
233const MAX_STRING_INTERNER_ENTRIES: u64 = 8_388_608;
234
235/// Receive-buffer size in bytes.
236///
237/// For [`ConfigProfile::Differential`] the value stays in a realistic range so
238/// lines actually arrive on both targets. For [`ConfigProfile::General`] it is
239/// usually realistic but rarely tiny or wild to probe the truncation edge. A
240/// sampled `0` leaves ADP no room past the 4-byte length prefix, so it drops
241/// every packet before decode — useful when ADP runs alone, but it would make
242/// the differential targets diverge for a reason the oracle is not testing.
243fn sample_buffer_size<R: Rng + ?Sized>(rng: &mut R, profile: ConfigProfile) -> u64 {
244    if profile.is_general() && rng.random_ratio(1, 16) {
245        Probe::new(0, 536_870_912).sample(rng)
246    } else {
247        rng.random_range(128..=65_536)
248    }
249}
250
251/// Agent-facing config. `hostname`, `api_key`, `dd_url`, and the socket are
252/// supplied by the environment; `log_level` and the `DogStatsD` options are
253/// sampled per branch. The static flags are appended by [`Self::to_yaml`], not
254/// fields here.
255#[derive(Debug, Serialize)]
256pub struct DatadogConfig {
257    /// Agent hostname. Supplied by the environment. ADP requires it
258    /// (`FixedHostProvider`); absent it refuses to boot.
259    hostname: String,
260    /// Agent API key. Supplied by the environment.
261    api_key: String,
262    /// Metrics intake base URL. Supplied by the environment.
263    dd_url: String,
264    /// Agent log verbosity. Pinned to `error` (see [`LogLevel`]).
265    log_level: LogLevel,
266    /// Aggregate transform context cap, the `aggregate_context_limit` key. This
267    /// is a Saluki-only key with no Datadog Agent counterpart: ADP enforces it
268    /// as a hard per-flush context drop while the Agent ignores it. Sampled only
269    /// for [`ConfigProfile::General`]; omitted otherwise so the differential
270    /// targets are not asked to honor a limit only one of them implements.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    aggregate_context_limit: Option<u64>,
273    /// `DogStatsD` options, flattened to top-level `dogstatsd_*` keys.
274    #[serde(flatten)]
275    dogstatsd: DogStatsdConfig,
276}
277
278impl DatadogConfig {
279    /// Generate a config for `profile`: the environmental fields come from the
280    /// caller, the rest are sampled from `rng`. With an Antithesis-backed rng,
281    /// each call after the snapshot yields an independent draw per replay branch.
282    #[must_use]
283    pub fn sample<R: Rng + ?Sized>(
284        rng: &mut R, hostname: &str, api_key: &str, dd_url: &str, dogstatsd_socket: &Path, profile: ConfigProfile,
285    ) -> Self {
286        Self {
287            hostname: hostname.to_owned(),
288            api_key: api_key.to_owned(),
289            dd_url: dd_url.to_owned(),
290            log_level: LogLevel::Error,
291            aggregate_context_limit: profile.is_general().then(|| Probe::new(1, 100_000_000).sample(rng)),
292            dogstatsd: DogStatsdConfig::sample(rng, dogstatsd_socket, profile),
293        }
294    }
295
296    /// Render `self` as a `datadog.yaml` string, followed by the static-tail
297    /// flags.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if serialization fails.
302    pub fn to_yaml(&self) -> anyhow::Result<String> {
303        let mut yaml = serde_yaml::to_string(self).context("serialize datadog.yaml")?;
304        yaml.push_str(STATIC_YAML_TAIL);
305        Ok(yaml)
306    }
307}
308
309/// Yaml flags the Agent reads at boot that never vary.
310const STATIC_YAML_TAIL: &str = "use_dogstatsd: true
311use_v2_api_series: true
312inventories_enabled: false
313enable_metadata_collection: false
314cloud_provider_metadata: []
315";
316
317#[cfg(test)]
318mod tests {
319    use std::convert::Infallible;
320
321    use rand::rand_core::TryRng;
322
323    use super::*;
324
325    /// A trivial deterministic `SplitMix64` generator. Implementing `TryRng` with
326    /// an infallible error gives a blanket [`rand::Rng`]. Key presence in the
327    /// rendered config is gated by [`ConfigProfile`], not by sampled values, so
328    /// any source of bits suffices.
329    #[derive(Debug)]
330    struct SeqRng(u64);
331
332    impl TryRng for SeqRng {
333        type Error = Infallible;
334
335        fn try_next_u32(&mut self) -> Result<u32, Infallible> {
336            let bytes = self.try_next_u64()?.to_le_bytes();
337            Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
338        }
339
340        fn try_next_u64(&mut self) -> Result<u64, Infallible> {
341            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
342            let mut z = self.0;
343            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
344            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
345            Ok(z ^ (z >> 31))
346        }
347
348        fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
349            for chunk in dst.chunks_mut(8) {
350                let bytes = self.try_next_u64()?.to_le_bytes();
351                chunk.copy_from_slice(&bytes[..chunk.len()]);
352            }
353            Ok(())
354        }
355    }
356
357    /// Keys the Datadog Agent rejects (`Incompatible`), honors differently
358    /// (`Partial`), or does not define (Saluki-only). The differential config
359    /// must omit every one; the general config must keep every one.
360    const GENERAL_ONLY_KEYS: &[&str] = &[
361        "aggregate_context_limit",
362        "dogstatsd_packet_buffer_size",
363        "dogstatsd_packet_buffer_flush_timeout",
364        "dogstatsd_queue_size",
365        "dogstatsd_pipeline_count",
366        "dogstatsd_workers_count",
367        "dogstatsd_expiry_seconds",
368        "dogstatsd_context_expiry_seconds",
369        "dogstatsd_mapper_cache_size",
370        "dogstatsd_no_aggregation_pipeline_batch_size",
371        "dogstatsd_metrics_stats_enable",
372        "dogstatsd_pipeline_autoadjust",
373        "dogstatsd_stats_enable",
374    ];
375
376    fn render(profile: ConfigProfile) -> String {
377        let mut rng = SeqRng(0);
378        DatadogConfig::sample(&mut rng, "h", "k", "http://intake:2049", Path::new("/s.sock"), profile)
379            .to_yaml()
380            .expect("render yaml")
381    }
382
383    #[test]
384    fn differential_omits_keys_the_agent_does_not_share() {
385        let yaml = render(ConfigProfile::Differential);
386        for key in GENERAL_ONLY_KEYS {
387            assert!(!yaml.contains(key), "differential config must omit `{key}`:\n{yaml}");
388        }
389    }
390
391    #[test]
392    fn general_keeps_the_full_feral_surface() {
393        let yaml = render(ConfigProfile::General);
394        for key in GENERAL_ONLY_KEYS {
395            assert!(yaml.contains(key), "general config must include `{key}`:\n{yaml}");
396        }
397    }
398
399    #[test]
400    fn differential_keeps_keys_both_targets_share() {
401        let yaml = render(ConfigProfile::Differential);
402        for key in [
403            "dogstatsd_string_interner_size",
404            "dogstatsd_non_local_traffic",
405            "dogstatsd_no_aggregation_pipeline",
406        ] {
407            assert!(
408                yaml.contains(key),
409                "differential config dropped shared key `{key}`:\n{yaml}"
410            );
411        }
412    }
413
414    #[test]
415    fn log_level_is_an_unambiguous_scalar_for_both_profiles() {
416        for profile in [ConfigProfile::General, ConfigProfile::Differential] {
417            let yaml = render(profile);
418            assert!(
419                yaml.contains("log_level: error"),
420                "missing `log_level: error` in:\n{yaml}"
421            );
422        }
423    }
424}