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