1use std::{
4 convert::Infallible,
5 num::NonZeroUsize,
6 sync::{Arc, LazyLock},
7};
8
9use async_trait::async_trait;
10use axum::{extract::Request, Router};
11use ddsketch::DDSketch;
12use http::{Response, StatusCode};
13use prometheus_exposition::{MetricType, PrometheusRenderer};
14use saluki_common::{collections::FastIndexMap, iter::ReusableDeduplicator};
15use saluki_context::{tags::Tag, Context};
16use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
17use saluki_core::components::{destinations::*, BuildContext};
18use saluki_core::data_model::event::{
19 metric::{Histogram, Metric, MetricValues},
20 EventType,
21};
22use saluki_core::runtime;
23use saluki_error::GenericError;
24use saluki_io::net::{server::http::HttpServer, ListenAddress};
25use serde::Deserialize;
26use stringtheory::{
27 interning::{FixedSizeInterner, Interner as _},
28 MetaString,
29};
30use tokio::{select, sync::RwLock};
31use tower::util::service_fn;
32use tracing::debug;
33
34const CONTEXT_LIMIT: usize = 10_000;
35const PAYLOAD_SIZE_LIMIT_BYTES: usize = 1024 * 1024;
36const TAGS_BUFFER_SIZE_LIMIT_BYTES: usize = 2048;
37const RAW_METRICS_PATH: &str = "/metrics";
38const LEGACY_RAW_METRICS_PATH: &str = "/";
39
40const TIME_HISTOGRAM_BUCKET_COUNT: usize = 30;
42static TIME_HISTOGRAM_BUCKETS: LazyLock<[(f64, &'static str); TIME_HISTOGRAM_BUCKET_COUNT]> =
43 LazyLock::new(|| histogram_buckets::<TIME_HISTOGRAM_BUCKET_COUNT>(0.000000128, 4.0));
44
45const NON_TIME_HISTOGRAM_BUCKET_COUNT: usize = 30;
46static NON_TIME_HISTOGRAM_BUCKETS: LazyLock<[(f64, &'static str); NON_TIME_HISTOGRAM_BUCKET_COUNT]> =
47 LazyLock::new(|| histogram_buckets::<NON_TIME_HISTOGRAM_BUCKET_COUNT>(1.0, 2.0));
48
49const METRIC_NAME_STRING_INTERNER_BYTES: NonZeroUsize = NonZeroUsize::new(65536).unwrap();
51
52pub trait PrometheusPayloadProvider: Send + Sync {
54 fn render_payload(&self) -> String;
56}
57
58impl<F> PrometheusPayloadProvider for F
59where
60 F: Fn() -> String + Send + Sync,
61{
62 fn render_payload(&self) -> String {
63 self()
64 }
65}
66
67#[derive(Clone)]
68struct PrometheusAdditionalRoute {
69 path: String,
70 provider: Arc<dyn PrometheusPayloadProvider>,
71}
72
73#[derive(Deserialize)]
91pub struct PrometheusConfiguration {
92 #[serde(rename = "prometheus_listen_addr")]
93 listen_addr: ListenAddress,
94
95 #[serde(skip)]
96 additional_routes: Vec<PrometheusAdditionalRoute>,
97}
98
99impl PrometheusConfiguration {
100 pub fn from_listen_address(listen_addr: ListenAddress) -> Self {
102 Self {
103 listen_addr,
104 additional_routes: Vec::new(),
105 }
106 }
107
108 pub fn with_additional_route(
110 mut self, path: impl Into<String>, provider: Arc<dyn PrometheusPayloadProvider>,
111 ) -> Self {
112 self.additional_routes.push(PrometheusAdditionalRoute {
113 path: path.into(),
114 provider,
115 });
116 self
117 }
118}
119
120#[async_trait]
121impl DestinationBuilder for PrometheusConfiguration {
122 fn input_event_type(&self) -> EventType {
123 EventType::Metric
124 }
125
126 async fn build(&self, _context: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
127 Ok(Box::new(Prometheus {
128 listen_addr: self.listen_addr.clone(),
129 additional_routes: self.additional_routes.clone(),
130 metrics: FastIndexMap::default(),
131 payload: Arc::new(RwLock::new(String::new())),
132 renderer: PrometheusRenderer::new(),
133 interner: FixedSizeInterner::new(METRIC_NAME_STRING_INTERNER_BYTES),
134 }))
135 }
136}
137
138impl MemoryBounds for PrometheusConfiguration {
139 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
140 builder
141 .minimum()
142 .with_single_value::<Prometheus>("component struct");
144
145 builder
146 .firm()
147 .with_map::<Context, PrometheusValue>("state map", CONTEXT_LIMIT)
151 .with_fixed_amount("payload size", PAYLOAD_SIZE_LIMIT_BYTES)
152 .with_fixed_amount("tags buffer", TAGS_BUFFER_SIZE_LIMIT_BYTES);
153 }
154}
155
156struct Prometheus {
157 listen_addr: ListenAddress,
158 additional_routes: Vec<PrometheusAdditionalRoute>,
159 metrics: FastIndexMap<PrometheusContext, FastIndexMap<Context, PrometheusValue>>,
160 payload: Arc<RwLock<String>>,
161 renderer: PrometheusRenderer,
162 interner: FixedSizeInterner<1>,
163}
164
165#[async_trait]
166impl Destination for Prometheus {
167 async fn run(mut self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
168 let Self {
169 listen_addr,
170 additional_routes,
171 mut metrics,
172 payload,
173 mut renderer,
174 interner,
175 } = *self;
176
177 let mut health = context.take_health_handle();
178
179 runtime::nested_supervisor(
182 build_scrape_server(listen_addr, Arc::clone(&payload), additional_routes)
183 .with_worker_pool(context.topology_context().global_thread_pool().clone())
184 .into_supervisor(),
185 )
186 .spawn();
187
188 health.mark_ready();
189
190 debug!("Prometheus destination started.");
191
192 let mut contexts = 0;
193 let mut tags_deduplicator = ReusableDeduplicator::new();
194
195 loop {
196 select! {
197 _ = health.live() => continue,
198 maybe_events = context.events().next() => match maybe_events {
199 Some(events) => {
200 for event in events {
203 if let Some(metric) = event.try_into_metric() {
204 let prom_context = match into_prometheus_metric(&metric, &mut renderer, &interner) {
208 Some(prom_context) => prom_context,
209 None => continue,
210 };
211
212 let (context, values, _) = metric.into_parts();
213
214 let existing_contexts = metrics.entry(prom_context.clone()).or_default();
216 match existing_contexts.get_mut(&context) {
217 Some(existing_prom_value) => merge_metric_values_with_prom_value(values, existing_prom_value),
218 None => {
219 if contexts >= CONTEXT_LIMIT {
220 debug!("Prometheus destination reached context limit. Skipping metric '{}'.", context.name());
221 continue
222 }
223
224 let mut new_prom_value = get_prom_value_for_prom_context(&prom_context);
225 merge_metric_values_with_prom_value(values, &mut new_prom_value);
226
227 existing_contexts.insert(context, new_prom_value);
228 contexts += 1;
229 }
230 }
231 }
232 }
233
234 regenerate_payload(&metrics, &payload, &mut renderer, &mut tags_deduplicator).await;
236 },
237 None => break,
238 },
239 }
240 }
241
242 debug!("Prometheus destination stopped.");
243
244 Ok(())
245 }
246}
247
248fn build_scrape_server(
253 listen_addr: ListenAddress, payload: Arc<RwLock<String>>, additional_routes: Vec<PrometheusAdditionalRoute>,
254) -> HttpServer {
255 let additional_routes = Arc::new(additional_routes);
256 let service = service_fn(move |req: Request| {
257 let payload = Arc::clone(&payload);
258 let additional_routes = Arc::clone(&additional_routes);
259 async move {
260 Ok::<_, Infallible>(build_scrape_response(req.uri().path(), &payload, additional_routes.as_ref()).await)
261 }
262 });
263
264 HttpServer::from_listen_address(listen_addr).with_routes(Router::new().fallback_service(service))
265}
266
267async fn build_scrape_response(
268 path: &str, payload: &Arc<RwLock<String>>, additional_routes: &[PrometheusAdditionalRoute],
269) -> Response<axum::body::Body> {
270 if path == RAW_METRICS_PATH || path == LEGACY_RAW_METRICS_PATH {
271 let payload = payload.read().await;
272 return Response::new(axum::body::Body::from(payload.to_string()));
273 }
274
275 if let Some(route) = additional_routes.iter().find(|route| route.path == path) {
276 return Response::new(axum::body::Body::from(route.provider.render_payload()));
277 }
278
279 Response::builder()
280 .status(StatusCode::NOT_FOUND)
281 .body(axum::body::Body::empty())
282 .expect("response builder should accept static status and empty body")
283}
284
285#[allow(clippy::mutable_key_type)]
286async fn regenerate_payload(
287 metrics: &FastIndexMap<PrometheusContext, FastIndexMap<Context, PrometheusValue>>, payload: &Arc<RwLock<String>>,
288 renderer: &mut PrometheusRenderer, tags_deduplicator: &mut ReusableDeduplicator<Tag>,
289) {
290 renderer.clear();
291
292 for (prom_context, contexts) in metrics {
293 if !write_metrics(renderer, prom_context, contexts, tags_deduplicator) {
294 debug!("Failed to write metric to payload. Continuing...");
295 continue;
296 }
297
298 if renderer.output().len() > PAYLOAD_SIZE_LIMIT_BYTES {
299 debug!(
300 payload_len = renderer.output().len(),
301 "Payload size limit exceeded. Skipping remaining metrics."
302 );
303 break;
304 }
305 }
306
307 let mut payload = payload.write().await;
308 payload.clear();
309 payload.push_str(renderer.output());
310}
311
312fn write_metrics(
313 renderer: &mut PrometheusRenderer, prom_context: &PrometheusContext,
314 contexts: &FastIndexMap<Context, PrometheusValue>, tags_deduplicator: &mut ReusableDeduplicator<Tag>,
315) -> bool {
316 if contexts.is_empty() {
317 debug!("No contexts for metric '{}'. Skipping.", prom_context.metric_name);
318 return true;
319 }
320
321 renderer.begin_group(&prom_context.metric_name, prom_context.metric_type, None);
322
323 for (context, values) in contexts {
324 let labels = match collect_tags(context, tags_deduplicator) {
325 Some(labels) => labels,
326 None => return false,
327 };
328
329 match values {
330 PrometheusValue::Counter(value) | PrometheusValue::Gauge(value) => {
331 renderer.write_gauge_or_counter_series(labels, *value);
332 }
333 PrometheusValue::Histogram(histogram) => {
334 renderer.write_histogram_series(labels, histogram.buckets(), histogram.sum, histogram.count);
335 }
336 PrometheusValue::Summary(sketch) => {
337 let quantiles = [0.1, 0.25, 0.5, 0.95, 0.99, 0.999]
338 .into_iter()
339 .map(|q| (q, sketch.quantile(q).unwrap_or_default()));
340
341 renderer.write_summary_series(labels, quantiles, sketch.sum().unwrap_or_default(), sketch.count());
342 }
343 }
344 }
345
346 renderer.finish_group();
347 true
348}
349
350fn collect_tags<'a>(
352 context: &'a Context, tags_deduplicator: &mut ReusableDeduplicator<Tag>,
353) -> Option<Vec<(&'a str, &'a str)>> {
354 let mut labels = Vec::new();
355 let mut total_bytes = 0;
356
357 let chained_tags = context.tags().into_iter().chain(context.origin_tags());
358 let deduplicated_tags = tags_deduplicator.deduplicated(chained_tags);
359
360 for tag in deduplicated_tags {
361 let tag_name = tag.name();
362 let tag_value = match tag.value() {
363 Some(value) => value,
364 None => {
365 debug!("Skipping bare tag.");
366 continue;
367 }
368 };
369
370 total_bytes += tag_name.len() + tag_value.len() + 4;
373 if total_bytes > TAGS_BUFFER_SIZE_LIMIT_BYTES {
374 debug!("Tags buffer size limit exceeded. Tags may be missing from this metric.");
375 return None;
376 }
377
378 labels.push((tag_name, tag_value));
379 }
380
381 Some(labels)
382}
383
384#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
385struct PrometheusContext {
386 metric_name: MetaString,
387 metric_type: MetricType,
388}
389
390enum PrometheusValue {
391 Counter(f64),
392 Gauge(f64),
393 Histogram(PrometheusHistogram),
394 Summary(DDSketch),
395}
396
397fn into_prometheus_metric(
398 metric: &Metric, renderer: &mut PrometheusRenderer, interner: &FixedSizeInterner<1>,
399) -> Option<PrometheusContext> {
400 let normalized = renderer.normalize_metric_name(metric.context().name());
402 let metric_name = match interner.try_intern(normalized).map(MetaString::from) {
403 Some(name) => name,
404 None => {
405 debug!(
406 "Failed to intern normalized metric name. Skipping metric '{}'.",
407 metric.context().name()
408 );
409 return None;
410 }
411 };
412
413 let metric_type = match metric.values() {
414 MetricValues::Counter(_) => MetricType::Counter,
415 MetricValues::Gauge(_) | MetricValues::Set(_) => MetricType::Gauge,
416 MetricValues::Histogram(_) => MetricType::Histogram,
417 MetricValues::Distribution(_) => MetricType::Summary,
418 _ => return None,
419 };
420
421 Some(PrometheusContext {
422 metric_name,
423 metric_type,
424 })
425}
426
427fn get_prom_value_for_prom_context(prom_context: &PrometheusContext) -> PrometheusValue {
428 match prom_context.metric_type {
429 MetricType::Counter => PrometheusValue::Counter(0.0),
430 MetricType::Gauge => PrometheusValue::Gauge(0.0),
431 MetricType::Histogram => PrometheusValue::Histogram(PrometheusHistogram::new(&prom_context.metric_name)),
432 MetricType::Summary => PrometheusValue::Summary(DDSketch::default()),
433 }
434}
435
436fn merge_metric_values_with_prom_value(values: MetricValues, prom_value: &mut PrometheusValue) {
437 match (values, prom_value) {
438 (MetricValues::Counter(counter_values), PrometheusValue::Counter(prom_counter)) => {
439 for (_, value) in counter_values {
440 *prom_counter += value;
441 }
442 }
443 (MetricValues::Gauge(gauge_values), PrometheusValue::Gauge(prom_gauge)) => {
444 let latest_value = gauge_values
445 .into_iter()
446 .max_by_key(|(ts, _)| ts.map(|v| v.get()).unwrap_or_default())
447 .map(|(_, value)| value)
448 .unwrap_or_default();
449 *prom_gauge = latest_value;
450 }
451 (MetricValues::Set(set_values), PrometheusValue::Gauge(prom_gauge)) => {
452 let latest_value = set_values
453 .into_iter()
454 .max_by_key(|(ts, _)| ts.map(|v| v.get()).unwrap_or_default())
455 .map(|(_, value)| value)
456 .unwrap_or_default();
457 *prom_gauge = latest_value;
458 }
459 (MetricValues::Histogram(histogram_values), PrometheusValue::Histogram(prom_histogram)) => {
460 for (_, value) in histogram_values {
461 prom_histogram.merge_histogram(&value);
462 }
463 }
464 (MetricValues::Distribution(distribution_values), PrometheusValue::Summary(prom_summary)) => {
465 for (_, value) in distribution_values {
466 prom_summary.merge(&value);
467 }
468 }
469 _ => panic!("Mismatched metric types"),
470 }
471}
472
473#[derive(Clone)]
474struct PrometheusHistogram {
475 sum: f64,
476 count: u64,
477 buckets: Vec<(f64, &'static str, u64)>,
478}
479
480impl PrometheusHistogram {
481 fn new(metric_name: &str) -> Self {
482 let base_buckets = if metric_name.ends_with("_seconds") {
484 &TIME_HISTOGRAM_BUCKETS[..]
485 } else {
486 &NON_TIME_HISTOGRAM_BUCKETS[..]
487 };
488
489 let buckets = base_buckets
490 .iter()
491 .map(|(upper_bound, upper_bound_str)| (*upper_bound, *upper_bound_str, 0))
492 .collect();
493
494 Self {
495 sum: 0.0,
496 count: 0,
497 buckets,
498 }
499 }
500
501 fn merge_histogram(&mut self, histogram: &Histogram) {
502 for sample in histogram.samples() {
503 self.add_sample(sample.value.into_inner(), sample.weight.0 as u64);
504 }
505 }
506
507 fn add_sample(&mut self, value: f64, weight: u64) {
508 self.sum += value * weight as f64;
509 self.count += weight;
510
511 for (upper_bound, _, count) in &mut self.buckets {
513 if value <= *upper_bound {
514 *count += weight;
515 }
516 }
517 }
518
519 fn buckets(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
520 self.buckets
521 .iter()
522 .map(|(_, upper_bound_str, count)| (*upper_bound_str, *count))
523 }
524}
525
526fn histogram_buckets<const N: usize>(base: f64, scale: f64) -> [(f64, &'static str); N] {
527 let mut buckets = [(0.0, ""); N];
535
536 let log_linear_buckets = std::iter::repeat(base).enumerate().flat_map(|(i, base)| {
537 let pow = scale.powf(i as f64);
538 let value = base * pow;
539
540 let next_pow = scale.powf((i + 1) as f64);
541 let next_value = base * next_pow;
542 let midpoint = (value + next_value) / 2.0;
543
544 [value, midpoint]
545 });
546
547 for (i, current_le) in log_linear_buckets.enumerate().take(N) {
548 let (bucket_le, bucket_le_str) = &mut buckets[i];
549 let current_le_str = format!("{}", current_le);
550
551 *bucket_le = current_le;
552 *bucket_le_str = current_le_str.leak();
553 }
554
555 buckets
556}
557
558#[cfg(test)]
559mod tests {
560 use std::collections::BTreeSet;
561
562 use http_body_util::BodyExt as _;
563 use saluki_context::tags::TagSet;
564
565 use super::*;
566
567 fn prom_context(metric_type: MetricType) -> PrometheusContext {
568 PrometheusContext {
569 metric_name: MetaString::from("test.metric"),
570 metric_type,
571 }
572 }
573
574 #[test]
575 fn histogram_buckets_are_monotonic_finite_and_labeled() {
576 fn assert_bucket_table(buckets: &[(f64, &'static str)], base: f64) {
582 assert!(!buckets.is_empty(), "bucket table must not be empty");
583 assert_eq!(
584 buckets[0].0, base,
585 "first bucket upper bound must equal the configured base"
586 );
587
588 let mut previous = f64::NEG_INFINITY;
589 for (upper_bound, label) in buckets {
590 assert!(
591 upper_bound.is_finite(),
592 "bucket upper bound must be finite, got {upper_bound}"
593 );
594 assert!(
595 *upper_bound > 0.0,
596 "bucket upper bound must be positive, got {upper_bound}"
597 );
598 assert!(
599 *upper_bound > previous,
600 "bucket upper bounds must be strictly increasing ({upper_bound} !> {previous})"
601 );
602 previous = *upper_bound;
603
604 let parsed: f64 = label.parse().expect("bucket label must parse as an f64");
605 assert_eq!(
606 parsed, *upper_bound,
607 "bucket label {label:?} must render its own upper bound {upper_bound}"
608 );
609 }
610 }
611
612 assert_eq!(TIME_HISTOGRAM_BUCKETS.len(), TIME_HISTOGRAM_BUCKET_COUNT);
613 assert_eq!(NON_TIME_HISTOGRAM_BUCKETS.len(), NON_TIME_HISTOGRAM_BUCKET_COUNT);
614 assert_bucket_table(&TIME_HISTOGRAM_BUCKETS[..], 0.000000128);
615 assert_bucket_table(&NON_TIME_HISTOGRAM_BUCKETS[..], 1.0);
616 }
617
618 #[test]
619 fn prom_histogram_add_sample() {
620 let sample1 = (0.25, 1);
621 let sample2 = (1.0, 2);
622 let sample3 = (2.0, 3);
623
624 let mut histogram = PrometheusHistogram::new("time_metric_seconds");
625 histogram.add_sample(sample1.0, sample1.1);
626 histogram.add_sample(sample2.0, sample2.1);
627 histogram.add_sample(sample3.0, sample3.1);
628
629 let sample1_weighted_value = sample1.0 * sample1.1 as f64;
630 let sample2_weighted_value = sample2.0 * sample2.1 as f64;
631 let sample3_weighted_value = sample3.0 * sample3.1 as f64;
632 let expected_sum = sample1_weighted_value + sample2_weighted_value + sample3_weighted_value;
633 let expected_count = sample1.1 + sample2.1 + sample3.1;
634 assert_eq!(histogram.sum, expected_sum);
635 assert_eq!(histogram.count, expected_count);
636
637 let mut expected_bucket_count = 0;
639 for sample in [sample1, sample2, sample3] {
640 for bucket in &histogram.buckets {
641 if sample.0 <= bucket.0 {
644 assert!(bucket.2 >= expected_bucket_count + sample.1);
645 }
646 }
647
648 expected_bucket_count += sample.1;
650 }
651 }
652
653 #[tokio::test]
654 async fn scrape_routes_serve_raw_compat_and_404() {
655 let payload = Arc::new(RwLock::new("raw".to_string()));
656 let routes = vec![PrometheusAdditionalRoute {
657 path: "/compat/metrics".to_string(),
658 provider: Arc::new(|| "compat".to_string()),
659 }];
660
661 let raw_response = build_scrape_response("/metrics", &payload, &routes).await;
662 assert_eq!(raw_response.status(), StatusCode::OK);
663 let raw_body = raw_response
664 .into_body()
665 .collect()
666 .await
667 .expect("body should collect")
668 .to_bytes();
669 assert_eq!(&raw_body[..], b"raw");
670
671 let legacy_response = build_scrape_response("/", &payload, &routes).await;
672 assert_eq!(legacy_response.status(), StatusCode::OK);
673 let legacy_body = legacy_response
674 .into_body()
675 .collect()
676 .await
677 .expect("body should collect")
678 .to_bytes();
679 assert_eq!(&legacy_body[..], b"raw");
680
681 let compat_response = build_scrape_response("/compat/metrics", &payload, &routes).await;
682 assert_eq!(compat_response.status(), StatusCode::OK);
683 let compat_body = compat_response
684 .into_body()
685 .collect()
686 .await
687 .expect("body should collect")
688 .to_bytes();
689 assert_eq!(&compat_body[..], b"compat");
690
691 let missing_response = build_scrape_response("/missing", &payload, &routes).await;
692 assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
693 }
694
695 #[test]
696 fn into_prometheus_metric_maps_value_type_and_normalizes_name() {
697 let mut renderer = PrometheusRenderer::new();
698 let interner = FixedSizeInterner::<1>::new(METRIC_NAME_STRING_INTERNER_BYTES);
699
700 let counter = into_prometheus_metric(&Metric::counter("my.counter", 1.0), &mut renderer, &interner)
703 .expect("counter should translate");
704 assert_eq!("my__counter", counter.metric_name.as_ref());
705 assert!(matches!(counter.metric_type, MetricType::Counter));
706
707 let gauge =
709 into_prometheus_metric(&Metric::gauge("g", 1.0), &mut renderer, &interner).expect("gauge should translate");
710 assert!(matches!(gauge.metric_type, MetricType::Gauge));
711 let set =
712 into_prometheus_metric(&Metric::set("s", "a"), &mut renderer, &interner).expect("set should translate");
713 assert!(matches!(set.metric_type, MetricType::Gauge));
714
715 let histogram = into_prometheus_metric(&Metric::histogram("h", [1.0]), &mut renderer, &interner)
717 .expect("histogram should translate");
718 assert!(matches!(histogram.metric_type, MetricType::Histogram));
719
720 let distribution = into_prometheus_metric(&Metric::distribution("d", [1.0]), &mut renderer, &interner)
723 .expect("distribution should translate");
724 assert!(matches!(distribution.metric_type, MetricType::Summary));
725 }
726
727 #[test]
728 fn merge_counter_sums_all_points() {
729 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Counter));
730 let (_, values, _) = Metric::counter("c", [(1, 1.0), (2, 2.0), (3, 4.0)]).into_parts();
731 merge_metric_values_with_prom_value(values, &mut value);
732 match value {
733 PrometheusValue::Counter(sum) => assert_eq!(7.0, sum),
734 _ => panic!("counter values should stay a counter"),
735 }
736 }
737
738 #[test]
739 fn merge_gauge_keeps_latest_by_timestamp() {
740 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Gauge));
741 let (_, values, _) = Metric::gauge("g", [(10, 1.0), (30, 3.0), (20, 2.0)]).into_parts();
743 merge_metric_values_with_prom_value(values, &mut value);
744 match value {
745 PrometheusValue::Gauge(latest) => assert_eq!(3.0, latest),
746 _ => panic!("gauge values should stay a gauge"),
747 }
748 }
749
750 #[test]
751 fn merge_set_maps_cardinality_into_gauge() {
752 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Gauge));
753 let (_, values, _) = Metric::set("s", "a").into_parts();
754 merge_metric_values_with_prom_value(values, &mut value);
755 match value {
756 PrometheusValue::Gauge(cardinality) => assert_eq!(1.0, cardinality),
757 _ => panic!("set values should merge into a gauge"),
758 }
759 }
760
761 #[test]
762 fn merge_histogram_accumulates_sum_and_count() {
763 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Histogram));
764 let (_, values, _) = Metric::histogram("h", [1.0, 2.0, 3.0]).into_parts();
765 merge_metric_values_with_prom_value(values, &mut value);
766 match value {
767 PrometheusValue::Histogram(histogram) => {
768 assert_eq!(3, histogram.count);
769 assert_eq!(6.0, histogram.sum);
770 }
771 _ => panic!("histogram values should stay a histogram"),
772 }
773 }
774
775 #[test]
776 fn merge_distribution_folds_into_ddsketch_summary() {
777 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Summary));
778 let (_, values, _) = Metric::distribution("d", [1.0, 2.0, 3.0, 4.0, 5.0]).into_parts();
779 merge_metric_values_with_prom_value(values, &mut value);
780 match value {
781 PrometheusValue::Summary(sketch) => {
782 assert_eq!(5, sketch.count());
783 let median = sketch.quantile(0.5).expect("median should be computable");
784 assert!((median - 3.0).abs() <= 0.5, "median should be ~= 3.0, got {median}");
785 }
786 _ => panic!("distribution values should merge into a summary"),
787 }
788 }
789
790 #[test]
791 #[should_panic(expected = "Mismatched metric types")]
792 fn merge_mismatched_value_and_accumulator_types_panics() {
793 let mut value = get_prom_value_for_prom_context(&prom_context(MetricType::Counter));
795 let (_, values, _) = Metric::gauge("g", 1.0).into_parts();
796 merge_metric_values_with_prom_value(values, &mut value);
797 }
798
799 #[test]
800 fn collect_tags_skips_bare_tags_and_keeps_key_value_pairs() {
801 let mut tags_deduplicator = ReusableDeduplicator::new();
802 let context = Context::from_static_parts("m", &["env:prod", "bare", "team:core"]);
803
804 let labels = collect_tags(&context, &mut tags_deduplicator).expect("tags should collect");
805 let labels = labels.into_iter().collect::<BTreeSet<_>>();
806
807 assert_eq!(BTreeSet::from([("env", "prod"), ("team", "core")]), labels);
809 }
810
811 #[test]
812 fn collect_tags_returns_none_when_buffer_limit_exceeded() {
813 let mut tags_deduplicator = ReusableDeduplicator::new();
814 let oversized_tag = format!("big:{}", "x".repeat(TAGS_BUFFER_SIZE_LIMIT_BYTES + 1));
816 let tags = std::iter::once(Tag::from(oversized_tag)).collect::<TagSet>();
817 let context = Context::from_parts("m", tags);
818
819 assert_eq!(None, collect_tags(&context, &mut tags_deduplicator));
820 }
821}