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}