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#[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 passthrough_timestamped_metrics: bool,
352
353 pub passthrough_idle_flush_timeout: Duration,
359
360 pub hist_config: HistogramConfiguration,
362
363 pub context_snapshot_receiver: AggregateContextSnapshotReceiver,
368}
369
370#[cfg(test)]
371impl AggregateConfiguration {
372 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 builder
447 .minimum()
448 .with_single_value::<Aggregate>("component struct");
450 builder
451 .firm()
452 .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 .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 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 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 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 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 let (maybe_timestamped_metric, maybe_nontimestamped_metric) = try_split_timestamped_values(metric);
561
562 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 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 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 final_primary_flush = true;
600 primary_flush.reset_immediately();
601
602 debug!("Aggregation transform stopping...");
603 }
604 },
605 }
606 }
607
608 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 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 (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 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 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 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 self.last_processed_at = Instant::now();
701 }
702
703 async fn try_flush(&mut self, dispatcher: &EventsDispatcher) {
704 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 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 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 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 !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 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 match self.contexts.entry(context) {
816 Entry::Occupied(mut entry) => {
817 let aggregated = entry.get_mut();
818
819 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 let split_timestamp = align_to_bucket_start(current_time, bucket_width_secs).saturating_sub(1);
853
854 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 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 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 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 debug!(timestamp = current_time, "Flushing buckets.");
901
902 for (context, am) in self.contexts.iter_mut() {
903 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 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 break;
938 }
939 }
940 }
941
942 if let Some(closed_bucket_values) = am.values.split_at_timestamp(split_timestamp) {
951 self.telemetry.increment_flushed(&closed_bucket_values);
952
953 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 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 MetricValues::Histogram(ref mut points) => {
1006 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 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 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 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 (bucket_start + bucket_width_secs.get() - 1) < current_time || flush_open_buckets
1111}
1112
1113#[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 const fn bucket_ts(step: u64) -> u64 {
1148 align_to_bucket_start(insert_ts(step), BUCKET_WIDTH_SECS)
1149 }
1150
1151 const fn insert_ts(step: u64) -> u64 {
1153 (BUCKET_WIDTH_SECS.get() * (step + 1)) - 2
1154 }
1155
1156 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 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 state
1279 .flush(timestamp, true, &mut buffered_dispatcher)
1280 .await
1281 .expect("should not fail to flush aggregation state");
1282
1283 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 let cases = [
1764 (1000, 995, BUCKET_WIDTH_SECS, false, false),
1766 (1000, 995, BUCKET_WIDTH_SECS, true, true),
1767 (1000, 1000, BUCKET_WIDTH_SECS, false, false),
1769 (1000, 1000, BUCKET_WIDTH_SECS, true, true),
1770 (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 let mut state = AggregationState::new(
1799 BUCKET_WIDTH_SECS,
1800 2,
1801 COUNTER_EXPIRE,
1802 HistogramConfiguration::default(),
1803 Telemetry::noop(),
1804 );
1805
1806 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 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 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 let mut state = AggregationState::new(
1849 BUCKET_WIDTH_SECS,
1850 2,
1851 COUNTER_EXPIRE,
1852 HistogramConfiguration::default(),
1853 Telemetry::noop(),
1854 );
1855
1856 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 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 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 assert!(!state.insert(insert_ts(3), input_metrics[2].clone()));
1880
1881 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 let flushed_metrics = get_flushed_metrics(flush_ts(4), &mut state).await;
1890 assert_eq!(flushed_metrics.len(), 0);
1891
1892 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 let mut state = AggregationState::new(
1904 BUCKET_WIDTH_SECS,
1905 10,
1906 COUNTER_EXPIRE,
1907 HistogramConfiguration::default(),
1908 Telemetry::noop(),
1909 );
1910
1911 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 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 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 assert!(state.insert(insert_ts(4), input_metrics[0].clone()));
1931 assert!(state.insert(insert_ts(4), input_metrics[1].clone()));
1932
1933 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 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 let mut state = AggregationState::new(
1953 BUCKET_WIDTH_SECS,
1954 10,
1955 COUNTER_EXPIRE,
1956 HistogramConfiguration::default(),
1957 Telemetry::noop(),
1958 );
1959
1960 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 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 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 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 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
1996 assert_eq!(flushed_metrics.len(), 3);
1997
1998 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 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 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 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 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 let mut state = AggregationState::new(
2067 BUCKET_WIDTH_SECS,
2068 10,
2069 COUNTER_EXPIRE,
2070 HistogramConfiguration::default(),
2071 Telemetry::noop(),
2072 );
2073
2074 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 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 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 let flushed_metrics = get_flushed_metrics(flush_ts(1), &mut state).await;
2111 assert_eq!(flushed_metrics.len(), 4);
2112
2113 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 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 let mut state = AggregationState::new(
2134 BUCKET_WIDTH_SECS,
2135 10,
2136 COUNTER_EXPIRE,
2137 HistogramConfiguration::default(),
2138 Telemetry::noop(),
2139 );
2140
2141 let input_metric = Metric::counter("metric1", counter_value);
2143 assert!(state.insert(insert_ts(1), input_metric.clone()));
2144
2145 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 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 let input_metric = Metric::counter("metric1", (timestamp, counter_value));
2166 batcher.push_metric(input_metric.clone(), &dispatcher).await;
2167
2168 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 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 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 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 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 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 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}