Skip to main content

stele/
metrics.rs

1use std::fmt;
2
3use datadog_protos::metrics::{v3::Payload as V3Payload, Dogsketch, MetricPayload, MetricType, SketchPayload};
4use ddsketch::DDSketch;
5use float_cmp::ApproxEqRatio as _;
6use saluki_error::{generic_error, GenericError};
7use serde::{Deserialize, Serialize};
8
9/// JSON envelope for the legacy V1 series intake (`/api/v1/series`).
10#[derive(Deserialize)]
11struct V1SeriesEnvelope {
12    series: Vec<V1Serie>,
13}
14
15/// A single `Serie` entry in a V1 series payload.
16///
17/// Mirrors the Datadog Agent's wire format (see `pkg/metrics/series.go`). `omitempty` fields default to empty.
18// Other fields present in the JSON envelope (`source_type_name`, `unit`) are intentionally not deserialized;
19// serde silently ignores unknown JSON fields, so omitting them here is sufficient.
20#[derive(Deserialize)]
21struct V1Serie {
22    metric: String,
23    points: Vec<(i64, f64)>,
24    #[serde(default)]
25    tags: Vec<String>,
26    #[serde(default)]
27    host: String,
28    #[serde(default)]
29    device: String,
30    #[serde(rename = "type", default)]
31    mtype: String,
32    #[serde(default)]
33    interval: i64,
34}
35
36/// A metric's unique identifier.
37///
38/// The host is normalized into the tag list as a `host:<value>` tag rather than carried as a separate field; the
39/// Datadog backend treats host as a first-class dimension on the time series, equivalent to any other tag. This
40/// keeps comparison logic uniform regardless of which wire format the metric originated from.
41#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
42pub struct MetricContext {
43    name: String,
44    tags: Vec<String>,
45}
46
47impl MetricContext {
48    /// Returns the name of the context.
49    pub fn name(&self) -> &str {
50        &self.name
51    }
52
53    /// Returns the tags of the context.
54    ///
55    /// When the underlying payload carried a host, it appears here as a `host:<value>` tag.
56    pub fn tags(&self) -> &[String] {
57        &self.tags
58    }
59
60    /// Consumes this context, returning the name and tags.
61    pub fn into_parts(self) -> (String, Vec<String>) {
62        (self.name, self.tags)
63    }
64}
65
66impl fmt::Display for MetricContext {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{}", self.name)?;
69
70        if !self.tags.is_empty() {
71            write!(f, " {{{}}}", self.tags.join(", "))?;
72        }
73
74        Ok(())
75    }
76}
77
78/// A simplified metric representation.
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80pub struct Metric {
81    context: MetricContext,
82    values: Vec<(u64, MetricValue)>,
83}
84
85impl Metric {
86    /// Returns the context of the metric.
87    pub fn context(&self) -> &MetricContext {
88        &self.context
89    }
90
91    /// Returns the values associated with the metric.
92    pub fn values(&self) -> &[(u64, MetricValue)] {
93        &self.values
94    }
95}
96
97/// A metric value.
98///
99/// # Equality
100///
101/// `MetricValue` implements `PartialEq` and `Eq`, the majority of which involves comparing floating-point (`f64`)
102/// numbers. Comparing floating-point numbers for equality is inherently tricky ([this][bitbanging_io] is just one blog
103/// post/article out of thousands on the subject). In the equality implementation for `MetricValue`, we use a
104/// ratio-based approach.
105///
106/// This means that when comparing two floating-point numbers, we look at their _ratio_ to one another, with an upper
107/// bound on the allowed difference. For example, if we compare 99 to 100, there's a difference of 1% (`1 - (99/100) =
108/// 0.01 = 1%`), while the difference between 99.999 and 100 is only 0.001% (`1 - (99.999/100) = 0.00001 = 0.001%`). As
109/// most comparisons are expected to be close, only differing by a few ULPs (units in the last place) due to slight
110/// differences in how floating-point numbers are implemented between Go and Rust, this approach is sufficient to
111/// compensate for the inherent imprecision while not falling victim to relying on ULPs or epsilon directly, whose
112/// applicability depends on the number range being compared.
113///
114/// Specifically, we compare floating-point numbers using a ratio of `0.00000001` (0.0000001%), meaning the smaller of
115/// the two values being compared must be within 99.999999% to 100% of the larger number, which is sufficiently precise
116/// for our concerns.
117///
118/// [bitbanging_io]: https://bitbashing.io/comparing-floats.html
119#[derive(Clone, Debug, Deserialize, Serialize)]
120#[serde(tag = "mtype")]
121pub enum MetricValue {
122    /// A count.
123    Count {
124        /// The value of the count.
125        value: f64,
126    },
127
128    /// A rate.
129    ///
130    /// Rates are per-second adjusted counts. For example, a count that increased by 100 over 10 seconds would be
131    /// represented as a rate with an interval of 10 (seconds) and a value of 10 (`100 / 10 = 10`).
132    Rate {
133        /// The interval of the rate, in seconds.
134        interval: u64,
135
136        /// The per-second value of the rate.
137        value: f64,
138    },
139
140    /// A gauge.
141    Gauge {
142        /// The value of the gauge.
143        value: f64,
144    },
145
146    /// A sketch.
147    Sketch {
148        /// The sketch data.
149        sketch: DDSketch,
150    },
151}
152
153impl PartialEq for MetricValue {
154    fn eq(&self, other: &Self) -> bool {
155        // When comparing two values, the smaller value cannot deviate by more than 0.0000001% of the larger value.
156        const RATIO_ERROR: f64 = 0.00000001;
157
158        match (self, other) {
159            (MetricValue::Count { value: value_a }, MetricValue::Count { value: value_b }) => {
160                value_a.approx_eq_ratio(value_b, RATIO_ERROR)
161            }
162            (
163                MetricValue::Rate {
164                    interval: interval_a,
165                    value: value_a,
166                },
167                MetricValue::Rate {
168                    interval: interval_b,
169                    value: value_b,
170                },
171            ) => interval_a == interval_b && value_a.approx_eq_ratio(value_b, RATIO_ERROR),
172            (MetricValue::Gauge { value: value_a }, MetricValue::Gauge { value: value_b }) => {
173                value_a.approx_eq_ratio(value_b, RATIO_ERROR)
174            }
175            (MetricValue::Sketch { sketch: sketch_a }, MetricValue::Sketch { sketch: sketch_b }) => {
176                approx_eq_ratio_optional(sketch_a.min(), sketch_b.min(), RATIO_ERROR)
177                    && approx_eq_ratio_optional(sketch_a.max(), sketch_b.max(), RATIO_ERROR)
178                    && approx_eq_ratio_optional(sketch_a.avg(), sketch_b.avg(), RATIO_ERROR)
179                    && approx_eq_ratio_optional(sketch_a.sum(), sketch_b.sum(), RATIO_ERROR)
180                    && sketch_a.count() == sketch_b.count()
181                    && sketch_a.bin_count() == sketch_b.bin_count()
182            }
183            _ => false,
184        }
185    }
186}
187
188impl Eq for MetricValue {}
189
190impl Metric {
191    /// Attempts to parse metrics from a series v1 payload.
192    ///
193    /// V1 keeps a separate `device` JSON field rather than a `device:<value>` tag like the V2 protobuf encoder. To
194    /// keep the post-conversion `stele::Metric` representation comparable between V1 and V2 payloads, this re-injects
195    /// `device:<value>` into the tag list when the JSON `device` field is non-empty.
196    ///
197    /// # Errors
198    ///
199    /// If the JSON can't be deserialized, contains invalid data (for example, an unknown `type`), or has out-of-range
200    /// timestamps, an error is returned.
201    pub fn try_from_series_v1(payload: &[u8]) -> Result<Vec<Self>, GenericError> {
202        let envelope: V1SeriesEnvelope = serde_json::from_slice(payload)
203            .map_err(|e| generic_error!("Failed to parse V1 series JSON payload: {}", e))?;
204
205        let mut metrics = Vec::with_capacity(envelope.series.len());
206
207        for serie in envelope.series {
208            let mut tags = serie.tags;
209            if !serie.host.is_empty() {
210                tags.push(format!("host:{}", serie.host));
211            }
212            if !serie.device.is_empty() {
213                tags.push(format!("device:{}", serie.device));
214            }
215
216            let mut values = Vec::with_capacity(serie.points.len());
217            for (ts, value) in serie.points {
218                let timestamp =
219                    u64::try_from(ts).map_err(|_| generic_error!("Invalid timestamp in V1 series payload: {}", ts))?;
220
221                let metric_value = match serie.mtype.as_str() {
222                    "count" => MetricValue::Count { value },
223                    "rate" => MetricValue::Rate {
224                        interval: serie.interval as u64,
225                        value,
226                    },
227                    "gauge" => MetricValue::Gauge { value },
228                    other => {
229                        return Err(generic_error!(
230                            "Unknown metric type '{}' in V1 series payload (metric '{}')",
231                            other,
232                            serie.metric
233                        ));
234                    }
235                };
236                values.push((timestamp, metric_value));
237            }
238
239            metrics.push(Metric {
240                context: MetricContext {
241                    name: serie.metric,
242                    tags,
243                },
244                values,
245            });
246        }
247
248        Ok(metrics)
249    }
250
251    /// Attempts to parse metrics from a series v2 payload.
252    ///
253    /// # Errors
254    ///
255    /// If the metric payload contains invalid data, an error will be returned.
256    pub fn try_from_series_v2(payload: MetricPayload) -> Result<Vec<Self>, GenericError> {
257        let mut metrics = Vec::new();
258
259        for series in payload.series {
260            let name = series.metric().to_string();
261            let mut tags: Vec<String> = series.tags().iter().map(|tag| tag.to_string()).collect();
262            // V2 protobuf encodes the hostname as a resource with type="host". The Datadog Agent's wire format
263            // contract requires at least one such resource per series, but we still tolerate its absence. The host
264            // is appended to the tag list (as `host:<value>`) to match the backend's representation and to keep
265            // comparison logic uniform with the V1 JSON path.
266            if let Some(host) = series.resources.iter().find(|r| r.type_() == "host") {
267                let host_name = host.name();
268                if !host_name.is_empty() {
269                    tags.push(format!("host:{}", host_name));
270                }
271            }
272            let mut values = Vec::new();
273
274            match series.type_() {
275                MetricType::UNSPECIFIED => {
276                    return Err(generic_error!("Received metric series with UNSPECIFIED type."));
277                }
278                MetricType::COUNT => {
279                    for point in series.points {
280                        let timestamp = u64::try_from(point.timestamp)
281                            .map_err(|_| generic_error!("Invalid timestamp for point: {}", point.timestamp))?;
282                        values.push((timestamp, MetricValue::Count { value: point.value }));
283                    }
284                }
285                MetricType::RATE => {
286                    for point in series.points {
287                        let timestamp = u64::try_from(point.timestamp)
288                            .map_err(|_| generic_error!("Invalid timestamp for point: {}", point.timestamp))?;
289                        values.push((
290                            timestamp,
291                            MetricValue::Rate {
292                                interval: series.interval as u64,
293                                value: point.value,
294                            },
295                        ));
296                    }
297                }
298                MetricType::GAUGE => {
299                    for point in series.points {
300                        let timestamp = u64::try_from(point.timestamp)
301                            .map_err(|_| generic_error!("Invalid timestamp for point: {}", point.timestamp))?;
302                        values.push((timestamp, MetricValue::Gauge { value: point.value }));
303                    }
304                }
305            }
306
307            metrics.push(Metric {
308                context: MetricContext { name, tags },
309                values,
310            })
311        }
312
313        Ok(metrics)
314    }
315
316    /// Attempts to parse metrics from a sketch payload.
317    ///
318    /// # Errors
319    ///
320    /// If the sketch payload contains invalid data, an error will be returned.
321    pub fn try_from_sketch(payload: SketchPayload) -> Result<Vec<Self>, GenericError> {
322        let mut metrics = Vec::new();
323
324        for sketch in payload.sketches {
325            let name = sketch.metric().to_string();
326            let mut tags: Vec<String> = sketch.tags().iter().map(|tag| tag.to_string()).collect();
327            // V2 sketches carry the host on the sketch message itself rather than via a resources list. Fold it
328            // into the tag list (as `host:<value>`) for comparison parity with the V1 JSON / V2 series paths.
329            let host = sketch.host();
330            if !host.is_empty() {
331                tags.push(format!("host:{}", host));
332            }
333            let mut values = Vec::new();
334
335            for dogsketch in sketch.dogsketches {
336                let timestamp = u64::try_from(dogsketch.ts)
337                    .map_err(|_| generic_error!("Invalid timestamp for sketch: {}", dogsketch.ts))?;
338                let sketch = DDSketch::try_from(dogsketch)
339                    .map_err(|e| generic_error!("Failed to convert DogSketch to DDSketch: {}", e))?;
340                values.push((timestamp, MetricValue::Sketch { sketch }));
341            }
342
343            metrics.push(Metric {
344                context: MetricContext { name, tags },
345                values,
346            })
347        }
348
349        Ok(metrics)
350    }
351}
352
353// V3 metric type constants (from intake_v3.proto metricType enum).
354const V3_METRIC_TYPE_COUNT: u64 = 1;
355const V3_METRIC_TYPE_RATE: u64 = 2;
356const V3_METRIC_TYPE_GAUGE: u64 = 3;
357const V3_METRIC_TYPE_SKETCH: u64 = 4;
358
359// V3 value type constants (from intake_v3.proto valueType enum).
360const V3_VALUE_TYPE_ZERO: u64 = 0x00;
361const V3_VALUE_TYPE_SINT64: u64 = 0x10;
362const V3_VALUE_TYPE_FLOAT32: u64 = 0x20;
363const V3_VALUE_TYPE_FLOAT64: u64 = 0x30;
364
365/// Tracks cursors into the various value arrays of a v3 payload during decoding.
366struct V3ValueCursors {
367    timestamp: usize,
368    sint64: usize,
369    float32: usize,
370    float64: usize,
371    sketch_point: usize,
372    sketch_bin_key: usize,
373    sketch_bin_cnt: usize,
374}
375
376impl V3ValueCursors {
377    fn new() -> Self {
378        Self {
379            timestamp: 0,
380            sint64: 0,
381            float32: 0,
382            float64: 0,
383            sketch_point: 0,
384            sketch_bin_key: 0,
385            sketch_bin_cnt: 0,
386        }
387    }
388}
389
390impl Metric {
391    /// Attempts to parse metrics from a v3 payload.
392    ///
393    /// The v3 format uses columnar encoding with dictionary deduplication and delta encoding.
394    ///
395    /// # Errors
396    ///
397    /// If the payload contains invalid data, an error will be returned.
398    pub fn try_from_v3(mut payload: V3Payload) -> Result<Vec<Self>, GenericError> {
399        let data = payload
400            .metricData
401            .take()
402            .ok_or_else(|| generic_error!("V3 payload missing metricData"))?;
403
404        let num_metrics = data.types.len();
405        if num_metrics == 0 {
406            return Ok(Vec::new());
407        }
408
409        // Parse dictionaries.
410        let names_dict = parse_dict_strings(&data.dictNameStr)?;
411        let tags_dict = parse_dict_strings(&data.dictTagStr)?;
412        let tagsets_dict = parse_tagsets(&data.dictTagsets, &tags_dict)?;
413        let resources_dict = parse_resources(
414            &data.dictResourceLen,
415            &data.dictResourceType,
416            &data.dictResourceName,
417            &parse_dict_strings(&data.dictResourceStr)?,
418        )?;
419
420        // Delta-decode index arrays.
421        let mut name_refs = data.nameRefs;
422        let mut tagset_refs = data.tagsetRefs;
423        let mut resources_refs = data.resourcesRefs;
424        let mut timestamps = data.timestamps;
425        delta_decode(&mut name_refs);
426        delta_decode(&mut tagset_refs);
427        delta_decode(&mut resources_refs);
428        delta_decode(&mut timestamps);
429
430        // Delta-decode sketch bin keys (per-sketch sequences are individually delta-encoded,
431        // but we handle that during iteration).
432        let mut sketch_bin_keys = data.sketchBinKeys;
433
434        let mut cursors = V3ValueCursors::new();
435        let mut metrics = Vec::with_capacity(num_metrics);
436
437        for i in 0..num_metrics {
438            let type_field = data
439                .types
440                .get(i)
441                .copied()
442                .ok_or_else(|| generic_error!("Ran out of metric types"))?;
443            let metric_type = type_field & 0x0F;
444            let value_type = type_field & 0xF0;
445            let num_points = data
446                .numPoints
447                .get(i)
448                .copied()
449                .ok_or_else(|| generic_error!("Ran out of numPoints"))
450                .and_then(|num_points| u64_to_usize(num_points, "numPoints"))?;
451
452            // Resolve name (1-based index).
453            let name_ref = name_refs
454                .get(i)
455                .copied()
456                .ok_or_else(|| generic_error!("Ran out of nameRefs"))
457                .and_then(|name_ref| i64_to_usize(name_ref, "name ref"))?;
458            let name = if name_ref == 0 {
459                String::new()
460            } else {
461                names_dict
462                    .get(name_ref - 1)
463                    .ok_or_else(|| generic_error!("Invalid name ref {} (dict size {})", name_ref, names_dict.len()))?
464                    .clone()
465            };
466
467            // Resolve tags (1-based index).
468            let tagset_ref = tagset_refs
469                .get(i)
470                .copied()
471                .ok_or_else(|| generic_error!("Ran out of tagsetRefs"))
472                .and_then(|tagset_ref| i64_to_usize(tagset_ref, "tagset ref"))?;
473            let mut tags = if tagset_ref == 0 {
474                Vec::new()
475            } else {
476                tagsets_dict
477                    .get(tagset_ref - 1)
478                    .ok_or_else(|| {
479                        generic_error!("Invalid tagset ref {} (dict size {})", tagset_ref, tagsets_dict.len())
480                    })?
481                    .clone()
482            };
483
484            let resource_ref = resources_refs
485                .get(i)
486                .copied()
487                .map(|resource_ref| i64_to_usize(resource_ref, "resource ref"))
488                .transpose()?
489                .unwrap_or(0);
490            if resource_ref != 0 {
491                let resources = resources_dict.get(resource_ref - 1).ok_or_else(|| {
492                    generic_error!(
493                        "Invalid resource ref {} (dict size {})",
494                        resource_ref,
495                        resources_dict.len()
496                    )
497                })?;
498                if let Some((_, host_name)) = resources
499                    .iter()
500                    .find(|(resource_type, resource_name)| resource_type == "host" && !resource_name.is_empty())
501                {
502                    tags.push(format!("host:{}", host_name));
503                }
504            }
505
506            let mut values = Vec::with_capacity(num_points);
507
508            if metric_type == V3_METRIC_TYPE_SKETCH {
509                for _ in 0..num_points {
510                    // Read timestamp.
511                    let ts = *timestamps
512                        .get(cursors.timestamp)
513                        .ok_or_else(|| generic_error!("Ran out of timestamps"))?;
514                    let timestamp = u64::try_from(ts).map_err(|_| generic_error!("Invalid timestamp: {}", ts))?;
515                    cursors.timestamp += 1;
516
517                    // The Agent writes sketch summaries as sum, min, max, then count. Count is always in valsSint64,
518                    // but integer summaries can share that column, so count must be read after the summary values.
519                    let sum = read_value(
520                        value_type,
521                        &mut cursors,
522                        &data.valsSint64,
523                        &data.valsFloat32,
524                        &data.valsFloat64,
525                    )?;
526                    let min = read_value(
527                        value_type,
528                        &mut cursors,
529                        &data.valsSint64,
530                        &data.valsFloat32,
531                        &data.valsFloat64,
532                    )?;
533                    let max = read_value(
534                        value_type,
535                        &mut cursors,
536                        &data.valsSint64,
537                        &data.valsFloat32,
538                        &data.valsFloat64,
539                    )?;
540                    let cnt = *data
541                        .valsSint64
542                        .get(cursors.sint64)
543                        .ok_or_else(|| generic_error!("Ran out of sint64 values for sketch count"))?;
544                    cursors.sint64 += 1;
545                    let avg = if cnt != 0 { sum / cnt as f64 } else { 0.0 };
546
547                    // Read bin data.
548                    let num_bins = *data
549                        .sketchNumBins
550                        .get(cursors.sketch_point)
551                        .ok_or_else(|| generic_error!("Ran out of sketchNumBins"))?
552                        as usize;
553                    cursors.sketch_point += 1;
554
555                    let bin_key_start = cursors.sketch_bin_key;
556                    let bin_key_end = bin_key_start + num_bins;
557                    if bin_key_end > sketch_bin_keys.len() {
558                        return Err(generic_error!("Ran out of sketch bin keys"));
559                    }
560
561                    // Delta-decode this sketch's bin keys.
562                    delta_decode_i32(&mut sketch_bin_keys[bin_key_start..bin_key_end]);
563
564                    let k: Vec<i32> = sketch_bin_keys[bin_key_start..bin_key_end].to_vec();
565                    cursors.sketch_bin_key = bin_key_end;
566
567                    let bin_cnt_start = cursors.sketch_bin_cnt;
568                    let bin_cnt_end = bin_cnt_start + num_bins;
569                    if bin_cnt_end > data.sketchBinCnts.len() {
570                        return Err(generic_error!("Ran out of sketch bin counts"));
571                    }
572                    let n: Vec<u32> = data.sketchBinCnts[bin_cnt_start..bin_cnt_end].to_vec();
573                    cursors.sketch_bin_cnt = bin_cnt_end;
574
575                    // Build a Dogsketch proto and use the existing TryFrom conversion.
576                    let mut dogsketch = Dogsketch::new();
577                    dogsketch.ts = ts;
578                    dogsketch.cnt = cnt;
579                    dogsketch.min = min;
580                    dogsketch.max = max;
581                    dogsketch.avg = avg;
582                    dogsketch.sum = sum;
583                    dogsketch.set_k(k);
584                    dogsketch.set_n(n);
585
586                    let sketch = DDSketch::try_from(dogsketch)
587                        .map_err(|e| generic_error!("Failed to convert v3 sketch to DDSketch: {}", e))?;
588                    values.push((timestamp, MetricValue::Sketch { sketch }));
589                }
590            } else {
591                for _ in 0..num_points {
592                    // Read timestamp.
593                    let ts = *timestamps
594                        .get(cursors.timestamp)
595                        .ok_or_else(|| generic_error!("Ran out of timestamps"))?;
596                    let timestamp = u64::try_from(ts).map_err(|_| generic_error!("Invalid timestamp: {}", ts))?;
597                    cursors.timestamp += 1;
598
599                    // Read point value.
600                    let value = read_value(
601                        value_type,
602                        &mut cursors,
603                        &data.valsSint64,
604                        &data.valsFloat32,
605                        &data.valsFloat64,
606                    )?;
607
608                    let metric_value = match metric_type {
609                        V3_METRIC_TYPE_COUNT => MetricValue::Count { value },
610                        V3_METRIC_TYPE_RATE => MetricValue::Rate {
611                            interval: data
612                                .intervals
613                                .get(i)
614                                .copied()
615                                .ok_or_else(|| generic_error!("Ran out of intervals"))?,
616                            value,
617                        },
618                        V3_METRIC_TYPE_GAUGE => MetricValue::Gauge { value },
619                        other => return Err(generic_error!("Unknown v3 metric type: {}", other)),
620                    };
621
622                    values.push((timestamp, metric_value));
623                }
624            }
625
626            metrics.push(Metric {
627                context: MetricContext { name, tags },
628                values,
629            });
630        }
631
632        Ok(metrics)
633    }
634}
635
636/// Delta-decode in place: convert deltas to absolute values (prefix sum).
637fn delta_decode(s: &mut [i64]) {
638    for i in 1..s.len() {
639        s[i] += s[i - 1];
640    }
641}
642
643/// Delta-decode i32 values in place.
644fn delta_decode_i32(s: &mut [i32]) {
645    for i in 1..s.len() {
646        s[i] += s[i - 1];
647    }
648}
649
650/// Read a varint from a byte slice, returning `(value, bytes_consumed)`.
651fn read_varint(data: &[u8]) -> Result<(u64, usize), GenericError> {
652    let mut value: u64 = 0;
653    let mut shift = 0;
654    for (i, &byte) in data.iter().enumerate() {
655        value |= ((byte & 0x7F) as u64) << shift;
656        if byte & 0x80 == 0 {
657            return Ok((value, i + 1));
658        }
659        shift += 7;
660        if shift >= 64 {
661            return Err(generic_error!("Varint too large"));
662        }
663    }
664    Err(generic_error!("Unexpected end of data reading varint"))
665}
666
667/// Parse varint-length-prefixed strings from a byte buffer.
668fn parse_dict_strings(data: &[u8]) -> Result<Vec<String>, GenericError> {
669    let mut strings = Vec::new();
670    let mut offset = 0;
671    while offset < data.len() {
672        let (len, varint_size) = read_varint(&data[offset..])?;
673        offset += varint_size;
674        let len = len as usize;
675        if offset + len > data.len() {
676            return Err(generic_error!("Dictionary string extends past end of buffer"));
677        }
678        let s = simdutf8::basic::from_utf8(&data[offset..offset + len])
679            .map_err(|e| generic_error!("Invalid UTF-8 in dictionary string: {}", e))?;
680        strings.push(s.to_string());
681        offset += len;
682    }
683    Ok(strings)
684}
685
686/// Parse tagsets from the `dictTagsets` array using the tag dictionary.
687///
688/// Each tagset in `dict_tagsets` is encoded as: length, then that many sorted-then-delta-encoded
689/// entries. Each decoded entry is one of:
690///
691/// - A positive value: a 1-based index into the tag-string dictionary.
692/// - A negative value `-N`: a backreference to the previously interned tagset with 1-based ID
693///   `N`, whose (already fully resolved) tags are included in this one. The Agent emits these when
694///   it splits a metric's composite tags into two groups and encodes the second group
695///   against the first (see `internTags` in the Agent's `iterable_series_v3.go`). Because the
696///   referenced tagset is always interned before the tagset that references it, a single forward
697///   pass can resolve the reference against the tagsets parsed so far.
698fn parse_tagsets(dict_tagsets: &[i64], tags_dict: &[String]) -> Result<Vec<Vec<String>>, GenericError> {
699    let mut tagsets: Vec<Vec<String>> = Vec::new();
700    let mut offset = 0;
701    while offset < dict_tagsets.len() {
702        let count = i64_to_usize(dict_tagsets[offset], "tagset length")?;
703        offset += 1;
704        if offset + count > dict_tagsets.len() {
705            return Err(generic_error!("Tagset extends past end of dictTagsets array"));
706        }
707
708        // Delta-decode the entries within this tagset.
709        let mut entries: Vec<i64> = dict_tagsets[offset..offset + count].to_vec();
710        delta_decode(&mut entries);
711
712        let mut tags = Vec::with_capacity(count);
713        for &entry in &entries {
714            match entry.cmp(&0) {
715                std::cmp::Ordering::Less => {
716                    // Prefix reference to a previously interned tagset (1-based ID = -entry).
717                    let prefix_id = i64_to_usize(-entry, "tagset prefix reference")?;
718                    let prefix_idx = prefix_id
719                        .checked_sub(1)
720                        .ok_or_else(|| generic_error!("Invalid zero tagset prefix reference"))?;
721                    let prefix_tags = tagsets.get(prefix_idx).ok_or_else(|| {
722                        generic_error!(
723                            "Invalid tagset prefix reference {} (only {} tagsets parsed so far)",
724                            prefix_id,
725                            tagsets.len()
726                        )
727                    })?;
728                    tags.extend(prefix_tags.iter().cloned());
729                }
730                std::cmp::Ordering::Equal => continue,
731                std::cmp::Ordering::Greater => {
732                    let idx = i64_to_usize(entry, "tag index")?;
733                    let tag = tags_dict
734                        .get(idx - 1)
735                        .ok_or_else(|| generic_error!("Invalid tag index {} (dict size {})", idx, tags_dict.len()))?;
736                    tags.push(tag.clone());
737                }
738            }
739        }
740        tagsets.push(tags);
741        offset += count;
742    }
743    Ok(tagsets)
744}
745
746fn u64_to_usize(value: u64, field: &str) -> Result<usize, GenericError> {
747    usize::try_from(value).map_err(|_| generic_error!("Invalid {}: {}", field, value))
748}
749
750fn i64_to_usize(value: i64, field: &str) -> Result<usize, GenericError> {
751    usize::try_from(value).map_err(|_| generic_error!("Invalid negative {}: {}", field, value))
752}
753
754/// Parse resource sets from V3 resource dictionaries.
755///
756/// Each resource set is encoded as one length entry plus that many locally delta-encoded type/name dictionary indexes.
757fn parse_resources(
758    dict_resource_len: &[i64], dict_resource_type: &[i64], dict_resource_name: &[i64], resource_strings: &[String],
759) -> Result<Vec<Vec<(String, String)>>, GenericError> {
760    let mut resources = Vec::with_capacity(dict_resource_len.len());
761    let mut offset = 0;
762
763    for &count in dict_resource_len {
764        let count = usize::try_from(count).map_err(|_| generic_error!("Invalid negative resource count: {}", count))?;
765        if offset + count > dict_resource_type.len() || offset + count > dict_resource_name.len() {
766            return Err(generic_error!("Resource set extends past resource dictionary arrays"));
767        }
768
769        let mut type_indices = dict_resource_type[offset..offset + count].to_vec();
770        let mut name_indices = dict_resource_name[offset..offset + count].to_vec();
771        delta_decode(&mut type_indices);
772        delta_decode(&mut name_indices);
773
774        let mut resource_set = Vec::with_capacity(count);
775        for (&type_idx, &name_idx) in type_indices.iter().zip(name_indices.iter()) {
776            let resource_type = resource_strings
777                .get(resource_index(type_idx)?)
778                .ok_or_else(|| generic_error!("Invalid resource type index {}", type_idx))?
779                .clone();
780            let resource_name = resource_strings
781                .get(resource_index(name_idx)?)
782                .ok_or_else(|| generic_error!("Invalid resource name index {}", name_idx))?
783                .clone();
784            resource_set.push((resource_type, resource_name));
785        }
786
787        resources.push(resource_set);
788        offset += count;
789    }
790
791    Ok(resources)
792}
793
794fn resource_index(idx: i64) -> Result<usize, GenericError> {
795    let idx = usize::try_from(idx).map_err(|_| generic_error!("Invalid negative resource index: {}", idx))?;
796    idx.checked_sub(1)
797        .ok_or_else(|| generic_error!("Invalid zero resource index"))
798}
799
800/// Read the next f64 value from the appropriate value array based on `value_type`.
801fn read_value(
802    value_type: u64, cursors: &mut V3ValueCursors, vals_sint64: &[i64], vals_float32: &[f32], vals_float64: &[f64],
803) -> Result<f64, GenericError> {
804    match value_type {
805        V3_VALUE_TYPE_ZERO => Ok(0.0),
806        V3_VALUE_TYPE_SINT64 => {
807            let v = *vals_sint64
808                .get(cursors.sint64)
809                .ok_or_else(|| generic_error!("Ran out of sint64 values"))?;
810            cursors.sint64 += 1;
811            Ok(v as f64)
812        }
813        V3_VALUE_TYPE_FLOAT32 => {
814            let v = *vals_float32
815                .get(cursors.float32)
816                .ok_or_else(|| generic_error!("Ran out of float32 values"))?;
817            cursors.float32 += 1;
818            Ok(v as f64)
819        }
820        V3_VALUE_TYPE_FLOAT64 => {
821            let v = *vals_float64
822                .get(cursors.float64)
823                .ok_or_else(|| generic_error!("Ran out of float64 values"))?;
824            cursors.float64 += 1;
825            Ok(v)
826        }
827        _ => Err(generic_error!("Unknown v3 value type: {:#x}", value_type)),
828    }
829}
830
831fn approx_eq_ratio_optional(a: Option<f64>, b: Option<f64>, ratio: f64) -> bool {
832    match (a, b) {
833        (Some(a), Some(b)) => a.approx_eq_ratio(&b, ratio),
834        (None, None) => true,
835        _ => false,
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn try_from_series_v1_parses_count_gauge_rate() {
845        let body = br#"{"series":[
846            {"metric":"a.count","points":[[100,5.0]],"tags":["env:prod"],"host":"h","type":"count","interval":0},
847            {"metric":"a.gauge","points":[[101,12.0]],"tags":[],"host":"h","type":"gauge","interval":0},
848            {"metric":"a.rate","points":[[102,3.0]],"tags":[],"host":"h","type":"rate","interval":10}
849        ]}"#;
850
851        let metrics = Metric::try_from_series_v1(body).expect("parse should succeed");
852        assert_eq!(metrics.len(), 3);
853
854        assert_eq!(metrics[0].context.name, "a.count");
855        assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
856        assert!(metrics[0].context.tags.contains(&"host:h".to_string()));
857        assert_eq!(metrics[0].values, vec![(100, MetricValue::Count { value: 5.0 })]);
858
859        assert_eq!(metrics[1].context.name, "a.gauge");
860        assert_eq!(metrics[1].context.tags, vec!["host:h".to_string()]);
861        assert_eq!(metrics[1].values, vec![(101, MetricValue::Gauge { value: 12.0 })]);
862
863        assert_eq!(metrics[2].context.name, "a.rate");
864        assert_eq!(metrics[2].context.tags, vec!["host:h".to_string()]);
865        assert_eq!(
866            metrics[2].values,
867            vec![(
868                102,
869                MetricValue::Rate {
870                    interval: 10,
871                    value: 3.0
872                }
873            )]
874        );
875    }
876
877    #[test]
878    fn try_from_series_v1_omits_host_tag_when_empty() {
879        // A payload without `host` is uncommon in practice but the parser must remain permissive — it omits the
880        // `host:` tag rather than emitting an empty one.
881        let body = br#"{"series":[
882            {"metric":"m","points":[[1,1.0]],"tags":[],"type":"count","interval":0}
883        ]}"#;
884
885        let metrics = Metric::try_from_series_v1(body).expect("parse should succeed");
886        assert!(!metrics[0].context.tags.iter().any(|t| t.starts_with("host:")));
887    }
888
889    #[test]
890    fn try_from_series_v1_reinjects_device_tag() {
891        let body = br#"{"series":[
892            {"metric":"m","points":[[1,1.0]],"tags":["env:prod"],"host":"h","device":"eth0","type":"count","interval":0}
893        ]}"#;
894
895        let metrics = Metric::try_from_series_v1(body).expect("parse should succeed");
896        assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
897        assert!(metrics[0].context.tags.contains(&"device:eth0".to_string()));
898    }
899
900    #[test]
901    fn try_from_series_v1_rejects_unknown_type() {
902        let body = br#"{"series":[
903            {"metric":"m","points":[[1,1.0]],"tags":[],"host":"h","type":"weird","interval":0}
904        ]}"#;
905
906        assert!(Metric::try_from_series_v1(body).is_err());
907    }
908
909    #[test]
910    fn try_from_series_v2_folds_host_into_tags() {
911        use datadog_protos::metrics::metric_payload::{
912            MetricPoint, MetricSeries, MetricType as ProtoMetricType, Resource,
913        };
914        use datadog_protos::metrics::MetricPayload;
915
916        let mut payload = MetricPayload::new();
917
918        let mut series = MetricSeries::new();
919        series.set_metric("my.metric".into());
920        series.set_type(ProtoMetricType::COUNT);
921        series.tags.push("env:prod".into());
922
923        let mut host_res = Resource::new();
924        host_res.set_type("host".into());
925        host_res.set_name("server-1".into());
926        series.resources.push(host_res);
927
928        // Non-host resources must not be folded into tags.
929        let mut device_res = Resource::new();
930        device_res.set_type("device".into());
931        device_res.set_name("eth0".into());
932        series.resources.push(device_res);
933
934        let mut point = MetricPoint::new();
935        point.value = 1.0;
936        point.timestamp = 1;
937        series.points.push(point);
938
939        payload.series.push(series);
940
941        let metrics = Metric::try_from_series_v2(payload).expect("parse should succeed");
942        assert_eq!(metrics.len(), 1);
943        assert!(metrics[0].context.tags.contains(&"host:server-1".to_string()));
944        assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
945        assert!(!metrics[0].context.tags.iter().any(|t| t.starts_with("device:")));
946    }
947
948    #[test]
949    fn try_from_sketch_folds_host_into_tags() {
950        use datadog_protos::metrics::sketch_payload::Sketch;
951        use datadog_protos::metrics::SketchPayload;
952
953        let mut payload = SketchPayload::new();
954        let mut sketch = Sketch::new();
955        sketch.set_metric("my.metric".into());
956        sketch.set_host("server-1".into());
957        sketch.tags.push("env:prod".into());
958        payload.sketches.push(sketch);
959
960        let metrics = Metric::try_from_sketch(payload).expect("parse should succeed");
961        assert_eq!(metrics.len(), 1);
962        assert!(metrics[0].context.tags.contains(&"host:server-1".to_string()));
963        assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
964    }
965
966    #[test]
967    fn try_from_v3_folds_host_resource_into_tags() {
968        use datadog_protos::metrics::v3::{MetricData, Payload};
969
970        let mut data = MetricData::new();
971        data.dictNameStr = length_prefixed_strings(["my.metric"]);
972        data.dictTagStr = length_prefixed_strings(["env:prod"]);
973        data.dictTagsets = vec![1, 1];
974        data.dictResourceStr = length_prefixed_strings(["host", "server-1", "device", "eth0"]);
975        data.dictResourceLen = vec![2];
976        data.dictResourceType = vec![1, 2];
977        data.dictResourceName = vec![2, 2];
978        data.types = vec![V3_METRIC_TYPE_COUNT | V3_VALUE_TYPE_ZERO];
979        data.nameRefs = vec![1];
980        data.tagsetRefs = vec![1];
981        data.resourcesRefs = vec![1];
982        data.intervals = vec![0];
983        data.numPoints = vec![1];
984        data.timestamps = vec![1];
985
986        let mut payload = Payload::new();
987        payload.metricData = Some(data).into();
988
989        let metrics = Metric::try_from_v3(payload).expect("parse should succeed");
990        assert_eq!(metrics.len(), 1);
991        assert!(metrics[0].context.tags.contains(&"env:prod".to_string()));
992        assert!(metrics[0].context.tags.contains(&"host:server-1".to_string()));
993        assert!(!metrics[0].context.tags.iter().any(|tag| tag.starts_with("device:")));
994    }
995
996    #[test]
997    fn try_from_v3_decodes_integer_sketch_summary_order() {
998        use datadog_protos::metrics::v3::{MetricData, Payload};
999
1000        let mut data = MetricData::new();
1001        data.dictNameStr = length_prefixed_strings(["my.sketch"]);
1002        data.types = vec![V3_METRIC_TYPE_SKETCH | V3_VALUE_TYPE_SINT64];
1003        data.nameRefs = vec![1];
1004        data.tagsetRefs = vec![0];
1005        data.resourcesRefs = vec![0];
1006        data.intervals = vec![0];
1007        data.numPoints = vec![1];
1008        data.timestamps = vec![123];
1009        // Agent V3 sketch ordering is sum, min, max, count when integer summaries share valsSint64.
1010        data.valsSint64 = vec![10, 1, 4, 4];
1011        data.sketchNumBins = vec![1];
1012        data.sketchBinKeys = vec![0];
1013        data.sketchBinCnts = vec![4];
1014
1015        let mut payload = Payload::new();
1016        payload.metricData = Some(data).into();
1017
1018        let metrics = Metric::try_from_v3(payload).expect("parse should succeed");
1019        assert_eq!(metrics.len(), 1);
1020
1021        let MetricValue::Sketch { sketch } = &metrics[0].values[0].1 else {
1022            panic!("expected sketch value");
1023        };
1024        assert_eq!(sketch.count(), 4);
1025        assert_eq!(sketch.sum(), Some(10.0));
1026        assert_eq!(sketch.min(), Some(1.0));
1027        assert_eq!(sketch.max(), Some(4.0));
1028        assert_eq!(sketch.avg(), Some(2.5));
1029    }
1030
1031    fn length_prefixed_strings(strings: impl IntoIterator<Item = &'static str>) -> Vec<u8> {
1032        let mut bytes = Vec::new();
1033        for s in strings {
1034            bytes.push(s.len() as u8);
1035            bytes.extend_from_slice(s.as_bytes());
1036        }
1037        bytes
1038    }
1039
1040    #[test]
1041    fn parse_tagsets_resolves_positive_indices() {
1042        // Two independent tagsets, each a length followed by sorted+delta-encoded 1-based tag
1043        // indices. Tagset 1: [a:1]. Tagset 2: [a:1, b:2] -> indices [1, 2] -> deltas [1, 1].
1044        let tags_dict = vec!["a:1".to_string(), "b:2".to_string(), "c:3".to_string()];
1045        let dict_tagsets = vec![1, 1, /* tagset 1 */ 2, 1, 1 /* tagset 2 */];
1046        let tagsets = parse_tagsets(&dict_tagsets, &tags_dict).expect("parse should succeed");
1047        assert_eq!(
1048            tagsets,
1049            vec![vec!["a:1".to_string()], vec!["a:1".to_string(), "b:2".to_string()]]
1050        );
1051    }
1052
1053    #[test]
1054    fn parse_tagsets_resolves_negative_prefix_reference() {
1055        // The Agent prefix-compresses composite tags: tagset 2 references tagset 1 (id 1) via a
1056        // negative `-1` entry plus its own tag `b:2` (index 2). The buffer `[-1, 2]` is sorted
1057        // then delta-encoded to `[-1, 3]`.
1058        let tags_dict = vec!["a:1".to_string(), "b:2".to_string()];
1059        let dict_tagsets = vec![
1060            1, 1, // tagset 1 (id 1): [a:1]
1061            2, -1, 3, // tagset 2 (id 2): prefix ref to id 1, then b:2
1062        ];
1063        let tagsets = parse_tagsets(&dict_tagsets, &tags_dict).expect("parse should succeed");
1064        assert_eq!(tagsets.len(), 2);
1065        assert_eq!(tagsets[0], vec!["a:1".to_string()]);
1066        // Tagset 2 resolves the prefix (a:1) and adds its own tag (b:2).
1067        assert_eq!(tagsets[1], vec!["a:1".to_string(), "b:2".to_string()]);
1068    }
1069
1070    #[test]
1071    fn parse_tagsets_rejects_forward_prefix_reference() {
1072        // A prefix reference to a tagset that hasn't been parsed yet is invalid.
1073        let tags_dict = vec!["a:1".to_string()];
1074        let dict_tagsets = vec![1, -5];
1075        assert!(parse_tagsets(&dict_tagsets, &tags_dict).is_err());
1076    }
1077}