Skip to main content

dynamo_runtime/
metrics.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Metrics registry trait and implementation for Prometheus metrics
5//!
6//! This module provides a trait-based interface for creating and managing Prometheus metrics
7//! with automatic label injection and hierarchical naming support.
8
9pub mod frontend_perf;
10pub mod otlp_export;
11pub mod prom_typed;
12pub mod prometheus_names;
13pub mod quic_response;
14pub mod request_plane;
15pub mod tokio_perf;
16pub mod transport_metrics;
17pub mod work_handler_perf;
18pub mod work_handler_pool;
19
20use parking_lot::Mutex;
21use std::collections::HashSet;
22use std::sync::Arc;
23
24use crate::component::ComponentBuilder;
25use anyhow;
26use anyhow::Context as _;
27use once_cell::sync::Lazy;
28use regex::Regex;
29use std::any::Any;
30use std::collections::HashMap;
31
32// Import commonly used items to avoid verbose prefixes
33use prometheus_names::{
34    build_component_metric_name, labels, name_prefix, sanitize_prometheus_label,
35    sanitize_prometheus_name, work_handler,
36};
37
38// Pipeline imports for endpoint creation
39use crate::pipeline::{
40    AsyncEngine, AsyncEngineContextProvider, Error, ManyOut, ResponseStream, SingleIn, async_trait,
41    network::Ingress,
42};
43use crate::protocols::annotated::Annotated;
44use crate::stream;
45use crate::stream::StreamExt;
46
47// Prometheus imports
48use prometheus::Encoder;
49
50/// Validate that a label slice has no duplicate keys.
51/// Returns Ok(()) when all keys are unique; otherwise returns an error naming the duplicate key.
52fn validate_no_duplicate_label_keys(labels: &[(&str, &str)]) -> anyhow::Result<()> {
53    let mut seen_keys = std::collections::HashSet::new();
54    for (key, _) in labels {
55        if !seen_keys.insert(*key) {
56            return Err(anyhow::anyhow!(
57                "Duplicate label key '{}' found in labels",
58                key
59            ));
60        }
61    }
62    Ok(())
63}
64
65/// ==============================
66/// Prometheus section
67/// ==============================
68/// Trait that defines common behavior for Prometheus metric types
69pub trait PrometheusMetric: prometheus::core::Collector + Clone + Send + Sync + 'static {
70    /// Create a new metric with the given options
71    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error>
72    where
73        Self: Sized;
74
75    /// Create a new metric with histogram options and custom buckets
76    /// This is a default implementation that will panic for non-histogram metrics
77    fn with_histogram_opts_and_buckets(
78        _opts: prometheus::HistogramOpts,
79        _buckets: Option<Vec<f64>>,
80    ) -> Result<Self, prometheus::Error>
81    where
82        Self: Sized,
83    {
84        panic!("with_histogram_opts_and_buckets is not implemented for this metric type");
85    }
86
87    /// Create a histogram vector with custom buckets and label names.
88    fn with_histogram_opts_buckets_and_label_names(
89        _opts: prometheus::HistogramOpts,
90        _buckets: Option<Vec<f64>>,
91        _label_names: &[&str],
92    ) -> Result<Self, prometheus::Error>
93    where
94        Self: Sized,
95    {
96        panic!(
97            "with_histogram_opts_buckets_and_label_names is not implemented for this metric type"
98        );
99    }
100
101    /// Create a new metric with counter options and label names (for CounterVec)
102    /// This is a default implementation that will panic for non-countervec metrics
103    fn with_opts_and_label_names(
104        _opts: prometheus::Opts,
105        _label_names: &[&str],
106    ) -> Result<Self, prometheus::Error>
107    where
108        Self: Sized,
109    {
110        panic!("with_opts_and_label_names is not implemented for this metric type");
111    }
112}
113
114// Implement the trait for Counter, IntCounter, and Gauge
115impl PrometheusMetric for prometheus::Counter {
116    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
117        prometheus::Counter::with_opts(opts)
118    }
119}
120
121impl PrometheusMetric for prometheus::IntCounter {
122    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
123        prometheus::IntCounter::with_opts(opts)
124    }
125}
126
127impl PrometheusMetric for prometheus::Gauge {
128    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
129        prometheus::Gauge::with_opts(opts)
130    }
131}
132
133impl PrometheusMetric for prometheus::IntGauge {
134    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
135        prometheus::IntGauge::with_opts(opts)
136    }
137}
138
139impl PrometheusMetric for prometheus::GaugeVec {
140    fn with_opts(_opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
141        Err(prometheus::Error::Msg(
142            "GaugeVec requires label names, use with_opts_and_label_names instead".to_string(),
143        ))
144    }
145
146    fn with_opts_and_label_names(
147        opts: prometheus::Opts,
148        label_names: &[&str],
149    ) -> Result<Self, prometheus::Error> {
150        prometheus::GaugeVec::new(opts, label_names)
151    }
152}
153
154impl PrometheusMetric for prometheus::IntGaugeVec {
155    fn with_opts(_opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
156        Err(prometheus::Error::Msg(
157            "IntGaugeVec requires label names, use with_opts_and_label_names instead".to_string(),
158        ))
159    }
160
161    fn with_opts_and_label_names(
162        opts: prometheus::Opts,
163        label_names: &[&str],
164    ) -> Result<Self, prometheus::Error> {
165        prometheus::IntGaugeVec::new(opts, label_names)
166    }
167}
168
169impl PrometheusMetric for prometheus::IntCounterVec {
170    fn with_opts(_opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
171        Err(prometheus::Error::Msg(
172            "IntCounterVec requires label names, use with_opts_and_label_names instead".to_string(),
173        ))
174    }
175
176    fn with_opts_and_label_names(
177        opts: prometheus::Opts,
178        label_names: &[&str],
179    ) -> Result<Self, prometheus::Error> {
180        prometheus::IntCounterVec::new(opts, label_names)
181    }
182}
183
184// Implement the trait for Histogram
185impl PrometheusMetric for prometheus::Histogram {
186    fn with_opts(opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
187        // Convert Opts to HistogramOpts
188        let histogram_opts = prometheus::HistogramOpts::new(opts.name, opts.help);
189        prometheus::Histogram::with_opts(histogram_opts)
190    }
191
192    fn with_histogram_opts_and_buckets(
193        mut opts: prometheus::HistogramOpts,
194        buckets: Option<Vec<f64>>,
195    ) -> Result<Self, prometheus::Error> {
196        if let Some(custom_buckets) = buckets {
197            opts = opts.buckets(custom_buckets);
198        }
199        prometheus::Histogram::with_opts(opts)
200    }
201}
202
203impl PrometheusMetric for prometheus::HistogramVec {
204    fn with_opts(_opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
205        Err(prometheus::Error::Msg(
206            "HistogramVec requires histogram options and label names".to_string(),
207        ))
208    }
209
210    fn with_histogram_opts_buckets_and_label_names(
211        mut opts: prometheus::HistogramOpts,
212        buckets: Option<Vec<f64>>,
213        label_names: &[&str],
214    ) -> Result<Self, prometheus::Error> {
215        if let Some(custom_buckets) = buckets {
216            opts = opts.buckets(custom_buckets);
217        }
218        prometheus::HistogramVec::new(opts, label_names)
219    }
220}
221
222// Implement the trait for CounterVec
223impl PrometheusMetric for prometheus::CounterVec {
224    fn with_opts(_opts: prometheus::Opts) -> Result<Self, prometheus::Error> {
225        // This will panic - CounterVec needs label names
226        panic!("CounterVec requires label names, use with_opts_and_label_names instead");
227    }
228
229    fn with_opts_and_label_names(
230        opts: prometheus::Opts,
231        label_names: &[&str],
232    ) -> Result<Self, prometheus::Error> {
233        prometheus::CounterVec::new(opts, label_names)
234    }
235}
236
237/// ==============================
238/// Metrics section
239/// ==============================
240/// Public helper function to create metrics - accessible for Python bindings
241pub fn create_metric<T: PrometheusMetric, H: MetricsHierarchy + ?Sized>(
242    hierarchy: &H,
243    metric_name: &str,
244    metric_desc: &str,
245    labels: &[(&str, &str)],
246    buckets: Option<Vec<f64>>,
247    const_labels: Option<&[&str]>,
248) -> anyhow::Result<T> {
249    // Validate that user-provided labels don't have duplicate keys
250    validate_no_duplicate_label_keys(labels)?;
251    // Note: stored labels functionality has been removed
252
253    let basename = hierarchy.basename();
254    let parent_hierarchies = hierarchy.parent_hierarchies();
255
256    // Build hierarchy path as vector of strings: parent names + [basename]
257    let mut hierarchy_names: Vec<String> =
258        parent_hierarchies.iter().map(|p| p.basename()).collect();
259    hierarchy_names.push(basename.clone());
260
261    let metric_name = build_component_metric_name(metric_name);
262
263    // Build updated_labels: auto-labels first, then `labels` + stored labels
264    let mut updated_labels: Vec<(String, String)> = Vec::new();
265
266    // Auto-label injection: Always add dynamo_namespace, dynamo_component, dynamo_endpoint labels
267    // based on the hierarchy. Label constants defined in prometheus_names.rs labels module.
268    //
269    // Python counterpart: components/src/dynamo/common/utils/prometheus.py register_engine_metrics_callback()
270
271    // Validate that user-provided labels don't conflict with auto-generated labels
272    for (key, _) in labels {
273        if *key == labels::NAMESPACE
274            || *key == labels::COMPONENT
275            || *key == labels::ENDPOINT
276            || *key == labels::WORKER_ID
277        {
278            return Err(anyhow::anyhow!(
279                "Label '{}' is automatically added by auto-label injection and cannot be manually set",
280                key
281            ));
282        }
283    }
284
285    // Also validate that vector label names (const_labels) don't collide with auto-injected
286    // const labels. A variable label named "worker_id" would conflict with the auto-injected
287    // worker_id const label, causing a prometheus registration error or ambiguous output.
288    if let Some(label_names) = const_labels {
289        for name in label_names.iter() {
290            if *name == labels::NAMESPACE
291                || *name == labels::COMPONENT
292                || *name == labels::ENDPOINT
293                || *name == labels::WORKER_ID
294            {
295                return Err(anyhow::anyhow!(
296                    "Variable label name '{}' conflicts with auto-injected const label and cannot be used",
297                    name
298                ));
299            }
300        }
301    }
302
303    // Add auto-generated labels with sanitized values
304    // Hierarchy: [drt, namespace, component, endpoint]
305    if hierarchy_names.len() > 1 {
306        let namespace = &hierarchy_names[1];
307        if !namespace.is_empty() {
308            let valid_namespace = sanitize_prometheus_label(namespace)?;
309            if !valid_namespace.is_empty() {
310                updated_labels.push((labels::NAMESPACE.to_string(), valid_namespace));
311            }
312        }
313    }
314    if hierarchy_names.len() > 2 {
315        let component = &hierarchy_names[2];
316        if !component.is_empty() {
317            let valid_component = sanitize_prometheus_label(component)?;
318            if !valid_component.is_empty() {
319                updated_labels.push((labels::COMPONENT.to_string(), valid_component));
320            }
321        }
322    }
323    if hierarchy_names.len() > 3 {
324        let endpoint = &hierarchy_names[3];
325        if !endpoint.is_empty() {
326            let valid_endpoint = sanitize_prometheus_label(endpoint)?;
327            if !valid_endpoint.is_empty() {
328                updated_labels.push((labels::ENDPOINT.to_string(), valid_endpoint));
329            }
330        }
331    }
332
333    // Auto-inject worker_id label from the hierarchy's connection_id (discovery instance ID).
334    // This provides a stable per-worker identity label so metrics from different workers
335    // serving the same endpoint can be distinguished without relying on Kubernetes labels.
336    if let Some(conn_id) = hierarchy.connection_id() {
337        updated_labels.push((labels::WORKER_ID.to_string(), format!("{:x}", conn_id)));
338    }
339
340    // Add user labels
341    updated_labels.extend(
342        labels
343            .iter()
344            .map(|(k, v)| ((*k).to_string(), (*v).to_string())),
345    );
346    // Note: stored labels functionality has been removed
347
348    // Handle different metric types
349    let prometheus_metric = if std::any::TypeId::of::<T>()
350        == std::any::TypeId::of::<prometheus::CounterVec>()
351    {
352        // Special handling for CounterVec with label names
353        // const_labels parameter is required for CounterVec
354        if buckets.is_some() {
355            return Err(anyhow::anyhow!(
356                "buckets parameter is not valid for CounterVec"
357            ));
358        }
359        let mut opts = prometheus::Opts::new(&metric_name, metric_desc);
360        for (key, value) in &updated_labels {
361            opts = opts.const_label(key.clone(), value.clone());
362        }
363        let label_names = const_labels
364            .ok_or_else(|| anyhow::anyhow!("CounterVec requires const_labels parameter"))?;
365        T::with_opts_and_label_names(opts, label_names)?
366    } else if std::any::TypeId::of::<T>() == std::any::TypeId::of::<prometheus::GaugeVec>() {
367        // Special handling for GaugeVec with label names
368        // const_labels parameter is required for GaugeVec
369        if buckets.is_some() {
370            return Err(anyhow::anyhow!(
371                "buckets parameter is not valid for GaugeVec"
372            ));
373        }
374        let mut opts = prometheus::Opts::new(&metric_name, metric_desc);
375        for (key, value) in &updated_labels {
376            opts = opts.const_label(key.clone(), value.clone());
377        }
378        let label_names = const_labels
379            .ok_or_else(|| anyhow::anyhow!("GaugeVec requires const_labels parameter"))?;
380        T::with_opts_and_label_names(opts, label_names)?
381    } else if std::any::TypeId::of::<T>() == std::any::TypeId::of::<prometheus::HistogramVec>() {
382        let mut opts = prometheus::HistogramOpts::new(&metric_name, metric_desc);
383        for (key, value) in &updated_labels {
384            opts = opts.const_label(key.clone(), value.clone());
385        }
386        let label_names = const_labels
387            .ok_or_else(|| anyhow::anyhow!("HistogramVec requires const_labels parameter"))?;
388        T::with_histogram_opts_buckets_and_label_names(opts, buckets, label_names)?
389    } else if std::any::TypeId::of::<T>() == std::any::TypeId::of::<prometheus::Histogram>() {
390        // Special handling for Histogram with custom buckets
391        // buckets parameter is valid for Histogram, const_labels is not used
392        if const_labels.is_some() {
393            return Err(anyhow::anyhow!(
394                "const_labels parameter is not valid for Histogram"
395            ));
396        }
397        let mut opts = prometheus::HistogramOpts::new(&metric_name, metric_desc);
398        for (key, value) in &updated_labels {
399            opts = opts.const_label(key.clone(), value.clone());
400        }
401        T::with_histogram_opts_and_buckets(opts, buckets)?
402    } else if std::any::TypeId::of::<T>() == std::any::TypeId::of::<prometheus::IntCounterVec>() {
403        // Special handling for IntCounterVec with label names
404        // const_labels parameter is required for IntCounterVec
405        if buckets.is_some() {
406            return Err(anyhow::anyhow!(
407                "buckets parameter is not valid for IntCounterVec"
408            ));
409        }
410        let mut opts = prometheus::Opts::new(&metric_name, metric_desc);
411        for (key, value) in &updated_labels {
412            opts = opts.const_label(key.clone(), value.clone());
413        }
414        let label_names = const_labels
415            .ok_or_else(|| anyhow::anyhow!("IntCounterVec requires const_labels parameter"))?;
416        T::with_opts_and_label_names(opts, label_names)?
417    } else if std::any::TypeId::of::<T>() == std::any::TypeId::of::<prometheus::IntGaugeVec>() {
418        // Special handling for IntGaugeVec with label names
419        // const_labels parameter is required for IntGaugeVec
420        if buckets.is_some() {
421            return Err(anyhow::anyhow!(
422                "buckets parameter is not valid for IntGaugeVec"
423            ));
424        }
425        let mut opts = prometheus::Opts::new(&metric_name, metric_desc);
426        for (key, value) in &updated_labels {
427            opts = opts.const_label(key.clone(), value.clone());
428        }
429        let label_names = const_labels
430            .ok_or_else(|| anyhow::anyhow!("IntGaugeVec requires const_labels parameter"))?;
431        T::with_opts_and_label_names(opts, label_names)?
432    } else {
433        // Standard handling for Counter, IntCounter, Gauge, IntGauge
434        // buckets and const_labels parameters are not valid for these types
435        if buckets.is_some() {
436            return Err(anyhow::anyhow!(
437                "buckets parameter is not valid for Counter, IntCounter, Gauge, or IntGauge"
438            ));
439        }
440        if const_labels.is_some() {
441            return Err(anyhow::anyhow!(
442                "const_labels parameter is not valid for Counter, IntCounter, Gauge, or IntGauge"
443            ));
444        }
445        let mut opts = prometheus::Opts::new(&metric_name, metric_desc);
446        for (key, value) in &updated_labels {
447            opts = opts.const_label(key.clone(), value.clone());
448        }
449        T::with_opts(opts)?
450    };
451
452    let collector: Box<dyn prometheus::core::Collector> = Box::new(prometheus_metric.clone());
453    hierarchy.get_metrics_registry().add_metric(collector)?;
454
455    Ok(prometheus_metric)
456}
457
458/// Wrapper struct that provides access to metrics functionality
459/// This struct is accessed via the `.metrics()` method on DistributedRuntime, Namespace, Component, and Endpoint
460pub struct Metrics<H: MetricsHierarchy> {
461    hierarchy: H,
462}
463
464impl<H: MetricsHierarchy> Metrics<H> {
465    pub fn new(hierarchy: H) -> Self {
466        Self { hierarchy }
467    }
468
469    // TODO: Add support for additional Prometheus metric types:
470    // - Counter: ✅ IMPLEMENTED - create_counter()
471    // - CounterVec: ✅ IMPLEMENTED - create_countervec()
472    // - Gauge: ✅ IMPLEMENTED - create_gauge()
473    // - GaugeVec: ✅ IMPLEMENTED - create_gaugevec()
474    // - GaugeHistogram: create_gauge_histogram() - for gauge histograms
475    // - Histogram: ✅ IMPLEMENTED - create_histogram()
476    // - HistogramVec: ✅ IMPLEMENTED - create_histogramvec()
477    // - Info: create_info() - for info metrics with labels
478    // - IntCounter: ✅ IMPLEMENTED - create_intcounter()
479    // - IntCounterVec: ✅ IMPLEMENTED - create_intcountervec()
480    // - IntGauge: ✅ IMPLEMENTED - create_intgauge()
481    // - IntGaugeVec: ✅ IMPLEMENTED - create_intgaugevec()
482    // - Stateset: create_stateset() - for state-based metrics
483    // - Summary: create_summary() - for quantiles and sum/count metrics
484    // - SummaryVec: create_summary_vec() - for labeled summaries
485    // - Untyped: create_untyped() - for untyped metrics
486    //
487    // NOTE: The order of create_* methods below is mirrored in lib/bindings/python/rust/lib.rs::Metrics
488    // Keep them synchronized when adding new metric types
489
490    /// Create a Counter metric
491    pub fn create_counter(
492        &self,
493        name: &str,
494        description: &str,
495        labels: &[(&str, &str)],
496    ) -> anyhow::Result<prometheus::Counter> {
497        create_metric(&self.hierarchy, name, description, labels, None, None)
498    }
499
500    /// Create a CounterVec metric with label names (for dynamic labels)
501    pub fn create_countervec(
502        &self,
503        name: &str,
504        description: &str,
505        const_labels: &[&str],
506        const_label_values: &[(&str, &str)],
507    ) -> anyhow::Result<prometheus::CounterVec> {
508        create_metric(
509            &self.hierarchy,
510            name,
511            description,
512            const_label_values,
513            None,
514            Some(const_labels),
515        )
516    }
517
518    /// Create a Gauge metric
519    pub fn create_gauge(
520        &self,
521        name: &str,
522        description: &str,
523        labels: &[(&str, &str)],
524    ) -> anyhow::Result<prometheus::Gauge> {
525        create_metric(&self.hierarchy, name, description, labels, None, None)
526    }
527
528    /// Create a GaugeVec metric with label names (for dynamic labels)
529    pub fn create_gaugevec(
530        &self,
531        name: &str,
532        description: &str,
533        const_labels: &[&str],
534        const_label_values: &[(&str, &str)],
535    ) -> anyhow::Result<prometheus::GaugeVec> {
536        create_metric(
537            &self.hierarchy,
538            name,
539            description,
540            const_label_values,
541            None,
542            Some(const_labels),
543        )
544    }
545
546    /// Create a Histogram metric with custom buckets
547    pub fn create_histogram(
548        &self,
549        name: &str,
550        description: &str,
551        labels: &[(&str, &str)],
552        buckets: Option<Vec<f64>>,
553    ) -> anyhow::Result<prometheus::Histogram> {
554        create_metric(&self.hierarchy, name, description, labels, buckets, None)
555    }
556
557    /// Create a HistogramVec metric with custom buckets and label names.
558    pub fn create_histogramvec(
559        &self,
560        name: &str,
561        description: &str,
562        label_names: &[&str],
563        labels: &[(&str, &str)],
564        buckets: Option<Vec<f64>>,
565    ) -> anyhow::Result<prometheus::HistogramVec> {
566        create_metric(
567            &self.hierarchy,
568            name,
569            description,
570            labels,
571            buckets,
572            Some(label_names),
573        )
574    }
575
576    /// Create an IntCounter metric
577    pub fn create_intcounter(
578        &self,
579        name: &str,
580        description: &str,
581        labels: &[(&str, &str)],
582    ) -> anyhow::Result<prometheus::IntCounter> {
583        create_metric(&self.hierarchy, name, description, labels, None, None)
584    }
585
586    /// Create an IntCounterVec metric with label names (for dynamic labels)
587    pub fn create_intcountervec(
588        &self,
589        name: &str,
590        description: &str,
591        const_labels: &[&str],
592        const_label_values: &[(&str, &str)],
593    ) -> anyhow::Result<prometheus::IntCounterVec> {
594        create_metric(
595            &self.hierarchy,
596            name,
597            description,
598            const_label_values,
599            None,
600            Some(const_labels),
601        )
602    }
603
604    /// Create an IntGauge metric
605    pub fn create_intgauge(
606        &self,
607        name: &str,
608        description: &str,
609        labels: &[(&str, &str)],
610    ) -> anyhow::Result<prometheus::IntGauge> {
611        create_metric(&self.hierarchy, name, description, labels, None, None)
612    }
613
614    /// Create an IntGaugeVec metric with label names (for dynamic labels)
615    pub fn create_intgaugevec(
616        &self,
617        name: &str,
618        description: &str,
619        const_labels: &[&str],
620        const_label_values: &[(&str, &str)],
621    ) -> anyhow::Result<prometheus::IntGaugeVec> {
622        create_metric(
623            &self.hierarchy,
624            name,
625            description,
626            const_label_values,
627            None,
628            Some(const_labels),
629        )
630    }
631
632    /// Get metrics in Prometheus text format
633    pub fn prometheus_expfmt(&self) -> anyhow::Result<String> {
634        self.hierarchy
635            .get_metrics_registry()
636            .prometheus_expfmt_combined()
637    }
638}
639
640/// This trait should be implemented by all metric registries, including Prometheus, Envy, OpenTelemetry, and others.
641/// It offers a unified interface for creating and managing metrics, organizing sub-registries, and
642/// generating output in Prometheus text format.
643use crate::traits::DistributedRuntimeProvider;
644
645pub trait MetricsHierarchy: Send + Sync {
646    // ========================================================================
647    // Required methods - must be implemented by all types
648    // ========================================================================
649
650    /// Get the name of this hierarchy (without any hierarchy prefix)
651    fn basename(&self) -> String;
652
653    /// Get the parent hierarchies as actual objects (not strings)
654    /// Returns a vector of hierarchy references, ordered from root to immediate parent.
655    /// For example, an Endpoint would return [DRT, Namespace, Component].
656    fn parent_hierarchies(&self) -> Vec<&dyn MetricsHierarchy>;
657
658    /// Get a reference to this hierarchy's metrics registry
659    fn get_metrics_registry(&self) -> &MetricsRegistry;
660
661    // ========================================================================
662    // Provided methods - have default implementations
663    // ========================================================================
664
665    /// Get the connection ID (discovery instance ID) for this hierarchy level.
666    ///
667    /// Returns `Some(id)` when the hierarchy has access to the DistributedRuntime
668    /// (e.g. Namespace, Component, Endpoint). Used by `create_metric()` to auto-inject
669    /// the `worker_id` label. Returns `None` by default.
670    fn connection_id(&self) -> Option<u64> {
671        None
672    }
673
674    /// Access the metrics interface for this hierarchy
675    /// This is a provided method that works for any type implementing MetricsHierarchy
676    fn metrics(&self) -> Metrics<&Self>
677    where
678        Self: Sized,
679    {
680        Metrics::new(self)
681    }
682}
683
684// Blanket implementation for references to types that implement MetricsHierarchy
685impl<T: MetricsHierarchy + ?Sized> MetricsHierarchy for &T {
686    fn basename(&self) -> String {
687        (**self).basename()
688    }
689
690    fn parent_hierarchies(&self) -> Vec<&dyn MetricsHierarchy> {
691        (**self).parent_hierarchies()
692    }
693
694    fn get_metrics_registry(&self) -> &MetricsRegistry {
695        (**self).get_metrics_registry()
696    }
697
698    fn connection_id(&self) -> Option<u64> {
699        (**self).connection_id()
700    }
701}
702
703/// Type alias for runtime callback functions to reduce complexity
704///
705/// This type represents an Arc-wrapped callback function that can be:
706/// - Shared efficiently across multiple threads and contexts
707/// - Cloned without duplicating the underlying closure
708/// - Used in generic contexts requiring 'static lifetime
709///
710/// The Arc wrapper is included in the type to make sharing explicit.
711pub type PrometheusUpdateCallback = Arc<dyn Fn() -> anyhow::Result<()> + Send + Sync + 'static>;
712
713/// One collection: the merged families, plus the UNIT metadata that came with
714/// the typed ones.
715///
716/// Kept beside the families because `prometheus::proto::MetricFamily` has no
717/// unit field, and the compatibility spec requires the unit reach OTLP when the
718/// source declares one.
719pub struct CollectedFamilies {
720    pub families: Vec<prometheus::proto::MetricFamily>,
721    /// Family name -> Prometheus unit word, for families that declared one.
722    pub units: HashMap<String, String>,
723    /// Family name -> Prometheus type word. Carried because the proto model has
724    /// no Info variant: an Info family arrives here as a gauge, and the spec
725    /// converts it to a non-monotonic Sum rather than a Gauge.
726    pub prom_types: HashMap<String, String>,
727}
728
729/// Type alias for exposition text callback functions that return Prometheus text
730pub type PrometheusExpositionFormatCallback =
731    Arc<dyn Fn() -> anyhow::Result<String> + Send + Sync + 'static>;
732
733/// Returns already-built families, so engine metrics reach the structured
734/// path without being rendered to text and parsed back.
735pub type PrometheusTypedCallback = Arc<
736    dyn Fn() -> anyhow::Result<Vec<crate::metrics::prom_typed::BuiltFamily>>
737        + Send
738        + Sync
739        + 'static,
740>;
741
742/// Structure to hold Prometheus registries and associated callbacks for a given hierarchy.
743///
744/// All fields are Arc-wrapped, so cloning shares state. This ensures metrics registered
745/// on cloned instances (e.g., cloned Client/Endpoint) are visible to the original.
746#[derive(Clone)]
747pub struct MetricsRegistry {
748    /// The Prometheus registry for this hierarchy.
749    /// Arc-wrapped so clones share the same registry (metrics registered on clones are visible everywhere).
750    pub prometheus_registry: Arc<std::sync::RwLock<prometheus::Registry>>,
751
752    /// Child registries included when emitting combined `/metrics` output.
753    ///
754    /// Why this exists:
755    /// - Previously, `create_metric()` registered every collector into *all* parent registries
756    ///   (Endpoint → Component → Namespace → DRT) so scraping the root registry included everything.
757    /// - That fan-out caused Prometheus collisions when different endpoints tried to register the
758    ///   same metric name with different const-labels (descriptor mismatch).
759    ///
760    /// We now register metrics only into the local hierarchy registry to avoid collisions.
761    /// `child_registries` rebuilds “what to scrape” as a tree of registries so `/metrics` can:
762    /// - traverse registries recursively,
763    /// - merge metric families into one exposition payload,
764    /// - warn/drop exact duplicate series, while allowing same metric name with different labels.
765    child_registries: Arc<std::sync::RwLock<Vec<MetricsRegistry>>>,
766
767    /// Update callbacks invoked before metrics are scraped.
768    /// Wrapped in Arc to preserve callbacks across clones (prevents callback loss when MetricsRegistry is cloned).
769    pub prometheus_update_callbacks: Arc<std::sync::RwLock<Vec<PrometheusUpdateCallback>>>,
770
771    /// Callbacks returning engine exposition text; these feed `/metrics` only.
772    #[allow(clippy::type_complexity)]
773    pub prometheus_expfmt_callbacks:
774        Arc<std::sync::RwLock<Vec<PrometheusExpositionFormatCallback>>>,
775
776    /// Callbacks returning engine metric families; these feed OTLP only.
777    /// Wrapped in Arc to preserve callbacks across clones (e.g. vLLM callbacks
778    /// registered at Endpoint remain reachable at DRT).
779    pub prometheus_typed_callbacks: Arc<std::sync::RwLock<Vec<PrometheusTypedCallback>>>,
780}
781
782impl std::fmt::Debug for MetricsRegistry {
783    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
784        f.debug_struct("MetricsRegistry")
785            .field("prometheus_registry", &"<RwLock<Registry>>")
786            .field(
787                "prometheus_update_callbacks",
788                &format!(
789                    "<RwLock<Vec<Callback>>> with {} callbacks",
790                    self.prometheus_update_callbacks.read().unwrap().len()
791                ),
792            )
793            .field(
794                "prometheus_expfmt_callbacks",
795                &format!(
796                    "<RwLock<Vec<Callback>>> with {} callbacks",
797                    self.prometheus_expfmt_callbacks.read().unwrap().len()
798                ),
799            )
800            .field(
801                "prometheus_typed_callbacks",
802                &format!(
803                    "<RwLock<Vec<Callback>>> with {} callbacks",
804                    self.prometheus_typed_callbacks.read().unwrap().len()
805                ),
806            )
807            .finish()
808    }
809}
810
811impl MetricsRegistry {
812    /// Create a new metrics registry with an empty Prometheus registry and callback lists
813    pub fn new() -> Self {
814        Self {
815            prometheus_registry: Arc::new(std::sync::RwLock::new(prometheus::Registry::new())),
816            child_registries: Arc::new(std::sync::RwLock::new(Vec::new())),
817            prometheus_update_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())),
818            prometheus_expfmt_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())),
819            prometheus_typed_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())),
820        }
821    }
822
823    /// Add a child registry to be included in combined /metrics output.
824    ///
825    /// Dedup is by underlying Prometheus registry pointer, so repeated registration via clones is safe.
826    pub fn add_child_registry(&self, child: &MetricsRegistry) {
827        let child_ptr = Arc::as_ptr(&child.prometheus_registry);
828        let mut guard = self.child_registries.write().unwrap();
829        if guard
830            .iter()
831            .any(|r| Arc::as_ptr(&r.prometheus_registry) == child_ptr)
832        {
833            return;
834        }
835        guard.push(child.clone());
836    }
837
838    fn registries_for_combined_scrape(&self) -> Vec<MetricsRegistry> {
839        // Traverse child registries recursively so `prometheus_expfmt()` on any hierarchy
840        // (DRT/namespace/component/endpoint) includes metrics from its descendants.
841        //
842        // Dedup by underlying Prometheus registry pointer so multiple paths (e.g. also registering
843        // directly on the root) won't duplicate output.
844        fn visit(
845            registry: &MetricsRegistry,
846            out: &mut Vec<MetricsRegistry>,
847            seen: &mut HashSet<*const std::sync::RwLock<prometheus::Registry>>,
848        ) {
849            let ptr = Arc::as_ptr(&registry.prometheus_registry);
850            if !seen.insert(ptr) {
851                return;
852            }
853
854            out.push(registry.clone());
855
856            let children: Vec<MetricsRegistry> = registry
857                .child_registries
858                .read()
859                .unwrap()
860                .iter()
861                .cloned()
862                .collect();
863            for child in children {
864                visit(&child, out, seen);
865            }
866        }
867
868        let mut out = Vec::new();
869        let mut seen: HashSet<*const std::sync::RwLock<prometheus::Registry>> = HashSet::new();
870        visit(self, &mut out, &mut seen);
871        out
872    }
873
874    /// Combine metrics across this registry and all registered children into one Prometheus exposition output.
875    ///
876    /// Engine metrics are appended as the text the engine produced. This is the
877    /// scrape path and is deliberately independent of the typed path that feeds
878    /// OTLP: `/metrics` output stays byte-for-byte what the engine emitted.
879    ///
880    /// - Families are merged by name; HELP and TYPE must match.
881    /// - Multiple series for the same name are allowed if labels differ.
882    /// - Exact duplicate series (same name + identical label pairs) are warned and dropped.
883    pub fn prometheus_expfmt_combined(&self) -> anyhow::Result<String> {
884        let registries = self.registries_for_combined_scrape();
885        run_update_callbacks(&registries);
886
887        let merged = FamilyMerger::from_gathered(&registries)?.into_sorted();
888
889        let encoder = prometheus::TextEncoder::new();
890        let mut buffer = Vec::new();
891        encoder.encode(&merged, &mut buffer)?;
892        let mut result = String::from_utf8(buffer)?;
893
894        // Append expfmt callbacks deterministically in registry order.
895        let mut expfmt = String::new();
896        for registry in &registries {
897            let text = registry.execute_expfmt_callbacks();
898            if !text.is_empty() {
899                if !expfmt.is_empty() && !expfmt.ends_with('\n') {
900                    expfmt.push('\n');
901                }
902                expfmt.push_str(&text);
903            }
904        }
905
906        if !expfmt.is_empty() {
907            if !result.ends_with('\n') {
908                result.push('\n');
909            }
910            result.push_str(&expfmt);
911        }
912
913        Ok(result)
914    }
915
916    /// Every metric family: Dynamo's own from `gather()`, plus engine families
917    /// delivered typed. This is the OTLP export path.
918    ///
919    /// Engine metrics cross the binding as a structure rather than as
920    /// exposition text, so their name, type and help arrive authoritative
921    /// instead of being re-derived by a parser.
922    ///
923    /// Each call re-executes the typed callbacks, which cross into Python and
924    /// take the GIL, so callers should collect on a deliberate cadence.
925    pub fn metric_families_combined(&self) -> anyhow::Result<CollectedFamilies> {
926        let registries = self.registries_for_combined_scrape();
927        run_update_callbacks(&registries);
928
929        let mut merger = FamilyMerger::from_gathered(&registries)?;
930
931        // The unit travels beside the family, keyed by name: the proto model
932        // has no unit field, so it would otherwise be dropped here.
933        let mut units: HashMap<String, String> = HashMap::new();
934        let mut prom_types: HashMap<String, String> = HashMap::new();
935        for registry in &registries {
936            for built in registry.execute_typed_callbacks() {
937                let name = built.family.name().to_string();
938                // Publish the side metadata only once the family is accepted.
939                // The maps are keyed by name, so metadata from a family the
940                // merger rejects would otherwise be applied to the family that
941                // did survive under that name -- a rejected `info` could make a
942                // surviving gauge encode as a non-monotonic Sum.
943                if let Err(error) = merger.add_family(built.family) {
944                    tracing::warn!(metric_name = %name, %error, "skipping typed metric family");
945                    continue;
946                }
947                // First writer wins, matching how the merger itself resolves a
948                // conflict: the first family fixes help and type, and later
949                // ones must agree. A later family can be accepted and still
950                // have every sample dropped as a duplicate series, so letting
951                // it overwrite the unit would label the surviving data with a
952                // unit from a source that contributed none of it.
953                if !built.unit.is_empty() {
954                    units.entry(name.clone()).or_insert(built.unit);
955                }
956                prom_types.entry(name).or_insert(built.prom_type);
957            }
958        }
959
960        Ok(CollectedFamilies {
961            families: merger.into_sorted(),
962            units,
963            prom_types,
964        })
965    }
966
967    /// Add a callback function that receives a reference to any MetricsHierarchy
968    pub fn add_update_callback(&self, callback: PrometheusUpdateCallback) {
969        self.prometheus_update_callbacks
970            .write()
971            .unwrap()
972            .push(callback);
973    }
974
975    /// Add an exposition text callback that returns Prometheus text.
976    ///
977    /// This feeds `/metrics` only. Engines that also want to be exported over
978    /// OTLP must register a typed callback via [`Self::add_typed_callback`].
979    pub fn add_expfmt_callback(&self, callback: PrometheusExpositionFormatCallback) {
980        self.prometheus_expfmt_callbacks
981            .write()
982            .unwrap()
983            .push(callback);
984    }
985
986    /// Execute all exposition text callbacks and return their concatenated text
987    pub fn execute_expfmt_callbacks(&self) -> String {
988        // Snapshot under the lock, then invoke without it held; see
989        // `execute_typed_callbacks` for why holding it across the calls
990        // deadlocks against registration from Python.
991        let callbacks: Vec<PrometheusExpositionFormatCallback> = self
992            .prometheus_expfmt_callbacks
993            .read()
994            .unwrap()
995            .iter()
996            .cloned()
997            .collect();
998        let mut result = String::new();
999        for callback in callbacks.iter() {
1000            match callback() {
1001                Ok(text) => {
1002                    if !text.is_empty() {
1003                        if !result.is_empty() && !result.ends_with('\n') {
1004                            result.push('\n');
1005                        }
1006                        result.push_str(&text);
1007                    }
1008                }
1009                Err(e) => {
1010                    tracing::error!("Error executing exposition text callback: {e}");
1011                }
1012            }
1013        }
1014        result
1015    }
1016
1017    /// Register a callback that returns typed families directly.
1018    ///
1019    /// This is how engine metrics reach OTLP.
1020    pub fn add_typed_callback(&self, callback: PrometheusTypedCallback) {
1021        self.prometheus_typed_callbacks
1022            .write()
1023            .unwrap()
1024            .push(callback);
1025    }
1026
1027    /// Collect from every typed callback, logging and skipping failures so one
1028    /// broken engine cannot empty the whole collection.
1029    pub fn execute_typed_callbacks(&self) -> Vec<crate::metrics::prom_typed::BuiltFamily> {
1030        // Snapshot under the lock, then invoke without it held. The callbacks
1031        // take the Python GIL, and registration arrives from Python holding the
1032        // GIL and blocking on `write()` -- holding the read lock across the
1033        // calls would deadlock those two against each other.
1034        let callbacks: Vec<PrometheusTypedCallback> = self
1035            .prometheus_typed_callbacks
1036            .read()
1037            .unwrap()
1038            .iter()
1039            .cloned()
1040            .collect();
1041
1042        callbacks
1043            .iter()
1044            .filter_map(|callback| match callback() {
1045                Ok(families) => Some(families),
1046                Err(error) => {
1047                    tracing::warn!(%error, "typed metrics callback failed");
1048                    None
1049                }
1050            })
1051            .flatten()
1052            .collect()
1053    }
1054
1055    /// Execute all update callbacks and return their results
1056    pub fn execute_update_callbacks(&self) -> Vec<anyhow::Result<()>> {
1057        // Snapshot before invoking, for the same reason as the typed and
1058        // exposition callbacks: a callback may take the Python GIL or register
1059        // another callback, and holding the read lock across the call deadlocks
1060        // against a registration that holds the GIL and wants the write lock.
1061        let callbacks: Vec<PrometheusUpdateCallback> = self
1062            .prometheus_update_callbacks
1063            .read()
1064            .unwrap()
1065            .iter()
1066            .cloned()
1067            .collect();
1068
1069        callbacks.iter().map(|callback| callback()).collect()
1070    }
1071
1072    /// Add a Prometheus metric collector to this registry
1073    pub fn add_metric(
1074        &self,
1075        collector: Box<dyn prometheus::core::Collector>,
1076    ) -> anyhow::Result<()> {
1077        self.prometheus_registry
1078            .write()
1079            .unwrap()
1080            .register(collector)
1081            .map_err(|e| anyhow::anyhow!("Failed to register metric: {}", e))
1082    }
1083
1084    /// Add a Prometheus metric collector, logging a warning on failure instead of returning an error.
1085    pub fn add_metric_or_warn(&self, collector: Box<dyn prometheus::core::Collector>, name: &str) {
1086        if let Err(e) = self.add_metric(collector) {
1087            tracing::warn!(error = %e, metric = name, "Failed to register metric");
1088        }
1089    }
1090
1091    /// Get a read guard to the Prometheus registry for scraping
1092    pub fn get_prometheus_registry(&self) -> std::sync::RwLockReadGuard<'_, prometheus::Registry> {
1093        self.prometheus_registry.read().unwrap()
1094    }
1095
1096    /// Returns true if a metric with the given name already exists in the Prometheus registry
1097    pub fn has_metric_named(&self, metric_name: &str) -> bool {
1098        self.prometheus_registry
1099            .read()
1100            .unwrap()
1101            .gather()
1102            .iter()
1103            .any(|mf| mf.name() == metric_name)
1104    }
1105}
1106
1107/// Run every registry's update callbacks, logging rather than propagating
1108/// failures so one broken callback cannot poison the whole collection.
1109fn run_update_callbacks(registries: &[MetricsRegistry]) {
1110    for registry in registries {
1111        for result in registry.execute_update_callbacks() {
1112            if let Err(error) = result {
1113                tracing::error!(%error, "metrics update callback failed");
1114            }
1115        }
1116    }
1117}
1118
1119/// Merges metric families from several sources under one set of rules.
1120///
1121/// Extracted so the text path (`prometheus_expfmt_combined`) and the structured
1122/// path (`metric_families_combined`) cannot drift: a series accepted by one must
1123/// be accepted by the other, or `/metrics` and OTLP would disagree.
1124struct FamilyMerger {
1125    by_name: HashMap<String, prometheus::proto::MetricFamily>,
1126    seen_series: HashSet<String>,
1127}
1128
1129impl FamilyMerger {
1130    fn from_gathered(registries: &[MetricsRegistry]) -> anyhow::Result<Self> {
1131        let mut merger = Self {
1132            by_name: HashMap::new(),
1133            seen_series: HashSet::new(),
1134        };
1135        for (idx, registry) in registries.iter().enumerate() {
1136            for family in registry.get_prometheus_registry().gather() {
1137                merger
1138                    .add_family(family)
1139                    .with_context(|| format!("gathering from registry {idx}"))?;
1140            }
1141        }
1142        Ok(merger)
1143    }
1144
1145    fn add_family(&mut self, mut family: prometheus::proto::MetricFamily) -> anyhow::Result<()> {
1146        let name = family.name().to_string();
1147
1148        let entry = self.by_name.entry(name.clone()).or_insert_with(|| {
1149            let mut out = prometheus::proto::MetricFamily::new();
1150            out.set_name(name.clone());
1151            out.set_help(family.help().to_string());
1152            out.set_field_type(family.get_field_type());
1153            out
1154        });
1155
1156        if entry.help() != family.help() || entry.get_field_type() != family.get_field_type() {
1157            return Err(anyhow::anyhow!(
1158                "Metric family '{}' has inconsistent help/type across sources",
1159                name
1160            ));
1161        }
1162
1163        for metric in family.take_metric() {
1164            let mut labels: Vec<(String, String)> = metric
1165                .get_label()
1166                .iter()
1167                .map(|lp| (lp.name().to_string(), lp.value().to_string()))
1168                .collect();
1169            labels.sort_by(|(ka, va), (kb, vb)| (ka, va).cmp(&(kb, vb)));
1170
1171            let key = format!(
1172                "{}|{}",
1173                name,
1174                labels
1175                    .iter()
1176                    .map(|(k, v)| format!("{}={}", k, v))
1177                    .collect::<Vec<_>>()
1178                    .join(",")
1179            );
1180
1181            if !self.seen_series.insert(key) {
1182                tracing::warn!(
1183                    metric_name = %name,
1184                    labels = ?labels,
1185                    "Duplicate Prometheus series while merging; dropping later sample"
1186                );
1187                continue;
1188            }
1189
1190            entry.mut_metric().push(metric);
1191        }
1192
1193        Ok(())
1194    }
1195
1196    fn into_sorted(self) -> Vec<prometheus::proto::MetricFamily> {
1197        let mut merged: Vec<prometheus::proto::MetricFamily> = self.by_name.into_values().collect();
1198        merged.sort_by(|a, b| a.name().cmp(b.name()));
1199        merged
1200    }
1201}
1202
1203impl Default for MetricsRegistry {
1204    fn default() -> Self {
1205        Self::new()
1206    }
1207}
1208
1209#[cfg(test)]
1210mod test_helpers {
1211    use super::prometheus_names::name_prefix;
1212    use super::*;
1213
1214    /// Base function to filter Prometheus output lines based on a predicate.
1215    /// Returns lines that match the predicate, converted to String.
1216    fn filter_prometheus_lines<F>(input: &str, mut predicate: F) -> Vec<String>
1217    where
1218        F: FnMut(&str) -> bool,
1219    {
1220        input
1221            .lines()
1222            .filter(|line| predicate(line))
1223            .map(|line| line.to_string())
1224            .collect::<Vec<_>>()
1225    }
1226
1227    /// Extracts all component metrics (excluding help text and type definitions).
1228    /// Returns only the actual metric lines with values.
1229    pub fn extract_metrics(input: &str) -> Vec<String> {
1230        filter_prometheus_lines(input, |line| {
1231            line.starts_with(&format!("{}_", name_prefix::COMPONENT))
1232                && !line.starts_with("#")
1233                && !line.trim().is_empty()
1234        })
1235    }
1236
1237    /// Parses a Prometheus metric line and extracts the name, labels, and value.
1238    /// Used instead of fetching metrics directly to test end-to-end results, not intermediate state.
1239    ///
1240    /// # Example
1241    /// ```
1242    /// let line = "http_requests_total{method=\"GET\"} 1234";
1243    /// let (name, labels, value) = parse_prometheus_metric(line).unwrap();
1244    /// assert_eq!(name, "http_requests_total");
1245    /// assert_eq!(labels.get("method"), Some(&"GET".to_string()));
1246    /// assert_eq!(value, 1234.0);
1247    /// ```
1248    pub fn parse_prometheus_metric(
1249        line: &str,
1250    ) -> Option<(String, std::collections::HashMap<String, String>, f64)> {
1251        if line.trim().is_empty() || line.starts_with('#') {
1252            return None;
1253        }
1254
1255        let parts: Vec<&str> = line.split_whitespace().collect();
1256        if parts.len() < 2 {
1257            return None;
1258        }
1259
1260        let metric_part = parts[0];
1261        let value: f64 = parts[1].parse().ok()?;
1262
1263        let (name, labels) = if metric_part.contains('{') {
1264            let brace_start = metric_part.find('{').unwrap();
1265            let brace_end = metric_part.rfind('}').unwrap_or(metric_part.len());
1266            let name = &metric_part[..brace_start];
1267            let labels_str = &metric_part[brace_start + 1..brace_end];
1268
1269            let mut labels = std::collections::HashMap::new();
1270            for pair in labels_str.split(',') {
1271                if let Some((k, v)) = pair.split_once('=') {
1272                    let v = v.trim_matches('"');
1273                    labels.insert(k.trim().to_string(), v.to_string());
1274                }
1275            }
1276            (name.to_string(), labels)
1277        } else {
1278            (metric_part.to_string(), std::collections::HashMap::new())
1279        };
1280
1281        Some((name, labels, value))
1282    }
1283
1284    /// Injects a `worker_id` label into Prometheus metric data lines.
1285    /// Prometheus places const labels (like worker_id) before special labels
1286    /// (like histogram `le`), so for histogram bucket lines we insert before
1287    /// `,le=`. For all other metric lines, we insert before the closing `}`.
1288    /// Comment lines and lines without labels are left unchanged.
1289    pub fn inject_worker_id(expected: &str, wid: &str) -> String {
1290        let wid_label = format!(",worker_id=\"{}\"", wid);
1291        expected
1292            .lines()
1293            .map(|line| {
1294                if line.starts_with('#') || line.trim().is_empty() || !line.contains('{') {
1295                    line.to_string()
1296                } else if let Some(le_pos) = line.find(",le=") {
1297                    // Histogram bucket lines: worker_id is a const label, `le` is special,
1298                    // so worker_id sorts before `le` in Prometheus output.
1299                    let mut s = line.to_string();
1300                    s.insert_str(le_pos, &wid_label);
1301                    s
1302                } else {
1303                    line.replacen("}", &format!("{}}}", wid_label), 1)
1304                }
1305            })
1306            .collect::<Vec<_>>()
1307            .join("\n")
1308    }
1309}
1310
1311#[cfg(test)]
1312mod test_metricsregistry_units {
1313    use super::*;
1314
1315    #[test]
1316    fn test_build_component_metric_name_with_prefix() {
1317        // Test that build_component_metric_name correctly prepends the dynamo_component prefix
1318        let result = build_component_metric_name("requests");
1319        assert_eq!(result, "dynamo_component_requests");
1320
1321        let result = build_component_metric_name("counter");
1322        assert_eq!(result, "dynamo_component_counter");
1323    }
1324
1325    #[test]
1326    fn test_parse_prometheus_metric() {
1327        use super::test_helpers::parse_prometheus_metric;
1328        use std::collections::HashMap;
1329
1330        // Test parsing a metric with labels
1331        let line = "http_requests_total{method=\"GET\",status=\"200\"} 1234";
1332        let parsed = parse_prometheus_metric(line);
1333        assert!(parsed.is_some());
1334
1335        let (name, labels, value) = parsed.unwrap();
1336        assert_eq!(name, "http_requests_total");
1337
1338        let mut expected_labels = HashMap::new();
1339        expected_labels.insert("method".to_string(), "GET".to_string());
1340        expected_labels.insert("status".to_string(), "200".to_string());
1341        assert_eq!(labels, expected_labels);
1342
1343        assert_eq!(value, 1234.0);
1344
1345        // Test parsing a metric without labels
1346        let line = "cpu_usage 98.5";
1347        let parsed = parse_prometheus_metric(line);
1348        assert!(parsed.is_some());
1349
1350        let (name, labels, value) = parsed.unwrap();
1351        assert_eq!(name, "cpu_usage");
1352        assert!(labels.is_empty());
1353        assert_eq!(value, 98.5);
1354
1355        // Test parsing a metric with float value
1356        let line = "response_time{service=\"api\"} 0.123";
1357        let parsed = parse_prometheus_metric(line);
1358        assert!(parsed.is_some());
1359
1360        let (name, labels, value) = parsed.unwrap();
1361        assert_eq!(name, "response_time");
1362
1363        let mut expected_labels = HashMap::new();
1364        expected_labels.insert("service".to_string(), "api".to_string());
1365        assert_eq!(labels, expected_labels);
1366
1367        assert_eq!(value, 0.123);
1368
1369        // Test parsing invalid lines
1370        assert!(parse_prometheus_metric("").is_none()); // Empty line
1371        assert!(parse_prometheus_metric("# HELP metric description").is_none()); // Help text
1372        assert!(parse_prometheus_metric("# TYPE metric counter").is_none()); // Type definition
1373        assert!(parse_prometheus_metric("metric_name").is_none()); // No value
1374
1375        println!("✓ Prometheus metric parsing works correctly!");
1376    }
1377
1378    #[test]
1379    fn test_metrics_registry_entry_callbacks() {
1380        use crate::MetricsRegistry;
1381        use std::sync::atomic::{AtomicUsize, Ordering};
1382
1383        // Test 1: Basic callback execution with counter increments
1384        {
1385            let registry = MetricsRegistry::new();
1386            let counter = Arc::new(AtomicUsize::new(0));
1387
1388            // Add callbacks with different increment values
1389            for increment in [1, 10, 100] {
1390                let counter_clone = counter.clone();
1391                registry.add_update_callback(Arc::new(move || {
1392                    counter_clone.fetch_add(increment, Ordering::SeqCst);
1393                    Ok(())
1394                }));
1395            }
1396
1397            // Verify counter starts at 0
1398            assert_eq!(counter.load(Ordering::SeqCst), 0);
1399
1400            // First execution
1401            let results = registry.execute_update_callbacks();
1402            assert_eq!(results.len(), 3);
1403            assert!(results.iter().all(|r| r.is_ok()));
1404            assert_eq!(counter.load(Ordering::SeqCst), 111); // 1 + 10 + 100
1405
1406            // Second execution - callbacks should be reusable
1407            let results = registry.execute_update_callbacks();
1408            assert_eq!(results.len(), 3);
1409            assert_eq!(counter.load(Ordering::SeqCst), 222); // 111 + 111
1410
1411            // Test cloning - cloned entry shares callbacks (callbacks are Arc-wrapped)
1412            let cloned = registry.clone();
1413            assert_eq!(cloned.execute_update_callbacks().len(), 3);
1414            assert_eq!(counter.load(Ordering::SeqCst), 333); // 222 + 111
1415
1416            // Original still has callbacks and shares the same Arc
1417            registry.execute_update_callbacks();
1418            assert_eq!(counter.load(Ordering::SeqCst), 444); // 333 + 111
1419        }
1420
1421        // Test 2: Mixed success and error callbacks
1422        {
1423            let registry = MetricsRegistry::new();
1424            let counter = Arc::new(AtomicUsize::new(0));
1425
1426            // Successful callback
1427            let counter_clone = counter.clone();
1428            registry.add_update_callback(Arc::new(move || {
1429                counter_clone.fetch_add(1, Ordering::SeqCst);
1430                Ok(())
1431            }));
1432
1433            // Error callback
1434            registry.add_update_callback(Arc::new(|| Err(anyhow::anyhow!("Simulated error"))));
1435
1436            // Another successful callback
1437            let counter_clone = counter.clone();
1438            registry.add_update_callback(Arc::new(move || {
1439                counter_clone.fetch_add(10, Ordering::SeqCst);
1440                Ok(())
1441            }));
1442
1443            // Execute and verify mixed results
1444            let results = registry.execute_update_callbacks();
1445            assert_eq!(results.len(), 3);
1446            assert!(results[0].is_ok());
1447            assert!(results[1].is_err());
1448            assert!(results[2].is_ok());
1449
1450            // Verify error message
1451            assert_eq!(
1452                results[1].as_ref().unwrap_err().to_string(),
1453                "Simulated error"
1454            );
1455
1456            // Verify successful callbacks still executed
1457            assert_eq!(counter.load(Ordering::SeqCst), 11); // 1 + 10
1458
1459            // Execute again - errors should be consistent
1460            let results = registry.execute_update_callbacks();
1461            assert!(results[1].is_err());
1462            assert_eq!(counter.load(Ordering::SeqCst), 22); // 11 + 11
1463        }
1464
1465        // Test 3: Empty registry
1466        {
1467            let registry = MetricsRegistry::new();
1468            let results = registry.execute_update_callbacks();
1469            assert_eq!(results.len(), 0);
1470        }
1471    }
1472}
1473
1474#[cfg(feature = "integration")]
1475#[cfg(test)]
1476mod test_metricsregistry_prefixes {
1477    use super::*;
1478    use crate::distributed::distributed_test_utils::create_test_drt_async;
1479    use prometheus::core::Collector;
1480
1481    #[tokio::test]
1482    async fn test_hierarchical_prefixes_and_parent_hierarchies() {
1483        let drt = create_test_drt_async().await;
1484
1485        const DRT_NAME: &str = "";
1486        const NAMESPACE_NAME: &str = "ns901";
1487        const COMPONENT_NAME: &str = "comp901";
1488        const ENDPOINT_NAME: &str = "ep901";
1489        let namespace = drt.namespace(NAMESPACE_NAME).unwrap();
1490        let component = namespace.component(COMPONENT_NAME).unwrap();
1491        let endpoint = component.endpoint(ENDPOINT_NAME);
1492
1493        // DRT
1494        assert_eq!(drt.basename(), DRT_NAME);
1495        assert_eq!(drt.parent_hierarchies().len(), 0);
1496        // DRT hierarchy is just its basename (empty string)
1497
1498        // Namespace
1499        assert_eq!(namespace.basename(), NAMESPACE_NAME);
1500        assert_eq!(namespace.parent_hierarchies().len(), 1);
1501        assert_eq!(namespace.parent_hierarchies()[0].basename(), DRT_NAME);
1502        // Namespace hierarchy is just its basename since parent is empty
1503
1504        // Component
1505        assert_eq!(component.basename(), COMPONENT_NAME);
1506        assert_eq!(component.parent_hierarchies().len(), 2);
1507        assert_eq!(component.parent_hierarchies()[0].basename(), DRT_NAME);
1508        assert_eq!(component.parent_hierarchies()[1].basename(), NAMESPACE_NAME);
1509        // Component hierarchy structure is validated by the individual assertions above
1510
1511        // Endpoint
1512        assert_eq!(endpoint.basename(), ENDPOINT_NAME);
1513        assert_eq!(endpoint.parent_hierarchies().len(), 3);
1514        assert_eq!(endpoint.parent_hierarchies()[0].basename(), DRT_NAME);
1515        assert_eq!(endpoint.parent_hierarchies()[1].basename(), NAMESPACE_NAME);
1516        assert_eq!(endpoint.parent_hierarchies()[2].basename(), COMPONENT_NAME);
1517        // Endpoint hierarchy structure is validated by the individual assertions above
1518
1519        // Relationships
1520        assert!(
1521            namespace
1522                .parent_hierarchies()
1523                .iter()
1524                .any(|h| h.basename() == drt.basename())
1525        );
1526        assert!(
1527            component
1528                .parent_hierarchies()
1529                .iter()
1530                .any(|h| h.basename() == namespace.basename())
1531        );
1532        assert!(
1533            endpoint
1534                .parent_hierarchies()
1535                .iter()
1536                .any(|h| h.basename() == component.basename())
1537        );
1538
1539        // Depth
1540        assert_eq!(drt.parent_hierarchies().len(), 0);
1541        assert_eq!(namespace.parent_hierarchies().len(), 1);
1542        assert_eq!(component.parent_hierarchies().len(), 2);
1543        assert_eq!(endpoint.parent_hierarchies().len(), 3);
1544
1545        // Invalid namespace behavior - sanitizes to "_123" and succeeds
1546        // @ryanolson intended to enable validation (see TODO comment in component.rs) but didn't turn it on,
1547        // so invalid characters are sanitized in MetricsRegistry rather than rejected.
1548        let invalid_namespace = drt.namespace("@@123").unwrap();
1549        let result =
1550            invalid_namespace
1551                .metrics()
1552                .create_counter("test_counter", "A test counter", &[]);
1553        assert!(result.is_ok());
1554        if let Ok(counter) = &result {
1555            // Verify the namespace was sanitized to "_123" in the label
1556            let desc = counter.desc();
1557            let namespace_label = desc[0]
1558                .const_label_pairs
1559                .iter()
1560                .find(|l| l.name() == "dynamo_namespace")
1561                .expect("Should have dynamo_namespace label");
1562            assert_eq!(namespace_label.value(), "_123");
1563        }
1564
1565        // Valid namespace works
1566        let valid_namespace = drt.namespace("ns567").unwrap();
1567        assert!(
1568            valid_namespace
1569                .metrics()
1570                .create_counter("test_counter", "A test counter", &[])
1571                .is_ok()
1572        );
1573    }
1574
1575    #[tokio::test]
1576    async fn test_typed_callback_only_registered_on_endpoint_is_collected_once() {
1577        // The registry tree is traversed once, so a callback registered on the
1578        // endpoint must not be collected again via its parents. Count
1579        // invocations rather than emitted series: the merger drops duplicate
1580        // series, so a double traversal would be invisible in the output.
1581        let drt = create_test_drt_async().await;
1582        let namespace = drt.namespace("ns_typed_ep_only").unwrap();
1583        let component = namespace.component("comp_typed_ep_only").unwrap();
1584        let endpoint = component.endpoint("ep_typed_ep_only");
1585
1586        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1587        let counter = calls.clone();
1588        let callback: PrometheusTypedCallback = Arc::new(move || {
1589            counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1590            let mut family = prometheus::proto::MetricFamily::new();
1591            family.set_name("dynamo_component_active_decode_blocks".to_string());
1592            family.set_field_type(prometheus::proto::MetricType::GAUGE);
1593            let mut metric = prometheus::proto::Metric::new();
1594            let mut label = prometheus::proto::LabelPair::new();
1595            label.set_name("dp_rank".to_string());
1596            label.set_value("0".to_string());
1597            metric.set_label(vec![label]);
1598            metric.set_gauge(prometheus::proto::Gauge::new());
1599            family.mut_metric().push(metric);
1600            Ok(vec![crate::metrics::prom_typed::BuiltFamily {
1601                family,
1602                unit: String::new(),
1603                prom_type: "gauge".to_string(),
1604            }])
1605        });
1606
1607        endpoint.get_metrics_registry().add_typed_callback(callback);
1608
1609        // Typed callbacks feed the OTLP collection, not the exposition path.
1610        let families = drt
1611            .get_metrics_registry()
1612            .metric_families_combined()
1613            .unwrap();
1614
1615        assert_eq!(
1616            calls.load(std::sync::atomic::Ordering::SeqCst),
1617            1,
1618            "endpoint-registered typed callback should be collected once"
1619        );
1620        assert!(
1621            families
1622                .families
1623                .iter()
1624                .any(|f| f.name() == "dynamo_component_active_decode_blocks"),
1625            "typed family missing from the combined collection"
1626        );
1627    }
1628
1629    #[tokio::test]
1630    async fn test_recursive_namespace() {
1631        // Create a distributed runtime for testing
1632        let drt = create_test_drt_async().await;
1633
1634        // Create a deeply chained namespace: ns1.ns2.ns3
1635        let ns1 = drt.namespace("ns1").unwrap();
1636        let ns2 = ns1.namespace("ns2").unwrap();
1637        let ns3 = ns2.namespace("ns3").unwrap();
1638
1639        // Create a component in the deepest namespace
1640        let component = ns3.component("test-component").unwrap();
1641
1642        // Verify the hierarchy structure
1643        assert_eq!(ns1.basename(), "ns1");
1644        assert_eq!(ns1.parent_hierarchies().len(), 1);
1645        assert_eq!(ns1.parent_hierarchies()[0].basename(), "");
1646        // ns1 hierarchy is just its basename since parent is empty
1647
1648        assert_eq!(ns2.basename(), "ns2");
1649        assert_eq!(ns2.parent_hierarchies().len(), 2);
1650        assert_eq!(ns2.parent_hierarchies()[0].basename(), "");
1651        assert_eq!(ns2.parent_hierarchies()[1].basename(), "ns1");
1652        // ns2 hierarchy structure validated by parent assertions above
1653
1654        assert_eq!(ns3.basename(), "ns3");
1655        assert_eq!(ns3.parent_hierarchies().len(), 3);
1656        assert_eq!(ns3.parent_hierarchies()[0].basename(), "");
1657        assert_eq!(ns3.parent_hierarchies()[1].basename(), "ns1");
1658        assert_eq!(ns3.parent_hierarchies()[2].basename(), "ns2");
1659        // ns3 hierarchy structure validated by parent assertions above
1660
1661        assert_eq!(component.basename(), "test-component");
1662        assert_eq!(component.parent_hierarchies().len(), 4);
1663        assert_eq!(component.parent_hierarchies()[0].basename(), "");
1664        assert_eq!(component.parent_hierarchies()[1].basename(), "ns1");
1665        assert_eq!(component.parent_hierarchies()[2].basename(), "ns2");
1666        assert_eq!(component.parent_hierarchies()[3].basename(), "ns3");
1667        // component hierarchy structure validated by parent assertions above
1668
1669        println!("✓ Chained namespace test passed - all prefixes correct");
1670    }
1671}
1672
1673#[cfg(feature = "integration")]
1674#[cfg(test)]
1675mod test_metricsregistry_prometheus_fmt_outputs {
1676    use super::prometheus_names::name_prefix;
1677    use super::*;
1678    use crate::distributed::distributed_test_utils::create_test_drt_async;
1679    use prometheus::Counter;
1680    use std::sync::Arc;
1681
1682    #[tokio::test]
1683    async fn test_prometheusfactory_using_metrics_registry_trait() {
1684        // Setup real DRT and registry using the test-friendly constructor
1685        let drt = create_test_drt_async().await;
1686
1687        // Use a simple constant namespace name
1688        let namespace_name = "ns345";
1689
1690        let namespace = drt.namespace(namespace_name).unwrap();
1691        let component = namespace.component("comp345").unwrap();
1692        let endpoint = component.endpoint("ep345");
1693
1694        // Test Counter creation
1695        let counter = endpoint
1696            .metrics()
1697            .create_counter("testcounter", "A test counter", &[])
1698            .unwrap();
1699        counter.inc_by(123.456789);
1700        let epsilon = 0.01;
1701        assert!((counter.get() - 123.456789).abs() < epsilon);
1702
1703        let endpoint_output_raw = endpoint.metrics().prometheus_expfmt().unwrap();
1704        println!("Endpoint output:");
1705        println!("{}", endpoint_output_raw);
1706
1707        // worker_id is runtime-generated (etcd lease ID), so we grab it from the DRT
1708        // and inject it into expected strings via the inject_worker_id helper.
1709        let wid = format!("{:x}", drt.connection_id());
1710        use super::test_helpers::inject_worker_id;
1711
1712        let expected_endpoint_output = inject_worker_id(
1713            r#"# HELP dynamo_component_testcounter A test counter
1714# TYPE dynamo_component_testcounter counter
1715dynamo_component_testcounter{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345"} 123.456789"#,
1716            &wid,
1717        );
1718
1719        assert_eq!(
1720            endpoint_output_raw.trim_end_matches('\n'),
1721            expected_endpoint_output.trim_end_matches('\n'),
1722            "\n=== ENDPOINT COMPARISON FAILED ===\n\
1723             Actual:\n{}\n\
1724             Expected:\n{}\n\
1725             ==============================",
1726            endpoint_output_raw,
1727            expected_endpoint_output
1728        );
1729
1730        // Test Gauge creation
1731        let gauge = component
1732            .metrics()
1733            .create_gauge("testgauge", "A test gauge", &[])
1734            .unwrap();
1735        gauge.set(50000.0);
1736        assert_eq!(gauge.get(), 50000.0);
1737
1738        // Test Prometheus format output for Component (gauge + histogram)
1739        let component_output_raw = component.metrics().prometheus_expfmt().unwrap();
1740        println!("Component output:");
1741        println!("{}", component_output_raw);
1742
1743        let expected_component_output = inject_worker_id(
1744            r#"# HELP dynamo_component_testcounter A test counter
1745# TYPE dynamo_component_testcounter counter
1746dynamo_component_testcounter{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345"} 123.456789
1747# HELP dynamo_component_testgauge A test gauge
1748# TYPE dynamo_component_testgauge gauge
1749dynamo_component_testgauge{dynamo_component="comp345",dynamo_namespace="ns345"} 50000"#,
1750            &wid,
1751        );
1752
1753        assert_eq!(
1754            component_output_raw.trim_end_matches('\n'),
1755            expected_component_output.trim_end_matches('\n'),
1756            "\n=== COMPONENT COMPARISON FAILED ===\n\
1757             Actual:\n{}\n\
1758             Expected:\n{}\n\
1759             ==============================",
1760            component_output_raw,
1761            expected_component_output
1762        );
1763
1764        let intcounter = namespace
1765            .metrics()
1766            .create_intcounter("testintcounter", "A test int counter", &[])
1767            .unwrap();
1768        intcounter.inc_by(12345);
1769        assert_eq!(intcounter.get(), 12345);
1770
1771        // Test Prometheus format output for Namespace (int_counter + gauge + histogram)
1772        let namespace_output_raw = namespace.metrics().prometheus_expfmt().unwrap();
1773        println!("Namespace output:");
1774        println!("{}", namespace_output_raw);
1775
1776        let expected_namespace_output = inject_worker_id(
1777            r#"# HELP dynamo_component_testcounter A test counter
1778# TYPE dynamo_component_testcounter counter
1779dynamo_component_testcounter{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345"} 123.456789
1780# HELP dynamo_component_testgauge A test gauge
1781# TYPE dynamo_component_testgauge gauge
1782dynamo_component_testgauge{dynamo_component="comp345",dynamo_namespace="ns345"} 50000
1783# HELP dynamo_component_testintcounter A test int counter
1784# TYPE dynamo_component_testintcounter counter
1785dynamo_component_testintcounter{dynamo_namespace="ns345"} 12345"#,
1786            &wid,
1787        );
1788
1789        assert_eq!(
1790            namespace_output_raw.trim_end_matches('\n'),
1791            expected_namespace_output.trim_end_matches('\n'),
1792            "\n=== NAMESPACE COMPARISON FAILED ===\n\
1793             Actual:\n{}\n\
1794             Expected:\n{}\n\
1795             ==============================",
1796            namespace_output_raw,
1797            expected_namespace_output
1798        );
1799
1800        // Test IntGauge creation
1801        let intgauge = namespace
1802            .metrics()
1803            .create_intgauge("testintgauge", "A test int gauge", &[])
1804            .unwrap();
1805        intgauge.set(42);
1806        assert_eq!(intgauge.get(), 42);
1807
1808        // Test IntGaugeVec creation
1809        let intgaugevec = namespace
1810            .metrics()
1811            .create_intgaugevec(
1812                "testintgaugevec",
1813                "A test int gauge vector",
1814                &["instance", "status"],
1815                &[("service", "api")],
1816            )
1817            .unwrap();
1818        intgaugevec
1819            .with_label_values(&["server1", "active"])
1820            .set(10);
1821        intgaugevec
1822            .with_label_values(&["server2", "inactive"])
1823            .set(0);
1824
1825        // Test CounterVec creation
1826        let countervec = endpoint
1827            .metrics()
1828            .create_countervec(
1829                "testcountervec",
1830                "A test counter vector",
1831                &["method", "status"],
1832                &[("service", "api")],
1833            )
1834            .unwrap();
1835        countervec.with_label_values(&["GET", "200"]).inc_by(10.0);
1836        countervec.with_label_values(&["POST", "201"]).inc_by(5.0);
1837
1838        // Test Histogram creation
1839        let histogram = component
1840            .metrics()
1841            .create_histogram("testhistogram", "A test histogram", &[], None)
1842            .unwrap();
1843        histogram.observe(1.0);
1844        histogram.observe(2.5);
1845        histogram.observe(4.0);
1846
1847        // Test Prometheus format output for DRT (all metrics combined)
1848        let drt_output_raw = drt.metrics().prometheus_expfmt().unwrap();
1849        println!("DRT output:");
1850        println!("{}", drt_output_raw);
1851
1852        // The uptime_seconds value is dynamic (depends on elapsed wall-clock time),
1853        // so we check all other lines exactly and validate uptime separately.
1854        let expected_drt_output_without_uptime = inject_worker_id(
1855            r#"# HELP dynamo_component_testcounter A test counter
1856# TYPE dynamo_component_testcounter counter
1857dynamo_component_testcounter{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345"} 123.456789
1858# HELP dynamo_component_testcountervec A test counter vector
1859# TYPE dynamo_component_testcountervec counter
1860dynamo_component_testcountervec{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345",method="GET",service="api",status="200"} 10
1861dynamo_component_testcountervec{dynamo_component="comp345",dynamo_endpoint="ep345",dynamo_namespace="ns345",method="POST",service="api",status="201"} 5
1862# HELP dynamo_component_testgauge A test gauge
1863# TYPE dynamo_component_testgauge gauge
1864dynamo_component_testgauge{dynamo_component="comp345",dynamo_namespace="ns345"} 50000
1865# HELP dynamo_component_testhistogram A test histogram
1866# TYPE dynamo_component_testhistogram histogram
1867dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.005"} 0
1868dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.01"} 0
1869dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.025"} 0
1870dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.05"} 0
1871dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.1"} 0
1872dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.25"} 0
1873dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="0.5"} 0
1874dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="1"} 1
1875dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="2.5"} 2
1876dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="5"} 3
1877dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="10"} 3
1878dynamo_component_testhistogram_bucket{dynamo_component="comp345",dynamo_namespace="ns345",le="+Inf"} 3
1879dynamo_component_testhistogram_sum{dynamo_component="comp345",dynamo_namespace="ns345"} 7.5
1880dynamo_component_testhistogram_count{dynamo_component="comp345",dynamo_namespace="ns345"} 3
1881# HELP dynamo_component_testintcounter A test int counter
1882# TYPE dynamo_component_testintcounter counter
1883dynamo_component_testintcounter{dynamo_namespace="ns345"} 12345
1884# HELP dynamo_component_testintgauge A test int gauge
1885# TYPE dynamo_component_testintgauge gauge
1886dynamo_component_testintgauge{dynamo_namespace="ns345"} 42
1887# HELP dynamo_component_testintgaugevec A test int gauge vector
1888# TYPE dynamo_component_testintgaugevec gauge
1889dynamo_component_testintgaugevec{dynamo_namespace="ns345",instance="server1",service="api",status="active"} 10
1890dynamo_component_testintgaugevec{dynamo_namespace="ns345",instance="server2",service="api",status="inactive"} 0"#,
1891            &wid,
1892        );
1893
1894        // Split actual output into non-uptime lines and validate the uptime value line.
1895        // The uptime metric now carries a worker_id label, so we match on the metric name
1896        // prefix and extract the value as the last whitespace-delimited token.
1897        let mut non_uptime_lines = Vec::new();
1898        let mut saw_uptime_value = false;
1899        for line in drt_output_raw.trim_end_matches('\n').lines() {
1900            if line.starts_with("dynamo_component_uptime_seconds") && !line.starts_with('#') {
1901                let val_str = line.split_whitespace().last().unwrap();
1902                val_str.parse::<f64>().expect("uptime should be a float");
1903                saw_uptime_value = true;
1904            } else if line.starts_with("# HELP dynamo_component_uptime_seconds")
1905                || line.starts_with("# TYPE dynamo_component_uptime_seconds")
1906            {
1907                // Skip HELP/TYPE lines for uptime (we just verify it exists via the value)
1908            } else {
1909                non_uptime_lines.push(line);
1910            }
1911        }
1912        assert!(
1913            saw_uptime_value,
1914            "uptime_seconds metric should be present in initial scrape"
1915        );
1916
1917        let actual_without_uptime = non_uptime_lines.join("\n");
1918        assert_eq!(
1919            actual_without_uptime,
1920            expected_drt_output_without_uptime.trim_end_matches('\n'),
1921            "\n=== DRT COMPARISON FAILED (excluding uptime) ===\n\
1922             Expected:\n{}\n\
1923             Actual:\n{}\n\
1924             ==============================",
1925            expected_drt_output_without_uptime,
1926            actual_without_uptime
1927        );
1928
1929        // Wait briefly so the uptime gauge is clearly positive on the next scrape.
1930        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1931        let drt_output_after = drt.metrics().prometheus_expfmt().unwrap();
1932        let uptime_line = drt_output_after
1933            .lines()
1934            .find(|l| l.starts_with("dynamo_component_uptime_seconds") && !l.starts_with('#'))
1935            .expect("uptime_seconds metric should be present after sleep");
1936        let uptime_after: f64 = uptime_line
1937            .split_whitespace()
1938            .last()
1939            .unwrap()
1940            .parse()
1941            .expect("uptime should be a float");
1942        assert!(
1943            uptime_after > 0.0,
1944            "uptime_seconds should be > 0 after 10ms sleep, got {}",
1945            uptime_after
1946        );
1947
1948        println!("✓ All Prometheus format outputs verified successfully!");
1949    }
1950
1951    #[test]
1952    fn test_refactored_filter_functions() {
1953        // Test data with component metrics
1954        let test_input = r#"# HELP dynamo_component_requests Total requests
1955# TYPE dynamo_component_requests counter
1956dynamo_component_requests 42
1957# HELP dynamo_component_latency Response latency
1958# TYPE dynamo_component_latency histogram
1959dynamo_component_latency_bucket{le="0.1"} 10
1960dynamo_component_latency_bucket{le="0.5"} 25
1961dynamo_component_errors_total 5"#;
1962
1963        // Test extract_metrics (only actual metric lines, excluding help/type)
1964        let metrics_only = super::test_helpers::extract_metrics(test_input);
1965        assert_eq!(metrics_only.len(), 4); // 4 actual metric lines (excluding help/type)
1966        assert!(
1967            metrics_only
1968                .iter()
1969                .all(|line| line.starts_with("dynamo_component") && !line.starts_with("#"))
1970        );
1971
1972        println!("✓ All refactored filter functions work correctly!");
1973    }
1974
1975    #[tokio::test]
1976    async fn test_same_metric_name_different_endpoints() {
1977        // Test that the same metric name can exist in different endpoints without collision.
1978        // This validates the multi-registry approach: each endpoint has its own registry,
1979        // and metrics are merged at scrape time with distinct labels.
1980        let drt = create_test_drt_async().await;
1981        let namespace = drt.namespace("ns_test").unwrap();
1982        let component = namespace.component("comp_test").unwrap();
1983
1984        // Create two endpoints with the same metric name
1985        let ep1 = component.endpoint("ep1");
1986        let ep2 = component.endpoint("ep2");
1987
1988        let counter1 = ep1
1989            .metrics()
1990            .create_counter("requests_total", "Total requests", &[])
1991            .unwrap();
1992        counter1.inc_by(100.0);
1993
1994        let counter2 = ep2
1995            .metrics()
1996            .create_counter("requests_total", "Total requests", &[])
1997            .unwrap();
1998        counter2.inc_by(200.0);
1999
2000        // Get merged Prometheus output from component level
2001        let output = component.metrics().prometheus_expfmt().unwrap();
2002
2003        let wid = format!("{:x}", drt.connection_id());
2004        use super::test_helpers::inject_worker_id;
2005
2006        let expected_output = inject_worker_id(
2007            r#"# HELP dynamo_component_requests_total Total requests
2008# TYPE dynamo_component_requests_total counter
2009dynamo_component_requests_total{dynamo_component="comp_test",dynamo_endpoint="ep1",dynamo_namespace="ns_test"} 100
2010dynamo_component_requests_total{dynamo_component="comp_test",dynamo_endpoint="ep2",dynamo_namespace="ns_test"} 200"#,
2011            &wid,
2012        );
2013
2014        assert_eq!(
2015            output.trim_end_matches('\n'),
2016            expected_output.trim_end_matches('\n'),
2017            "\n=== MULTI-REGISTRY COMPARISON FAILED ===\n\
2018             Actual:\n{}\n\
2019             Expected:\n{}\n\
2020             ==============================",
2021            output,
2022            expected_output
2023        );
2024
2025        println!("✓ Multi-registry prevents Prometheus collisions!");
2026    }
2027
2028    #[tokio::test]
2029    async fn test_duplicate_series_warning() {
2030        // Test that duplicate series (same metric name + same labels) are detected and deduplicated.
2031        // This should log a warning and keep only one of the duplicate series.
2032        let drt = create_test_drt_async().await;
2033        let namespace = drt.namespace("ns_dup").unwrap();
2034        let component = namespace.component("comp_dup").unwrap();
2035
2036        // Create two endpoints with counters that will have identical labels when scraped
2037        let ep1 = component.endpoint("ep_same");
2038        let ep2 = component.endpoint("ep_same"); // Same endpoint name = duplicate labels
2039
2040        let counter1 = ep1
2041            .metrics()
2042            .create_counter("dup_metric", "Duplicate metric test", &[])
2043            .unwrap();
2044        counter1.inc_by(50.0);
2045
2046        let counter2 = ep2
2047            .metrics()
2048            .create_counter("dup_metric", "Duplicate metric test", &[])
2049            .unwrap();
2050        counter2.inc_by(75.0);
2051
2052        // Get merged output - duplicates should be deduplicated
2053        let output = component.metrics().prometheus_expfmt().unwrap();
2054
2055        let wid = format!("{:x}", drt.connection_id());
2056        use super::test_helpers::inject_worker_id;
2057
2058        let expected_output = inject_worker_id(
2059            r#"# HELP dynamo_component_dup_metric Duplicate metric test
2060# TYPE dynamo_component_dup_metric counter
2061dynamo_component_dup_metric{dynamo_component="comp_dup",dynamo_endpoint="ep_same",dynamo_namespace="ns_dup"} 50"#,
2062            &wid,
2063        );
2064
2065        assert_eq!(
2066            output.trim_end_matches('\n'),
2067            expected_output.trim_end_matches('\n'),
2068            "\n=== DEDUPLICATION COMPARISON FAILED ===\n\
2069             Actual:\n{}\n\
2070             Expected:\n{}\n\
2071             ==============================",
2072            output,
2073            expected_output
2074        );
2075
2076        println!("✓ Duplicate series detection and deduplication works!");
2077    }
2078}
2079
2080#[cfg(test)]
2081mod test_metric_families_combined {
2082    use super::*;
2083    use std::sync::Arc as StdArc;
2084
2085    fn gauge_family(name: &str, help: &str, value: f64) -> prometheus::proto::MetricFamily {
2086        let mut family = prometheus::proto::MetricFamily::new();
2087        family.set_name(name.to_string());
2088        family.set_help(help.to_string());
2089        family.set_field_type(prometheus::proto::MetricType::GAUGE);
2090        let mut metric = prometheus::proto::Metric::new();
2091        let mut gauge = prometheus::proto::Gauge::new();
2092        gauge.set_value(value);
2093        metric.set_gauge(gauge);
2094        family.mut_metric().push(metric);
2095        family
2096    }
2097
2098    fn registry() -> MetricsRegistry {
2099        let registry = MetricsRegistry::new();
2100        let counter = prometheus::IntCounter::new("dynamo_native_total", "Native counter").unwrap();
2101        counter.inc_by(7);
2102        registry
2103            .get_prometheus_registry()
2104            .register(Box::new(counter))
2105            .unwrap();
2106        registry.add_typed_callback(StdArc::new(|| {
2107            Ok(vec![crate::metrics::prom_typed::BuiltFamily {
2108                unit: String::new(),
2109                prom_type: "gauge".to_string(),
2110                family: gauge_family("vllm:num_requests_running", "Running requests", 4.0),
2111            }])
2112        }));
2113        registry
2114    }
2115
2116    fn names(fs: &[prometheus::proto::MetricFamily]) -> Vec<&str> {
2117        fs.iter().map(|f| f.name()).collect()
2118    }
2119
2120    #[test]
2121    fn typed_and_native_families_share_one_collection() {
2122        let collected = registry().metric_families_combined().expect("combined");
2123        assert!(names(&collected.families).contains(&"vllm:num_requests_running"));
2124        assert!(names(&collected.families).contains(&"dynamo_native_total"));
2125    }
2126
2127    /// `units` and `prom_types` are keyed by family name, so a family the
2128    /// merger rejects must not leave its metadata behind: it would then be
2129    /// applied to whichever family did survive under that name. Here the
2130    /// rejected entry claims `info`, which would flip the surviving gauge to a
2131    /// non-monotonic Sum on the OTLP side.
2132    /// The merger resolves a conflict first-writer-wins: the first family
2133    /// fixes help and type and later ones must agree. The metadata maps must
2134    /// resolve the same way. A later family can pass that check and still have
2135    /// every sample dropped as a duplicate series, so a last-writer-wins unit
2136    /// would label the surviving data with a unit from a source that
2137    /// contributed none of it.
2138    #[test]
2139    fn metadata_from_a_source_that_contributed_no_samples_does_not_win() {
2140        let registry = MetricsRegistry::new();
2141        registry.add_typed_callback(StdArc::new(|| {
2142            Ok(vec![
2143                crate::metrics::prom_typed::BuiltFamily {
2144                    unit: "bytes".to_string(),
2145                    prom_type: "gauge".to_string(),
2146                    family: gauge_family("vllm:probe", "Same help", 1.0),
2147                },
2148                // Same name, help and type, so the merger accepts it -- but the
2149                // labels match too, so every sample is dropped as a duplicate.
2150                crate::metrics::prom_typed::BuiltFamily {
2151                    unit: "seconds".to_string(),
2152                    prom_type: "gauge".to_string(),
2153                    family: gauge_family("vllm:probe", "Same help", 2.0),
2154                },
2155            ])
2156        }));
2157
2158        let collected = registry.metric_families_combined().expect("combined");
2159
2160        let family = collected
2161            .families
2162            .iter()
2163            .find(|f| f.name() == "vllm:probe")
2164            .expect("family");
2165        assert_eq!(
2166            family.get_metric()[0].get_gauge().value(),
2167            1.0,
2168            "the surviving sample should be the first source's"
2169        );
2170        assert_eq!(
2171            collected.units.get("vllm:probe").map(String::as_str),
2172            Some("bytes"),
2173            "unit came from a source whose samples were all dropped"
2174        );
2175    }
2176
2177    #[test]
2178    fn rejected_family_leaves_no_metadata_behind() {
2179        let registry = MetricsRegistry::new();
2180        registry.add_typed_callback(StdArc::new(|| {
2181            Ok(vec![
2182                crate::metrics::prom_typed::BuiltFamily {
2183                    unit: String::new(),
2184                    prom_type: "gauge".to_string(),
2185                    family: gauge_family("vllm:cache_usage", "Cache usage", 0.5),
2186                },
2187                // Same name, different help -> rejected by the merger.
2188                crate::metrics::prom_typed::BuiltFamily {
2189                    unit: "seconds".to_string(),
2190                    prom_type: "info".to_string(),
2191                    family: gauge_family("vllm:cache_usage", "A different help", 1.0),
2192                },
2193            ])
2194        }));
2195
2196        let collected = registry.metric_families_combined().expect("combined");
2197
2198        assert_eq!(
2199            collected
2200                .prom_types
2201                .get("vllm:cache_usage")
2202                .map(String::as_str),
2203            Some("gauge"),
2204            "the rejected family's type overwrote the accepted family's"
2205        );
2206        assert!(
2207            !collected.units.contains_key("vllm:cache_usage"),
2208            "the rejected family's unit was published for a family that has none"
2209        );
2210    }
2211
2212    /// The two surfaces are fed independently: typed callbacks reach OTLP,
2213    /// exposition-text callbacks reach `/metrics`. A registry that registers
2214    /// only one appears on only that surface -- which is why the Python
2215    /// helpers always register both.
2216    #[test]
2217    fn typed_callbacks_feed_otlp_and_not_the_scrape() {
2218        let registry = registry();
2219
2220        let text = registry.prometheus_expfmt_combined().expect("text");
2221        assert!(
2222            text.contains("dynamo_native_total"),
2223            "native metrics must still render on /metrics"
2224        );
2225        assert!(
2226            !text.contains("vllm:num_requests_running"),
2227            "typed callbacks feed OTLP only; /metrics is fed by expfmt callbacks"
2228        );
2229
2230        registry.add_expfmt_callback(StdArc::new(|| {
2231            Ok("# HELP vllm:num_requests_running Running requests\n\
2232                # TYPE vllm:num_requests_running gauge\n\
2233                vllm:num_requests_running 4\n"
2234                .to_string())
2235        }));
2236        let text = registry.prometheus_expfmt_combined().expect("text");
2237        assert!(
2238            text.contains("vllm:num_requests_running"),
2239            "engine exposition text must be appended verbatim"
2240        );
2241    }
2242
2243    /// Collection must not hold the callback lock while invoking callbacks.
2244    ///
2245    /// Registration arrives from Python holding the GIL and blocks on
2246    /// `write()`; a collection that held the read lock while calling into
2247    /// Python would deadlock the two against each other. Registering from
2248    /// inside a callback reproduces that ordering deterministically, without
2249    /// depending on a thread interleaving.
2250    ///
2251    /// Run on a worker thread with a deadline so a regression fails the test
2252    /// rather than hanging the suite forever.
2253    #[test]
2254    fn collection_does_not_hold_the_callback_lock() {
2255        for (name, collect) in [
2256            (
2257                "typed",
2258                Box::new(|r: &MetricsRegistry| {
2259                    r.execute_typed_callbacks();
2260                }) as Box<dyn Fn(&MetricsRegistry) + Send>,
2261            ),
2262            (
2263                "expfmt",
2264                Box::new(|r: &MetricsRegistry| {
2265                    r.execute_expfmt_callbacks();
2266                }),
2267            ),
2268        ] {
2269            let (tx, rx) = std::sync::mpsc::channel();
2270            std::thread::spawn(move || {
2271                let registry = MetricsRegistry::new();
2272                let reentrant = registry.clone();
2273                registry.add_typed_callback(StdArc::new(move || {
2274                    reentrant.add_typed_callback(StdArc::new(|| Ok(Vec::new())));
2275                    Ok(Vec::new())
2276                }));
2277                let reentrant = registry.clone();
2278                registry.add_expfmt_callback(StdArc::new(move || {
2279                    reentrant.add_expfmt_callback(StdArc::new(|| Ok(String::new())));
2280                    Ok(String::new())
2281                }));
2282                collect(&registry);
2283                let _ = tx.send(());
2284            });
2285
2286            assert!(
2287                rx.recv_timeout(std::time::Duration::from_secs(10)).is_ok(),
2288                "{name} collection deadlocked: the callback lock was held while a \
2289                 callback registered another"
2290            );
2291        }
2292    }
2293
2294    /// A failing engine callback is skipped, never fatal: one broken engine
2295    /// must not empty the collection for every other.
2296    #[test]
2297    fn failing_typed_callback_is_skipped() {
2298        let registry = MetricsRegistry::new();
2299        registry.add_typed_callback(StdArc::new(|| anyhow::bail!("engine exploded")));
2300        assert!(
2301            registry
2302                .metric_families_combined()
2303                .expect("collection survives")
2304                .families
2305                .is_empty()
2306        );
2307    }
2308}