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}