From f6b07a2444bbcff43ef7ad5be76e14017c5021b1 Mon Sep 17 00:00:00 2001 From: Raphael Bizos Date: Fri, 16 Oct 2026 18:09:43 +0200 Subject: [PATCH 1/2] Route OpenMetrics 2.0.0 requests to a dedicated writer behind opt-in flag Signed-off-by: Raphael Bizos --- prometheus_client/__init__.py | 3 + prometheus_client/openmetrics/exposition.py | 81 +++++++++++++++++++++ tests/openmetrics/test_exposition.py | 33 ++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index 221ad273..d1e174d2 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -17,6 +17,7 @@ Histogram, Info, Summary, ) from .metrics_core import Metric +from .openmetrics.exposition import disable_openmetrics_2, enable_openmetrics_2 from .platform_collector import PLATFORM_COLLECTOR, PlatformCollector from .process_collector import PROCESS_COLLECTOR, ProcessCollector from .registry import CollectorRegistry, REGISTRY @@ -33,6 +34,8 @@ 'Enum', 'enable_created_metrics', 'disable_created_metrics', + 'enable_openmetrics_2', + 'disable_openmetrics_2', 'CONTENT_TYPE_LATEST', 'CONTENT_TYPE_PLAIN_0_0_4', 'CONTENT_TYPE_PLAIN_1_0_0', diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 5d276e2f..926faf92 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from io import StringIO +import os from sys import maxunicode from typing import Callable @@ -16,6 +17,25 @@ ESCAPING_HEADER_TAG = 'escaping' +def _get_openmetrics_2_enabled() -> bool: + return os.environ.get("PROMETHEUS_ENABLE_OPENMETRICS_2", 'False').lower() in ('true', '1', 't') + + +_openmetrics_2_enabled = _get_openmetrics_2_enabled() + + +def enable_openmetrics_2(): + """Serve the experimental OpenMetrics 2.0 text format when version 2.0.0 is requested.""" + global _openmetrics_2_enabled + _openmetrics_2_enabled = True + + +def disable_openmetrics_2(): + """Serve OpenMetrics 1.0 text with native histograms when version 2.0.0 is requested.""" + global _openmetrics_2_enabled + _openmetrics_2_enabled = False + + ALLOWUTF8 = 'allow-utf-8' UNDERSCORES = 'underscores' DOTS = 'dots' @@ -57,6 +77,12 @@ def _compose_exemplar_string(metric, sample, exemplar): def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): '''Returns the metrics from the registry in latest text format as a string.''' + if _openmetrics_2_enabled and parse_version(version) == (2, 0, 0): + return _generate_latest_2_0(registry, escaping) + return _generate_latest_1_0(registry, escaping, version) + + +def _generate_latest_1_0(registry, escaping, version): output = [] for metric in registry.collect(): try: @@ -170,6 +196,61 @@ def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): return ''.join(output).encode('utf-8') +def _generate_latest_2_0(registry, escaping): + """EXPERIMENTAL: targets OpenMetrics 2.0 and may change without notice.""" + formatters = { + 'counter': _format_counter_2_0, + 'gauge': _format_gauge_2_0, + 'summary': _format_summary_2_0, + 'histogram': _format_histogram_2_0, + 'gaugehistogram': _format_gaugehistogram_2_0, + 'unknown': _format_unknown_2_0, + 'info': _format_info_2_0, + 'stateset': _format_stateset_2_0, + } + output = [] + for metric in registry.collect(): + try: + output.append(formatters[metric.type](metric, escaping)) + except Exception as exception: + exception.args = (exception.args or ('',)) + (metric,) + raise + output.append('# EOF\n') + return ''.join(output).encode('utf-8') + + +def _format_counter_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support counter metrics yet') + + +def _format_gauge_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support gauge metrics yet') + + +def _format_summary_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support summary metrics yet') + + +def _format_histogram_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support histogram metrics yet') + + +def _format_gaugehistogram_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support gaugehistogram metrics yet') + + +def _format_unknown_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support unknown metrics yet') + + +def _format_info_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support info metrics yet') + + +def _format_stateset_2_0(metric, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support stateset metrics yet') + + def escape_metric_name(s: str, escaping: str = UNDERSCORES) -> str: """Escapes the metric name and puts it in quotes iff the name does not conform to the legacy Prometheus character set. diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 3ba4031a..91e11d5d 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -12,8 +12,9 @@ HistogramMetricFamily, NativeHistogram, Timestamp, ) from prometheus_client.openmetrics.exposition import ( - ALLOWUTF8, DOTS, escape_label_name, escape_metric_name, generate_latest, - UNDERSCORES, VALUES, + ALLOWUTF8, disable_openmetrics_2, DOTS, enable_openmetrics_2, + escape_label_name, escape_metric_name, generate_latest, UNDERSCORES, + VALUES, ) @@ -525,6 +526,34 @@ def test_native_histogram_version_comparison(self) -> None: """, generate_latest(self.registry, version="2.0.0")) + +class TestGenerateText20(unittest.TestCase): + def setUp(self): + self.registry = CollectorRegistry() + enable_openmetrics_2() + + def tearDown(self): + disable_openmetrics_2() + + def test_unsupported_type(self) -> None: + Gauge('gg', 'A gauge', registry=self.registry) + with self.assertRaises(NotImplementedError): + generate_latest(self.registry, version="2.0.0") + + def test_version_1_0_0(self) -> None: + g = Gauge('gg', 'A gauge', registry=self.registry) + g.set(17) + self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n# EOF\n', + generate_latest(self.registry, version="1.0.0")) + + def test_disabled(self) -> None: + disable_openmetrics_2() + g = Gauge('gg', 'A gauge', registry=self.registry) + g.set(17) + self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n# EOF\n', + generate_latest(self.registry, version="2.0.0")) + + @pytest.mark.parametrize("scenario", [ { "name": "empty string", From 5d30364ddb18d1eba0c153a22c96ed00afb75666 Mon Sep 17 00:00:00 2001 From: Raphael Bizos Date: Wed, 21 Oct 2026 20:41:50 +0200 Subject: [PATCH 2/2] Add OpenMetrics 2.0 writer for gauge, info and unknown metrics Signed-off-by: Raphael Bizos --- prometheus_client/__init__.py | 3 - prometheus_client/openmetrics/exposition.py | 98 +++++++----- tests/openmetrics/test_exposition.py | 169 ++++++++++++++++++-- 3 files changed, 213 insertions(+), 57 deletions(-) diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index d1e174d2..221ad273 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -17,7 +17,6 @@ Histogram, Info, Summary, ) from .metrics_core import Metric -from .openmetrics.exposition import disable_openmetrics_2, enable_openmetrics_2 from .platform_collector import PLATFORM_COLLECTOR, PlatformCollector from .process_collector import PROCESS_COLLECTOR, ProcessCollector from .registry import CollectorRegistry, REGISTRY @@ -34,8 +33,6 @@ 'Enum', 'enable_created_metrics', 'disable_created_metrics', - 'enable_openmetrics_2', - 'disable_openmetrics_2', 'CONTENT_TYPE_LATEST', 'CONTENT_TYPE_PLAIN_0_0_4', 'CONTENT_TYPE_PLAIN_1_0_0', diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 926faf92..bc6cce2f 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -1,7 +1,6 @@ #!/usr/bin/env python from io import StringIO -import os from sys import maxunicode from typing import Callable @@ -17,24 +16,9 @@ ESCAPING_HEADER_TAG = 'escaping' -def _get_openmetrics_2_enabled() -> bool: - return os.environ.get("PROMETHEUS_ENABLE_OPENMETRICS_2", 'False').lower() in ('true', '1', 't') - - -_openmetrics_2_enabled = _get_openmetrics_2_enabled() - - -def enable_openmetrics_2(): - """Serve the experimental OpenMetrics 2.0 text format when version 2.0.0 is requested.""" - global _openmetrics_2_enabled - _openmetrics_2_enabled = True - - -def disable_openmetrics_2(): - """Serve OpenMetrics 1.0 text with native histograms when version 2.0.0 is requested.""" - global _openmetrics_2_enabled - _openmetrics_2_enabled = False - +# TODO: remove once every metric type is supported by _generate_latest_2_0, so +# that version 2.0.0 is chosen by content negotiation alone. +_openmetrics_2_enabled = False ALLOWUTF8 = 'allow-utf-8' UNDERSCORES = 'underscores' @@ -196,22 +180,29 @@ def _generate_latest_1_0(registry, escaping, version): return ''.join(output).encode('utf-8') +# OpenMetrics 2.0 requires the family name to equal the sample name, while +# metric.name omits the suffix these types add to their samples. +_FAMILY_NAME_SUFFIXES_2_0 = {'counter': '_total', 'info': '_info'} + + def _generate_latest_2_0(registry, escaping): """EXPERIMENTAL: targets OpenMetrics 2.0 and may change without notice.""" formatters = { 'counter': _format_counter_2_0, - 'gauge': _format_gauge_2_0, + 'gauge': _format_simple_samples_2_0, 'summary': _format_summary_2_0, 'histogram': _format_histogram_2_0, 'gaugehistogram': _format_gaugehistogram_2_0, - 'unknown': _format_unknown_2_0, - 'info': _format_info_2_0, + 'unknown': _format_simple_samples_2_0, + 'info': _format_simple_samples_2_0, 'stateset': _format_stateset_2_0, } output = [] for metric in registry.collect(): try: - output.append(formatters[metric.type](metric, escaping)) + family_name = metric.name + _FAMILY_NAME_SUFFIXES_2_0.get(metric.type, '') + output.append(_format_metadata_2_0(metric, family_name, escaping)) + output.append(formatters[metric.type](metric, family_name, escaping)) except Exception as exception: exception.args = (exception.args or ('',)) + (metric,) raise @@ -219,35 +210,66 @@ def _generate_latest_2_0(registry, escaping): return ''.join(output).encode('utf-8') -def _format_counter_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support counter metrics yet') +def _format_metadata_2_0(metric, family_name, escaping): + """Formats the TYPE, UNIT and HELP lines, in the order OpenMetrics 2.0 recommends.""" + escaped_name = escape_metric_name(family_name, escaping) + output = [f'# TYPE {escaped_name} {metric.type}\n'] + if metric.unit: + output.append(f'# UNIT {escaped_name} {metric.unit}\n') + output.append('# HELP {} {}\n'.format( + escaped_name, _escape(metric.documentation, ALLOWUTF8, _is_legacy_labelname_rune))) + return ''.join(output) -def _format_gauge_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support gauge metrics yet') +def _format_simple_samples_2_0(metric, family_name, escaping): + """Formats the samples of a type that has neither start timestamps nor exemplars. + OpenMetrics 2.0 only allows exemplars on counters and histograms, and says + to drop invalid exemplars rather than fail the exposition, so any exemplar + on these samples is left out. + """ + return ''.join(_format_sample_2_0(family_name, s, escaping) for s in metric.samples) -def _format_summary_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support summary metrics yet') +def _format_sample_2_0(family_name, sample, escaping): + """Formats a sample as ``name{labels} value [timestamp]``, named after its family as OpenMetrics 2.0 requires.""" + line = f'{_format_name_and_labels_2_0(family_name, sample.labels, escaping)} {floatToGoString(sample.value)}' + if sample.timestamp is not None: + line += f' {sample.timestamp}' + return line + '\n' -def _format_histogram_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support histogram metrics yet') +def _format_name_and_labels_2_0(name, labels, escaping): + """Formats ``name{labels}``, or ``{"name",labels}`` when the name has to be quoted.""" + escaped_name = escape_metric_name(name, escaping) + items = [ + '{}="{}"'.format(escape_label_name(k, escaping), _escape(v, ALLOWUTF8, _is_legacy_labelname_rune)) + for k, v in sorted(labels.items()) + ] + if escaped_name.startswith('"'): + return '{' + ','.join([escaped_name] + items) + '}' + if items: + return escaped_name + '{' + ','.join(items) + '}' + return escaped_name -def _format_gaugehistogram_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support gaugehistogram metrics yet') + +def _format_counter_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support counter metrics yet') -def _format_unknown_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support unknown metrics yet') +def _format_summary_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support summary metrics yet') + +def _format_histogram_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support histogram metrics yet') -def _format_info_2_0(metric, escaping): - raise NotImplementedError('OpenMetrics 2.0 does not support info metrics yet') + +def _format_gaugehistogram_2_0(metric, family_name, escaping): + raise NotImplementedError('OpenMetrics 2.0 does not support gaugehistogram metrics yet') -def _format_stateset_2_0(metric, escaping): +def _format_stateset_2_0(metric, family_name, escaping): raise NotImplementedError('OpenMetrics 2.0 does not support stateset metrics yet') diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 91e11d5d..025577dd 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -9,12 +9,11 @@ ) from prometheus_client.core import ( BucketSpan, Exemplar, GaugeHistogramMetricFamily, GaugeMetricFamily, - HistogramMetricFamily, NativeHistogram, Timestamp, + HistogramMetricFamily, NativeHistogram, Timestamp, UnknownMetricFamily, ) from prometheus_client.openmetrics.exposition import ( - ALLOWUTF8, disable_openmetrics_2, DOTS, enable_openmetrics_2, - escape_label_name, escape_metric_name, generate_latest, UNDERSCORES, - VALUES, + _generate_latest_2_0, ALLOWUTF8, DOTS, escape_label_name, + escape_metric_name, generate_latest, UNDERSCORES, VALUES, ) @@ -530,24 +529,162 @@ def test_native_histogram_version_comparison(self) -> None: class TestGenerateText20(unittest.TestCase): def setUp(self): self.registry = CollectorRegistry() - enable_openmetrics_2() - def tearDown(self): - disable_openmetrics_2() + def custom_collector(self, metric_family: Any) -> None: + class CustomCollector: + def collect(self): + return [metric_family] - def test_unsupported_type(self) -> None: - Gauge('gg', 'A gauge', registry=self.registry) - with self.assertRaises(NotImplementedError): - generate_latest(self.registry, version="2.0.0") + self.registry.register(CustomCollector()) - def test_version_1_0_0(self) -> None: + def test_gauge(self) -> None: g = Gauge('gg', 'A gauge', registry=self.registry) g.set(17) - self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n# EOF\n', - generate_latest(self.registry, version="1.0.0")) + self.assertEqual(b'# TYPE gg gauge\n# HELP gg A gauge\ngg 17.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_unit_and_labels(self) -> None: + g = Gauge('gg_bytes', 'A gauge', ['a', 'b'], registry=self.registry, unit='bytes') + g.labels('c', 'd').set(17) + self.assertEqual(b"""# TYPE gg_bytes gauge +# UNIT gg_bytes bytes +# HELP gg_bytes A gauge +gg_bytes{a="c",b="d"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_timestamp(self) -> None: + g = GaugeMetricFamily('gg', 'A gauge', labels=['a']) + g.add_metric(['b'], 17, timestamp=1520879607.789) + self.custom_collector(g) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg A gauge +gg{a="b"} 17.0 1520879607.789 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_utf8(self) -> None: + g = Gauge('gg.with.dots', 'A gauge', ['label.with.dots'], registry=self.registry) + g.labels('v').set(17) + self.assertEqual(b"""# TYPE "gg.with.dots" gauge +# HELP "gg.with.dots" A gauge +{"gg.with.dots","label.with.dots"="v"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, ALLOWUTF8)) + + def test_gauge_utf8_escaped_underscores(self) -> None: + g = Gauge('gg.with.dots', 'A gauge', ['label.with.dots'], registry=self.registry) + g.labels('v').set(17) + self.assertEqual(b"""# TYPE gg_with_dots gauge +# HELP gg_with_dots A gauge +gg_with_dots{label_with_dots="v"} 17.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_gauge_exemplar_dropped(self) -> None: + metric = Metric('gg', 'A gauge', 'gauge') + metric.add_sample('gg', {}, 1, None, Exemplar({'a': 'b'}, 0.5, 123)) + self.custom_collector(metric) + self.assertEqual(b'# TYPE gg gauge\n# HELP gg A gauge\ngg 1.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_info(self) -> None: + i = Info('ii', 'A info', ['a', 'b'], registry=self.registry) + i.labels('c', 'd').info({'foo': 'bar'}) + self.assertEqual(b"""# TYPE ii_info info +# HELP ii_info A info +ii_info{a="c",b="d",foo="bar"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_info_escaping(self) -> None: + i = Info('ii', 'A info', registry=self.registry) + i.info({'name': 'pretty "name"\n\\'}) + self.assertEqual(b"""# TYPE ii_info info +# HELP ii_info A info +ii_info{name="pretty \\"name\\"\\n\\\\"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unknown(self) -> None: + u = UnknownMetricFamily('uu', 'An unknown', labels=['a']) + u.add_metric(['b'], 42.23) + self.custom_collector(u) + self.assertEqual(b"""# TYPE uu unknown +# HELP uu An unknown +uu{a="b"} 42.23 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unknown_exemplar_dropped(self) -> None: + metric = Metric('uu', 'An unknown', 'untyped') + metric.add_sample('uu', {}, 1, None, Exemplar({'a': 'b'}, 0.5, 123)) + self.custom_collector(metric) + self.assertEqual(b'# TYPE uu unknown\n# HELP uu An unknown\nuu 1.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unicode(self) -> None: + g = Gauge('gg', '\u4500', ['l'], registry=self.registry) + g.labels('\u4500').set(1) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg \xe4\x94\x80 +gg{l="\xe4\x94\x80"} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_escaping(self) -> None: + g = Gauge('gg', 'A\ngau\\ge\"', ['a'], registry=self.registry) + g.labels('\\x\n"').set(1) + self.assertEqual(b"""# TYPE gg gauge +# HELP gg A\\ngau\\\\ge\\" +gg{a="\\\\x\\n\\""} 1.0 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_nonnumber(self) -> None: + class MyNumber: + def __repr__(self): + return "MyNumber(123)" + + def __float__(self): + return 123.0 + + class MyCollector: + def collect(self): + metric = Metric("nonnumber", "Non number", 'untyped') + metric.add_sample("nonnumber", {}, MyNumber()) + yield metric + + self.registry.register(MyCollector()) + self.assertEqual(b'# TYPE nonnumber unknown\n# HELP nonnumber Non number\nnonnumber 123.0\n# EOF\n', + _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_timestamp(self) -> None: + metric = Metric("ts", "help", 'unknown') + metric.add_sample("ts", {"foo": "a"}, 0, 123.456) + metric.add_sample("ts", {"foo": "b"}, 0, -123.456) + metric.add_sample("ts", {"foo": "c"}, 0, 123) + metric.add_sample("ts", {"foo": "d"}, 0, Timestamp(123, 456000000)) + metric.add_sample("ts", {"foo": "e"}, 0, Timestamp(123, 456000)) + metric.add_sample("ts", {"foo": "f"}, 0, Timestamp(123, 456)) + self.custom_collector(metric) + self.assertEqual(b"""# TYPE ts unknown +# HELP ts help +ts{foo="a"} 0.0 123.456 +ts{foo="b"} 0.0 -123.456 +ts{foo="c"} 0.0 123 +ts{foo="d"} 0.0 123.456000000 +ts{foo="e"} 0.0 123.000456000 +ts{foo="f"} 0.0 123.000000456 +# EOF +""", _generate_latest_2_0(self.registry, UNDERSCORES)) + + def test_unsupported_type(self) -> None: + Summary('ss', 'A summary', registry=self.registry) + with self.assertRaises(NotImplementedError): + _generate_latest_2_0(self.registry, UNDERSCORES) - def test_disabled(self) -> None: - disable_openmetrics_2() + def test_version_2_0_0_served_by_1_0_writer_until_supported(self) -> None: g = Gauge('gg', 'A gauge', registry=self.registry) g.set(17) self.assertEqual(b'# HELP gg A gauge\n# TYPE gg gauge\ngg 17.0\n# EOF\n',