harness/payload/
dogstatsd.rs

1//! `DogStatsD` datagram packing from one pull of pooled contexts.
2//!
3//! A driver pulls a set of [`Context`]s from the shared intake pool, [`crate::contexts`], once per
4//! invocation and reuses it for every datagram it sends. Each datagram is a fresh per-occurrence render
5//! of as many of those contexts as fit, and the rest are dropped. Identities recur because the pool is
6//! bounded, while their load varies per render. There is no clean/feral/mixed configuration. The legal
7//! space is exactly the set of payloads [`crate::dogstatsd::is_malformed`] accepts.
8//!
9//! Whether a datagram carries a non-UTF-8 byte is settled by the pull, before the driver sees the
10//! contexts. The intake puts such a context in a fixed fraction of pulls, and a pull holding one leads
11//! every datagram with it, so the fraction of datagrams carrying the byte is the fraction of pulls that
12//! do. Nothing here depends on how many contexts a timeline sampled, which is what makes the rate hold
13//! for a pull of one as well as a pull of a thousand.
14//!
15//! ```text
16//! metric:        <NAME>:<VALUE>(:<VALUE>)*|<TYPE>[|@<RATE>][|#<TAGS>][|c:..][|e:..][|card:..]
17//! event:         _e{<TITLE_LEN>,<TEXT_LEN>}:<TITLE>|<TEXT>[|opt...]|d:<TS>[|#<TAGS>]
18//! service check: _sc|<NAME>|<STATUS>[|opt...]|d:<TS>[|#<TAGS>]|m:<MESSAGE>
19//! ```
20
21use rand::{Rng, RngExt};
22
23use crate::contexts::Context;
24
25pub(crate) mod common;
26
27/// Ceiling on a generated datagram, the Datadog Agent's default `dogstatsd_buffer_size`. A run caps
28/// each datagram to the smaller of this and the SUT's sampled receive buffer, so a packed datagram
29/// always fits one read and the SUT never truncates a line mid-token.
30pub const DATAGRAM_BYTE_LIMIT: usize = 8_192;
31
32/// What a generated datagram holds, for anchoring assertions.
33#[derive(Clone, Copy, Debug, Default)]
34pub struct DatagramStats {
35    /// Lines packed into the datagram.
36    pub lines: usize,
37    /// Largest packed multi-value run among those lines. Zero when none.
38    pub max_packed: usize,
39}
40
41/// One pull of contexts, held for a driver invocation and packed into every datagram it sends.
42#[derive(Clone, Debug)]
43pub struct Pull {
44    /// The contexts as served.
45    contexts: Vec<Context>,
46    /// The context carrying an invalid UTF-8 byte, if the pull holds one. It leads every datagram, so
47    /// the intake's per-pull decision reaches every datagram the invocation sends rather than a
48    /// sampling-dependent share of them.
49    lead: Option<usize>,
50    /// The smallest floor in the pull. Packing stops once the room left is under it, which is what keeps
51    /// a pull far larger than a datagram from costing a scan per datagram.
52    min_floor: usize,
53}
54
55impl Pull {
56    /// Take a pull of contexts. Returns `None` for an empty one, which the pool never serves.
57    #[must_use]
58    pub fn new(contexts: Vec<Context>) -> Option<Self> {
59        let min_floor = contexts.iter().map(Context::floor).min()?;
60        let lead = contexts.iter().position(Context::has_non_utf8);
61        Some(Self {
62            contexts,
63            lead,
64            min_floor,
65        })
66    }
67
68    /// Whether the pull carries a context bearing an invalid UTF-8 byte.
69    #[must_use]
70    pub fn carries_non_utf8(&self) -> bool {
71        self.lead.is_some()
72    }
73}
74
75/// Pack one `\n`-terminated line per context into `buf`, within `limit_bytes`, until the room left
76/// cannot hold the smallest context in the pull. Each line is a fresh per-occurrence render against the
77/// room left, so nothing is built and then thrown away, and a context whose line will not fit is passed
78/// over. Clears `buf` first.
79///
80/// A pull carrying an invalid UTF-8 byte renders that context first, where the whole budget is free, so
81/// the byte cannot be lost to a full datagram. The rest are walked from a fresh offset each datagram, so
82/// a pull larger than one datagram still puts every context it holds on the wire across the invocation
83/// rather than only the ones that happen to sort first.
84///
85/// # Panics
86///
87/// Panics when a context whose floor fits the room left produces no line the Agent forwards, and when a
88/// pull carrying an invalid UTF-8 byte cannot pack it. Both are generator bugs rather than SUT behaviour,
89/// and a quieter datagram or a thinner non-UTF-8 rate would hide them.
90pub fn write_datagram(
91    rng: &mut (impl Rng + ?Sized), pull: &Pull, buf: &mut Vec<u8>, limit_bytes: usize,
92) -> DatagramStats {
93    buf.clear();
94    let mut stats = DatagramStats::default();
95    let mut line = Vec::new();
96    if let Some(lead) = pull.lead {
97        let context = &pull.contexts[lead];
98        // The pull's decision has to reach this datagram. The pool mints every context against the same
99        // datagram limit less its newline, so the first line of an empty datagram always has room for it.
100        // Skipping it here would drop the run's non-UTF-8 rate below the rate the pool set, silently.
101        assert!(
102            pack(rng, context, buf, &mut line, limit_bytes, &mut stats),
103            "a pull carrying an invalid UTF-8 byte could not pack it into a {limit_bytes}-byte datagram, \
104             so the context was minted against a larger limit than it is packed at: {context:?}"
105        );
106    }
107    let count = pull.contexts.len();
108    let start = rng.random_range(0..count);
109    for step in 0..count {
110        let index = (start + step) % count;
111        if Some(index) == pull.lead {
112            continue;
113        }
114        // `\n` is part of what a line costs.
115        let Some(room) = limit_bytes.checked_sub(buf.len() + 1) else {
116            break;
117        };
118        if room < pull.min_floor {
119            break;
120        }
121        pack(rng, &pull.contexts[index], buf, &mut line, limit_bytes, &mut stats);
122    }
123    stats
124}
125
126/// Render `context` into `buf` when the room left holds it, and count what it packed. Reports whether a
127/// line was written.
128fn pack(
129    rng: &mut (impl Rng + ?Sized), context: &Context, buf: &mut Vec<u8>, line: &mut Vec<u8>, limit_bytes: usize,
130    stats: &mut DatagramStats,
131) -> bool {
132    let Some(room) = limit_bytes.checked_sub(buf.len() + 1) else {
133        return false;
134    };
135    if context.floor() > room {
136        return false;
137    }
138    line.clear();
139    // A context whose floor fits renders, always: the repair loop retries the per-occurrence content the
140    // Agent would drop, and its last try renders at the floor, where the occurrence is the shortest the
141    // identity admits. Passing over the context instead would hide a generator that builds one it cannot
142    // render, so this fails where it breaks.
143    let Some(packed) = context.render_wellformed_within(rng, line, room) else {
144        panic!("context floor fits the {room}-byte room left but no repaired render forwarded: {context:?}")
145    };
146    buf.extend_from_slice(line);
147    buf.push(b'\n');
148    stats.lines += 1;
149    stats.max_packed = stats.max_packed.max(packed);
150    true
151}
152
153#[cfg(test)]
154mod test {
155    use proptest::prelude::*;
156    use rand::rngs::SmallRng;
157    use rand::SeedableRng;
158
159    use super::{write_datagram, Pull, DATAGRAM_BYTE_LIMIT};
160    use crate::contexts::{Context, Kind};
161    use crate::dogstatsd::is_malformed;
162
163    /// A pull built against the budget it will be packed at, as the intake builds one. The pool mints
164    /// against the timeline's datagram limit less the newline for exactly this reason: a context minted
165    /// against a larger budget may not fit a smaller datagram.
166    fn pull_of(rng: &mut SmallRng, n: usize, datagram_limit: usize, non_utf8: bool) -> Pull {
167        let budget = datagram_limit.saturating_sub(1);
168        let contexts = (0..n)
169            .filter_map(|slot| {
170                let kind = Kind::sample(rng);
171                if non_utf8 && slot == 0 {
172                    Context::mint_non_utf8_within(kind, rng, budget)
173                } else {
174                    Context::mint_within(kind, rng, budget)
175                }
176            })
177            .collect();
178        Pull::new(contexts).expect("minted no context")
179    }
180
181    /// The identity bytes every render of a context puts on the wire, for spotting it in a datagram.
182    fn identity_bytes(context: &Context) -> &[u8] {
183        match context {
184            Context::Metric(c) => &c.name,
185            Context::Event(c) => &c.title,
186            Context::ServiceCheck(c) => &c.name,
187        }
188    }
189
190    /// Lines carry no interior newline and each is `\n`-terminated, so the line count equals the
191    /// newline count.
192    #[allow(clippy::naive_bytecount)]
193    fn newline_count(buf: &[u8]) -> usize {
194        buf.iter().filter(|&&b| b == b'\n').count()
195    }
196
197    // The rate is the pull's, so a pull carrying the byte must put it in EVERY datagram it packs. If it
198    // reached only some of them the datagram rate would fall below the pull rate by a factor nobody
199    // configured.
200    #[test]
201    fn a_pull_carrying_non_utf8_puts_it_in_every_datagram() {
202        for seed in 0..8u64 {
203            for limit_bytes in [128usize, 512, 8_192] {
204                let mut rng = SmallRng::seed_from_u64(seed);
205                let pull = pull_of(&mut rng, 8, limit_bytes, true);
206                assert!(pull.carries_non_utf8(), "the pull was expected to carry the byte");
207                let mut buf = Vec::new();
208                for _ in 0..200 {
209                    write_datagram(&mut rng, &pull, &mut buf, limit_bytes);
210                    assert!(
211                        simdutf8::basic::from_utf8(&buf).is_err(),
212                        "a datagram from a non-UTF-8 pull carried no invalid byte at limit {limit_bytes}"
213                    );
214                    assert_eq!(is_malformed(&buf), Ok(()), "the datagram was droppable");
215                }
216            }
217        }
218    }
219
220    // The converse. A pull without the byte must never manufacture one, or the rate exceeds the pull
221    // rate and the intake is no longer the only thing deciding it.
222    #[test]
223    fn a_pull_without_non_utf8_never_emits_it() {
224        for seed in 0..8u64 {
225            let mut rng = SmallRng::seed_from_u64(seed);
226            let limit_bytes = 1024;
227            let pull = pull_of(&mut rng, 8, limit_bytes, false);
228            assert!(!pull.carries_non_utf8());
229            let mut buf = Vec::new();
230            for _ in 0..200 {
231                write_datagram(&mut rng, &pull, &mut buf, limit_bytes);
232                assert!(
233                    simdutf8::basic::from_utf8(&buf).is_ok(),
234                    "a datagram from a UTF-8 pull carried an invalid byte"
235                );
236            }
237        }
238    }
239
240    // The byte must reach a metric tag as well as a metric name. The v3 intake splits on exactly that
241    // axis, coercing non-UTF-8 in the tag dictionary and rejecting the whole payload on the name
242    // dictionary, so a byte pinned to one position can only ever drive half of it. Counting any poisoned
243    // event or service-check line as the other side would let a handful of those satisfy this while the
244    // metric tag path stayed unreachable.
245    #[test]
246    fn non_utf8_reaches_more_than_the_metric_name() {
247        let mut in_name = 0;
248        let mut outside_name = 0;
249        for seed in 0..32u64 {
250            let mut rng = SmallRng::seed_from_u64(seed);
251            let pull = pull_of(&mut rng, 8, 512, true);
252            let mut buf = Vec::new();
253            for _ in 0..50 {
254                write_datagram(&mut rng, &pull, &mut buf, 512);
255                for line in buf.split(|&b| b == b'\n') {
256                    if line.is_empty() || simdutf8::basic::from_utf8(line).is_ok() {
257                        continue;
258                    }
259                    if line.starts_with(b"_e{") || line.starts_with(b"_sc") {
260                        continue;
261                    }
262                    let field0 = line.split(|&b| b == b'|').next().unwrap_or(line);
263                    let name = field0.split(|&b| b == b':').next().unwrap_or(field0);
264                    if simdutf8::basic::from_utf8(name).is_err() {
265                        in_name += 1;
266                    } else {
267                        outside_name += 1;
268                    }
269                }
270            }
271        }
272        assert!(in_name > 0, "no non-UTF-8 byte ever landed in a metric name");
273        assert!(
274            outside_name > 0,
275            "every non-UTF-8 byte landed in a metric name, so the tag-dictionary path is unreachable"
276        );
277    }
278
279    // A pull far larger than a datagram must still put every context it holds on the wire across the
280    // invocation. Packing from a fixed end would leave the tail of a thousand-context pull unsent, so the
281    // pool would mint identities the SUT never sees.
282    #[test]
283    fn a_pull_larger_than_a_datagram_still_reaches_every_context() {
284        let mut rng = SmallRng::seed_from_u64(13);
285        let limit_bytes = 512;
286        let pull = pull_of(&mut rng, 64, limit_bytes, false);
287        let mut seen = vec![false; pull.contexts.len()];
288        let mut buf = Vec::new();
289        for _ in 0..2_000 {
290            write_datagram(&mut rng, &pull, &mut buf, limit_bytes);
291            for (index, context) in pull.contexts.iter().enumerate() {
292                let name = identity_bytes(context);
293                if !name.is_empty() && buf.windows(name.len()).any(|window| window == name) {
294                    seen[index] = true;
295                }
296            }
297        }
298        let unseen = seen.iter().filter(|&&hit| !hit).count();
299        assert_eq!(
300            unseen,
301            0,
302            "{unseen} of {} contexts never reached a datagram",
303            pull.contexts.len()
304        );
305    }
306
307    proptest! {
308        /// Every context the room affords renders a line the Agent forwards. The packer panics rather
309        /// than passing over one, so this is the invariant that keeps it from firing.
310        #[test]
311        fn property_test_an_affordable_identity_always_renders(seed: u64, limit_bytes in 128..=8_192usize) {
312            let mut rng = SmallRng::seed_from_u64(seed);
313            let pull = pull_of(&mut rng, 8, limit_bytes, seed % 2 == 0);
314            for context in &pull.contexts {
315                for budget in [context.floor(), limit_bytes - 1, limit_bytes / 2] {
316                    if context.floor() > budget {
317                        continue;
318                    }
319                    let mut line = Vec::new();
320                    let rendered = context.render_wellformed_within(&mut rng, &mut line, budget);
321                    prop_assert!(rendered.is_some(), "affordable identity never forwarded: {context:?}");
322                }
323            }
324        }
325
326        /// A rendered metric carries exactly the tag set its identity holds. The Agent assigns the tag
327        /// set from every `#`-prefixed optional field it sees, last one winning, so a second such field
328        /// would swap the identity's tags for whatever an occurrence body happened to contain. That
329        /// would make one pooled identity render as several, putting cardinality past its cap and one
330        /// point in each of many series. Occurrence bodies do carry `|` and `#`, but segments are
331        /// always joined by a separator, so the two never land adjacent. This pins that.
332        #[test]
333        fn property_test_metric_carries_one_tag_field(seed: u64) {
334            let mut rng = SmallRng::seed_from_u64(seed);
335            let pull = pull_of(&mut rng, 8, DATAGRAM_BYTE_LIMIT, seed % 2 == 0);
336            let mut buf = Vec::new();
337            for _ in 0..4 {
338                write_datagram(&mut rng, &pull, &mut buf, DATAGRAM_BYTE_LIMIT);
339                for line in buf.split(|&b| b == b'\n') {
340                    if line.is_empty() || line.starts_with(b"_e{") || line.starts_with(b"_sc") {
341                        continue;
342                    }
343                    // Field 0 is `name:value` and field 1 the type. The Agent reads a tag set only from
344                    // the optional fields after those, so a `#` opening either of the first two is not
345                    // one.
346                    let tag_fields = line
347                        .split(|&b| b == b'|')
348                        .skip(2)
349                        .filter(|field| field.first() == Some(&b'#'))
350                        .count();
351                    prop_assert!(
352                        tag_fields <= 1,
353                        "{tag_fields} tag fields in {:?}",
354                        String::from_utf8_lossy(line)
355                    );
356                }
357            }
358        }
359
360        /// Every datagram the driver packs from a pull is one the Agent forwards.
361        #[test]
362        fn property_test_every_payload_is_well_formed(seed: u64) {
363            let mut rng = SmallRng::seed_from_u64(seed);
364            let pull = pull_of(&mut rng, 8, DATAGRAM_BYTE_LIMIT, seed % 2 == 0);
365            let mut buf = Vec::new();
366            for _ in 0..8 {
367                write_datagram(&mut rng, &pull, &mut buf, DATAGRAM_BYTE_LIMIT);
368                prop_assert_eq!(is_malformed(&buf), Ok(()), "emitted a droppable datagram: {:?}", String::from_utf8_lossy(&buf));
369            }
370        }
371
372        /// A pull whose smallest context fits yields load. An empty datagram spends one of the driver's
373        /// configured sends without reaching the SUT.
374        #[test]
375        fn property_test_payload_is_never_empty_when_a_context_fits(seed: u64, limit_bytes in 128..=8_192usize) {
376            let mut rng = SmallRng::seed_from_u64(seed);
377            let pull = pull_of(&mut rng, 8, limit_bytes, seed % 2 == 0);
378            let mut buf = Vec::new();
379            let stats = write_datagram(&mut rng, &pull, &mut buf, limit_bytes);
380
381            let smallest = pull.min_floor;
382            if limit_bytes > smallest {
383                prop_assert!(!buf.is_empty(), "empty datagram at limit {limit_bytes}, smallest floor {smallest}");
384                prop_assert!(stats.lines > 0);
385            }
386        }
387
388        #[test]
389        fn property_test_payload_stays_within_its_limit(seed: u64, limit_bytes in 128..=8_192usize) {
390            let mut rng = SmallRng::seed_from_u64(seed);
391            let pull = pull_of(&mut rng, 8, limit_bytes, seed % 2 == 0);
392            let mut buf = Vec::new();
393            let stats = write_datagram(&mut rng, &pull, &mut buf, limit_bytes);
394
395            prop_assert!(buf.len() <= limit_bytes);
396            prop_assert_eq!(newline_count(&buf), stats.lines);
397            if !buf.is_empty() {
398                prop_assert_eq!(buf[buf.len() - 1], b'\n');
399            }
400        }
401    }
402}