From afd5b3af66d6fd05b7d151c0959dfe833c5189ab Mon Sep 17 00:00:00 2001 From: Sean Kim Date: Thu, 9 Jul 2026 15:05:53 -0700 Subject: [PATCH] Fix operator precedence allowing exemplars on any metric type _is_valid_exemplar_metric() guarded histograms with if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name: Because 'and' binds tighter than 'or', this parsed as (metric.type in ('histogram') and sample.name.endswith('_bucket')) or (sample.name == metric.name) so the trailing 'sample.name == metric.name' clause fired for every metric type. As a result the OpenMetrics writer emitted exemplars on gauges, info, stateset, summary and untyped metrics whenever a sample name equalled the metric name -- output that the library's own OpenMetrics parser rejects (only histogram/gaugehistogram buckets, counter _total, and native histograms may carry exemplars). Group the histogram condition correctly so the same-name clause (which exists to allow native-histogram exemplars) only applies to histograms. While here, replace the 'metric.type in (...)' single-string membership checks with '==' -- they were doing substring matching, not the intended equality. Add test_gauge_exemplar, which asserts a gauge sample carrying an exemplar raises ValueError, matching the existing untyped/non-bucket exemplar tests. Signed-off-by: Sean Kim --- prometheus_client/openmetrics/exposition.py | 4 ++-- tests/openmetrics/test_exposition.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 5e69e463..5a7711b0 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -25,9 +25,9 @@ def _is_valid_exemplar_metric(metric, sample): if metric.type == 'counter' and sample.name.endswith('_total'): return True - if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'): + if metric.type == 'gaugehistogram' and sample.name.endswith('_bucket'): return True - if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name: + if metric.type == 'histogram' and (sample.name.endswith('_bucket') or sample.name == metric.name): return True return False diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index a3ed0d6e..a849f5fa 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -347,6 +347,21 @@ def collect(self): with self.assertRaises(ValueError): generate_latest(self.registry) + def test_gauge_exemplar(self) -> None: + class MyCollector: + def collect(self): + metric = Metric("gg", "A gauge", 'gauge') + # A sample whose name equals the metric name must not be + # treated as exemplar-eligible just because it matches; + # only histogram/gaugehistogram buckets, counter _total, and + # native histograms may carry exemplars. + metric.add_sample("gg", {}, 1, None, Exemplar({'a': 'b'}, 0.5)) + yield metric + + self.registry.register(MyCollector()) + with self.assertRaises(ValueError): + generate_latest(self.registry) + def test_gaugehistogram(self) -> None: self.custom_collector( GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))], gsum_value=7))