Skip to main content

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 resource_accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr};
10use saluki_common::time::get_unix_timestamp;
11use saluki_config::GenericConfiguration;
12use saluki_context::Context;
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        // The context map is hard-capped at `context_limit` and no path grows it past the cap. This is the one
573        // non-advisory runtime memory bound, so we assert it as an invariant under Antithesis. The numeric form hands
574        // the search the margin to the limit as a gradient.
575        saluki_antithesis::always_le!(
576            self.contexts.len(),
577            self.context_limit,
578            "aggregate context map within context_limit",
579            { "len": self.contexts.len(), "limit": self.context_limit }
580        );
581
582        // If we haven't seen this context yet, and it would put us over the limit to insert it, then return early.
583        if !self.contexts.contains_key(metric.context()) && self.contexts.len() >= self.context_limit {
584            // Anti-vacuity anchor: prove a run actually reaches the cap, else the invariant above passes trivially.
585            saluki_antithesis::sometimes!(
586                true,
587                "aggregate context limit breached",
588                { "limit": self.context_limit }
589            );
590
591            self.context_limit_breached = true;
592            return false;
593        }
594
595        let (context, mut values, metadata) = metric.into_parts();
596
597        // Collapse all non-timestamped values into a single timestamped value.
598        //
599        // We do this pre-aggregation step because unless we're merging into an existing context, we'll end up with
600        // however many values were in the original metric instead of full aggregated values.
601        let bucket_ts = align_to_bucket_start(timestamp, self.bucket_width_secs);
602        values.collapse_non_timestamped(bucket_ts);
603
604        trace!(
605            bucket_ts,
606            kind = values.as_str(),
607            "Inserting metric into aggregation state."
608        );
609
610        // If we're already tracking this context, update the last seen time and merge the new values into the existing
611        // values. Otherwise, create a new entry.
612        match self.contexts.entry(context) {
613            Entry::Occupied(mut entry) => {
614                let aggregated = entry.get_mut();
615
616                // We ignore metadata changes within a flush interval to keep things simple.
617                aggregated.last_seen = timestamp;
618                aggregated.values.merge(values);
619            }
620            Entry::Vacant(entry) => {
621                self.telemetry.increment_contexts(entry.key(), &values);
622
623                entry.insert(AggregatedMetric {
624                    values,
625                    metadata,
626                    last_seen: timestamp,
627                });
628            }
629        }
630
631        true
632    }
633
634    async fn flush(
635        &mut self, current_time: u64, flush_open_buckets: bool, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
636    ) -> Result<(), GenericError> {
637        let bucket_width_secs = self.bucket_width_secs;
638        let counter_expire_secs = self.counter_expire_secs.map(|d| d.get()).unwrap_or(0);
639
640        // We want our split timestamp to be before the start of the current bucket, which ensures any timestamp that is
641        // less than or equal to `split_timestamp` resides in a closed bucket.
642        let split_timestamp = align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1);
643
644        // Calculate the buckets we need to potentially generate zero-value counters for.
645        //
646        // We only need to do this if we've flushed before, since we won't have any knowledge of which counters are idle
647        // or not until that happens.
648        let mut zero_value_buckets = SmallVec::<[(u64, MetricValues); 4]>::new();
649        if self.last_flush != 0 {
650            let start = align_to_bucket_start(self.last_flush, bucket_width_secs);
651
652            // Clock-skew guards. Bucketing reads the wall clock while the flush cadence is monotonic, so a wall-clock
653            // jump is not bounded by the flush interval. A backward jump empties the zero-value range (a silent counter
654            // gap); a forward jump makes the loop below run once per bucket across the whole jumped span — O(jump) work
655            // and allocation. Assert before the loop so a flood fails fast rather than after the damage is done.
656            saluki_antithesis::always_ge!(
657                current_time,
658                self.last_flush,
659                "aggregate flush wall-clock did not move backward",
660                { "current_time": current_time, "last_flush": self.last_flush }
661            );
662            // The 10_000 bound is generous. A default 15s flush over a 10s bucket yields one or two buckets. The bound
663            // trips only on a multi-hour wall-clock jump, never on a slow-but-sane flush.
664            saluki_antithesis::always_le!(
665                current_time.saturating_sub(self.last_flush) / bucket_width_secs.get(),
666                10_000,
667                "aggregate zero-value bucket span bounded across a flush",
668                {
669                    "current_time": current_time,
670                    "last_flush": self.last_flush,
671                    "bucket_width_secs": bucket_width_secs.get()
672                }
673            );
674
675            for bucket_start in (start..current_time).step_by(bucket_width_secs.get() as usize) {
676                if is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets) {
677                    zero_value_buckets.push((bucket_start, MetricValues::counter((bucket_start, 0.0))));
678                }
679            }
680
681            // Anti-vacuity anchor: prove the idle-counter zero-value path actually runs in some timeline.
682            saluki_antithesis::sometimes!(
683                !zero_value_buckets.is_empty(),
684                "aggregate flush generated zero-value counter buckets",
685                { "count": zero_value_buckets.len() }
686            );
687        }
688
689        // Iterate over each context we're tracking, and flush any values that are in buckets which are now closed.
690        debug!(timestamp = current_time, "Flushing buckets.");
691
692        for (context, am) in self.contexts.iter_mut() {
693            // Figure out if we should remove this metric or not if it has no values in open buckets.
694            //
695            // We have a special carve-out for counters here, which we have the ability to keep alive after they are
696            // flushed, based on a configured expiration period. This allows us to continue emitting a zero value for
697            // counters when they're idle, which can make them appear "live" in downstream systems, even when they're
698            // not.
699            //
700            // This is useful for sparsely-updated counters.
701            let should_expire_if_empty = match &am.values {
702                MetricValues::Counter(..) => {
703                    saluki_antithesis::always_le!(
704                        am.last_seen,
705                        u64::MAX - counter_expire_secs,
706                        "aggregate counter expiry add does not overflow",
707                        { "last_seen": am.last_seen, "counter_expire_secs": counter_expire_secs }
708                    );
709                    counter_expire_secs != 0 && am.last_seen.saturating_add(counter_expire_secs) < current_time
710                }
711                _ => true,
712            };
713
714            // If we're dealing with a counter, we'll merge in our calculated set of zero values. We only merge in the
715            // values that represent now-closed buckets.
716            //
717            // This is also safe to do even when there are real values in those buckets since adding zero to anything is
718            // a no-op from the perspective of what we end up flushing, and it doesn't mess with the "last seen" time.
719            if let MetricValues::Counter(..) = &mut am.values {
720                let expires_at = am.last_seen.saturating_add(counter_expire_secs);
721                for (zv_bucket_start, zero_value) in &zero_value_buckets {
722                    if expires_at > *zv_bucket_start {
723                        am.values.merge(zero_value.clone());
724                    } else {
725                        // Since zero-value buckets are in order, we can break early if this bucket is past the
726                        // expiration cutoff of the counter. No other bucket will be within the expiration range.
727                        break;
728                    }
729                }
730            }
731
732            // Finally, figure out if the current metric can be removed.
733            //
734            // For any metric with values that are in open buckets, we split off the values that are in closed buckets
735            // and keep the metric alive. When all the values are in closed buckets, or there are no values, we'll
736            // remove the metric if `should_remove_if_empty` is `true`.
737            //
738            // This means we'll always remove all-closed/empty non-counter metrics, and we _may_ remove all-closed/empty
739            // counters.
740            if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
741                self.telemetry.increment_flushed(&closed_bucket_values);
742
743                // We got some closed bucket values, so flush those out.
744                transform_and_push_metric(
745                    context.clone(),
746                    closed_bucket_values,
747                    am.metadata.clone(),
748                    bucket_width_secs,
749                    &self.hist_config,
750                    dispatcher,
751                )
752                .await?;
753            }
754
755            if am.values.is_empty() && should_expire_if_empty {
756                self.telemetry.decrement_contexts(context, &am.values);
757                self.contexts_remove_buf.push(context.clone());
758            }
759        }
760
761        // Remove any contexts that were marked as needing to be removed.
762        let contexts_len_before = self.contexts.len();
763        for context in self.contexts_remove_buf.drain(..) {
764            self.contexts.remove(&context);
765        }
766        let contexts_len_after = self.contexts.len();
767
768        let contexts_delta = contexts_len_before.saturating_sub(contexts_len_after);
769        let target_contexts_capacity = contexts_len_after.saturating_add(contexts_delta / 2);
770        self.contexts.shrink_to(target_contexts_capacity);
771
772        if self.context_limit_breached && self.contexts.len() < self.context_limit {
773            self.context_limit_breached = false;
774        }
775
776        self.last_flush = current_time;
777
778        Ok(())
779    }
780
781    fn context_limit_breached(&self) -> bool {
782        self.context_limit_breached
783    }
784}
785
786async fn transform_and_push_metric(
787    context: Context, mut values: MetricValues, metadata: MetricMetadata, bucket_width_secs: NonZeroU64,
788    hist_config: &HistogramConfiguration, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
789) -> Result<(), GenericError> {
790    let bucket_width = Duration::from_secs(bucket_width_secs.get());
791
792    match values {
793        // If we're dealing with a histogram, we calculate a configured set of aggregates/percentiles from it, and emit
794        // them as individual metrics.
795        MetricValues::Histogram(ref mut points) => {
796            // Convert histogram to distribution
797            if hist_config.copy_to_distribution() {
798                let sketch_points = points
799                    .into_iter()
800                    .map(|(ts, hist)| {
801                        let mut sketch = DDSketch::default();
802                        for sample in hist.samples() {
803                            sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
804                        }
805                        (ts, sketch)
806                    })
807                    .collect::<SketchPoints>();
808                let distribution_values = MetricValues::distribution(sketch_points);
809                let metric_context = if !hist_config.copy_to_distribution_prefix().is_empty() {
810                    context.with_name(format!(
811                        "{}{}",
812                        hist_config.copy_to_distribution_prefix(),
813                        context.name()
814                    ))
815                } else {
816                    context.clone()
817                };
818                let new_metric = Metric::from_parts(metric_context, distribution_values, metadata.clone());
819                dispatcher.push(Event::Metric(new_metric)).await?;
820            }
821            // We collect our histogram points in their "summary" view, which sorts the underlying samples allowing
822            // proper quantile queries to be answered, hence our "sorted" points. We do it this way because rather than
823            // sort every time we insert, or cloning the points, we only sort when a summary view is constructed, which
824            // requires mutable access to sort the samples in-place.
825            let mut sorted_points = Vec::new();
826            for (ts, h) in points {
827                sorted_points.push((ts, h.summary_view()));
828            }
829
830            for statistic in hist_config.statistics() {
831                let new_points = sorted_points
832                    .iter()
833                    .map(|(ts, hs)| (*ts, statistic.value_from_histogram(hs)))
834                    .collect::<ScalarPoints>();
835
836                let new_values = if statistic.is_rate_statistic() {
837                    MetricValues::rate(new_points, bucket_width)
838                } else {
839                    MetricValues::gauge(new_points)
840                };
841
842                // Counts are dimensionless, so clear any unit inherited from the input histogram.
843                let new_metadata = if matches!(statistic, HistogramStatistic::Count) {
844                    metadata.clone().with_unit(MetaString::empty())
845                } else {
846                    metadata.clone()
847                };
848
849                let new_context = context.with_name(format!("{}.{}", context.name(), statistic.suffix()));
850                let new_metric = Metric::from_parts(new_context, new_values, new_metadata);
851                dispatcher.push(Event::Metric(new_metric)).await?;
852            }
853
854            Ok(())
855        }
856
857        // If we're not dealing with a histogram, then all we need to worry about is converting counters to rates before
858        // forwarding our single, aggregated metric.
859        values => {
860            let adjusted_values = counter_values_to_rate(values, bucket_width_secs);
861
862            let metric = Metric::from_parts(context, adjusted_values, metadata);
863            dispatcher.push(Event::Metric(metric)).await
864        }
865    }
866}
867
868fn counter_values_to_rate(values: MetricValues, interval_secs: NonZeroU64) -> MetricValues {
869    match values {
870        MetricValues::Counter(points) => MetricValues::rate(points, Duration::from_secs(interval_secs.get())),
871        values => values,
872    }
873}
874
875const fn align_to_bucket_start(timestamp: u64, bucket_width_secs: NonZeroU64) -> u64 {
876    timestamp - (timestamp % bucket_width_secs.get())
877}
878
879const fn is_bucket_closed(
880    current_time: u64, bucket_start: u64, bucket_width_secs: NonZeroU64, flush_open_buckets: bool,
881) -> bool {
882    // A bucket is considered "closed" if the current time is greater than the end of the bucket, or if
883    // `flush_open_buckets` is `true`.
884    //
885    // Buckets represent a half-open interval, where the start is inclusive and the end is exclusive. This means that
886    // 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
887    // 20, with the 20 excluded, or [10, 20) in interval notation. Simply put, if we have a timestamp of 10, or anything
888    // smaller than 20, we would consider it to fall within the bucket... but 20 or more would be outside of the bucket.
889    //
890    // We can also represent this visually:
891    //
892    // <--------- bucket 1 ----------> <--------- bucket 2 ----------> <--------- bucket 3 ---------->
893    // [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]
894    //
895    // We can see that each bucket is 10 seconds wide (10 elements, one for each second), and that their ends are
896    // effectively `start + width - 1`. This means that for any of these buckets to be considered "closed", the current
897    // time has to be _greater_ than `start + width - 1`. For example, if the current time is 19, then no buckets are
898    // closed, and if the current time is 29, then bucket 1 is closed but buckets 2 and 3 are still open, and if the
899    // current time is 30, then both buckets 1 and 2 are closed, but bucket 3 is still open.
900    (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
901}
902
903// TODO: One thing we ought to consider is a property test, specifically a state machine property test, where we
904// generate a randomized offset to start time from, a bucket width, flush interval, and operations, and so on... and
905// then we run it to make sure that we are always generating sequential timestamps for data points, etc.
906#[cfg(test)]
907mod tests {
908    use float_cmp::ApproxEqRatio as _;
909    use saluki_core::{
910        components::ComponentContext,
911        topology::{interconnect::Dispatcher, ComponentId, OutputName},
912    };
913    use saluki_metrics::test::TestRecorder;
914    use stringtheory::MetaString;
915    use tokio::sync::mpsc;
916
917    use super::config::HistogramStatistic;
918    use super::*;
919
920    const BUCKET_WIDTH_SECS: NonZeroU64 = NonZeroU64::new(10).expect("not zero");
921    const BUCKET_WIDTH: Duration = Duration::from_secs(BUCKET_WIDTH_SECS.get());
922    const COUNTER_EXPIRE_SECS: u64 = 20;
923    const COUNTER_EXPIRE: Option<Duration> = Some(Duration::from_secs(COUNTER_EXPIRE_SECS));
924
925    /// Gets the bucket start timestamp for the given step.
926    const fn bucket_ts(step: u64) -> u64 {
927        align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
928    }
929
930    /// Gets the insert timestamp for the given step.
931    const fn insert_ts(step: u64) -> u64 {
932        (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
933    }
934
935    /// Gets the flush timestamp for the given step.
936    const fn flush_ts(step: u64) -> u64 {
937        BUCKET_WIDTH_SECS.get() * (step + 1)
938    }
939
940    struct DispatcherReceiver {
941        receiver: mpsc::Receiver<EventsBuffer>,
942    }
943
944    impl DispatcherReceiver {
945        fn collect_next(&mut self) -> Vec<Metric> {
946            match self.receiver.try_recv() {
947                Ok(event_buffer) => {
948                    let mut metrics = event_buffer
949                        .into_iter()
950                        .filter_map(|event| event.try_into_metric())
951                        .collect::<Vec<Metric>>();
952
953                    metrics.sort_by(|a, b| a.context().name().cmp(b.context().name()));
954                    metrics
955                }
956                Err(_) => Vec::new(),
957            }
958        }
959    }
960
961    /// Constructs a basic `Dispatcher` with a fixed-size event buffer.
962    fn build_basic_dispatcher() -> (EventsDispatcher, DispatcherReceiver) {
963        let component_id = ComponentId::try_from("test").expect("should not fail to create component ID");
964        let mut dispatcher = Dispatcher::new(ComponentContext::transform(component_id));
965
966        let (buffer_tx, buffer_rx) = mpsc::channel(1);
967        dispatcher.add_output(OutputName::Default).unwrap();
968        dispatcher
969            .attach_sender_to_output(&OutputName::Default, buffer_tx)
970            .unwrap();
971
972        (dispatcher, DispatcherReceiver { receiver: buffer_rx })
973    }
974
975    async fn get_flushed_metrics(timestamp: u64, state: &mut AggregationState) -> Vec<Metric> {
976        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
977        let mut buffered_dispatcher = dispatcher.buffered().expect("default output should always exist");
978
979        // Flush the metrics to an event buffer.
980        state
981            .flush(timestamp, true, &mut buffered_dispatcher)
982            .await
983            .expect("should not fail to flush aggregation state");
984
985        // Flush our buffered dispatcher, which should ensure that the event buffer is sent out, and then read it from the
986        // receiver:
987        buffered_dispatcher
988            .flush()
989            .await
990            .expect("should not fail to flush buffered sender");
991
992        dispatcher_receiver.collect_next()
993    }
994
995    macro_rules! compare_points {
996        (scalar, $expected:expr, $actual:expr, $error_ratio:literal) => {
997            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
998                let (expected_ts, expected_point) = expected_value;
999                let (actual_ts, actual_point) = actual_value;
1000
1001                assert_eq!(
1002                    expected_ts, actual_ts,
1003                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1004                    idx, expected_ts, actual_ts
1005                );
1006                assert!(
1007                    expected_point.approx_eq_ratio(&actual_point, $error_ratio),
1008                    "point for value #{} does not match: {} (expected) vs {} (actual)",
1009                    idx,
1010                    expected_point,
1011                    actual_point
1012                );
1013            }
1014        };
1015        (distribution, $expected:expr, $actual:expr) => {
1016            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1017                let (expected_ts, expected_sketch) = expected_value;
1018                let (actual_ts, actual_sketch) = actual_value;
1019
1020                assert_eq!(
1021                    expected_ts, actual_ts,
1022                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1023                    idx, expected_ts, actual_ts
1024                );
1025                assert_eq!(
1026                    expected_sketch, actual_sketch,
1027                    "sketch for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1028                    idx, expected_sketch, actual_sketch
1029                );
1030            }
1031        };
1032    }
1033
1034    macro_rules! assert_flushed_scalar_metric {
1035        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1036            assert_flushed_scalar_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1037        };
1038        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1039            let actual_metric = $actual;
1040
1041            assert_eq!($original.context(), actual_metric.context(), "expected context ({}) and actual context ({}) do not match", $original.context(), actual_metric.context());
1042
1043            let expected_points = ScalarPoints::from([$(($ts, $value)),+]);
1044
1045            match actual_metric.values() {
1046                MetricValues::Counter(ref actual_points) | MetricValues::Gauge(ref actual_points) | MetricValues::Rate(ref actual_points, _) => {
1047                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1048                    compare_points!(scalar, expected_points, actual_points, $error_ratio);
1049                },
1050                _ => panic!("only counters, rates, and gauges are supported in assert_flushed_scalar_metric"),
1051            }
1052        };
1053    }
1054
1055    macro_rules! assert_flushed_distribution_metric {
1056        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1057            assert_flushed_distribution_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1058        };
1059        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1060            let actual_metric = $actual;
1061
1062            assert_eq!($original.context(), actual_metric.context());
1063
1064            match actual_metric.values() {
1065                MetricValues::Distribution(ref actual_points) => {
1066                    let expected_points = SketchPoints::from([$(($ts, $value)),+]);
1067                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1068
1069                    compare_points!(distribution, &expected_points, actual_points);
1070                },
1071                _ => panic!("only distributions are supported in assert_flushed_distribution_metric"),
1072            }
1073        };
1074    }
1075
1076    #[test]
1077    fn bucket_is_closed() {
1078        // Cases are defined as:
1079        // (current time, bucket start, bucket width, flush open buckets, expected result)
1080        let cases = [
1081            // Bucket goes from [995, 1005), current time of 1000, so bucket is open.
1082            (1000, 995, BUCKET_WIDTH_SECS, false, false),
1083            (1000, 995, BUCKET_WIDTH_SECS, true, true),
1084            // Bucket goes from [1000, 1010), current time of 1000, so bucket is open.
1085            (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1086            (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1087            // Bucket goes from [1000, 1010), current time of 1010, so bucket is closed.
1088            (1010, 1000, BUCKET_WIDTH_SECS, false, true),
1089            (1010, 1000, BUCKET_WIDTH_SECS, true, true),
1090        ];
1091
1092        for (current_time, bucket_start, bucket_width_secs, flush_open_buckets, expected) in cases {
1093            let expected_reason = if expected {
1094                "closed, was open"
1095            } else {
1096                "open, was closed"
1097            };
1098
1099            assert_eq!(
1100                is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets),
1101                expected,
1102                "expected bucket to be {} (current_time={}, bucket_start={}, bucket_width={}, flush_open_buckets={})",
1103                expected_reason,
1104                current_time,
1105                bucket_start,
1106                bucket_width_secs,
1107                flush_open_buckets
1108            );
1109        }
1110    }
1111
1112    #[tokio::test]
1113    async fn context_limit() {
1114        // Create our aggregation state with a context limit of 2.
1115        let mut state = AggregationState::new(
1116            BUCKET_WIDTH_SECS,
1117            2,
1118            COUNTER_EXPIRE,
1119            HistogramConfiguration::default(),
1120            Telemetry::noop(),
1121        );
1122
1123        // Create four unique gauges, and insert all of them. The third and fourth should fail because we've reached
1124        // the context limit.
1125        let input_metrics = [
1126            Metric::gauge("metric1", 1.0),
1127            Metric::gauge("metric2", 2.0),
1128            Metric::gauge("metric3", 3.0),
1129            Metric::gauge("metric4", 4.0),
1130        ];
1131
1132        assert!(!state.context_limit_breached());
1133
1134        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1135        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1136        assert!(!state.context_limit_breached());
1137
1138        assert!(!state.insert(insert_ts(1), input_metrics[2].clone()));
1139        assert!(state.context_limit_breached());
1140        assert!(!state.insert(insert_ts(1), input_metrics[3].clone()));
1141        assert!(state.context_limit_breached());
1142
1143        // We should only see the first two gauges after flushing. The flush should also clear the breached flag since
1144        // contexts drop below the limit.
1145        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1146        assert_eq!(flushed_metrics.len(), 2);
1147        assert_eq!(input_metrics[0].context(), flushed_metrics[0].context());
1148        assert_eq!(input_metrics[1].context(), flushed_metrics[1].context());
1149        assert!(!state.context_limit_breached());
1150
1151        // We should be able to insert the third and fourth gauges now as the first two have been flushed, and along
1152        // with them, their contexts should no longer be tracked in the aggregation state:
1153        assert!(state.insert(insert_ts(2), input_metrics[2].clone()));
1154        assert!(state.insert(insert_ts(2), input_metrics[3].clone()));
1155
1156        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1157        assert_eq!(flushed_metrics.len(), 2);
1158        assert_eq!(input_metrics[2].context(), flushed_metrics[0].context());
1159        assert_eq!(input_metrics[3].context(), flushed_metrics[1].context());
1160    }
1161
1162    #[tokio::test]
1163    async fn context_limit_with_zero_value_counters() {
1164        // We test here to ensure that zero-value counters contribute to the context limit.
1165        let mut state = AggregationState::new(
1166            BUCKET_WIDTH_SECS,
1167            2,
1168            COUNTER_EXPIRE,
1169            HistogramConfiguration::default(),
1170            Telemetry::noop(),
1171        );
1172
1173        // Create our input metrics.
1174        let input_metrics = [
1175            Metric::counter("metric1", 1.0),
1176            Metric::counter("metric2", 2.0),
1177            Metric::counter("metric3", 3.0),
1178        ];
1179
1180        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1181        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1182
1183        // Flush the aggregation state, and observe they're both present.
1184        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1185        assert_eq!(flushed_metrics.len(), 2);
1186        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1187        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1188
1189        // Flush _again_ to ensure that we then emit zero-value variants for both counters.
1190        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1191        assert_eq!(flushed_metrics.len(), 2);
1192        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1193        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1194
1195        // Now try to insert a third counter, which should fail because we've reached the context limit.
1196        assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1197
1198        // Flush the aggregation state, and observe that we only see the two original counters.
1199        let flushed_metrics = get_flushed_metrics(flush_ts(3), &mut state).await;
1200        assert_eq!(flushed_metrics.len(), 2);
1201        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0]);
1202        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0]);
1203
1204        // With a fourth flush interval, the two counters should now have expired, and thus be dropped and no longer
1205        // contributing to the context limit.
1206        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1207        assert_eq!(flushed_metrics.len(), 0);
1208
1209        // Now we should be able to insert the third counter, and it should be the only one present after flushing.
1210        assert!(state.insert(insert_ts(5), input_metrics[2].clone()));
1211
1212        let flushed_metrics = get_flushed_metrics(flush_ts(5), &mut state).await;
1213        assert_eq!(flushed_metrics.len(), 1);
1214        assert_flushed_scalar_metric!(&input_metrics[2], &flushed_metrics[0], [bucket_ts(5) => 3.0]);
1215    }
1216
1217    #[tokio::test]
1218    async fn zero_value_counters() {
1219        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1220        let mut state = AggregationState::new(
1221            BUCKET_WIDTH_SECS,
1222            10,
1223            COUNTER_EXPIRE,
1224            HistogramConfiguration::default(),
1225            Telemetry::noop(),
1226        );
1227
1228        // Create two unique counters, and insert both of them.
1229        let input_metrics = [Metric::counter("metric1", 1.0), Metric::counter("metric2", 2.0)];
1230
1231        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1232        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1233
1234        // Flush the aggregation state, and observe they're both present.
1235        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1236        assert_eq!(flushed_metrics.len(), 2);
1237        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1238        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1239
1240        // Perform our second flush, which should have them as zero-value counters.
1241        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1242        assert_eq!(flushed_metrics.len(), 2);
1243        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1244        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1245
1246        // Now, we'll pretend to skip a flush period and add updates to them again after that.
1247        assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1248        assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1249
1250        // Flush the aggregation state, and observe that we have two zero-value counters for the flush period we
1251        // skipped, but that we see them appear again in the fourth flush period.
1252        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1253        assert_eq!(flushed_metrics.len(), 2);
1254        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0, bucket_ts(4) => 1.0]);
1255        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0, bucket_ts(4) => 2.0]);
1256
1257        // Now we'll skip multiple flush periods and ensure that we emit zero-value counters up until the point they
1258        // expire. As our zero-value counter expiration is 20 seconds, this is two flush periods, so we skip by three
1259        // flush periods, and we should only see the counters emitted for the first two.
1260        let flushed_metrics = get_flushed_metrics(flush_ts(7), &mut state).await;
1261        assert_eq!(flushed_metrics.len(), 2);
1262        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1263        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1264    }
1265
1266    #[tokio::test]
1267    async fn merge_identical_timestamped_values_on_flush() {
1268        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1269        let mut state = AggregationState::new(
1270            BUCKET_WIDTH_SECS,
1271            10,
1272            COUNTER_EXPIRE,
1273            HistogramConfiguration::default(),
1274            Telemetry::noop(),
1275        );
1276
1277        // Create one multi-value counter, and insert it.
1278        let input_metric = Metric::counter("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1279
1280        assert!(state.insert(insert_ts(1), input_metric.clone()));
1281
1282        // Flush the aggregation state, and observe the metric is present _and_ that we've properly merged all of the
1283        // values within the same timestamp.
1284        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1285        assert_eq!(flushed_metrics.len(), 1);
1286        assert_flushed_scalar_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => 15.0]);
1287    }
1288
1289    #[tokio::test]
1290    async fn histogram_statistics() {
1291        // We're testing that we properly emit individual metrics (min, max, sum, etc) for a histogram.
1292        let hist_config = HistogramConfiguration::from_statistics(
1293            &[
1294                HistogramStatistic::Count,
1295                HistogramStatistic::Sum,
1296                HistogramStatistic::Percentile {
1297                    q: 0.5,
1298                    suffix: "p50".into(),
1299                },
1300            ],
1301            false,
1302            "".into(),
1303        );
1304        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1305
1306        // Create one multi-value histogram and insert it.
1307        let input_metric = Metric::histogram("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1308        assert!(state.insert(insert_ts(1), input_metric.clone()));
1309
1310        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1311        // the form of three metrics: count, sum, and p50.
1312        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1313        assert_eq!(flushed_metrics.len(), 3);
1314
1315        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1316        // matter here, but we do need a `Metric` for it to compare the context to.
1317        let count_metric = Metric::rate("metric1.count", 0.0, Duration::from_secs(BUCKET_WIDTH_SECS.get()));
1318        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1319        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1320
1321        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1322        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1323        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[0], [bucket_ts(1) => 5.0]);
1324        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[1], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1325        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[2], [bucket_ts(1) => 15.0]);
1326    }
1327
1328    #[tokio::test]
1329    async fn histogram_statistics_unit_propagation() {
1330        // We're testing that the unit from the input histogram metadata propagates to all flushed output metrics.
1331        let hist_config = HistogramConfiguration::from_statistics(
1332            &[
1333                HistogramStatistic::Count,
1334                HistogramStatistic::Sum,
1335                HistogramStatistic::Percentile {
1336                    q: 0.5,
1337                    suffix: "p50".into(),
1338                },
1339            ],
1340            false,
1341            "".into(),
1342        );
1343        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1344
1345        // Build a histogram with unit = "millisecond", simulating what arrives from a DogStatsD `ms` metric.
1346        let context = Context::from_static_parts("metric1", &[]);
1347        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
1348        let input_metric = Metric::from_parts(
1349            context,
1350            MetricValues::histogram([1.0_f64, 2.0, 3.0, 4.0, 5.0]),
1351            metadata,
1352        );
1353        assert!(state.insert(insert_ts(1), input_metric));
1354
1355        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1356        assert_eq!(flushed_metrics.len(), 3);
1357
1358        // Counts are dimensionless: the `.count` series must drop the unit while all other
1359        // aggregate series carry the unit from the input histogram.
1360        for metric in &flushed_metrics {
1361            let name = metric.context().name();
1362            if name.ends_with(".count") {
1363                assert_eq!(
1364                    metric.metadata().unit(),
1365                    None,
1366                    "flushed metric '{}' should be dimensionless",
1367                    name
1368                );
1369            } else {
1370                assert_eq!(
1371                    metric.metadata().unit(),
1372                    Some("millisecond"),
1373                    "flushed metric '{}' should carry unit='millisecond'",
1374                    name
1375                );
1376            }
1377        }
1378    }
1379
1380    #[tokio::test]
1381    async fn distributions() {
1382        // We're testing that we pass through distributions untouched.
1383        let mut state = AggregationState::new(
1384            BUCKET_WIDTH_SECS,
1385            10,
1386            COUNTER_EXPIRE,
1387            HistogramConfiguration::default(),
1388            Telemetry::noop(),
1389        );
1390
1391        // Create one multi-value distribution, with server-side aggregation, and insert it.
1392        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1393        let input_metric = Metric::distribution("metric1", &values[..]);
1394
1395        assert!(state.insert(insert_ts(1), input_metric.clone()));
1396
1397        // Flush the aggregation state, and observe that we've emitted the original distribution.
1398        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1399        assert_eq!(flushed_metrics.len(), 1);
1400
1401        assert_flushed_distribution_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1402    }
1403
1404    #[tokio::test]
1405    async fn histogram_copy_to_distribution() {
1406        let hist_config = HistogramConfiguration::from_statistics(
1407            &[
1408                HistogramStatistic::Count,
1409                HistogramStatistic::Sum,
1410                HistogramStatistic::Percentile {
1411                    q: 0.5,
1412                    suffix: "p50".into(),
1413                },
1414            ],
1415            true,
1416            "dist_prefix.".into(),
1417        );
1418        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1419
1420        // Create one multi-value histogram and insert it.
1421        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1422        let input_metric = Metric::histogram("metric1", values);
1423        assert!(state.insert(insert_ts(1), input_metric.clone()));
1424
1425        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1426        // the form of three metrics: count, sum, and p50 as well as the additional metric from copying the histogram.
1427        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1428        assert_eq!(flushed_metrics.len(), 4);
1429
1430        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1431        // matter here, but we do need a `Metric` for it to compare the context to.
1432        let count_metric = Metric::rate("metric1.count", 0.0, BUCKET_WIDTH);
1433        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1434        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1435        let expected_distribution = Metric::distribution("dist_prefix.metric1", &values[..]);
1436
1437        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1438        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1439        assert_flushed_distribution_metric!(expected_distribution, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1440        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[1], [bucket_ts(1) => 5.0]);
1441        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[2], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1442        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[3], [bucket_ts(1) => 15.0]);
1443    }
1444
1445    #[tokio::test]
1446    async fn nonaggregated_counters_to_rate() {
1447        let counter_value = 42.0;
1448
1449        // Create a basic aggregation state.
1450        let mut state = AggregationState::new(
1451            BUCKET_WIDTH_SECS,
1452            10,
1453            COUNTER_EXPIRE,
1454            HistogramConfiguration::default(),
1455            Telemetry::noop(),
1456        );
1457
1458        // Create a simple non-aggregated counter, and insert it.
1459        let input_metric = Metric::counter("metric1", counter_value);
1460        assert!(state.insert(insert_ts(1), input_metric.clone()));
1461
1462        // Flush the aggregation state, and observe that we've emitted the expected counter and that it has the right
1463        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
1464        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1465        assert_eq!(flushed_metrics.len(), 1);
1466        let flushed_metric = &flushed_metrics[0];
1467
1468        assert_flushed_scalar_metric!(&input_metric, flushed_metric, [bucket_ts(1) => counter_value]);
1469        assert_eq!(flushed_metric.values().as_str(), "rate");
1470    }
1471
1472    #[tokio::test]
1473    async fn preaggregated_counters_to_rate() {
1474        let counter_value = 42.0;
1475        let timestamp = 123456;
1476
1477        // Create a basic passthrough batcher and forwarder.
1478        let mut batcher = PassthroughBatcher::new(Duration::from_nanos(1), BUCKET_WIDTH_SECS, Telemetry::noop()).await;
1479        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
1480
1481        // Create a simple pre-aggregated counter, and batch it.
1482        let input_metric = Metric::counter("metric1", (timestamp, counter_value));
1483        batcher.push_metric(input_metric.clone(), &dispatcher).await;
1484
1485        // Flush the batcher, and observe that we've emitted the expected counter and that it has the right
1486        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
1487        batcher.try_flush(&dispatcher).await;
1488
1489        let mut flushed_metrics = dispatcher_receiver.collect_next();
1490        assert_eq!(flushed_metrics.len(), 1);
1491        assert_eq!(
1492            Metric::rate("metric1", (timestamp, counter_value), BUCKET_WIDTH),
1493            flushed_metrics.remove(0)
1494        );
1495    }
1496
1497    #[tokio::test]
1498    async fn telemetry() {
1499        // TODO: We don't check `component_events_dropped_total` or `aggregate_passthrough_metrics_total` here as
1500        // they're set directly in the aggregate component future rather than `AggregationState`, which is harder to
1501        // drive overall and would have required even more boilerplate.
1502        //
1503        // Leaving that as a future improvement.
1504
1505        let recorder = TestRecorder::default();
1506        let _local = metrics::set_default_local_recorder(&recorder);
1507
1508        let builder = MetricsBuilder::default();
1509        let telemetry = Telemetry::new(&builder);
1510
1511        let mut state = AggregationState::new(
1512            BUCKET_WIDTH_SECS,
1513            2,
1514            COUNTER_EXPIRE,
1515            HistogramConfiguration::default(),
1516            telemetry,
1517        );
1518
1519        // Make sure our telemetry is registered at default values.
1520        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(0.0));
1521        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
1522        assert_eq!(
1523            recorder.counter(("component_events_dropped_total", &[("intentional", "true")])),
1524            Some(0)
1525        );
1526        for metric_type in &["counter", "gauge", "rate", "set", "histogram", "distribution"] {
1527            assert_eq!(
1528                recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", *metric_type)])),
1529                Some(0.0)
1530            );
1531        }
1532
1533        // Insert a counter with a non-timestamped value.
1534        assert!(state.insert(insert_ts(1), Metric::counter("metric1", 42.0)));
1535        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
1536        assert_eq!(
1537            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1538            Some(1.0)
1539        );
1540        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
1541
1542        // Insert a gauge with a timestamped value.
1543        assert!(state.insert(insert_ts(1), Metric::gauge("metric2", (insert_ts(1), 42.0))));
1544        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
1545        assert_eq!(
1546            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
1547            Some(1.0)
1548        );
1549
1550        // We've reached our context limit at this point, so the next metric should not be inserted.
1551        assert!(!state.insert(insert_ts(1), Metric::counter("metric3", 42.0)));
1552        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
1553        assert_eq!(
1554            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1555            Some(1.0)
1556        );
1557
1558        // Now let's flush the state which should flush the gauge entirely, reducing the context count, but not flush
1559        // the counter, since it'll be in zero-value mode.
1560        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1561        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
1562        assert_eq!(
1563            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
1564            Some(1.0)
1565        );
1566        assert_eq!(
1567            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
1568            Some(0.0)
1569        );
1570    }
1571}
1572
1573#[cfg(test)]
1574mod config_smoke {
1575    use datadog_agent_config_testing::config_registry::structs;
1576    use datadog_agent_config_testing::run_config_smoke_tests;
1577    use serde_json::json;
1578
1579    use super::AggregateConfiguration;
1580    use crate::config::{DatadogRemapper, KEY_ALIASES};
1581
1582    #[tokio::test]
1583    async fn smoke_test() {
1584        // Duration fields serialize as {secs, nanos}. We inject whole-second values so the nanos
1585        // sub-fields are always 0 — they are not independently configurable.
1586        run_config_smoke_tests(
1587            structs::AGGREGATE_CONFIGURATION,
1588            &[
1589                "aggregate_flush_interval.nanos",
1590                "aggregate_passthrough_idle_flush_timeout.nanos",
1591            ],
1592            json!({}),
1593            |cfg| {
1594                cfg.as_typed::<AggregateConfiguration>()
1595                    .expect("AggregateConfiguration should deserialize")
1596            },
1597            KEY_ALIASES,
1598            DatadogRemapper::new,
1599        )
1600        .await
1601    }
1602}