Skip to main content

libdd_trace_stats/span_concentrator/
aggregation.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! This module implement the logic for stats aggregation into time buckets and stats group.
5//! This includes the aggregation key to group spans together and the computation of stats from a
6//! span.
7
8use hashbrown::{HashMap, HashSet};
9use libdd_trace_obfuscation::ip_address::quantize_peer_ip_addresses;
10use libdd_trace_protobuf::pb;
11use std::{
12    borrow::{Borrow, Cow},
13    hash::{DefaultHasher, Hash, Hasher as _},
14};
15use tracing::warn;
16
17use crate::span_concentrator::{cardinality_limit_telemetry::CollapsedFieldSet, StatSpan};
18
19use super::{
20    cardinality_limit_telemetry::{self, CollapsedFieldsMetrics},
21    CardinalityLimitConfig,
22};
23
24/// Sentinel value used for cardinality limiting.
25pub const TRACER_BLOCKED_VALUE: &str = "tracer_blocked_value";
26
27const TAG_STATUS_CODE: &str = "http.status_code";
28const TAG_METHOD: &str = "http.method";
29// OpenTelemetry HTTP semantic-convention names for the two attributes above. A tracer running in
30// OTel semantics mode (DD_TRACE_OTEL_SEMANTICS_ENABLED) emits these instead of the Datadog names,
31// so reading only the Datadog names silently drops the status and method stats dimensions: the
32// bucket reports status 0 and an empty method while the span itself is correct.
33// http.endpoint and http.route need no equivalent: http.route is already the OTel name, and
34// http.endpoint is Datadog-only and retained in that mode.
35const TAG_STATUS_CODE_OTEL: &str = "http.response.status_code";
36const TAG_METHOD_OTEL: &str = "http.request.method";
37const ADDITIONAL_METRIC_TAG_VALUE_MAX_LEN: usize = 200;
38const TAG_SYNTHETICS: &str = "synthetics";
39const TAG_SPANKIND: &str = "span.kind";
40const TAG_ORIGIN: &str = "_dd.origin";
41const TAG_SVC_SRC: &str = "_dd.svc_src";
42const GRPC_STATUS_CODE_FIELD: &[&str] = &[
43    "rpc.grpc.status_code",
44    "grpc.code",
45    "rpc.grpc.status.code",
46    "grpc.status.code",
47];
48
49/// Aggregation key fields shared across all concentrator implementations — everything
50/// **except** peer tags.
51///
52/// `T` is the string representation:
53/// * `&'a str`   — borrowed references used in [`BorrowedAggregationKey`]
54/// * `String`    — owned values used in `OwnedAggregationKey`
55/// * `StringRef` — offset+len into a SHM string pool, used in `ShmKeyHeader`
56#[derive(
57    Clone, Default, Hash, Eq, PartialEq, Debug, PartialOrd, serde::Serialize, serde::Deserialize,
58)]
59pub struct FixedAggregationKey<T> {
60    pub resource_name: T,
61    pub service_name: T,
62    pub operation_name: T,
63    pub span_type: T,
64    pub span_kind: T,
65    pub http_method: T,
66    pub http_endpoint: T,
67    pub service_source: T,
68    pub http_status_code: u32,
69    pub grpc_status_code: Option<u8>,
70    pub is_synthetics_request: bool,
71    pub is_trace_root: pb::Trilean,
72}
73
74impl<T> FixedAggregationKey<T> {
75    /// Map all string fields through `f`, preserving scalar fields.
76    pub fn convert<'a, V: 'a, I: ?Sized + 'a, F: Fn(&'a I) -> V>(
77        &'a self,
78        f: F,
79    ) -> FixedAggregationKey<V>
80    where
81        T: Borrow<I>,
82    {
83        FixedAggregationKey {
84            resource_name: f(self.resource_name.borrow()),
85            service_name: f(self.service_name.borrow()),
86            operation_name: f(self.operation_name.borrow()),
87            span_type: f(self.span_type.borrow()),
88            span_kind: f(self.span_kind.borrow()),
89            http_method: f(self.http_method.borrow()),
90            http_endpoint: f(self.http_endpoint.borrow()),
91            service_source: f(self.service_source.borrow()),
92            http_status_code: self.http_status_code,
93            grpc_status_code: self.grpc_status_code,
94            is_synthetics_request: self.is_synthetics_request,
95            is_trace_root: self.is_trace_root,
96        }
97    }
98}
99
100#[derive(Clone, Hash, PartialEq, Eq)]
101/// Represent a stats aggregation key borrowed from span data
102pub(super) struct BorrowedAggregationKey<'a> {
103    fixed: FixedAggregationKey<&'a str>,
104    peer_tags: Vec<(&'a str, Cow<'a, str>)>,
105    additional_metric_tags: Vec<(&'a str, &'a str)>,
106}
107
108impl hashbrown::Equivalent<OwnedAggregationKey> for BorrowedAggregationKey<'_> {
109    #[inline]
110    fn equivalent(&self, other: &OwnedAggregationKey) -> bool {
111        self.fixed == other.fixed.convert(|s| s)
112            && self.peer_tags.len() == other.peer_tags.len()
113            && self
114                .peer_tags
115                .iter()
116                .zip(other.peer_tags.iter())
117                .all(|((k1, v1), (k2, v2))| k1 == k2 && v1 == v2)
118            && self.additional_metric_tags.len() == other.additional_metric_tags.len()
119            && self
120                .additional_metric_tags
121                .iter()
122                .zip(other.additional_metric_tags.iter())
123                .all(|((k1, v1), (k2, v2))| k1 == k2 && v1 == v2)
124    }
125}
126
127#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Default)]
128/// Represents a span aggregation key with owned data
129///
130/// To be able to use BorrowedAggregationKey to index into a stats bucket hashmap two
131/// conditions must stay true:
132/// * Hashing an owned key derived from a borrowed key should produce the same hash as hashing the
133///   borrowed key
134/// * Running the Equivalent trait on an owned key derived from a borrowed key should produce true
135pub(super) struct OwnedAggregationKey {
136    fixed: FixedAggregationKey<String>,
137    peer_tags: Vec<(String, String)>,
138    additional_metric_tags: Vec<(String, String)>,
139}
140
141impl From<&BorrowedAggregationKey<'_>> for OwnedAggregationKey {
142    fn from(value: &BorrowedAggregationKey<'_>) -> Self {
143        OwnedAggregationKey {
144            fixed: value.fixed.convert(str::to_owned),
145            peer_tags: value
146                .peer_tags
147                .iter()
148                .map(|(k, v)| (k.to_string(), v.to_string()))
149                .collect(),
150            additional_metric_tags: value
151                .additional_metric_tags
152                .iter()
153                .map(|(k, v)| (k.to_string(), v.to_string()))
154                .collect(),
155        }
156    }
157}
158
159fn float_to_int(f: f64) -> Option<u8> {
160    if f.floor() != f {
161        return None;
162    }
163    if f < 0.0 || (u8::MAX as f64) < f {
164        return None;
165    }
166    Some(f as u8)
167}
168
169fn float_to_u32(f: f64) -> Option<u32> {
170    if !f.is_finite() || f.floor() != f || f < 0.0 || (u32::MAX as f64) < f {
171        return None;
172    }
173    // The checks above guarantee the finite integral value is representable without loss.
174    Some(f as u32)
175}
176
177fn is_valid_http_status_code(code: &u32) -> bool {
178    (100..600).contains(code)
179}
180
181/// The HTTP method, under either naming convention, or "" when the span carries neither.
182///
183/// The Datadog name is canonical in the Agent semantic registry and is preferred when both names
184/// are present.
185fn get_http_method<'a>(span: &'a impl StatSpan<'a>) -> &'a str {
186    for key in [TAG_METHOD, TAG_METHOD_OTEL] {
187        if let Some(value) = span.get_meta(key) {
188            if !value.is_empty() {
189                return value;
190            }
191        }
192    }
193
194    ""
195}
196
197/// The HTTP status code, under either naming convention, or 0 when the span carries neither.
198///
199/// `metrics` is checked before `meta` for each name because a numeric attribute is routed there
200/// by the tracer's own setter, and OTel types `http.response.status_code` as an int. The Datadog
201/// key retains its existing conversion and precedence behavior. Invalid OTel values are ignored.
202fn get_http_status_code<'a>(span: &'a impl StatSpan<'a>) -> u32 {
203    for key in [TAG_STATUS_CODE, TAG_STATUS_CODE_OTEL] {
204        if let Some(value) = span.get_metrics(key) {
205            if key == TAG_STATUS_CODE {
206                // Preserve the existing Datadog-key conversion behavior.
207                return value as u32;
208            }
209            if let Some(code) = float_to_u32(value).filter(is_valid_http_status_code) {
210                return code;
211            }
212        }
213
214        if let Some(value) = span.get_meta(key) {
215            if key == TAG_STATUS_CODE {
216                // Preserve the existing Datadog-key parse-or-zero behavior.
217                return value.parse().unwrap_or_default();
218            }
219            if let Some(code) = value.parse().ok().filter(is_valid_http_status_code) {
220                return code;
221            }
222        }
223    }
224
225    0
226}
227
228fn get_grpc_status_code<'a>(span: &'a impl StatSpan<'a>) -> Option<u8> {
229    for key in GRPC_STATUS_CODE_FIELD {
230        if let Some(val) = span.get_meta(key) {
231            if let Some(code) = grpc_status_str_to_int_value(val) {
232                return Some(code);
233            }
234        }
235    }
236
237    for key in GRPC_STATUS_CODE_FIELD {
238        if let Some(val) = span.get_metrics(key) {
239            if let Some(code) = float_to_int(val) {
240                return Some(code);
241            }
242        }
243    }
244
245    None
246}
247
248fn grpc_status_str_to_int_value(v: &str) -> Option<u8> {
249    if let Ok(status) = v.parse() {
250        return Some(status);
251    }
252    let mut status_uppercase = [0u8; 32];
253    let mut status = v.trim_start_matches("StatusCode.");
254
255    let mut needs_upcasing = false;
256    for b in status.as_bytes() {
257        if !b.is_ascii() {
258            return None;
259        }
260        needs_upcasing |= b.is_ascii_lowercase()
261    }
262    if needs_upcasing {
263        for (c, d) in status.as_bytes().iter().zip(&mut status_uppercase) {
264            *d = c.to_ascii_uppercase();
265        }
266        status = std::str::from_utf8(&status_uppercase[0..status.len().min(status_uppercase.len())])
267            .ok()?
268    }
269
270    match status {
271        "OK" => return Some(0),
272        "CANCELLED" | "CANCELED" => return Some(1),
273        "UNKNOWN" => return Some(2),
274        "INVALID_ARGUMENT" | "INVALIDARGUMENT" => return Some(3),
275        "DEADLINE_EXCEEDED" | "DEADLINEEXCEEDED" => return Some(4),
276        "NOT_FOUND" | "NOTFOUND" => return Some(5),
277        "ALREADY_EXISTS" | "ALREADYEXISTS" => return Some(6),
278        "PERMISSION_DENIED" | "PERMISSIONDENIED" => return Some(7),
279        "UNAUTHENTICATED" => return Some(16),
280        "RESOURCE_EXHAUSTED" | "RESOURCEEXHAUSTED" => return Some(8),
281        "FAILED_PRECONDITION" | "FAILEDPRECONDITION" => return Some(9),
282        "ABORTED" => return Some(10),
283        "OUT_OF_RANGE" | "OUTOFRANGE" => return Some(11),
284        "UNIMPLEMENTED" => return Some(12),
285        "INTERNAL" => return Some(13),
286        "UNAVAILABLE" => return Some(14),
287        "DATA_LOSS" | "DATALOSS" => return Some(15),
288        _ => {}
289    }
290    None
291}
292
293impl<'a> BorrowedAggregationKey<'a> {
294    /// Return an AggregationKey matching the given span.
295    ///
296    /// If `peer_tag_keys` is not empty then the peer tags of the span will be included in the
297    /// key.
298    /// If `additional_metric_tags` is not empty then matching span tags keys are included in the
299    /// key.
300    pub(super) fn from_span<T: StatSpan<'a>>(
301        span: &'a T,
302        peer_tag_keys: &'a [String],
303        additional_metric_tag_keys: &'a [String],
304    ) -> Self {
305        Self::from_obfuscated_span(
306            span.resource(),
307            span,
308            peer_tag_keys,
309            additional_metric_tag_keys,
310        )
311    }
312
313    pub(crate) fn from_obfuscated_span<'b, T>(
314        resource_name: &'a str,
315        span: &'b T,
316        peer_tag_keys: &'b [String],
317        additional_metric_tag_keys: &'b [String],
318    ) -> BorrowedAggregationKey<'a>
319    where
320        T: StatSpan<'b>,
321        // resource_name is a temporary string on the stack the span will outlive it
322        'b: 'a,
323    {
324        let span_kind = span.get_meta(TAG_SPANKIND).unwrap_or_default();
325        let peer_tags = if should_track_peer_tags(span_kind) {
326            // Parse the meta tags of the span and return a list of the peer tags based on the list
327            // of `peer_tag_keys`. IP address values are quantized to reduce cardinality.
328            peer_tag_keys
329                .iter()
330                .filter_map(|key| {
331                    let value = span.get_meta(key.as_str())?;
332                    Some((key.as_str(), quantize_peer_ip_addresses(value)))
333                })
334                .collect()
335        } else if let Some(base_service) = span.get_meta("_dd.base_service") {
336            // Internal spans with a base service override use only _dd.base_service as peer tag
337            vec![("_dd.base_service", Cow::Borrowed(base_service))]
338        } else {
339            vec![]
340        };
341
342        // The Datadog name wins when both are present. An empty value counts as absent, so a
343        // tracer that writes the key unconditionally cannot shadow a real value under the other
344        // name, which is the mistake get_grpc_status_code above already avoids.
345        let http_method = get_http_method(span);
346
347        let http_endpoint = span
348            .get_meta("http.route")
349            .filter(|value| !value.is_empty())
350            .or_else(|| {
351                span.get_meta("http.endpoint")
352                    .filter(|value| !value.is_empty())
353            })
354            .unwrap_or_default();
355
356        let status_code = get_http_status_code(span);
357
358        let grpc_status_code = get_grpc_status_code(span);
359        let service_source = span.get_meta(TAG_SVC_SRC).unwrap_or_default();
360
361        let additional_metric_tags: Vec<(&'a str, &'a str)> = additional_metric_tag_keys
362            .iter()
363            .filter_map(|key| match span.get_meta(key.as_str()) {
364                Some(v) if !v.is_empty() => {
365                    // Byte length >= char count, so skip the char walk when byte length alone
366                    // is within the max character length, otherwise stop as soon as we pass the max character length.
367                    if v.len() > ADDITIONAL_METRIC_TAG_VALUE_MAX_LEN
368                        && v.chars().nth(ADDITIONAL_METRIC_TAG_VALUE_MAX_LEN).is_some()
369                    {
370                        warn!(
371                            "additional_metric_tags: value for key '{}' exceeds {} characters; substituting tracer_blocked_value",
372                            key, ADDITIONAL_METRIC_TAG_VALUE_MAX_LEN,
373                        );
374                        Some((key.as_str(), TRACER_BLOCKED_VALUE))
375                    } else {
376                        Some((key.as_str(), v))
377                    }
378                }
379                _ => None,
380            })
381            .collect();
382
383        Self {
384            fixed: FixedAggregationKey {
385                resource_name,
386                service_name: span.service(),
387                operation_name: span.name(),
388                span_type: span.r#type(),
389                span_kind,
390                http_method,
391                http_endpoint,
392                service_source,
393                http_status_code: status_code,
394                grpc_status_code,
395                is_synthetics_request: span
396                    .get_meta(TAG_ORIGIN)
397                    .is_some_and(|origin| origin.starts_with(TAG_SYNTHETICS)),
398                is_trace_root: if span.is_trace_root() {
399                    pb::Trilean::True
400                } else {
401                    pb::Trilean::False
402                },
403            },
404            peer_tags,
405            additional_metric_tags,
406        }
407    }
408
409    /// Truncates string fields in accordance with the cardinality limit RFC
410    ///
411    /// This should be called only after obfuscation
412    #[cfg_attr(not(feature = "stats-obfuscation"), allow(unused))]
413    pub(crate) fn truncate(&mut self, big_resource: bool) {
414        let resource_length_limit = if big_resource { 15_000 } else { 5000 };
415        self.fixed.resource_name = slice_up_to(self.fixed.resource_name, resource_length_limit);
416        self.fixed.service_name = slice_up_to(self.fixed.service_name, 100);
417        self.fixed.operation_name = slice_up_to(self.fixed.operation_name, 100);
418        self.fixed.span_type = slice_up_to(self.fixed.span_type, 100);
419    }
420}
421
422/// Truncate `s` to at most `max_len` bytes
423fn slice_up_to(s: &str, max_len: usize) -> &str {
424    if max_len >= s.len() {
425        return s;
426    }
427    // TODO: use `floor_char_boundary` once our MSRV is 1.91 or higher
428    // (https://doc.rust-lang.org/std/primitive.str.html#method.floor_char_boundary)
429    let mut idx = max_len;
430    while !s.is_char_boundary(idx) {
431        idx -= 1;
432    }
433    &s[..idx]
434}
435
436impl OwnedAggregationKey {
437    /// Return the overflow sentinel key.
438    pub(super) fn overflow_key() -> Self {
439        OwnedAggregationKey {
440            fixed: FixedAggregationKey {
441                resource_name: TRACER_BLOCKED_VALUE.to_owned(),
442                service_name: TRACER_BLOCKED_VALUE.to_owned(),
443                operation_name: TRACER_BLOCKED_VALUE.to_owned(),
444                span_type: TRACER_BLOCKED_VALUE.to_owned(),
445                span_kind: TRACER_BLOCKED_VALUE.to_owned(),
446                http_method: TRACER_BLOCKED_VALUE.to_owned(),
447                http_endpoint: TRACER_BLOCKED_VALUE.to_owned(),
448                service_source: TRACER_BLOCKED_VALUE.to_owned(),
449                http_status_code: 0,
450                grpc_status_code: None,
451                is_synthetics_request: false,
452                is_trace_root: pb::Trilean::NotSet,
453            },
454            peer_tags: vec![(TRACER_BLOCKED_VALUE.to_owned(), "".to_owned())],
455            additional_metric_tags: vec![(TRACER_BLOCKED_VALUE.to_owned(), "".to_owned())],
456        }
457    }
458}
459
460impl From<pb::ClientGroupedStats> for OwnedAggregationKey {
461    fn from(value: pb::ClientGroupedStats) -> Self {
462        Self {
463            fixed: FixedAggregationKey {
464                resource_name: value.resource,
465                service_name: value.service,
466                operation_name: value.name,
467                span_type: value.r#type,
468                span_kind: value.span_kind,
469                http_method: value.http_method,
470                http_endpoint: value.http_endpoint,
471                service_source: value.service_source,
472                http_status_code: value.http_status_code,
473                grpc_status_code: value.grpc_status_code.parse().ok(),
474                is_synthetics_request: value.synthetics,
475                is_trace_root: pb::Trilean::try_from(value.is_trace_root)
476                    .unwrap_or(pb::Trilean::NotSet),
477            },
478            peer_tags: value
479                .peer_tags
480                .into_iter()
481                .filter_map(|t| {
482                    let (key, value) = t.split_once(':')?;
483                    Some((key.to_string(), value.to_string()))
484                })
485                .collect(),
486            additional_metric_tags: value
487                .additional_metric_tags
488                .into_iter()
489                .filter_map(|t| {
490                    let (key, value) = t.split_once(':')?;
491                    Some((key.to_string(), value.to_string()))
492                })
493                .collect(),
494        }
495    }
496}
497
498/// Return true if we care about peer tags on the span
499fn should_track_peer_tags(span_kind: &str) -> bool {
500    matches!(
501        span_kind.to_lowercase().as_str(),
502        "client" | "producer" | "consumer"
503    )
504}
505
506/// The stats computed from a group of span with the same AggregationKey
507#[derive(Debug, Default, Clone)]
508pub(super) struct GroupedStats {
509    hits: u64,
510    errors: u64,
511    duration: u64,
512    top_level_hits: u64,
513    ok_summary: libdd_ddsketch::DDSketch,
514    error_summary: libdd_ddsketch::DDSketch,
515    // Exact per-cell (ok/error) scalars used by the OTLP trace-metrics path. These are tracked
516    // separately from `duration` so the /v0.6/stats agent payload is byte-for-byte unchanged.
517    ok_duration: u64,
518    ok_min: u64,
519    ok_max: u64,
520    error_duration: u64,
521    error_min: u64,
522    error_max: u64,
523}
524
525impl GroupedStats {
526    /// Update the stats of a GroupedStats by inserting a span.
527    fn insert(&mut self, duration: i64, is_error: bool, is_top_level: bool) {
528        self.hits += 1;
529        self.duration += duration as u64;
530        let d = duration as u64;
531        if is_error {
532            self.errors += 1;
533            let _ = self.error_summary.add(duration as f64);
534            self.error_duration += d;
535            self.error_min = if self.errors == 1 {
536                d
537            } else {
538                self.error_min.min(d)
539            };
540            self.error_max = self.error_max.max(d);
541        } else {
542            let _ = self.ok_summary.add(duration as f64);
543            self.ok_duration += d;
544            let ok_count = self.hits - self.errors;
545            self.ok_min = if ok_count == 1 { d } else { self.ok_min.min(d) };
546            self.ok_max = self.ok_max.max(d);
547        }
548        if is_top_level {
549            self.top_level_hits += 1;
550        }
551    }
552}
553
554/// Exact per-cell (ok or error) scalars for one aggregation group, surfaced to the OTLP
555/// trace-metrics path. `count` is exact; `duration_ns`/`min_ns`/`max_ns` are exact when
556/// `count > 0` and meaningless otherwise (the OTLP mapper suppresses empty cells).
557#[derive(Debug, Clone, Copy, Default)]
558pub struct OtlpExactCell {
559    pub count: u64,
560    pub duration_ns: u64,
561    pub min_ns: u64,
562    pub max_ns: u64,
563}
564
565/// Exact OK/ERROR cells for one aggregation group, in the same order as the `stats` vector
566/// of the accompanying [`pb::ClientStatsBucket`].
567#[derive(Debug, Clone, Default)]
568pub struct OtlpExactGroup {
569    pub ok: OtlpExactCell,
570    pub error: OtlpExactCell,
571}
572
573/// A bucket flushed for the OTLP trace-metrics path. `exact[i]` is the exact-scalar sidecar
574/// for `bucket.stats[i]`; the protobuf bucket itself is identical to what the agent path uses.
575#[derive(Debug, Clone)]
576pub struct OtlpStatsBucket {
577    pub bucket: pb::ClientStatsBucket,
578    pub exact: Vec<OtlpExactGroup>,
579}
580
581/// A time bucket used for stats aggregation. It stores a map of GroupedStats storing the stats of
582/// spans aggregated on their AggregationKey.
583#[derive(Debug, Clone)]
584pub(super) struct StatsBucket {
585    data: HashMap<OwnedAggregationKey, GroupedStats>,
586    start: u64,
587    /// Maximum number of distinct aggregation keys this bucket will hold before collapsing new
588    /// ones into the overflow sentinel key.
589    cardinality_limits: CardinalityLimitConfig,
590    // HashSet of hashes of field values so we save memory
591    // This is not 100% accurate but the probability of getting collision is close to 0
592    // In the very rare case we get a collision, we would get one extra bucket which is totally
593    // fine
594    distinct_resources: HashSet<u64>,
595    distinct_http_endpoints: HashSet<u64>,
596    distinct_peer_tags: HashSet<u64>,
597    distinct_additional_tags: HashSet<u64>,
598    /// Number of spans collapsed into the overflow bucket due to whole-key cardinality limiting.
599    collapsed_count: u64,
600    collapsed_fields_metrics: CollapsedFieldsMetrics,
601    /// Indicates if stats obfuscated in this bucket. This is set once at creation and stays
602    /// constant per bucket
603    #[cfg(feature = "stats-obfuscation")]
604    pub(super) obfuscated: bool,
605}
606
607impl StatsBucket {
608    /// Return a new StatsBucket starting at `start_timestamp`.
609    ///
610    /// `cardinality_limits` are the values for whole-key and per-field cardinality limits
611    pub(super) fn new(
612        start_timestamp: u64,
613        cardinality_limits: CardinalityLimitConfig,
614        #[cfg(feature = "stats-obfuscation")] obfuscation_enabled: bool,
615    ) -> Self {
616        Self {
617            data: HashMap::new(),
618            start: start_timestamp,
619            cardinality_limits,
620            collapsed_count: 0,
621            #[cfg(feature = "stats-obfuscation")]
622            obfuscated: obfuscation_enabled,
623            distinct_resources: HashSet::new(),
624            distinct_http_endpoints: HashSet::new(),
625            distinct_peer_tags: HashSet::new(),
626            distinct_additional_tags: HashSet::new(),
627            collapsed_fields_metrics: cardinality_limit_telemetry::CollapsedFieldsMetrics::zero(),
628        }
629    }
630
631    /// Returns metrics on spans field collapse with reasons.
632    pub fn collapsed_fields_metrics(&self) -> cardinality_limit_telemetry::CollapsedFieldsMetrics {
633        self.collapsed_fields_metrics
634    }
635
636    /// Return the number of spans collapsed into the overflow bucket.
637    pub(super) fn collapsed_count(&self) -> u64 {
638        self.collapsed_count
639    }
640
641    /// Insert a value as stats in the group corresponding to the aggregation key, if it does not
642    /// exist it creates it.
643    ///
644    /// Keys that already exist in this bucket always merge normally. A new key is subject to the
645    /// `max_entries` limit, which collapses it into the overflow sentinel key.
646    pub(super) fn insert(
647        &mut self,
648        mut key: BorrowedAggregationKey<'_>,
649        duration: i64,
650        is_error: bool,
651        is_top_level: bool,
652    ) {
653        // Per field cardinality limiting
654        self.collapse_key_fields_cardinality(&mut key);
655
656        // The map can't change size before the entry below is resolved, so this single read
657        // covers the `max_entries` check in the vacant branch without a further lookup.
658        let len_before_insert = self.data.len();
659
660        match self.data.entry_ref(&key) {
661            // Existing key, merge
662            hashbrown::hash_map::EntryRef::Occupied(mut e) => {
663                e.get_mut().insert(duration, is_error, is_top_level);
664            }
665            hashbrown::hash_map::EntryRef::Vacant(e) => {
666                // New key over the max entry limit, collapse into the overflow
667                // sentinel.
668                if len_before_insert >= self.cardinality_limits.whole_key_limit {
669                    self.collapsed_count += 1;
670                    self.data
671                        .entry(OwnedAggregationKey::overflow_key())
672                        .or_default()
673                        .insert(duration, is_error, is_top_level);
674                    return;
675                }
676                // Within the max entry limit, admit key as a new distinct entry.
677                e.insert(GroupedStats::default())
678                    .insert(duration, is_error, is_top_level);
679            }
680        }
681    }
682
683    /// Collapse an aggregation key fields following the bucket's `CardinalityLimitConfig`
684    fn collapse_key_fields_cardinality(&mut self, key: &mut BorrowedAggregationKey<'_>) {
685        use hashbrown::hash_set::Entry;
686        fn hash(input: &impl Hash) -> u64 {
687            let mut hasher = DefaultHasher::new();
688            input.hash(&mut hasher);
689            hasher.finish()
690        }
691
692        let mut collapsed_fields = CollapsedFieldSet::empty();
693
694        let resource_hash = hash(&key.fixed.resource_name);
695        let resources_count = self.distinct_resources.len();
696        if let Entry::Vacant(slot) = self.distinct_resources.entry(resource_hash) {
697            if resources_count >= self.cardinality_limits.resource_limit {
698                key.fixed.resource_name = TRACER_BLOCKED_VALUE;
699                collapsed_fields.add(CollapsedFieldSet::RESOURCE_NAME);
700            } else {
701                slot.insert();
702            }
703        }
704
705        let http_endpoint_hash = hash(&key.fixed.http_endpoint);
706        let http_endpoints_count = self.distinct_http_endpoints.len();
707        if let Entry::Vacant(slot) = self.distinct_http_endpoints.entry(http_endpoint_hash) {
708            if http_endpoints_count >= self.cardinality_limits.http_endpoint_limit {
709                key.fixed.http_endpoint = TRACER_BLOCKED_VALUE;
710                collapsed_fields.add(CollapsedFieldSet::HTTP_ENDPOINT);
711            } else {
712                slot.insert();
713            }
714        }
715
716        let peer_tags_hash = hash(&key.peer_tags);
717        let peer_tags_count = self.distinct_peer_tags.len();
718        if let Entry::Vacant(slot) = self.distinct_peer_tags.entry(peer_tags_hash) {
719            if peer_tags_count >= self.cardinality_limits.peer_tags_limit {
720                key.peer_tags = vec![(TRACER_BLOCKED_VALUE, Cow::Borrowed(""))];
721                collapsed_fields.add(CollapsedFieldSet::PEER_TAGS);
722            } else {
723                slot.insert();
724            }
725        }
726
727        let additional_tags_hash = hash(&key.additional_metric_tags);
728        let additional_tags_count = self.distinct_additional_tags.len();
729        if let Entry::Vacant(slot) = self.distinct_additional_tags.entry(additional_tags_hash) {
730            if additional_tags_count >= self.cardinality_limits.additional_tags_limit {
731                key.additional_metric_tags = vec![(TRACER_BLOCKED_VALUE, "")];
732                collapsed_fields.add(CollapsedFieldSet::ADDITIONAL_TAGS);
733            } else {
734                slot.insert();
735            }
736        }
737        self.collapsed_fields_metrics.increment(collapsed_fields);
738    }
739
740    /// Consume the bucket and return a ClientStatsBucket containing the bucket stats.
741    /// `bucket_duration` is the size of buckets for the concentrator containing the bucket.
742    pub(super) fn flush(self, bucket_duration: u64) -> pb::ClientStatsBucket {
743        self.flush_with_otlp_exact(bucket_duration).bucket
744    }
745
746    /// Like [`Self::flush`], but additionally produces exact per-cell scalars for the OTLP
747    /// trace-metrics path. The `bucket` field is identical to what [`Self::flush`] returns.
748    pub(super) fn flush_with_otlp_exact(self, bucket_duration: u64) -> OtlpStatsBucket {
749        let mut stats = Vec::with_capacity(self.data.len());
750        let mut exact = Vec::with_capacity(self.data.len());
751        for (k, g) in self.data {
752            exact.push(OtlpExactGroup {
753                ok: OtlpExactCell {
754                    count: g.hits.saturating_sub(g.errors),
755                    duration_ns: g.ok_duration,
756                    min_ns: g.ok_min,
757                    max_ns: g.ok_max,
758                },
759                error: OtlpExactCell {
760                    count: g.errors,
761                    duration_ns: g.error_duration,
762                    min_ns: g.error_min,
763                    max_ns: g.error_max,
764                },
765            });
766            stats.push(encode_grouped_stats(k, g));
767        }
768        OtlpStatsBucket {
769            bucket: pb::ClientStatsBucket {
770                start: self.start,
771                duration: bucket_duration,
772                stats,
773                agent_time_shift: 0,
774            },
775            exact,
776        }
777    }
778}
779
780/// Create a ClientGroupedStats struct based on the given AggregationKey and GroupedStats
781fn encode_grouped_stats(key: OwnedAggregationKey, group: GroupedStats) -> pb::ClientGroupedStats {
782    let f = key.fixed;
783    pb::ClientGroupedStats {
784        service: f.service_name,
785        name: f.operation_name,
786        resource: f.resource_name,
787        http_status_code: f.http_status_code,
788        r#type: f.span_type,
789        db_type: String::new(), // db_type is not used yet (see proto definition)
790
791        hits: group.hits,
792        errors: group.errors,
793        duration: group.duration,
794
795        ok_summary: group.ok_summary.encode_to_vec(),
796        error_summary: group.error_summary.encode_to_vec(),
797        synthetics: f.is_synthetics_request,
798        top_level_hits: group.top_level_hits,
799        span_kind: f.span_kind,
800
801        peer_tags: key
802            .peer_tags
803            .into_iter()
804            .map(|(k, v)| {
805                if v.is_empty() {
806                    k.to_string()
807                } else {
808                    format!("{k}:{v}")
809                }
810            })
811            .collect(),
812        is_trace_root: f.is_trace_root.into(),
813        http_method: f.http_method,
814        http_endpoint: f.http_endpoint,
815        grpc_status_code: f
816            .grpc_status_code
817            .map(|c| c.to_string())
818            .unwrap_or_default(),
819        service_source: f.service_source,
820        span_derived_primary_tags: vec![],
821        additional_metric_tags: key
822            .additional_metric_tags
823            .into_iter()
824            .map(|(k, v)| format!("{k}:{v}"))
825            .collect(),
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use libdd_trace_utils::span::v04::{SpanBytes, SpanSlice};
832
833    use super::*;
834    use libdd_trace_protobuf::pb;
835    use std::hash::Hash;
836
837    fn get_hash(v: &impl Hash) -> u64 {
838        use std::hash::Hasher;
839        let mut hasher = std::hash::DefaultHasher::new();
840        v.hash(&mut hasher);
841        hasher.finish()
842    }
843
844    impl FixedAggregationKey<String> {
845        fn into_key(self) -> OwnedAggregationKey {
846            OwnedAggregationKey {
847                fixed: self,
848                peer_tags: vec![],
849                additional_metric_tags: vec![],
850            }
851        }
852        fn into_key_with_peers(self, peer_tags: Vec<(String, String)>) -> OwnedAggregationKey {
853            OwnedAggregationKey {
854                fixed: self,
855                peer_tags,
856                additional_metric_tags: vec![],
857            }
858        }
859    }
860
861    #[test]
862    fn test_float_to_u32_rejects_malformed_values() {
863        assert_eq!(float_to_u32(0.0), Some(0));
864        assert_eq!(float_to_u32(500.0), Some(500));
865        assert_eq!(float_to_u32(u32::MAX as f64), Some(u32::MAX));
866        assert_eq!(float_to_u32(f64::NAN), None);
867        assert_eq!(float_to_u32(f64::INFINITY), None);
868        assert_eq!(float_to_u32(-1.0), None);
869        assert_eq!(float_to_u32(1.5), None);
870        assert_eq!(float_to_u32((u32::MAX as f64) + 1.0), None);
871    }
872
873    #[test]
874    fn test_aggregation_key_from_span() {
875        let test_cases: Vec<(SpanBytes, OwnedAggregationKey)> = vec![
876            // Root span
877            (
878                SpanBytes {
879                    service: "service".into(),
880                    name: "op".into(),
881                    resource: "res".into(),
882                    span_id: 1,
883                    parent_id: 0,
884                    ..Default::default()
885                },
886                FixedAggregationKey {
887                    service_name: "service".into(),
888                    operation_name: "op".into(),
889                    resource_name: "res".into(),
890                    is_trace_root: pb::Trilean::True,
891                    ..Default::default()
892                }
893                .into_key(),
894            ),
895            // Datadog-key numeric conversion retains its existing cast behavior.
896            (
897                SpanBytes {
898                    service: "service".into(),
899                    name: "op".into(),
900                    resource: "res".into(),
901                    span_id: 1,
902                    parent_id: 0,
903                    meta: vec![("http.response.status_code".into(), "418".into())].into(),
904                    metrics: vec![("http.status_code".into(), f64::NAN)].into(),
905                    ..Default::default()
906                },
907                FixedAggregationKey {
908                    service_name: "service".into(),
909                    operation_name: "op".into(),
910                    resource_name: "res".into(),
911                    is_synthetics_request: false,
912                    is_trace_root: pb::Trilean::True,
913                    http_status_code: 0,
914                    ..Default::default()
915                }
916                .into_key(),
917            ),
918            // The Datadog key keeps its existing u32 behavior and wins over the OTel key.
919            (
920                SpanBytes {
921                    service: "service".into(),
922                    name: "op".into(),
923                    resource: "res".into(),
924                    span_id: 1,
925                    parent_id: 0,
926                    meta: vec![("http.response.status_code".into(), "418".into())].into(),
927                    metrics: vec![("http.status_code".into(), 600.0)].into(),
928                    ..Default::default()
929                },
930                FixedAggregationKey {
931                    service_name: "service".into(),
932                    operation_name: "op".into(),
933                    resource_name: "res".into(),
934                    is_synthetics_request: false,
935                    is_trace_root: pb::Trilean::True,
936                    http_status_code: 600,
937                    ..Default::default()
938                }
939                .into_key(),
940            ),
941            // Datadog-key numeric conversion keeps the existing Rust cast behavior.
942            (
943                SpanBytes {
944                    service: "service".into(),
945                    name: "op".into(),
946                    resource: "res".into(),
947                    span_id: 1,
948                    parent_id: 0,
949                    meta: vec![("http.response.status_code".into(), "418".into())].into(),
950                    metrics: vec![("http.status_code".into(), 1.5)].into(),
951                    ..Default::default()
952                },
953                FixedAggregationKey {
954                    service_name: "service".into(),
955                    operation_name: "op".into(),
956                    resource_name: "res".into(),
957                    is_synthetics_request: false,
958                    is_trace_root: pb::Trilean::True,
959                    http_status_code: 1,
960                    ..Default::default()
961                }
962                .into_key(),
963            ),
964            // The Datadog key also keeps its existing u32 behavior when stored as metadata.
965            (
966                SpanBytes {
967                    service: "service".into(),
968                    name: "op".into(),
969                    resource: "res".into(),
970                    span_id: 1,
971                    parent_id: 0,
972                    meta: vec![("http.status_code".into(), "99".into())].into(),
973                    metrics: vec![("http.response.status_code".into(), 418.0)].into(),
974                    ..Default::default()
975                },
976                FixedAggregationKey {
977                    service_name: "service".into(),
978                    operation_name: "op".into(),
979                    resource_name: "res".into(),
980                    is_synthetics_request: false,
981                    is_trace_root: pb::Trilean::True,
982                    http_status_code: 99,
983                    ..Default::default()
984                }
985                .into_key(),
986            ),
987            // An out-of-range OTel status is not a valid HTTP response status.
988            (
989                SpanBytes {
990                    service: "service".into(),
991                    name: "op".into(),
992                    resource: "res".into(),
993                    span_id: 1,
994                    parent_id: 0,
995                    metrics: vec![("http.response.status_code".into(), 600.0)].into(),
996                    ..Default::default()
997                },
998                FixedAggregationKey {
999                    service_name: "service".into(),
1000                    operation_name: "op".into(),
1001                    resource_name: "res".into(),
1002                    is_synthetics_request: false,
1003                    is_trace_root: pb::Trilean::True,
1004                    http_status_code: 0,
1005                    ..Default::default()
1006                }
1007                .into_key(),
1008            ),
1009            // Span with span kind
1010            (
1011                SpanBytes {
1012                    service: "service".into(),
1013                    name: "op".into(),
1014                    resource: "res".into(),
1015                    span_id: 1,
1016                    parent_id: 0,
1017                    meta: vec![("span.kind".into(), "client".into())].into(),
1018                    ..Default::default()
1019                },
1020                FixedAggregationKey {
1021                    service_name: "service".into(),
1022                    operation_name: "op".into(),
1023                    resource_name: "res".into(),
1024                    span_kind: "client".into(),
1025                    is_trace_root: pb::Trilean::True,
1026                    ..Default::default()
1027                }
1028                .into_key(),
1029            ),
1030            // Span with peer tags but peertags aggregation disabled
1031            (
1032                SpanBytes {
1033                    service: "service".into(),
1034                    name: "op".into(),
1035                    resource: "res".into(),
1036                    span_id: 1,
1037                    parent_id: 0,
1038                    meta: vec![
1039                        ("span.kind".into(), "client".into()),
1040                        ("aws.s3.bucket".into(), "bucket-a".into()),
1041                    ]
1042                    .into(),
1043                    ..Default::default()
1044                },
1045                FixedAggregationKey {
1046                    service_name: "service".into(),
1047                    operation_name: "op".into(),
1048                    resource_name: "res".into(),
1049                    span_kind: "client".into(),
1050                    is_trace_root: pb::Trilean::True,
1051                    ..Default::default()
1052                }
1053                .into_key(),
1054            ),
1055            // Span with multiple peer tags but peertags aggregation disabled
1056            (
1057                SpanBytes {
1058                    service: "service".into(),
1059                    name: "op".into(),
1060                    resource: "res".into(),
1061                    span_id: 1,
1062                    parent_id: 0,
1063                    meta: vec![
1064                        ("span.kind".into(), "producer".into()),
1065                        ("aws.s3.bucket".into(), "bucket-a".into()),
1066                        ("db.instance".into(), "dynamo.test.us1".into()),
1067                        ("db.system".into(), "dynamodb".into()),
1068                    ]
1069                    .into(),
1070                    ..Default::default()
1071                },
1072                FixedAggregationKey {
1073                    service_name: "service".into(),
1074                    operation_name: "op".into(),
1075                    resource_name: "res".into(),
1076                    span_kind: "producer".into(),
1077                    is_trace_root: pb::Trilean::True,
1078                    ..Default::default()
1079                }
1080                .into_key(),
1081            ),
1082            // Span with multiple peer tags but peertags aggregation disabled and span kind is
1083            // server
1084            (
1085                SpanBytes {
1086                    service: "service".into(),
1087                    name: "op".into(),
1088                    resource: "res".into(),
1089                    span_id: 1,
1090                    parent_id: 0,
1091                    meta: vec![
1092                        ("span.kind".into(), "server".into()),
1093                        ("aws.s3.bucket".into(), "bucket-a".into()),
1094                        ("db.instance".into(), "dynamo.test.us1".into()),
1095                        ("db.system".into(), "dynamodb".into()),
1096                    ]
1097                    .into(),
1098                    ..Default::default()
1099                },
1100                FixedAggregationKey {
1101                    service_name: "service".into(),
1102                    operation_name: "op".into(),
1103                    resource_name: "res".into(),
1104                    span_kind: "server".into(),
1105                    is_trace_root: pb::Trilean::True,
1106                    ..Default::default()
1107                }
1108                .into_key(),
1109            ),
1110            // Span from synthetics
1111            (
1112                SpanBytes {
1113                    service: "service".into(),
1114                    name: "op".into(),
1115                    resource: "res".into(),
1116                    span_id: 1,
1117                    parent_id: 0,
1118                    meta: vec![("_dd.origin".into(), "synthetics-browser".into())].into(),
1119                    ..Default::default()
1120                },
1121                FixedAggregationKey {
1122                    service_name: "service".into(),
1123                    operation_name: "op".into(),
1124                    resource_name: "res".into(),
1125                    is_synthetics_request: true,
1126                    is_trace_root: pb::Trilean::True,
1127                    ..Default::default()
1128                }
1129                .into_key(),
1130            ),
1131            // Span with status code in meta
1132            (
1133                SpanBytes {
1134                    service: "service".into(),
1135                    name: "op".into(),
1136                    resource: "res".into(),
1137                    span_id: 1,
1138                    parent_id: 0,
1139                    meta: vec![("http.status_code".into(), "418".into())].into(),
1140                    ..Default::default()
1141                },
1142                FixedAggregationKey {
1143                    service_name: "service".into(),
1144                    operation_name: "op".into(),
1145                    resource_name: "res".into(),
1146                    is_synthetics_request: false,
1147                    is_trace_root: pb::Trilean::True,
1148                    http_status_code: 418,
1149                    ..Default::default()
1150                }
1151                .into_key(),
1152            ),
1153            // Span with invalid status code in meta
1154            (
1155                SpanBytes {
1156                    service: "service".into(),
1157                    name: "op".into(),
1158                    resource: "res".into(),
1159                    span_id: 1,
1160                    parent_id: 0,
1161                    meta: vec![("http.status_code".into(), "x".into())].into(),
1162                    ..Default::default()
1163                },
1164                FixedAggregationKey {
1165                    service_name: "service".into(),
1166                    operation_name: "op".into(),
1167                    resource_name: "res".into(),
1168                    is_synthetics_request: false,
1169                    is_trace_root: pb::Trilean::True,
1170                    ..Default::default()
1171                }
1172                .into_key(),
1173            ),
1174            // Span with status code in metrics
1175            (
1176                SpanBytes {
1177                    service: "service".into(),
1178                    name: "op".into(),
1179                    resource: "res".into(),
1180                    span_id: 1,
1181                    parent_id: 0,
1182                    metrics: vec![("http.status_code".into(), 418.0)].into(),
1183                    ..Default::default()
1184                },
1185                FixedAggregationKey {
1186                    service_name: "service".into(),
1187                    operation_name: "op".into(),
1188                    resource_name: "res".into(),
1189                    is_synthetics_request: false,
1190                    is_trace_root: pb::Trilean::True,
1191                    http_status_code: 418,
1192                    ..Default::default()
1193                }
1194                .into_key(),
1195            ),
1196            // Span in OTel semantics mode: status code in metrics under the OTel name, which is
1197            // where the int-typed OTel attribute lands
1198            (
1199                SpanBytes {
1200                    service: "service".into(),
1201                    name: "op".into(),
1202                    resource: "res".into(),
1203                    span_id: 1,
1204                    parent_id: 0,
1205                    metrics: vec![("http.response.status_code".into(), 418.0)].into(),
1206                    ..Default::default()
1207                },
1208                FixedAggregationKey {
1209                    service_name: "service".into(),
1210                    operation_name: "op".into(),
1211                    resource_name: "res".into(),
1212                    is_synthetics_request: false,
1213                    is_trace_root: pb::Trilean::True,
1214                    http_status_code: 418,
1215                    ..Default::default()
1216                }
1217                .into_key(),
1218            ),
1219            // Span in OTel semantics mode: status code in meta under the OTel name, which is
1220            // where it lands whenever the tracer stringifies its attributes
1221            (
1222                SpanBytes {
1223                    service: "service".into(),
1224                    name: "op".into(),
1225                    resource: "res".into(),
1226                    span_id: 1,
1227                    parent_id: 0,
1228                    meta: vec![("http.response.status_code".into(), "418".into())].into(),
1229                    ..Default::default()
1230                },
1231                FixedAggregationKey {
1232                    service_name: "service".into(),
1233                    operation_name: "op".into(),
1234                    resource_name: "res".into(),
1235                    is_synthetics_request: false,
1236                    is_trace_root: pb::Trilean::True,
1237                    http_status_code: 418,
1238                    ..Default::default()
1239                }
1240                .into_key(),
1241            ),
1242            // Datadog name in meta, OTel name in metrics: the Datadog name still wins, so the
1243            // metrics-before-meta order inside one name never leaks across names
1244            (
1245                SpanBytes {
1246                    service: "service".into(),
1247                    name: "op".into(),
1248                    resource: "res".into(),
1249                    span_id: 1,
1250                    parent_id: 0,
1251                    meta: vec![("http.status_code".into(), "500".into())].into(),
1252                    metrics: vec![("http.response.status_code".into(), 418.0)].into(),
1253                    ..Default::default()
1254                },
1255                FixedAggregationKey {
1256                    service_name: "service".into(),
1257                    operation_name: "op".into(),
1258                    resource_name: "res".into(),
1259                    is_synthetics_request: false,
1260                    is_trace_root: pb::Trilean::True,
1261                    http_status_code: 500,
1262                    ..Default::default()
1263                }
1264                .into_key(),
1265            ),
1266            // An unparseable Datadog status retains its existing parse-or-zero behavior.
1267            (
1268                SpanBytes {
1269                    service: "service".into(),
1270                    name: "op".into(),
1271                    resource: "res".into(),
1272                    span_id: 1,
1273                    parent_id: 0,
1274                    meta: vec![
1275                        ("http.status_code".into(), "x".into()),
1276                        ("http.response.status_code".into(), "418".into()),
1277                    ]
1278                    .into(),
1279                    ..Default::default()
1280                },
1281                FixedAggregationKey {
1282                    service_name: "service".into(),
1283                    operation_name: "op".into(),
1284                    resource_name: "res".into(),
1285                    is_synthetics_request: false,
1286                    is_trace_root: pb::Trilean::True,
1287                    http_status_code: 0,
1288                    ..Default::default()
1289                }
1290                .into_key(),
1291            ),
1292            // Datadog metrics retain their existing precedence and cast behavior.
1293            (
1294                SpanBytes {
1295                    service: "service".into(),
1296                    name: "op".into(),
1297                    resource: "res".into(),
1298                    span_id: 1,
1299                    parent_id: 0,
1300                    meta: vec![("http.status_code".into(), "500".into())].into(),
1301                    metrics: vec![("http.status_code".into(), f64::NAN)].into(),
1302                    ..Default::default()
1303                },
1304                FixedAggregationKey {
1305                    service_name: "service".into(),
1306                    operation_name: "op".into(),
1307                    resource_name: "res".into(),
1308                    is_synthetics_request: false,
1309                    is_trace_root: pb::Trilean::True,
1310                    http_status_code: 0,
1311                    ..Default::default()
1312                }
1313                .into_key(),
1314            ),
1315            // An empty Datadog method counts as absent, so it cannot shadow the OTel name
1316            (
1317                SpanBytes {
1318                    service: "service".into(),
1319                    name: "op".into(),
1320                    resource: "res".into(),
1321                    span_id: 1,
1322                    parent_id: 0,
1323                    meta: vec![
1324                        ("http.method".into(), "".into()),
1325                        ("http.request.method".into(), "GET".into()),
1326                    ]
1327                    .into(),
1328                    ..Default::default()
1329                },
1330                FixedAggregationKey {
1331                    service_name: "service".into(),
1332                    operation_name: "op".into(),
1333                    resource_name: "res".into(),
1334                    is_synthetics_request: false,
1335                    is_trace_root: pb::Trilean::True,
1336                    http_method: "GET".into(),
1337                    ..Default::default()
1338                }
1339                .into_key(),
1340            ),
1341            // Both names present: the canonical Datadog names win.
1342            (
1343                SpanBytes {
1344                    service: "service".into(),
1345                    name: "op".into(),
1346                    resource: "res".into(),
1347                    span_id: 1,
1348                    parent_id: 0,
1349                    meta: vec![
1350                        ("http.status_code".into(), "500".into()),
1351                        ("http.response.status_code".into(), "418".into()),
1352                        ("http.method".into(), "GET".into()),
1353                        ("http.request.method".into(), "POST".into()),
1354                    ]
1355                    .into(),
1356                    ..Default::default()
1357                },
1358                FixedAggregationKey {
1359                    service_name: "service".into(),
1360                    operation_name: "op".into(),
1361                    resource_name: "res".into(),
1362                    is_synthetics_request: false,
1363                    is_trace_root: pb::Trilean::True,
1364                    http_status_code: 500,
1365                    http_method: "GET".into(),
1366                    ..Default::default()
1367                }
1368                .into_key(),
1369            ),
1370            // Span in OTel semantics mode: method under the OTel name, route unchanged because
1371            // http.route is already the OTel name
1372            (
1373                SpanBytes {
1374                    service: "service".into(),
1375                    name: "op".into(),
1376                    resource: "GET /api/v1/users".into(),
1377                    span_id: 1,
1378                    parent_id: 0,
1379                    meta: vec![
1380                        ("http.request.method".into(), "GET".into()),
1381                        ("http.route".into(), "/api/v1/users".into()),
1382                    ]
1383                    .into(),
1384                    ..Default::default()
1385                },
1386                FixedAggregationKey {
1387                    service_name: "service".into(),
1388                    operation_name: "op".into(),
1389                    resource_name: "GET /api/v1/users".into(),
1390                    is_synthetics_request: false,
1391                    is_trace_root: pb::Trilean::True,
1392                    http_method: "GET".into(),
1393                    http_endpoint: "/api/v1/users".into(),
1394                    ..Default::default()
1395                }
1396                .into_key(),
1397            ),
1398            // Span with http.method and http.route
1399            (
1400                SpanBytes {
1401                    service: "service".into(),
1402                    name: "op".into(),
1403                    resource: "GET /api/v1/users".into(),
1404                    span_id: 1,
1405                    parent_id: 0,
1406                    meta: vec![
1407                        ("http.method".into(), "GET".into()),
1408                        ("http.route".into(), "/api/v1/users".into()),
1409                    ]
1410                    .into(),
1411                    ..Default::default()
1412                },
1413                FixedAggregationKey {
1414                    service_name: "service".into(),
1415                    operation_name: "op".into(),
1416                    resource_name: "GET /api/v1/users".into(),
1417                    http_method: "GET".into(),
1418                    http_endpoint: "/api/v1/users".into(),
1419                    is_synthetics_request: false,
1420                    is_trace_root: pb::Trilean::True,
1421                    ..Default::default()
1422                }
1423                .into_key(),
1424            ),
1425            // The canonical OTel route takes precedence over the Datadog-only endpoint.
1426            (
1427                SpanBytes {
1428                    service: "service".into(),
1429                    name: "op".into(),
1430                    resource: "POST /users/create".into(),
1431                    span_id: 1,
1432                    parent_id: 0,
1433                    meta: vec![
1434                        ("http.method".into(), "POST".into()),
1435                        ("http.route".into(), "/users/create".into()),
1436                        ("http.endpoint".into(), "/users/create2".into()),
1437                    ]
1438                    .into(),
1439                    ..Default::default()
1440                },
1441                FixedAggregationKey {
1442                    service_name: "service".into(),
1443                    operation_name: "op".into(),
1444                    resource_name: "POST /users/create".into(),
1445                    http_method: "POST".into(),
1446                    http_endpoint: "/users/create".into(),
1447                    is_synthetics_request: false,
1448                    is_trace_root: pb::Trilean::True,
1449                    ..Default::default()
1450                }
1451                .into_key(),
1452            ),
1453            // An empty route falls back to the retained Datadog endpoint.
1454            (
1455                SpanBytes {
1456                    service: "service".into(),
1457                    name: "op".into(),
1458                    resource: "POST /users/create".into(),
1459                    span_id: 1,
1460                    parent_id: 0,
1461                    meta: vec![
1462                        ("http.method".into(), "POST".into()),
1463                        ("http.route".into(), "".into()),
1464                        ("http.endpoint".into(), "/users/create2".into()),
1465                    ]
1466                    .into(),
1467                    ..Default::default()
1468                },
1469                FixedAggregationKey {
1470                    service_name: "service".into(),
1471                    operation_name: "op".into(),
1472                    resource_name: "POST /users/create".into(),
1473                    http_method: "POST".into(),
1474                    http_endpoint: "/users/create2".into(),
1475                    is_synthetics_request: false,
1476                    is_trace_root: pb::Trilean::True,
1477                    ..Default::default()
1478                }
1479                .into_key(),
1480            ),
1481            // Span with grpc status from meta as named string
1482            (
1483                SpanBytes {
1484                    meta: vec![("rpc.grpc.status_code".into(), "OK".into())].into(),
1485                    ..Default::default()
1486                },
1487                FixedAggregationKey {
1488                    grpc_status_code: Some(0),
1489                    is_trace_root: pb::Trilean::True,
1490                    ..Default::default()
1491                }
1492                .into_key(),
1493            ),
1494            // grpc.method.name is carried in GroupedStats (for OTLP), not in the aggregation key.
1495            (
1496                SpanBytes {
1497                    meta: vec![("grpc.method.name".into(), "/pkg.Svc/Method".into())].into(),
1498                    ..Default::default()
1499                },
1500                FixedAggregationKey {
1501                    is_trace_root: pb::Trilean::True,
1502                    ..Default::default()
1503                }
1504                .into_key(),
1505            ),
1506            // Span with grpc status from meta as numeric string
1507            (
1508                SpanBytes {
1509                    meta: vec![("rpc.grpc.status_code".into(), "14".into())].into(),
1510                    ..Default::default()
1511                },
1512                FixedAggregationKey {
1513                    grpc_status_code: Some(14),
1514                    is_trace_root: pb::Trilean::True,
1515                    ..Default::default()
1516                }
1517                .into_key(),
1518            ),
1519            // Span with grpc status from meta with StatusCode. prefix
1520            (
1521                SpanBytes {
1522                    meta: vec![("grpc.code".into(), "StatusCode.UNAVAILABLE".into())].into(),
1523                    ..Default::default()
1524                },
1525                FixedAggregationKey {
1526                    grpc_status_code: Some(14),
1527                    is_trace_root: pb::Trilean::True,
1528                    ..Default::default()
1529                }
1530                .into_key(),
1531            ),
1532            // Span with grpc status from metrics takes precedence over meta
1533            (
1534                SpanBytes {
1535                    meta: vec![("rpc.grpc.status_code".into(), "PERMISSION_DENIED".into())].into(),
1536                    metrics: vec![("rpc.grpc.status_code".into(), 2.0)].into(),
1537                    ..Default::default()
1538                },
1539                FixedAggregationKey {
1540                    grpc_status_code: Some(7),
1541                    is_trace_root: pb::Trilean::True,
1542                    ..Default::default()
1543                }
1544                .into_key(),
1545            ),
1546            // Span with grpc status from metrics via secondary key
1547            (
1548                SpanBytes {
1549                    metrics: vec![("grpc.code".into(), 3.0)].into(),
1550                    ..Default::default()
1551                },
1552                FixedAggregationKey {
1553                    grpc_status_code: Some(3),
1554                    is_trace_root: pb::Trilean::True,
1555                    ..Default::default()
1556                }
1557                .into_key(),
1558            ),
1559            // Span with invalid grpc status string
1560            (
1561                SpanBytes {
1562                    meta: vec![("rpc.grpc.status_code".into(), "NOPE".into())].into(),
1563                    ..Default::default()
1564                },
1565                FixedAggregationKey {
1566                    is_trace_root: pb::Trilean::True,
1567                    ..Default::default()
1568                }
1569                .into_key(),
1570            ),
1571            // Span with service source set by integration
1572            (
1573                SpanBytes {
1574                    service: "my-service".into(),
1575                    name: "op".into(),
1576                    resource: "res".into(),
1577                    span_id: 1,
1578                    parent_id: 0,
1579                    meta: vec![("_dd.svc_src".into(), "redis".into())].into(),
1580                    ..Default::default()
1581                },
1582                FixedAggregationKey {
1583                    service_name: "my-service".into(),
1584                    operation_name: "op".into(),
1585                    resource_name: "res".into(),
1586                    is_trace_root: pb::Trilean::True,
1587                    service_source: "redis".into(),
1588                    ..Default::default()
1589                }
1590                .into_key(),
1591            ),
1592            // Span with service source set by configuration option
1593            (
1594                SpanBytes {
1595                    service: "my-service".into(),
1596                    name: "op".into(),
1597                    resource: "res".into(),
1598                    span_id: 1,
1599                    parent_id: 0,
1600                    meta: vec![("_dd.svc_src".into(), "opt.split_by_tag".into())].into(),
1601                    ..Default::default()
1602                },
1603                FixedAggregationKey {
1604                    service_name: "my-service".into(),
1605                    operation_name: "op".into(),
1606                    resource_name: "res".into(),
1607                    is_trace_root: pb::Trilean::True,
1608                    service_source: "opt.split_by_tag".into(),
1609                    ..Default::default()
1610                }
1611                .into_key(),
1612            ),
1613            // Span without service source (default service name)
1614            (
1615                SpanBytes {
1616                    service: "my-service".into(),
1617                    name: "op".into(),
1618                    resource: "res".into(),
1619                    span_id: 1,
1620                    parent_id: 0,
1621                    ..Default::default()
1622                },
1623                FixedAggregationKey {
1624                    service_name: "my-service".into(),
1625                    operation_name: "op".into(),
1626                    resource_name: "res".into(),
1627                    is_trace_root: pb::Trilean::True,
1628                    service_source: "".into(),
1629                    ..Default::default()
1630                }
1631                .into_key(),
1632            ),
1633        ];
1634
1635        let test_peer_tags = vec![
1636            "aws.s3.bucket".to_string(),
1637            "db.instance".to_string(),
1638            "db.system".to_string(),
1639        ];
1640
1641        let test_cases_with_peer_tags: Vec<(SpanSlice, OwnedAggregationKey)> = vec![
1642            // Span with peer tags with peertags aggregation enabled
1643            (
1644                SpanSlice {
1645                    service: Cow::Borrowed("service"),
1646                    name: Cow::Borrowed("op"),
1647                    resource: Cow::Borrowed("res"),
1648                    span_id: 1,
1649                    parent_id: 0,
1650                    meta: vec![
1651                        (Cow::Borrowed("span.kind"), Cow::Borrowed("client")),
1652                        (Cow::Borrowed("aws.s3.bucket"), Cow::Borrowed("bucket-a")),
1653                    ]
1654                    .into(),
1655                    ..Default::default()
1656                },
1657                FixedAggregationKey {
1658                    service_name: "service".into(),
1659                    operation_name: "op".into(),
1660                    resource_name: "res".into(),
1661                    span_kind: "client".into(),
1662                    is_trace_root: pb::Trilean::True,
1663                    ..Default::default()
1664                }
1665                .into_key_with_peers(vec![("aws.s3.bucket".into(), "bucket-a".into())]),
1666            ),
1667            // Span with multiple peer tags with peertags aggregation enabled
1668            (
1669                SpanSlice {
1670                    service: Cow::Borrowed("service"),
1671                    name: Cow::Borrowed("op"),
1672                    resource: Cow::Borrowed("res"),
1673                    span_id: 1,
1674                    parent_id: 0,
1675                    meta: vec![
1676                        (Cow::Borrowed("span.kind"), Cow::Borrowed("producer")),
1677                        (Cow::Borrowed("aws.s3.bucket"), Cow::Borrowed("bucket-a")),
1678                        (
1679                            Cow::Borrowed("db.instance"),
1680                            Cow::Borrowed("dynamo.test.us1"),
1681                        ),
1682                        (Cow::Borrowed("db.system"), Cow::Borrowed("dynamodb")),
1683                    ]
1684                    .into(),
1685                    ..Default::default()
1686                },
1687                FixedAggregationKey {
1688                    service_name: "service".into(),
1689                    operation_name: "op".into(),
1690                    resource_name: "res".into(),
1691                    span_kind: "producer".into(),
1692                    is_trace_root: pb::Trilean::True,
1693                    ..Default::default()
1694                }
1695                .into_key_with_peers(vec![
1696                    ("aws.s3.bucket".into(), "bucket-a".into()),
1697                    ("db.instance".into(), "dynamo.test.us1".into()),
1698                    ("db.system".into(), "dynamodb".into()),
1699                ]),
1700            ),
1701            // Span with multiple peer tags with peertags aggregation enabled and span kind is
1702            // server
1703            (
1704                SpanSlice {
1705                    service: Cow::Borrowed("service"),
1706                    name: Cow::Borrowed("op"),
1707                    resource: Cow::Borrowed("res"),
1708                    span_id: 1,
1709                    parent_id: 0,
1710                    meta: vec![
1711                        (Cow::Borrowed("span.kind"), Cow::Borrowed("server")),
1712                        (Cow::Borrowed("aws.s3.bucket"), Cow::Borrowed("bucket-a")),
1713                        (
1714                            Cow::Borrowed("db.instance"),
1715                            Cow::Borrowed("dynamo.test.us1"),
1716                        ),
1717                        (Cow::Borrowed("db.system"), Cow::Borrowed("dynamodb")),
1718                    ]
1719                    .into(),
1720                    ..Default::default()
1721                },
1722                FixedAggregationKey {
1723                    service_name: "service".into(),
1724                    operation_name: "op".into(),
1725                    resource_name: "res".into(),
1726                    span_kind: "server".into(),
1727                    is_trace_root: pb::Trilean::True,
1728                    ..Default::default()
1729                }
1730                .into_key(),
1731            ),
1732        ];
1733
1734        for (span, expected_key) in test_cases {
1735            let borrowed_key = BorrowedAggregationKey::from_span(&span, &[], &[]);
1736            assert_eq!(
1737                OwnedAggregationKey::from(&borrowed_key),
1738                expected_key,
1739                "for span {span:?}"
1740            );
1741            assert_eq!(
1742                get_hash(&borrowed_key),
1743                get_hash(&OwnedAggregationKey::from(&borrowed_key))
1744            );
1745        }
1746
1747        for (span, expected_key) in test_cases_with_peer_tags {
1748            let borrowed_key =
1749                BorrowedAggregationKey::from_span(&span, test_peer_tags.as_slice(), &[]);
1750            assert_eq!(OwnedAggregationKey::from(&borrowed_key), expected_key);
1751            assert_eq!(
1752                get_hash(&borrowed_key),
1753                get_hash(&OwnedAggregationKey::from(&borrowed_key))
1754            );
1755        }
1756    }
1757
1758    #[test]
1759    fn test_peer_tag_ip_quantization_in_aggregation_key() {
1760        let peer_tag_keys = vec!["peer.hostname".to_string(), "db.instance".to_string()];
1761
1762        // IPv4 address peer tag gets replaced with blocked-ip-address
1763        let span_ipv4 = SpanSlice {
1764            service: Cow::Borrowed("service"),
1765            name: Cow::Borrowed("op"),
1766            resource: Cow::Borrowed("res"),
1767            span_id: 1,
1768            parent_id: 0,
1769            meta: vec![
1770                (Cow::Borrowed("span.kind"), Cow::Borrowed("client")),
1771                (Cow::Borrowed("peer.hostname"), Cow::Borrowed("10.1.2.3")),
1772                (Cow::Borrowed("db.instance"), Cow::Borrowed("my-db")),
1773            ]
1774            .into(),
1775            ..Default::default()
1776        };
1777        let key = BorrowedAggregationKey::from_span(&span_ipv4, &peer_tag_keys, &[]);
1778        let owned = OwnedAggregationKey::from(&key);
1779        assert_eq!(
1780            owned.peer_tags,
1781            vec![
1782                (
1783                    "peer.hostname".to_string(),
1784                    "blocked-ip-address".to_string()
1785                ),
1786                ("db.instance".to_string(), "my-db".to_string()),
1787            ]
1788        );
1789
1790        // IPv6 address peer tag gets replaced with blocked-ip-address
1791        let span_ipv6 = SpanSlice {
1792            service: Cow::Borrowed("service"),
1793            name: Cow::Borrowed("op"),
1794            resource: Cow::Borrowed("res"),
1795            span_id: 1,
1796            parent_id: 0,
1797            meta: vec![
1798                (Cow::Borrowed("span.kind"), Cow::Borrowed("client")),
1799                (
1800                    Cow::Borrowed("peer.hostname"),
1801                    Cow::Borrowed("2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF"),
1802                ),
1803            ]
1804            .into(),
1805            ..Default::default()
1806        };
1807        let ipv6_keys = vec!["peer.hostname".to_string()];
1808        let key = BorrowedAggregationKey::from_span(&span_ipv6, &ipv6_keys, &[]);
1809        let owned = OwnedAggregationKey::from(&key);
1810        assert_eq!(
1811            owned.peer_tags,
1812            vec![(
1813                "peer.hostname".to_string(),
1814                "blocked-ip-address".to_string()
1815            )]
1816        );
1817
1818        // Non-IP peer tags pass through unchanged
1819        let span_non_ip = SpanSlice {
1820            service: Cow::Borrowed("service"),
1821            name: Cow::Borrowed("op"),
1822            resource: Cow::Borrowed("res"),
1823            span_id: 1,
1824            parent_id: 0,
1825            meta: vec![
1826                (Cow::Borrowed("span.kind"), Cow::Borrowed("client")),
1827                (
1828                    Cow::Borrowed("db.instance"),
1829                    Cow::Borrowed("dynamo.test.us1"),
1830                ),
1831            ]
1832            .into(),
1833            ..Default::default()
1834        };
1835        let non_ip_keys = vec!["db.instance".to_string()];
1836        let key = BorrowedAggregationKey::from_span(&span_non_ip, &non_ip_keys, &[]);
1837        let owned = OwnedAggregationKey::from(&key);
1838        assert_eq!(
1839            owned.peer_tags,
1840            vec![("db.instance".to_string(), "dynamo.test.us1".to_string())]
1841        );
1842    }
1843
1844    #[test]
1845    fn test_grpc_status_str_to_int_value() {
1846        // Numeric strings parse directly
1847        assert_eq!(grpc_status_str_to_int_value("0"), Some(0));
1848        assert_eq!(grpc_status_str_to_int_value("14"), Some(14));
1849        assert_eq!(grpc_status_str_to_int_value("255"), Some(255));
1850        assert_eq!(grpc_status_str_to_int_value("256"), None);
1851        assert_eq!(grpc_status_str_to_int_value("-1"), None);
1852
1853        // Named status codes (uppercase)
1854        assert_eq!(grpc_status_str_to_int_value("OK"), Some(0));
1855        assert_eq!(grpc_status_str_to_int_value("CANCELLED"), Some(1));
1856        assert_eq!(grpc_status_str_to_int_value("UNKNOWN"), Some(2));
1857        assert_eq!(grpc_status_str_to_int_value("INVALID_ARGUMENT"), Some(3));
1858        assert_eq!(grpc_status_str_to_int_value("DEADLINE_EXCEEDED"), Some(4));
1859        assert_eq!(grpc_status_str_to_int_value("NOT_FOUND"), Some(5));
1860        assert_eq!(grpc_status_str_to_int_value("ALREADY_EXISTS"), Some(6));
1861        assert_eq!(grpc_status_str_to_int_value("PERMISSION_DENIED"), Some(7));
1862        assert_eq!(grpc_status_str_to_int_value("UNAUTHENTICATED"), Some(16));
1863        assert_eq!(grpc_status_str_to_int_value("RESOURCE_EXHAUSTED"), Some(8));
1864        assert_eq!(grpc_status_str_to_int_value("FAILED_PRECONDITION"), Some(9));
1865        assert_eq!(grpc_status_str_to_int_value("ABORTED"), Some(10));
1866        assert_eq!(grpc_status_str_to_int_value("OUT_OF_RANGE"), Some(11));
1867        assert_eq!(grpc_status_str_to_int_value("UNIMPLEMENTED"), Some(12));
1868        assert_eq!(grpc_status_str_to_int_value("INTERNAL"), Some(13));
1869        assert_eq!(grpc_status_str_to_int_value("UNAVAILABLE"), Some(14));
1870        assert_eq!(grpc_status_str_to_int_value("DATA_LOSS"), Some(15));
1871
1872        // Case-insensitive matching
1873        assert_eq!(grpc_status_str_to_int_value("ok"), Some(0));
1874        assert_eq!(grpc_status_str_to_int_value("Cancelled"), Some(1));
1875        assert_eq!(grpc_status_str_to_int_value("not_found"), Some(5));
1876
1877        // StatusCode. prefix is stripped
1878        assert_eq!(grpc_status_str_to_int_value("StatusCode.OK"), Some(0));
1879        assert_eq!(
1880            grpc_status_str_to_int_value("StatusCode.UNAVAILABLE"),
1881            Some(14)
1882        );
1883        assert_eq!(
1884            grpc_status_str_to_int_value("StatusCode.not_found"),
1885            Some(5)
1886        );
1887
1888        // Alternate spellings
1889        assert_eq!(grpc_status_str_to_int_value("CANCELED"), Some(1));
1890
1891        // Unknown / empty strings
1892        assert_eq!(grpc_status_str_to_int_value("NOPE"), None);
1893        assert_eq!(grpc_status_str_to_int_value(""), None);
1894        assert_eq!(
1895            grpc_status_str_to_int_value("this_is_a_kinda_long_string_that_needs_upcasing"),
1896            None
1897        );
1898
1899        // Non ascii
1900        assert_eq!(grpc_status_str_to_int_value("🤣"), None);
1901    }
1902}