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_context::Context;
13use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr};
14use saluki_core::{
15    components::{transforms::*, ComponentContext},
16    data_model::event::{metric::*, Event, EventType},
17    observability::ComponentMetricsExt as _,
18    topology::{interconnect::BufferedDispatcher, OutputDefinition},
19    topology::{EventsBuffer, EventsDispatcher},
20};
21use saluki_error::{generic_error, GenericError};
22use saluki_metrics::MetricsBuilder;
23use smallvec::SmallVec;
24use stringtheory::MetaString;
25use tokio::{
26    pin, select,
27    sync::{mpsc, oneshot},
28    time::{interval, interval_at},
29};
30use tracing::{debug, error, info, trace, warn};
31
32mod telemetry;
33use self::telemetry::Telemetry;
34
35mod config;
36pub use self::config::HistogramConfiguration;
37use self::config::HistogramStatistic;
38
39const PASSTHROUGH_IDLE_FLUSH_CHECK_INTERVAL: Duration = Duration::from_secs(2);
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    /// Whether or not to immediately forward (passthrough) metrics with pre-defined timestamps.
346    ///
347    /// When enabled, this causes the aggregator to immediately forward metrics that already have a timestamp present.
348    /// Only metrics without a timestamp will be aggregated. This can be useful when metrics are already pre-aggregated
349    /// client-side and both timeliness and memory efficiency are paramount, as it avoids the overhead of aggregating
350    /// within the pipeline.
351    pub passthrough_timestamped_metrics: bool,
352
353    /// How often to flush buffered passthrough metrics.
354    ///
355    /// While passthrough metrics aren't re-aggregated by the transform, they will still be temporarily buffered in
356    /// order to optimize the efficiency of processing them in the next component. This setting controls the maximum
357    /// amount of time that passthrough metrics will be buffered before being forwarded.
358    pub passthrough_idle_flush_timeout: Duration,
359
360    /// Statistics to calculate over histograms, and how to copy them to distributions.
361    pub hist_config: HistogramConfiguration,
362
363    /// Owner side of the channel used to serve retained-context snapshot requests.
364    ///
365    /// This is runtime wiring rather than configuration: it carries no settings, and is created by the caller with
366    /// [`aggregate_context_snapshot_channel`] so that the caller keeps the matching handle.
367    pub context_snapshot_receiver: AggregateContextSnapshotReceiver,
368}
369
370#[cfg(test)]
371impl AggregateConfiguration {
372    /// Creates a fixture configuration, for tests that exercise aggregation behavior rather than configuration.
373    ///
374    /// The snapshot handle paired with the configuration's receiver is dropped. Tests that request snapshots build the
375    /// channel with [`aggregate_context_snapshot_channel`] and override `context_snapshot_receiver`.
376    fn for_test() -> Self {
377        let (_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
378
379        Self {
380            window_duration_seconds: NonZeroU64::new(10).expect("not zero"),
381            primary_flush_interval: Duration::from_secs(15),
382            context_limit: 1_000_000,
383            flush_open_windows: false,
384            counter_expiry_seconds: Some(300),
385            passthrough_timestamped_metrics: true,
386            passthrough_idle_flush_timeout: Duration::from_secs(1),
387            hist_config: HistogramConfiguration::default(),
388            context_snapshot_receiver,
389        }
390    }
391}
392
393#[async_trait]
394impl TransformBuilder for AggregateConfiguration {
395    async fn build(&self, context: ComponentContext) -> Result<Box<dyn Transform + Send>, GenericError> {
396        let context_snapshot_requests = self.context_snapshot_receiver.take_receiver()?;
397        let metrics_builder = MetricsBuilder::from_component_context(&context);
398        let telemetry = Telemetry::new(&metrics_builder);
399
400        let state = AggregationState::new(
401            self.window_duration_seconds,
402            self.context_limit,
403            self.counter_expiry_seconds.filter(|s| *s != 0).map(Duration::from_secs),
404            self.hist_config.clone(),
405            telemetry.clone(),
406        );
407
408        let passthrough_batcher = PassthroughBatcher::new(
409            self.passthrough_idle_flush_timeout,
410            self.window_duration_seconds,
411            telemetry.clone(),
412        )
413        .await;
414
415        Ok(Box::new(Aggregate {
416            state,
417            telemetry,
418            primary_flush_interval: self.primary_flush_interval,
419            flush_open_windows: self.flush_open_windows,
420            passthrough_batcher,
421            passthrough_timestamped_metrics: self.passthrough_timestamped_metrics,
422            context_snapshot_requests: Some(context_snapshot_requests),
423        }))
424    }
425
426    fn input_event_type(&self) -> EventType {
427        EventType::Metric
428    }
429
430    fn outputs(&self) -> &[OutputDefinition<EventType>] {
431        static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
432        OUTPUTS
433    }
434}
435
436impl MemoryBounds for AggregateConfiguration {
437    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
438        // TODO: While we account for the aggregation state map accurately, what we don't currently account for is the
439        // fact that a metric could have multiple distinct values. For the common pipeline of metrics in via DogStatsD,
440        // this generally shouldn't be a problem because the values don't have a timestamp, so they get aggregated into
441        // the same bucket, leading to two values per `MetricValues` at most, which is already baked into the size of
442        // `MetricValues` due to using `SmallVec`.
443        //
444        // However, there could be many more values in a single metric, and we don't account for that.
445
446        builder
447            .minimum()
448            // Capture the size of the heap allocation when the component is built.
449            .with_single_value::<Aggregate>("component struct");
450        builder
451            .firm()
452            // Account for the aggregation state map, where we map contexts to the merged metric.
453            .with_expr(UsageExpr::product(
454                "aggregation state map",
455                UsageExpr::sum(
456                    "context map entry",
457                    UsageExpr::struct_size::<Context>("context"),
458                    UsageExpr::struct_size::<AggregatedMetric>("aggregated metric"),
459                ),
460                UsageExpr::config("aggregate_context_limit", self.context_limit),
461            ))
462            // A snapshot is constructed while the aggregation state remains live, so its peak allocation is additive.
463            .with_expr(UsageExpr::product(
464                "retained context snapshot",
465                UsageExpr::struct_size::<AggregateContextSnapshotEntry>("snapshot entry"),
466                UsageExpr::config("aggregate_context_limit", self.context_limit),
467            ));
468    }
469}
470
471pub struct Aggregate {
472    state: AggregationState,
473    telemetry: Telemetry,
474    primary_flush_interval: Duration,
475    flush_open_windows: bool,
476    passthrough_batcher: PassthroughBatcher,
477    passthrough_timestamped_metrics: bool,
478    context_snapshot_requests: Option<AggregateContextSnapshotRequestReceiver>,
479}
480
481#[async_trait]
482impl Transform for Aggregate {
483    async fn run(mut self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
484        let mut health = context.take_health_handle();
485
486        let mut primary_flush = interval_at(
487            tokio::time::Instant::now() + self.primary_flush_interval,
488            self.primary_flush_interval,
489        );
490        let mut final_primary_flush = false;
491
492        let passthrough_flush = interval(PASSTHROUGH_IDLE_FLUSH_CHECK_INTERVAL);
493
494        health.mark_ready();
495        debug!("Aggregation transform started.");
496
497        pin!(passthrough_flush);
498
499        loop {
500            select! {
501                _ = health.live() => continue,
502                _ = primary_flush.tick() => {
503                    // We've reached the end of the current window. Flush our aggregation state and forward the metrics
504                    // onwards. Regardless of whether any metrics were aggregated, we always update the aggregation
505                    // state to track the start time of the current aggregation window.
506                    if !self.state.is_empty() {
507                        debug!("Flushing aggregated metrics...");
508
509                        let should_flush_open_windows = final_primary_flush && self.flush_open_windows;
510
511                        // Remember if the context limit had been surpassed before this flush.
512                        let was_breached = self.state.context_limit_breached();
513
514                        let mut dispatcher = context.dispatcher().buffered().expect("default output should always exist");
515                        if let Err(e) = self.state.flush(get_unix_timestamp(), should_flush_open_windows, &mut dispatcher).await {
516                            error!(error = %e, "Failed to flush aggregation state.");
517                        }
518
519                        self.telemetry.increment_flushes();
520
521                        // If flush recovered us from a breach, log the recovery.
522                        if was_breached && !self.state.context_limit_breached() {
523                            info!("Context limit no longer exceeded, metrics are being accepted again.");
524                        }
525
526                        match dispatcher.flush().await {
527                            Ok(aggregated_events) => debug!(aggregated_events, "Dispatched events."),
528                            Err(e) => error!(error = %e, "Failed to flush aggregated events."),
529                        }
530                    }
531
532                    // If this is the final flush, we break out of the loop.
533                    if final_primary_flush {
534                        debug!("All aggregation complete.");
535                        break
536                    }
537                },
538                _ = passthrough_flush.tick() => self.passthrough_batcher.try_flush(context.dispatcher()).await,
539                snapshot_request = receive_context_snapshot_request(&mut self.context_snapshot_requests) => {
540                    match snapshot_request {
541                        Some(response) => {
542                            send_context_snapshot_if_open(response, || self.state.snapshot_contexts());
543                        }
544                        None => self.context_snapshot_requests = None,
545                    }
546                },
547                maybe_events = context.events().next(), if !final_primary_flush => match maybe_events {
548                    Some(events) => {
549                        trace!(events_len = events.len(), "Received events.");
550
551                        let current_time = get_unix_timestamp();
552                        let mut processed_passthrough_metrics = false;
553
554                        for event in events {
555                            if let Some(metric) = event.try_into_metric() {
556                                let metric = if self.passthrough_timestamped_metrics {
557                                    // Try splitting out any timestamped values, and if we have any, we'll buffer them
558                                    // separately and process the remaining nontimestamped metric (if any) by
559                                    // aggregating it like normal.
560                                    let (maybe_timestamped_metric, maybe_nontimestamped_metric) = try_split_timestamped_values(metric);
561
562                                    // If we have a timestamped metric, then batch it up out-of-band.
563                                    if let Some(timestamped_metric) = maybe_timestamped_metric {
564                                        self.passthrough_batcher.push_metric(timestamped_metric, context.dispatcher()).await;
565                                        processed_passthrough_metrics = true;
566                                    }
567
568                                    // If we have an nontimestamped metric, we'll process it like normal.
569                                    //
570                                    // Otherwise, continue to the next event.
571                                    match maybe_nontimestamped_metric {
572                                        Some(metric) => metric,
573                                        None => continue,
574                                    }
575                                } else {
576                                    metric
577                                };
578
579                                let was_breached = self.state.context_limit_breached();
580                                if !self.state.insert(current_time, metric) {
581                                    trace!("Dropping metric due to context limit.");
582                                    if !was_breached {
583                                        // First drop since the last recovery — emit a single warning.
584                                        warn!(context_limit = self.state.context_limit, "Context limit reached, \
585                                        dropping metrics. Consider increasing `aggregate_context_limit`.");
586                                    }
587                                    self.telemetry.increment_events_dropped();
588                                }
589                            }
590                        }
591
592                        if processed_passthrough_metrics {
593                            self.passthrough_batcher.update_last_processed_at();
594                        }
595                    },
596                    None => {
597                        // We've reached the end of our input stream, so mark ourselves for a final flush and reset the
598                        // interval so it ticks immediately on the next loop iteration.
599                        final_primary_flush = true;
600                        primary_flush.reset_immediately();
601
602                        debug!("Aggregation transform stopping...");
603                    }
604                },
605            }
606        }
607
608        // Do a final flush of any timestamped metrics that we've buffered up.
609        self.passthrough_batcher.try_flush(context.dispatcher()).await;
610
611        debug!("Aggregation transform stopped.");
612
613        Ok(())
614    }
615}
616
617async fn receive_context_snapshot_request(
618    receiver: &mut Option<AggregateContextSnapshotRequestReceiver>,
619) -> Option<AggregateContextSnapshotRequest> {
620    match receiver {
621        Some(receiver) => receiver.recv().await,
622        None => pending().await,
623    }
624}
625
626fn try_split_timestamped_values(mut metric: Metric) -> (Option<Metric>, Option<Metric>) {
627    if metric.values().all_timestamped() {
628        (Some(metric), None)
629    } else if metric.values().any_timestamped() {
630        // Only _some_ of the values are timestamped, so we'll split the timestamped values into a new metric.
631        let new_metric_values = metric.values_mut().split_timestamped();
632        let new_metric = Metric::from_parts(metric.context().clone(), new_metric_values, metric.metadata().clone());
633
634        (Some(new_metric), Some(metric))
635    } else {
636        // No timestamped values, so we need to aggregate this metric.
637        (None, Some(metric))
638    }
639}
640
641struct PassthroughBatcher {
642    active_buffer: EventsBuffer,
643    active_buffer_start: Instant,
644    last_processed_at: Instant,
645    idle_flush_timeout: Duration,
646    bucket_width_secs: NonZeroU64,
647    telemetry: Telemetry,
648}
649
650impl PassthroughBatcher {
651    async fn new(idle_flush_timeout: Duration, bucket_width_secs: NonZeroU64, telemetry: Telemetry) -> Self {
652        let active_buffer = EventsBuffer::default();
653
654        Self {
655            active_buffer,
656            active_buffer_start: Instant::now(),
657            last_processed_at: Instant::now(),
658            idle_flush_timeout,
659            bucket_width_secs,
660            telemetry,
661        }
662    }
663
664    async fn push_metric(&mut self, metric: Metric, dispatcher: &EventsDispatcher) {
665        // Convert counters to rates before we batch them up.
666        //
667        // This involves specifying the rate interval as the bucket width of the aggregate transform itself, which when
668        // you say it out loud is sort of confusing and nonsensical since the whole point is that these are
669        // _pre-aggregated_ metrics but we have to match the behavior of the Datadog Agent. ¯\_(ツ)_/¯
670        let (context, values, metadata) = metric.into_parts();
671        let adjusted_values = counter_values_to_rate(values, self.bucket_width_secs);
672        let metric = Metric::from_parts(context, adjusted_values, metadata);
673
674        // Try pushing the metric into our active buffer.
675        //
676        // If our active buffer is full, then we'll flush the buffer, grab a new one, and push the metric into it.
677        if let Some(event) = self.active_buffer.try_push(Event::Metric(metric)) {
678            debug!("Passthrough event buffer was full. Flushing...");
679            self.dispatch_events(dispatcher).await;
680
681            if self.active_buffer.try_push(event).is_some() {
682                error!("Event buffer is full even after dispatching events. Dropping event.");
683                self.telemetry.increment_events_dropped();
684                return;
685            }
686        }
687
688        // If this is the first metric in the buffer, we've started a new batch, so track when it started.
689        if self.active_buffer.len() == 1 {
690            self.active_buffer_start = Instant::now();
691        }
692
693        self.telemetry.increment_passthrough_metrics();
694    }
695
696    fn update_last_processed_at(&mut self) {
697        // We expose this as a standalone method, rather than just doing it automatically in `push_metric`, because
698        // otherwise we might be calling this 10-20K times per second, instead of simply doing it after the end of each
699        // input event buffer in the transform's main loop, which should be much less frequent.
700        self.last_processed_at = Instant::now();
701    }
702
703    async fn try_flush(&mut self, dispatcher: &EventsDispatcher) {
704        // If our active buffer isn't empty, and we've exceeded our idle flush timeout, then flush the buffer.
705        if !self.active_buffer.is_empty() && self.last_processed_at.elapsed() >= self.idle_flush_timeout {
706            debug!("Passthrough processing exceeded idle flush timeout. Flushing...");
707
708            self.dispatch_events(dispatcher).await;
709        }
710    }
711
712    async fn dispatch_events(&mut self, dispatcher: &EventsDispatcher) {
713        if !self.active_buffer.is_empty() {
714            let unaggregated_events = self.active_buffer.len();
715
716            // Track how long this batch was alive for.
717            let batch_duration = self.active_buffer_start.elapsed();
718            self.telemetry.record_passthrough_batch_duration(batch_duration);
719
720            self.telemetry.increment_passthrough_flushes();
721
722            // Swap our active buffer with a new, empty one, and then forward the old one.
723            let new_active_buffer = EventsBuffer::default();
724            let old_active_buffer = std::mem::replace(&mut self.active_buffer, new_active_buffer);
725
726            match dispatcher.dispatch(old_active_buffer).await {
727                Ok(()) => debug!(unaggregated_events, "Dispatched events."),
728                Err(e) => error!(error = %e, "Failed to flush unaggregated events."),
729            }
730        }
731    }
732}
733
734#[derive(Clone)]
735struct AggregatedMetric {
736    values: MetricValues,
737    metadata: MetricMetadata,
738    last_seen: u64,
739}
740
741struct AggregationState {
742    contexts: HashMap<Context, AggregatedMetric, foldhash::quality::RandomState>,
743    contexts_remove_buf: Vec<Context>,
744    context_limit: usize,
745    bucket_width_secs: NonZeroU64,
746    counter_expire_secs: Option<NonZeroU64>,
747    last_flush: u64,
748    hist_config: HistogramConfiguration,
749    telemetry: Telemetry,
750    /// Tracks whether the context limit has been breached. Starts out as `false`. Set to `true` on the first dropped
751    /// metric. Reset to `false` when the context count drops below the limit during flush.
752    context_limit_breached: bool,
753}
754
755impl AggregationState {
756    fn new(
757        bucket_width_secs: NonZeroU64, context_limit: usize, counter_expiration: Option<Duration>,
758        hist_config: HistogramConfiguration, telemetry: Telemetry,
759    ) -> Self {
760        let counter_expire_secs = counter_expiration.map(|d| d.as_secs()).and_then(NonZeroU64::new);
761
762        Self {
763            contexts: HashMap::default(),
764            contexts_remove_buf: Vec::new(),
765            context_limit,
766            bucket_width_secs,
767            counter_expire_secs,
768            last_flush: 0,
769            hist_config,
770            telemetry,
771            context_limit_breached: false,
772        }
773    }
774
775    fn is_empty(&self) -> bool {
776        self.contexts.is_empty()
777    }
778
779    fn snapshot_contexts(&self) -> Vec<AggregateContextSnapshotEntry> {
780        let mut snapshot = Vec::with_capacity(self.contexts.len());
781        for (context, aggregated) in &self.contexts {
782            snapshot.push(AggregateContextSnapshotEntry {
783                context: context.clone(),
784                metric_type: AggregateMetricType::from(&aggregated.values),
785                unit: aggregated.metadata.unit.clone(),
786            });
787        }
788        snapshot
789    }
790
791    fn insert(&mut self, timestamp: u64, metric: Metric) -> bool {
792        // If we haven't seen this context yet, and it would put us over the limit to insert it, then return early.
793        if !self.contexts.contains_key(metric.context()) && self.contexts.len() >= self.context_limit {
794            self.context_limit_breached = true;
795            return false;
796        }
797
798        let (context, mut values, metadata) = metric.into_parts();
799
800        // Collapse all non-timestamped values into a single timestamped value.
801        //
802        // We do this pre-aggregation step because unless we're merging into an existing context, we'll end up with
803        // however many values were in the original metric instead of full aggregated values.
804        let bucket_ts = align_to_bucket_start(timestamp, self.bucket_width_secs);
805        values.collapse_non_timestamped(bucket_ts);
806
807        trace!(
808            bucket_ts,
809            kind = values.as_str(),
810            "Inserting metric into aggregation state."
811        );
812
813        // If we're already tracking this context, update the last seen time and merge the new values into the existing
814        // values. Otherwise, create a new entry.
815        match self.contexts.entry(context) {
816            Entry::Occupied(mut entry) => {
817                let aggregated = entry.get_mut();
818
819                // We ignore metadata changes within a flush interval to keep things simple.
820                aggregated.last_seen = timestamp;
821                aggregated.values.merge(values);
822            }
823            Entry::Vacant(entry) => {
824                self.telemetry.increment_contexts(entry.key(), &values);
825
826                entry.insert(AggregatedMetric {
827                    values,
828                    metadata,
829                    last_seen: timestamp,
830                });
831
832                saluki_antithesis::always_le!(
833                    self.contexts.len(),
834                    self.context_limit,
835                    "aggregate context map within context_limit",
836                    { "len": self.contexts.len(), "limit": self.context_limit }
837                );
838            }
839        }
840
841        true
842    }
843
844    async fn flush(
845        &mut self, current_time: u64, flush_open_buckets: bool, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
846    ) -> Result<(), GenericError> {
847        let bucket_width_secs = self.bucket_width_secs;
848        let counter_expire_secs = self.counter_expire_secs.map(|d| d.get()).unwrap_or(0);
849
850        // We want our split timestamp to be before the start of the current bucket, which ensures any timestamp that is
851        // less than or equal to `split_timestamp` resides in a closed bucket.
852        let split_timestamp = align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1);
853
854        // Calculate the buckets we need to potentially generate zero-value counters for.
855        //
856        // We only need to do this if we've flushed before, since we won't have any knowledge of which counters are idle
857        // or not until that happens.
858        let mut zero_value_buckets = SmallVec::<[(u64, MetricValues); 4]>::new();
859        if self.last_flush != 0 {
860            let start = align_to_bucket_start(self.last_flush, bucket_width_secs);
861
862            // Clock-skew guards. Bucketing reads the wall clock while the flush cadence is monotonic, so a wall-clock
863            // jump is not bounded by the flush interval. A backward jump empties the zero-value range (a silent counter
864            // gap); a forward jump makes the loop below run once per bucket across the whole jumped span — O(jump) work
865            // and allocation. Assert before the loop so a flood fails fast rather than after the damage is done.
866            saluki_antithesis::always_ge!(
867                current_time,
868                self.last_flush,
869                "aggregate flush wall-clock did not move backward",
870                { "current_time": current_time, "last_flush": self.last_flush }
871            );
872            // The 10_000 bound is generous. A default 15s flush over a 10s bucket yields one or two buckets. The bound
873            // trips only on a multi-hour wall-clock jump, never on a slow-but-sane flush.
874            saluki_antithesis::always_le!(
875                current_time.saturating_sub(self.last_flush) / bucket_width_secs.get(),
876                10_000,
877                "aggregate zero-value bucket span bounded across a flush",
878                {
879                    "current_time": current_time,
880                    "last_flush": self.last_flush,
881                    "bucket_width_secs": bucket_width_secs.get()
882                }
883            );
884
885            for bucket_start in (start..current_time).step_by(bucket_width_secs.get() as usize) {
886                if is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets) {
887                    zero_value_buckets.push((bucket_start, MetricValues::counter((bucket_start, 0.0))));
888                }
889            }
890
891            // Anti-vacuity anchor: prove the idle-counter zero-value path actually runs in some timeline.
892            saluki_antithesis::sometimes!(
893                !zero_value_buckets.is_empty(),
894                "aggregate flush generated zero-value counter buckets",
895                { "count": zero_value_buckets.len() }
896            );
897        }
898
899        // Iterate over each context we're tracking, and flush any values that are in buckets which are now closed.
900        debug!(timestamp = current_time, "Flushing buckets.");
901
902        for (context, am) in self.contexts.iter_mut() {
903            // Figure out if we should remove this metric or not if it has no values in open buckets.
904            //
905            // We have a special carve-out for counters here, which we have the ability to keep alive after they are
906            // flushed, based on a configured expiration period. This allows us to continue emitting a zero value for
907            // counters when they're idle, which can make them appear "live" in downstream systems, even when they're
908            // not.
909            //
910            // This is useful for sparsely-updated counters.
911            let should_expire_if_empty = match &am.values {
912                MetricValues::Counter(..) => {
913                    saluki_antithesis::always_le!(
914                        am.last_seen,
915                        u64::MAX - counter_expire_secs,
916                        "aggregate counter expiry add does not overflow",
917                        { "last_seen": am.last_seen, "counter_expire_secs": counter_expire_secs }
918                    );
919                    counter_expire_secs != 0 && am.last_seen.saturating_add(counter_expire_secs) < current_time
920                }
921                _ => true,
922            };
923
924            // If we're dealing with a counter, we'll merge in our calculated set of zero values. We only merge in the
925            // values that represent now-closed buckets.
926            //
927            // This is also safe to do even when there are real values in those buckets since adding zero to anything is
928            // a no-op from the perspective of what we end up flushing, and it doesn't mess with the "last seen" time.
929            if let MetricValues::Counter(..) = &mut am.values {
930                let expires_at = am.last_seen.saturating_add(counter_expire_secs);
931                for (zv_bucket_start, zero_value) in &zero_value_buckets {
932                    if expires_at > *zv_bucket_start {
933                        am.values.merge(zero_value.clone());
934                    } else {
935                        // Since zero-value buckets are in order, we can break early if this bucket is past the
936                        // expiration cutoff of the counter. No other bucket will be within the expiration range.
937                        break;
938                    }
939                }
940            }
941
942            // Finally, figure out if the current metric can be removed.
943            //
944            // For any metric with values that are in open buckets, we split off the values that are in closed buckets
945            // and keep the metric alive. When all the values are in closed buckets, or there are no values, we'll
946            // remove the metric if `should_remove_if_empty` is `true`.
947            //
948            // This means we'll always remove all-closed/empty non-counter metrics, and we _may_ remove all-closed/empty
949            // counters.
950            if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
951                self.telemetry.increment_flushed(&closed_bucket_values);
952
953                // We got some closed bucket values, so flush those out.
954                transform_and_push_metric(
955                    context.clone(),
956                    closed_bucket_values,
957                    am.metadata.clone(),
958                    bucket_width_secs,
959                    &self.hist_config,
960                    dispatcher,
961                )
962                .await?;
963            }
964
965            if am.values.is_empty() && should_expire_if_empty {
966                self.telemetry.decrement_contexts(context, &am.values);
967                self.contexts_remove_buf.push(context.clone());
968            }
969        }
970
971        // Remove any contexts that were marked as needing to be removed.
972        let contexts_len_before = self.contexts.len();
973        for context in self.contexts_remove_buf.drain(..) {
974            self.contexts.remove(&context);
975        }
976        let contexts_len_after = self.contexts.len();
977
978        let contexts_delta = contexts_len_before.saturating_sub(contexts_len_after);
979        let target_contexts_capacity = contexts_len_after.saturating_add(contexts_delta / 2);
980        self.contexts.shrink_to(target_contexts_capacity);
981
982        if self.context_limit_breached && self.contexts.len() < self.context_limit {
983            self.context_limit_breached = false;
984        }
985
986        self.last_flush = current_time;
987
988        Ok(())
989    }
990
991    fn context_limit_breached(&self) -> bool {
992        self.context_limit_breached
993    }
994}
995
996async fn transform_and_push_metric(
997    context: Context, mut values: MetricValues, metadata: MetricMetadata, bucket_width_secs: NonZeroU64,
998    hist_config: &HistogramConfiguration, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
999) -> Result<(), GenericError> {
1000    let bucket_width = Duration::from_secs(bucket_width_secs.get());
1001
1002    match values {
1003        // If we're dealing with a histogram, we calculate a configured set of aggregates/percentiles from it, and emit
1004        // them as individual metrics.
1005        MetricValues::Histogram(ref mut points) => {
1006            // Convert histogram to distribution
1007            if hist_config.copy_to_distribution() {
1008                let sketch_points = points
1009                    .into_iter()
1010                    .map(|(ts, hist)| {
1011                        let mut sketch = DDSketch::default();
1012                        for sample in hist.samples() {
1013                            sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
1014                        }
1015                        (ts, sketch)
1016                    })
1017                    .collect::<SketchPoints>();
1018                let distribution_values = MetricValues::distribution(sketch_points);
1019                let metric_context = if !hist_config.copy_to_distribution_prefix().is_empty() {
1020                    context.with_name(format!(
1021                        "{}{}",
1022                        hist_config.copy_to_distribution_prefix(),
1023                        context.name()
1024                    ))
1025                } else {
1026                    context.clone()
1027                };
1028                let new_metric = Metric::from_parts(metric_context, distribution_values, metadata.clone());
1029                dispatcher.push(Event::Metric(new_metric)).await?;
1030            }
1031            // We collect our histogram points in their "summary" view, which sorts the underlying samples allowing
1032            // proper quantile queries to be answered, hence our "sorted" points. We do it this way because rather than
1033            // sort every time we insert, or cloning the points, we only sort when a summary view is constructed, which
1034            // requires mutable access to sort the samples in-place.
1035            let mut sorted_points = Vec::new();
1036            for (ts, h) in points {
1037                sorted_points.push((ts, h.summary_view()));
1038            }
1039
1040            for statistic in hist_config.statistics() {
1041                let new_points = sorted_points
1042                    .iter()
1043                    .map(|(ts, hs)| (*ts, statistic.value_from_histogram(hs)))
1044                    .collect::<ScalarPoints>();
1045
1046                let new_values = if statistic.is_rate_statistic() {
1047                    MetricValues::rate(new_points, bucket_width)
1048                } else {
1049                    MetricValues::gauge(new_points)
1050                };
1051
1052                // Counts are dimensionless, so clear any unit inherited from the input histogram.
1053                let new_metadata = if matches!(statistic, HistogramStatistic::Count) {
1054                    metadata.clone().with_unit(MetaString::empty())
1055                } else {
1056                    metadata.clone()
1057                };
1058
1059                let new_context = context.with_name(format!("{}.{}", context.name(), statistic.suffix()));
1060                let new_metric = Metric::from_parts(new_context, new_values, new_metadata);
1061                dispatcher.push(Event::Metric(new_metric)).await?;
1062            }
1063
1064            Ok(())
1065        }
1066
1067        // If we're not dealing with a histogram, then all we need to worry about is converting counters to rates before
1068        // forwarding our single, aggregated metric.
1069        values => {
1070            let adjusted_values = counter_values_to_rate(values, bucket_width_secs);
1071
1072            let metric = Metric::from_parts(context, adjusted_values, metadata);
1073            dispatcher.push(Event::Metric(metric)).await
1074        }
1075    }
1076}
1077
1078fn counter_values_to_rate(values: MetricValues, interval_secs: NonZeroU64) -> MetricValues {
1079    match values {
1080        MetricValues::Counter(points) => MetricValues::rate(points, Duration::from_secs(interval_secs.get())),
1081        values => values,
1082    }
1083}
1084
1085const fn align_to_bucket_start(timestamp: u64, bucket_width_secs: NonZeroU64) -> u64 {
1086    timestamp - (timestamp % bucket_width_secs.get())
1087}
1088
1089const fn is_bucket_closed(
1090    current_time: u64, bucket_start: u64, bucket_width_secs: NonZeroU64, flush_open_buckets: bool,
1091) -> bool {
1092    // A bucket is considered "closed" if the current time is greater than the end of the bucket, or if
1093    // `flush_open_buckets` is `true`.
1094    //
1095    // Buckets represent a half-open interval, where the start is inclusive and the end is exclusive. This means that
1096    // 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
1097    // 20, with the 20 excluded, or [10, 20) in interval notation. Simply put, if we have a timestamp of 10, or anything
1098    // smaller than 20, we would consider it to fall within the bucket... but 20 or more would be outside of the bucket.
1099    //
1100    // We can also represent this visually:
1101    //
1102    // <--------- bucket 1 ----------> <--------- bucket 2 ----------> <--------- bucket 3 ---------->
1103    // [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]
1104    //
1105    // We can see that each bucket is 10 seconds wide (10 elements, one for each second), and that their ends are
1106    // effectively `start + width - 1`. This means that for any of these buckets to be considered "closed", the current
1107    // time has to be _greater_ than `start + width - 1`. For example, if the current time is 19, then no buckets are
1108    // closed, and if the current time is 29, then bucket 1 is closed but buckets 2 and 3 are still open, and if the
1109    // current time is 30, then both buckets 1 and 2 are closed, but bucket 3 is still open.
1110    (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
1111}
1112
1113// TODO: One thing we ought to consider is a property test, specifically a state machine property test, where we
1114// generate a randomized offset to start time from, a bucket width, flush interval, and operations, and so on... and
1115// then we run it to make sure that we are always generating sequential timestamps for data points, etc.
1116#[cfg(test)]
1117mod tests {
1118    use std::{cell::Cell, mem::size_of};
1119
1120    use float_cmp::ApproxEqRatio as _;
1121    use saluki_context::tags::{Tag, TagSet};
1122    use saluki_core::{
1123        accounting::{ComponentRegistry, MemoryLimiter},
1124        components::{
1125            destinations::{Destination, DestinationBuilder, DestinationContext},
1126            sources::{Source, SourceBuilder, SourceContext},
1127            ComponentContext,
1128        },
1129        health::HealthRegistry,
1130        runtime::Supervisor,
1131        support::SubsystemIdentifier,
1132        topology::{interconnect::Dispatcher, OutputDefinition, OutputName, TopologyBlueprint},
1133    };
1134    use saluki_metrics::test::TestRecorder;
1135    use stringtheory::MetaString;
1136    use tokio::sync::{mpsc, oneshot};
1137
1138    use super::config::HistogramStatistic;
1139    use super::*;
1140
1141    const BUCKET_WIDTH_SECS: NonZeroU64 = NonZeroU64::new(10).expect("not zero");
1142    const BUCKET_WIDTH: Duration = Duration::from_secs(BUCKET_WIDTH_SECS.get());
1143    const COUNTER_EXPIRE_SECS: u64 = 20;
1144    const COUNTER_EXPIRE: Option<Duration> = Some(Duration::from_secs(COUNTER_EXPIRE_SECS));
1145
1146    /// Gets the bucket start timestamp for the given step.
1147    const fn bucket_ts(step: u64) -> u64 {
1148        align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
1149    }
1150
1151    /// Gets the insert timestamp for the given step.
1152    const fn insert_ts(step: u64) -> u64 {
1153        (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
1154    }
1155
1156    /// Gets the flush timestamp for the given step.
1157    const fn flush_ts(step: u64) -> u64 {
1158        BUCKET_WIDTH_SECS.get() * (step + 1)
1159    }
1160
1161    struct ControlledMetricSource {
1162        events: mpsc::Receiver<Event>,
1163    }
1164
1165    #[async_trait]
1166    impl Source for ControlledMetricSource {
1167        async fn run(mut self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
1168            let shutdown = context.take_shutdown_handle();
1169            tokio::pin!(shutdown);
1170            let mut events_open = true;
1171
1172            loop {
1173                select! {
1174                    _ = &mut shutdown => break,
1175                    maybe_event = self.events.recv(), if events_open => match maybe_event {
1176                        Some(event) => context.dispatcher().dispatch_one(event).await?,
1177                        None => events_open = false,
1178                    }
1179                }
1180            }
1181            Ok(())
1182        }
1183    }
1184
1185    struct ControlledMetricSourceBuilder {
1186        events: Mutex<Option<mpsc::Receiver<Event>>>,
1187        outputs: Vec<OutputDefinition<EventType>>,
1188    }
1189
1190    #[async_trait]
1191    impl SourceBuilder for ControlledMetricSourceBuilder {
1192        fn outputs(&self) -> &[OutputDefinition<EventType>] {
1193            &self.outputs
1194        }
1195
1196        async fn build(&self, _context: ComponentContext) -> Result<Box<dyn Source + Send>, GenericError> {
1197            let events = self
1198                .events
1199                .lock()
1200                .map_err(|_| generic_error!("controlled metric source receiver lock is poisoned"))?
1201                .take()
1202                .ok_or_else(|| generic_error!("controlled metric source receiver has already been taken"))?;
1203            Ok(Box::new(ControlledMetricSource { events }))
1204        }
1205    }
1206
1207    impl MemoryBounds for ControlledMetricSourceBuilder {
1208        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1209    }
1210
1211    struct DrainingMetricDestination;
1212
1213    #[async_trait]
1214    impl Destination for DrainingMetricDestination {
1215        async fn run(self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
1216            while context.events().next().await.is_some() {}
1217            Ok(())
1218        }
1219    }
1220
1221    struct DrainingMetricDestinationBuilder;
1222
1223    #[async_trait]
1224    impl DestinationBuilder for DrainingMetricDestinationBuilder {
1225        fn input_event_type(&self) -> EventType {
1226            EventType::Metric
1227        }
1228
1229        async fn build(&self, _context: ComponentContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1230            Ok(Box::new(DrainingMetricDestination))
1231        }
1232    }
1233
1234    impl MemoryBounds for DrainingMetricDestinationBuilder {
1235        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1236    }
1237
1238    struct DispatcherReceiver {
1239        receiver: mpsc::Receiver<EventsBuffer>,
1240    }
1241
1242    impl DispatcherReceiver {
1243        fn collect_next(&mut self) -> Vec<Metric> {
1244            match self.receiver.try_recv() {
1245                Ok(event_buffer) => {
1246                    let mut metrics = event_buffer
1247                        .into_iter()
1248                        .filter_map(|event| event.try_into_metric())
1249                        .collect::<Vec<Metric>>();
1250
1251                    metrics.sort_by(|a, b| a.context().name().cmp(b.context().name()));
1252                    metrics
1253                }
1254                Err(_) => Vec::new(),
1255            }
1256        }
1257    }
1258
1259    /// Constructs a basic `Dispatcher` with a fixed-size event buffer.
1260    fn build_basic_dispatcher() -> (EventsDispatcher, DispatcherReceiver) {
1261        let context = ComponentContext::test_transform("test");
1262        let mut dispatcher = Dispatcher::new(context);
1263
1264        let (buffer_tx, buffer_rx) = mpsc::channel(1);
1265        dispatcher.add_output(OutputName::Default).unwrap();
1266        dispatcher
1267            .attach_sender_to_output(&OutputName::Default, buffer_tx)
1268            .unwrap();
1269
1270        (dispatcher, DispatcherReceiver { receiver: buffer_rx })
1271    }
1272
1273    async fn get_flushed_metrics(timestamp: u64, state: &mut AggregationState) -> Vec<Metric> {
1274        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
1275        let mut buffered_dispatcher = dispatcher.buffered().expect("default output should always exist");
1276
1277        // Flush the metrics to an event buffer.
1278        state
1279            .flush(timestamp, true, &mut buffered_dispatcher)
1280            .await
1281            .expect("should not fail to flush aggregation state");
1282
1283        // Flush our buffered dispatcher, which should ensure that the event buffer is sent out, and then read it from the
1284        // receiver:
1285        buffered_dispatcher
1286            .flush()
1287            .await
1288            .expect("should not fail to flush buffered sender");
1289
1290        dispatcher_receiver.collect_next()
1291    }
1292
1293    macro_rules! compare_points {
1294        (scalar, $expected:expr, $actual:expr, $error_ratio:literal) => {
1295            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1296                let (expected_ts, expected_point) = expected_value;
1297                let (actual_ts, actual_point) = actual_value;
1298
1299                assert_eq!(
1300                    expected_ts, actual_ts,
1301                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1302                    idx, expected_ts, actual_ts
1303                );
1304                assert!(
1305                    expected_point.approx_eq_ratio(&actual_point, $error_ratio),
1306                    "point for value #{} does not match: {} (expected) vs {} (actual)",
1307                    idx,
1308                    expected_point,
1309                    actual_point
1310                );
1311            }
1312        };
1313        (distribution, $expected:expr, $actual:expr) => {
1314            for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1315                let (expected_ts, expected_sketch) = expected_value;
1316                let (actual_ts, actual_sketch) = actual_value;
1317
1318                assert_eq!(
1319                    expected_ts, actual_ts,
1320                    "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1321                    idx, expected_ts, actual_ts
1322                );
1323                assert_eq!(
1324                    expected_sketch, actual_sketch,
1325                    "sketch for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1326                    idx, expected_sketch, actual_sketch
1327                );
1328            }
1329        };
1330    }
1331
1332    macro_rules! assert_flushed_scalar_metric {
1333        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1334            assert_flushed_scalar_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1335        };
1336        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1337            let actual_metric = $actual;
1338
1339            assert_eq!($original.context(), actual_metric.context(), "expected context ({}) and actual context ({}) do not match", $original.context(), actual_metric.context());
1340
1341            let expected_points = ScalarPoints::from([$(($ts, $value)),+]);
1342
1343            match actual_metric.values() {
1344                MetricValues::Counter(ref actual_points) | MetricValues::Gauge(ref actual_points) | MetricValues::Rate(ref actual_points, _) => {
1345                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1346                    compare_points!(scalar, expected_points, actual_points, $error_ratio);
1347                },
1348                _ => panic!("only counters, rates, and gauges are supported in assert_flushed_scalar_metric"),
1349            }
1350        };
1351    }
1352
1353    macro_rules! assert_flushed_distribution_metric {
1354        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1355            assert_flushed_distribution_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1356        };
1357        ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1358            let actual_metric = $actual;
1359
1360            assert_eq!($original.context(), actual_metric.context());
1361
1362            match actual_metric.values() {
1363                MetricValues::Distribution(ref actual_points) => {
1364                    let expected_points = SketchPoints::from([$(($ts, $value)),+]);
1365                    assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1366
1367                    compare_points!(distribution, &expected_points, actual_points);
1368                },
1369                _ => panic!("only distributions are supported in assert_flushed_distribution_metric"),
1370            }
1371        };
1372    }
1373
1374    #[test]
1375    fn aggregate_metric_type_matches_every_metric_shape() {
1376        let cases = [
1377            (MetricValues::counter(1.0), AggregateMetricType::Counter),
1378            (
1379                MetricValues::rate(1.0, Duration::from_secs(10)),
1380                AggregateMetricType::Rate,
1381            ),
1382            (MetricValues::gauge(1.0), AggregateMetricType::Gauge),
1383            (MetricValues::set("value"), AggregateMetricType::Set),
1384            (MetricValues::histogram([1.0]), AggregateMetricType::Histogram),
1385            (
1386                MetricValues::distribution(&[1.0][..]),
1387                AggregateMetricType::Distribution,
1388            ),
1389        ];
1390
1391        for (values, expected) in cases {
1392            assert_eq!(AggregateMetricType::from(&values), expected);
1393        }
1394    }
1395
1396    #[test]
1397    fn snapshot_contexts_preserves_full_context_shape_and_unit() {
1398        let mut state = AggregationState::new(
1399            BUCKET_WIDTH_SECS,
1400            10,
1401            COUNTER_EXPIRE,
1402            HistogramConfiguration::default(),
1403            Telemetry::noop(),
1404        );
1405
1406        let histogram_context = Context::from_static_parts("request.duration", &["env:prod"])
1407            .with_host(Some(MetaString::from_static("host-a")))
1408            .with_origin_tags(TagSet::from(Tag::from_static("container:one")));
1409        let gauge_context = Context::from_static_parts("request.duration", &["env:prod"])
1410            .with_host(Some(MetaString::from_static("host-b")))
1411            .with_origin_tags(TagSet::from(Tag::from_static("container:two")));
1412        let histogram = Metric::from_parts(
1413            histogram_context.clone(),
1414            MetricValues::histogram([12.0]),
1415            MetricMetadata::default().with_unit(MetaString::from_static("millisecond")),
1416        );
1417        let gauge = Metric::gauge(gauge_context.clone(), 2.0);
1418
1419        assert!(state.insert(insert_ts(1), histogram));
1420        assert!(state.insert(insert_ts(1), gauge));
1421
1422        let mut snapshot = state.snapshot_contexts();
1423        assert_eq!(snapshot.len(), 2);
1424        assert!(snapshot.capacity() >= state.contexts.len());
1425        snapshot.sort_by(|a, b| a.context().host().cmp(&b.context().host()));
1426
1427        assert_eq!(snapshot[0].context(), &histogram_context);
1428        assert_eq!(snapshot[0].context().tags().len(), 1);
1429        assert_eq!(snapshot[0].context().origin_tags().len(), 1);
1430        assert_eq!(snapshot[0].metric_type(), AggregateMetricType::Histogram);
1431        assert_eq!(snapshot[0].unit(), Some("millisecond"));
1432
1433        assert_eq!(snapshot[1].context(), &gauge_context);
1434        assert_eq!(snapshot[1].metric_type(), AggregateMetricType::Gauge);
1435        assert_eq!(snapshot[1].unit(), None);
1436    }
1437
1438    #[tokio::test]
1439    async fn snapshot_contexts_follows_ordinary_context_lifecycle() {
1440        let mut state = AggregationState::new(
1441            BUCKET_WIDTH_SECS,
1442            10,
1443            COUNTER_EXPIRE,
1444            HistogramConfiguration::default(),
1445            Telemetry::noop(),
1446        );
1447        let context = Context::from_static_name("active.gauge");
1448
1449        assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1450        let active_snapshot = state.snapshot_contexts();
1451        assert_eq!(active_snapshot.len(), 1);
1452        assert_eq!(active_snapshot[0].context(), &context);
1453        assert_eq!(active_snapshot[0].metric_type(), AggregateMetricType::Gauge);
1454
1455        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1456        assert!(state.snapshot_contexts().is_empty());
1457    }
1458
1459    #[tokio::test]
1460    async fn snapshot_contexts_retains_idle_counter_until_expiry() {
1461        let mut state = AggregationState::new(
1462            BUCKET_WIDTH_SECS,
1463            10,
1464            COUNTER_EXPIRE,
1465            HistogramConfiguration::default(),
1466            Telemetry::noop(),
1467        );
1468        let context = Context::from_static_name("sparse.counter");
1469
1470        assert!(state.insert(insert_ts(1), Metric::counter(context.clone(), 1.0)));
1471        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1472        let first_snapshot = state.snapshot_contexts();
1473        assert_eq!(first_snapshot.len(), 1);
1474        assert_eq!(first_snapshot[0].context(), &context);
1475        assert_eq!(first_snapshot[0].metric_type(), AggregateMetricType::Counter);
1476
1477        let _ = get_flushed_metrics(flush_ts(2), &mut state).await;
1478        let second_snapshot = state.snapshot_contexts();
1479        assert_eq!(second_snapshot.len(), 1);
1480        assert_eq!(second_snapshot[0].context(), &context);
1481        assert_eq!(second_snapshot[0].metric_type(), AggregateMetricType::Counter);
1482
1483        let _ = get_flushed_metrics(flush_ts(3), &mut state).await;
1484        assert!(state.snapshot_contexts().is_empty());
1485    }
1486
1487    #[test]
1488    fn canceled_snapshot_response_skips_snapshot_construction() {
1489        let mut state = AggregationState::new(
1490            BUCKET_WIDTH_SECS,
1491            10,
1492            COUNTER_EXPIRE,
1493            HistogramConfiguration::default(),
1494            Telemetry::noop(),
1495        );
1496        assert!(state.insert(
1497            insert_ts(1),
1498            Metric::gauge(Context::from_static_name("canceled.snapshot"), 1.0),
1499        ));
1500        let (response, receiver) = oneshot::channel();
1501        drop(receiver);
1502        let snapshot_calls = Cell::new(0);
1503
1504        send_context_snapshot_if_open(response, || {
1505            snapshot_calls.set(snapshot_calls.get() + 1);
1506            state.snapshot_contexts()
1507        });
1508
1509        assert_eq!(snapshot_calls.get(), 0);
1510    }
1511
1512    #[test]
1513    fn open_snapshot_response_constructs_once_and_returns_exact_entries() {
1514        let mut state = AggregationState::new(
1515            BUCKET_WIDTH_SECS,
1516            10,
1517            COUNTER_EXPIRE,
1518            HistogramConfiguration::default(),
1519            Telemetry::noop(),
1520        );
1521        let context = Context::from_static_name("open.snapshot");
1522        assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1523        let expected = vec![AggregateContextSnapshotEntry {
1524            context,
1525            metric_type: AggregateMetricType::Gauge,
1526            unit: MetaString::empty(),
1527        }];
1528        let (response, mut receiver) = oneshot::channel();
1529        let snapshot_calls = Cell::new(0);
1530
1531        send_context_snapshot_if_open(response, || {
1532            snapshot_calls.set(snapshot_calls.get() + 1);
1533            state.snapshot_contexts()
1534        });
1535
1536        assert_eq!(snapshot_calls.get(), 1);
1537        assert_eq!(
1538            receiver.try_recv().expect("open requester should receive a snapshot"),
1539            expected
1540        );
1541    }
1542
1543    #[test]
1544    fn aggregate_memory_bounds_include_peak_context_snapshot() {
1545        let context_limit = 17;
1546        let config = AggregateConfiguration {
1547            context_limit,
1548            ..AggregateConfiguration::for_test()
1549        };
1550        let registry = ComponentRegistry::default();
1551        config.specify_bounds(&mut registry.bounds_builder(&SubsystemIdentifier::from_dotted("test")));
1552        let bounds = registry.as_bounds();
1553
1554        let expected_minimum = size_of::<Aggregate>();
1555        let aggregation_state_bytes = context_limit * (size_of::<Context>() + size_of::<AggregatedMetric>());
1556        let context_snapshot_bytes = context_limit * size_of::<AggregateContextSnapshotEntry>();
1557
1558        assert_eq!(bounds.total_minimum_required_bytes(), expected_minimum);
1559        assert_eq!(
1560            bounds.total_firm_limit_bytes(),
1561            expected_minimum + aggregation_state_bytes + context_snapshot_bytes
1562        );
1563    }
1564
1565    #[tokio::test]
1566    async fn production_owner_loop_serves_snapshots_and_stops_after_cancellation() {
1567        tokio::time::timeout(Duration::from_secs(5), async {
1568            let (snapshot_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
1569            let config = AggregateConfiguration {
1570                primary_flush_interval: Duration::from_secs(60),
1571                context_snapshot_receiver,
1572                ..AggregateConfiguration::for_test()
1573            };
1574
1575            let available_request_capacity = snapshot_handle.requests.capacity();
1576            let canceled_request = tokio::spawn({
1577                let snapshot_handle = snapshot_handle.clone();
1578                async move { snapshot_handle.snapshot().await }
1579            });
1580            while snapshot_handle.requests.capacity() == available_request_capacity {
1581                tokio::task::yield_now().await;
1582            }
1583            canceled_request.abort();
1584            assert!(canceled_request
1585                .await
1586                .expect_err("snapshot requester should be canceled")
1587                .is_cancelled());
1588
1589            let (events_tx, events_rx) = mpsc::channel(1);
1590            let source = ControlledMetricSourceBuilder {
1591                events: Mutex::new(Some(events_rx)),
1592                outputs: vec![OutputDefinition::default_output(EventType::Metric)],
1593            };
1594            let component_registry = ComponentRegistry::default();
1595            let mut blueprint = TopologyBlueprint::new("aggregate_snapshot_owner", &component_registry);
1596            blueprint
1597                .add_source("source", source)
1598                .expect("controlled source should be accepted")
1599                .add_transform("aggregate", config)
1600                .expect("aggregate transform should be accepted")
1601                .add_destination("destination", DrainingMetricDestinationBuilder)
1602                .expect("draining destination should be accepted");
1603            blueprint
1604                .connect_components_in_order(["source", "aggregate", "destination"])
1605                .expect("test topology should connect");
1606            blueprint
1607                .with_health_registry(HealthRegistry::new())
1608                .with_memory_limiter(MemoryLimiter::noop())
1609                .with_ambient_worker_pool();
1610
1611            let mut supervisor =
1612                Supervisor::new("aggregate-snapshot-owner").expect("test supervisor should be created");
1613            supervisor.add_worker(blueprint);
1614            let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
1615            let topology_task = tokio::spawn(async move { supervisor.run_with_shutdown(shutdown_rx).await });
1616
1617            let passthrough_context = Context::from_static_name("owner.loop.timestamped.gauge");
1618            events_tx
1619                .send(Event::Metric(Metric::gauge(
1620                    passthrough_context.clone(),
1621                    (insert_ts(1), 1.0),
1622                )))
1623                .await
1624                .expect("controlled source should accept a timestamped event");
1625
1626            let retained_context = Context::from_static_name("owner.loop.mixed.counter");
1627            let mixed_values = ScalarPoints::from_iter([(None, 2.0), (NonZeroU64::new(insert_ts(1)), 3.0)]);
1628            events_tx
1629                .send(Event::Metric(Metric::counter(retained_context.clone(), mixed_values)))
1630                .await
1631                .expect("controlled source should accept a mixed event");
1632
1633            let snapshot = loop {
1634                let snapshot = snapshot_handle
1635                    .snapshot()
1636                    .await
1637                    .expect("running aggregate should fulfill snapshots");
1638                if snapshot.iter().any(|entry| entry.context() == &retained_context) {
1639                    break snapshot;
1640                }
1641                tokio::task::yield_now().await;
1642            };
1643            assert_eq!(
1644                snapshot.len(),
1645                1,
1646                "only the mixed metric's retained portion belongs in state"
1647            );
1648            let entry = &snapshot[0];
1649            assert_eq!(entry.context(), &retained_context);
1650            assert_eq!(entry.metric_type(), AggregateMetricType::Counter);
1651            assert_eq!(entry.unit(), None);
1652            assert!(snapshot.iter().all(|entry| entry.context() != &passthrough_context));
1653
1654            drop(events_tx);
1655            drop(snapshot_handle);
1656            shutdown_tx.send(()).expect("test topology should still be running");
1657            let topology_result = topology_task.await.expect("topology task should not panic");
1658            assert!(
1659                topology_result.is_ok(),
1660                "topology should stop cleanly: {topology_result:?}"
1661            );
1662        })
1663        .await
1664        .expect("production aggregate owner loop should complete without spinning or hanging");
1665    }
1666
1667    #[tokio::test]
1668    async fn snapshot_handle_round_trips_through_responder() {
1669        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1670        let expected = vec![AggregateContextSnapshotEntry::for_test(
1671            Context::from_static_name("round.trip"),
1672            AggregateMetricType::Gauge,
1673            MetaString::from_static("widget"),
1674        )];
1675        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1676
1677        responder
1678            .respond(expected.clone())
1679            .await
1680            .expect("responder should receive and fulfill a snapshot request");
1681
1682        let actual = snapshot_task
1683            .await
1684            .expect("snapshot task should complete")
1685            .expect("snapshot request should succeed");
1686        assert_eq!(actual, expected);
1687    }
1688
1689    #[tokio::test]
1690    async fn snapshot_handle_reports_dropped_receiver() {
1691        let (handle, responder) = aggregate_context_snapshot_channel_for_test();
1692        drop(responder);
1693
1694        let error = handle
1695            .snapshot()
1696            .await
1697            .expect_err("snapshot should fail after its owner is dropped");
1698        assert!(error.to_string().contains("unavailable"));
1699    }
1700
1701    #[tokio::test]
1702    async fn snapshot_responder_stops_after_accepting_request_and_cancels_response() {
1703        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1704        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1705
1706        responder
1707            .stop_after_receiving()
1708            .await
1709            .expect("responder should accept the snapshot request before stopping");
1710
1711        let error = snapshot_task
1712            .await
1713            .expect("snapshot task should complete")
1714            .expect_err("snapshot should fail when the accepted response is dropped");
1715        assert!(error.to_string().contains("stopped before responding"));
1716    }
1717
1718    #[tokio::test]
1719    async fn aggregate_configuration_receiver_can_only_be_taken_once() {
1720        let config = AggregateConfiguration::for_test();
1721        let first = config.build(ComponentContext::test_transform("aggregate_one")).await;
1722        assert!(first.is_ok());
1723
1724        let second = config.build(ComponentContext::test_transform("aggregate_two")).await;
1725        let error = match second {
1726            Ok(_) => panic!("second build should not take the snapshot receiver again"),
1727            Err(error) => error,
1728        };
1729        assert!(error.to_string().contains("already been taken"));
1730    }
1731
1732    #[tokio::test]
1733    async fn snapshot_responder_reports_closed_request_channel() {
1734        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1735        drop(handle);
1736
1737        let error = responder
1738            .respond(Vec::new())
1739            .await
1740            .expect_err("responder should fail when every request handle is dropped");
1741        assert!(error.to_string().contains("closed"));
1742    }
1743
1744    #[tokio::test]
1745    async fn snapshot_responder_ignores_canceled_requester() {
1746        let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1747        let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1748        let pending_response = responder
1749            .receive()
1750            .await
1751            .expect("responder should accept the snapshot request");
1752
1753        snapshot_task.abort();
1754        let _ = snapshot_task.await;
1755
1756        pending_response.respond(Vec::new());
1757    }
1758
1759    #[test]
1760    fn bucket_is_closed() {
1761        // Cases are defined as:
1762        // (current time, bucket start, bucket width, flush open buckets, expected result)
1763        let cases = [
1764            // Bucket goes from [995, 1005), current time of 1000, so bucket is open.
1765            (1000, 995, BUCKET_WIDTH_SECS, false, false),
1766            (1000, 995, BUCKET_WIDTH_SECS, true, true),
1767            // Bucket goes from [1000, 1010), current time of 1000, so bucket is open.
1768            (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1769            (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1770            // Bucket goes from [1000, 1010), current time of 1010, so bucket is closed.
1771            (1010, 1000, BUCKET_WIDTH_SECS, false, true),
1772            (1010, 1000, BUCKET_WIDTH_SECS, true, true),
1773        ];
1774
1775        for (current_time, bucket_start, bucket_width_secs, flush_open_buckets, expected) in cases {
1776            let expected_reason = if expected {
1777                "closed, was open"
1778            } else {
1779                "open, was closed"
1780            };
1781
1782            assert_eq!(
1783                is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets),
1784                expected,
1785                "expected bucket to be {} (current_time={}, bucket_start={}, bucket_width={}, flush_open_buckets={})",
1786                expected_reason,
1787                current_time,
1788                bucket_start,
1789                bucket_width_secs,
1790                flush_open_buckets
1791            );
1792        }
1793    }
1794
1795    #[tokio::test]
1796    async fn context_limit() {
1797        // Create our aggregation state with a context limit of 2.
1798        let mut state = AggregationState::new(
1799            BUCKET_WIDTH_SECS,
1800            2,
1801            COUNTER_EXPIRE,
1802            HistogramConfiguration::default(),
1803            Telemetry::noop(),
1804        );
1805
1806        // Create four unique gauges, and insert all of them. The third and fourth should fail because we've reached
1807        // the context limit.
1808        let input_metrics = [
1809            Metric::gauge("metric1", 1.0),
1810            Metric::gauge("metric2", 2.0),
1811            Metric::gauge("metric3", 3.0),
1812            Metric::gauge("metric4", 4.0),
1813        ];
1814
1815        assert!(!state.context_limit_breached());
1816
1817        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1818        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1819        assert!(!state.context_limit_breached());
1820
1821        assert!(!state.insert(insert_ts(1), input_metrics[2].clone()));
1822        assert!(state.context_limit_breached());
1823        assert!(!state.insert(insert_ts(1), input_metrics[3].clone()));
1824        assert!(state.context_limit_breached());
1825
1826        // We should only see the first two gauges after flushing. The flush should also clear the breached flag since
1827        // contexts drop below the limit.
1828        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1829        assert_eq!(flushed_metrics.len(), 2);
1830        assert_eq!(input_metrics[0].context(), flushed_metrics[0].context());
1831        assert_eq!(input_metrics[1].context(), flushed_metrics[1].context());
1832        assert!(!state.context_limit_breached());
1833
1834        // We should be able to insert the third and fourth gauges now as the first two have been flushed, and along
1835        // with them, their contexts should no longer be tracked in the aggregation state:
1836        assert!(state.insert(insert_ts(2), input_metrics[2].clone()));
1837        assert!(state.insert(insert_ts(2), input_metrics[3].clone()));
1838
1839        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1840        assert_eq!(flushed_metrics.len(), 2);
1841        assert_eq!(input_metrics[2].context(), flushed_metrics[0].context());
1842        assert_eq!(input_metrics[3].context(), flushed_metrics[1].context());
1843    }
1844
1845    #[tokio::test]
1846    async fn context_limit_with_zero_value_counters() {
1847        // We test here to ensure that zero-value counters contribute to the context limit.
1848        let mut state = AggregationState::new(
1849            BUCKET_WIDTH_SECS,
1850            2,
1851            COUNTER_EXPIRE,
1852            HistogramConfiguration::default(),
1853            Telemetry::noop(),
1854        );
1855
1856        // Create our input metrics.
1857        let input_metrics = [
1858            Metric::counter("metric1", 1.0),
1859            Metric::counter("metric2", 2.0),
1860            Metric::counter("metric3", 3.0),
1861        ];
1862
1863        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1864        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1865
1866        // Flush the aggregation state, and observe they're both present.
1867        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1868        assert_eq!(flushed_metrics.len(), 2);
1869        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1870        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1871
1872        // Flush _again_ to ensure that we then emit zero-value variants for both counters.
1873        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1874        assert_eq!(flushed_metrics.len(), 2);
1875        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1876        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1877
1878        // Now try to insert a third counter, which should fail because we've reached the context limit.
1879        assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1880
1881        // Flush the aggregation state, and observe that we only see the two original counters.
1882        let flushed_metrics = get_flushed_metrics(flush_ts(3), &mut state).await;
1883        assert_eq!(flushed_metrics.len(), 2);
1884        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0]);
1885        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0]);
1886
1887        // With a fourth flush interval, the two counters should now have expired, and thus be dropped and no longer
1888        // contributing to the context limit.
1889        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1890        assert_eq!(flushed_metrics.len(), 0);
1891
1892        // Now we should be able to insert the third counter, and it should be the only one present after flushing.
1893        assert!(state.insert(insert_ts(5), input_metrics[2].clone()));
1894
1895        let flushed_metrics = get_flushed_metrics(flush_ts(5), &mut state).await;
1896        assert_eq!(flushed_metrics.len(), 1);
1897        assert_flushed_scalar_metric!(&input_metrics[2], &flushed_metrics[0], [bucket_ts(5) => 3.0]);
1898    }
1899
1900    #[tokio::test]
1901    async fn zero_value_counters() {
1902        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1903        let mut state = AggregationState::new(
1904            BUCKET_WIDTH_SECS,
1905            10,
1906            COUNTER_EXPIRE,
1907            HistogramConfiguration::default(),
1908            Telemetry::noop(),
1909        );
1910
1911        // Create two unique counters, and insert both of them.
1912        let input_metrics = [Metric::counter("metric1", 1.0), Metric::counter("metric2", 2.0)];
1913
1914        assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1915        assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1916
1917        // Flush the aggregation state, and observe they're both present.
1918        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1919        assert_eq!(flushed_metrics.len(), 2);
1920        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1921        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1922
1923        // Perform our second flush, which should have them as zero-value counters.
1924        let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1925        assert_eq!(flushed_metrics.len(), 2);
1926        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1927        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1928
1929        // Now, we'll pretend to skip a flush period and add updates to them again after that.
1930        assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1931        assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1932
1933        // Flush the aggregation state, and observe that we have two zero-value counters for the flush period we
1934        // skipped, but that we see them appear again in the fourth flush period.
1935        let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1936        assert_eq!(flushed_metrics.len(), 2);
1937        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0, bucket_ts(4) => 1.0]);
1938        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0, bucket_ts(4) => 2.0]);
1939
1940        // Now we'll skip multiple flush periods and ensure that we emit zero-value counters up until the point they
1941        // expire. As our zero-value counter expiration is 20 seconds, this is two flush periods, so we skip by three
1942        // flush periods, and we should only see the counters emitted for the first two.
1943        let flushed_metrics = get_flushed_metrics(flush_ts(7), &mut state).await;
1944        assert_eq!(flushed_metrics.len(), 2);
1945        assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1946        assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1947    }
1948
1949    #[tokio::test]
1950    async fn merge_identical_timestamped_values_on_flush() {
1951        // We're testing that we properly emit and expire zero-value counters in all relevant scenarios.
1952        let mut state = AggregationState::new(
1953            BUCKET_WIDTH_SECS,
1954            10,
1955            COUNTER_EXPIRE,
1956            HistogramConfiguration::default(),
1957            Telemetry::noop(),
1958        );
1959
1960        // Create one multi-value counter, and insert it.
1961        let input_metric = Metric::counter("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1962
1963        assert!(state.insert(insert_ts(1), input_metric.clone()));
1964
1965        // Flush the aggregation state, and observe the metric is present _and_ that we've properly merged all of the
1966        // values within the same timestamp.
1967        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1968        assert_eq!(flushed_metrics.len(), 1);
1969        assert_flushed_scalar_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => 15.0]);
1970    }
1971
1972    #[tokio::test]
1973    async fn histogram_statistics() {
1974        // We're testing that we properly emit individual metrics (min, max, sum, etc) for a histogram.
1975        let hist_config = HistogramConfiguration::from_statistics(
1976            &[
1977                HistogramStatistic::Count,
1978                HistogramStatistic::Sum,
1979                HistogramStatistic::Percentile {
1980                    q: 0.5,
1981                    suffix: "p50".into(),
1982                },
1983            ],
1984            false,
1985            "".into(),
1986        );
1987        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1988
1989        // Create one multi-value histogram and insert it.
1990        let input_metric = Metric::histogram("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1991        assert!(state.insert(insert_ts(1), input_metric.clone()));
1992
1993        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
1994        // the form of three metrics: count, sum, and p50.
1995        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1996        assert_eq!(flushed_metrics.len(), 3);
1997
1998        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
1999        // matter here, but we do need a `Metric` for it to compare the context to.
2000        let count_metric = Metric::rate("metric1.count", 0.0, Duration::from_secs(BUCKET_WIDTH_SECS.get()));
2001        let sum_metric = Metric::gauge("metric1.sum", 0.0);
2002        let p50_metric = Metric::gauge("metric1.p50", 0.0);
2003
2004        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
2005        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
2006        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[0], [bucket_ts(1) => 5.0]);
2007        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[1], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
2008        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[2], [bucket_ts(1) => 15.0]);
2009    }
2010
2011    #[tokio::test]
2012    async fn histogram_statistics_unit_propagation() {
2013        // We're testing that the unit from the input histogram metadata propagates to all flushed output metrics.
2014        let hist_config = HistogramConfiguration::from_statistics(
2015            &[
2016                HistogramStatistic::Count,
2017                HistogramStatistic::Sum,
2018                HistogramStatistic::Percentile {
2019                    q: 0.5,
2020                    suffix: "p50".into(),
2021                },
2022            ],
2023            false,
2024            "".into(),
2025        );
2026        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
2027
2028        // Build a histogram with unit = "millisecond", simulating what arrives from a DogStatsD `ms` metric.
2029        let context = Context::from_static_parts("metric1", &[]);
2030        let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
2031        let input_metric = Metric::from_parts(
2032            context,
2033            MetricValues::histogram([1.0_f64, 2.0, 3.0, 4.0, 5.0]),
2034            metadata,
2035        );
2036        assert!(state.insert(insert_ts(1), input_metric));
2037
2038        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2039        assert_eq!(flushed_metrics.len(), 3);
2040
2041        // Counts are dimensionless: the `.count` series must drop the unit while all other
2042        // aggregate series carry the unit from the input histogram.
2043        for metric in &flushed_metrics {
2044            let name = metric.context().name();
2045            if name.ends_with(".count") {
2046                assert_eq!(
2047                    metric.metadata().unit(),
2048                    None,
2049                    "flushed metric '{}' should be dimensionless",
2050                    name
2051                );
2052            } else {
2053                assert_eq!(
2054                    metric.metadata().unit(),
2055                    Some("millisecond"),
2056                    "flushed metric '{}' should carry unit='millisecond'",
2057                    name
2058                );
2059            }
2060        }
2061    }
2062
2063    #[tokio::test]
2064    async fn distributions() {
2065        // We're testing that we pass through distributions untouched.
2066        let mut state = AggregationState::new(
2067            BUCKET_WIDTH_SECS,
2068            10,
2069            COUNTER_EXPIRE,
2070            HistogramConfiguration::default(),
2071            Telemetry::noop(),
2072        );
2073
2074        // Create one multi-value distribution, with server-side aggregation, and insert it.
2075        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2076        let input_metric = Metric::distribution("metric1", &values[..]);
2077
2078        assert!(state.insert(insert_ts(1), input_metric.clone()));
2079
2080        // Flush the aggregation state, and observe that we've emitted the original distribution.
2081        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2082        assert_eq!(flushed_metrics.len(), 1);
2083
2084        assert_flushed_distribution_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
2085    }
2086
2087    #[tokio::test]
2088    async fn histogram_copy_to_distribution() {
2089        let hist_config = HistogramConfiguration::from_statistics(
2090            &[
2091                HistogramStatistic::Count,
2092                HistogramStatistic::Sum,
2093                HistogramStatistic::Percentile {
2094                    q: 0.5,
2095                    suffix: "p50".into(),
2096                },
2097            ],
2098            true,
2099            "dist_prefix.".into(),
2100        );
2101        let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
2102
2103        // Create one multi-value histogram and insert it.
2104        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2105        let input_metric = Metric::histogram("metric1", values);
2106        assert!(state.insert(insert_ts(1), input_metric.clone()));
2107
2108        // Flush the aggregation state, and observe that we've emitted all of the configured distribution statistics in
2109        // the form of three metrics: count, sum, and p50 as well as the additional metric from copying the histogram.
2110        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2111        assert_eq!(flushed_metrics.len(), 4);
2112
2113        // Create versions of the metric for each of the statistics we're expecting to emit. The values themselves don't
2114        // matter here, but we do need a `Metric` for it to compare the context to.
2115        let count_metric = Metric::rate("metric1.count", 0.0, BUCKET_WIDTH);
2116        let sum_metric = Metric::gauge("metric1.sum", 0.0);
2117        let p50_metric = Metric::gauge("metric1.p50", 0.0);
2118        let expected_distribution = Metric::distribution("dist_prefix.metric1", &values[..]);
2119
2120        // We use a less strict error ratio (how much the expected vs actual) for the percentile check, as we generally
2121        // expect the value to be somewhat off the exact value due to the lossy nature of `DDSketch`.
2122        assert_flushed_distribution_metric!(expected_distribution, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
2123        assert_flushed_scalar_metric!(count_metric, &flushed_metrics[1], [bucket_ts(1) => 5.0]);
2124        assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[2], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
2125        assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[3], [bucket_ts(1) => 15.0]);
2126    }
2127
2128    #[tokio::test]
2129    async fn nonaggregated_counters_to_rate() {
2130        let counter_value = 42.0;
2131
2132        // Create a basic aggregation state.
2133        let mut state = AggregationState::new(
2134            BUCKET_WIDTH_SECS,
2135            10,
2136            COUNTER_EXPIRE,
2137            HistogramConfiguration::default(),
2138            Telemetry::noop(),
2139        );
2140
2141        // Create a simple non-aggregated counter, and insert it.
2142        let input_metric = Metric::counter("metric1", counter_value);
2143        assert!(state.insert(insert_ts(1), input_metric.clone()));
2144
2145        // Flush the aggregation state, and observe that we've emitted the expected counter and that it has the right
2146        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
2147        let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2148        assert_eq!(flushed_metrics.len(), 1);
2149        let flushed_metric = &flushed_metrics[0];
2150
2151        assert_flushed_scalar_metric!(&input_metric, flushed_metric, [bucket_ts(1) => counter_value]);
2152        assert_eq!(flushed_metric.values().as_str(), "rate");
2153    }
2154
2155    #[tokio::test]
2156    async fn preaggregated_counters_to_rate() {
2157        let counter_value = 42.0;
2158        let timestamp = 123456;
2159
2160        // Create a basic passthrough batcher and forwarder.
2161        let mut batcher = PassthroughBatcher::new(Duration::from_nanos(1), BUCKET_WIDTH_SECS, Telemetry::noop()).await;
2162        let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
2163
2164        // Create a simple pre-aggregated counter, and batch it.
2165        let input_metric = Metric::counter("metric1", (timestamp, counter_value));
2166        batcher.push_metric(input_metric.clone(), &dispatcher).await;
2167
2168        // Flush the batcher, and observe that we've emitted the expected counter and that it has the right
2169        // value, but specifically that it's a rate with an interval that matches our configured bucket width:
2170        batcher.try_flush(&dispatcher).await;
2171
2172        let mut flushed_metrics = dispatcher_receiver.collect_next();
2173        assert_eq!(flushed_metrics.len(), 1);
2174        assert_eq!(
2175            Metric::rate("metric1", (timestamp, counter_value), BUCKET_WIDTH),
2176            flushed_metrics.remove(0)
2177        );
2178    }
2179
2180    #[tokio::test]
2181    async fn telemetry() {
2182        // TODO: We don't check `component_events_dropped_total` or `aggregate_passthrough_metrics_total` here as
2183        // they're set directly in the aggregate component future rather than `AggregationState`, which is harder to
2184        // drive overall and would have required even more boilerplate.
2185        //
2186        // Leaving that as a future improvement.
2187
2188        let recorder = TestRecorder::default();
2189        let _local = metrics::set_default_local_recorder(&recorder);
2190
2191        let builder = MetricsBuilder::default();
2192        let telemetry = Telemetry::new(&builder);
2193
2194        let mut state = AggregationState::new(
2195            BUCKET_WIDTH_SECS,
2196            2,
2197            COUNTER_EXPIRE,
2198            HistogramConfiguration::default(),
2199            telemetry,
2200        );
2201
2202        // Make sure our telemetry is registered at default values.
2203        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(0.0));
2204        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
2205        assert_eq!(
2206            recorder.counter(("component_events_dropped_total", &[("intentional", "true")])),
2207            Some(0)
2208        );
2209        for metric_type in &["counter", "gauge", "rate", "set", "histogram", "distribution"] {
2210            assert_eq!(
2211                recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", *metric_type)])),
2212                Some(0.0)
2213            );
2214        }
2215
2216        // Insert a counter with a non-timestamped value.
2217        assert!(state.insert(insert_ts(1), Metric::counter("metric1", 42.0)));
2218        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2219        assert_eq!(
2220            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2221            Some(1.0)
2222        );
2223        assert_eq!(recorder.counter("aggregate_passthrough_metrics_total"), Some(0));
2224
2225        // Insert a gauge with a timestamped value.
2226        assert!(state.insert(insert_ts(1), Metric::gauge("metric2", (insert_ts(1), 42.0))));
2227        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2228        assert_eq!(
2229            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2230            Some(1.0)
2231        );
2232
2233        // We've reached our context limit at this point, so the next metric should not be inserted.
2234        assert!(!state.insert(insert_ts(1), Metric::counter("metric3", 42.0)));
2235        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2236        assert_eq!(
2237            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2238            Some(1.0)
2239        );
2240
2241        // Now let's flush the state which should flush the gauge entirely, reducing the context count, but not flush
2242        // the counter, since it'll be in zero-value mode.
2243        let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
2244        assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2245        assert_eq!(
2246            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2247            Some(1.0)
2248        );
2249        assert_eq!(
2250            recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2251            Some(0.0)
2252        );
2253    }
2254}