harness/payload/
dogstatsd.rs

1//! `DogStatsD` payload generation.
2//!
3//! Dogstatsd is three message types: metric, event, service check.
4//!
5//! # Metrics
6//!
7//! ```text
8//! <NAME>:<VALUE>|<TYPE>|@<SAMPLE_RATE>|#<TAG>,<TAG>...|c:<CONTAINER>|T<TS>|e:<EXT>|card:<CARD>
9//!
10//! Required: <NAME>:<VALUE>|<TYPE>.
11//!
12//! <NAME>        := [^:|\n]+
13//! <VALUE>       := <NUMBER>(:<NUMBER>)*        ':'-packed multi-value, non-set
14//!                | [^|\n]+                     raw string, set type
15//! <NUMBER>      := [+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)? | [+-]?(inf|infinity|nan)
16//! <TYPE>        := c|g|ms|h|s|d                count gauge timer histogram set distribution
17//! <SAMPLE_RATE> := @<NUMBER>
18//! <TAG>         := [^,|\n]+                    conventionally <KEY>:<VALUE>, the ':' is not required
19//! <CONTAINER>   := c:[^|\n]+                   e.g. ci-<id>, in-<inode>
20//! <TS>          := T\d+                        unix seconds
21//! <EXT>         := e:[^|\n]+                   e.g. it-,cn-,pu-
22//! <CARD>        := card:[^|\n]+                recognized: none|low|orchestrator|high
23//! ```
24//!
25//! # Events
26//!
27//! ```text
28//! _e{<TITLE_LEN>,<TEXT_LEN>}:<TITLE>|<TEXT>|d:<TS>|h:<HOST>|k:<AGGKEY>|p:<PRIO>|s:<SRC>|t:<ALERT>|#<TAGS>
29//!
30//! Required: _e{<TITLE_LEN>,<TEXT_LEN>}:<TITLE>|<TEXT>. c: / e: / card: are valid here too.
31//!
32//! <TITLE_LEN>,<TEXT_LEN> := \d+               byte length of TITLE / TEXT
33//! <TITLE>,<TEXT>         := [^\n]{LEN}         length-delimited, so '|' and ':' are allowed
34//! <TS>          := d:\d+                       unix seconds
35//! <HOST>        := h:[^|\n]+
36//! <AGGKEY>      := k:[^|\n]+
37//! <PRIO>        := p:[^|\n]+                   recognized: normal|low (else default)
38//! <SRC>         := s:[^|\n]+
39//! <ALERT>       := t:[^|\n]+                   recognized: error|warning|info|success (else default)
40//! <TAGS>        := #<TAG>(,<TAG>)*
41//! ```
42//!
43//! # Service checks
44//!
45//! ```text
46//! _sc|<NAME>|<STATUS>|d:<TS>|h:<HOST>|#<TAG>,<TAG>...|m:<MESSAGE>
47//!
48//! Required: _sc|<NAME>|<STATUS>. c: / e: / card: are valid here too.
49//!
50//! <NAME>        := [^|\n]+
51//! <STATUS>      := [0-3]                       OK warning critical unknown
52//! <TS>          := d:\d+                       unix seconds
53//! <HOST>        := h:[^|\n]+
54//! <TAGS>        := #<TAG>(,<TAG>)*
55//! <MESSAGE>     := m:[^|\n]+
56//! ```
57//!
58//! # Name combinatorics
59//!
60//! A clean name is `c` segments from `COMPLIANT_WORD` (10 words) joined by
61//! `NAME_SEPARATORS` (4). Distinct names at count `c`: `10^c · 4^(c-1)`. `c` is
62//! sampled by vibe: clean draws from `ELEMENT_COUNTS_CLEAN` (a small body, no
63//! boundary cases); feral draws from `ELEMENT_COUNTS_FERAL`, which adds the `0`
64//! and large boundary counts as a tail.
65//!
66//! | `c`   | P(c) clean | P(c) feral | distinct names |
67//! |-------|------------|------------|----------------|
68//! | 0     | —          | 1/12       | 1 (empty)      |
69//! | 1-3   | 6/9        | 6/12       | 10 .. ~16e3    |
70//! | 4-6   | 3/9        | 3/12       | ~640e3 .. ~1e9 |
71//! | 127   | —          | 1/12       | ~10^203        |
72//! | 255   | —          | 1/12       | ~10^408        |
73
74use rand::{Rng, RngExt};
75
76mod common;
77mod events;
78mod metrics;
79mod service_checks;
80
81pub use common::{sample_vibe, Vibe};
82
83/// The three `DogStatsD` message types.
84#[derive(Clone, Copy)]
85enum Message {
86    Metric,
87    Event,
88    ServiceCheck,
89}
90
91/// Sample a message type. The mix is heavily metric-weighted — 98% metric, 1%
92/// event, 1% service check. Metrics drive the aggregate context map and the
93/// sketch bin paths, the invariants worth exercising, so the bulk of load goes
94/// there while events and service checks still fire often enough to keep their
95/// own anchors non-vacuous.
96fn choose_message<R: Rng + ?Sized>(rng: &mut R) -> Message {
97    match rng.random_range(0..100u32) {
98        0 => Message::Event,
99        1 => Message::ServiceCheck,
100        _ => Message::Metric,
101    }
102}
103
104/// Ceiling on a generated datagram, the Datadog Agent's default
105/// `dogstatsd_buffer_size`. A run caps each datagram to the smaller of this and
106/// the SUT's sampled receive buffer, so a packed datagram always fits one read
107/// and the SUT never truncates a line mid-token.
108pub const PAYLOAD_BYTE_LIMIT: usize = 8_192;
109
110/// What a generated payload holds, for anchoring assertions.
111#[derive(Clone, Copy, Debug, Default)]
112pub struct Payload {
113    /// Lines packed into the buffer.
114    pub lines: usize,
115    /// Largest packed multi-value run among those lines. Zero when none.
116    pub max_packed: usize,
117}
118
119/// Append one `DogStatsD` line of a sampled type to `buf`. When a line would
120/// exceed `limit_bytes`, drop it whole rather than shear it, leaving `buf`
121/// unchanged. A non-empty line always ends in `\n`.
122///
123/// Returns the packed value count when a multi-value metric was emitted, else
124/// `None`.
125pub fn write_line<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, vibe: Vibe, limit_bytes: usize) -> Option<usize> {
126    let start = buf.len();
127    let packed = match choose_message(rng) {
128        Message::Event => {
129            events::write(rng, buf, vibe);
130            None
131        }
132        Message::ServiceCheck => {
133            service_checks::write(rng, buf, vibe);
134            None
135        }
136        Message::Metric => metrics::write(rng, buf, vibe),
137    };
138    if buf.len() - start > limit_bytes {
139        // Drop a whole line that exceeds limit_bytes rather than shear it mid-token.
140        // A sheared fragment is exactly the parse-error spew this generator avoids.
141        buf.truncate(start);
142        return None;
143    }
144    packed
145}
146
147/// Per-run line composition: every line clean, every line feral, or a per-line
148/// clean-or-feral mix.
149#[derive(Clone, Copy, Debug)]
150pub enum Batch {
151    /// Every line clean.
152    Clean,
153    /// Every line feral.
154    Feral,
155    /// Each line independently clean or feral.
156    Mixed,
157}
158
159impl Batch {
160    /// The vibe for one line of this batch, sampled per call so `Mixed` interleaves.
161    fn vibe<R: Rng + ?Sized>(self, rng: &mut R) -> Vibe {
162        match self {
163            Batch::Clean => Vibe::Clean,
164            Batch::Feral => Vibe::Feral,
165            Batch::Mixed => sample_vibe(rng),
166        }
167    }
168}
169
170/// Pack whole `\n`-terminated lines into `buf` until the next sampled line does
171/// not fit the space left under `limit_bytes`. That overflowing line is dropped
172/// whole rather than sheared and ends the payload, so the packed datagram never
173/// exceeds `limit_bytes` and holds only whole lines. Each line takes its vibe
174/// from `batch`, so a `Mixed` payload interleaves clean and feral lines. Clears
175/// `buf` first.
176pub fn write_payload<R: Rng + ?Sized>(rng: &mut R, buf: &mut Vec<u8>, batch: Batch, limit_bytes: usize) -> Payload {
177    buf.clear();
178    let mut payload = Payload::default();
179    loop {
180        let vibe = batch.vibe(rng);
181        let start = buf.len();
182        // Pass the budget still free so an overflowing line is dropped whole, not
183        // sheared, and the total stays within `limit_bytes`.
184        let packed = write_line(rng, buf, vibe, limit_bytes - buf.len());
185        if buf.len() == start {
186            // The line did not fit the space left, so the payload is complete.
187            break;
188        }
189        payload.lines += 1;
190        if let Some(count) = packed {
191            payload.max_packed = payload.max_packed.max(count);
192        }
193    }
194    payload
195}
196
197#[cfg(test)]
198mod test {
199    use proptest::prelude::*;
200    use rand::rngs::SmallRng;
201    use rand::SeedableRng;
202
203    use super::{write_line, write_payload, Batch, Vibe};
204
205    fn any_vibe() -> impl Strategy<Value = Vibe> {
206        prop_oneof![Just(Vibe::Clean), Just(Vibe::Feral)]
207    }
208
209    fn any_batch() -> impl Strategy<Value = Batch> {
210        prop_oneof![Just(Batch::Clean), Just(Batch::Feral), Just(Batch::Mixed)]
211    }
212
213    /// Lines carry no interior newline and each is `\n`-terminated, so the line
214    /// count equals the newline count.
215    #[allow(clippy::naive_bytecount)]
216    fn newline_count(buf: &[u8]) -> usize {
217        buf.iter().filter(|&&b| b == b'\n').count()
218    }
219
220    proptest! {
221        #[test]
222        fn write_line_stays_within_its_limit(seed: u64, limit_bytes: u16, vibe in any_vibe()) {
223            let mut rng = SmallRng::seed_from_u64(seed);
224            let limit_bytes = usize::from(limit_bytes);
225            let mut buf = Vec::new();
226            write_line(&mut rng, &mut buf, vibe, limit_bytes);
227
228            prop_assert!(buf.len() <= limit_bytes);
229            if !buf.is_empty() {
230                prop_assert_eq!(buf[buf.len() - 1], b'\n');
231                prop_assert_eq!(newline_count(&buf), 1);
232            }
233        }
234
235        #[test]
236        fn write_payload_stays_within_its_limit(seed: u64, limit_bytes: u16, batch in any_batch()) {
237            let mut rng = SmallRng::seed_from_u64(seed);
238            let limit_bytes = usize::from(limit_bytes);
239            let mut buf = Vec::new();
240            let payload = write_payload(&mut rng, &mut buf, batch, limit_bytes);
241
242            prop_assert!(buf.len() <= limit_bytes);
243            prop_assert_eq!(newline_count(&buf), payload.lines);
244            // Whole lines only: a non-empty datagram ends on a line boundary, never a shorn token.
245            if !buf.is_empty() {
246                prop_assert_eq!(buf[buf.len() - 1], b'\n');
247            }
248        }
249    }
250}