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}