Skip to main content

saluki_components/transforms/aggregate/
mod.rs

1use std::{
2    future::pending,
3    num::NonZeroU64,
4    sync::Mutex,
5    time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use ddsketch::DDSketch;
10use hashbrown::{hash_map::Entry, HashMap};
11use saluki_common::time::get_unix_timestamp;
12use saluki_core::{
13    accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr},
14    components::{transforms::*, BuildContext},
15    data_model::event::{
16        metric::{context::Context, *},
17        Event, EventType,
18    },
19    observability::ComponentMetricsExt as _,
20    topology::{interconnect::BufferedDispatcher, EventsBuffer, OutputDefinition},
21};
22use saluki_error::{generic_error, GenericError};
23use saluki_metrics::MetricsBuilder;
24use smallvec::SmallVec;
25use stringtheory::MetaString;
26use tokio::{
27    select,
28    sync::{mpsc, oneshot},
29    time::interval_at,
30};
31use tracing::{debug, error, info, trace, warn};
32
33mod telemetry;
34use self::telemetry::{FlushCounts, Telemetry};
35
36mod config;
37pub use self::config::HistogramConfiguration;
38use self::config::HistogramStatistic;
39
40const CONTEXT_SNAPSHOT_REQUEST_CHANNEL_CAPACITY: usize = 1;
41
42/// The shape of metric values retained by the aggregate transform.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum AggregateMetricType {
45    /// Counter values.
46    Counter,
47
48    /// Rate values.
49    Rate,
50
51    /// Gauge values.
52    Gauge,
53
54    /// Set values.
55    Set,
56
57    /// Histogram values.
58    Histogram,
59
60    /// Distribution values.
61    Distribution,
62}
63
64impl From<&MetricValues> for AggregateMetricType {
65    fn from(values: &MetricValues) -> Self {
66        match values {
67            MetricValues::Counter(_) => Self::Counter,
68            MetricValues::Rate(_, _) => Self::Rate,
69            MetricValues::Gauge(_) => Self::Gauge,
70            MetricValues::Set(_) => Self::Set,
71            MetricValues::Histogram(_) => Self::Histogram,
72            MetricValues::Distribution(_) => Self::Distribution,
73        }
74    }
75}
76
77/// A retained metric context and its small aggregation metadata.
78///
79/// Cloning an entry shares the underlying context name and tags rather than copying their contents.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct AggregateContextSnapshotEntry {
82    context: Context,
83    metric_type: AggregateMetricType,
84    unit: MetaString,
85}
86
87impl AggregateContextSnapshotEntry {
88    /// Returns the retained metric context.
89    pub fn context(&self) -> &Context {
90        &self.context
91    }
92
93    /// Returns the shape of the retained metric values.
94    pub fn metric_type(&self) -> AggregateMetricType {
95        self.metric_type
96    }
97
98    /// Returns the unit attached to the retained metric values, if one is set.
99    pub fn unit(&self) -> Option<&str> {
100        if self.unit.is_empty() {
101            None
102        } else {
103            Some(&self.unit)
104        }
105    }
106
107    /// Creates a snapshot entry for downstream test and benchmark fixtures.
108    #[cfg(any(test, feature = "test-util"))]
109    pub fn for_test(context: Context, metric_type: AggregateMetricType, unit: MetaString) -> Self {
110        Self {
111            context,
112            metric_type,
113            unit,
114        }
115    }
116}
117
118type AggregateContextSnapshot = Vec<AggregateContextSnapshotEntry>;
119type AggregateContextSnapshotRequest = oneshot::Sender<AggregateContextSnapshot>;
120type AggregateContextSnapshotRequestReceiver = mpsc::Receiver<AggregateContextSnapshotRequest>;
121
122/// A handle for requesting retained-context snapshots from an aggregate transform.
123///
124/// Snapshot construction runs on the aggregate owner task. The returned entries contain shared context handles and
125/// small metric metadata, allowing callers to perform heavier processing after the owner resumes ingestion.
126#[derive(Clone, Debug)]
127pub struct AggregateContextSnapshotHandle {
128    requests: mpsc::Sender<AggregateContextSnapshotRequest>,
129}
130
131impl AggregateContextSnapshotHandle {
132    /// Requests the aggregate transform's current retained contexts.
133    ///
134    /// The returned snapshot uses O(context count) memory. After delivery, the caller owns this memory, so callers that
135    /// retain snapshots must include their retained size in their own memory accounting.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the aggregate owner is unavailable or stops before responding.
140    pub async fn snapshot(&self) -> Result<Vec<AggregateContextSnapshotEntry>, GenericError> {
141        let (response_tx, response_rx) = oneshot::channel();
142        self.requests
143            .send(response_tx)
144            .await
145            .map_err(|_| generic_error!("aggregate context snapshot owner is unavailable"))?;
146
147        response_rx
148            .await
149            .map_err(|_| generic_error!("aggregate context snapshot owner stopped before responding"))
150    }
151}
152
153/// Creates a retained-context snapshot handle and its owner-side receiver.
154///
155/// The receiver belongs to the aggregate transform, and is supplied to it through
156/// [`AggregateConfiguration::context_snapshot_receiver`]. The handle belongs to whoever requests snapshots, and can be
157/// cloned to let several callers request them.
158pub fn aggregate_context_snapshot_channel() -> (AggregateContextSnapshotHandle, AggregateContextSnapshotReceiver) {
159    let (requests, receiver) = mpsc::channel(CONTEXT_SNAPSHOT_REQUEST_CHANNEL_CAPACITY);
160    (
161        AggregateContextSnapshotHandle { requests },
162        AggregateContextSnapshotReceiver {
163            receiver: Mutex::new(Some(receiver)),
164        },
165    )
166}
167
168#[inline]
169fn send_context_snapshot_if_open(
170    response: AggregateContextSnapshotRequest, build_snapshot: impl FnOnce() -> AggregateContextSnapshot,
171) {
172    if response.is_closed() {
173        return;
174    }
175
176    let _ = response.send(build_snapshot());
177}
178
179/// An accepted retained-context snapshot request for test fixtures.
180#[cfg(any(test, feature = "test-util"))]
181pub struct AggregateContextSnapshotPendingResponse {
182    response: AggregateContextSnapshotRequest,
183}
184
185#[cfg(any(test, feature = "test-util"))]
186impl AggregateContextSnapshotPendingResponse {
187    /// Responds to the accepted snapshot request with the supplied entries.
188    ///
189    /// If the requester was canceled after the request was accepted, the response is discarded.
190    pub fn respond(self, snapshot: Vec<AggregateContextSnapshotEntry>) {
191        let _ = self.response.send(snapshot);
192    }
193}
194
195/// A responder for retained-context snapshot test fixtures.
196#[cfg(any(test, feature = "test-util"))]
197pub struct AggregateContextSnapshotResponder {
198    receiver: AggregateContextSnapshotRequestReceiver,
199}
200
201#[cfg(any(test, feature = "test-util"))]
202impl AggregateContextSnapshotResponder {
203    /// Waits for one snapshot request and responds with the supplied entries.
204    ///
205    /// A canceled requester is treated as a successful no-op delivery.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the request channel closes before a request arrives.
210    pub async fn respond(&mut self, snapshot: Vec<AggregateContextSnapshotEntry>) -> Result<(), GenericError> {
211        self.receive().await?.respond(snapshot);
212        Ok(())
213    }
214
215    /// Waits for one snapshot request and returns its pending response.
216    ///
217    /// The returned response lets tests deterministically control whether the owner responds, stops, or outlives a
218    /// canceled requester after accepting the request.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the request channel closes before a request arrives.
223    pub async fn receive(&mut self) -> Result<AggregateContextSnapshotPendingResponse, GenericError> {
224        let response = self
225            .receiver
226            .recv()
227            .await
228            .ok_or_else(|| generic_error!("aggregate context snapshot request channel is closed"))?;
229        Ok(AggregateContextSnapshotPendingResponse { response })
230    }
231
232    /// Waits for one snapshot request and stops without responding.
233    ///
234    /// This accepts the request from the owner channel before dropping its one-shot response sender, allowing tests to
235    /// distinguish an owner that stops mid-request from an owner whose request channel is unavailable.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the request channel closes before a request arrives.
240    pub async fn stop_after_receiving(&mut self) -> Result<(), GenericError> {
241        drop(self.receive().await?);
242        Ok(())
243    }
244}
245
246/// Creates a retained-context snapshot handle and owner-side responder for tests.
247#[cfg(any(test, feature = "test-util"))]
248pub fn aggregate_context_snapshot_channel_for_test(
249) -> (AggregateContextSnapshotHandle, AggregateContextSnapshotResponder) {
250    let (requests, receiver) = mpsc::channel(CONTEXT_SNAPSHOT_REQUEST_CHANNEL_CAPACITY);
251    (
252        AggregateContextSnapshotHandle { requests },
253        AggregateContextSnapshotResponder { receiver },
254    )
255}
256
257/// The owner side of a retained-context snapshot channel.
258///
259/// The aggregate transform takes the receiver out of this holder when it is built. A receiver can only be taken once,
260/// so an [`AggregateConfiguration`] holding one can only build a single transform.
261pub struct AggregateContextSnapshotReceiver {
262    receiver: Mutex<Option<AggregateContextSnapshotRequestReceiver>>,
263}
264
265impl AggregateContextSnapshotReceiver {
266    fn take_receiver(&self) -> Result<AggregateContextSnapshotRequestReceiver, GenericError> {
267        let mut receiver = self
268            .receiver
269            .lock()
270            .map_err(|_| generic_error!("aggregate context snapshot receiver lock is poisoned"))?;
271        receiver
272            .take()
273            .ok_or_else(|| generic_error!("aggregate context snapshot receiver has already been taken"))
274    }
275}
276
277/// Aggregate transform.
278///
279/// Aggregates metrics into fixed-size windows, flushing them at a regular interval.
280///
281/// ## Zero-value counters
282///
283/// When metrics are aggregated and then flushed, they're typically removed entirely from the aggregation state. Unless
284/// they're updated again, they won't be emitted again. However, for counters, a slightly different approach is
285/// taken by tracking "zero-value" counters.
286///
287/// Counters are aggregated and flushed normally. However, when flushed, counters are added to a list of "zero-value"
288/// counters, and if those counters aren't updated again, the transform emits a copy of the counter with a value of
289/// zero. It does this until the counter is updated again, or the zero-value counter expires (no updates), whichever
290/// comes first.
291///
292/// This provides a continuity in the output of a counter, from the perspective of a downstream system, when counters
293/// are otherwise sparse. The expiration period is configurable, and allows a trade-off in how sparse/infrequent the
294/// updates to counters can be versus how long it takes for counters that don't exist anymore to actually cease to be
295/// emitted.
296pub struct AggregateConfiguration {
297    /// Size of the aggregation window, in seconds.
298    ///
299    /// Metrics are aggregated into fixed-size windows, such that all updates to the same metric within a window are
300    /// aggregated into a single metric. The window size controls how efficiently metrics are aggregated, and in turn,
301    /// how many data points are emitted downstream.
302    pub window_duration_seconds: NonZeroU64,
303
304    /// How often to flush buckets.
305    ///
306    /// This represents a trade-off between the savings in network bandwidth (sending fewer requests to downstream
307    /// systems, etc) and the frequency of updates (how often updates to a metric are emitted).
308    pub primary_flush_interval: Duration,
309
310    /// Maximum number of contexts to aggregate per window.
311    ///
312    /// A context is the unique combination of a metric name and its set of tags. For example,
313    /// `metric.name.here{tag1=A,tag2=B}` represents a single context, and would be different than
314    /// `metric.name.here{tag1=A,tag2=C}`.
315    ///
316    /// When the maximum number of contexts is reached in the current aggregation window, additional metrics are dropped
317    /// until the next window starts.
318    pub context_limit: usize,
319
320    /// Whether to flush open buckets when stopping the transform.
321    ///
322    /// Normally, open buckets (a bucket whose end hasn't yet occurred) aren't flushed when the transform is stopped.
323    /// This is done to avoid the chance of flushing a partial window, restarting the process, and then flushing the
324    /// same window again. Downstream systems sometimes can't cope with this gracefully, as there is no way to
325    /// determine that it's an incremental update, and so they treat it as an absolute update, overwriting the
326    /// previously flushed value.
327    ///
328    /// In cases where flushing all outstanding data is paramount, this can be enabled.
329    pub flush_open_windows: bool,
330
331    /// How long to keep idle counters alive after they've been flushed, in seconds.
332    ///
333    /// When metrics are flushed, they're removed from the aggregation state. However, if a counter expiration is set,
334    /// counters will be kept alive in an "idle" state. For as long as a counter is idle, but not yet expired, a zero
335    /// value will be emitted for it during each flush. This allows more gracefully handling sparse counters, where
336    /// updates are infrequent but leaving gaps in the time series would be undesirable from a user experience
337    /// perspective.
338    ///
339    /// After a counter has been idle (no updates) for longer than the expiry period, it will be completely removed and
340    /// no further zero values will be emitted.
341    ///
342    /// A value of `0`, or `None`, disables idle counter keep-alive.
343    pub counter_expiry_seconds: Option<u64>,
344
345    /// Statistics to calculate over histograms, and how to copy them to distributions.
346    pub hist_config: HistogramConfiguration,
347
348    /// Owner side of the channel used to serve retained-context snapshot requests.
349    ///
350    /// This is runtime wiring rather than configuration: it carries no settings, and is created by the caller with
351    /// [`aggregate_context_snapshot_channel`] so that the caller keeps the matching handle.
352    pub context_snapshot_receiver: AggregateContextSnapshotReceiver,
353}
354
355#[cfg(test)]
356impl AggregateConfiguration {
357    /// Creates a fixture configuration, for tests that exercise aggregation behavior rather than configuration.
358    ///
359    /// The snapshot handle paired with the configuration's receiver is dropped. Tests that request snapshots build the
360    /// channel with [`aggregate_context_snapshot_channel`] and override `context_snapshot_receiver`.
361    fn for_test() -> Self {
362        let (_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
363
364        Self {
365            window_duration_seconds: NonZeroU64::new(10).expect("not zero"),
366            primary_flush_interval: Duration::from_secs(15),
367            context_limit: 1_000_000,
368            flush_open_windows: false,
369            counter_expiry_seconds: Some(300),
370            hist_config: HistogramConfiguration::default(),
371            context_snapshot_receiver,
372        }
373    }
374}
375
376#[async_trait]
377impl TransformBuilder for AggregateConfiguration {
378    async fn build(&self, context: BuildContext) -> Result<Box<dyn Transform + Send>, GenericError> {
379        let context_snapshot_requests = self.context_snapshot_receiver.take_receiver()?;
380        let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
381        let telemetry = Telemetry::new(&metrics_builder);
382
383        let state = AggregationState::new(
384            self.window_duration_seconds,
385            self.context_limit,
386            self.counter_expiry_seconds.filter(|s| *s != 0).map(Duration::from_secs),
387            self.hist_config.clone(),
388            telemetry.clone(),
389        );
390
391        Ok(Box::new(Aggregate {
392            state,
393            telemetry,
394            primary_flush_interval: self.primary_flush_interval,
395            flush_open_windows: self.flush_open_windows,
396            context_snapshot_requests: Some(context_snapshot_requests),
397        }))
398    }
399
400    fn input_event_type(&self) -> EventType {
401        EventType::Metric
402    }
403
404    fn outputs(&self) -> &[OutputDefinition<EventType>] {
405        static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
406        OUTPUTS
407    }
408}
409
410impl MemoryBounds for AggregateConfiguration {
411    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
412        // TODO: While we account for the aggregation state map accurately, what we don't currently account for is the
413        // fact that a metric could have multiple distinct values. For the common pipeline of metrics in via DogStatsD,
414        // this generally shouldn't be a problem because the values don't have a timestamp, so they get aggregated into
415        // the same bucket, leading to two values per `MetricValues` at most, which is already baked into the size of
416        // `MetricValues` due to using `SmallVec`.
417        //
418        // However, there could be many more values in a single metric, and we don't account for that.
419
420        builder
421            .minimum()
422            // Capture the size of the heap allocation when the component is built.
423            .with_single_value::<Aggregate>("component struct");
424        builder
425            .firm()
426            // Account for the aggregation state map, where we map contexts to the merged metric.
427            .with_expr(UsageExpr::product(
428                "aggregation state map",
429                UsageExpr::sum(
430                    "context map entry",
431                    UsageExpr::struct_size::<Context>("context"),
432                    UsageExpr::struct_size::<AggregatedMetric>("aggregated metric"),
433                ),
434                UsageExpr::config("aggregate_context_limit", self.context_limit),
435            ))
436            // A snapshot is constructed while the aggregation state remains live, so its peak allocation is additive.
437            .with_expr(UsageExpr::product(
438                "retained context snapshot",
439                UsageExpr::struct_size::<AggregateContextSnapshotEntry>("snapshot entry"),
440                UsageExpr::config("aggregate_context_limit", self.context_limit),
441            ));
442    }
443}
444
445pub struct Aggregate {
446    state: AggregationState,
447    telemetry: Telemetry,
448    primary_flush_interval: Duration,
449    flush_open_windows: bool,
450    context_snapshot_requests: Option<AggregateContextSnapshotRequestReceiver>,
451}
452
453#[async_trait]
454impl Transform for Aggregate {
455    async fn run(mut self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
456        let mut health = context.take_health_handle();
457
458        let mut primary_flush = interval_at(
459            tokio::time::Instant::now() + self.primary_flush_interval,
460            self.primary_flush_interval,
461        );
462        let mut final_primary_flush = false;
463
464        health.mark_ready();
465        debug!("Aggregation transform started.");
466
467        loop {
468            select! {
469                _ = health.live() => continue,
470                _ = primary_flush.tick() => {
471                    // We've reached the end of the current window. Flush our aggregation state and forward the metrics
472                    // onwards. Regardless of whether any metrics were aggregated, we always update the aggregation
473                    // state to track the start time of the current aggregation window.
474                    if !self.state.is_empty() {
475                        debug!("Flushing aggregated metrics...");
476
477                        let should_flush_open_windows = final_primary_flush && self.flush_open_windows;
478
479                        // Remember if the context limit had been surpassed before this flush.
480                        let was_breached = self.state.context_limit_breached();
481
482                        let mut dispatcher = context.dispatcher().buffered().expect("default output should always exist");
483                        let flush = async {
484                            let flush_start = Instant::now();
485
486                            if let Err(e) = self.state.flush(get_unix_timestamp(), should_flush_open_windows, &mut dispatcher).await {
487                                error!(error = %e, "Failed to flush aggregation state.");
488                            }
489
490                            self.telemetry.increment_flushes();
491
492                            // If flush recovered us from a breach, log the recovery.
493                            if was_breached && !self.state.context_limit_breached() {
494                                info!("Context limit no longer exceeded, metrics are being accepted again.");
495                            }
496
497                            match dispatcher.flush().await {
498                                Ok(aggregated_events) => debug!(aggregated_events, "Dispatched events."),
499                                Err(e) => error!(error = %e, "Failed to flush aggregated events."),
500                            }
501
502                            self.telemetry.record_last_flush_duration(flush_start.elapsed());
503                        };
504                        tokio::pin!(flush);
505
506                        // A flush can wait a long time for space in a full downstream channel. Keep answering liveness
507                        // probes while it waits. Probes go first so that a waiting probe is seen on every wakeup, even
508                        // when the flush would use up the task's cooperative budget.
509                        loop {
510                            select! {
511                                biased;
512                                _ = health.live() => {},
513                                _ = &mut flush => break,
514                            }
515                        }
516                    }
517
518                    // If this is the final flush, we break out of the loop.
519                    if final_primary_flush {
520                        debug!("All aggregation complete.");
521                        break
522                    }
523                },
524                snapshot_request = receive_context_snapshot_request(&mut self.context_snapshot_requests) => {
525                    match snapshot_request {
526                        Some(response) => {
527                            send_context_snapshot_if_open(response, || self.state.snapshot_contexts());
528                        }
529                        None => self.context_snapshot_requests = None,
530                    }
531                },
532                maybe_events = context.events().next(), if !final_primary_flush => match maybe_events {
533                    Some(events) => {
534                        trace!(events_len = events.len(), "Received events.");
535
536                        let current_time = get_unix_timestamp();
537
538                        for event in events {
539                            if let Some(metric) = event.try_into_metric() {
540                                let was_breached = self.state.context_limit_breached();
541                                if !self.state.insert(current_time, metric) {
542                                    trace!("Dropping metric due to context limit.");
543                                    if !was_breached {
544                                        // First drop since the last recovery — emit a single warning.
545                                        warn!(context_limit = self.state.context_limit, "Context limit reached, \
546                                        dropping metrics. Consider increasing `aggregate_context_limit`.");
547                                    }
548                                    self.telemetry.increment_events_dropped();
549                                }
550                            }
551                        }
552                    },
553                    None => {
554                        // We've reached the end of our input stream, so mark ourselves for a final flush and reset the
555                        // interval so it ticks immediately on the next loop iteration.
556                        final_primary_flush = true;
557                        primary_flush.reset_immediately();
558
559                        debug!("Aggregation transform stopping...");
560                    }
561                },
562            }
563        }
564
565        debug!("Aggregation transform stopped.");
566
567        Ok(())
568    }
569}
570
571async fn receive_context_snapshot_request(
572    receiver: &mut Option<AggregateContextSnapshotRequestReceiver>,
573) -> Option<AggregateContextSnapshotRequest> {
574    match receiver {
575        Some(receiver) => receiver.recv().await,
576        None => pending().await,
577    }
578}
579
580#[derive(Clone)]
581struct AggregatedMetric {
582    values: MetricValues,
583    metadata: MetricMetadata,
584    last_seen: u64,
585}
586
587struct AggregationState {
588    contexts: HashMap<Context, AggregatedMetric, foldhash::quality::RandomState>,
589    contexts_remove_buf: Vec<Context>,
590    context_limit: usize,
591    bucket_width_secs: NonZeroU64,
592    counter_expire_secs: Option<NonZeroU64>,
593    last_flush: u64,
594    hist_config: HistogramConfiguration,
595    telemetry: Telemetry,
596    /// Tracks whether the context limit has been breached. Starts out as `false`. Set to `true` on the first dropped
597    /// metric. Reset to `false` when the context count drops below the limit during flush.
598    context_limit_breached: bool,
599}
600
601impl AggregationState {
602    fn new(
603        bucket_width_secs: NonZeroU64, context_limit: usize, counter_expiration: Option<Duration>,
604        hist_config: HistogramConfiguration, telemetry: Telemetry,
605    ) -> Self {
606        let counter_expire_secs = counter_expiration.map(|d| d.as_secs()).and_then(NonZeroU64::new);
607
608        Self {
609            contexts: HashMap::default(),
610            contexts_remove_buf: Vec::new(),
611            context_limit,
612            bucket_width_secs,
613            counter_expire_secs,
614            last_flush: 0,
615            hist_config,
616            telemetry,
617            context_limit_breached: false,
618        }
619    }
620
621    fn is_empty(&self) -> bool {
622        self.contexts.is_empty()
623    }
624
625    fn snapshot_contexts(&self) -> Vec<AggregateContextSnapshotEntry> {
626        let mut snapshot = Vec::with_capacity(self.contexts.len());
627        for (context, aggregated) in &self.contexts {
628            snapshot.push(AggregateContextSnapshotEntry {
629                context: context.clone(),
630                metric_type: AggregateMetricType::from(&aggregated.values),
631                unit: aggregated.metadata.unit.clone(),
632            });
633        }
634        snapshot
635    }
636
637    fn insert(&mut self, timestamp: u64, metric: Metric) -> bool {
638        // If we haven't seen this context yet, and it would put us over the limit to insert it, then return early.
639        if !self.contexts.contains_key(metric.context()) && self.contexts.len() >= self.context_limit {
640            self.context_limit_breached = true;
641            return false;
642        }
643
644        // An empty state has no counters to keep alive. The owner skips flushing an empty state, so its last flush can
645        // be arbitrarily old; carrying that timestamp into a newly inserted counter would synthesize zero buckets from
646        // before the counter existed.
647        if self.contexts.is_empty() {
648            self.last_flush = 0;
649        }
650
651        let (context, mut values, metadata) = metric.into_parts();
652
653        // Collapse all non-timestamped values into a single timestamped value.
654        //
655        // We do this pre-aggregation step because unless we're merging into an existing context, we'll end up with
656        // however many values were in the original metric instead of full aggregated values.
657        let bucket_ts = align_to_bucket_start(timestamp, self.bucket_width_secs);
658        values.collapse_non_timestamped(bucket_ts);
659
660        trace!(
661            bucket_ts,
662            kind = values.as_str(),
663            "Inserting metric into aggregation state."
664        );
665
666        // If we're already tracking this context, update the last seen time and merge the new values into the existing
667        // values. Otherwise, create a new entry.
668        match self.contexts.entry(context) {
669            Entry::Occupied(mut entry) => {
670                let aggregated = entry.get_mut();
671
672                // We ignore metadata changes within a flush interval to keep things simple.
673                aggregated.last_seen = timestamp;
674                aggregated.values.merge(values);
675            }
676            Entry::Vacant(entry) => {
677                self.telemetry.increment_contexts(entry.key(), &values);
678
679                entry.insert(AggregatedMetric {
680                    values,
681                    metadata,
682                    last_seen: timestamp,
683                });
684
685                saluki_antithesis::always_le!(
686                    self.contexts.len(),
687                    self.context_limit,
688                    "aggregate context map within context_limit",
689                    { "len": self.contexts.len(), "limit": self.context_limit }
690                );
691            }
692        }
693
694        true
695    }
696
697    async fn flush(
698        &mut self, current_time: u64, flush_open_buckets: bool, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
699    ) -> Result<(), GenericError> {
700        let bucket_width_secs = self.bucket_width_secs;
701        let counter_expire_secs = self.counter_expire_secs.map(|d| d.get()).unwrap_or(0);
702
703        // Values with a timestamp less than or equal to `split_timestamp` are flushed. Normally that's just before the
704        // start of the current bucket, so only closed buckets are flushed. When flushing open buckets, it's every value.
705        let split_timestamp = if flush_open_buckets {
706            u64::MAX
707        } else {
708            align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1)
709        };
710
711        // Calculate the buckets we need to potentially generate zero-value counters for.
712        //
713        // We only need to do this if we've flushed before, since we won't have any knowledge of which counters are idle
714        // or not until that happens.
715        let mut zero_value_buckets = SmallVec::<[(u64, MetricValues); 4]>::new();
716        if self.last_flush != 0 {
717            let start = align_to_bucket_start(self.last_flush, bucket_width_secs);
718
719            // Clock-skew guards. Bucketing reads the wall clock while the flush cadence is monotonic, so a wall-clock
720            // jump is not bounded by the flush interval. A backward jump empties the zero-value range (a silent counter
721            // gap); a forward jump makes the loop below run once per bucket across the whole jumped span — O(jump) work
722            // and allocation. Assert before the loop so a flood fails fast rather than after the damage is done.
723            saluki_antithesis::always_ge!(
724                current_time,
725                self.last_flush,
726                "aggregate flush wall-clock did not move backward",
727                { "current_time": current_time, "last_flush": self.last_flush }
728            );
729            // The 10_000 bound is generous. A default 15s flush over a 10s bucket yields one or two buckets. The bound
730            // trips only on a multi-hour wall-clock jump, never on a slow-but-sane flush.
731            saluki_antithesis::always_le!(
732                current_time.saturating_sub(self.last_flush) / bucket_width_secs.get(),
733                10_000,
734                "aggregate zero-value bucket span bounded across a flush",
735                {
736                    "current_time": current_time,
737                    "last_flush": self.last_flush,
738                    "bucket_width_secs": bucket_width_secs.get()
739                }
740            );
741
742            for bucket_start in (start..current_time).step_by(bucket_width_secs.get() as usize) {
743                if is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets) {
744                    zero_value_buckets.push((bucket_start, MetricValues::counter((bucket_start, 0.0))));
745                }
746            }
747
748            // Anti-vacuity anchor: prove the idle-counter zero-value path actually runs in some timeline.
749            saluki_antithesis::sometimes!(
750                !zero_value_buckets.is_empty(),
751                "aggregate flush generated zero-value counter buckets",
752                { "count": zero_value_buckets.len() }
753            );
754        }
755
756        // Iterate over each context we're tracking, and flush any values that are in buckets which are now closed.
757        debug!(timestamp = current_time, "Flushing buckets.");
758
759        let mut flush_counts = FlushCounts::default();
760
761        for (context, am) in self.contexts.iter_mut() {
762            // Figure out if we should remove this metric or not if it has no values in open buckets.
763            //
764            // We have a special carve-out for counters here, which we have the ability to keep alive after they are
765            // flushed, based on a configured expiration period. This allows us to continue emitting a zero value for
766            // counters when they're idle, which can make them appear "live" in downstream systems, even when they're
767            // not.
768            //
769            // This is useful for sparsely-updated counters.
770            let should_expire_if_empty = match &am.values {
771                MetricValues::Counter(..) => {
772                    saluki_antithesis::always_le!(
773                        am.last_seen,
774                        u64::MAX - counter_expire_secs,
775                        "aggregate counter expiry add does not overflow",
776                        { "last_seen": am.last_seen, "counter_expire_secs": counter_expire_secs }
777                    );
778                    counter_expire_secs == 0 || am.last_seen.saturating_add(counter_expire_secs) < current_time
779                }
780                _ => true,
781            };
782
783            // If we're dealing with a counter, we'll merge in our calculated set of zero values. We only merge in the
784            // values that represent now-closed buckets.
785            //
786            // This is also safe to do even when there are real values in those buckets since adding zero to anything is
787            // a no-op from the perspective of what we end up flushing, and it doesn't mess with the "last seen" time.
788            if let MetricValues::Counter(..) = &mut am.values {
789                let expires_at = am.last_seen.saturating_add(counter_expire_secs);
790                for (zv_bucket_start, zero_value) in &zero_value_buckets {
791                    if expires_at > *zv_bucket_start {
792                        am.values.merge(zero_value.clone());
793                    } else {
794                        // Since zero-value buckets are in order, we can break early if this bucket is past the
795                        // expiration cutoff of the counter. No other bucket will be within the expiration range.
796                        break;
797                    }
798                }
799            }
800
801            // Finally, figure out if the current metric can be removed.
802            //
803            // For any metric with values that are in open buckets, we split off the values that are in closed buckets
804            // and keep the metric alive. When all the values are in closed buckets, or there are no values, we'll
805            // remove the metric if `should_remove_if_empty` is `true`.
806            //
807            // This means we'll always remove all-closed/empty non-counter metrics, and we _may_ remove all-closed/empty
808            // counters.
809            if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
810                self.telemetry
811                    .increment_flushed(&closed_bucket_values, &mut flush_counts);
812
813                // We got some closed bucket values, so flush those out.
814                transform_and_push_metric(
815                    context.clone(),
816                    closed_bucket_values,
817                    am.metadata.clone(),
818                    bucket_width_secs,
819                    &self.hist_config,
820                    dispatcher,
821                )
822                .await?;
823            }
824
825            if am.values.is_empty() && should_expire_if_empty {
826                self.telemetry.decrement_contexts(context, &am.values);
827                self.contexts_remove_buf.push(context.clone());
828            }
829        }
830
831        // Remove any contexts that were marked as needing to be removed.
832        let contexts_len_before = self.contexts.len();
833        for context in self.contexts_remove_buf.drain(..) {
834            self.contexts.remove(&context);
835        }
836        let contexts_len_after = self.contexts.len();
837
838        let contexts_delta = contexts_len_before.saturating_sub(contexts_len_after);
839        let target_contexts_capacity = contexts_len_after.saturating_add(contexts_delta / 2);
840        self.contexts.shrink_to(target_contexts_capacity);
841
842        if self.context_limit_breached && self.contexts.len() < self.context_limit {
843            self.context_limit_breached = false;
844        }
845
846        self.last_flush = current_time;
847        self.telemetry.record_last_flush_counts(&flush_counts);
848
849        Ok(())
850    }
851
852    fn context_limit_breached(&self) -> bool {
853        self.context_limit_breached
854    }
855}
856
857async fn transform_and_push_metric(
858    context: Context, mut values: MetricValues, metadata: MetricMetadata, bucket_width_secs: NonZeroU64,
859    hist_config: &HistogramConfiguration, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
860) -> Result<(), GenericError> {
861    let bucket_width = Duration::from_secs(bucket_width_secs.get());
862
863    match values {
864        // If we're dealing with a histogram, we calculate a configured set of aggregates/percentiles from it, and emit
865        // them as individual metrics.
866        MetricValues::Histogram(ref mut points) => {
867            // Convert histogram to distribution
868            if hist_config.copy_to_distribution() {
869                let sketch_points = points
870                    .into_iter()
871                    .map(|(ts, hist)| {
872                        let mut sketch = DDSketch::default();
873                        for sample in hist.samples() {
874                            sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
875                        }
876                        (ts, sketch)
877                    })
878                    .collect::<SketchPoints>();
879                let distribution_values = MetricValues::distribution(sketch_points);
880                let metric_context = if !hist_config.copy_to_distribution_prefix().is_empty() {
881                    context.with_name(format!(
882                        "{}{}",
883                        hist_config.copy_to_distribution_prefix(),
884                        context.name()
885                    ))
886                } else {
887                    context.clone()
888                };
889                let new_metric = Metric::from_parts(metric_context, distribution_values, metadata.clone());
890                dispatcher.push(Event::Metric(new_metric)).await?;
891            }
892            // We collect our histogram points in their "summary" view, which sorts the underlying samples allowing
893            // proper quantile queries to be answered, hence our "sorted" points. We do it this way because rather than
894            // sort every time we insert, or cloning the points, we only sort when a summary view is constructed, which
895            // requires mutable access to sort the samples in-place.
896            let mut sorted_points = Vec::new();
897            for (ts, h) in points {
898                sorted_points.push((ts, h.summary_view()));
899            }
900
901            for statistic in hist_config.statistics() {
902                let new_points = sorted_points
903                    .iter()
904                    .map(|(ts, hs)| (*ts, statistic.value_from_histogram(hs)))
905                    .collect::<ScalarPoints>();
906
907                let new_values = if statistic.is_rate_statistic() {
908                    MetricValues::rate(new_points, bucket_width)
909                } else {
910                    MetricValues::gauge(new_points)
911                };
912
913                // Counts are dimensionless, so clear any unit inherited from the input histogram.
914                let new_metadata = if matches!(statistic, HistogramStatistic::Count) {
915                    metadata.clone().with_unit(MetaString::empty())
916                } else {
917                    metadata.clone()
918                };
919
920                let new_context = context.with_name(format!("{}.{}", context.name(), statistic.suffix()));
921                let new_metric = Metric::from_parts(new_context, new_values, new_metadata);
922                dispatcher.push(Event::Metric(new_metric)).await?;
923            }
924
925            Ok(())
926        }
927
928        // If we're not dealing with a histogram, then all we need to worry about is converting counters to rates before
929        // forwarding our single, aggregated metric.
930        values => {
931            let adjusted_values = counter_values_to_rate(values, bucket_width_secs);
932
933            let metric = Metric::from_parts(context, adjusted_values, metadata);
934            dispatcher.push(Event::Metric(metric)).await
935        }
936    }
937}
938
939fn counter_values_to_rate(values: MetricValues, interval_secs: NonZeroU64) -> MetricValues {
940    match values {
941        MetricValues::Counter(points) => MetricValues::rate(points, Duration::from_secs(interval_secs.get())),
942        values => values,
943    }
944}
945
946const fn align_to_bucket_start(timestamp: u64, bucket_width_secs: NonZeroU64) -> u64 {
947    timestamp - (timestamp % bucket_width_secs.get())
948}
949
950const fn is_bucket_closed(
951    current_time: u64, bucket_start: u64, bucket_width_secs: NonZeroU64, flush_open_buckets: bool,
952) -> bool {
953    // A bucket is considered "closed" if the current time is greater than the end of the bucket, or if
954    // `flush_open_buckets` is `true`.
955    //
956    // Buckets represent a half-open interval, where the start is inclusive and the end is exclusive. This means that
957    // 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
958    // 20, with the 20 excluded, or [10, 20) in interval notation. Simply put, if we have a timestamp of 10, or anything
959    // smaller than 20, we would consider it to fall within the bucket... but 20 or more would be outside of the bucket.
960    //
961    // We can also represent this visually:
962    //
963    // <--------- bucket 1 ----------> <--------- bucket 2 ----------> <--------- bucket 3 ---------->
964    // [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]
965    //
966    // We can see that each bucket is 10 seconds wide (10 elements, one for each second), and that their ends are
967    // effectively `start + width - 1`. This means that for any of these buckets to be considered "closed", the current
968    // time has to be _greater_ than `start + width - 1`. For example, if the current time is 19, then no buckets are
969    // closed, and if the current time is 29, then bucket 1 is closed but buckets 2 and 3 are still open, and if the
970    // current time is 30, then both buckets 1 and 2 are closed, but bucket 3 is still open.
971    (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
972}
973
974// TODO: One thing we ought to consider is a property test, specifically a state machine property test, where we
975// generate a randomized offset to start time from, a bucket width, flush interval, and operations, and so on... and
976// then we run it to make sure that we are always generating sequential timestamps for data points, etc.
977#[cfg(test)]
978mod tests {
979    use std::{cell::Cell, mem::size_of};
980
981    use float_cmp::ApproxEqRatio as _;
982    use saluki_core::{
983        accounting::ComponentRegistry,
984        components::{test_util::TestComponentDriver, ComponentContext},
985        data_model::tags::{Tag, TagSet},
986        support::SubsystemIdentifier,
987        topology::{interconnect::Dispatcher, EventsDispatcher, OutputName},
988    };
989    use saluki_metrics::test::TestRecorder;
990    use stringtheory::MetaString;
991    use tokio::sync::{mpsc, oneshot};
992
993    use super::config::HistogramStatistic;
994    use super::*;
995
996    const BUCKET_WIDTH_SECS: NonZeroU64 = NonZeroU64::new(10).expect("not zero");
997    const BUCKET_WIDTH: Duration = Duration::from_secs(BUCKET_WIDTH_SECS.get());
998    const COUNTER_EXPIRE_SECS: u64 = 20;
999    const COUNTER_EXPIRE: Option<Duration> = Some(Duration::from_secs(COUNTER_EXPIRE_SECS));
1000
1001    /// Gets the bucket start timestamp for the given step.
1002    const fn bucket_ts(step: u64) -> u64 {
1003        align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
1004    }
1005
1006    /// Gets the insert timestamp for the given step.
1007    const fn insert_ts(step: u64) -> u64 {
1008        (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
1009    }
1010
1011    /// Gets the flush timestamp for the given step.
1012    const fn flush_ts(step: u64) -> u64 {
1013        BUCKET_WIDTH_SECS.get() * (step + 1)
1014    }
1015
1016    struct DispatcherReceiver {
1017        receiver: mpsc::Receiver<EventsBuffer>,
1018    }
1019
1020    impl DispatcherReceiver {
1021        fn collect_next(&mut self) -> Vec<Metric> {
1022            match self.receiver.try_recv() {
1023                Ok(event_buffer) => {
1024                    let mut metrics = event_buffer
1025                        .into_iter()
1026                        .filter_map(|event| event.try_into_metric())
1027                        .collect::<Vec<Metric>>();
1028
1029                    metrics.sort_by(|a, b| a.context().name().cmp(b.context().name()));
1030                    metrics
1031                }
1032                Err(_) => Vec::new(),
1033            }
1034        }
1035    }
1036
1037    /// Constructs a basic `Dispatcher` with a fixed-size event buffer.
1038    fn build_basic_dispatcher() -> (EventsDispatcher, DispatcherReceiver) {
1039        let context = ComponentContext::test_transform("test");
1040        let mut dispatcher = Dispatcher::new(context);
1041
1042        let (buffer_tx, buffer_rx) = mpsc::channel(1);
1043        dispatcher.add_output(OutputName::Default).unwrap();
1044        dispatcher
1045            .attach_sender_to_output(&OutputName::Default, buffer_tx)
1046            .unwrap();
1047
1048        (dispatcher, DispatcherReceiver { receiver: buffer_rx })
1049    }
1050
1051    async fn get_flushed_metrics(timestamp: u64, state: &mut AggregationState) -> Vec<Metric> {
1052        get_flushed_metrics_with_open_windows(timestamp, true, state).await
1053    }
1054
1055    async fn get_flushed_metrics_with_open_windows(
1056        timestamp: u64, flush_open_buckets: bool, state: &mut AggregationState,
1057    ) -> Vec<Metric> {
1058        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
1059        let mut buffered_dispatcher = dispatcher.buffered().expect("default output should always exist");
1060
1061        // Flush the metrics to an event buffer.
1062        state
1063            .flush(timestamp, flush_open_buckets, &mut buffered_dispatcher)
1064            .await
1065            .expect("should not fail to flush aggregation state");
1066
1067        // Flush our buffered dispatcher, which should ensure that the event buffer is sent out, and then read it from the
1068        // receiver:
1069        buffered_dispatcher
1070            .flush()
1071            .await
1072            .expect("should not fail to flush buffered sender");
1073
1074        dispatcher_receiver.collect_next()
1075    }
1076
1077    macro_rules! compare_points {
1078        (scalar, $expected:expr, $actual:expr, $error_ratio:literal) => {
1079            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1080                let (expected_ts, expected_point) = expected_value;
1081                let (actual_ts, actual_point) = actual_value;
1082
1083                assert_eq!(
1084                    expected_ts, actual_ts,
1085                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1086                    idx, expected_ts, actual_ts
1087                );
1088                assert!(
1089                    expected_point.approx_eq_ratio(&actual_point, $error_ratio),
1090                    "point for value #{} does not match: {} (expected) vs {} (actual)",
1091                    idx,
1092                    expected_point,
1093                    actual_point
1094                );
1095            }
1096        };
1097        (distribution, $expected:expr, $actual:expr) => {
1098            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1099                let (expected_ts, expected_sketch) = expected_value;
1100                let (actual_ts, actual_sketch) = actual_value;
1101
1102                assert_eq!(
1103                    expected_ts, actual_ts,
1104                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1105                    idx, expected_ts, actual_ts
1106                );
1107                assert_eq!(
1108                    expected_sketch, actual_sketch,
1109                    "sketch for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1110                    idx, expected_sketch, actual_sketch
1111                );
1112            }
1113        };
1114    }
1115
1116    macro_rules! assert_flushed_scalar_metric {
1117        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1118            assert_flushed_scalar_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1119        };
1120        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1121            let actual_metric = $actual;
1122
1123            assert_eq!($original.context(), actual_metric.context(), "expected context ({}) and actual context ({}) do not match", $original.context(), actual_metric.context());
1124
1125            let expected_points = ScalarPoints::from([$(($ts, $value)),+]);
1126
1127            match actual_metric.values() {
1128                MetricValues::Counter(ref actual_points) | MetricValues::Gauge(ref actual_points) | MetricValues::Rate(ref actual_points, _) => {
1129                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1130                    compare_points!(scalar, expected_points, actual_points, $error_ratio);
1131                },
1132                _ => panic!("only counters, rates, and gauges are supported in assert_flushed_scalar_metric"),
1133            }
1134        };
1135    }
1136
1137    macro_rules! assert_flushed_distribution_metric {
1138        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1139            assert_flushed_distribution_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1140        };
1141        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1142            let actual_metric = $actual;
1143
1144            assert_eq!($original.context(), actual_metric.context());
1145
1146            match actual_metric.values() {
1147                MetricValues::Distribution(ref actual_points) => {
1148                    let expected_points = SketchPoints::from([$(($ts, $value)),+]);
1149                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1150
1151                    compare_points!(distribution, &expected_points, actual_points);
1152                },
1153                _ => panic!("only distributions are supported in assert_flushed_distribution_metric"),
1154            }
1155        };
1156    }
1157
1158    #[test]
1159    fn aggregate_metric_type_matches_every_metric_shape() {
1160        let cases = [
1161            (MetricValues::counter(1.0), AggregateMetricType::Counter),
1162            (
1163                MetricValues::rate(1.0, Duration::from_secs(10)),
1164                AggregateMetricType::Rate,
1165            ),
1166            (MetricValues::gauge(1.0), AggregateMetricType::Gauge),
1167            (MetricValues::set("value"), AggregateMetricType::Set),
1168            (MetricValues::histogram([1.0]), AggregateMetricType::Histogram),
1169            (
1170                MetricValues::distribution(&[1.0][..]),
1171                AggregateMetricType::Distribution,
1172            ),
1173        ];
1174
1175        for (values, expected) in cases {
1176            assert_eq!(AggregateMetricType::from(&values), expected);
1177        }
1178    }
1179
1180    #[tokio::test]
1181    async fn flush_records_last_flush_counts() {
1182        let recorder = TestRecorder::default();
1183        let _local = metrics::set_default_local_recorder(&recorder);
1184
1185        let builder = MetricsBuilder::default();
1186        let mut state = AggregationState::new(
1187            BUCKET_WIDTH_SECS,
1188            10,
1189            COUNTER_EXPIRE,
1190            HistogramConfiguration::default(),
1191            Telemetry::new(&builder),
1192        );
1193
1194        assert!(state.insert(insert_ts(1), Metric::counter("metric1", 1.0)));
1195        assert!(state.insert(insert_ts(1), Metric::gauge("metric2", 2.0)));
1196        assert!(state.insert(insert_ts(1), Metric::distribution("metric3", 3.0)));
1197
1198        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1199
1200        assert_eq!(
1201            recorder.gauge(("aggregate_last_flush_count", &[("data_type", "series")])),
1202            Some(2.0)
1203        );
1204        assert_eq!(
1205            recorder.gauge(("aggregate_last_flush_count", &[("data_type", "sketches")])),
1206            Some(1.0)
1207        );
1208
1209        // The next flush only has the idle counter's zero value, so the gauges reflect just that flush.
1210        let _ = get_flushed_metrics(flush_ts(2), &mut state).await;
1211
1212        assert_eq!(
1213            recorder.gauge(("aggregate_last_flush_count", &[("data_type", "series")])),
1214            Some(1.0)
1215        );
1216        assert_eq!(
1217            recorder.gauge(("aggregate_last_flush_count", &[("data_type", "sketches")])),
1218            Some(0.0)
1219        );
1220    }
1221
1222    #[test]
1223    fn snapshot_contexts_preserves_full_context_shape_and_unit() {
1224        let mut state = AggregationState::new(
1225            BUCKET_WIDTH_SECS,
1226            10,
1227            COUNTER_EXPIRE,
1228            HistogramConfiguration::default(),
1229            Telemetry::noop(),
1230        );
1231
1232        let histogram_context = Context::from_static_parts("request.duration", &["env:prod"])
1233            .with_host(Some(MetaString::from_static("host-a")))
1234            .with_origin_tags(TagSet::from(Tag::from_static("container:one")));
1235        let gauge_context = Context::from_static_parts("request.duration", &["env:prod"])
1236            .with_host(Some(MetaString::from_static("host-b")))
1237            .with_origin_tags(TagSet::from(Tag::from_static("container:two")));
1238        let histogram = Metric::from_parts(
1239            histogram_context.clone(),
1240            MetricValues::histogram([12.0]),
1241            MetricMetadata::default().with_unit(MetaString::from_static("millisecond")),
1242        );
1243        let gauge = Metric::gauge(gauge_context.clone(), 2.0);
1244
1245        assert!(state.insert(insert_ts(1), histogram));
1246        assert!(state.insert(insert_ts(1), gauge));
1247
1248        let mut snapshot = state.snapshot_contexts();
1249        assert_eq!(snapshot.len(), 2);
1250        assert!(snapshot.capacity() >= state.contexts.len());
1251        snapshot.sort_by(|a, b| a.context().host().cmp(&b.context().host()));
1252
1253        assert_eq!(snapshot[0].context(), &histogram_context);
1254        assert_eq!(snapshot[0].context().tags().len(), 1);
1255        assert_eq!(snapshot[0].context().origin_tags().len(), 1);
1256        assert_eq!(snapshot[0].metric_type(), AggregateMetricType::Histogram);
1257        assert_eq!(snapshot[0].unit(), Some("millisecond"));
1258
1259        assert_eq!(snapshot[1].context(), &gauge_context);
1260        assert_eq!(snapshot[1].metric_type(), AggregateMetricType::Gauge);
1261        assert_eq!(snapshot[1].unit(), None);
1262    }
1263
1264    #[tokio::test]
1265    async fn snapshot_contexts_follows_ordinary_context_lifecycle() {
1266        let mut state = AggregationState::new(
1267            BUCKET_WIDTH_SECS,
1268            10,
1269            COUNTER_EXPIRE,
1270            HistogramConfiguration::default(),
1271            Telemetry::noop(),
1272        );
1273        let context = Context::from_static_name("active.gauge");
1274
1275        assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1276        let active_snapshot = state.snapshot_contexts();
1277        assert_eq!(active_snapshot.len(), 1);
1278        assert_eq!(active_snapshot[0].context(), &context);
1279        assert_eq!(active_snapshot[0].metric_type(), AggregateMetricType::Gauge);
1280
1281        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1282        assert!(state.snapshot_contexts().is_empty());
1283    }
1284
1285    #[tokio::test]
1286    async fn snapshot_contexts_retains_idle_counter_until_expiry() {
1287        let mut state = AggregationState::new(
1288            BUCKET_WIDTH_SECS,
1289            10,
1290            COUNTER_EXPIRE,
1291            HistogramConfiguration::default(),
1292            Telemetry::noop(),
1293        );
1294        let context = Context::from_static_name("sparse.counter");
1295
1296        assert!(state.insert(insert_ts(1), Metric::counter(context.clone(), 1.0)));
1297        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1298        let first_snapshot = state.snapshot_contexts();
1299        assert_eq!(first_snapshot.len(), 1);
1300        assert_eq!(first_snapshot[0].context(), &context);
1301        assert_eq!(first_snapshot[0].metric_type(), AggregateMetricType::Counter);
1302
1303        let _ = get_flushed_metrics(flush_ts(2), &mut state).await;
1304        let second_snapshot = state.snapshot_contexts();
1305        assert_eq!(second_snapshot.len(), 1);
1306        assert_eq!(second_snapshot[0].context(), &context);
1307        assert_eq!(second_snapshot[0].metric_type(), AggregateMetricType::Counter);
1308
1309        let _ = get_flushed_metrics(flush_ts(3), &mut state).await;
1310        assert!(state.snapshot_contexts().is_empty());
1311    }
1312
1313    #[test]
1314    fn canceled_snapshot_response_skips_snapshot_construction() {
1315        let mut state = AggregationState::new(
1316            BUCKET_WIDTH_SECS,
1317            10,
1318            COUNTER_EXPIRE,
1319            HistogramConfiguration::default(),
1320            Telemetry::noop(),
1321        );
1322        assert!(state.insert(
1323            insert_ts(1),
1324            Metric::gauge(Context::from_static_name("canceled.snapshot"), 1.0),
1325        ));
1326        let (response, receiver) = oneshot::channel();
1327        drop(receiver);
1328        let snapshot_calls = Cell::new(0);
1329
1330        send_context_snapshot_if_open(response, || {
1331            snapshot_calls.set(snapshot_calls.get() + 1);
1332            state.snapshot_contexts()
1333        });
1334
1335        assert_eq!(snapshot_calls.get(), 0);
1336    }
1337
1338    #[test]
1339    fn open_snapshot_response_constructs_once_and_returns_exact_entries() {
1340        let mut state = AggregationState::new(
1341            BUCKET_WIDTH_SECS,
1342            10,
1343            COUNTER_EXPIRE,
1344            HistogramConfiguration::default(),
1345            Telemetry::noop(),
1346        );
1347        let context = Context::from_static_name("open.snapshot");
1348        assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1349        let expected = vec![AggregateContextSnapshotEntry {
1350            context,
1351            metric_type: AggregateMetricType::Gauge,
1352            unit: MetaString::empty(),
1353        }];
1354        let (response, mut receiver) = oneshot::channel();
1355        let snapshot_calls = Cell::new(0);
1356
1357        send_context_snapshot_if_open(response, || {
1358            snapshot_calls.set(snapshot_calls.get() + 1);
1359            state.snapshot_contexts()
1360        });
1361
1362        assert_eq!(snapshot_calls.get(), 1);
1363        assert_eq!(
1364            receiver.try_recv().expect("open requester should receive a snapshot"),
1365            expected
1366        );
1367    }
1368
1369    #[test]
1370    fn aggregate_memory_bounds_include_peak_context_snapshot() {
1371        let context_limit = 17;
1372        let config = AggregateConfiguration {
1373            context_limit,
1374            ..AggregateConfiguration::for_test()
1375        };
1376        let registry = ComponentRegistry::default();
1377        config.specify_bounds(&mut registry.bounds_builder(&SubsystemIdentifier::from_dotted("test")));
1378        let bounds = registry.as_bounds();
1379
1380        let expected_minimum = size_of::<Aggregate>();
1381        let aggregation_state_bytes = context_limit * (size_of::<Context>() + size_of::<AggregatedMetric>());
1382        let context_snapshot_bytes = context_limit * size_of::<AggregateContextSnapshotEntry>();
1383
1384        assert_eq!(bounds.total_minimum_required_bytes(), expected_minimum);
1385        assert_eq!(
1386            bounds.total_firm_limit_bytes(),
1387            expected_minimum + aggregation_state_bytes + context_snapshot_bytes
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn production_owner_loop_serves_snapshots_and_stops_after_cancellation() {
1393        tokio::time::timeout(Duration::from_secs(5), async {
1394            let (snapshot_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
1395            let config = AggregateConfiguration {
1396                primary_flush_interval: Duration::from_secs(60),
1397                context_snapshot_receiver,
1398                ..AggregateConfiguration::for_test()
1399            };
1400
1401            let available_request_capacity = snapshot_handle.requests.capacity();
1402            let canceled_request = tokio::spawn({
1403                let snapshot_handle = snapshot_handle.clone();
1404                async move { snapshot_handle.snapshot().await }
1405            });
1406            while snapshot_handle.requests.capacity() == available_request_capacity {
1407                tokio::task::yield_now().await;
1408            }
1409            canceled_request.abort();
1410            assert!(canceled_request
1411                .await
1412                .expect_err("snapshot requester should be canceled")
1413                .is_cancelled());
1414
1415            // The default output has to stay open, since the aggregate dispatches what it doesn't retain.
1416            let (control, _outputs) = TestComponentDriver::transform(config)
1417                .await
1418                .expect("aggregate transform should build");
1419
1420            let retained_context = Context::from_static_name("owner.loop.mixed.counter");
1421            let mixed_values = ScalarPoints::from_iter([(None, 2.0), (NonZeroU64::new(insert_ts(1)), 3.0)]);
1422            control
1423                .send_event(Event::Metric(Metric::counter(retained_context.clone(), mixed_values)))
1424                .await;
1425
1426            let snapshot = loop {
1427                let snapshot = snapshot_handle
1428                    .snapshot()
1429                    .await
1430                    .expect("running aggregate should fulfill snapshots");
1431                if snapshot.iter().any(|entry| entry.context() == &retained_context) {
1432                    break snapshot;
1433                }
1434                tokio::task::yield_now().await;
1435            };
1436            assert_eq!(
1437                snapshot.len(),
1438                1,
1439                "only the mixed metric's retained portion belongs in state"
1440            );
1441            let entry = &snapshot[0];
1442            assert_eq!(entry.context(), &retained_context);
1443            assert_eq!(entry.metric_type(), AggregateMetricType::Counter);
1444            assert_eq!(entry.unit(), None);
1445
1446            drop(snapshot_handle);
1447            control.shutdown().await.expect("aggregate should stop cleanly");
1448        })
1449        .await
1450        .expect("production aggregate owner loop should complete without spinning or hanging");
1451    }
1452
1453    #[tokio::test]
1454    async fn snapshot_handle_round_trips_through_responder() {
1455        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1456        let expected = vec![AggregateContextSnapshotEntry::for_test(
1457            Context::from_static_name("round.trip"),
1458            AggregateMetricType::Gauge,
1459            MetaString::from_static("widget"),
1460        )];
1461        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1462
1463        responder
1464            .respond(expected.clone())
1465            .await
1466            .expect("responder should receive and fulfill a snapshot request");
1467
1468        let actual = snapshot_task
1469            .await
1470            .expect("snapshot task should complete")
1471            .expect("snapshot request should succeed");
1472        assert_eq!(actual, expected);
1473    }
1474
1475    #[tokio::test]
1476    async fn snapshot_handle_reports_dropped_receiver() {
1477        let (handle, responder) = aggregate_context_snapshot_channel_for_test();
1478        drop(responder);
1479
1480        let error = handle
1481            .snapshot()
1482            .await
1483            .expect_err("snapshot should fail after its owner is dropped");
1484        assert!(error.to_string().contains("unavailable"));
1485    }
1486
1487    #[tokio::test]
1488    async fn snapshot_responder_stops_after_accepting_request_and_cancels_response() {
1489        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1490        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1491
1492        responder
1493            .stop_after_receiving()
1494            .await
1495            .expect("responder should accept the snapshot request before stopping");
1496
1497        let error = snapshot_task
1498            .await
1499            .expect("snapshot task should complete")
1500            .expect_err("snapshot should fail when the accepted response is dropped");
1501        assert!(error.to_string().contains("stopped before responding"));
1502    }
1503
1504    #[tokio::test]
1505    async fn aggregate_configuration_receiver_can_only_be_taken_once() {
1506        let config = AggregateConfiguration::for_test();
1507        let first = config.build(BuildContext::test_transform("aggregate_one")).await;
1508        assert!(first.is_ok());
1509
1510        let second = config.build(BuildContext::test_transform("aggregate_two")).await;
1511        let error = match second {
1512            Ok(_) => panic!("second build should not take the snapshot receiver again"),
1513            Err(error) => error,
1514        };
1515        assert!(error.to_string().contains("already been taken"));
1516    }
1517
1518    #[tokio::test]
1519    async fn snapshot_responder_reports_closed_request_channel() {
1520        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1521        drop(handle);
1522
1523        let error = responder
1524            .respond(Vec::new())
1525            .await
1526            .expect_err("responder should fail when every request handle is dropped");
1527        assert!(error.to_string().contains("closed"));
1528    }
1529
1530    #[tokio::test]
1531    async fn snapshot_responder_ignores_canceled_requester() {
1532        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1533        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1534        let pending_response = responder
1535            .receive()
1536            .await
1537            .expect("responder should accept the snapshot request");
1538
1539        snapshot_task.abort();
1540        let _ = snapshot_task.await;
1541
1542        pending_response.respond(Vec::new());
1543    }
1544
1545    #[test]
1546    fn bucket_is_closed() {
1547        // Cases are defined as:
1548        // (current time, bucket start, bucket width, flush open buckets, expected result)
1549        let cases = [
1550            // Bucket goes from [995, 1005), current time of 1000, so bucket is open.
1551            (1000, 995, BUCKET_WIDTH_SECS, false, false),
1552            (1000, 995, BUCKET_WIDTH_SECS, true, true),
1553            // Bucket goes from [1000, 1010), current time of 1000, so bucket is open.
1554            (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1555            (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1556            // Bucket goes from [1000, 1010), current time of 1010, so bucket is closed.
1557            (1010, 1000, BUCKET_WIDTH_SECS, false, true),
1558            (1010, 1000, BUCKET_WIDTH_SECS, true, true),
1559        ];
1560
1561        for (current_time, bucket_start, bucket_width_secs, flush_open_buckets, expected) in cases {
1562            let expected_reason = if expected {
1563                "closed, was open"
1564            } else {
1565                "open, was closed"
1566            };
1567
1568            assert_eq!(
1569                is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets),
1570                expected,
1571                "expected bucket to be {} (current_time={}, bucket_start={}, bucket_width={}, flush_open_buckets={})",
1572                expected_reason,
1573                current_time,
1574                bucket_start,
1575                bucket_width_secs,
1576                flush_open_buckets
1577            );
1578        }
1579    }
1580
1581    #[tokio::test]
1582    async fn context_limit() {
1583        // Create our aggregation state with a context limit of 2.
1584        let mut state = AggregationState::new(
1585            BUCKET_WIDTH_SECS,
1586            2,
1587            COUNTER_EXPIRE,
1588            HistogramConfiguration::default(),
1589            Telemetry::noop(),
1590        );
1591
1592        // Create four unique gauges, and insert all of them. The third and fourth should fail because we've reached
1593        // the context limit.
1594        let input_metrics = [
1595            Metric::gauge("metric1", 1.0),
1596            Metric::gauge("metric2", 2.0),
1597            Metric::gauge("metric3", 3.0),
1598            Metric::gauge("metric4", 4.0),
1599        ];
1600
1601        assert!(!state.context_limit_breached());
1602
1603        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1604        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1605        assert!(!state.context_limit_breached());
1606
1607        assert!(!state.insert(insert_ts(1), input_metrics[2].clone()));
1608        assert!(state.context_limit_breached());
1609        assert!(!state.insert(insert_ts(1), input_metrics[3].clone()));
1610        assert!(state.context_limit_breached());
1611
1612        // We should only see the first two gauges after flushing. The flush should also clear the breached flag since
1613        // contexts drop below the limit.
1614        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1615        assert_eq!(flushed_metrics.len(), 2);
1616        assert_eq!(input_metrics[0].context(), flushed_metrics[0].context());
1617        assert_eq!(input_metrics[1].context(), flushed_metrics[1].context());
1618        assert!(!state.context_limit_breached());
1619
1620        // We should be able to insert the third and fourth gauges now as the first two have been flushed, and along
1621        // with them, their contexts should no longer be tracked in the aggregation state:
1622        assert!(state.insert(insert_ts(2), input_metrics[2].clone()));
1623        assert!(state.insert(insert_ts(2), input_metrics[3].clone()));
1624
1625        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1626        assert_eq!(flushed_metrics.len(), 2);
1627        assert_eq!(input_metrics[2].context(), flushed_metrics[0].context());
1628        assert_eq!(input_metrics[3].context(), flushed_metrics[1].context());
1629    }
1630
1631    #[tokio::test]
1632    async fn context_limit_with_zero_value_counters() {
1633        // We test here to ensure that zero-value counters contribute to the context limit.
1634        let mut state = AggregationState::new(
1635            BUCKET_WIDTH_SECS,
1636            2,
1637            COUNTER_EXPIRE,
1638            HistogramConfiguration::default(),
1639            Telemetry::noop(),
1640        );
1641
1642        // Create our input metrics.
1643        let input_metrics = [
1644            Metric::counter("metric1", 1.0),
1645            Metric::counter("metric2", 2.0),
1646            Metric::counter("metric3", 3.0),
1647        ];
1648
1649        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1650        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1651
1652        // Flush the aggregation state, and observe they're both present.
1653        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1654        assert_eq!(flushed_metrics.len(), 2);
1655        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1656        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1657
1658        // Flush _again_ to ensure that we then emit zero-value variants for both counters.
1659        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1660        assert_eq!(flushed_metrics.len(), 2);
1661        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1662        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1663
1664        // Now try to insert a third counter, which should fail because we've reached the context limit.
1665        assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1666
1667        // Flush the aggregation state, and observe that we only see the two original counters.
1668        let flushed_metrics = get_flushed_metrics(flush_ts(3), &mut state).await;
1669        assert_eq!(flushed_metrics.len(), 2);
1670        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0]);
1671        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0]);
1672
1673        // With a fourth flush interval, the two counters should now have expired, and thus be dropped and no longer
1674        // contributing to the context limit.
1675        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1676        assert_eq!(flushed_metrics.len(), 0);
1677
1678        // Now we should be able to insert the third counter, and it should be the only one present after flushing.
1679        assert!(state.insert(insert_ts(5), input_metrics[2].clone()));
1680
1681        let flushed_metrics = get_flushed_metrics(flush_ts(5), &mut state).await;
1682        assert_eq!(flushed_metrics.len(), 1);
1683        assert_flushed_scalar_metric!(&input_metrics[2], &flushed_metrics[0], [bucket_ts(5) => 3.0]);
1684    }
1685
1686    /// Regression test: with idle-counter keep-alive disabled (`dogstatsd_expiry_seconds: 0`), a flushed counter used
1687    /// to stay in the aggregation state forever. It emitted no zero values, but still counted toward
1688    /// `aggregate_context_limit`, so enough distinct counters eventually blocked new contexts.
1689    #[tokio::test]
1690    async fn disabled_counter_keep_alive_releases_contexts_after_flush() {
1691        let mut state = AggregationState::new(
1692            BUCKET_WIDTH_SECS,
1693            10,
1694            None,
1695            HistogramConfiguration::default(),
1696            Telemetry::noop(),
1697        );
1698
1699        assert!(state.insert(insert_ts(1), Metric::counter("counter", 1.0)));
1700        let flushed = get_flushed_metrics(flush_ts(1), &mut state).await;
1701
1702        assert_eq!(flushed.len(), 1);
1703        assert!(state.is_empty());
1704    }
1705
1706    /// Regression test: after the state emptied and sat idle, its last flush time stayed behind. The next flush then
1707    /// generated zero-value buckets for the whole idle period and merged them into a counter inserted afterward, so
1708    /// the counter reported points from before it existed (97 extra zeros in this scenario).
1709    #[tokio::test]
1710    async fn counter_after_idle_single_window_does_not_backfill_zeros() {
1711        let mut state = AggregationState::new(
1712            BUCKET_WIDTH_SECS,
1713            10,
1714            Some(Duration::from_secs(300)),
1715            HistogramConfiguration::default(),
1716            Telemetry::noop(),
1717        );
1718
1719        assert!(state.insert(11, Metric::gauge("previous", 1.0)));
1720        let flushed = get_flushed_metrics_with_open_windows(30, false, &mut state).await;
1721        assert_eq!(flushed.len(), 1);
1722        assert!(state.is_empty());
1723
1724        let counter = Metric::counter("counter", 7.0);
1725        assert!(state.insert(1_000, counter.clone()));
1726        let flushed = get_flushed_metrics_with_open_windows(1_010, false, &mut state).await;
1727
1728        assert_eq!(flushed.len(), 1);
1729        assert_flushed_scalar_metric!(&counter, &flushed[0], [1_000 => 7.0]);
1730    }
1731
1732    /// Regression test: flushing open windows (`dogstatsd_flush_incomplete_buckets`) only affected idle-counter zero
1733    /// values. Real values in the still-open window stayed behind the closed-bucket cutoff, so a shutdown flush
1734    /// discarded them. The Core Agent flushes every bucket, open or not, when forced:
1735    /// https://github.com/DataDog/datadog-agent/blob/78c2c984e848055b4aa63c5f6a9e94a534a07a44/pkg/aggregator/time_sampler.go#L184
1736    #[tokio::test]
1737    async fn flushing_open_windows_emits_values_in_the_open_window() {
1738        for flush_open_buckets in [false, true] {
1739            let mut state = AggregationState::new(
1740                BUCKET_WIDTH_SECS,
1741                10,
1742                COUNTER_EXPIRE,
1743                HistogramConfiguration::default(),
1744                Telemetry::noop(),
1745            );
1746            let gauge = Metric::gauge("gauge", 1.0);
1747            let counter = Metric::counter("counter", 2.0);
1748            let distribution = Metric::distribution("distribution", 3.0);
1749            for metric in [&gauge, &counter, &distribution] {
1750                assert!(state.insert(1_001, metric.clone()));
1751            }
1752
1753            // The window [1000, 1010) is still open at 1005.
1754            let mut flushed = get_flushed_metrics_with_open_windows(1_005, flush_open_buckets, &mut state).await;
1755            if !flush_open_buckets {
1756                assert!(
1757                    flushed.is_empty(),
1758                    "open windows must wait unless they're being flushed"
1759                );
1760                continue;
1761            }
1762
1763            flushed.sort_by(|a, b| a.context().name().cmp(b.context().name()));
1764            let names = flushed
1765                .iter()
1766                .map(|metric| metric.context().name().as_ref())
1767                .collect::<Vec<_>>();
1768            assert_eq!(names, ["counter", "distribution", "gauge"]);
1769            assert_flushed_scalar_metric!(&counter, &flushed[0], [1_000 => 2.0]);
1770            assert_flushed_distribution_metric!(&distribution, &flushed[1], [1_000 => &[3.0][..]]);
1771            assert_flushed_scalar_metric!(&gauge, &flushed[2], [1_000 => 1.0]);
1772        }
1773    }
1774
1775    #[tokio::test]
1776    async fn zero_value_counters() {
1777        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1778        let mut state = AggregationState::new(
1779            BUCKET_WIDTH_SECS,
1780            10,
1781            COUNTER_EXPIRE,
1782            HistogramConfiguration::default(),
1783            Telemetry::noop(),
1784        );
1785
1786        // Create two unique counters, and insert both of them.
1787        let input_metrics = [Metric::counter("metric1", 1.0), Metric::counter("metric2", 2.0)];
1788
1789        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1790        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1791
1792        // Flush the aggregation state, and observe they're both present.
1793        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1794        assert_eq!(flushed_metrics.len(), 2);
1795        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1796        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1797
1798        // Perform our second flush, which should have them as zero-value counters.
1799        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1800        assert_eq!(flushed_metrics.len(), 2);
1801        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1802        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1803
1804        // Now, we'll pretend to skip a flush period and add updates to them again after that.
1805        assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1806        assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1807
1808        // Flush the aggregation state, and observe that we have two zero-value counters for the flush period we
1809        // skipped, but that we see them appear again in the fourth flush period.
1810        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1811        assert_eq!(flushed_metrics.len(), 2);
1812        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0, bucket_ts(4) => 1.0]);
1813        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0, bucket_ts(4) => 2.0]);
1814
1815        // Now we'll skip multiple flush periods and ensure that we emit zero-value counters up until the point they
1816        // expire. As our zero-value counter expiration is 20 seconds, this is two flush periods, so we skip by three
1817        // flush periods, and we should only see the counters emitted for the first two.
1818        let flushed_metrics = get_flushed_metrics(flush_ts(7), &mut state).await;
1819        assert_eq!(flushed_metrics.len(), 2);
1820        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1821        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1822    }
1823
1824    #[tokio::test]
1825    async fn merge_identical_timestamped_values_on_flush() {
1826        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1827        let mut state = AggregationState::new(
1828            BUCKET_WIDTH_SECS,
1829            10,
1830            COUNTER_EXPIRE,
1831            HistogramConfiguration::default(),
1832            Telemetry::noop(),
1833        );
1834
1835        // Create one multi-value counter, and insert it.
1836        let input_metric = Metric::counter("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1837
1838        assert!(state.insert(insert_ts(1), input_metric.clone()));
1839
1840        // Flush the aggregation state, and observe the metric is present _and_ that we've properly merged all of the
1841        // values within the same timestamp.
1842        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1843        assert_eq!(flushed_metrics.len(), 1);
1844        assert_flushed_scalar_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => 15.0]);
1845    }
1846
1847    #[tokio::test]
1848    async fn histogram_statistics() {
1849        // We're testing that we properly emit individual metrics (min, max, sum, etc) for a histogram.
1850        let hist_config = HistogramConfiguration::from_statistics(
1851            &[
1852                HistogramStatistic::Count,
1853                HistogramStatistic::Sum,
1854                HistogramStatistic::Percentile {
1855                    q: 0.5,
1856                    suffix: "p50".into(),
1857                },
1858            ],
1859            false,
1860            "".into(),
1861        );
1862        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1863
1864        // Create one multi-value histogram and insert it.
1865        let input_metric = Metric::histogram("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1866        assert!(state.insert(insert_ts(1), input_metric.clone()));
1867
1868        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1869        // the form of three metrics: count, sum, and p50.
1870        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1871        assert_eq!(flushed_metrics.len(), 3);
1872
1873        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1874        // matter here, but we do need a `Metric` for it to compare the context to.
1875        let count_metric = Metric::rate("metric1.count", 0.0, Duration::from_secs(BUCKET_WIDTH_SECS.get()));
1876        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1877        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1878
1879        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1880        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1881        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[0], [bucket_ts(1) => 5.0]);
1882        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[1], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1883        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[2], [bucket_ts(1) => 15.0]);
1884    }
1885
1886    #[tokio::test]
1887    async fn histogram_statistics_unit_propagation() {
1888        // We're testing that the unit from the input histogram metadata propagates to all flushed output metrics.
1889        let hist_config = HistogramConfiguration::from_statistics(
1890            &[
1891                HistogramStatistic::Count,
1892                HistogramStatistic::Sum,
1893                HistogramStatistic::Percentile {
1894                    q: 0.5,
1895                    suffix: "p50".into(),
1896                },
1897            ],
1898            false,
1899            "".into(),
1900        );
1901        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1902
1903        // Build a histogram with unit = "millisecond", simulating what arrives from a DogStatsD `ms` metric.
1904        let context = Context::from_static_parts("metric1", &[]);
1905        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
1906        let input_metric = Metric::from_parts(
1907            context,
1908            MetricValues::histogram([1.0_f64, 2.0, 3.0, 4.0, 5.0]),
1909            metadata,
1910        );
1911        assert!(state.insert(insert_ts(1), input_metric));
1912
1913        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1914        assert_eq!(flushed_metrics.len(), 3);
1915
1916        // Counts are dimensionless: the `.count` series must drop the unit while all other
1917        // aggregate series carry the unit from the input histogram.
1918        for metric in &flushed_metrics {
1919            let name = metric.context().name();
1920            if name.ends_with(".count") {
1921                assert_eq!(
1922                    metric.metadata().unit(),
1923                    None,
1924                    "flushed metric '{}' should be dimensionless",
1925                    name
1926                );
1927            } else {
1928                assert_eq!(
1929                    metric.metadata().unit(),
1930                    Some("millisecond"),
1931                    "flushed metric '{}' should carry unit='millisecond'",
1932                    name
1933                );
1934            }
1935        }
1936    }
1937
1938    #[tokio::test]
1939    async fn distributions() {
1940        // We're testing that we pass through distributions untouched.
1941        let mut state = AggregationState::new(
1942            BUCKET_WIDTH_SECS,
1943            10,
1944            COUNTER_EXPIRE,
1945            HistogramConfiguration::default(),
1946            Telemetry::noop(),
1947        );
1948
1949        // Create one multi-value distribution, with server-side aggregation, and insert it.
1950        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1951        let input_metric = Metric::distribution("metric1", &values[..]);
1952
1953        assert!(state.insert(insert_ts(1), input_metric.clone()));
1954
1955        // Flush the aggregation state, and observe that we've emitted the original distribution.
1956        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1957        assert_eq!(flushed_metrics.len(), 1);
1958
1959        assert_flushed_distribution_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1960    }
1961
1962    #[tokio::test]
1963    async fn histogram_copy_to_distribution() {
1964        let hist_config = HistogramConfiguration::from_statistics(
1965            &[
1966                HistogramStatistic::Count,
1967                HistogramStatistic::Sum,
1968                HistogramStatistic::Percentile {
1969                    q: 0.5,
1970                    suffix: "p50".into(),
1971                },
1972            ],
1973            true,
1974            "dist_prefix.".into(),
1975        );
1976        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1977
1978        // Create one multi-value histogram and insert it.
1979        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1980        let input_metric = Metric::histogram("metric1", values);
1981        assert!(state.insert(insert_ts(1), input_metric.clone()));
1982
1983        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1984        // the form of three metrics: count, sum, and p50 as well as the additional metric from copying the histogram.
1985        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1986        assert_eq!(flushed_metrics.len(), 4);
1987
1988        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1989        // matter here, but we do need a `Metric` for it to compare the context to.
1990        let count_metric = Metric::rate("metric1.count", 0.0, BUCKET_WIDTH);
1991        let sum_metric = Metric::gauge("metric1.sum", 0.0);
1992        let p50_metric = Metric::gauge("metric1.p50", 0.0);
1993        let expected_distribution = Metric::distribution("dist_prefix.metric1", &values[..]);
1994
1995        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
1996        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
1997        assert_flushed_distribution_metric!(expected_distribution, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1998        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[1], [bucket_ts(1) => 5.0]);
1999        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[2], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
2000        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[3], [bucket_ts(1) => 15.0]);
2001    }
2002
2003    #[tokio::test]
2004    async fn nonaggregated_counters_to_rate() {
2005        let counter_value = 42.0;
2006
2007        // Create a basic aggregation state.
2008        let mut state = AggregationState::new(
2009            BUCKET_WIDTH_SECS,
2010            10,
2011            COUNTER_EXPIRE,
2012            HistogramConfiguration::default(),
2013            Telemetry::noop(),
2014        );
2015
2016        // Create a simple non-aggregated counter, and insert it.
2017        let input_metric = Metric::counter("metric1", counter_value);
2018        assert!(state.insert(insert_ts(1), input_metric.clone()));
2019
2020        // Flush the aggregation state, and observe that we've emitted the expected counter and that it has the right
2021        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
2022        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2023        assert_eq!(flushed_metrics.len(), 1);
2024        let flushed_metric = &flushed_metrics[0];
2025
2026        assert_flushed_scalar_metric!(&input_metric, flushed_metric, [bucket_ts(1) => counter_value]);
2027        assert_eq!(flushed_metric.values().as_str(), "rate");
2028    }
2029
2030    #[tokio::test]
2031    async fn telemetry() {
2032        // TODO: We don't check `component_events_dropped_total` here as it's set directly in the aggregate
2033        // component future rather than `AggregationState`, which is harder to drive overall and would have
2034        // required even more boilerplate.
2035        //
2036        // Leaving that as a future improvement.
2037
2038        let recorder = TestRecorder::default();
2039        let _local = metrics::set_default_local_recorder(&recorder);
2040
2041        let builder = MetricsBuilder::default();
2042        let telemetry = Telemetry::new(&builder);
2043
2044        let mut state = AggregationState::new(
2045            BUCKET_WIDTH_SECS,
2046            2,
2047            COUNTER_EXPIRE,
2048            HistogramConfiguration::default(),
2049            telemetry,
2050        );
2051
2052        // Make sure our telemetry is registered at default values.
2053        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(0.0));
2054        assert_eq!(
2055            recorder.counter(("component_events_dropped_total", &[("intentional", "true")])),
2056            Some(0)
2057        );
2058        for metric_type in &["counter", "gauge", "rate", "set", "histogram", "distribution"] {
2059            assert_eq!(
2060                recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", *metric_type)])),
2061                Some(0.0)
2062            );
2063        }
2064
2065        // Insert a counter with a non-timestamped value.
2066        assert!(state.insert(insert_ts(1), Metric::counter("metric1", 42.0)));
2067        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2068        assert_eq!(
2069            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2070            Some(1.0)
2071        );
2072
2073        // Insert a gauge with a timestamped value.
2074        assert!(state.insert(insert_ts(1), Metric::gauge("metric2", (insert_ts(1), 42.0))));
2075        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2076        assert_eq!(
2077            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2078            Some(1.0)
2079        );
2080
2081        // We've reached our context limit at this point, so the next metric should not be inserted.
2082        assert!(!state.insert(insert_ts(1), Metric::counter("metric3", 42.0)));
2083        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2084        assert_eq!(
2085            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2086            Some(1.0)
2087        );
2088
2089        // Now let's flush the state which should flush the gauge entirely, reducing the context count, but not flush
2090        // the counter, since it'll be in zero-value mode.
2091        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
2092        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2093        assert_eq!(
2094            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2095            Some(1.0)
2096        );
2097        assert_eq!(
2098            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2099            Some(0.0)
2100        );
2101    }
2102}