Skip to main content

saluki_components/destinations/prometheus/
mod.rs

1//! Prometheus destination.
2
3use 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
44// Histogram-related constants and pre-calculated buckets.
45const 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
53// SAFETY: This is obviously not zero.
54const METRIC_NAME_STRING_INTERNER_BYTES: NonZeroUsize = NonZeroUsize::new(65536).unwrap();
55
56/// Provides a Prometheus scrape payload for an additional route.
57pub trait PrometheusPayloadProvider: Send + Sync {
58    /// Renders the current Prometheus text payload.
59    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/// Prometheus destination.
78///
79/// Exposes a Prometheus scrape endpoint that emits metrics in the Prometheus exposition format.
80///
81/// # Limits
82///
83/// - Number of contexts (unique series) is limited to 10,000.
84/// - Maximum size of scrape payload response is ~1MiB.
85///
86/// # Missing
87///
88/// - no support for expiring metrics (which we don't really need because the only use for this destination at the
89///   moment is internal metrics, which aren't dynamic since we don't use dynamic tags or have dynamic topology support,
90///   but... you know, we'll eventually need this)
91/// - full support for distributions (we can't convert a distribution to an aggregated histogram, and native histogram
92///   support is still too fresh for most clients, so we simply expose aggregated summaries as a stopgap)
93///
94#[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    /// Creates a new `PrometheusConfiguration` for the given listen address.
105    pub fn from_listen_address(listen_addr: ListenAddress) -> Self {
106        Self {
107            listen_addr,
108            additional_routes: Vec::new(),
109        }
110    }
111
112    /// Adds an additional scrape route backed by the given payload provider.
113    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            // Capture the size of the heap allocation when the component is built.
147            .with_single_value::<Prometheus>("component struct");
148
149        builder
150            .firm()
151            // Even though our context map is really the Prometheus context to a map of context/value pairs, we're just
152            // simplifying things here because the ratio of true "contexts" to Prometheus contexts should be very high,
153            // high enough to make this a reasonable approximation.
154            .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        // The scrape endpoint runs as a supervised child of this component, not as a part of its body. When this
184        // component is done, the scrape endpoint stops and drains its in-flight connections.
185        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                        // Process each metric event in the batch, either merging it with the existing value or
206                        // inserting it for the first time.
207                        for event in events {
208                            if let Some(metric) = event.try_into_metric() {
209                                // Break apart our metric into its constituent parts, and then normalize it for
210                                // Prometheus: adjust the name if necessary, figuring out the equivalent Prometheus
211                                // metric type, and so on.
212                                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                                // Create an entry for the context if we don't already have one, obeying our configured context limit.
220                                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 the scrape payload.
240                        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
253/// Builds the server that answers scrape requests.
254///
255/// Every path is answered by the same service rather than being routed: the raw metrics paths take precedence over the
256/// additional routes, so a path claimed by both is answered with the raw payload rather than being a conflict.
257fn 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
355/// Collects tags from a context into key-value pairs suitable for the renderer.
356fn 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        // Can't exceed the tags buffer size limit: we calculate the addition as tag name/value length plus three bytes
376        // to account for having to format it as `name="value",`.
377        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    // Normalize the metric name using the renderer, then intern it.
406    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        // Super hacky but effective way to decide when to switch to the time-oriented buckets.
488        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        // Add the value to each bucket that it falls into, up to the maximum number of buckets.
517        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    // We generate a set of "log-linear" buckets: logarithmically spaced values which are then subdivided linearly.
533    //
534    // As an example, with base=2 and scale=4, we would get: 2, 5, 8, 20, 32, 80, 128, 320, 512, and so on.
535    //
536    // We calculate buckets in pairs, where the n-th pair is `i` and `j`, such that `i` is `base * scale^n` and `j` is
537    // the midpoint between `i` and the next `i` (`base * scale^(n+1)`).
538
539    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        // The pre-computed log-linear bucket tables back every Prometheus histogram we expose, so their invariants are
582        // load-bearing: exactly N upper bounds, strictly increasing, finite and positive, starting at the configured
583        // base, each paired with a string label that renders (and parses back to) its own float value. A regression
584        // producing NaN/inf bounds, non-monotonic spacing, or a mismatched label would silently corrupt the exposition
585        // output, so assert the contract rather than just printing the tables.
586        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        // Go through and make sure we have things in the right buckets.
643        let mut expected_bucket_count = 0;
644        for sample in [sample1, sample2, sample3] {
645            for bucket in &histogram.buckets {
646                // If we've finally hit a bucket that includes our sample value, it's count should be equal to or
647                // greater than our expected bucket count when we account for the current sample.
648                if sample.0 <= bucket.0 {
649                    assert!(bucket.2 >= expected_bucket_count + sample.1);
650                }
651            }
652
653            // Adjust the expected bucket count to fully account for the current sample before moving on.
654            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        // The metric name is normalized to the Prometheus character set: the `.` separator is not a valid name
706        // character, so it is replaced (the renderer yields `my__counter`).
707        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        // Gauges and sets both map to a Prometheus gauge.
713        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        // Histograms map to a native Prometheus histogram.
721        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        // Distributions are exposed as a DDSketch-backed summary: the documented stopgap, since a distribution can't
726        // be converted to an aggregated histogram.
727        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        // Points are supplied out of timestamp order; the highest timestamp's value wins.
747        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        // Feeding gauge values into a counter accumulator is an invariant violation and panics.
799        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        // Key/value tags become labels; the bare `bare` tag (no value) is dropped.
813        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        // A single tag larger than the 2048-byte buffer trips the size-limit bail-out.
820        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}