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::DATAGRAM_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/// Compressor both targets serialize metric payloads with.
121#[derive(Debug, Clone, Copy, Serialize)]
122#[serde(rename_all = "lowercase")]
123pub(crate) enum CompressorKind {
124 /// Deflate. Disables the v3 series intake on both targets.
125 Zlib,
126 /// Zstandard.
127 Zstd,
128 /// Gzip.
129 Gzip,
130 /// No compression, the Agent's `NoneKind`.
131 None,
132 /// A codec neither target implements, so each falls back its own way and the two lanes disagree
133 /// on the wire from one config value.
134 Snappy,
135}
136
137impl Distribution<CompressorKind> for StandardUniform {
138 fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> CompressorKind {
139 match rng.random_range(0..5u8) {
140 0 => CompressorKind::Zlib,
141 1 => CompressorKind::Zstd,
142 2 => CompressorKind::Gzip,
143 3 => CompressorKind::None,
144 _ => CompressorKind::Snappy,
145 }
146 }
147}
148
149/// The Agent's nested `use_v3_api.series` switch between the v2 and v3 series
150/// intake.
151#[derive(Debug, Serialize)]
152pub(crate) struct UseV3ApiConfig {
153 /// The series sub-tree.
154 series: V3SeriesConfig,
155}
156
157/// The `enabled` leaf under a v3 series key.
158#[derive(Debug, Serialize)]
159pub(crate) struct V3SeriesConfig {
160 /// Whether the series intake is v3 rather than v2. A string because both the Agent and ADP read
161 /// this leaf as one, and ADP's typed model rejects a YAML boolean outright.
162 enabled: &'static str,
163}
164
165/// Agent-facing config. `hostname`, `api_key`, `dd_url`, and the socket are
166/// supplied by the environment; `log_level`, the series intake API, and the
167/// `DogStatsD` options are sampled per branch. The static flags are appended by
168/// [`Self::to_yaml`], not fields here.
169#[derive(Debug, Serialize)]
170pub struct DatadogConfig {
171 /// Agent hostname. Supplied by the environment. ADP requires it
172 /// (`FixedHostProvider`); absent it refuses to boot.
173 hostname: String,
174 /// Agent API key. Supplied by the environment.
175 api_key: String,
176 /// Metrics intake base URL. Supplied by the environment.
177 dd_url: String,
178 /// Agent log verbosity. Pinned to `error` (see [`LogLevel`]).
179 log_level: LogLevel,
180 /// Series intake API for this timeline.
181 use_v3_api: UseV3ApiConfig,
182 /// Compressor for metric payloads. Sampled independently of the series API.
183 serializer_compressor_kind: CompressorKind,
184 /// ADP's safety gate for authoritative v3 series, which the Agent has no counterpart for.
185 /// Sampled with [`Self::use_v3_api`] so ADP and the Agent never split encodings in a timeline.
186 data_plane_metrics_v3_series_enabled: bool,
187 /// `DogStatsD` options, flattened to top-level `dogstatsd_*` keys.
188 #[serde(flatten)]
189 dogstatsd: DogStatsdConfig,
190}
191
192impl DatadogConfig {
193 /// Generate a config: the environmental fields come from the caller, the rest
194 /// are sampled from `rng`. With an Antithesis-backed rng, each call after the
195 /// snapshot yields an independent draw per replay branch.
196 #[must_use]
197 pub fn sample<R: Rng + ?Sized>(
198 rng: &mut R, hostname: &str, api_key: &str, dd_url: &str, dogstatsd_socket: &Path,
199 ) -> Self {
200 let series_v3 = rng.random();
201 Self {
202 hostname: hostname.to_owned(),
203 api_key: api_key.to_owned(),
204 dd_url: dd_url.to_owned(),
205 log_level: LogLevel::Error,
206 use_v3_api: UseV3ApiConfig {
207 series: V3SeriesConfig {
208 enabled: if series_v3 { "true" } else { "false" },
209 },
210 },
211 serializer_compressor_kind: rng.random(),
212 data_plane_metrics_v3_series_enabled: series_v3,
213 dogstatsd: DogStatsdConfig::sample(rng, dogstatsd_socket),
214 }
215 }
216
217 /// Render `self` as a `datadog.yaml` string, followed by the static-tail
218 /// flags.
219 ///
220 /// # Errors
221 ///
222 /// Returns an error if serialization fails.
223 pub fn to_yaml(&self) -> anyhow::Result<String> {
224 let mut yaml = serde_yaml::to_string(self).context("serialize datadog.yaml")?;
225 yaml.push_str(STATIC_YAML_TAIL);
226 Ok(yaml)
227 }
228
229 /// Derive the [`DriverConfig`] a load generator reads to match this timeline's
230 /// SUT, sampling its knobs from `rng` so they land with the SUT config and the
231 /// two cannot disagree.
232 #[must_use]
233 pub fn driver_config<R: Rng + ?Sized>(&self, rng: &mut R) -> DriverConfig {
234 DriverConfig::sample(rng, self.dogstatsd.dogstatsd_buffer_size)
235 }
236}
237
238/// Yaml flags the Agent reads at boot that never vary.
239const STATIC_YAML_TAIL: &str = "use_dogstatsd: true
240inventories_enabled: false
241enable_metadata_collection: false
242cloud_provider_metadata: []
243";
244
245/// Upper bound on datagrams one driver invocation ships in a timeline.
246const MAX_DATAGRAMS: usize = 10_000;
247
248/// Upper bound on the working set one driver invocation fetches from the shared context pool.
249const MAX_WORKING_SET: u64 = 1_024;
250
251/// The intake's ceiling on one `/contexts` request. A `context_count` past it is rejected there, so a
252/// config carrying one is rejected here instead.
253const MAX_CONTEXTS_PER_REQUEST: usize = 65_536;
254
255/// Config a load generator reads to shape its output to this timeline's SUT.
256/// `first_sample_config` samples it beside `datadog.yaml` from one draw, so the
257/// generator and the SUT are driven together.
258#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
259pub struct DriverConfig {
260 /// Max bytes a generator packs into one datagram, the smaller of the SUT's
261 /// sampled receive buffer and [`DATAGRAM_BYTE_LIMIT`]. A datagram this size
262 /// fits one read, so the SUT never truncates a line mid-token.
263 pub datagram_byte_limit: usize,
264 /// Datagrams a driver invocation ships this timeline.
265 pub datagram_count: usize,
266 /// Distinct contexts a driver invocation fetches from the shared pool as its working set.
267 ///
268 /// Sampled boundary-biased log-uniform in `1..=1_024`. Valid values run `1..=65_536`, the intake's
269 /// per-request ceiling. Outside that the intake rejects every `/contexts` request, the driver waits
270 /// out its fetch budget and ships nothing, so [`Self::read`] rejects such a config rather than
271 /// letting a timeline generate no load. Every context in a pull gets a line in every datagram that
272 /// has room, so a larger pull makes fatter datagrams over more identities rather than thinner
273 /// series, and the trade is against how often any one identity recurs.
274 pub context_count: usize,
275}
276
277impl DriverConfig {
278 /// Sample the driver knobs for a SUT whose receive buffer is `buffer_size`.
279 fn sample<R: Rng + ?Sized>(rng: &mut R, buffer_size: u64) -> Self {
280 // The min is at most DATAGRAM_BYTE_LIMIT, so a buffer wider than usize
281 // caps to the ceiling like any other oversized buffer.
282 let datagram_byte_limit = match usize::try_from(buffer_size.min(DATAGRAM_BYTE_LIMIT as u64)) {
283 Ok(bytes) => bytes,
284 Err(_) => DATAGRAM_BYTE_LIMIT,
285 };
286 Self {
287 datagram_byte_limit,
288 datagram_count: rng.random_range(0..=MAX_DATAGRAMS),
289 context_count: usize::try_from(Probe::new(1, MAX_WORKING_SET).sample(rng)).unwrap_or(usize::MAX),
290 }
291 }
292
293 /// Render `self` as a `driver.yaml` string.
294 ///
295 /// # Errors
296 ///
297 /// Returns an error if serialization fails.
298 pub fn to_yaml(&self) -> anyhow::Result<String> {
299 serde_yaml::to_string(self).context("serialize driver.yaml")
300 }
301
302 /// Read the driver config from the `driver.yaml` that `first_sample_config`
303 /// wrote to `config_dir`.
304 ///
305 /// # Errors
306 ///
307 /// Returns an error if the config is unreadable or is not valid YAML with an
308 /// integer `datagram_byte_limit`.
309 pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
310 let path = config_dir.join("driver.yaml");
311 let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
312 let config: Self =
313 serde_yaml::from_str(&yaml).with_context(|| format!("parse driver config from {}", path.display()))?;
314 anyhow::ensure!(
315 (1..=MAX_CONTEXTS_PER_REQUEST).contains(&config.context_count),
316 "context_count {} outside 1..={} in {}, the intake would reject every fetch and the driver would ship nothing",
317 config.context_count,
318 MAX_CONTEXTS_PER_REQUEST,
319 path.display()
320 );
321 Ok(config)
322 }
323}
324
325/// Upper bound on the distinct contexts a shared pool holds across every kind. The pool retains each
326/// minted context, so the ceiling belongs to the total rather than to any one kind.
327const MAX_CONTEXTS_TOTAL: u64 = 1_000_000;
328
329/// The per-kind caps a timeline's shared context pool fills to before it recurs existing contexts.
330/// `first_sample_config` samples this beside `datadog.yaml` so cardinality varies per timeline. Each
331/// cap is drawn against the budget the earlier draws left, so a kind's cardinality still varies at
332/// random while the three together stay under `MAX_CONTEXTS_TOTAL`.
333#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
334pub struct ContextSourceConfig {
335 /// Bytes a rendered line of any pooled context must fit, this timeline's real datagram budget
336 /// rather than the protocol ceiling. Mint builds identities against it, so every served context
337 /// has a rendering the driver can pack. Defaults to the sampled `datagram_byte_limit`, which is the
338 /// smaller of the SUT's receive buffer and [`DATAGRAM_BYTE_LIMIT`].
339 pub datagram_byte_limit: usize,
340 /// Distinct metric contexts the pool holds before it recurs the ones it has.
341 ///
342 /// Sampled in `2..=MAX_CONTEXTS_TOTAL` minus what the other kinds took. A larger cap explores more
343 /// identities and puts fewer points in each, and costs memory: the pool retains every context it
344 /// mints for the life of the run. Two is the floor because the pool holds one context carrying an
345 /// invalid UTF-8 byte per kind alongside the rest, and a kind capped at one could hold only one of
346 /// the two.
347 pub metric_contexts: usize,
348 /// Distinct event contexts the pool holds. Same range and trade as [`Self::metric_contexts`].
349 pub event_contexts: usize,
350 /// Distinct service-check contexts the pool holds. Same range and trade as
351 /// [`Self::metric_contexts`].
352 pub service_check_contexts: usize,
353}
354
355impl ContextSourceConfig {
356 /// Sample the per-kind caps, each boundary-biased log-uniform against the budget still free.
357 ///
358 /// The draws run in order and each spends from one shared ceiling, so the total is bounded by
359 /// construction rather than by scaling three independent draws afterwards. Every kind keeps at
360 /// least two contexts, one of which carries an invalid UTF-8 byte.
361 #[must_use]
362 pub fn sample<R: Rng + ?Sized>(rng: &mut R, datagram_byte_limit: usize) -> Self {
363 // Four contexts held back so the two later kinds can each keep their two.
364 let metric_contexts = sample_cap(rng, MAX_CONTEXTS_TOTAL - 4);
365 let free = MAX_CONTEXTS_TOTAL - metric_contexts as u64;
366 let event_contexts = sample_cap(rng, free - 2);
367 let free = free - event_contexts as u64;
368 Self {
369 datagram_byte_limit,
370 metric_contexts,
371 event_contexts,
372 service_check_contexts: sample_cap(rng, free),
373 }
374 }
375
376 /// Render `self` as a `context_source.yaml` string.
377 ///
378 /// # Errors
379 ///
380 /// Returns an error if serialization fails.
381 pub fn to_yaml(&self) -> anyhow::Result<String> {
382 serde_yaml::to_string(self).context("serialize context_source.yaml")
383 }
384
385 /// Read the context-source config from the `context_source.yaml` that `first_sample_config` wrote
386 /// to `config_dir`.
387 ///
388 /// # Errors
389 ///
390 /// Returns an error if the config is unreadable, is not valid YAML, or caps a kind below two. The
391 /// pool seeds every kind with one context carrying an invalid UTF-8 byte and one without, so a cap of
392 /// one cannot hold both and the pool would exceed its own cap assertion. [`Self::sample`] never draws
393 /// below two, and this rejects a config that reached the pool by another route rather than letting it
394 /// redden a run.
395 pub fn read(config_dir: &Path) -> anyhow::Result<Self> {
396 let path = config_dir.join("context_source.yaml");
397 let yaml = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
398 let config: Self = serde_yaml::from_str(&yaml)
399 .with_context(|| format!("parse context source config from {}", path.display()))?;
400 for (kind, cap) in [
401 ("metric_contexts", config.metric_contexts),
402 ("event_contexts", config.event_contexts),
403 ("service_check_contexts", config.service_check_contexts),
404 ] {
405 anyhow::ensure!(
406 cap >= MIN_CONTEXTS_PER_KIND,
407 "{kind} is {cap}, which is below the {MIN_CONTEXTS_PER_KIND} the pool seeds"
408 );
409 }
410 Ok(config)
411 }
412}
413
414/// The fewest contexts a kind may be capped at. The pool seeds every kind with one context carrying an
415/// invalid UTF-8 byte and one without, and both count against the cap.
416pub const MIN_CONTEXTS_PER_KIND: usize = 2;
417
418/// A single per-kind cap in `2..=ceiling`. The ceiling is well within `usize` on every supported
419/// target, so the saturating conversion is unreachable in practice.
420fn sample_cap<R: Rng + ?Sized>(rng: &mut R, ceiling: u64) -> usize {
421 usize::try_from(Probe::new(2, ceiling.max(2)).sample(rng)).unwrap_or(usize::MAX)
422}
423
424#[cfg(test)]
425mod tests {
426 use std::collections::BTreeSet;
427 use std::convert::Infallible;
428
429 use rand::rand_core::TryRng;
430
431 use super::*;
432
433 /// A trivial deterministic `SplitMix64` generator. Implementing `TryRng` with
434 /// an infallible error gives a blanket [`rand::Rng`].
435 #[derive(Debug)]
436 struct SeqRng(u64);
437
438 impl TryRng for SeqRng {
439 type Error = Infallible;
440
441 fn try_next_u32(&mut self) -> Result<u32, Infallible> {
442 let bytes = self.try_next_u64()?.to_le_bytes();
443 Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
444 }
445
446 fn try_next_u64(&mut self) -> Result<u64, Infallible> {
447 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
448 let mut z = self.0;
449 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
450 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
451 Ok(z ^ (z >> 31))
452 }
453
454 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Infallible> {
455 for chunk in dst.chunks_mut(8) {
456 let bytes = self.try_next_u64()?.to_le_bytes();
457 chunk.copy_from_slice(&bytes[..chunk.len()]);
458 }
459 Ok(())
460 }
461 }
462
463 fn render(seed: u64) -> String {
464 let mut rng = SeqRng(seed);
465 DatadogConfig::sample(&mut rng, "h", "k", "http://intake:2049", Path::new("/s.sock"))
466 .to_yaml()
467 .expect("render yaml")
468 }
469
470 /// A rendered top-level key, matched at the start of a line to avoid a
471 /// prefix collision between related keys.
472 fn has_key(yaml: &str, key: &str) -> bool {
473 yaml.lines().any(|line| line.starts_with(&format!("{key}:")))
474 }
475
476 // A cap below what the pool seeds would make the pool exceed its own cap assertion and redden the
477 // run on a config value. Rejected at the boundary, as the sibling driver config is.
478 #[test]
479 fn context_source_read_rejects_a_cap_below_the_seeded_minimum() {
480 let dir = std::env::temp_dir().join(format!("ctxcfg-{}", std::process::id()));
481 std::fs::create_dir_all(&dir).expect("create temp dir");
482 std::fs::write(
483 dir.join("context_source.yaml"),
484 "datagram_byte_limit: 8192\nmetric_contexts: 1\nevent_contexts: 4\nservice_check_contexts: 4\n",
485 )
486 .expect("write config");
487 let err = ContextSourceConfig::read(&dir).expect_err("a cap of 1 must be rejected");
488 assert!(err.to_string().contains("metric_contexts"), "{err}");
489 }
490
491 #[test]
492 fn driver_config_caps_payload_to_the_smaller_bound() {
493 assert_eq!(DriverConfig::sample(&mut SeqRng(0), 512).datagram_byte_limit, 512);
494 assert_eq!(
495 DriverConfig::sample(&mut SeqRng(0), 1 << 30).datagram_byte_limit,
496 DATAGRAM_BYTE_LIMIT
497 );
498 assert_eq!(DriverConfig::sample(&mut SeqRng(0), 0).datagram_byte_limit, 0);
499 }
500
501 #[test]
502 fn log_level_is_always_an_unambiguous_scalar() {
503 assert!(has_key(&render(0), "log_level"));
504 assert!(render(0).contains("log_level: error"));
505 }
506
507 /// The Agent's nested switch and ADP's safety gate, as a timeline renders them.
508 fn series_api(seed: u64) -> (bool, bool) {
509 let yaml = render(seed);
510 let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("parse rendered yaml");
511 // A string, not a YAML boolean: ADP's typed model reads this leaf as `String`, so an unquoted
512 // boolean fails deserialization and the target never boots.
513 let agent = match parsed["use_v3_api"]["series"]["enabled"]
514 .as_str()
515 .expect("use_v3_api.series.enabled is a string")
516 {
517 "true" => true,
518 "false" => false,
519 other => panic!("unexpected series mode {other}"),
520 };
521 let adp = parsed["data_plane_metrics_v3_series_enabled"]
522 .as_bool()
523 .expect("data_plane_metrics_v3_series_enabled");
524 (agent, adp)
525 }
526
527 #[test]
528 fn both_lanes_share_one_series_api() {
529 for seed in 0..16 {
530 let (agent, adp) = series_api(seed);
531 assert_eq!(agent, adp, "seed {seed}");
532 }
533 }
534
535 #[test]
536 fn series_api_samples_both_intakes() {
537 let mut seen = [false, false];
538 for seed in 0..16 {
539 seen[usize::from(series_api(seed).0)] = true;
540 }
541 assert_eq!(seen, [true, true]);
542 }
543
544 // The pool holds every minted context, so the ceiling is on the total across kinds rather than on
545 // each kind alone. Three independent draws at the ceiling would retain three million.
546 #[test]
547 fn context_caps_sum_within_the_total_ceiling() {
548 for seed in 0..64 {
549 let caps = ContextSourceConfig::sample(&mut SeqRng(seed), 8_192);
550 let total = (caps.metric_contexts + caps.event_contexts + caps.service_check_contexts) as u64;
551 assert!(total <= MAX_CONTEXTS_TOTAL, "seed {seed} sampled {total}");
552 // The pool seeds every kind with one context carrying an invalid UTF-8 byte and one without,
553 // and both count against the cap, so a kind capped below two makes the pool exceed its own
554 // cap assertion.
555 assert!(
556 caps.metric_contexts >= MIN_CONTEXTS_PER_KIND
557 && caps.event_contexts >= MIN_CONTEXTS_PER_KIND
558 && caps.service_check_contexts >= MIN_CONTEXTS_PER_KIND
559 );
560 }
561 }
562
563 // Randomness still drives each kind rather than the total being split evenly.
564 #[test]
565 fn context_caps_vary_per_kind() {
566 let spread: BTreeSet<usize> = (0..64)
567 .map(|seed| ContextSourceConfig::sample(&mut SeqRng(seed), 8_192).metric_contexts)
568 .collect();
569 assert!(spread.len() > 8, "metric cap barely varies: {spread:?}");
570 }
571
572 #[test]
573 fn compressor_samples_every_kind() {
574 let mut seen = BTreeSet::new();
575 for seed in 0..64 {
576 let yaml = render(seed);
577 let kind = yaml
578 .lines()
579 .find_map(|line| line.strip_prefix("serializer_compressor_kind: "))
580 .expect("serializer_compressor_kind")
581 .to_owned();
582 seen.insert(kind);
583 }
584 let want = ["gzip", "none", "snappy", "zlib", "zstd"]
585 .into_iter()
586 .map(str::to_owned)
587 .collect::<BTreeSet<_>>();
588 assert_eq!(seen, want);
589 }
590}