saluki_components/transforms/aggregate/
mod.rs

1use std::{
2    num::NonZeroU64,
3    time::{Duration, Instant},
4};
5
6use async_trait::async_trait;
7use ddsketch::DDSketch;
8use hashbrown::{hash_map::Entry, HashMap};
9use saluki_common::time::get_unix_timestamp;
10use saluki_config::GenericConfiguration;
11use saluki_context::Context;
12use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr};
13use saluki_core::{
14    components::{transforms::*, ComponentContext},
15    data_model::event::{metric::*, Event, EventType},
16    observability::ComponentMetricsExt as _,
17    topology::{interconnect::BufferedDispatcher, OutputDefinition},
18    topology::{EventsBuffer, EventsDispatcher},
19};
20use saluki_error::GenericError;
21use saluki_metrics::MetricsBuilder;
22use serde::Deserialize;
23use smallvec::SmallVec;
24use stringtheory::MetaString;
25use tokio::{
26    pin, select,
27    time::{interval, interval_at},
28};
29use tracing::{debug, error, info, trace, warn};
30
31mod telemetry;
32use self::telemetry::Telemetry;
33
34mod config;
35use self::config::{HistogramConfiguration, HistogramStatistic};
36
37const PASSTHROUGH_IDLE_FLUSH_CHECK_INTERVAL: Duration = Duration::from_secs(2);
38
39const fn default_window_duration_seconds() -> NonZeroU64 {
40    NonZeroU64::new(10).expect("not zero")
41}
42
43const fn default_primary_flush_interval() -> Duration {
44    Duration::from_secs(15)
45}
46
47const fn default_context_limit() -> usize {
48    1_000_000
49}
50
51const fn default_counter_expiry_seconds() -> Option<u64> {
52    Some(300)
53}
54
55const fn default_passthrough_timestamped_metrics() -> bool {
56    true
57}
58
59const fn default_passthrough_idle_flush_timeout() -> Duration {
60    Duration::from_secs(1)
61}
62
63/// Aggregate transform.
64///
65/// Aggregates metrics into fixed-size windows, flushing them at a regular interval.
66///
67/// ## Zero-value counters
68///
69/// When metrics are aggregated and then flushed, they're typically removed entirely from the aggregation state. Unless
70/// they're updated again, they won't be emitted again. However, for counters, a slightly different approach is
71/// taken by tracking "zero-value" counters.
72///
73/// Counters are aggregated and flushed normally. However, when flushed, counters are added to a list of "zero-value"
74/// counters, and if those counters aren't updated again, the transform emits a copy of the counter with a value of
75/// zero. It does this until the counter is updated again, or the zero-value counter expires (no updates), whichever
76/// comes first.
77///
78/// This provides a continuity in the output of a counter, from the perspective of a downstream system, when counters
79/// are otherwise sparse. The expiration period is configurable, and allows a trade-off in how sparse/infrequent the
80/// updates to counters can be versus how long it takes for counters that don't exist anymore to actually cease to be
81/// emitted.
82#[derive(Deserialize)]
83#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
84pub struct AggregateConfiguration {
85    /// Size of the aggregation window, in seconds.
86    ///
87    /// Metrics are aggregated into fixed-size windows, such that all updates to the same metric within a window are
88    /// aggregated into a single metric. The window size controls how efficiently metrics are aggregated, and in turn,
89    /// how many data points are emitted downstream.
90    ///
91    /// Window durations cannot be zero.
92    ///
93    /// Defaults to 10 seconds.
94    #[serde(
95        rename = "aggregate_window_duration_seconds",
96        default = "default_window_duration_seconds"
97    )]
98    window_duration_seconds: NonZeroU64,
99
100    /// How often to flush buckets.
101    ///
102    /// This represents a trade-off between the savings in network bandwidth (sending fewer requests to downstream
103    /// systems, etc) and the frequency of updates (how often updates to a metric are emitted).
104    ///
105    /// Defaults to 15 seconds.
106    #[serde(rename = "aggregate_flush_interval", default = "default_primary_flush_interval")]
107    primary_flush_interval: Duration,
108
109    /// Maximum number of contexts to aggregate per window.
110    ///
111    /// A context is the unique combination of a metric name and its set of tags. For example,
112    /// `metric.name.here{tag1=A,tag2=B}` represents a single context, and would be different than
113    /// `metric.name.here{tag1=A,tag2=C}`.
114    ///
115    /// When the maximum number of contexts is reached in the current aggregation window, additional metrics are dropped
116    /// until the next window starts.
117    ///
118    /// Defaults to 1,000,000.
119    #[serde(rename = "aggregate_context_limit", default = "default_context_limit")]
120    context_limit: usize,
121
122    /// Whether to flush open buckets when stopping the transform.
123    ///
124    /// Normally, open buckets (a bucket whose end hasn't yet occurred) aren't flushed when the transform is stopped.
125    /// This is done to avoid the chance of flushing a partial window, restarting the process, and then flushing the
126    /// same window again. Downstream systems sometimes can't cope with this gracefully, as there is no way to
127    /// determine that it's an incremental update, and so they treat it as an absolute update, overwriting the
128    /// previously flushed value.
129    ///
130    /// In cases where flushing all outstanding data is paramount, this can be enabled.
131    ///
132    /// Defaults to `false`.
133    #[serde(
134        rename = "aggregate_flush_open_windows",
135        alias = "dogstatsd_flush_incomplete_buckets",
136        default
137    )]
138    flush_open_windows: bool,
139
140    /// How long to keep idle counters alive after they've been flushed, in seconds.
141    ///
142    /// When metrics are flushed, they're removed from the aggregation state. However, if a counter expiration is set,
143    /// counters will be kept alive in an "idle" state. For as long as a counter is idle, but not yet expired, a zero
144    /// value will be emitted for it during each flush. This allows more gracefully handling sparse counters, where
145    /// updates are infrequent but leaving gaps in the time series would be undesirable from a user experience
146    /// perspective.
147    ///
148    /// After a counter has been idle (no updates) for longer than the expiry period, it will be completely removed and
149    /// no further zero values will be emitted.
150    ///
151    /// Defaults to 300 seconds (5 minutes). Setting a value of `0` disables idle counter keep-alive.
152    #[serde(alias = "dogstatsd_expiry_seconds", default = "default_counter_expiry_seconds")]
153    counter_expiry_seconds: Option<u64>,
154
155    /// Whether or not to immediately forward (passthrough) metrics with pre-defined timestamps.
156    ///
157    /// When enabled, this causes the aggregator to immediately forward metrics that already have a timestamp present.
158    /// Only metrics without a timestamp will be aggregated. This can be useful when metrics are already pre-aggregated
159    /// client-side and both timeliness and memory efficiency are paramount, as it avoids the overhead of aggregating
160    /// within the pipeline.
161    ///
162    /// Defaults to `true`.
163    #[serde(
164        rename = "dogstatsd_no_aggregation_pipeline",
165        default = "default_passthrough_timestamped_metrics"
166    )]
167    passthrough_timestamped_metrics: bool,
168
169    /// How often to flush buffered passthrough metrics.
170    ///
171    /// While passthrough metrics aren't re-aggregated by the transform, they will still be temporarily buffered in
172    /// order to optimize the efficiency of processing them in the next component. This setting controls the maximum
173    /// amount of time that passthrough metrics will be buffered before being forwarded.
174    ///
175    /// Defaults to 1 seconds.
176    #[serde(
177        rename = "aggregate_passthrough_idle_flush_timeout",
178        default = "default_passthrough_idle_flush_timeout"
179    )]
180    passthrough_idle_flush_timeout: Duration,
181
182    /// Histogram aggregation configuration.
183    ///
184    /// Controls the aggregates/percentiles that are generated for distributions in "histogram" mode (client-side
185    /// distribution aggregation).
186    #[serde(flatten)]
187    hist_config: HistogramConfiguration,
188}
189
190impl AggregateConfiguration {
191    /// Creates a new `AggregateConfiguration` from the given configuration.
192    pub fn from_configuration(config: &GenericConfiguration) -> Result<Self, GenericError> {
193        Ok(config.as_typed()?)
194    }
195
196    /// Creates a new `AggregateConfiguration` with default values.
197    pub fn with_defaults() -> Self {
198        Self {
199            window_duration_seconds: default_window_duration_seconds(),
200            primary_flush_interval: default_primary_flush_interval(),
201            context_limit: default_context_limit(),
202            flush_open_windows: false,
203            counter_expiry_seconds: default_counter_expiry_seconds(),
204            passthrough_timestamped_metrics: default_passthrough_timestamped_metrics(),
205            passthrough_idle_flush_timeout: default_passthrough_idle_flush_timeout(),
206            hist_config: HistogramConfiguration::default(),
207        }
208    }
209}
210
211#[async_trait]
212impl TransformBuilder for AggregateConfiguration {
213    async fn build(&self, context: ComponentContext) -> Result<Box<dyn Transform + Send>, GenericError> {
214        let metrics_builder = MetricsBuilder::from_component_context(&context);
215        let telemetry = Telemetry::new(&metrics_builder);
216
217        let state = AggregationState::new(
218            self.window_duration_seconds,
219            self.context_limit,
220            self.counter_expiry_seconds.filter(|s| *s != 0).map(Duration::from_secs),
221            self.hist_config.clone(),
222            telemetry.clone(),
223        );
224
225        let passthrough_batcher = PassthroughBatcher::new(
226            self.passthrough_idle_flush_timeout,
227            self.window_duration_seconds,
228            telemetry.clone(),
229        )
230        .await;
231
232        Ok(Box::new(Aggregate {
233            state,
234            telemetry,
235            primary_flush_interval: self.primary_flush_interval,
236            flush_open_windows: self.flush_open_windows,
237            passthrough_batcher,
238            passthrough_timestamped_metrics: self.passthrough_timestamped_metrics,
239        }))
240    }
241
242    fn input_event_type(&self) -> EventType {
243        EventType::Metric
244    }
245
246    fn outputs(&self) -> &[OutputDefinition<EventType>] {
247        static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
248        OUTPUTS
249    }
250}
251
252impl MemoryBounds for AggregateConfiguration {
253    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
254        // TODO: While we account for the aggregation state map accurately, what we don't currently account for is the
255        // fact that a metric could have multiple distinct values. For the common pipeline of metrics in via DogStatsD,
256        // this generally shouldn't be a problem because the values don't have a timestamp, so they get aggregated into
257        // the same bucket, leading to two values per `MetricValues` at most, which is already baked into the size of
258        // `MetricValues` due to using `SmallVec`.
259        //
260        // However, there could be many more values in a single metric, and we don't account for that.
261
262        builder
263            .minimum()
264            // Capture the size of the heap allocation when the component is built.
265            .with_single_value::<Aggregate>("component struct");
266        builder
267            .firm()
268            // Account for the aggregation state map, where we map contexts to the merged metric.
269            .with_expr(UsageExpr::product(
270                "aggregation state map",
271                UsageExpr::sum(
272                    "context map entry",
273                    UsageExpr::struct_size::<Context>("context"),
274                    UsageExpr::struct_size::<AggregatedMetric>("aggregated metric"),
275                ),
276                UsageExpr::config("aggregate_context_limit", self.context_limit),
277            ));
278    }
279}
280
281pub struct Aggregate {
282    state: AggregationState,
283    telemetry: Telemetry,
284    primary_flush_interval: Duration,
285    flush_open_windows: bool,
286    passthrough_batcher: PassthroughBatcher,
287    passthrough_timestamped_metrics: bool,
288}
289
290#[async_trait]
291impl Transform for Aggregate {
292    async fn run(mut self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
293        let mut health = context.take_health_handle();
294
295        let mut primary_flush = interval_at(
296            tokio::time::Instant::now() + self.primary_flush_interval,
297            self.primary_flush_interval,
298        );
299        let mut final_primary_flush = false;
300
301        let passthrough_flush = interval(PASSTHROUGH_IDLE_FLUSH_CHECK_INTERVAL);
302
303        health.mark_ready();
304        debug!("Aggregation transform started.");
305
306        pin!(passthrough_flush);
307
308        loop {
309            select! {
310                _ = health.live() => continue,
311                _ = primary_flush.tick() => {
312                    // We've reached the end of the current window. Flush our aggregation state and forward the metrics
313                    // onwards. Regardless of whether any metrics were aggregated, we always update the aggregation
314                    // state to track the start time of the current aggregation window.
315                    if !self.state.is_empty() {
316                        debug!("Flushing aggregated metrics...");
317
318                        let should_flush_open_windows = final_primary_flush && self.flush_open_windows;
319
320                        // Remember if the context limit had been surpassed before this flush.
321                        let was_breached = self.state.context_limit_breached();
322
323                        let mut dispatcher = context.dispatcher().buffered().expect("default output should always exist");
324                        if let Err(e) = self.state.flush(get_unix_timestamp(), should_flush_open_windows, &mut dispatcher).await {
325                            error!(error = %e, "Failed to flush aggregation state.");
326                        }
327
328                        self.telemetry.increment_flushes();
329
330                        // If flush recovered us from a breach, log the recovery.
331                        if was_breached && !self.state.context_limit_breached() {
332                            info!("Context limit no longer exceeded, metrics are being accepted again.");
333                        }
334
335                        match dispatcher.flush().await {
336                            Ok(aggregated_events) => debug!(aggregated_events, "Dispatched events."),
337                            Err(e) => error!(error = %e, "Failed to flush aggregated events."),
338                        }
339                    }
340
341                    // If this is the final flush, we break out of the loop.
342                    if final_primary_flush {
343                        debug!("All aggregation complete.");
344                        break
345                    }
346                },
347                _ = passthrough_flush.tick() => self.passthrough_batcher.try_flush(context.dispatcher()).await,
348                maybe_events = context.events().next(), if !final_primary_flush => match maybe_events {
349                    Some(events) => {
350                        trace!(events_len = events.len(), "Received events.");
351
352                        let current_time = get_unix_timestamp();
353                        let mut processed_passthrough_metrics = false;
354
355                        for event in events {
356                            if let Some(metric) = event.try_into_metric() {
357                                let metric = if self.passthrough_timestamped_metrics {
358                                    // Try splitting out any timestamped values, and if we have any, we'll buffer them
359                                    // separately and process the remaining nontimestamped metric (if any) by
360                                    // aggregating it like normal.
361                                    let (maybe_timestamped_metric, maybe_nontimestamped_metric) = try_split_timestamped_values(metric);
362
363                                    // If we have a timestamped metric, then batch it up out-of-band.
364                                    if let Some(timestamped_metric) = maybe_timestamped_metric {
365                                        self.passthrough_batcher.push_metric(timestamped_metric, context.dispatcher()).await;
366                                        processed_passthrough_metrics = true;
367                                    }
368
369                                    // If we have an nontimestamped metric, we'll process it like normal.
370                                    //
371                                    // Otherwise, continue to the next event.
372                                    match maybe_nontimestamped_metric {
373                                        Some(metric) => metric,
374                                        None => continue,
375                                    }
376                                } else {
377                                    metric
378                                };
379
380                                let was_breached = self.state.context_limit_breached();
381                                if !self.state.insert(current_time, metric) {
382                                    trace!("Dropping metric due to context limit.");
383                                    if !was_breached {
384                                        // First drop since the last recovery — emit a single warning.
385                                        warn!(context_limit = self.state.context_limit, "Context limit reached, \
386                                        dropping metrics. Consider increasing `aggregate_context_limit`.");
387                                    }
388                                    self.telemetry.increment_events_dropped();
389                                }
390                            }
391                        }
392
393                        if processed_passthrough_metrics {
394                            self.passthrough_batcher.update_last_processed_at();
395                        }
396                    },
397                    None => {
398                        // We've reached the end of our input stream, so mark ourselves for a final flush and reset the
399                        // interval so it ticks immediately on the next loop iteration.
400                        final_primary_flush = true;
401                        primary_flush.reset_immediately();
402
403                        debug!("Aggregation transform stopping...");
404                    }
405                },
406            }
407        }
408
409        // Do a final flush of any timestamped metrics that we've buffered up.
410        self.passthrough_batcher.try_flush(context.dispatcher()).await;
411
412        debug!("Aggregation transform stopped.");
413
414        Ok(())
415    }
416}
417
418fn try_split_timestamped_values(mut metric: Metric) -> (Option<Metric>, Option<Metric>) {
419    if metric.values().all_timestamped() {
420        (Some(metric), None)
421    } else if metric.values().any_timestamped() {
422        // Only _some_ of the values are timestamped, so we'll split the timestamped values into a new metric.
423        let new_metric_values = metric.values_mut().split_timestamped();
424        let new_metric = Metric::from_parts(metric.context().clone(), new_metric_values, metric.metadata().clone());
425
426        (Some(new_metric), Some(metric))
427    } else {
428        // No timestamped values, so we need to aggregate this metric.
429        (None, Some(metric))
430    }
431}
432
433struct PassthroughBatcher {
434    active_buffer: EventsBuffer,
435    active_buffer_start: Instant,
436    last_processed_at: Instant,
437    idle_flush_timeout: Duration,
438    bucket_width_secs: NonZeroU64,
439    telemetry: Telemetry,
440}
441
442impl PassthroughBatcher {
443    async fn new(idle_flush_timeout: Duration, bucket_width_secs: NonZeroU64, telemetry: Telemetry) -> Self {
444        let active_buffer = EventsBuffer::default();
445
446        Self {
447            active_buffer,
448            active_buffer_start: Instant::now(),
449            last_processed_at: Instant::now(),
450            idle_flush_timeout,
451            bucket_width_secs,
452            telemetry,
453        }
454    }
455
456    async fn push_metric(&mut self, metric: Metric, dispatcher: &EventsDispatcher) {
457        // Convert counters to rates before we batch them up.
458        //
459        // This involves specifying the rate interval as the bucket width of the aggregate transform itself, which when
460        // you say it out loud is sort of confusing and nonsensical since the whole point is that these are
461        // _pre-aggregated_ metrics but we have to match the behavior of the Datadog Agent. ¯\_(ツ)_/¯
462        let (context, values, metadata) = metric.into_parts();
463        let adjusted_values = counter_values_to_rate(values, self.bucket_width_secs);
464        let metric = Metric::from_parts(context, adjusted_values, metadata);
465
466        // Try pushing the metric into our active buffer.
467        //
468        // If our active buffer is full, then we'll flush the buffer, grab a new one, and push the metric into it.
469        if let Some(event) = self.active_buffer.try_push(Event::Metric(metric)) {
470            debug!("Passthrough event buffer was full. Flushing...");
471            self.dispatch_events(dispatcher).await;
472
473            if self.active_buffer.try_push(event).is_some() {
474                error!("Event buffer is full even after dispatching events. Dropping event.");
475                self.telemetry.increment_events_dropped();
476                return;
477            }
478        }
479
480        // If this is the first metric in the buffer, we've started a new batch, so track when it started.
481        if self.active_buffer.len() == 1 {
482            self.active_buffer_start = Instant::now();
483        }
484
485        self.telemetry.increment_passthrough_metrics();
486    }
487
488    fn update_last_processed_at(&mut self) {
489        // We expose this as a standalone method, rather than just doing it automatically in `push_metric`, because
490        // otherwise we might be calling this 10-20K times per second, instead of simply doing it after the end of each
491        // input event buffer in the transform's main loop, which should be much less frequent.
492        self.last_processed_at = Instant::now();
493    }
494
495    async fn try_flush(&mut self, dispatcher: &EventsDispatcher) {
496        // If our active buffer isn't empty, and we've exceeded our idle flush timeout, then flush the buffer.
497        if !self.active_buffer.is_empty() && self.last_processed_at.elapsed() >= self.idle_flush_timeout {
498            debug!("Passthrough processing exceeded idle flush timeout. Flushing...");
499
500            self.dispatch_events(dispatcher).await;
501        }
502    }
503
504    async fn dispatch_events(&mut self, dispatcher: &EventsDispatcher) {
505        if !self.active_buffer.is_empty() {
506            let unaggregated_events = self.active_buffer.len();
507
508            // Track how long this batch was alive for.
509            let batch_duration = self.active_buffer_start.elapsed();
510            self.telemetry.record_passthrough_batch_duration(batch_duration);
511
512            self.telemetry.increment_passthrough_flushes();
513
514            // Swap our active buffer with a new, empty one, and then forward the old one.
515            let new_active_buffer = EventsBuffer::default();
516            let old_active_buffer = std::mem::replace(&mut self.active_buffer, new_active_buffer);
517
518            match dispatcher.dispatch(old_active_buffer).await {
519                Ok(()) => debug!(unaggregated_events, "Dispatched events."),
520                Err(e) => error!(error = %e, "Failed to flush unaggregated events."),
521            }
522        }
523    }
524}
525
526#[derive(Clone)]
527struct AggregatedMetric {
528    values: MetricValues,
529    metadata: MetricMetadata,
530    last_seen: u64,
531}
532
533struct AggregationState {
534    contexts: HashMap<Context, AggregatedMetric, foldhash::quality::RandomState>,
535    contexts_remove_buf: Vec<Context>,
536    context_limit: usize,
537    bucket_width_secs: NonZeroU64,
538    counter_expire_secs: Option<NonZeroU64>,
539    last_flush: u64,
540    hist_config: HistogramConfiguration,
541    telemetry: Telemetry,
542    /// Tracks whether the context limit has been breached. Starts out as `false`. Set to `true` on the first dropped
543    /// metric. Reset to `false` when the context count drops below the limit during flush.
544    context_limit_breached: bool,
545}
546
547impl AggregationState {
548    fn new(
549        bucket_width_secs: NonZeroU64, context_limit: usize, counter_expiration: Option<Duration>,
550        hist_config: HistogramConfiguration, telemetry: Telemetry,
551    ) -> Self {
552        let counter_expire_secs = counter_expiration.map(|d| d.as_secs()).and_then(NonZeroU64::new);
553
554        Self {
555            contexts: HashMap::default(),
556            contexts_remove_buf: Vec::new(),
557            context_limit,
558            bucket_width_secs,
559            counter_expire_secs,
560            last_flush: 0,
561            hist_config,
562            telemetry,
563            context_limit_breached: false,
564        }
565    }
566
567    fn is_empty(&self) -> bool {
568        self.contexts.is_empty()
569    }
570
571    fn insert(&mut self, timestamp: u64, metric: Metric) -> bool {
572        // If we haven't seen this context yet, and it would put us over the limit to insert it, then return early.
573        if !self.contexts.contains_key(metric.context()) && self.contexts.len() >= self.context_limit {
574            self.context_limit_breached = true;
575            return false;
576        }
577
578        let (context, mut values, metadata) = metric.into_parts();
579
580        // Collapse all non-timestamped values into a single timestamped value.
581        //
582        // We do this pre-aggregation step because unless we're merging into an existing context, we'll end up with
583        // however many values were in the original metric instead of full aggregated values.
584        let bucket_ts = align_to_bucket_start(timestamp, self.bucket_width_secs);
585        values.collapse_non_timestamped(bucket_ts);
586
587        trace!(
588            bucket_ts,
589            kind = values.as_str(),
590            "Inserting metric into aggregation state."
591        );
592
593        // If we're already tracking this context, update the last seen time and merge the new values into the existing
594        // values. Otherwise, create a new entry.
595        match self.contexts.entry(context) {
596            Entry::Occupied(mut entry) => {
597                let aggregated = entry.get_mut();
598
599                // We ignore metadata changes within a flush interval to keep things simple.
600                aggregated.last_seen = timestamp;
601                aggregated.values.merge(values);
602            }
603            Entry::Vacant(entry) => {
604                self.telemetry.increment_contexts(entry.key(), &values);
605
606                entry.insert(AggregatedMetric {
607                    values,
608                    metadata,
609                    last_seen: timestamp,
610                });
611
612                saluki_antithesis::always_le!(
613                    self.contexts.len(),
614                    self.context_limit,
615                    "aggregate context map within context_limit",
616                    { "len": self.contexts.len(), "limit": self.context_limit }
617                );
618            }
619        }
620
621        true
622    }
623
624    async fn flush(
625        &mut self, current_time: u64, flush_open_buckets: bool, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
626    ) -> Result<(), GenericError> {
627        let bucket_width_secs = self.bucket_width_secs;
628        let counter_expire_secs = self.counter_expire_secs.map(|d| d.get()).unwrap_or(0);
629
630        // We want our split timestamp to be before the start of the current bucket, which ensures any timestamp that is
631        // less than or equal to `split_timestamp` resides in a closed bucket.
632        let split_timestamp = align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1);
633
634        // Calculate the buckets we need to potentially generate zero-value counters for.
635        //
636        // We only need to do this if we've flushed before, since we won't have any knowledge of which counters are idle
637        // or not until that happens.
638        let mut zero_value_buckets = SmallVec::<[(u64, MetricValues); 4]>::new();
639        if self.last_flush != 0 {
640            let start = align_to_bucket_start(self.last_flush, bucket_width_secs);
641
642            // Clock-skew guards. Bucketing reads the wall clock while the flush cadence is monotonic, so a wall-clock
643            // jump is not bounded by the flush interval. A backward jump empties the zero-value range (a silent counter
644            // gap); a forward jump makes the loop below run once per bucket across the whole jumped span — O(jump) work
645            // and allocation. Assert before the loop so a flood fails fast rather than after the damage is done.
646            saluki_antithesis::always_ge!(
647                current_time,
648                self.last_flush,
649                "aggregate flush wall-clock did not move backward",
650                { "current_time": current_time, "last_flush": self.last_flush }
651            );
652            // The 10_000 bound is generous. A default 15s flush over a 10s bucket yields one or two buckets. The bound
653            // trips only on a multi-hour wall-clock jump, never on a slow-but-sane flush.
654            saluki_antithesis::always_le!(
655                current_time.saturating_sub(self.last_flush) / bucket_width_secs.get(),
656                10_000,
657                "aggregate zero-value bucket span bounded across a flush",
658                {
659                    "current_time": current_time,
660                    "last_flush": self.last_flush,
661                    "bucket_width_secs": bucket_width_secs.get()
662                }
663            );
664
665            for bucket_start in (start..current_time).step_by(bucket_width_secs.get() as usize) {
666                if is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets) {
667                    zero_value_buckets.push((bucket_start, MetricValues::counter((bucket_start, 0.0))));
668                }
669            }
670
671            // Anti-vacuity anchor: prove the idle-counter zero-value path actually runs in some timeline.
672            saluki_antithesis::sometimes!(
673                !zero_value_buckets.is_empty(),
674                "aggregate flush generated zero-value counter buckets",
675                { "count": zero_value_buckets.len() }
676            );
677        }
678
679        // Iterate over each context we're tracking, and flush any values that are in buckets which are now closed.
680        debug!(timestamp = current_time, "Flushing buckets.");
681
682        for (context, am) in self.contexts.iter_mut() {
683            // Figure out if we should remove this metric or not if it has no values in open buckets.
684            //
685            // We have a special carve-out for counters here, which we have the ability to keep alive after they are
686            // flushed, based on a configured expiration period. This allows us to continue emitting a zero value for
687            // counters when they're idle, which can make them appear "live" in downstream systems, even when they're
688            // not.
689            //
690            // This is useful for sparsely-updated counters.
691            let should_expire_if_empty = match &am.values {
692                MetricValues::Counter(..) => {
693                    saluki_antithesis::always_le!(
694                        am.last_seen,
695                        u64::MAX - counter_expire_secs,
696                        "aggregate counter expiry add does not overflow",
697                        { "last_seen": am.last_seen, "counter_expire_secs": counter_expire_secs }
698                    );
699                    counter_expire_secs != 0 && am.last_seen.saturating_add(counter_expire_secs) < current_time
700                }
701                _ => true,
702            };
703
704            // If we're dealing with a counter, we'll merge in our calculated set of zero values. We only merge in the
705            // values that represent now-closed buckets.
706            //
707            // This is also safe to do even when there are real values in those buckets since adding zero to anything is
708            // a no-op from the perspective of what we end up flushing, and it doesn't mess with the "last seen" time.
709            if let MetricValues::Counter(..) = &mut am.values {
710                let expires_at = am.last_seen.saturating_add(counter_expire_secs);
711                for (zv_bucket_start, zero_value) in &zero_value_buckets {
712                    if expires_at > *zv_bucket_start {
713                        am.values.merge(zero_value.clone());
714                    } else {
715                        // Since zero-value buckets are in order, we can break early if this bucket is past the
716                        // expiration cutoff of the counter. No other bucket will be within the expiration range.
717                        break;
718                    }
719                }
720            }
721
722            // Finally, figure out if the current metric can be removed.
723            //
724            // For any metric with values that are in open buckets, we split off the values that are in closed buckets
725            // and keep the metric alive. When all the values are in closed buckets, or there are no values, we'll
726            // remove the metric if `should_remove_if_empty` is `true`.
727            //
728            // This means we'll always remove all-closed/empty non-counter metrics, and we _may_ remove all-closed/empty
729            // counters.
730            if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
731                self.telemetry.increment_flushed(&closed_bucket_values);
732
733                // We got some closed bucket values, so flush those out.
734                transform_and_push_metric(
735                    context.clone(),
736                    closed_bucket_values,
737                    am.metadata.clone(),
738                    bucket_width_secs,
739                    &self.hist_config,
740                    dispatcher,
741                )
742                .await?;
743            }
744
745            if am.values.is_empty() && should_expire_if_empty {
746                self.telemetry.decrement_contexts(context, &am.values);
747                self.contexts_remove_buf.push(context.clone());
748            }
749        }
750
751        // Remove any contexts that were marked as needing to be removed.
752        let contexts_len_before = self.contexts.len();
753        for context in self.contexts_remove_buf.drain(..) {
754            self.contexts.remove(&context);
755        }
756        let contexts_len_after = self.contexts.len();
757
758        let contexts_delta = contexts_len_before.saturating_sub(contexts_len_after);
759        let target_contexts_capacity = contexts_len_after.saturating_add(contexts_delta / 2);
760        self.contexts.shrink_to(target_contexts_capacity);
761
762        if self.context_limit_breached && self.contexts.len() < self.context_limit {
763            self.context_limit_breached = false;
764        }
765
766        self.last_flush = current_time;
767
768        Ok(())
769    }
770
771    fn context_limit_breached(&self) -> bool {
772        self.context_limit_breached
773    }
774}
775
776async fn transform_and_push_metric(
777    context: Context, mut values: MetricValues, metadata: MetricMetadata, bucket_width_secs: NonZeroU64,
778    hist_config: &HistogramConfiguration, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
779) -> Result<(), GenericError> {
780    let bucket_width = Duration::from_secs(bucket_width_secs.get());
781
782    match values {
783        // If we're dealing with a histogram, we calculate a configured set of aggregates/percentiles from it, and emit
784        // them as individual metrics.
785        MetricValues::Histogram(ref mut points) => {
786            // Convert histogram to distribution
787            if hist_config.copy_to_distribution() {
788                let sketch_points = points
789                    .into_iter()
790                    .map(|(ts, hist)| {
791                        let mut sketch = DDSketch::default();
792                        for sample in hist.samples() {
793                            sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
794                        }
795                        (ts, sketch)
796                    })
797                    .collect::<SketchPoints>();
798                let distribution_values = MetricValues::distribution(sketch_points);
799                let metric_context = if !hist_config.copy_to_distribution_prefix().is_empty() {
800                    context.with_name(format!(
801                        "{}{}",
802                        hist_config.copy_to_distribution_prefix(),
803                        context.name()
804                    ))
805                } else {
806                    context.clone()
807                };
808                let new_metric = Metric::from_parts(metric_context, distribution_values, metadata.clone());
809                dispatcher.push(Event::Metric(new_metric)).await?;
810            }
811            // We collect our histogram points in their "summary" view, which sorts the underlying samples allowing
812            // proper quantile queries to be answered, hence our "sorted" points. We do it this way because rather than
813            // sort every time we insert, or cloning the points, we only sort when a summary view is constructed, which
814            // requires mutable access to sort the samples in-place.
815            let mut sorted_points = Vec::new();
816            for (ts, h) in points {
817                sorted_points.push((ts, h.summary_view()));
818            }
819
820            for statistic in hist_config.statistics() {
821                let new_points = sorted_points
822                    .iter()
823                    .map(|(ts, hs)| (*ts, statistic.value_from_histogram(hs)))
824                    .collect::<ScalarPoints>();
825
826                let new_values = if statistic.is_rate_statistic() {
827                    MetricValues::rate(new_points, bucket_width)
828                } else {
829                    MetricValues::gauge(new_points)
830                };
831
832                // Counts are dimensionless, so clear any unit inherited from the input histogram.
833                let new_metadata = if matches!(statistic, HistogramStatistic::Count) {
834                    metadata.clone().with_unit(MetaString::empty())
835                } else {
836                    metadata.clone()
837                };
838
839                let new_context = context.with_name(format!("{}.{}", context.name(), statistic.suffix()));
840                let new_metric = Metric::from_parts(new_context, new_values, new_metadata);
841                dispatcher.push(Event::Metric(new_metric)).await?;
842            }
843
844            Ok(())
845        }
846
847        // If we're not dealing with a histogram, then all we need to worry about is converting counters to rates before
848        // forwarding our single, aggregated metric.
849        values => {
850            let adjusted_values = counter_values_to_rate(values, bucket_width_secs);
851
852            let metric = Metric::from_parts(context, adjusted_values, metadata);
853            dispatcher.push(Event::Metric(metric)).await
854        }
855    }
856}
857
858fn counter_values_to_rate(values: MetricValues, interval_secs: NonZeroU64) -> MetricValues {
859    match values {
860        MetricValues::Counter(points) => MetricValues::rate(points, Duration::from_secs(interval_secs.get())),
861        values => values,
862    }
863}
864
865const fn align_to_bucket_start(timestamp: u64, bucket_width_secs: NonZeroU64) -> u64 {
866    timestamp - (timestamp % bucket_width_secs.get())
867}
868
869const fn is_bucket_closed(
870    current_time: u64, bucket_start: u64, bucket_width_secs: NonZeroU64, flush_open_buckets: bool,
871) -> bool {
872    // A bucket is considered "closed" if the current time is greater than the end of the bucket, or if
873    // `flush_open_buckets` is `true`.
874    //
875    // Buckets represent a half-open interval, where the start is inclusive and the end is exclusive. This means that
876    // for a bucket start of 10, and a width of 10, the bucket is 10 seconds "wide", and its start and end are 10 and
877    // 20, with the 20 excluded, or [10, 20) in interval notation. Simply put, if we have a timestamp of 10, or anything
878    // smaller than 20, we would consider it to fall within the bucket... but 20 or more would be outside of the bucket.
879    //
880    // We can also represent this visually:
881    //
882    // <--------- bucket 1 ----------> <--------- bucket 2 ----------> <--------- bucket 3 ---------->
883    // [10 11 12 13 14 15 16 17 18 19] [20 21 22 23 24 25 26 27 28 29] [30 31 32 33 34 35 36 37 38 39]
884    //
885    // We can see that each bucket is 10 seconds wide (10 elements, one for each second), and that their ends are
886    // effectively `start + width - 1`. This means that for any of these buckets to be considered "closed", the current
887    // time has to be _greater_ than `start + width - 1`. For example, if the current time is 19, then no buckets are
888    // closed, and if the current time is 29, then bucket 1 is closed but buckets 2 and 3 are still open, and if the
889    // current time is 30, then both buckets 1 and 2 are closed, but bucket 3 is still open.
890    (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
891}
892
893// TODO: One thing we ought to consider is a property test, specifically a state machine property test, where we
894// generate a randomized offset to start time from, a bucket width, flush interval, and operations, and so on... and
895// then we run it to make sure that we are always generating sequential timestamps for data points, etc.
896#[cfg(test)]
897mod tests {
898    use float_cmp::ApproxEqRatio as _;
899    use saluki_core::{
900        components::ComponentContext,
901        topology::{interconnect::Dispatcher, OutputName},
902    };
903    use saluki_metrics::test::TestRecorder;
904    use stringtheory::MetaString;
905    use tokio::sync::mpsc;
906
907    use super::config::HistogramStatistic;
908    use super::*;
909
910    const BUCKET_WIDTH_SECS: NonZeroU64 = NonZeroU64::new(10).expect("not zero");
911    const BUCKET_WIDTH: Duration = Duration::from_secs(BUCKET_WIDTH_SECS.get());
912    const COUNTER_EXPIRE_SECS: u64 = 20;
913    const COUNTER_EXPIRE: Option<Duration> = Some(Duration::from_secs(COUNTER_EXPIRE_SECS));
914
915    /// Gets the bucket start timestamp for the given step.
916    const fn bucket_ts(step: u64) -> u64 {
917        align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
918    }
919
920    /// Gets the insert timestamp for the given step.
921    const fn insert_ts(step: u64) -> u64 {
922        (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
923    }
924
925    /// Gets the flush timestamp for the given step.
926    const fn flush_ts(step: u64) -> u64 {
927        BUCKET_WIDTH_SECS.get() * (step + 1)
928    }
929
930    struct DispatcherReceiver {
931        receiver: mpsc::Receiver<EventsBuffer>,
932    }
933
934    impl DispatcherReceiver {
935        fn collect_next(&mut self) -> Vec<Metric> {
936            match self.receiver.try_recv() {
937                Ok(event_buffer) => {
938                    let mut metrics = event_buffer
939                        .into_iter()
940                        .filter_map(|event| event.try_into_metric())
941                        .collect::<Vec<Metric>>();
942
943                    metrics.sort_by(|a, b| a.context().name().cmp(b.context().name()));
944                    metrics
945                }
946                Err(_) => Vec::new(),
947            }
948        }
949    }
950
951    /// Constructs a basic `Dispatcher` with a fixed-size event buffer.
952    fn build_basic_dispatcher() -> (EventsDispatcher, DispatcherReceiver) {
953        let context = ComponentContext::test_transform("test");
954        let mut dispatcher = Dispatcher::new(context);
955
956        let (buffer_tx, buffer_rx) = mpsc::channel(1);
957        dispatcher.add_output(OutputName::Default).unwrap();
958        dispatcher
959            .attach_sender_to_output(&OutputName::Default, buffer_tx)
960            .unwrap();
961
962        (dispatcher, DispatcherReceiver { receiver: buffer_rx })
963    }
964
965    async fn get_flushed_metrics(timestamp: u64, state: &mut AggregationState) -> Vec<Metric> {
966        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
967        let mut buffered_dispatcher = dispatcher.buffered().expect("default output should always exist");
968
969        // Flush the metrics to an event buffer.
970        state
971            .flush(timestamp, true, &mut buffered_dispatcher)
972            .await
973            .expect("should not fail to flush aggregation state");
974
975        // Flush our buffered dispatcher, which should ensure that the event buffer is sent out, and then read it from the
976        // receiver:
977        buffered_dispatcher
978            .flush()
979            .await
980            .expect("should not fail to flush buffered sender");
981
982        dispatcher_receiver.collect_next()
983    }
984
985    macro_rules! compare_points {
986        (scalar, $expected:expr, $actual:expr, $error_ratio:literal) => {
987            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
988                let (expected_ts, expected_point) = expected_value;
989                let (actual_ts, actual_point) = actual_value;
990
991                assert_eq!(
992                    expected_ts, actual_ts,
993                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
994                    idx, expected_ts, actual_ts
995                );
996                assert!(
997                    expected_point.approx_eq_ratio(&actual_point, $error_ratio),
998                    "point for value #{} does not match: {} (expected) vs {} (actual)",
999                    idx,
1000                    expected_point,
1001                    actual_point
1002                );
1003            }
1004        };
1005        (distribution, $expected:expr, $actual:expr) => {
1006            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1007                let (expected_ts, expected_sketch) = expected_value;
1008                let (actual_ts, actual_sketch) = actual_value;
1009
1010                assert_eq!(
1011                    expected_ts, actual_ts,
1012                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1013                    idx, expected_ts, actual_ts
1014                );
1015                assert_eq!(
1016                    expected_sketch, actual_sketch,
1017                    "sketch for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1018                    idx, expected_sketch, actual_sketch
1019                );
1020            }
1021        };
1022    }
1023
1024    macro_rules! assert_flushed_scalar_metric {
1025        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1026            assert_flushed_scalar_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1027        };
1028        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1029            let actual_metric = $actual;
1030
1031            assert_eq!($original.context(), actual_metric.context(), "expected context ({}) and actual context ({}) do not match", $original.context(), actual_metric.context());
1032
1033            let expected_points = ScalarPoints::from([$(($ts, $value)),+]);
1034
1035            match actual_metric.values() {
1036                MetricValues::Counter(ref actual_points) | MetricValues::Gauge(ref actual_points) | MetricValues::Rate(ref actual_points, _) => {
1037                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1038                    compare_points!(scalar, expected_points, actual_points, $error_ratio);
1039                },
1040                _ => panic!("only counters, rates, and gauges are supported in assert_flushed_scalar_metric"),
1041            }
1042        };
1043    }
1044
1045    macro_rules! assert_flushed_distribution_metric {
1046        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1047            assert_flushed_distribution_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1048        };
1049        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1050            let actual_metric = $actual;
1051
1052            assert_eq!($original.context(), actual_metric.context());
1053
1054            match actual_metric.values() {
1055                MetricValues::Distribution(ref actual_points) => {
1056                    let expected_points = SketchPoints::from([$(($ts, $value)),+]);
1057                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1058
1059                    compare_points!(distribution, &expected_points, actual_points);
1060                },
1061                _ => panic!("only distributions are supported in assert_flushed_distribution_metric"),
1062            }
1063        };
1064    }
1065
1066    #[test]
1067    fn bucket_is_closed() {
1068        // Cases are defined as:
1069        // (current time, bucket start, bucket width, flush open buckets, expected result)
1070        let cases = [
1071            // Bucket goes from [995, 1005), current time of 1000, so bucket is open.
1072            (1000, 995, BUCKET_WIDTH_SECS, false, false),
1073            (1000, 995, BUCKET_WIDTH_SECS, true, true),
1074            // Bucket goes from [1000, 1010), current time of 1000, so bucket is open.
1075            (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1076            (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1077            // Bucket goes from [1000, 1010), current time of 1010, so bucket is closed.
1078            (1010, 1000, BUCKET_WIDTH_SECS, false, true),
1079            (1010, 1000, BUCKET_WIDTH_SECS, true, true),
1080        ];
1081
1082        for (current_time, bucket_start, bucket_width_secs, flush_open_buckets, expected) in cases {
1083            let expected_reason = if expected {
1084                "closed, was open"
1085            } else {
1086                "open, was closed"
1087            };
1088
1089            assert_eq!(
1090                is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets),
1091                expected,
1092                "expected bucket to be {} (current_time={}, bucket_start={}, bucket_width={}, flush_open_buckets={})",
1093                expected_reason,
1094                current_time,
1095                bucket_start,
1096                bucket_width_secs,
1097                flush_open_buckets
1098            );
1099        }
1100    }
1101
1102    #[tokio::test]
1103    async fn context_limit() {
1104        // Create our aggregation state with a context limit of 2.
1105        let mut state = AggregationState::new(
1106            BUCKET_WIDTH_SECS,
1107            2,
1108            COUNTER_EXPIRE,
1109            HistogramConfiguration::default(),
1110            Telemetry::noop(),
1111        );
1112
1113        // Create four unique gauges, and insert all of them. The third and fourth should fail because we've reached
1114        // the context limit.
1115        let input_metrics = [
1116            Metric::gauge("metric1", 1.0),
1117            Metric::gauge("metric2", 2.0),
1118            Metric::gauge("metric3", 3.0),
1119            Metric::gauge("metric4", 4.0),
1120        ];
1121
1122        assert!(!state.context_limit_breached());
1123
1124        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1125        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1126        assert!(!state.context_limit_breached());
1127
1128        assert!(!state.insert(insert_ts(1), input_metrics[2].clone()));
1129        assert!(state.context_limit_breached());
1130        assert!(!state.insert(insert_ts(1), input_metrics[3].clone()));
1131        assert!(state.context_limit_breached());
1132
1133        // We should only see the first two gauges after flushing. The flush should also clear the breached flag since
1134        // contexts drop below the limit.
1135        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1136        assert_eq!(flushed_metrics.len(), 2);
1137        assert_eq!(input_metrics[0].context(), flushed_metrics[0].context());
1138        assert_eq!(input_metrics[1].context(), flushed_metrics[1].context());
1139        assert!(!state.context_limit_breached());
1140
1141        // We should be able to insert the third and fourth gauges now as the first two have been flushed, and along
1142        // with them, their contexts should no longer be tracked in the aggregation state:
1143        assert!(state.insert(insert_ts(2), input_metrics[2].clone()));
1144        assert!(state.insert(insert_ts(2), input_metrics[3].clone()));
1145
1146        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1147        assert_eq!(flushed_metrics.len(), 2);
1148        assert_eq!(input_metrics[2].context(), flushed_metrics[0].context());
1149        assert_eq!(input_metrics[3].context(), flushed_metrics[1].context());
1150    }
1151
1152    #[tokio::test]
1153    async fn context_limit_with_zero_value_counters() {
1154        // We test here to ensure that zero-value counters contribute to the context limit.
1155        let mut state = AggregationState::new(
1156            BUCKET_WIDTH_SECS,
1157            2,
1158            COUNTER_EXPIRE,
1159            HistogramConfiguration::default(),
1160            Telemetry::noop(),
1161        );
1162
1163        // Create our input metrics.
1164        let input_metrics = [
1165            Metric::counter("metric1", 1.0),
1166            Metric::counter("metric2", 2.0),
1167            Metric::counter("metric3", 3.0),
1168        ];
1169
1170        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1171        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1172
1173        // Flush the aggregation state, and observe they're both present.
1174        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1175        assert_eq!(flushed_metrics.len(), 2);
1176        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1177        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1178
1179        // Flush _again_ to ensure that we then emit zero-value variants for both counters.
1180        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1181        assert_eq!(flushed_metrics.len(), 2);
1182        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1183        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1184
1185        // Now try to insert a third counter, which should fail because we've reached the context limit.
1186        assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1187
1188        // Flush the aggregation state, and observe that we only see the two original counters.
1189        let flushed_metrics = get_flushed_metrics(flush_ts(3), &mut state).await;
1190        assert_eq!(flushed_metrics.len(), 2);
1191        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0]);
1192        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0]);
1193
1194        // With a fourth flush interval, the two counters should now have expired, and thus be dropped and no longer
1195        // contributing to the context limit.
1196        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1197        assert_eq!(flushed_metrics.len(), 0);
1198
1199        // Now we should be able to insert the third counter, and it should be the only one present after flushing.
1200        assert!(state.insert(insert_ts(5), input_metrics[2].clone()));
1201
1202        let flushed_metrics = get_flushed_metrics(flush_ts(5), &mut state).await;
1203        assert_eq!(flushed_metrics.len(), 1);
1204        assert_flushed_scalar_metric!(&input_metrics[2], &flushed_metrics[0], [bucket_ts(5) => 3.0]);
1205    }
1206
1207    #[tokio::test]
1208    async fn zero_value_counters() {
1209        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1210        let mut state = AggregationState::new(
1211            BUCKET_WIDTH_SECS,
1212            10,
1213            COUNTER_EXPIRE,
1214            HistogramConfiguration::default(),
1215            Telemetry::noop(),
1216        );
1217
1218        // Create two unique counters, and insert both of them.
1219        let input_metrics = [Metric::counter("metric1", 1.0), Metric::counter("metric2", 2.0)];
1220
1221        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1222        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1223
1224        // Flush the aggregation state, and observe they're both present.
1225        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1226        assert_eq!(flushed_metrics.len(), 2);
1227        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1228        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1229
1230        // Perform our second flush, which should have them as zero-value counters.
1231        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1232        assert_eq!(flushed_metrics.len(), 2);
1233        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1234        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1235
1236        // Now, we'll pretend to skip a flush period and add updates to them again after that.
1237        assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1238        assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1239
1240        // Flush the aggregation state, and observe that we have two zero-value counters for the flush period we
1241        // skipped, but that we see them appear again in the fourth flush period.
1242        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1243        assert_eq!(flushed_metrics.len(), 2);
1244        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0, bucket_ts(4) => 1.0]);
1245        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0, bucket_ts(4) => 2.0]);
1246
1247        // Now we'll skip multiple flush periods and ensure that we emit zero-value counters up until the point they
1248        // expire. As our zero-value counter expiration is 20 seconds, this is two flush periods, so we skip by three
1249        // flush periods, and we should only see the counters emitted for the first two.
1250        let flushed_metrics = get_flushed_metrics(flush_ts(7), &mut state).await;
1251        assert_eq!(flushed_metrics.len(), 2);
1252        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1253        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1254    }
1255
1256    #[tokio::test]
1257    async fn merge_identical_timestamped_values_on_flush() {
1258        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1259        let mut state = AggregationState::new(
1260            BUCKET_WIDTH_SECS,
1261            10,
1262            COUNTER_EXPIRE,
1263            HistogramConfiguration::default(),
1264            Telemetry::noop(),
1265        );
1266
1267        // Create one multi-value counter, and insert it.
1268        let input_metric = Metric::counter("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1269
1270        assert!(state.insert(insert_ts(1), input_metric.clone()));
1271
1272        // Flush the aggregation state, and observe the metric is present _and_ that we've properly merged all of the
1273        // values within the same timestamp.
1274        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1275        assert_eq!(flushed_metrics.len(), 1);
1276        assert_flushed_scalar_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => 15.0]);
1277    }
1278
1279    #[tokio::test]
1280    async fn histogram_statistics() {
1281        // We're testing that we properly emit individual metrics (min, max, sum, etc) for a histogram.
1282        let hist_config = HistogramConfiguration::from_statistics(
1283            &[
1284                HistogramStatistic::Count,
1285                HistogramStatistic::Sum,
1286                HistogramStatistic::Percentile {
1287                    q: 0.5,
1288                    suffix: "p50".into(),
1289                },
1290            ],
1291            false,
1292            "".into(),
1293        );
1294        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1295
1296        // Create one multi-value histogram and insert it.
1297        let input_metric = Metric::histogram("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1298        assert!(state.insert(insert_ts(1), input_metric.clone()));
1299
1300        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1301        // the form of three metrics: count, sum, and p50.
1302        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1303        assert_eq!(flushed_metrics.len(), 3);
1304
1305        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1306        // matter here, but we do need a `Metric` for it to compare the context to.
1307        let count_metric = Metric::rate("metric1.count", 0.0, Duration::from_secs(BUCKET_WIDTH_SECS.get()));
1308        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1309        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1310
1311        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1312        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1313        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[0], [bucket_ts(1) => 5.0]);
1314        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[1], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1315        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[2], [bucket_ts(1) => 15.0]);
1316    }
1317
1318    #[tokio::test]
1319    async fn histogram_statistics_unit_propagation() {
1320        // We're testing that the unit from the input histogram metadata propagates to all flushed output metrics.
1321        let hist_config = HistogramConfiguration::from_statistics(
1322            &[
1323                HistogramStatistic::Count,
1324                HistogramStatistic::Sum,
1325                HistogramStatistic::Percentile {
1326                    q: 0.5,
1327                    suffix: "p50".into(),
1328                },
1329            ],
1330            false,
1331            "".into(),
1332        );
1333        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1334
1335        // Build a histogram with unit = "millisecond", simulating what arrives from a DogStatsD `ms` metric.
1336        let context = Context::from_static_parts("metric1", &[]);
1337        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
1338        let input_metric = Metric::from_parts(
1339            context,
1340            MetricValues::histogram([1.0_f64, 2.0, 3.0, 4.0, 5.0]),
1341            metadata,
1342        );
1343        assert!(state.insert(insert_ts(1), input_metric));
1344
1345        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1346        assert_eq!(flushed_metrics.len(), 3);
1347
1348        // Counts are dimensionless: the `.count` series must drop the unit while all other
1349        // aggregate series carry the unit from the input histogram.
1350        for metric in &flushed_metrics {
1351            let name = metric.context().name();
1352            if name.ends_with(".count") {
1353                assert_eq!(
1354                    metric.metadata().unit(),
1355                    None,
1356                    "flushed metric '{}' should be dimensionless",
1357                    name
1358                );
1359            } else {
1360                assert_eq!(
1361                    metric.metadata().unit(),
1362                    Some("millisecond"),
1363                    "flushed metric '{}' should carry unit='millisecond'",
1364                    name
1365                );
1366            }
1367        }
1368    }
1369
1370    #[tokio::test]
1371    async fn distributions() {
1372        // We're testing that we pass through distributions untouched.
1373        let mut state = AggregationState::new(
1374            BUCKET_WIDTH_SECS,
1375            10,
1376            COUNTER_EXPIRE,
1377            HistogramConfiguration::default(),
1378            Telemetry::noop(),
1379        );
1380
1381        // Create one multi-value distribution, with server-side aggregation, and insert it.
1382        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1383        let input_metric = Metric::distribution("metric1", &values[..]);
1384
1385        assert!(state.insert(insert_ts(1), input_metric.clone()));
1386
1387        // Flush the aggregation state, and observe that we've emitted the original distribution.
1388        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1389        assert_eq!(flushed_metrics.len(), 1);
1390
1391        assert_flushed_distribution_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1392    }
1393
1394    #[tokio::test]
1395    async fn histogram_copy_to_distribution() {
1396        let hist_config = HistogramConfiguration::from_statistics(
1397            &[
1398                HistogramStatistic::Count,
1399                HistogramStatistic::Sum,
1400                HistogramStatistic::Percentile {
1401                    q: 0.5,
1402                    suffix: "p50".into(),
1403                },
1404            ],
1405            true,
1406            "dist_prefix.".into(),
1407        );
1408        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1409
1410        // Create one multi-value histogram and insert it.
1411        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1412        let input_metric = Metric::histogram("metric1", values);
1413        assert!(state.insert(insert_ts(1), input_metric.clone()));
1414
1415        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1416        // the form of three metrics: count, sum, and p50 as well as the additional metric from copying the histogram.
1417        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1418        assert_eq!(flushed_metrics.len(), 4);
1419
1420        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1421        // matter here, but we do need a `Metric` for it to compare the context to.
1422        let count_metric = Metric::rate("metric1.count", 0.0, BUCKET_WIDTH);
1423        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1424        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1425        let expected_distribution = Metric::distribution("dist_prefix.metric1", &values[..]);
1426
1427        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1428        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1429        assert_flushed_distribution_metric!(expected_distribution, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1430        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[1], [bucket_ts(1) => 5.0]);
1431        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[2], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1432        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[3], [bucket_ts(1) => 15.0]);
1433    }
1434
1435    #[tokio::test]
1436    async fn nonaggregated_counters_to_rate() {
1437        let counter_value = 42.0;
1438
1439        // Create a basic aggregation state.
1440        let mut state = AggregationState::new(
1441            BUCKET_WIDTH_SECS,
1442            10,
1443            COUNTER_EXPIRE,
1444            HistogramConfiguration::default(),
1445            Telemetry::noop(),
1446        );
1447
1448        // Create a simple non-aggregated counter, and insert it.
1449        let input_metric = Metric::counter("metric1", counter_value);
1450        assert!(state.insert(insert_ts(1), input_metric.clone()));
1451
1452        // Flush the aggregation state, and observe that we've emitted the expected counter and that it has the right
1453        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
1454        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1455        assert_eq!(flushed_metrics.len(), 1);
1456        let flushed_metric = &flushed_metrics[0];
1457
1458        assert_flushed_scalar_metric!(&input_metric, flushed_metric, [bucket_ts(1) => counter_value]);
1459        assert_eq!(flushed_metric.values().as_str(), "rate");
1460    }
1461
1462    #[tokio::test]
1463    async fn preaggregated_counters_to_rate() {
1464        let counter_value = 42.0;
1465        let timestamp = 123456;
1466
1467        // Create a basic passthrough batcher and forwarder.
1468        let mut batcher = PassthroughBatcher::new(Duration::from_nanos(1), BUCKET_WIDTH_SECS, Telemetry::noop()).await;
1469        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
1470
1471        // Create a simple pre-aggregated counter, and batch it.
1472        let input_metric = Metric::counter("metric1", (timestamp, counter_value));
1473        batcher.push_metric(input_metric.clone(), &dispatcher).await;
1474
1475        // Flush the batcher, and observe that we've emitted the expected counter and that it has the right
1476        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
1477        batcher.try_flush(&dispatcher).await;
1478
1479        let mut flushed_metrics = dispatcher_receiver.collect_next();
1480        assert_eq!(flushed_metrics.len(), 1);
1481        assert_eq!(
1482            Metric::rate("metric1", (timestamp, counter_value), BUCKET_WIDTH),
1483            flushed_metrics.remove(0)
1484        );
1485    }
1486
1487    #[tokio::test]
1488    async fn telemetry() {
1489        // TODO: We don't check `component_events_dropped_total` or `aggregate_passthrough_metrics_total` here as
1490        // they're set directly in the aggregate component future rather than `AggregationState`, which is harder to
1491        // drive overall and would have required even more boilerplate.
1492        //
1493        // Leaving that as a future improvement.
1494
1495        let recorder = TestRecorder::default();
1496        let _local = metrics::set_default_local_recorder(&recorder);
1497
1498        let builder = MetricsBuilder::default();
1499        let telemetry = Telemetry::new(&builder);
1500
1501        let mut state = AggregationState::new(
1502            BUCKET_WIDTH_SECS,
1503            2,
1504            COUNTER_EXPIRE,
1505            HistogramConfiguration::default(),
1506            telemetry,
1507        );
1508
1509        // Make sure our telemetry is registered at default values.
1510        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(0.0));
1511        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
1512        assert_eq!(
1513            recorder.counter(("component_events_dropped_total", &[("intentional", "true")])),
1514            Some(0)
1515        );
1516        for metric_type in &["counter", "gauge", "rate", "set", "histogram", "distribution"] {
1517            assert_eq!(
1518                recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", *metric_type)])),
1519                Some(0.0)
1520            );
1521        }
1522
1523        // Insert a counter with a non-timestamped value.
1524        assert!(state.insert(insert_ts(1), Metric::counter("metric1", 42.0)));
1525        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
1526        assert_eq!(
1527            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1528            Some(1.0)
1529        );
1530        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
1531
1532        // Insert a gauge with a timestamped value.
1533        assert!(state.insert(insert_ts(1), Metric::gauge("metric2", (insert_ts(1), 42.0))));
1534        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
1535        assert_eq!(
1536            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
1537            Some(1.0)
1538        );
1539
1540        // We've reached our context limit at this point, so the next metric should not be inserted.
1541        assert!(!state.insert(insert_ts(1), Metric::counter("metric3", 42.0)));
1542        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
1543        assert_eq!(
1544            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1545            Some(1.0)
1546        );
1547
1548        // Now let's flush the state which should flush the gauge entirely, reducing the context count, but not flush
1549        // the counter, since it'll be in zero-value mode.
1550        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1551        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
1552        assert_eq!(
1553            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1554            Some(1.0)
1555        );
1556        assert_eq!(
1557            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
1558            Some(0.0)
1559        );
1560    }
1561}
1562
1563#[cfg(test)]
1564mod config_smoke {
1565    use datadog_agent_config_testing::config_registry::structs;
1566    use datadog_agent_config_testing::run_config_smoke_tests;
1567    use serde_json::json;
1568
1569    use super::AggregateConfiguration;
1570    use crate::config::{DatadogRemapper, KEY_ALIASES};
1571
1572    #[tokio::test]
1573    async fn smoke_test() {
1574        // Duration fields serialize as {secs, nanos}. We inject whole-second values so the nanos
1575        // sub-fields are always 0 — they are not independently configurable.
1576        run_config_smoke_tests(
1577            structs::AGGREGATE_CONFIGURATION,
1578            &[
1579                "aggregate_flush_interval.nanos",
1580                "aggregate_passthrough_idle_flush_timeout.nanos",
1581            ],
1582            json!({}),
1583            |cfg| {
1584                cfg.as_typed::<AggregateConfiguration>()
1585                    .expect("AggregateConfiguration should deserialize")
1586            },
1587            KEY_ALIASES,
1588            DatadogRemapper::from_env_vars,
1589        )
1590        .await
1591    }
1592}