Skip to main content

libdd_trace_stats/span_concentrator/
mod.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3//! This module implements the SpanConcentrator used to aggregate spans into stats
4mod aggregation;
5pub mod cardinality_limit_telemetry;
6pub mod stat_span;
7
8use std::collections::HashMap;
9use std::time::Duration;
10use tracing::{debug, warn};
11// std::time::SystemTime panics on wasm32.
12use web_time::{SystemTime, UNIX_EPOCH};
13
14use libdd_trace_protobuf::pb;
15
16use aggregation::StatsBucket;
17
18use aggregation::BorrowedAggregationKey;
19pub use aggregation::{FixedAggregationKey, OtlpExactCell, OtlpExactGroup, OtlpStatsBucket};
20use cardinality_limit_telemetry::CollapsedFieldsMetrics;
21
22pub use stat_span::{ChunkSpanView, StatSpan};
23
24const ADDITIONAL_METRIC_TAGS_MAX_KEYS: usize = 4;
25
26/// Deduplicate, sort alphabetically, and cap `keys` using [`ADDITIONAL_METRIC_TAGS_MAX_KEYS`].
27/// Excess keys are dropped and logged as a one time warning.
28fn normalize_additional_metric_tag_keys(mut keys: Vec<String>) -> Vec<String> {
29    keys.sort_unstable();
30    keys.dedup();
31    if keys.len() > ADDITIONAL_METRIC_TAGS_MAX_KEYS {
32        let dropped = keys.split_off(ADDITIONAL_METRIC_TAGS_MAX_KEYS);
33        warn!(
34            "additional_metric_tag_keys: {} additional metric tag keys exceed the cap of {}; dropping: {:?}",
35            dropped.len() + ADDITIONAL_METRIC_TAGS_MAX_KEYS,
36            ADDITIONAL_METRIC_TAGS_MAX_KEYS,
37            dropped,
38        );
39    }
40    keys
41}
42
43/// Result of flushing a concentrator.
44///
45/// Obfuscated and un-obfuscated buckets are kept separate because they must be sent in distinct
46/// stats payloads: only the obfuscated payload carries the `datadog-obfuscation-version` header.
47pub struct FlushResult<T> {
48    /// Buckets whose resource names were obfuscated client-side.
49    pub obfuscated_buckets: Vec<T>,
50    /// Buckets whose resource names were left as-is.
51    pub unobfuscated_buckets: Vec<T>,
52    /// Total number of spans that were collapsed into the overflow sentinel bucket due to
53    /// cardinality limiting across all flushed time buckets.
54    pub collapsed_spans: u64,
55    pub collapsed_fields_metrics: CollapsedFieldsMetrics,
56}
57
58impl<T> FlushResult<T> {
59    /// All flushed buckets regardless of obfuscation.
60    pub fn all_buckets(self) -> Vec<T> {
61        let mut buckets = self.obfuscated_buckets;
62        buckets.extend(self.unobfuscated_buckets);
63        buckets
64    }
65}
66
67/// Concentrators that can provide raw time buckets for export implement this trait.
68///
69/// `StatsExporter` is generic over `C: FlushableConcentrator` so it can work with
70/// both the in-process [`SpanConcentrator`] and the SHM-backed `ShmSpanConcentrator`.
71pub trait FlushableConcentrator {
72    /// Flush time buckets and return them together with flush metadata. If `force` is true, flush
73    /// all buckets. See [`FlushResult`] for the returned data.
74    fn flush_buckets(&mut self, force: bool) -> FlushResult<pb::ClientStatsBucket>;
75}
76
77impl FlushableConcentrator for SpanConcentrator {
78    fn flush_buckets(&mut self, force: bool) -> FlushResult<pb::ClientStatsBucket> {
79        self.flush(SystemTime::now(), force)
80    }
81}
82
83/// Return a Duration between t and the unix epoch
84/// If t is before the unix epoch return 0
85fn system_time_to_unix_duration(t: SystemTime) -> Duration {
86    t.duration_since(UNIX_EPOCH)
87        .unwrap_or(Duration::from_nanos(0))
88}
89
90/// Align a timestamp on the start of a bucket
91#[inline]
92fn align_timestamp(t: u64, bucket_size: u64) -> u64 {
93    t - (t % bucket_size)
94}
95
96/// Return true if the span is eligible for stats computation
97pub fn is_span_eligible<'a, T>(span: &'a T, span_kinds_stats_computed: &[String]) -> bool
98where
99    T: StatSpan<'a>,
100{
101    (span.has_top_level() || span.is_measured() || {
102        span.get_meta("span.kind")
103            .is_some_and(|span_kind| span_kinds_stats_computed.contains(&span_kind.to_lowercase()))
104    }) && !span.is_partial_snapshot()
105}
106
107#[cfg(feature = "stats-obfuscation")]
108#[derive(Clone, Debug, Default)]
109#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
110pub struct StatsComputationObfuscationConfig {
111    pub enabled: bool,
112    pub sql_obfuscation_mode: libdd_trace_obfuscation::sql::SqlObfuscationMode,
113}
114
115#[cfg(feature = "stats-obfuscation")]
116pub type SharedStatsComputationObfuscationConfig =
117    std::sync::Arc<arc_swap::ArcSwap<StatsComputationObfuscationConfig>>;
118
119/// Config to override the default stats cardinality limit values
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121#[repr(C)]
122pub struct CardinalityLimitConfig {
123    /// The whole-key cardinality limit (defaults to 7000)
124    pub whole_key_limit: usize,
125    /// The per-field cardinality limit for the Resource field (defaults to 1024)
126    pub resource_limit: usize,
127    /// The per-field cardinality limit for the HttpEndpoint field (defaults to 512)
128    pub http_endpoint_limit: usize,
129    /// The per-field cardinality limit for the PeerTags field (defaults to 512)
130    pub peer_tags_limit: usize,
131    /// The per-field cardinality limit for the AdditionalTags field (defaults to 100)
132    pub additional_tags_limit: usize,
133}
134
135impl Default for CardinalityLimitConfig {
136    fn default() -> Self {
137        Self {
138            // Default maximum number of distinct aggregation keys per time bucket.
139            //
140            // 7 168 is the limit to exactly saturate hashbrown's internal table at its maximum
141            // load factor of 7/8. Any higher limit would immediately force a doubling
142            // of the table capacity, wasting half the allocated slots for a modest
143            // increase in cardinality. To avoid future changes going over this limit
144            // (e.g. adding extra overflow buckets) we set a slightly lower limit.
145            whole_key_limit: 7_000,
146            // Other defaults from the spec
147            resource_limit: 1_024,
148            http_endpoint_limit: 512,
149            peer_tags_limit: 512,
150            additional_tags_limit: 100,
151        }
152    }
153}
154
155/// SpanConcentrator compute stats on span aggregated by time and span attributes
156///
157/// # Aggregation
158/// Spans are aggregated into time buckets based on their end_time. Within each time bucket there
159/// is another level of aggregation based on the spans fields (e.g. resource_name, service_name)
160/// and the peer tags if the `peer_tags_aggregation` is enabled.
161///
162/// # Span eligibility
163/// The ingested spans are only aggregated if they are root, top-level, measured or if their
164/// `span.kind` is eligible and the `compute_stats_by_span_kind` is enabled.
165///
166/// # Flushing
167/// When the SpanConcentrator is flushed it keeps the `buffer_len` most recent buckets and remove
168/// all older buckets returning their content. When using force flush all buckets are flushed
169/// regardless of their age.
170///
171/// # Cardinality limiting
172/// Each time bucket holds at most `max_entries_per_bucket` distinct aggregation keys. Once that
173/// limit is reached, spans whose key is not already present are merged into a single overflow
174/// bucket keyed by [`aggregation::TRACER_BLOCKED_VALUE`].
175#[derive(Debug, Clone)]
176pub struct SpanConcentrator {
177    /// Size of the time buckets used for aggregation in nanos
178    bucket_size: u64,
179    buckets: HashMap<u64, StatsBucket>,
180    /// Timestamp of the oldest time bucket for which we allow data.
181    /// Any ingested stats older than it get added to this bucket.
182    oldest_timestamp: u64,
183    /// bufferLen is the number stats bucket we keep when flushing.
184    buffer_len: usize,
185    /// Config values for whole-key and per-field cardinality limits
186    cardinality_limits: CardinalityLimitConfig,
187    /// span.kind fields eligible for stats computation
188    span_kinds_stats_computed: Vec<String>,
189    /// keys for supplementary tags that describe peer.service entities
190    peer_tag_keys: Vec<String>,
191    /// keys for additional tags on trace stats
192    additional_metric_tag_keys: Vec<String>,
193    /// If true, the maximum length for a resource goes from `5_000` to `15_000`
194    big_resource: bool,
195    #[cfg(feature = "stats-obfuscation")]
196    obfuscation_config: SharedStatsComputationObfuscationConfig,
197}
198
199impl SpanConcentrator {
200    /// Return a new concentrator with the given parameters
201    /// - `bucket_size` is the size of the time buckets
202    /// - `now` the current system time, used to define the oldest bucket
203    /// - `span_kinds_stats_computed` list of span kinds eligible for stats computation
204    /// - `peer_tags_keys` list of keys considered as peer tags for aggregation
205    /// - `override_cardinality_limits` config values for whole-key and per-field cardinality limit.
206    ///   Pass `None` to use defaults (see [`CardinalityLimitConfig`]).
207    /// - `additional_metric_tag_keys` list of keys considered as addtional tags for aggregation
208    /// - `obfuscation_config` optional and updatable config for resource key obfuscation
209    pub fn new(
210        bucket_size: Duration,
211        now: SystemTime,
212        span_kinds_stats_computed: Vec<String>,
213        peer_tag_keys: Vec<String>,
214        override_cardinality_limits: Option<CardinalityLimitConfig>,
215        additional_metric_tag_keys: Vec<String>,
216        #[cfg(feature = "stats-obfuscation")] obfuscation_config: Option<
217            SharedStatsComputationObfuscationConfig,
218        >,
219    ) -> SpanConcentrator {
220        if let Some(cardinality_limit_config) = override_cardinality_limits.as_ref() {
221            if cardinality_limit_config.whole_key_limit == 0
222                || cardinality_limit_config.resource_limit == 0
223                || cardinality_limit_config.http_endpoint_limit == 0
224                || cardinality_limit_config.peer_tags_limit == 0
225                || cardinality_limit_config.additional_tags_limit == 0
226            {
227                warn!(
228                    ?cardinality_limit_config,
229                    "Stats cardinality limit is misconfigured: cardinality limits must not be 0 otherwise all the stats get collapsed!"
230                );
231            }
232            if cardinality_limit_config.whole_key_limit <= cardinality_limit_config.resource_limit
233                || cardinality_limit_config.whole_key_limit
234                    <= cardinality_limit_config.http_endpoint_limit
235                || cardinality_limit_config.whole_key_limit
236                    <= cardinality_limit_config.peer_tags_limit
237                || cardinality_limit_config.whole_key_limit
238                    <= cardinality_limit_config.additional_tags_limit
239            {
240                warn!(
241                    ?cardinality_limit_config,
242                    "Stats cardinality limit is misconfigured: per-field limits must be lower than whole-key limit otherwise they have no effect and you will get over-collapsed stats!"
243                );
244            }
245        }
246        SpanConcentrator {
247            bucket_size: bucket_size.as_nanos() as u64,
248            buckets: HashMap::new(),
249            oldest_timestamp: align_timestamp(
250                system_time_to_unix_duration(now).as_nanos() as u64,
251                bucket_size.as_nanos() as u64,
252            ),
253            buffer_len: 2,
254            cardinality_limits: override_cardinality_limits.unwrap_or_default(),
255            span_kinds_stats_computed,
256            peer_tag_keys,
257            additional_metric_tag_keys: normalize_additional_metric_tag_keys(
258                additional_metric_tag_keys,
259            ),
260            big_resource: false,
261            #[cfg(feature = "stats-obfuscation")]
262            obfuscation_config: obfuscation_config.unwrap_or_default(),
263        }
264    }
265
266    /// Return the list of span kinds eligible for stats computation
267    pub fn span_kinds(&self) -> &[String] {
268        &self.span_kinds_stats_computed
269    }
270
271    /// Set the list of span kinds eligible for stats computation
272    pub fn set_span_kinds(&mut self, span_kinds: Vec<String>) {
273        self.span_kinds_stats_computed = span_kinds;
274    }
275
276    /// Return the list of keys considered as peer_tags for aggregation
277    pub fn peer_tag_keys(&self) -> &[String] {
278        &self.peer_tag_keys
279    }
280
281    /// Set the list of keys considered as peer_tags for aggregation
282    pub fn set_peer_tags(&mut self, peer_tags: Vec<String>) {
283        self.peer_tag_keys = peer_tags;
284    }
285
286    /// Set the `big_resource` flag, it changes the resource field limit from `5_000` to `15_000`
287    pub fn set_big_resource(&mut self, big_resource: bool) {
288        self.big_resource = big_resource;
289    }
290
291    /// Return the list of keys considered as additional_metric_tag_keys for aggregation
292    pub fn additional_metric_tag_keys(&self) -> &[String] {
293        &self.additional_metric_tag_keys
294    }
295
296    /// Set the list of keys considered as additional_metric_tag_keys for aggregation
297    pub fn set_additional_metric_tag_keys(&mut self, tag_keys: Vec<String>) {
298        self.additional_metric_tag_keys = normalize_additional_metric_tag_keys(tag_keys);
299    }
300
301    /// Return the bucket size used for aggregation
302    pub fn get_bucket_size(&self) -> Duration {
303        Duration::from_nanos(self.bucket_size)
304    }
305
306    /// Add a span into the concentrator, by computing stats if the span is eligible for stats
307    /// computation.
308    pub fn add_span<'a>(&'a mut self, span: &'a impl StatSpan<'a>) {
309        if !is_span_eligible(span, self.span_kinds_stats_computed.as_slice()) {
310            return;
311        }
312        let mut bucket_timestamp =
313            align_timestamp((span.start() + span.duration()) as u64, self.bucket_size);
314        // If the span is to old we aggregate it in the latest bucket instead of
315        // creating a new one
316        if bucket_timestamp < self.oldest_timestamp {
317            bucket_timestamp = self.oldest_timestamp;
318        }
319
320        let target_bucket = self.buckets.entry(bucket_timestamp).or_insert_with(|| {
321            StatsBucket::new(
322                bucket_timestamp,
323                self.cardinality_limits,
324                #[cfg(feature = "stats-obfuscation")]
325                self.obfuscation_config.load().enabled,
326            )
327        });
328        #[cfg(feature = "stats-obfuscation")]
329        let obfuscated_resource = if target_bucket.obfuscated {
330            Self::compute_obfuscated_span(self.obfuscation_config.load().sql_obfuscation_mode, span)
331        } else {
332            None
333        };
334        #[cfg(not(feature = "stats-obfuscation"))]
335        let obfuscated_resource: Option<String> = None;
336        let agg_key = match obfuscated_resource.as_deref() {
337            Some(res) => BorrowedAggregationKey::from_obfuscated_span(
338                res,
339                span,
340                self.peer_tag_keys.as_slice(),
341                self.additional_metric_tag_keys.as_slice(),
342            ),
343            None => BorrowedAggregationKey::from_span(
344                span,
345                self.peer_tag_keys.as_slice(),
346                self.additional_metric_tag_keys.as_slice(),
347            ),
348        };
349        // Apply field truncation only when obfuscation was applied
350        #[cfg(feature = "stats-obfuscation")]
351        let mut agg_key = agg_key;
352        #[cfg(feature = "stats-obfuscation")]
353        if target_bucket.obfuscated {
354            agg_key.truncate(self.big_resource);
355        }
356        target_bucket.insert(
357            agg_key,
358            span.duration(),
359            span.is_error(),
360            span.has_top_level(),
361        );
362    }
363
364    #[cfg(feature = "stats-obfuscation")]
365    fn compute_obfuscated_span<'a>(
366        sql_obfuscation_mode: libdd_trace_obfuscation::sql::SqlObfuscationMode,
367        span: &'a impl StatSpan<'a>,
368    ) -> Option<String> {
369        let dbms_hint: Option<&str> = span.get_meta("db.type");
370        libdd_trace_obfuscation::obfuscate::obfuscate_resource_for_stats(
371            span.r#type(),
372            span.resource(),
373            dbms_hint,
374            sql_obfuscation_mode,
375        )
376    }
377
378    /// Flush all stats bucket except for the `buffer_len` most recent. If `force` is true, flush
379    /// all buckets.
380    ///
381    /// Obfuscated and un-obfuscated buckets are returned separately, see [`FlushResult`].
382    pub fn flush(&mut self, now: SystemTime, force: bool) -> FlushResult<pb::ClientStatsBucket> {
383        self.drain_due_buckets(now, force, StatsBucket::flush)
384    }
385
386    /// Like [`Self::flush`], but also emits exact per-cell scalars alongside each bucket for the
387    /// OTLP trace-metrics path. The protobuf bucket inside each [`OtlpStatsBucket`] is identical
388    /// to what [`Self::flush`] would produce, so the /v0.6/stats agent path is unaffected.
389    pub fn flush_with_otlp_exact(&mut self, now: SystemTime, force: bool) -> Vec<OtlpStatsBucket> {
390        self.drain_due_buckets(now, force, StatsBucket::flush_with_otlp_exact)
391            .all_buckets()
392    }
393
394    /// Drain the buckets that are due for flushing, encoding each with `encode`.
395    ///
396    /// Returns a tuple `(buckets, collapsed_spans)` where each encoded bucket is paired with a
397    /// boolean indicating whether it was obfuscated client-side (always `false` when the
398    /// `stats-obfuscation` feature is disabled), and `collapsed_spans` is the total number of
399    /// spans collapsed into the overflow sentinel bucket due to cardinality limiting.
400    fn drain_due_buckets<T>(
401        &mut self,
402        now: SystemTime,
403        force: bool,
404        encode: impl Fn(StatsBucket, u64) -> T,
405    ) -> FlushResult<T> {
406        // TODO: Wait for HashMap::extract_if to be stabilized to avoid a full drain
407        let now_timestamp = system_time_to_unix_duration(now).as_nanos() as u64;
408        let buckets: Vec<(u64, StatsBucket)> = self.buckets.drain().collect();
409        self.oldest_timestamp = if force {
410            align_timestamp(now_timestamp, self.bucket_size)
411        } else {
412            align_timestamp(now_timestamp, self.bucket_size)
413                - (self.buffer_len as u64 - 1) * self.bucket_size
414        };
415        let mut collapsed_spans = 0;
416        let mut collapsed_fields_metrics = CollapsedFieldsMetrics::zero();
417        let buckets_pb: Vec<(bool, T)> = buckets
418            .into_iter()
419            .filter_map(|(timestamp, bucket)| {
420                // Always keep `bufferLen` buckets (default is 2: current + previous one).
421                // This is a trade-off: we accept slightly late traces (clock skew and stuff)
422                // but we delay flushing by at most `bufferLen` buckets.
423                // This delay might result in not flushing stats payload (data loss)
424                // if the tracer stops while the latest buckets aren't old enough to be flushed.
425                // The "force" boolean skips the delay and flushes all buckets, typically on
426                // shutdown.
427                let keep = !force
428                    && timestamp > (now_timestamp - self.buffer_len as u64 * self.bucket_size);
429                if keep {
430                    self.buckets.insert(timestamp, bucket);
431                    return None;
432                }
433                collapsed_spans += bucket.collapsed_count();
434                collapsed_fields_metrics += bucket.collapsed_fields_metrics();
435                #[cfg(feature = "stats-obfuscation")]
436                let obfuscated = bucket.obfuscated;
437                #[cfg(not(feature = "stats-obfuscation"))]
438                let obfuscated = false;
439                Some((obfuscated, encode(bucket, self.bucket_size)))
440            })
441            .collect();
442        if collapsed_spans > 0 {
443            debug!(
444                max_entries_per_bucket = self.cardinality_limits.whole_key_limit,
445                collapsed_spans,
446                "Client-side stats values have been collapsed to 'tracer_blocked_value'. This is due to the cardinality exceeding DD_TRACE_STATS_CARDINALITY_LIMIT"
447            );
448        }
449
450        let mut obfuscated_buckets = Vec::new();
451        let mut unobfuscated_buckets = Vec::new();
452        for (obfuscated, bucket) in buckets_pb {
453            if obfuscated {
454                obfuscated_buckets.push(bucket);
455            } else {
456                unobfuscated_buckets.push(bucket);
457            }
458        }
459
460        FlushResult {
461            obfuscated_buckets,
462            unobfuscated_buckets,
463            collapsed_spans,
464            collapsed_fields_metrics,
465        }
466    }
467}
468
469#[cfg(feature = "stats-obfuscation")]
470impl StatsComputationObfuscationConfig {
471    pub fn disabled() -> SharedStatsComputationObfuscationConfig {
472        use arc_swap::ArcSwap;
473        use std::sync::Arc;
474
475        Arc::new(ArcSwap::from_pointee(
476            StatsComputationObfuscationConfig::default(),
477        ))
478    }
479}
480
481#[cfg(test)]
482mod tests;