1use std::{
2 future::pending,
3 num::NonZeroU64,
4 sync::Mutex,
5 time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use ddsketch::DDSketch;
10use hashbrown::{hash_map::Entry, HashMap};
11use saluki_common::time::get_unix_timestamp;
12use saluki_core::{
13 accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr},
14 components::{transforms::*, BuildContext},
15 data_model::event::{
16 metric::{context::Context, *},
17 Event, EventType,
18 },
19 observability::ComponentMetricsExt as _,
20 topology::{interconnect::BufferedDispatcher, EventsBuffer, OutputDefinition},
21};
22use saluki_error::{generic_error, GenericError};
23use saluki_metrics::MetricsBuilder;
24use smallvec::SmallVec;
25use stringtheory::MetaString;
26use tokio::{
27 select,
28 sync::{mpsc, oneshot},
29 time::interval_at,
30};
31use tracing::{debug, error, info, trace, warn};
32
33mod telemetry;
34use self::telemetry::{FlushCounts, Telemetry};
35
36mod config;
37pub use self::config::HistogramConfiguration;
38use self::config::HistogramStatistic;
39
40const CONTEXT_SNAPSHOT_REQUEST_CHANNEL_CAPACITY: usize = 1;
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum AggregateMetricType {
45 Counter,
47
48 Rate,
50
51 Gauge,
53
54 Set,
56
57 Histogram,
59
60 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#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct AggregateContextSnapshotEntry {
82 context: Context,
83 metric_type: AggregateMetricType,
84 unit: MetaString,
85}
86
87impl AggregateContextSnapshotEntry {
88 pub fn context(&self) -> &Context {
90 &self.context
91 }
92
93 pub fn metric_type(&self) -> AggregateMetricType {
95 self.metric_type
96 }
97
98 pub fn unit(&self) -> Option<&str> {
100 if self.unit.is_empty() {
101 None
102 } else {
103 Some(&self.unit)
104 }
105 }
106
107 #[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#[derive(Clone, Debug)]
127pub struct AggregateContextSnapshotHandle {
128 requests: mpsc::Sender<AggregateContextSnapshotRequest>,
129}
130
131impl AggregateContextSnapshotHandle {
132 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
153pub 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#[cfg(any(test, feature = "test-util"))]
181pub struct AggregateContextSnapshotPendingResponse {
182 response: AggregateContextSnapshotRequest,
183}
184
185#[cfg(any(test, feature = "test-util"))]
186impl AggregateContextSnapshotPendingResponse {
187 pub fn respond(self, snapshot: Vec<AggregateContextSnapshotEntry>) {
191 let _ = self.response.send(snapshot);
192 }
193}
194
195#[cfg(any(test, feature = "test-util"))]
197pub struct AggregateContextSnapshotResponder {
198 receiver: AggregateContextSnapshotRequestReceiver,
199}
200
201#[cfg(any(test, feature = "test-util"))]
202impl AggregateContextSnapshotResponder {
203 pub async fn respond(&mut self, snapshot: Vec<AggregateContextSnapshotEntry>) -> Result<(), GenericError> {
211 self.receive().await?.respond(snapshot);
212 Ok(())
213 }
214
215 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 pub async fn stop_after_receiving(&mut self) -> Result<(), GenericError> {
241 drop(self.receive().await?);
242 Ok(())
243 }
244}
245
246#[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
257pub 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
277pub struct AggregateConfiguration {
297 pub window_duration_seconds: NonZeroU64,
303
304 pub primary_flush_interval: Duration,
309
310 pub context_limit: usize,
319
320 pub flush_open_windows: bool,
330
331 pub counter_expiry_seconds: Option<u64>,
344
345 pub hist_config: HistogramConfiguration,
347
348 pub context_snapshot_receiver: AggregateContextSnapshotReceiver,
353}
354
355#[cfg(test)]
356impl AggregateConfiguration {
357 fn for_test() -> Self {
362 let (_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
363
364 Self {
365 window_duration_seconds: NonZeroU64::new(10).expect("not zero"),
366 primary_flush_interval: Duration::from_secs(15),
367 context_limit: 1_000_000,
368 flush_open_windows: false,
369 counter_expiry_seconds: Some(300),
370 hist_config: HistogramConfiguration::default(),
371 context_snapshot_receiver,
372 }
373 }
374}
375
376#[async_trait]
377impl TransformBuilder for AggregateConfiguration {
378 async fn build(&self, context: BuildContext) -> Result<Box<dyn Transform + Send>, GenericError> {
379 let context_snapshot_requests = self.context_snapshot_receiver.take_receiver()?;
380 let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
381 let telemetry = Telemetry::new(&metrics_builder);
382
383 let state = AggregationState::new(
384 self.window_duration_seconds,
385 self.context_limit,
386 self.counter_expiry_seconds.filter(|s| *s != 0).map(Duration::from_secs),
387 self.hist_config.clone(),
388 telemetry.clone(),
389 );
390
391 Ok(Box::new(Aggregate {
392 state,
393 telemetry,
394 primary_flush_interval: self.primary_flush_interval,
395 flush_open_windows: self.flush_open_windows,
396 context_snapshot_requests: Some(context_snapshot_requests),
397 }))
398 }
399
400 fn input_event_type(&self) -> EventType {
401 EventType::Metric
402 }
403
404 fn outputs(&self) -> &[OutputDefinition<EventType>] {
405 static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
406 OUTPUTS
407 }
408}
409
410impl MemoryBounds for AggregateConfiguration {
411 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
412 builder
421 .minimum()
422 .with_single_value::<Aggregate>("component struct");
424 builder
425 .firm()
426 .with_expr(UsageExpr::product(
428 "aggregation state map",
429 UsageExpr::sum(
430 "context map entry",
431 UsageExpr::struct_size::<Context>("context"),
432 UsageExpr::struct_size::<AggregatedMetric>("aggregated metric"),
433 ),
434 UsageExpr::config("aggregate_context_limit", self.context_limit),
435 ))
436 .with_expr(UsageExpr::product(
438 "retained context snapshot",
439 UsageExpr::struct_size::<AggregateContextSnapshotEntry>("snapshot entry"),
440 UsageExpr::config("aggregate_context_limit", self.context_limit),
441 ));
442 }
443}
444
445pub struct Aggregate {
446 state: AggregationState,
447 telemetry: Telemetry,
448 primary_flush_interval: Duration,
449 flush_open_windows: bool,
450 context_snapshot_requests: Option<AggregateContextSnapshotRequestReceiver>,
451}
452
453#[async_trait]
454impl Transform for Aggregate {
455 async fn run(mut self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
456 let mut health = context.take_health_handle();
457
458 let mut primary_flush = interval_at(
459 tokio::time::Instant::now() + self.primary_flush_interval,
460 self.primary_flush_interval,
461 );
462 let mut final_primary_flush = false;
463
464 health.mark_ready();
465 debug!("Aggregation transform started.");
466
467 loop {
468 select! {
469 _ = health.live() => continue,
470 _ = primary_flush.tick() => {
471 if !self.state.is_empty() {
475 debug!("Flushing aggregated metrics...");
476
477 let should_flush_open_windows = final_primary_flush && self.flush_open_windows;
478
479 let was_breached = self.state.context_limit_breached();
481
482 let mut dispatcher = context.dispatcher().buffered().expect("default output should always exist");
483 let flush = async {
484 let flush_start = Instant::now();
485
486 if let Err(e) = self.state.flush(get_unix_timestamp(), should_flush_open_windows, &mut dispatcher).await {
487 error!(error = %e, "Failed to flush aggregation state.");
488 }
489
490 self.telemetry.increment_flushes();
491
492 if was_breached && !self.state.context_limit_breached() {
494 info!("Context limit no longer exceeded, metrics are being accepted again.");
495 }
496
497 match dispatcher.flush().await {
498 Ok(aggregated_events) => debug!(aggregated_events, "Dispatched events."),
499 Err(e) => error!(error = %e, "Failed to flush aggregated events."),
500 }
501
502 self.telemetry.record_last_flush_duration(flush_start.elapsed());
503 };
504 tokio::pin!(flush);
505
506 loop {
510 select! {
511 biased;
512 _ = health.live() => {},
513 _ = &mut flush => break,
514 }
515 }
516 }
517
518 if final_primary_flush {
520 debug!("All aggregation complete.");
521 break
522 }
523 },
524 snapshot_request = receive_context_snapshot_request(&mut self.context_snapshot_requests) => {
525 match snapshot_request {
526 Some(response) => {
527 send_context_snapshot_if_open(response, || self.state.snapshot_contexts());
528 }
529 None => self.context_snapshot_requests = None,
530 }
531 },
532 maybe_events = context.events().next(), if !final_primary_flush => match maybe_events {
533 Some(events) => {
534 trace!(events_len = events.len(), "Received events.");
535
536 let current_time = get_unix_timestamp();
537
538 for event in events {
539 if let Some(metric) = event.try_into_metric() {
540 let was_breached = self.state.context_limit_breached();
541 if !self.state.insert(current_time, metric) {
542 trace!("Dropping metric due to context limit.");
543 if !was_breached {
544 warn!(context_limit = self.state.context_limit, "Context limit reached, \
546 dropping metrics. Consider increasing `aggregate_context_limit`.");
547 }
548 self.telemetry.increment_events_dropped();
549 }
550 }
551 }
552 },
553 None => {
554 final_primary_flush = true;
557 primary_flush.reset_immediately();
558
559 debug!("Aggregation transform stopping...");
560 }
561 },
562 }
563 }
564
565 debug!("Aggregation transform stopped.");
566
567 Ok(())
568 }
569}
570
571async fn receive_context_snapshot_request(
572 receiver: &mut Option<AggregateContextSnapshotRequestReceiver>,
573) -> Option<AggregateContextSnapshotRequest> {
574 match receiver {
575 Some(receiver) => receiver.recv().await,
576 None => pending().await,
577 }
578}
579
580#[derive(Clone)]
581struct AggregatedMetric {
582 values: MetricValues,
583 metadata: MetricMetadata,
584 last_seen: u64,
585}
586
587struct AggregationState {
588 contexts: HashMap<Context, AggregatedMetric, foldhash::quality::RandomState>,
589 contexts_remove_buf: Vec<Context>,
590 context_limit: usize,
591 bucket_width_secs: NonZeroU64,
592 counter_expire_secs: Option<NonZeroU64>,
593 last_flush: u64,
594 hist_config: HistogramConfiguration,
595 telemetry: Telemetry,
596 context_limit_breached: bool,
599}
600
601impl AggregationState {
602 fn new(
603 bucket_width_secs: NonZeroU64, context_limit: usize, counter_expiration: Option<Duration>,
604 hist_config: HistogramConfiguration, telemetry: Telemetry,
605 ) -> Self {
606 let counter_expire_secs = counter_expiration.map(|d| d.as_secs()).and_then(NonZeroU64::new);
607
608 Self {
609 contexts: HashMap::default(),
610 contexts_remove_buf: Vec::new(),
611 context_limit,
612 bucket_width_secs,
613 counter_expire_secs,
614 last_flush: 0,
615 hist_config,
616 telemetry,
617 context_limit_breached: false,
618 }
619 }
620
621 fn is_empty(&self) -> bool {
622 self.contexts.is_empty()
623 }
624
625 fn snapshot_contexts(&self) -> Vec<AggregateContextSnapshotEntry> {
626 let mut snapshot = Vec::with_capacity(self.contexts.len());
627 for (context, aggregated) in &self.contexts {
628 snapshot.push(AggregateContextSnapshotEntry {
629 context: context.clone(),
630 metric_type: AggregateMetricType::from(&aggregated.values),
631 unit: aggregated.metadata.unit.clone(),
632 });
633 }
634 snapshot
635 }
636
637 fn insert(&mut self, timestamp: u64, metric: Metric) -> bool {
638 if !self.contexts.contains_key(metric.context()) && self.contexts.len() >= self.context_limit {
640 self.context_limit_breached = true;
641 return false;
642 }
643
644 if self.contexts.is_empty() {
648 self.last_flush = 0;
649 }
650
651 let (context, mut values, metadata) = metric.into_parts();
652
653 let bucket_ts = align_to_bucket_start(timestamp, self.bucket_width_secs);
658 values.collapse_non_timestamped(bucket_ts);
659
660 trace!(
661 bucket_ts,
662 kind = values.as_str(),
663 "Inserting metric into aggregation state."
664 );
665
666 match self.contexts.entry(context) {
669 Entry::Occupied(mut entry) => {
670 let aggregated = entry.get_mut();
671
672 aggregated.last_seen = timestamp;
674 aggregated.values.merge(values);
675 }
676 Entry::Vacant(entry) => {
677 self.telemetry.increment_contexts(entry.key(), &values);
678
679 entry.insert(AggregatedMetric {
680 values,
681 metadata,
682 last_seen: timestamp,
683 });
684
685 saluki_antithesis::always_le!(
686 self.contexts.len(),
687 self.context_limit,
688 "aggregate context map within context_limit",
689 { "len": self.contexts.len(), "limit": self.context_limit }
690 );
691 }
692 }
693
694 true
695 }
696
697 async fn flush(
698 &mut self, current_time: u64, flush_open_buckets: bool, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
699 ) -> Result<(), GenericError> {
700 let bucket_width_secs = self.bucket_width_secs;
701 let counter_expire_secs = self.counter_expire_secs.map(|d| d.get()).unwrap_or(0);
702
703 let split_timestamp = if flush_open_buckets {
706 u64::MAX
707 } else {
708 align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1)
709 };
710
711 let mut zero_value_buckets = SmallVec::<[(u64, MetricValues); 4]>::new();
716 if self.last_flush != 0 {
717 let start = align_to_bucket_start(self.last_flush, bucket_width_secs);
718
719 saluki_antithesis::always_ge!(
724 current_time,
725 self.last_flush,
726 "aggregate flush wall-clock did not move backward",
727 { "current_time": current_time, "last_flush": self.last_flush }
728 );
729 saluki_antithesis::always_le!(
732 current_time.saturating_sub(self.last_flush) / bucket_width_secs.get(),
733 10_000,
734 "aggregate zero-value bucket span bounded across a flush",
735 {
736 "current_time": current_time,
737 "last_flush": self.last_flush,
738 "bucket_width_secs": bucket_width_secs.get()
739 }
740 );
741
742 for bucket_start in (start..current_time).step_by(bucket_width_secs.get() as usize) {
743 if is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets) {
744 zero_value_buckets.push((bucket_start, MetricValues::counter((bucket_start, 0.0))));
745 }
746 }
747
748 saluki_antithesis::sometimes!(
750 !zero_value_buckets.is_empty(),
751 "aggregate flush generated zero-value counter buckets",
752 { "count": zero_value_buckets.len() }
753 );
754 }
755
756 debug!(timestamp = current_time, "Flushing buckets.");
758
759 let mut flush_counts = FlushCounts::default();
760
761 for (context, am) in self.contexts.iter_mut() {
762 let should_expire_if_empty = match &am.values {
771 MetricValues::Counter(..) => {
772 saluki_antithesis::always_le!(
773 am.last_seen,
774 u64::MAX - counter_expire_secs,
775 "aggregate counter expiry add does not overflow",
776 { "last_seen": am.last_seen, "counter_expire_secs": counter_expire_secs }
777 );
778 counter_expire_secs == 0 || am.last_seen.saturating_add(counter_expire_secs) < current_time
779 }
780 _ => true,
781 };
782
783 if let MetricValues::Counter(..) = &mut am.values {
789 let expires_at = am.last_seen.saturating_add(counter_expire_secs);
790 for (zv_bucket_start, zero_value) in &zero_value_buckets {
791 if expires_at > *zv_bucket_start {
792 am.values.merge(zero_value.clone());
793 } else {
794 break;
797 }
798 }
799 }
800
801 if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
810 self.telemetry
811 .increment_flushed(&closed_bucket_values, &mut flush_counts);
812
813 transform_and_push_metric(
815 context.clone(),
816 closed_bucket_values,
817 am.metadata.clone(),
818 bucket_width_secs,
819 &self.hist_config,
820 dispatcher,
821 )
822 .await?;
823 }
824
825 if am.values.is_empty() && should_expire_if_empty {
826 self.telemetry.decrement_contexts(context, &am.values);
827 self.contexts_remove_buf.push(context.clone());
828 }
829 }
830
831 let contexts_len_before = self.contexts.len();
833 for context in self.contexts_remove_buf.drain(..) {
834 self.contexts.remove(&context);
835 }
836 let contexts_len_after = self.contexts.len();
837
838 let contexts_delta = contexts_len_before.saturating_sub(contexts_len_after);
839 let target_contexts_capacity = contexts_len_after.saturating_add(contexts_delta / 2);
840 self.contexts.shrink_to(target_contexts_capacity);
841
842 if self.context_limit_breached && self.contexts.len() < self.context_limit {
843 self.context_limit_breached = false;
844 }
845
846 self.last_flush = current_time;
847 self.telemetry.record_last_flush_counts(&flush_counts);
848
849 Ok(())
850 }
851
852 fn context_limit_breached(&self) -> bool {
853 self.context_limit_breached
854 }
855}
856
857async fn transform_and_push_metric(
858 context: Context, mut values: MetricValues, metadata: MetricMetadata, bucket_width_secs: NonZeroU64,
859 hist_config: &HistogramConfiguration, dispatcher: &mut BufferedDispatcher<'_, EventsBuffer>,
860) -> Result<(), GenericError> {
861 let bucket_width = Duration::from_secs(bucket_width_secs.get());
862
863 match values {
864 MetricValues::Histogram(ref mut points) => {
867 if hist_config.copy_to_distribution() {
869 let sketch_points = points
870 .into_iter()
871 .map(|(ts, hist)| {
872 let mut sketch = DDSketch::default();
873 for sample in hist.samples() {
874 sketch.insert_n(sample.value.into_inner(), sample.weight.0 as u64);
875 }
876 (ts, sketch)
877 })
878 .collect::<SketchPoints>();
879 let distribution_values = MetricValues::distribution(sketch_points);
880 let metric_context = if !hist_config.copy_to_distribution_prefix().is_empty() {
881 context.with_name(format!(
882 "{}{}",
883 hist_config.copy_to_distribution_prefix(),
884 context.name()
885 ))
886 } else {
887 context.clone()
888 };
889 let new_metric = Metric::from_parts(metric_context, distribution_values, metadata.clone());
890 dispatcher.push(Event::Metric(new_metric)).await?;
891 }
892 let mut sorted_points = Vec::new();
897 for (ts, h) in points {
898 sorted_points.push((ts, h.summary_view()));
899 }
900
901 for statistic in hist_config.statistics() {
902 let new_points = sorted_points
903 .iter()
904 .map(|(ts, hs)| (*ts, statistic.value_from_histogram(hs)))
905 .collect::<ScalarPoints>();
906
907 let new_values = if statistic.is_rate_statistic() {
908 MetricValues::rate(new_points, bucket_width)
909 } else {
910 MetricValues::gauge(new_points)
911 };
912
913 let new_metadata = if matches!(statistic, HistogramStatistic::Count) {
915 metadata.clone().with_unit(MetaString::empty())
916 } else {
917 metadata.clone()
918 };
919
920 let new_context = context.with_name(format!("{}.{}", context.name(), statistic.suffix()));
921 let new_metric = Metric::from_parts(new_context, new_values, new_metadata);
922 dispatcher.push(Event::Metric(new_metric)).await?;
923 }
924
925 Ok(())
926 }
927
928 values => {
931 let adjusted_values = counter_values_to_rate(values, bucket_width_secs);
932
933 let metric = Metric::from_parts(context, adjusted_values, metadata);
934 dispatcher.push(Event::Metric(metric)).await
935 }
936 }
937}
938
939fn counter_values_to_rate(values: MetricValues, interval_secs: NonZeroU64) -> MetricValues {
940 match values {
941 MetricValues::Counter(points) => MetricValues::rate(points, Duration::from_secs(interval_secs.get())),
942 values => values,
943 }
944}
945
946const fn align_to_bucket_start(timestamp: u64, bucket_width_secs: NonZeroU64) -> u64 {
947 timestamp - (timestamp % bucket_width_secs.get())
948}
949
950const fn is_bucket_closed(
951 current_time: u64, bucket_start: u64, bucket_width_secs: NonZeroU64, flush_open_buckets: bool,
952) -> bool {
953 (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
972}
973
974#[cfg(test)]
978mod tests {
979 use std::{cell::Cell, mem::size_of};
980
981 use float_cmp::ApproxEqRatio as _;
982 use saluki_core::{
983 accounting::ComponentRegistry,
984 components::{test_util::TestComponentDriver, ComponentContext},
985 data_model::tags::{Tag, TagSet},
986 support::SubsystemIdentifier,
987 topology::{interconnect::Dispatcher, EventsDispatcher, OutputName},
988 };
989 use saluki_metrics::test::TestRecorder;
990 use stringtheory::MetaString;
991 use tokio::sync::{mpsc, oneshot};
992
993 use super::config::HistogramStatistic;
994 use super::*;
995
996 const BUCKET_WIDTH_SECS: NonZeroU64 = NonZeroU64::new(10).expect("not zero");
997 const BUCKET_WIDTH: Duration = Duration::from_secs(BUCKET_WIDTH_SECS.get());
998 const COUNTER_EXPIRE_SECS: u64 = 20;
999 const COUNTER_EXPIRE: Option<Duration> = Some(Duration::from_secs(COUNTER_EXPIRE_SECS));
1000
1001 const fn bucket_ts(step: u64) -> u64 {
1003 align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
1004 }
1005
1006 const fn insert_ts(step: u64) -> u64 {
1008 (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
1009 }
1010
1011 const fn flush_ts(step: u64) -> u64 {
1013 BUCKET_WIDTH_SECS.get() * (step + 1)
1014 }
1015
1016 struct DispatcherReceiver {
1017 receiver: mpsc::Receiver<EventsBuffer>,
1018 }
1019
1020 impl DispatcherReceiver {
1021 fn collect_next(&mut self) -> Vec<Metric> {
1022 match self.receiver.try_recv() {
1023 Ok(event_buffer) => {
1024 let mut metrics = event_buffer
1025 .into_iter()
1026 .filter_map(|event| event.try_into_metric())
1027 .collect::<Vec<Metric>>();
1028
1029 metrics.sort_by(|a, b| a.context().name().cmp(b.context().name()));
1030 metrics
1031 }
1032 Err(_) => Vec::new(),
1033 }
1034 }
1035 }
1036
1037 fn build_basic_dispatcher() -> (EventsDispatcher, DispatcherReceiver) {
1039 let context = ComponentContext::test_transform("test");
1040 let mut dispatcher = Dispatcher::new(context);
1041
1042 let (buffer_tx, buffer_rx) = mpsc::channel(1);
1043 dispatcher.add_output(OutputName::Default).unwrap();
1044 dispatcher
1045 .attach_sender_to_output(&OutputName::Default, buffer_tx)
1046 .unwrap();
1047
1048 (dispatcher, DispatcherReceiver { receiver: buffer_rx })
1049 }
1050
1051 async fn get_flushed_metrics(timestamp: u64, state: &mut AggregationState) -> Vec<Metric> {
1052 get_flushed_metrics_with_open_windows(timestamp, true, state).await
1053 }
1054
1055 async fn get_flushed_metrics_with_open_windows(
1056 timestamp: u64, flush_open_buckets: bool, state: &mut AggregationState,
1057 ) -> Vec<Metric> {
1058 let (dispatcher, mut dispatcher_receiver) = build_basic_dispatcher();
1059 let mut buffered_dispatcher = dispatcher.buffered().expect("default output should always exist");
1060
1061 state
1063 .flush(timestamp, flush_open_buckets, &mut buffered_dispatcher)
1064 .await
1065 .expect("should not fail to flush aggregation state");
1066
1067 buffered_dispatcher
1070 .flush()
1071 .await
1072 .expect("should not fail to flush buffered sender");
1073
1074 dispatcher_receiver.collect_next()
1075 }
1076
1077 macro_rules! compare_points {
1078 (scalar, $expected:expr, $actual:expr, $error_ratio:literal) => {
1079 for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1080 let (expected_ts, expected_point) = expected_value;
1081 let (actual_ts, actual_point) = actual_value;
1082
1083 assert_eq!(
1084 expected_ts, actual_ts,
1085 "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1086 idx, expected_ts, actual_ts
1087 );
1088 assert!(
1089 expected_point.approx_eq_ratio(&actual_point, $error_ratio),
1090 "point for value #{} does not match: {} (expected) vs {} (actual)",
1091 idx,
1092 expected_point,
1093 actual_point
1094 );
1095 }
1096 };
1097 (distribution, $expected:expr, $actual:expr) => {
1098 for (idx, (expected_value, actual_value)) in $expected.into_iter().zip($actual.into_iter()).enumerate() {
1099 let (expected_ts, expected_sketch) = expected_value;
1100 let (actual_ts, actual_sketch) = actual_value;
1101
1102 assert_eq!(
1103 expected_ts, actual_ts,
1104 "timestamp for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1105 idx, expected_ts, actual_ts
1106 );
1107 assert_eq!(
1108 expected_sketch, actual_sketch,
1109 "sketch for value #{} does not match: {:?} (expected) vs {:?} (actual)",
1110 idx, expected_sketch, actual_sketch
1111 );
1112 }
1113 };
1114 }
1115
1116 macro_rules! assert_flushed_scalar_metric {
1117 ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1118 assert_flushed_scalar_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1119 };
1120 ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1121 let actual_metric = $actual;
1122
1123 assert_eq!($original.context(), actual_metric.context(), "expected context ({}) and actual context ({}) do not match", $original.context(), actual_metric.context());
1124
1125 let expected_points = ScalarPoints::from([$(($ts, $value)),+]);
1126
1127 match actual_metric.values() {
1128 MetricValues::Counter(ref actual_points) | MetricValues::Gauge(ref actual_points) | MetricValues::Rate(ref actual_points, _) => {
1129 assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1130 compare_points!(scalar, expected_points, actual_points, $error_ratio);
1131 },
1132 _ => panic!("only counters, rates, and gauges are supported in assert_flushed_scalar_metric"),
1133 }
1134 };
1135 }
1136
1137 macro_rules! assert_flushed_distribution_metric {
1138 ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+]) => {
1139 assert_flushed_distribution_metric!($original, $actual, [$($ts => $value),+], error_ratio => 0.000001);
1140 };
1141 ($original:expr, $actual:expr, [$($ts:expr => $value:expr),+], error_ratio => $error_ratio:literal) => {
1142 let actual_metric = $actual;
1143
1144 assert_eq!($original.context(), actual_metric.context());
1145
1146 match actual_metric.values() {
1147 MetricValues::Distribution(ref actual_points) => {
1148 let expected_points = SketchPoints::from([$(($ts, $value)),+]);
1149 assert_eq!(expected_points.len(), actual_points.len(), "expected and actual values have different number of points");
1150
1151 compare_points!(distribution, &expected_points, actual_points);
1152 },
1153 _ => panic!("only distributions are supported in assert_flushed_distribution_metric"),
1154 }
1155 };
1156 }
1157
1158 #[test]
1159 fn aggregate_metric_type_matches_every_metric_shape() {
1160 let cases = [
1161 (MetricValues::counter(1.0), AggregateMetricType::Counter),
1162 (
1163 MetricValues::rate(1.0, Duration::from_secs(10)),
1164 AggregateMetricType::Rate,
1165 ),
1166 (MetricValues::gauge(1.0), AggregateMetricType::Gauge),
1167 (MetricValues::set("value"), AggregateMetricType::Set),
1168 (MetricValues::histogram([1.0]), AggregateMetricType::Histogram),
1169 (
1170 MetricValues::distribution(&[1.0][..]),
1171 AggregateMetricType::Distribution,
1172 ),
1173 ];
1174
1175 for (values, expected) in cases {
1176 assert_eq!(AggregateMetricType::from(&values), expected);
1177 }
1178 }
1179
1180 #[tokio::test]
1181 async fn flush_records_last_flush_counts() {
1182 let recorder = TestRecorder::default();
1183 let _local = metrics::set_default_local_recorder(&recorder);
1184
1185 let builder = MetricsBuilder::default();
1186 let mut state = AggregationState::new(
1187 BUCKET_WIDTH_SECS,
1188 10,
1189 COUNTER_EXPIRE,
1190 HistogramConfiguration::default(),
1191 Telemetry::new(&builder),
1192 );
1193
1194 assert!(state.insert(insert_ts(1), Metric::counter("metric1", 1.0)));
1195 assert!(state.insert(insert_ts(1), Metric::gauge("metric2", 2.0)));
1196 assert!(state.insert(insert_ts(1), Metric::distribution("metric3", 3.0)));
1197
1198 let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1199
1200 assert_eq!(
1201 recorder.gauge(("aggregate_last_flush_count", &[("data_type", "series")])),
1202 Some(2.0)
1203 );
1204 assert_eq!(
1205 recorder.gauge(("aggregate_last_flush_count", &[("data_type", "sketches")])),
1206 Some(1.0)
1207 );
1208
1209 let _ = get_flushed_metrics(flush_ts(2), &mut state).await;
1211
1212 assert_eq!(
1213 recorder.gauge(("aggregate_last_flush_count", &[("data_type", "series")])),
1214 Some(1.0)
1215 );
1216 assert_eq!(
1217 recorder.gauge(("aggregate_last_flush_count", &[("data_type", "sketches")])),
1218 Some(0.0)
1219 );
1220 }
1221
1222 #[test]
1223 fn snapshot_contexts_preserves_full_context_shape_and_unit() {
1224 let mut state = AggregationState::new(
1225 BUCKET_WIDTH_SECS,
1226 10,
1227 COUNTER_EXPIRE,
1228 HistogramConfiguration::default(),
1229 Telemetry::noop(),
1230 );
1231
1232 let histogram_context = Context::from_static_parts("request.duration", &["env:prod"])
1233 .with_host(Some(MetaString::from_static("host-a")))
1234 .with_origin_tags(TagSet::from(Tag::from_static("container:one")));
1235 let gauge_context = Context::from_static_parts("request.duration", &["env:prod"])
1236 .with_host(Some(MetaString::from_static("host-b")))
1237 .with_origin_tags(TagSet::from(Tag::from_static("container:two")));
1238 let histogram = Metric::from_parts(
1239 histogram_context.clone(),
1240 MetricValues::histogram([12.0]),
1241 MetricMetadata::default().with_unit(MetaString::from_static("millisecond")),
1242 );
1243 let gauge = Metric::gauge(gauge_context.clone(), 2.0);
1244
1245 assert!(state.insert(insert_ts(1), histogram));
1246 assert!(state.insert(insert_ts(1), gauge));
1247
1248 let mut snapshot = state.snapshot_contexts();
1249 assert_eq!(snapshot.len(), 2);
1250 assert!(snapshot.capacity() >= state.contexts.len());
1251 snapshot.sort_by(|a, b| a.context().host().cmp(&b.context().host()));
1252
1253 assert_eq!(snapshot[0].context(), &histogram_context);
1254 assert_eq!(snapshot[0].context().tags().len(), 1);
1255 assert_eq!(snapshot[0].context().origin_tags().len(), 1);
1256 assert_eq!(snapshot[0].metric_type(), AggregateMetricType::Histogram);
1257 assert_eq!(snapshot[0].unit(), Some("millisecond"));
1258
1259 assert_eq!(snapshot[1].context(), &gauge_context);
1260 assert_eq!(snapshot[1].metric_type(), AggregateMetricType::Gauge);
1261 assert_eq!(snapshot[1].unit(), None);
1262 }
1263
1264 #[tokio::test]
1265 async fn snapshot_contexts_follows_ordinary_context_lifecycle() {
1266 let mut state = AggregationState::new(
1267 BUCKET_WIDTH_SECS,
1268 10,
1269 COUNTER_EXPIRE,
1270 HistogramConfiguration::default(),
1271 Telemetry::noop(),
1272 );
1273 let context = Context::from_static_name("active.gauge");
1274
1275 assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1276 let active_snapshot = state.snapshot_contexts();
1277 assert_eq!(active_snapshot.len(), 1);
1278 assert_eq!(active_snapshot[0].context(), &context);
1279 assert_eq!(active_snapshot[0].metric_type(), AggregateMetricType::Gauge);
1280
1281 let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1282 assert!(state.snapshot_contexts().is_empty());
1283 }
1284
1285 #[tokio::test]
1286 async fn snapshot_contexts_retains_idle_counter_until_expiry() {
1287 let mut state = AggregationState::new(
1288 BUCKET_WIDTH_SECS,
1289 10,
1290 COUNTER_EXPIRE,
1291 HistogramConfiguration::default(),
1292 Telemetry::noop(),
1293 );
1294 let context = Context::from_static_name("sparse.counter");
1295
1296 assert!(state.insert(insert_ts(1), Metric::counter(context.clone(), 1.0)));
1297 let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
1298 let first_snapshot = state.snapshot_contexts();
1299 assert_eq!(first_snapshot.len(), 1);
1300 assert_eq!(first_snapshot[0].context(), &context);
1301 assert_eq!(first_snapshot[0].metric_type(), AggregateMetricType::Counter);
1302
1303 let _ = get_flushed_metrics(flush_ts(2), &mut state).await;
1304 let second_snapshot = state.snapshot_contexts();
1305 assert_eq!(second_snapshot.len(), 1);
1306 assert_eq!(second_snapshot[0].context(), &context);
1307 assert_eq!(second_snapshot[0].metric_type(), AggregateMetricType::Counter);
1308
1309 let _ = get_flushed_metrics(flush_ts(3), &mut state).await;
1310 assert!(state.snapshot_contexts().is_empty());
1311 }
1312
1313 #[test]
1314 fn canceled_snapshot_response_skips_snapshot_construction() {
1315 let mut state = AggregationState::new(
1316 BUCKET_WIDTH_SECS,
1317 10,
1318 COUNTER_EXPIRE,
1319 HistogramConfiguration::default(),
1320 Telemetry::noop(),
1321 );
1322 assert!(state.insert(
1323 insert_ts(1),
1324 Metric::gauge(Context::from_static_name("canceled.snapshot"), 1.0),
1325 ));
1326 let (response, receiver) = oneshot::channel();
1327 drop(receiver);
1328 let snapshot_calls = Cell::new(0);
1329
1330 send_context_snapshot_if_open(response, || {
1331 snapshot_calls.set(snapshot_calls.get() + 1);
1332 state.snapshot_contexts()
1333 });
1334
1335 assert_eq!(snapshot_calls.get(), 0);
1336 }
1337
1338 #[test]
1339 fn open_snapshot_response_constructs_once_and_returns_exact_entries() {
1340 let mut state = AggregationState::new(
1341 BUCKET_WIDTH_SECS,
1342 10,
1343 COUNTER_EXPIRE,
1344 HistogramConfiguration::default(),
1345 Telemetry::noop(),
1346 );
1347 let context = Context::from_static_name("open.snapshot");
1348 assert!(state.insert(insert_ts(1), Metric::gauge(context.clone(), 1.0)));
1349 let expected = vec![AggregateContextSnapshotEntry {
1350 context,
1351 metric_type: AggregateMetricType::Gauge,
1352 unit: MetaString::empty(),
1353 }];
1354 let (response, mut receiver) = oneshot::channel();
1355 let snapshot_calls = Cell::new(0);
1356
1357 send_context_snapshot_if_open(response, || {
1358 snapshot_calls.set(snapshot_calls.get() + 1);
1359 state.snapshot_contexts()
1360 });
1361
1362 assert_eq!(snapshot_calls.get(), 1);
1363 assert_eq!(
1364 receiver.try_recv().expect("open requester should receive a snapshot"),
1365 expected
1366 );
1367 }
1368
1369 #[test]
1370 fn aggregate_memory_bounds_include_peak_context_snapshot() {
1371 let context_limit = 17;
1372 let config = AggregateConfiguration {
1373 context_limit,
1374 ..AggregateConfiguration::for_test()
1375 };
1376 let registry = ComponentRegistry::default();
1377 config.specify_bounds(&mut registry.bounds_builder(&SubsystemIdentifier::from_dotted("test")));
1378 let bounds = registry.as_bounds();
1379
1380 let expected_minimum = size_of::<Aggregate>();
1381 let aggregation_state_bytes = context_limit * (size_of::<Context>() + size_of::<AggregatedMetric>());
1382 let context_snapshot_bytes = context_limit * size_of::<AggregateContextSnapshotEntry>();
1383
1384 assert_eq!(bounds.total_minimum_required_bytes(), expected_minimum);
1385 assert_eq!(
1386 bounds.total_firm_limit_bytes(),
1387 expected_minimum + aggregation_state_bytes + context_snapshot_bytes
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn production_owner_loop_serves_snapshots_and_stops_after_cancellation() {
1393 tokio::time::timeout(Duration::from_secs(5), async {
1394 let (snapshot_handle, context_snapshot_receiver) = aggregate_context_snapshot_channel();
1395 let config = AggregateConfiguration {
1396 primary_flush_interval: Duration::from_secs(60),
1397 context_snapshot_receiver,
1398 ..AggregateConfiguration::for_test()
1399 };
1400
1401 let available_request_capacity = snapshot_handle.requests.capacity();
1402 let canceled_request = tokio::spawn({
1403 let snapshot_handle = snapshot_handle.clone();
1404 async move { snapshot_handle.snapshot().await }
1405 });
1406 while snapshot_handle.requests.capacity() == available_request_capacity {
1407 tokio::task::yield_now().await;
1408 }
1409 canceled_request.abort();
1410 assert!(canceled_request
1411 .await
1412 .expect_err("snapshot requester should be canceled")
1413 .is_cancelled());
1414
1415 let (control, _outputs) = TestComponentDriver::transform(config)
1417 .await
1418 .expect("aggregate transform should build");
1419
1420 let retained_context = Context::from_static_name("owner.loop.mixed.counter");
1421 let mixed_values = ScalarPoints::from_iter([(None, 2.0), (NonZeroU64::new(insert_ts(1)), 3.0)]);
1422 control
1423 .send_event(Event::Metric(Metric::counter(retained_context.clone(), mixed_values)))
1424 .await;
1425
1426 let snapshot = loop {
1427 let snapshot = snapshot_handle
1428 .snapshot()
1429 .await
1430 .expect("running aggregate should fulfill snapshots");
1431 if snapshot.iter().any(|entry| entry.context() == &retained_context) {
1432 break snapshot;
1433 }
1434 tokio::task::yield_now().await;
1435 };
1436 assert_eq!(
1437 snapshot.len(),
1438 1,
1439 "only the mixed metric's retained portion belongs in state"
1440 );
1441 let entry = &snapshot[0];
1442 assert_eq!(entry.context(), &retained_context);
1443 assert_eq!(entry.metric_type(), AggregateMetricType::Counter);
1444 assert_eq!(entry.unit(), None);
1445
1446 drop(snapshot_handle);
1447 control.shutdown().await.expect("aggregate should stop cleanly");
1448 })
1449 .await
1450 .expect("production aggregate owner loop should complete without spinning or hanging");
1451 }
1452
1453 #[tokio::test]
1454 async fn snapshot_handle_round_trips_through_responder() {
1455 let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1456 let expected = vec![AggregateContextSnapshotEntry::for_test(
1457 Context::from_static_name("round.trip"),
1458 AggregateMetricType::Gauge,
1459 MetaString::from_static("widget"),
1460 )];
1461 let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1462
1463 responder
1464 .respond(expected.clone())
1465 .await
1466 .expect("responder should receive and fulfill a snapshot request");
1467
1468 let actual = snapshot_task
1469 .await
1470 .expect("snapshot task should complete")
1471 .expect("snapshot request should succeed");
1472 assert_eq!(actual, expected);
1473 }
1474
1475 #[tokio::test]
1476 async fn snapshot_handle_reports_dropped_receiver() {
1477 let (handle, responder) = aggregate_context_snapshot_channel_for_test();
1478 drop(responder);
1479
1480 let error = handle
1481 .snapshot()
1482 .await
1483 .expect_err("snapshot should fail after its owner is dropped");
1484 assert!(error.to_string().contains("unavailable"));
1485 }
1486
1487 #[tokio::test]
1488 async fn snapshot_responder_stops_after_accepting_request_and_cancels_response() {
1489 let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1490 let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1491
1492 responder
1493 .stop_after_receiving()
1494 .await
1495 .expect("responder should accept the snapshot request before stopping");
1496
1497 let error = snapshot_task
1498 .await
1499 .expect("snapshot task should complete")
1500 .expect_err("snapshot should fail when the accepted response is dropped");
1501 assert!(error.to_string().contains("stopped before responding"));
1502 }
1503
1504 #[tokio::test]
1505 async fn aggregate_configuration_receiver_can_only_be_taken_once() {
1506 let config = AggregateConfiguration::for_test();
1507 let first = config.build(BuildContext::test_transform("aggregate_one")).await;
1508 assert!(first.is_ok());
1509
1510 let second = config.build(BuildContext::test_transform("aggregate_two")).await;
1511 let error = match second {
1512 Ok(_) => panic!("second build should not take the snapshot receiver again"),
1513 Err(error) => error,
1514 };
1515 assert!(error.to_string().contains("already been taken"));
1516 }
1517
1518 #[tokio::test]
1519 async fn snapshot_responder_reports_closed_request_channel() {
1520 let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1521 drop(handle);
1522
1523 let error = responder
1524 .respond(Vec::new())
1525 .await
1526 .expect_err("responder should fail when every request handle is dropped");
1527 assert!(error.to_string().contains("closed"));
1528 }
1529
1530 #[tokio::test]
1531 async fn snapshot_responder_ignores_canceled_requester() {
1532 let (handle, mut responder) = aggregate_context_snapshot_channel_for_test();
1533 let snapshot_task = tokio::spawn(async move { handle.snapshot().await });
1534 let pending_response = responder
1535 .receive()
1536 .await
1537 .expect("responder should accept the snapshot request");
1538
1539 snapshot_task.abort();
1540 let _ = snapshot_task.await;
1541
1542 pending_response.respond(Vec::new());
1543 }
1544
1545 #[test]
1546 fn bucket_is_closed() {
1547 let cases = [
1550 (1000, 995, BUCKET_WIDTH_SECS, false, false),
1552 (1000, 995, BUCKET_WIDTH_SECS, true, true),
1553 (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1555 (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1556 (1010, 1000, BUCKET_WIDTH_SECS, false, true),
1558 (1010, 1000, BUCKET_WIDTH_SECS, true, true),
1559 ];
1560
1561 for (current_time, bucket_start, bucket_width_secs, flush_open_buckets, expected) in cases {
1562 let expected_reason = if expected {
1563 "closed, was open"
1564 } else {
1565 "open, was closed"
1566 };
1567
1568 assert_eq!(
1569 is_bucket_closed(current_time, bucket_start, bucket_width_secs, flush_open_buckets),
1570 expected,
1571 "expected bucket to be {} (current_time={}, bucket_start={}, bucket_width={}, flush_open_buckets={})",
1572 expected_reason,
1573 current_time,
1574 bucket_start,
1575 bucket_width_secs,
1576 flush_open_buckets
1577 );
1578 }
1579 }
1580
1581 #[tokio::test]
1582 async fn context_limit() {
1583 let mut state = AggregationState::new(
1585 BUCKET_WIDTH_SECS,
1586 2,
1587 COUNTER_EXPIRE,
1588 HistogramConfiguration::default(),
1589 Telemetry::noop(),
1590 );
1591
1592 let input_metrics = [
1595 Metric::gauge("metric1", 1.0),
1596 Metric::gauge("metric2", 2.0),
1597 Metric::gauge("metric3", 3.0),
1598 Metric::gauge("metric4", 4.0),
1599 ];
1600
1601 assert!(!state.context_limit_breached());
1602
1603 assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1604 assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1605 assert!(!state.context_limit_breached());
1606
1607 assert!(!state.insert(insert_ts(1), input_metrics[2].clone()));
1608 assert!(state.context_limit_breached());
1609 assert!(!state.insert(insert_ts(1), input_metrics[3].clone()));
1610 assert!(state.context_limit_breached());
1611
1612 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1615 assert_eq!(flushed_metrics.len(), 2);
1616 assert_eq!(input_metrics[0].context(), flushed_metrics[0].context());
1617 assert_eq!(input_metrics[1].context(), flushed_metrics[1].context());
1618 assert!(!state.context_limit_breached());
1619
1620 assert!(state.insert(insert_ts(2), input_metrics[2].clone()));
1623 assert!(state.insert(insert_ts(2), input_metrics[3].clone()));
1624
1625 let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1626 assert_eq!(flushed_metrics.len(), 2);
1627 assert_eq!(input_metrics[2].context(), flushed_metrics[0].context());
1628 assert_eq!(input_metrics[3].context(), flushed_metrics[1].context());
1629 }
1630
1631 #[tokio::test]
1632 async fn context_limit_with_zero_value_counters() {
1633 let mut state = AggregationState::new(
1635 BUCKET_WIDTH_SECS,
1636 2,
1637 COUNTER_EXPIRE,
1638 HistogramConfiguration::default(),
1639 Telemetry::noop(),
1640 );
1641
1642 let input_metrics = [
1644 Metric::counter("metric1", 1.0),
1645 Metric::counter("metric2", 2.0),
1646 Metric::counter("metric3", 3.0),
1647 ];
1648
1649 assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1650 assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1651
1652 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1654 assert_eq!(flushed_metrics.len(), 2);
1655 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1656 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1657
1658 let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1660 assert_eq!(flushed_metrics.len(), 2);
1661 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1662 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1663
1664 assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1666
1667 let flushed_metrics = get_flushed_metrics(flush_ts(3), &mut state).await;
1669 assert_eq!(flushed_metrics.len(), 2);
1670 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0]);
1671 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0]);
1672
1673 let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1676 assert_eq!(flushed_metrics.len(), 0);
1677
1678 assert!(state.insert(insert_ts(5), input_metrics[2].clone()));
1680
1681 let flushed_metrics = get_flushed_metrics(flush_ts(5), &mut state).await;
1682 assert_eq!(flushed_metrics.len(), 1);
1683 assert_flushed_scalar_metric!(&input_metrics[2], &flushed_metrics[0], [bucket_ts(5) => 3.0]);
1684 }
1685
1686 #[tokio::test]
1690 async fn disabled_counter_keep_alive_releases_contexts_after_flush() {
1691 let mut state = AggregationState::new(
1692 BUCKET_WIDTH_SECS,
1693 10,
1694 None,
1695 HistogramConfiguration::default(),
1696 Telemetry::noop(),
1697 );
1698
1699 assert!(state.insert(insert_ts(1), Metric::counter("counter", 1.0)));
1700 let flushed = get_flushed_metrics(flush_ts(1), &mut state).await;
1701
1702 assert_eq!(flushed.len(), 1);
1703 assert!(state.is_empty());
1704 }
1705
1706 #[tokio::test]
1710 async fn counter_after_idle_single_window_does_not_backfill_zeros() {
1711 let mut state = AggregationState::new(
1712 BUCKET_WIDTH_SECS,
1713 10,
1714 Some(Duration::from_secs(300)),
1715 HistogramConfiguration::default(),
1716 Telemetry::noop(),
1717 );
1718
1719 assert!(state.insert(11, Metric::gauge("previous", 1.0)));
1720 let flushed = get_flushed_metrics_with_open_windows(30, false, &mut state).await;
1721 assert_eq!(flushed.len(), 1);
1722 assert!(state.is_empty());
1723
1724 let counter = Metric::counter("counter", 7.0);
1725 assert!(state.insert(1_000, counter.clone()));
1726 let flushed = get_flushed_metrics_with_open_windows(1_010, false, &mut state).await;
1727
1728 assert_eq!(flushed.len(), 1);
1729 assert_flushed_scalar_metric!(&counter, &flushed[0], [1_000 => 7.0]);
1730 }
1731
1732 #[tokio::test]
1737 async fn flushing_open_windows_emits_values_in_the_open_window() {
1738 for flush_open_buckets in [false, true] {
1739 let mut state = AggregationState::new(
1740 BUCKET_WIDTH_SECS,
1741 10,
1742 COUNTER_EXPIRE,
1743 HistogramConfiguration::default(),
1744 Telemetry::noop(),
1745 );
1746 let gauge = Metric::gauge("gauge", 1.0);
1747 let counter = Metric::counter("counter", 2.0);
1748 let distribution = Metric::distribution("distribution", 3.0);
1749 for metric in [&gauge, &counter, &distribution] {
1750 assert!(state.insert(1_001, metric.clone()));
1751 }
1752
1753 let mut flushed = get_flushed_metrics_with_open_windows(1_005, flush_open_buckets, &mut state).await;
1755 if !flush_open_buckets {
1756 assert!(
1757 flushed.is_empty(),
1758 "open windows must wait unless they're being flushed"
1759 );
1760 continue;
1761 }
1762
1763 flushed.sort_by(|a, b| a.context().name().cmp(b.context().name()));
1764 let names = flushed
1765 .iter()
1766 .map(|metric| metric.context().name().as_ref())
1767 .collect::<Vec<_>>();
1768 assert_eq!(names, ["counter", "distribution", "gauge"]);
1769 assert_flushed_scalar_metric!(&counter, &flushed[0], [1_000 => 2.0]);
1770 assert_flushed_distribution_metric!(&distribution, &flushed[1], [1_000 => &[3.0][..]]);
1771 assert_flushed_scalar_metric!(&gauge, &flushed[2], [1_000 => 1.0]);
1772 }
1773 }
1774
1775 #[tokio::test]
1776 async fn zero_value_counters() {
1777 let mut state = AggregationState::new(
1779 BUCKET_WIDTH_SECS,
1780 10,
1781 COUNTER_EXPIRE,
1782 HistogramConfiguration::default(),
1783 Telemetry::noop(),
1784 );
1785
1786 let input_metrics = [Metric::counter("metric1", 1.0), Metric::counter("metric2", 2.0)];
1788
1789 assert!(state.insert(insert_ts(1), input_metrics[0].clone()));
1790 assert!(state.insert(insert_ts(1), input_metrics[1].clone()));
1791
1792 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1794 assert_eq!(flushed_metrics.len(), 2);
1795 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(1) => 1.0]);
1796 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(1) => 2.0]);
1797
1798 let flushed_metrics = get_flushed_metrics(flush_ts(2), &mut state).await;
1800 assert_eq!(flushed_metrics.len(), 2);
1801 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(2) => 0.0]);
1802 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(2) => 0.0]);
1803
1804 assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1806 assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1807
1808 let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1811 assert_eq!(flushed_metrics.len(), 2);
1812 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(3) => 0.0, bucket_ts(4) => 1.0]);
1813 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(3) => 0.0, bucket_ts(4) => 2.0]);
1814
1815 let flushed_metrics = get_flushed_metrics(flush_ts(7), &mut state).await;
1819 assert_eq!(flushed_metrics.len(), 2);
1820 assert_flushed_scalar_metric!(&input_metrics[0], &flushed_metrics[0], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1821 assert_flushed_scalar_metric!(&input_metrics[1], &flushed_metrics[1], [bucket_ts(5) => 0.0, bucket_ts(6) => 0.0]);
1822 }
1823
1824 #[tokio::test]
1825 async fn merge_identical_timestamped_values_on_flush() {
1826 let mut state = AggregationState::new(
1828 BUCKET_WIDTH_SECS,
1829 10,
1830 COUNTER_EXPIRE,
1831 HistogramConfiguration::default(),
1832 Telemetry::noop(),
1833 );
1834
1835 let input_metric = Metric::counter("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1837
1838 assert!(state.insert(insert_ts(1), input_metric.clone()));
1839
1840 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1843 assert_eq!(flushed_metrics.len(), 1);
1844 assert_flushed_scalar_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => 15.0]);
1845 }
1846
1847 #[tokio::test]
1848 async fn histogram_statistics() {
1849 let hist_config = HistogramConfiguration::from_statistics(
1851 &[
1852 HistogramStatistic::Count,
1853 HistogramStatistic::Sum,
1854 HistogramStatistic::Percentile {
1855 q: 0.5,
1856 suffix: "p50".into(),
1857 },
1858 ],
1859 false,
1860 "".into(),
1861 );
1862 let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1863
1864 let input_metric = Metric::histogram("metric1", [1.0, 2.0, 3.0, 4.0, 5.0]);
1866 assert!(state.insert(insert_ts(1), input_metric.clone()));
1867
1868 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1871 assert_eq!(flushed_metrics.len(), 3);
1872
1873 let count_metric = Metric::rate("metric1.count", 0.0, Duration::from_secs(BUCKET_WIDTH_SECS.get()));
1876 let sum_metric = Metric::gauge("metric1.sum", 0.0);
1877 let p50_metric = Metric::gauge("metric1.p50", 0.0);
1878
1879 assert_flushed_scalar_metric!(count_metric, &flushed_metrics[0], [bucket_ts(1) => 5.0]);
1882 assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[1], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
1883 assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[2], [bucket_ts(1) => 15.0]);
1884 }
1885
1886 #[tokio::test]
1887 async fn histogram_statistics_unit_propagation() {
1888 let hist_config = HistogramConfiguration::from_statistics(
1890 &[
1891 HistogramStatistic::Count,
1892 HistogramStatistic::Sum,
1893 HistogramStatistic::Percentile {
1894 q: 0.5,
1895 suffix: "p50".into(),
1896 },
1897 ],
1898 false,
1899 "".into(),
1900 );
1901 let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1902
1903 let context = Context::from_static_parts("metric1", &[]);
1905 let metadata = MetricMetadata::default().with_unit(MetaString::from_static("millisecond"));
1906 let input_metric = Metric::from_parts(
1907 context,
1908 MetricValues::histogram([1.0_f64, 2.0, 3.0, 4.0, 5.0]),
1909 metadata,
1910 );
1911 assert!(state.insert(insert_ts(1), input_metric));
1912
1913 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1914 assert_eq!(flushed_metrics.len(), 3);
1915
1916 for metric in &flushed_metrics {
1919 let name = metric.context().name();
1920 if name.ends_with(".count") {
1921 assert_eq!(
1922 metric.metadata().unit(),
1923 None,
1924 "flushed metric '{}' should be dimensionless",
1925 name
1926 );
1927 } else {
1928 assert_eq!(
1929 metric.metadata().unit(),
1930 Some("millisecond"),
1931 "flushed metric '{}' should carry unit='millisecond'",
1932 name
1933 );
1934 }
1935 }
1936 }
1937
1938 #[tokio::test]
1939 async fn distributions() {
1940 let mut state = AggregationState::new(
1942 BUCKET_WIDTH_SECS,
1943 10,
1944 COUNTER_EXPIRE,
1945 HistogramConfiguration::default(),
1946 Telemetry::noop(),
1947 );
1948
1949 let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1951 let input_metric = Metric::distribution("metric1", &values[..]);
1952
1953 assert!(state.insert(insert_ts(1), input_metric.clone()));
1954
1955 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1957 assert_eq!(flushed_metrics.len(), 1);
1958
1959 assert_flushed_distribution_metric!(&input_metric, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1960 }
1961
1962 #[tokio::test]
1963 async fn histogram_copy_to_distribution() {
1964 let hist_config = HistogramConfiguration::from_statistics(
1965 &[
1966 HistogramStatistic::Count,
1967 HistogramStatistic::Sum,
1968 HistogramStatistic::Percentile {
1969 q: 0.5,
1970 suffix: "p50".into(),
1971 },
1972 ],
1973 true,
1974 "dist_prefix.".into(),
1975 );
1976 let mut state = AggregationState::new(BUCKET_WIDTH_SECS, 10, COUNTER_EXPIRE, hist_config, Telemetry::noop());
1977
1978 let values = [1.0, 2.0, 3.0, 4.0, 5.0];
1980 let input_metric = Metric::histogram("metric1", values);
1981 assert!(state.insert(insert_ts(1), input_metric.clone()));
1982
1983 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1986 assert_eq!(flushed_metrics.len(), 4);
1987
1988 let count_metric = Metric::rate("metric1.count", 0.0, BUCKET_WIDTH);
1991 let sum_metric = Metric::gauge("metric1.sum", 0.0);
1992 let p50_metric = Metric::gauge("metric1.p50", 0.0);
1993 let expected_distribution = Metric::distribution("dist_prefix.metric1", &values[..]);
1994
1995 assert_flushed_distribution_metric!(expected_distribution, &flushed_metrics[0], [bucket_ts(1) => &values[..]]);
1998 assert_flushed_scalar_metric!(count_metric, &flushed_metrics[1], [bucket_ts(1) => 5.0]);
1999 assert_flushed_scalar_metric!(p50_metric, &flushed_metrics[2], [bucket_ts(1) => 3.0], error_ratio => 0.0025);
2000 assert_flushed_scalar_metric!(sum_metric, &flushed_metrics[3], [bucket_ts(1) => 15.0]);
2001 }
2002
2003 #[tokio::test]
2004 async fn nonaggregated_counters_to_rate() {
2005 let counter_value = 42.0;
2006
2007 let mut state = AggregationState::new(
2009 BUCKET_WIDTH_SECS,
2010 10,
2011 COUNTER_EXPIRE,
2012 HistogramConfiguration::default(),
2013 Telemetry::noop(),
2014 );
2015
2016 let input_metric = Metric::counter("metric1", counter_value);
2018 assert!(state.insert(insert_ts(1), input_metric.clone()));
2019
2020 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2023 assert_eq!(flushed_metrics.len(), 1);
2024 let flushed_metric = &flushed_metrics[0];
2025
2026 assert_flushed_scalar_metric!(&input_metric, flushed_metric, [bucket_ts(1) => counter_value]);
2027 assert_eq!(flushed_metric.values().as_str(), "rate");
2028 }
2029
2030 #[tokio::test]
2031 async fn telemetry() {
2032 let recorder = TestRecorder::default();
2039 let _local = metrics::set_default_local_recorder(&recorder);
2040
2041 let builder = MetricsBuilder::default();
2042 let telemetry = Telemetry::new(&builder);
2043
2044 let mut state = AggregationState::new(
2045 BUCKET_WIDTH_SECS,
2046 2,
2047 COUNTER_EXPIRE,
2048 HistogramConfiguration::default(),
2049 telemetry,
2050 );
2051
2052 assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(0.0));
2054 assert_eq!(
2055 recorder.counter(("component_events_dropped_total", &[("intentional", "true")])),
2056 Some(0)
2057 );
2058 for metric_type in &["counter", "gauge", "rate", "set", "histogram", "distribution"] {
2059 assert_eq!(
2060 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", *metric_type)])),
2061 Some(0.0)
2062 );
2063 }
2064
2065 assert!(state.insert(insert_ts(1), Metric::counter("metric1", 42.0)));
2067 assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2068 assert_eq!(
2069 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2070 Some(1.0)
2071 );
2072
2073 assert!(state.insert(insert_ts(1), Metric::gauge("metric2", (insert_ts(1), 42.0))));
2075 assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2076 assert_eq!(
2077 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2078 Some(1.0)
2079 );
2080
2081 assert!(!state.insert(insert_ts(1), Metric::counter("metric3", 42.0)));
2083 assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(2.0));
2084 assert_eq!(
2085 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2086 Some(1.0)
2087 );
2088
2089 let _ = get_flushed_metrics(flush_ts(1), &mut state).await;
2092 assert_eq!(recorder.gauge("aggregate_active_contexts"), Some(1.0));
2093 assert_eq!(
2094 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "counter")])),
2095 Some(1.0)
2096 );
2097 assert_eq!(
2098 recorder.gauge(("aggregate_active_contexts_by_type", &[("metric_type", "gauge")])),
2099 Some(0.0)
2100 );
2101 }
2102}