saluki_io/deser/codec/dogstatsd/mod.rs
1use std::fmt;
2
3mod event;
4pub use self::event::EventPacket;
5
6mod helpers;
7pub use self::helpers::{parse_message_type, MessageType};
8
9mod metric;
10pub use self::metric::MetricPacket;
11
12mod service_check;
13pub use self::service_check::ServiceCheckPacket;
14
15type NomParserError<'a> = nom::Err<nom::error::Error<&'a [u8]>>;
16
17// This is the lowest sample rate that we consider to be "safe" with typical DogStatsD default settings.
18//
19// Our logic here is:
20// - DogStatsD payloads are limited to 8KiB by default
21// - a valid distribution metric could have a multi-value payload with ~4093 values (value of `1`, when factoring for
22// protocol overhead)
23// - to avoid overflow in resulting sketch, total count of all values must be less than or equal to 2^64
24// - 2^64 / 4093 = 4.5069006e+15.. which is really big
25// - our DDSketch implementation we write into, however, is effectively capped at ~270M (4096 bins max, `u16` for bin
26// count, so 4096 * 2^16 = 268,435,456)
27// - we take 260M to be safe, which when calculating the sample rate, gives us 1 / 260,000,000, or 0.000000003845
28const MINIMUM_SAFE_DEFAULT_SAMPLE_RATE: f64 = 0.000000003845;
29
30/// Parser error.
31#[derive(Debug)]
32pub struct ParseError {
33 kind: nom::error::ErrorKind,
34 data: String,
35}
36
37impl fmt::Display for ParseError {
38 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
39 write!(
40 f,
41 "encountered error '{:?}' while processing message '{}'",
42 self.kind, self.data
43 )
44 }
45}
46
47impl std::error::Error for ParseError {}
48
49impl<'a> From<NomParserError<'a>> for ParseError {
50 fn from(err: NomParserError<'a>) -> Self {
51 match err {
52 nom::Err::Error(e) | nom::Err::Failure(e) => Self {
53 kind: e.code,
54 data: String::from_utf8_lossy(e.input).to_string(),
55 },
56 nom::Err::Incomplete(_) => {
57 // The DSD codec uses complete parsers, so `Incomplete` is structurally impossible. Surface it to
58 // Antithesis before the panic guards the invariant.
59 saluki_antithesis::unreachable!("DogStatsD codec received Incomplete from a complete parser");
60 unreachable!("DogStatsD codec only supports complete payloads")
61 }
62 }
63 }
64}
65
66/// A DogStatsD packet.
67pub enum ParsedPacket<'a> {
68 /// Metric.
69 Metric(MetricPacket<'a>),
70
71 /// Event.
72 Event(EventPacket<'a>),
73
74 /// Service check.
75 ServiceCheck(ServiceCheckPacket<'a>),
76}
77
78/// DogStatsD codec configuration.
79#[derive(Clone, Debug)]
80pub struct DogStatsDCodecConfiguration {
81 permissive: bool,
82 maximum_tag_length: usize,
83 maximum_tag_count: usize,
84 timestamps: bool,
85 minimum_sample_rate: f64,
86 client_origin_detection: bool,
87}
88
89impl DogStatsDCodecConfiguration {
90 /// Sets whether or not the codec should operate in permissive mode.
91 ///
92 /// In permissive mode, the codec will attempt to parse as much of the input as possible, relying solely on
93 /// structural markers (specific delimiting characters) to determine the boundaries of different parts of the
94 /// payload. This allows for decoding payloads with invalid contents (for example, characters that are valid UTF-8, but
95 /// aren't within ASCII bounds, etc) such that the data plane can attempt to process them further.
96 ///
97 /// Permissive mode doesn't allow for decoding payloads with structural errors (for example, missing delimiters, etc) or
98 /// that can't be safely handled internally (for example, invalid UTF-8 characters in a metric name). Invalid UTF-8 tag
99 /// bytes are normalized to U+FFFD before further processing.
100 ///
101 /// Defaults to `false`.
102 pub fn with_permissive_mode(mut self, permissive: bool) -> Self {
103 self.permissive = permissive;
104 self
105 }
106
107 /// Sets the maximum tag length.
108 ///
109 /// This controls the number of bytes that are allowed for a single tag. If a tag exceeds this limit, it's
110 /// truncated to the closest previous UTF-8 character boundary, in order to preserve UTF-8 validity.
111 ///
112 /// Defaults to no limit.
113 pub fn with_maximum_tag_length(mut self, maximum_tag_length: usize) -> Self {
114 self.maximum_tag_length = maximum_tag_length;
115 self
116 }
117
118 /// Sets the maximum tag count.
119 ///
120 /// This is the maximum number of tags allowed for a single metric. If the number of tags exceeds this limit,
121 /// remaining tags are simply ignored.
122 ///
123 /// Defaults to no limit.
124 pub fn with_maximum_tag_count(mut self, maximum_tag_count: usize) -> Self {
125 self.maximum_tag_count = maximum_tag_count;
126 self
127 }
128
129 /// Sets whether or not timestamps are read from metrics.
130 ///
131 /// This is generally used in conjunction with aggregating metrics pipelines to control whether or not metrics are
132 /// able to specify their own timestamp in order to be forwarded immediately without aggregation.
133 ///
134 /// Defaults to `true`.
135 pub fn with_timestamps(mut self, timestamps: bool) -> Self {
136 self.timestamps = timestamps;
137 self
138 }
139
140 /// Sets the minimum sample rate.
141 ///
142 /// This is the minimum sample rate that's allowed for a metric payload. If the sample rate is less than this limit,
143 /// the sample rate is clamped to this value and a log message is emitted.
144 ///
145 /// Defaults to `0.000000003845`.
146 pub fn with_minimum_sample_rate(mut self, minimum_sample_rate: f64) -> Self {
147 self.minimum_sample_rate = minimum_sample_rate;
148 self
149 }
150
151 /// Sets whether client-provided origin detection fields are parsed.
152 ///
153 /// When disabled, the `c:` (Local Data), `e:` (External Data), and `card:` (Cardinality) fields are ignored even if
154 /// present in the payload.
155 ///
156 /// Defaults to `false`.
157 pub fn with_client_origin_detection(mut self, enabled: bool) -> Self {
158 self.client_origin_detection = enabled;
159 self
160 }
161}
162
163impl Default for DogStatsDCodecConfiguration {
164 fn default() -> Self {
165 Self {
166 maximum_tag_length: usize::MAX,
167 maximum_tag_count: usize::MAX,
168 timestamps: true,
169 permissive: false,
170 minimum_sample_rate: MINIMUM_SAFE_DEFAULT_SAMPLE_RATE,
171 client_origin_detection: false,
172 }
173 }
174}
175
176/// A [DogStatsD][dsd] codec.
177///
178/// This codec is used to parse the DogStatsD protocol, which is a superset of the StatsD protocol. DogStatsD adds a
179/// number of additional features, such as the ability to specify tags, send histograms directly, send service checks
180/// and events (Datadog-specific), and more.
181///
182/// [dsd]: https://docs.datadoghq.com/developers/dogstatsd/
183#[derive(Clone, Debug)]
184pub struct DogStatsDCodec {
185 config: DogStatsDCodecConfiguration,
186}
187
188impl DogStatsDCodec {
189 /// Sets the given configuration for the codec.
190 ///
191 /// Different aspects of the codec's behavior (such as tag length, tag count, and timestamp parsing) can be
192 /// controlled through its configuration. See [`DogStatsDCodecConfiguration`] for more information.
193 pub fn from_configuration(config: DogStatsDCodecConfiguration) -> Self {
194 Self { config }
195 }
196
197 /// Decodes a DogStatsD packet from the given raw data.
198 ///
199 /// # Errors
200 ///
201 /// If the raw data isn't a valid DogStatsD packet, an error is returned.
202 pub fn decode_packet<'a>(&self, data: &'a [u8]) -> Result<ParsedPacket<'a>, ParseError> {
203 match parse_message_type(data) {
204 MessageType::Event => self.decode_event(data).map(ParsedPacket::Event),
205 MessageType::ServiceCheck => self.decode_service_check(data).map(ParsedPacket::ServiceCheck),
206 MessageType::MetricSample => self.decode_metric(data).map(ParsedPacket::Metric),
207 }
208 }
209
210 fn decode_metric<'a>(&self, data: &'a [u8]) -> Result<MetricPacket<'a>, ParseError> {
211 // Decode the payload and get the representative parts of the metric.
212 // TODO: Can probably assert remaining is empty now.
213 let (_remaining, metric) = self::metric::parse_dogstatsd_metric(data, &self.config)?;
214 Ok(metric)
215 }
216
217 fn decode_event<'a>(&self, data: &'a [u8]) -> Result<EventPacket<'a>, ParseError> {
218 let (_remaining, event) = self::event::parse_dogstatsd_event(data, &self.config)?;
219 Ok(event)
220 }
221
222 fn decode_service_check<'a>(&self, data: &'a [u8]) -> Result<ServiceCheckPacket<'a>, ParseError> {
223 let (_remaining, service_check) = self::service_check::parse_dogstatsd_service_check(data, &self.config)?;
224 Ok(service_check)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn codec() -> DogStatsDCodec {
233 DogStatsDCodec::from_configuration(DogStatsDCodecConfiguration::default())
234 }
235
236 #[test]
237 fn parse_message_type_routes_by_leading_marker() {
238 // Only the exact `_e{` and `_sc|` prefixes select the event/service-check parsers; everything else — including
239 // payloads that merely start with `_e`/`_sc` but lack the structural marker — is treated as a metric sample.
240 assert!(matches!(parse_message_type(b"_e{5,4}:title|text"), MessageType::Event));
241 assert!(matches!(parse_message_type(b"_sc|svc|0"), MessageType::ServiceCheck));
242
243 assert!(matches!(
244 parse_message_type(b"page.views:1|c"),
245 MessageType::MetricSample
246 ));
247 // `_events` / `_scope` share a leading substring with the markers but aren't the markers themselves.
248 assert!(matches!(parse_message_type(b"_events:1|c"), MessageType::MetricSample));
249 assert!(matches!(parse_message_type(b"_scope:1|c"), MessageType::MetricSample));
250 assert!(matches!(parse_message_type(b""), MessageType::MetricSample));
251 }
252
253 #[test]
254 fn decode_packet_dispatches_to_the_matching_parser() {
255 let codec = codec();
256
257 // A metric sample is routed to the metric parser.
258 match codec.decode_packet(b"page.views:1|c").expect("metric should decode") {
259 ParsedPacket::Metric(metric) => assert_eq!(metric.metric_name, "page.views"),
260 _ => panic!("expected a metric packet"),
261 }
262
263 // An `_e{`-prefixed payload is routed to the event parser.
264 match codec.decode_packet(b"_e{5,4}:title|text").expect("event should decode") {
265 ParsedPacket::Event(event) => {
266 assert_eq!(&*event.title, "title");
267 assert_eq!(&*event.text, "text");
268 }
269 _ => panic!("expected an event packet"),
270 }
271
272 // An `_sc|`-prefixed payload is routed to the service-check parser.
273 match codec
274 .decode_packet(b"_sc|my.check|0")
275 .expect("service check should decode")
276 {
277 ParsedPacket::ServiceCheck(service_check) => assert_eq!(&*service_check.name, "my.check"),
278 _ => panic!("expected a service-check packet"),
279 }
280 }
281
282 #[test]
283 fn decode_packet_propagates_parser_errors_from_the_selected_path() {
284 // Dispatch happens before parsing, so a payload with the event marker but an invalid body is routed to the
285 // event parser and surfaces that parser's error rather than being silently retried as a metric.
286 let codec = codec();
287 match codec.decode_packet(b"_e{0,4}:|text") {
288 Err(err) => assert_eq!(err.kind, nom::error::ErrorKind::Verify),
289 Ok(_) => panic!("empty-title event must fail to decode"),
290 }
291 }
292}