Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions prometheus_client/openmetrics/exposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
ESCAPING_HEADER_TAG = 'escaping'


# 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'
DOTS = 'dots'
Expand Down Expand Up @@ -57,6 +61,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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenMetrics 2.0 natively supports UTF-8 names. Should generate_latest (and choose_encoder once 2.0 is enabled) default escaping to ALLOWUTF8 when version is 2.0.0 and no explicit escaping is passed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not, this would need to be documented as breaking somehow as without changing the escaping param this could break alerts/dashboards. I do like the idea though, maybe on a separate change ?

@rbizos rbizos Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This made me think of another similar issue, on OM2. From https://prometheus.io/docs/guides/open_metrics_2_0_migration/#counter-suffix-rules

In OM 2.0, counter Metric names should end in _total. It is no longer mandatory.

Currently it gets added automatically and the users have no control. The way it works is that the suffix is stripped before hand and added while serializing the metrics. This would require to be reworked totally to allow for user choice. let me add that to the issue and do that separately from the implem of the actual metrics :)

return _generate_latest_1_0(registry, escaping, version)


def _generate_latest_1_0(registry, escaping, version):
output = []
for metric in registry.collect():
try:
Expand Down Expand Up @@ -170,6 +180,99 @@ def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"):
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_simple_samples_2_0,
'summary': _format_summary_2_0,
'histogram': _format_histogram_2_0,
'gaugehistogram': _format_gaugehistogram_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:
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
output.append('# EOF\n')
return ''.join(output).encode('utf-8')


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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OpenMetrics 2.0 ABNF (# UNIT <name> *metricname-char LF), units still only allow [a-zA-Z0-9_:] characters even when UTF-8 metric names are enabled. Because Metric.__init__ validates unit via _validate_metric_name(name) (which allows arbitrary UTF-8 by default), something like Gauge('foo', 'help', unit='req/s') succeeds at construction time and emits # UNIT "foo_req/s" req/s here. Should we validate or escape metric.unit so it conforms to *metricname-char?

output.append('# HELP {} {}\n'.format(
escaped_name, _escape(metric.documentation, ALLOWUTF8, _is_legacy_labelname_rune)))
return ''.join(output)


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_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)}'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a custom collector constructs a Metric and adds a sample whose sample.name does not match family_name, this will silently rewrite the sample name to family_name. Should we validate that sample.name == family_name and raise a ValueError on mismatch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, here I'm a bit greedy as I force it to the family_name so that its always a OM2 valid output. I'm very hesitant to failing the full scrape for a sample name mismatching.

if sample.timestamp is not None:
line += f' {sample.timestamp}'
return line + '\n'


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_counter_2_0(metric, family_name, escaping):
raise NotImplementedError('OpenMetrics 2.0 does not support counter 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_gaugehistogram_2_0(metric, family_name, escaping):
raise NotImplementedError('OpenMetrics 2.0 does not support gaugehistogram metrics yet')


def _format_stateset_2_0(metric, family_name, 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.
Expand Down
172 changes: 169 additions & 3 deletions tests/openmetrics/test_exposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +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, DOTS, 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,
)


Expand Down Expand Up @@ -529,6 +529,172 @@ 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()

def custom_collector(self, metric_family: Any) -> None:
class CustomCollector:
def collect(self):
return [metric_family]

self.registry.register(CustomCollector())

def test_gauge(self) -> None:
g = Gauge('gg', 'A gauge', registry=self.registry)
g.set(17)
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_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',
generate_latest(self.registry, version="2.0.0"))


@pytest.mark.parametrize("scenario", [
{
"name": "empty string",
Expand Down
Loading