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//! One `datadog.yaml` is sampled per timeline. Only keys ADP and the Datadog
7//! Agent both fully support are emitted, and their values vary per timeline, so a
8//! divergence between the two targets is a finding rather than a config artifact.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::Context as _;
14use rand::distr::{Distribution, StandardUniform};
15use rand::{Rng, RngExt};
16use serde::{Deserialize, Serialize};
17
18use crate::payload::dogstatsd::PAYLOAD_BYTE_LIMIT;
19use crate::rand::Probe;
20
21/// Agent log level.
22///
23/// Pinned to `error`: the quietest level that still logs and a value both
24/// targets parse identically. Louder levels blow Antithesis's per-hour
25/// log-output budget. `off` is intentionally absent — `serde_yaml` renders it as
26/// the bare scalar `off`, which a YAML 1.1 reader decodes as the boolean
27/// `false`, and the Datadog Agent then rejects the level and refuses to boot.
28#[derive(Debug, Clone, Copy, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub(crate) enum LogLevel {
31 /// Errors only — the quietest level that still logs.
32 Error,
33}
34
35/// Tag granularity for origin-detected `DogStatsD` tags.
36#[derive(Debug, Clone, Copy, Serialize)]
37#[serde(rename_all = "lowercase")]
38pub(crate) enum TagCardinality {
39 /// Low-cardinality objects: clusters, hosts, deployments, images. Agent
40 /// default.
41 Low,
42 /// Orchestrator-level: pod (Kubernetes) or task (ECS/Mesos) cardinality.
43 Orchestrator,
44 /// High-cardinality objects: individual containers, request user IDs, etc.
45 High,
46}
47
48impl Distribution<TagCardinality> for StandardUniform {
49 fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> TagCardinality {
50 match rng.random_range(0..3u8) {
51 0 => TagCardinality::Low,
52 1 => TagCardinality::Orchestrator,
53 _ => TagCardinality::High,
54 }
55 }
56}
57
58/// The Agent's `DogStatsD` configuration surface. `dogstatsd_socket` is supplied
59/// by the environment, the rest are sampled.
60///
61/// Numeric fields are sampled per field, the wide ones with [`Probe`].
62#[allow(clippy::struct_field_names, clippy::struct_excessive_bools)]
63#[derive(Debug, Serialize)]
64pub(crate) struct DogStatsdConfig {
65 /// Unix socket the server listens on. Supplied by the environment.
66 dogstatsd_socket: PathBuf,
67 /// Buffer used to receive statsd packets, in bytes.
68 dogstatsd_buffer_size: u64,
69 /// Bytes for the socket receive buffer (`POSIX`); `0` keeps the OS default.
70 dogstatsd_so_rcvbuf: u64,
71 /// Maximum entries in the string interner cache.
72 dogstatsd_string_interner_size: u64,
73 /// Tag granularity for origin-detected tags.
74 dogstatsd_tag_cardinality: TagCardinality,
75 /// Listen for non-local UDP traffic (binds `0.0.0.0`).
76 dogstatsd_non_local_traffic: bool,
77 /// Tag metrics with container metadata from the Unix socket peer.
78 dogstatsd_origin_detection: bool,
79 /// Use a client-provided container ID to enrich metrics.
80 dogstatsd_origin_detection_client: bool,
81 /// Let clients opt out of origin detection via cardinality `none`.
82 dogstatsd_origin_optout_enabled: bool,
83 /// When an `Entity-ID` is set, skip origin-detection tag enrichment.
84 dogstatsd_entity_id_precedence: bool,
85 /// Enable the no-aggregation pipeline (forward timestamped metrics with
86 /// tagging only).
87 dogstatsd_no_aggregation_pipeline: bool,
88 /// Flush incomplete metric time buckets on shutdown.
89 dogstatsd_flush_incomplete_buckets: bool,
90}
91
92impl DogStatsdConfig {
93 /// Sample the `DogStatsD` options from `rng`, taking the socket from the
94 /// environment.
95 fn sample<R: Rng + ?Sized>(rng: &mut R, dogstatsd_socket: &Path) -> Self {
96 Self {
97 dogstatsd_socket: dogstatsd_socket.to_path_buf(),
98 dogstatsd_buffer_size: rng.random_range(128..=65_536),
99 dogstatsd_so_rcvbuf: Probe::new(0, 25_165_824).sample(rng),
100 dogstatsd_string_interner_size: Probe::new(1, MAX_STRING_INTERNER_ENTRIES).sample(rng),
101 dogstatsd_tag_cardinality: rng.random(),
102 dogstatsd_non_local_traffic: rng.random(),
103 dogstatsd_origin_detection: rng.random(),
104 dogstatsd_origin_detection_client: rng.random(),
105 dogstatsd_origin_optout_enabled: rng.random(),
106 dogstatsd_entity_id_precedence: rng.random(),
107 dogstatsd_no_aggregation_pipeline: rng.random(),
108 dogstatsd_flush_incomplete_buckets: rng.random(),
109 }
110 }
111}
112
113/// Entry-count ceiling for `dogstatsd_string_interner_size`.
114///
115/// ADP and the Core Agent both preallocate the interner at boot, multiplying
116/// the entry count by 512 bytes when `dogstatsd_string_interner_size_bytes` is
117/// unset. The current value caps the preallocation at 512 MiB.
118const MAX_STRING_INTERNER_ENTRIES: u64 = 1_048_576;
119
120/// Agent-facing config. `hostname`, `api_key`, `dd_url`, and the socket are
121/// supplied by the environment; `log_level` and the `DogStatsD` options are
122/// sampled per branch. The static flags are appended by [`Self::to_yaml`], not
123/// fields here.
124#[derive(Debug, Serialize)]
125pub struct DatadogConfig {
126 /// Agent hostname. Supplied by the environment. ADP requires it
127 /// (`FixedHostProvider`); absent it refuses to boot.
128 hostname: String,
129 /// Agent API key. Supplied by the environment.
130 api_key: String,
131 /// Metrics intake base URL. Supplied by the environment.
132 dd_url: String,
133 /// Agent log verbosity. Pinned to `error` (see [`LogLevel`]).
134 log_level: LogLevel,
135 /// `DogStatsD` options, flattened to top-level `dogstatsd_*` keys.
136 #[serde(flatten)]
137 dogstatsd: DogStatsdConfig,
138}
139
140impl DatadogConfig {
141 /// Generate a config: the environmental fields come from the caller, the rest
142 /// are sampled from `rng`. With an Antithesis-backed rng, each call after the
143 /// snapshot yields an independent draw per replay branch.
144 #[must_use]
145 pub fn sample<R: Rng + ?Sized>(
146 rng: &mut R, hostname: &str, api_key: &str, dd_url: &str, dogstatsd_socket: &Path,
147 ) -> Self {
148 Self {
149 hostname: hostname.to_owned(),
150 api_key: api_key.to_owned(),
151 dd_url: dd_url.to_owned(),
152 log_level: LogLevel::Error,
153 dogstatsd: DogStatsdConfig::sample(rng, dogstatsd_socket),
154 }
155 }
156
157 /// Render `self` as a `datadog.yaml` string, followed by the static-tail
158 /// flags.
159 ///
160 /// # Errors
161 ///
162 /// Returns an error if serialization fails.
163 pub fn to_yaml(&self) -> anyhow::Result<String> {
164 let mut yaml = serde_yaml::to_string(self).context("serialize datadog.yaml")?;
165 yaml.push_str(STATIC_YAML_TAIL);
166 Ok(yaml)
167 }
168
169 /// Derive the [`DriverConfig`] a load generator reads to match this timeline's
170 /// SUT, sampling its knobs from `rng` so they land with the SUT config and the
171 /// two cannot disagree.
172 #[must_use]
173 pub fn driver_config<R: Rng + ?Sized>(&self, rng: &mut R) -> DriverConfig {
174 DriverConfig::sample(rng, self.dogstatsd.dogstatsd_buffer_size)
175 }
176}
177
178/// Yaml flags the Agent reads at boot that never vary.
179const STATIC_YAML_TAIL: &str = "use_dogstatsd: true
180use_v2_api_series: true
181inventories_enabled: false
182enable_metadata_collection: false
183cloud_provider_metadata: []
184";
185
186/// Upper bound on datagrams one driver invocation ships in a timeline.
187const MAX_DATAGRAMS: usize = 10_000;
188
189/// Config a load generator reads to shape its output to this timeline's SUT.
190/// `first_sample_config` samples it beside `datadog.yaml` from one draw, so the
191/// generator and the SUT are driven together.
192#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
193pub struct DriverConfig {
194 /// Max bytes a generator packs into one datagram, the smaller of the SUT's
195 /// sampled receive buffer and [`PAYLOAD_BYTE_LIMIT`]. A datagram this size
196 /// fits one read, so the SUT never truncates a line mid-token.
197 pub payload_byte_limit: usize,
198 /// Datagrams a driver invocation ships this timeline.
199 pub datagram_count: usize,
200}
201
202impl DriverConfig {
203 /// Sample the driver knobs for a SUT whose receive buffer is `buffer_size`.
204 fn sample<R: Rng + ?Sized>(rng: &mut R, buffer_size: u64) -> Self {
205 // The min is at most PAYLOAD_BYTE_LIMIT, so a buffer wider than usize
206 // caps to the ceiling like any other oversized buffer.
207 let payload_byte_limit = match usize::try_from(buffer_size.min(PAYLOAD_BYTE_LIMIT as u64)) {
208 Ok(bytes) => bytes,
209 Err(_) => PAYLOAD_BYTE_LIMIT,
210 };
211 Self {
212 payload_byte_limit,
213 datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
214 }
215 }
216
217 /// Render `self` as a `driver.yaml` string.
218 ///
219 /// # Errors
220 ///
221 /// Returns an error if serialization fails.
222 pub fn to_yaml(&self) -> anyhow::Result<String> {
223 serde_yaml::to_string(self).context("serialize driver.yaml")
224 }
225
226 /// Read the driver config from the `driver.yaml` that `first_sample_config`
227 /// wrote to `config_dir`.
228 ///
229 /// # Errors
230 ///
231 /// Returns an error if the config is unreadable or is not valid YAML with an
232 /// integer `payload_byte_limit`.
233 pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
234 let path = config_dir.join("driver.yaml");
235 let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
236 serde_yaml::from_str(&yaml).with_context(|| format!("parse driver config from {}", path.display()))
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use std::convert::Infallible;
243
244 use rand::rand_core::TryRng;
245
246 use super::*;
247
248 /// A trivial deterministic `SplitMix64` generator. Implementing `TryRng` with
249 /// an infallible error gives a blanket [`rand::Rng`].
250 #[derive(Debug)]
251 struct SeqRng(u64);
252
253 impl TryRng for SeqRng {
254 type Error = Infallible;
255
256 fn try_next_u32(&mut self) -> Result<u32, Infallible> {
257 let bytes = self.try_next_u64()?.to_le_bytes();
258 Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
259 }
260
261 fn try_next_u64(&mut self) -> Result<u64, Infallible> {
262 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
263 let mut z = self.0;
264 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
265 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
266 Ok(z ^ (z >> 31))
267 }
268
269 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
270 for chunk in dst.chunks_mut(8) {
271 let bytes = self.try_next_u64()?.to_le_bytes();
272 chunk.copy_from_slice(&bytes[..chunk.len()]);
273 }
274 Ok(())
275 }
276 }
277
278 fn render(seed: u64) -> String {
279 let mut rng = SeqRng(seed);
280 DatadogConfig::sample(&mut rng, "h", "k", "http://intake:2049", Path::new("/s.sock"))
281 .to_yaml()
282 .expect("render yaml")
283 }
284
285 /// A rendered top-level key, matched at the start of a line to avoid a
286 /// prefix collision between related keys.
287 fn has_key(yaml: &str, key: &str) -> bool {
288 yaml.lines().any(|line| line.starts_with(&format!("{key}:")))
289 }
290
291 #[test]
292 fn driver_config_caps_payload_to_the_smaller_bound() {
293 assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).payload_byte_limit, 512);
294 assert_eq!(
295 DriverConfig::sample(&mut SeqRng(0), 1 << 30).payload_byte_limit,
296 PAYLOAD_BYTE_LIMIT
297 );
298 assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).payload_byte_limit, 0);
299 }
300
301 #[test]
302 fn log_level_is_always_an_unambiguous_scalar() {
303 assert!(has_key(&render(0), "log_level"));
304 assert!(render(0).contains("log_level: error"));
305 }
306}