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