From d358f469a7bc2480005775a9be8de30c20c88ab6 Mon Sep 17 00:00:00 2001 From: Kajinami Takashi Date: Wed, 3 Sep 2025 23:29:50 +0900 Subject: [PATCH 01/30] Bump flake8 libraries (#1127) ... to the latest version available now. Also, do not pin bugfix version because any bugfix update is supposed to bring no breaking fix. Signed-off-by: Takashi Kajinami --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index e19b25a3..bef57f85 100644 --- a/tox.ini +++ b/tox.ini @@ -30,9 +30,9 @@ commands = [testenv:flake8] deps = - flake8==6.0.0 - flake8-docstrings==1.6.0 - flake8-import-order==0.18.2 + flake8~=7.3 + flake8-docstrings~=1.7 + flake8-import-order~=0.19 skip_install = true commands = flake8 prometheus_client/ tests/ From 9e3eb6c7e146d8003d12e24db56f5abfcc0bbef6 Mon Sep 17 00:00:00 2001 From: hack Date: Wed, 3 Sep 2025 16:32:55 +0200 Subject: [PATCH 02/30] Fix bug which caused metric publishing to not accept query string parameters in ASGI app (#1125) * fix query string encoding in asgi app Signed-off-by: hack * isolate the asgi qs encoding bug into a test case Signed-off-by: hacksparr0w --------- Signed-off-by: hack Signed-off-by: hacksparr0w --- prometheus_client/asgi.py | 2 +- tests/test_asgi.py | 48 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index e1864b8b..affd9844 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -11,7 +11,7 @@ def make_asgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: b async def prometheus_app(scope, receive, send): assert scope.get("type") == "http" # Prepare parameters - params = parse_qs(scope.get('query_string', b'')) + params = parse_qs(scope.get('query_string', b'').decode("utf8")) accept_header = ",".join([ value.decode("utf8") for (name, value) in scope.get('headers') if name.decode("utf8").lower() == 'accept' diff --git a/tests/test_asgi.py b/tests/test_asgi.py index eaa195d0..386ff598 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -93,6 +93,16 @@ def increment_metrics(self, metric_name, help_text, increments): for _ in range(increments): c.inc() + def assert_metrics(self, output, metric_name, help_text, increments): + self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) + + def assert_not_metrics(self, output, metric_name, help_text, increments): + self.assertNotIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertNotIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertNotIn(metric_name + "_total " + str(increments) + ".0\n", output) + def assert_outputs(self, outputs, metric_name, help_text, increments, compressed): self.assertEqual(len(outputs), 2) response_start = outputs[0] @@ -112,9 +122,8 @@ def assert_outputs(self, outputs, metric_name, help_text, increments, compressed output = gzip.decompress(response_body['body']).decode('utf8') else: output = response_body['body'].decode('utf8') - self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) - self.assertIn("# TYPE " + metric_name + "_total counter\n", output) - self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) + + self.assert_metrics(output, metric_name, help_text, increments) def validate_metrics(self, metric_name, help_text, increments): """ @@ -190,3 +199,36 @@ def test_plaintext_encoding(self): content_type = self.get_response_header_value('Content-Type').split(";")[0] assert content_type == "text/plain" + + def test_qs_parsing(self): + """Only metrics that match the 'name[]' query string param appear""" + + app = make_asgi_app(self.registry) + metrics = [ + ("asdf", "first test metric", 1), + ("bsdf", "second test metric", 2) + ] + + for m in metrics: + self.increment_metrics(*m) + + for i_1 in range(len(metrics)): + self.seed_app(app) + self.scope['query_string'] = f"name[]={metrics[i_1][0]}_total".encode("utf-8") + self.send_default_request() + + outputs = self.get_all_output() + response_body = outputs[1] + output = response_body['body'].decode('utf8') + + self.assert_metrics(output, *metrics[i_1]) + + for i_2 in range(len(metrics)): + if i_1 == i_2: + continue + + self.assert_not_metrics(output, *metrics[i_2]) + + asyncio.get_event_loop().run_until_complete( + self.communicator.wait() + ) From 3586355e648f1d8a058cdb711bc2ce920ce58ca4 Mon Sep 17 00:00:00 2001 From: Arianna Vespri <36129782+vesari@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:30:41 +0200 Subject: [PATCH 03/30] Emit native histograms only when OM 2.0.0 is requested (#1128) * Emit NH only if OM 2.0.0 is requested Signed-off-by: Arianna Vespri * Adjust logic, add version comparison tests for NH Signed-off-by: Arianna Vespri * Skip nh sample earlier in the logic Signed-off-by: Arianna Vespri --------- Signed-off-by: Arianna Vespri --- prometheus_client/exposition.py | 6 +- prometheus_client/openmetrics/exposition.py | 12 ++- tests/openmetrics/test_exposition.py | 88 +++++++++++++++------ 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 100e8e2b..93285804 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -345,10 +345,10 @@ def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], by # Only return an escaping header if we have a good version and # mimetype. if not version: - return (partial(openmetrics.generate_latest, escaping=openmetrics.UNDERSCORES), openmetrics.CONTENT_TYPE_LATEST) + return (partial(openmetrics.generate_latest, escaping=openmetrics.UNDERSCORES, version="1.0.0"), openmetrics.CONTENT_TYPE_LATEST) if version and Version(version) >= Version('1.0.0'): - return (partial(openmetrics.generate_latest, escaping=escaping), - openmetrics.CONTENT_TYPE_LATEST + '; escaping=' + str(escaping)) + return (partial(openmetrics.generate_latest, escaping=escaping, version=version), + f'application/openmetrics-text; version={version}; charset=utf-8; escaping=' + str(escaping)) elif accepted.split(';')[0].strip() == 'text/plain': toks = accepted.split(';') version = _get_version(toks) diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index e4178392..bc24c7cf 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -4,13 +4,17 @@ from sys import maxunicode from typing import Callable +from packaging.version import Version + from ..utils import floatToGoString from ..validation import ( _is_valid_legacy_labelname, _is_valid_legacy_metric_name, ) CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-8' -"""Content type of the latest OpenMetrics text format""" +"""Content type of the latest OpenMetrics 1.0 text format""" +CONTENT_TYPE_LATEST_2_0 = 'application/openmetrics-text; version=2.0.0; charset=utf-8' +"""Content type of the OpenMetrics 2.0 text format""" ESCAPING_HEADER_TAG = 'escaping' @@ -53,7 +57,7 @@ def _compose_exemplar_string(metric, sample, exemplar): return exemplarstr -def generate_latest(registry, escaping=UNDERSCORES): +def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): '''Returns the metrics from the registry in latest text format as a string.''' output = [] for metric in registry.collect(): @@ -89,6 +93,10 @@ def generate_latest(registry, escaping=UNDERSCORES): if s.timestamp is not None: timestamp = f' {s.timestamp}' + # Skip native histogram samples entirely if version < 2.0.0 + if s.native_histogram and Version(version) < Version('2.0.0'): + continue + native_histogram = '' negative_spans = '' negative_deltas = '' diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index b972cadc..6c879ec4 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -83,7 +83,7 @@ def test_summary(self) -> None: ss_sum{a="c",b="d"} 17.0 ss_created{a="c",b="d"} 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_histogram(self) -> None: s = Histogram('hh', 'A histogram', registry=self.registry) @@ -109,7 +109,7 @@ def test_histogram(self) -> None: hh_sum 0.05 hh_created 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_native_histogram(self) -> None: @@ -120,7 +120,7 @@ def test_native_histogram(self) -> None: # TYPE nh histogram nh {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_nh_histogram_with_exemplars(self) -> None: hfm = HistogramMetricFamily("nh", "nh") @@ -130,7 +130,7 @@ def test_nh_histogram_with_exemplars(self) -> None: # TYPE nh histogram nh {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # {trace_id="KOO5S4vxi0o"} 0.67 # {trace_id="oHg5SJYRHA0"} 9.8 1520879607.789 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_nh_no_observation(self) -> None: hfm = HistogramMetricFamily("nhnoobs", "nhnoobs") @@ -140,7 +140,7 @@ def test_nh_no_observation(self) -> None: # TYPE nhnoobs histogram nhnoobs {count:0,sum:0,schema:3,zero_threshold:2.938735877055719e-39,zero_count:0} # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_nh_longer_spans(self) -> None: @@ -151,7 +151,7 @@ def test_nh_longer_spans(self) -> None: # TYPE nhsp histogram nhsp {count:4,sum:6,schema:3,zero_threshold:2.938735877055719e-39,zero_count:1,positive_spans:[0:1,7:1,4:1],positive_deltas:[1,0,0]} # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_native_histogram_utf8(self) -> None: hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram") @@ -161,7 +161,7 @@ def test_native_histogram_utf8(self) -> None: # TYPE "native{histogram" histogram {"native{histogram"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF -""", generate_latest(self.registry, ALLOWUTF8)) +""", generate_latest(self.registry, ALLOWUTF8, version="2.0.0")) def test_native_histogram_utf8_stress(self) -> None: hfm = HistogramMetricFamily("native{histogram", "Is a basic example of a native histogram") @@ -171,7 +171,7 @@ def test_native_histogram_utf8_stress(self) -> None: # TYPE "native{histogram" histogram {"native{histogram", "xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF -""", generate_latest(self.registry, ALLOWUTF8)) +""", generate_latest(self.registry, ALLOWUTF8, version="2.0.0")) def test_native_histogram_with_labels(self) -> None: hfm = HistogramMetricFamily("hist_w_labels", "Is a basic example of a native histogram with labels") @@ -181,7 +181,7 @@ def test_native_histogram_with_labels(self) -> None: # TYPE hist_w_labels histogram hist_w_labels{baz="qux",foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_native_histogram_with_labels_utf8(self) -> None: hfm = HistogramMetricFamily("hist.w.labels", "Is a basic example of a native histogram with labels") @@ -191,7 +191,7 @@ def test_native_histogram_with_labels_utf8(self) -> None: # TYPE "hist.w.labels" histogram {"hist.w.labels", baz="qux",foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF -""", generate_latest(self.registry, ALLOWUTF8)) +""", generate_latest(self.registry, ALLOWUTF8, version="2.0.0")) def test_native_histogram_with_classic_histogram(self) -> None: hfm = HistogramMetricFamily("hist_w_classic", "Is a basic example of a native histogram coexisting with a classic histogram") @@ -209,7 +209,7 @@ def test_native_histogram_with_classic_histogram(self) -> None: hist_w_classic_count{foo="bar"} 24.0 hist_w_classic_sum{foo="bar"} 100.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_native_plus_classic_histogram_two_labelsets(self) -> None: hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets") @@ -237,7 +237,33 @@ def test_native_plus_classic_histogram_two_labelsets(self) -> None: hist_w_classic_two_sets_count{foo="baz"} 24.0 hist_w_classic_two_sets_sum{foo="baz"} 100.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) + + def test_native_plus_classic_histogram_two_labelsets_OM_1(self) -> None: + hfm = HistogramMetricFamily("hist_w_classic_two_sets", "Is an example of a native histogram plus a classic histogram with two label sets in OM 1.0.0") + hfm.add_sample("hist_w_classic_two_sets", {"foo": "bar"}, 0, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "bar", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "bar"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "bar"}, 100.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets", {"foo": "baz"}, 0, None, None, NativeHistogram(24, 100, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "0.001"}, 4.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_bucket", {"foo": "baz", "le": "+Inf"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_count", {"foo": "baz"}, 24.0, None, None, None) + hfm.add_sample("hist_w_classic_two_sets_sum", {"foo": "baz"}, 100.0, None, None, None) + self.custom_collector(hfm) + self.assertEqual(b"""# HELP hist_w_classic_two_sets Is an example of a native histogram plus a classic histogram with two label sets in OM 1.0.0 +# TYPE hist_w_classic_two_sets histogram +hist_w_classic_two_sets_bucket{foo="bar",le="0.001"} 4.0 +hist_w_classic_two_sets_bucket{foo="bar",le="+Inf"} 24.0 +hist_w_classic_two_sets_count{foo="bar"} 24.0 +hist_w_classic_two_sets_sum{foo="bar"} 100.0 +hist_w_classic_two_sets_bucket{foo="baz",le="0.001"} 4.0 +hist_w_classic_two_sets_bucket{foo="baz",le="+Inf"} 24.0 +hist_w_classic_two_sets_count{foo="baz"} 24.0 +hist_w_classic_two_sets_sum{foo="baz"} 100.0 +# EOF +""", generate_latest(self.registry, version="1.0.0")) def test_histogram_negative_buckets(self) -> None: s = Histogram('hh', 'A histogram', buckets=[-1, -0.5, 0, 0.5, 1], registry=self.registry) @@ -253,7 +279,7 @@ def test_histogram_negative_buckets(self) -> None: hh_count 1.0 hh_created 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_histogram_exemplar(self) -> None: s = Histogram('hh', 'A histogram', buckets=[1, 2, 3, 4], registry=self.registry) @@ -273,7 +299,7 @@ def test_histogram_exemplar(self) -> None: hh_sum 8.0 hh_created 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_counter_exemplar(self) -> None: c = Counter('cc', 'A counter', registry=self.registry) @@ -283,7 +309,7 @@ def test_counter_exemplar(self) -> None: cc_total 1.0 # {a="b"} 1.0 123.456 cc_created 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_untyped_exemplar(self) -> None: class MyCollector: @@ -331,7 +357,7 @@ def test_gaugehistogram(self) -> None: gh_gcount 5.0 gh_gsum 7.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_gaugehistogram_negative_buckets(self) -> None: self.custom_collector( @@ -343,7 +369,7 @@ def test_gaugehistogram_negative_buckets(self) -> None: gh_gcount 5.0 gh_gsum -7.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) def test_info(self) -> None: i = Info('ii', 'A info', ['a', 'b'], registry=self.registry) @@ -352,7 +378,7 @@ def test_info(self) -> None: # TYPE ii info ii_info{a="c",b="d",foo="bar"} 1.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_enum(self) -> None: i = Enum('ee', 'An enum', ['a', 'b'], registry=self.registry, states=['foo', 'bar']) @@ -362,7 +388,7 @@ def test_enum(self) -> None: ee{a="c",b="d",ee="foo"} 0.0 ee{a="c",b="d",ee="bar"} 1.0 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_unicode(self) -> None: c = Counter('cc', '\u4500', ['l'], registry=self.registry) @@ -372,7 +398,7 @@ def test_unicode(self) -> None: cc_total{l="\xe4\x94\x80"} 1.0 cc_created{l="\xe4\x94\x80"} 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_escaping(self) -> None: c = Counter('cc', 'A\ncount\\er\"', ['a'], registry=self.registry) @@ -382,7 +408,7 @@ def test_escaping(self) -> None: cc_total{a="\\\\x\\n\\""} 1.0 cc_created{a="\\\\x\\n\\""} 123.456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="2.0.0")) def test_nonnumber(self) -> None: class MyNumber: @@ -424,7 +450,25 @@ def collect(self): ts{foo="e"} 0.0 123.000456000 ts{foo="f"} 0.0 123.000000456 # EOF -""", generate_latest(self.registry)) +""", generate_latest(self.registry, version="1.0.0")) + + def test_native_histogram_version_comparison(self) -> None: + hfm = HistogramMetricFamily("nh_version", "nh version test") + hfm.add_sample("nh_version", {}, 0, None, None, NativeHistogram(5, 10, 0, 0.01, 2, (BucketSpan(0, 1),), (BucketSpan(0, 1),), (3,), (4,))) + self.custom_collector(hfm) + + # Version 1.0.0 should omit native histogram samples entirely + self.assertEqual(b"""# HELP nh_version nh version test +# TYPE nh_version histogram +# EOF +""", generate_latest(self.registry, version="1.0.0")) + + # Version 2.0.0 should emit native histogram format + self.assertEqual(b"""# HELP nh_version nh version test +# TYPE nh_version histogram +nh_version {count:5,sum:10,schema:0,zero_threshold:0.01,zero_count:2,negative_spans:[0:1],negative_deltas:[4],positive_spans:[0:1],positive_deltas:[3]} +# EOF +""", generate_latest(self.registry, version="2.0.0")) @pytest.mark.parametrize("scenario", [ From 4de31eee009a527ba7a5cda76a4aef403df7ab0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20S=C3=BC=C3=9F?= Date: Mon, 8 Sep 2025 16:35:19 +0200 Subject: [PATCH 04/30] fix: remove space after comma in openmetrics exposition (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenMetrics doesn't allow for spaces between labels and prometheus fails with a parsing error. Removing this fixes UTF8 metrics exposition Signed-off-by: Dominik Süß --- prometheus_client/openmetrics/exposition.py | 2 +- tests/openmetrics/test_exposition.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index bc24c7cf..1dc05c5b 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -72,7 +72,7 @@ def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): if escaping == ALLOWUTF8 and not _is_valid_legacy_metric_name(s.name): labelstr = escape_metric_name(s.name, escaping) if s.labels: - labelstr += ', ' + labelstr += ',' else: labelstr = '' diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index 6c879ec4..a3ed0d6e 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -169,7 +169,7 @@ def test_native_histogram_utf8_stress(self) -> None: self.custom_collector(hfm) self.assertEqual(b"""# HELP "native{histogram" Is a basic example of a native histogram # TYPE "native{histogram" histogram -{"native{histogram", "xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} +{"native{histogram","xx{} # {}"=" EOF # {}}}"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF """, generate_latest(self.registry, ALLOWUTF8, version="2.0.0")) @@ -189,7 +189,7 @@ def test_native_histogram_with_labels_utf8(self) -> None: self.custom_collector(hfm) self.assertEqual(b"""# HELP "hist.w.labels" Is a basic example of a native histogram with labels # TYPE "hist.w.labels" histogram -{"hist.w.labels", baz="qux",foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} +{"hist.w.labels",baz="qux",foo="bar"} {count:24,sum:100,schema:0,zero_threshold:0.001,zero_count:4,negative_spans:[0:2,1:2],negative_deltas:[2,1,-2,3],positive_spans:[0:2,1:2],positive_deltas:[2,1,-3,3]} # EOF """, generate_latest(self.registry, ALLOWUTF8, version="2.0.0")) From 47d2b416d75f5569863e2bb08a15b58218563814 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Mon, 8 Sep 2025 11:23:33 -0600 Subject: [PATCH 05/30] Do not use global when only reading variable (#1133) The global keyword is only necessary when writing to a variable, this will fix the lint failures that came with a newer version of flake8. Signed-off-by: Chris Marchbanks --- prometheus_client/validation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/prometheus_client/validation.py b/prometheus_client/validation.py index 7ada5d81..6fcc8018 100644 --- a/prometheus_client/validation.py +++ b/prometheus_client/validation.py @@ -16,7 +16,6 @@ def _init_legacy_validation() -> bool: def get_legacy_validation() -> bool: """Return the current status of the legacy validation setting.""" - global _legacy_validation return _legacy_validation @@ -39,7 +38,6 @@ def _validate_metric_name(name: str) -> None: """ if not name: raise ValueError("metric name cannot be empty") - global _legacy_validation if _legacy_validation: if not METRIC_NAME_RE.match(name): raise ValueError("invalid metric name " + name) @@ -63,7 +61,6 @@ def _validate_metric_label_name_token(tok: str) -> None: """ if not tok: raise ValueError("invalid label name token " + tok) - global _legacy_validation quoted = tok[0] == '"' and tok[-1] == '"' if not quoted or _legacy_validation: if not METRIC_LABEL_NAME_RE.match(tok): From b3fbbca891a6c6d07b83a3680919956a3c3ab523 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 11 Sep 2025 13:45:57 -0600 Subject: [PATCH 06/30] Fix issue parsing double spaces after # HELP/# TYPE (#1134) A regression was reported for 0.22.x where if there are two spaces after HELP or TEXT we would raise a value error. This was caused by having an extra entry in our array of splits due to how we preocess the splits. Signed-off-by: Chris Marchbanks --- prometheus_client/parser.py | 6 ++++++ tests/test_parser.py | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/prometheus_client/parser.py b/prometheus_client/parser.py index ec71b2ab..ceca273b 100644 --- a/prometheus_client/parser.py +++ b/prometheus_client/parser.py @@ -186,6 +186,12 @@ def _split_quoted(text, separator, maxsplit=0): tokens[-1] = text[x:] x = len(text) continue + # If the first character is the separator keep going. This happens when + # there are double whitespace characters separating symbols. + if split_pos == x: + x += 1 + continue + if maxsplit > 0 and len(tokens) > maxsplit: tokens[-1] = text[x:] break diff --git a/tests/test_parser.py b/tests/test_parser.py index c8b17fa1..49c4dc8c 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -121,7 +121,6 @@ def test_blank_lines_and_comments(self): """) self.assertEqualMetrics([CounterMetricFamily("a", "help", value=1)], list(families)) - def test_comments_parts_are_not_validated_against_legacy_metric_name(self): # https://github.com/prometheus/client_python/issues/1108 families = text_string_to_metric_families(""" @@ -130,7 +129,12 @@ def test_comments_parts_are_not_validated_against_legacy_metric_name(self): """) self.assertEqualMetrics([], list(families)) - + def test_extra_whitespace(self): + families = text_string_to_metric_families("""# TYPE a counter +# HELP a help +a 1 +""") + self.assertEqualMetrics([CounterMetricFamily("a", "help", value=1)], list(families)) def test_tabs(self): families = text_string_to_metric_families("""#\tTYPE\ta\tcounter From b9e78a3f701fd442f57db23701c2021a529a84c3 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 11 Sep 2025 13:48:45 -0600 Subject: [PATCH 07/30] Release 0.23.0 Signed-off-by: Chris Marchbanks --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0c762505..af4c7f2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.22.1" +version = "0.23.0" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From f9471403a82de6af93feeac2d38938ca1c384b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Fri, 12 Sep 2025 21:36:41 +0200 Subject: [PATCH 08/30] fix: Use `asyncio.new_event_loop()` to create event loop for tests (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Use `asyncio.new_event_loop()` to create event loop for tests Replace the use of `asyncio.get_event_loop()` with more appropriate `asyncio.new_event_loop()` to create event loops for testing. The former used to be a wrapper that either returned the currently running event loop or created a new one, but the latter behavior was deprecated and removed in Python 3.14. Since the tests are always run in a synchronous context, and they always run the obtained event loop to completion, just always create a new event loop. Fixes #1137 Signed-off-by: Michał Górny * fix: Remove obsolete asgiref pin Remove the `asgiref` pin linked to #1020. I can't reproduce the issue anymore with the current `asgiref` versions, and the pin actually breaks the tests with the `asyncio` event loop fixes. Signed-off-by: Michał Górny --------- Signed-off-by: Michał Górny --- tests/test_asgi.py | 8 ++++---- tox.ini | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 386ff598..86431d21 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -45,7 +45,7 @@ def setUp(self): def tearDown(self): if self.communicator: - asyncio.get_event_loop().run_until_complete( + asyncio.new_event_loop().run_until_complete( self.communicator.wait() ) @@ -53,7 +53,7 @@ def seed_app(self, app): self.communicator = ApplicationCommunicator(app, self.scope) def send_input(self, payload): - asyncio.get_event_loop().run_until_complete( + asyncio.new_event_loop().run_until_complete( self.communicator.send_input(payload) ) @@ -61,7 +61,7 @@ def send_default_request(self): self.send_input({"type": "http.request", "body": b""}) def get_output(self): - output = asyncio.get_event_loop().run_until_complete( + output = asyncio.new_event_loop().run_until_complete( self.communicator.receive_output(0) ) return output @@ -229,6 +229,6 @@ def test_qs_parsing(self): self.assert_not_metrics(output, *metrics[i_2]) - asyncio.get_event_loop().run_until_complete( + asyncio.new_event_loop().run_until_complete( self.communicator.wait() ) diff --git a/tox.ini b/tox.ini index bef57f85..40337027 100644 --- a/tox.ini +++ b/tox.ini @@ -8,9 +8,6 @@ deps = pytest-benchmark attrs {py3.9,pypy3.9}: twisted - # NOTE: Pinned due to https://github.com/prometheus/client_python/issues/1020 - py3.9: asgiref==3.7 - pypy3.9: asgiref==3.7 commands = coverage run --parallel -m pytest {posargs} [testenv:py3.9-nooptionals] From 266beb2567e0040a5790836c32de5a643d5177e4 Mon Sep 17 00:00:00 2001 From: Ruslan Kuprieiev Date: Thu, 18 Sep 2025 23:09:55 +0300 Subject: [PATCH 09/30] fix: use tuples instead of packaging Version (#1136) Signed-off-by: Ruslan Kuprieiev --- prometheus_client/exposition.py | 8 +++----- prometheus_client/openmetrics/exposition.py | 6 ++---- prometheus_client/utils.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 93285804..0d471707 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -18,11 +18,9 @@ ) from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer -from packaging.version import Version - from .openmetrics import exposition as openmetrics from .registry import CollectorRegistry, REGISTRY -from .utils import floatToGoString +from .utils import floatToGoString, parse_version __all__ = ( 'CONTENT_TYPE_LATEST', @@ -346,7 +344,7 @@ def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], by # mimetype. if not version: return (partial(openmetrics.generate_latest, escaping=openmetrics.UNDERSCORES, version="1.0.0"), openmetrics.CONTENT_TYPE_LATEST) - if version and Version(version) >= Version('1.0.0'): + if version and parse_version(version) >= (1, 0, 0): return (partial(openmetrics.generate_latest, escaping=escaping, version=version), f'application/openmetrics-text; version={version}; charset=utf-8; escaping=' + str(escaping)) elif accepted.split(';')[0].strip() == 'text/plain': @@ -355,7 +353,7 @@ def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], by escaping = _get_escaping(toks) # Only return an escaping header if we have a good version and # mimetype. - if version and Version(version) >= Version('1.0.0'): + if version and parse_version(version) >= (1, 0, 0): return (partial(generate_latest, escaping=escaping), CONTENT_TYPE_LATEST + '; escaping=' + str(escaping)) return generate_latest, CONTENT_TYPE_PLAIN_0_0_4 diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 1dc05c5b..5e69e463 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -4,9 +4,7 @@ from sys import maxunicode from typing import Callable -from packaging.version import Version - -from ..utils import floatToGoString +from ..utils import floatToGoString, parse_version from ..validation import ( _is_valid_legacy_labelname, _is_valid_legacy_metric_name, ) @@ -94,7 +92,7 @@ def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"): timestamp = f' {s.timestamp}' # Skip native histogram samples entirely if version < 2.0.0 - if s.native_histogram and Version(version) < Version('2.0.0'): + if s.native_histogram and parse_version(version) < (2, 0, 0): continue native_histogram = '' diff --git a/prometheus_client/utils.py b/prometheus_client/utils.py index 0d2b0948..87b75ca8 100644 --- a/prometheus_client/utils.py +++ b/prometheus_client/utils.py @@ -1,4 +1,5 @@ import math +from typing import Union INF = float("inf") MINUS_INF = float("-inf") @@ -22,3 +23,14 @@ def floatToGoString(d): mantissa = f'{s[0]}.{s[1:dot]}{s[dot + 1:]}'.rstrip('0.') return f'{mantissa}e+0{dot - 1}' return s + + +def parse_version(version_str: str) -> tuple[Union[int, str], ...]: + version: list[Union[int, str]] = [] + for part in version_str.split('.'): + try: + version.append(int(part)) + except ValueError: + version.append(part) + + return tuple(version) From 8746c49a76a7929795fab7b593b1c44dc8c972d2 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 18 Sep 2025 14:45:49 -0600 Subject: [PATCH 10/30] Release 0.23.1 Signed-off-by: Chris Marchbanks --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index af4c7f2f..86988592 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.23.0" +version = "0.23.1" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From 10db862c7a300e98e4529e26d969d2afd40d6811 Mon Sep 17 00:00:00 2001 From: Lexi Robinson Date: Fri, 19 Sep 2025 18:43:45 +0100 Subject: [PATCH 11/30] Add an AIOHTTP exporter (#1139) * Always run the asgi tests Since the client now requires a minimum of Python 3.9, we don't need to have this feature gate in place any more Signed-off-by: Lexi Robinson * Add an AIOHTTP exporter Unfortunately the AIOHTTP library doesn't support ASGI and apparently has no plans to do so which makes the ASGI exporter not suitable for anyone using it to run their python server. Where possible this commit follows the existing ASGI implementation and runs the same tests for consistency. Signed-off-by: Lexi Robinson --------- Signed-off-by: Lexi Robinson --- docs/content/exporting/http/aiohttp.md | 23 +++ prometheus_client/aiohttp/__init__.py | 5 + prometheus_client/aiohttp/exposition.py | 39 +++++ pyproject.toml | 3 + tests/test_aiohttp.py | 192 ++++++++++++++++++++++++ tests/test_asgi.py | 19 +-- tox.ini | 3 + 7 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 docs/content/exporting/http/aiohttp.md create mode 100644 prometheus_client/aiohttp/__init__.py create mode 100644 prometheus_client/aiohttp/exposition.py create mode 100644 tests/test_aiohttp.py diff --git a/docs/content/exporting/http/aiohttp.md b/docs/content/exporting/http/aiohttp.md new file mode 100644 index 00000000..726b92cb --- /dev/null +++ b/docs/content/exporting/http/aiohttp.md @@ -0,0 +1,23 @@ +--- +title: AIOHTTP +weight: 6 +--- + +To use Prometheus with a [AIOHTTP server](https://docs.aiohttp.org/en/stable/web.html), +there is `make_aiohttp_handler` which creates a handler. + +```python +from aiohttp import web +from prometheus_client.aiohttp import make_aiohttp_handler + +app = web.Application() +app.router.add_get("/metrics", make_aiohttp_handler()) +``` + +By default, this handler will instruct AIOHTTP to automatically compress the +response if requested by the client. This behaviour can be disabled by passing +`disable_compression=True` when creating the app, like this: + +```python +app.router.add_get("/metrics", make_aiohttp_handler(disable_compression=True)) +``` diff --git a/prometheus_client/aiohttp/__init__.py b/prometheus_client/aiohttp/__init__.py new file mode 100644 index 00000000..9e5da157 --- /dev/null +++ b/prometheus_client/aiohttp/__init__.py @@ -0,0 +1,5 @@ +from .exposition import make_aiohttp_handler + +__all__ = [ + "make_aiohttp_handler", +] diff --git a/prometheus_client/aiohttp/exposition.py b/prometheus_client/aiohttp/exposition.py new file mode 100644 index 00000000..914fb26f --- /dev/null +++ b/prometheus_client/aiohttp/exposition.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from aiohttp import hdrs, web +from aiohttp.typedefs import Handler + +from ..exposition import _bake_output +from ..registry import CollectorRegistry, REGISTRY + + +def make_aiohttp_handler( + registry: CollectorRegistry = REGISTRY, + disable_compression: bool = False, +) -> Handler: + """Create a aiohttp handler which serves the metrics from a registry.""" + + async def prometheus_handler(request: web.Request) -> web.Response: + # Prepare parameters + params = {key: request.query.getall(key) for key in request.query.keys()} + accept_header = ",".join(request.headers.getall(hdrs.ACCEPT, [])) + accept_encoding_header = "" + # Bake output + status, headers, output = _bake_output( + registry, + accept_header, + accept_encoding_header, + params, + # use AIOHTTP's compression + disable_compression=True, + ) + response = web.Response( + status=int(status.split(" ")[0]), + headers=headers, + body=output, + ) + if not disable_compression: + response.enable_compression() + return response + + return prometheus_handler diff --git a/pyproject.toml b/pyproject.toml index 86988592..2078c314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ classifiers = [ twisted = [ "twisted", ] +aiohttp = [ + "aiohttp", +] [project.urls] Homepage = "https://github.com/prometheus/client_python" diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py new file mode 100644 index 00000000..e4fa368b --- /dev/null +++ b/tests/test_aiohttp.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import gzip +from typing import TYPE_CHECKING +from unittest import skipUnless + +from prometheus_client import CollectorRegistry, Counter +from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4 + +try: + from aiohttp import ClientResponse, hdrs, web + from aiohttp.test_utils import AioHTTPTestCase + + from prometheus_client.aiohttp import make_aiohttp_handler + + AIOHTTP_INSTALLED = True +except ImportError: + if TYPE_CHECKING: + assert False + + from unittest import IsolatedAsyncioTestCase as AioHTTPTestCase + + AIOHTTP_INSTALLED = False + + +class AioHTTPTest(AioHTTPTestCase): + @skipUnless(AIOHTTP_INSTALLED, "AIOHTTP is not installed") + def setUp(self) -> None: + self.registry = CollectorRegistry() + + async def get_application(self) -> web.Application: + app = web.Application() + # The AioHTTPTestCase requires that applications be static, so we need + # both versions to be available so the test can choose between them + app.router.add_get("/metrics", make_aiohttp_handler(self.registry)) + app.router.add_get( + "/metrics_uncompressed", + make_aiohttp_handler(self.registry, disable_compression=True), + ) + return app + + def increment_metrics( + self, + metric_name: str, + help_text: str, + increments: int, + ) -> None: + c = Counter(metric_name, help_text, registry=self.registry) + for _ in range(increments): + c.inc() + + def assert_metrics( + self, + output: str, + metric_name: str, + help_text: str, + increments: int, + ) -> None: + self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) + + def assert_not_metrics( + self, + output: str, + metric_name: str, + help_text: str, + increments: int, + ) -> None: + self.assertNotIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertNotIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertNotIn(metric_name + "_total " + str(increments) + ".0\n", output) + + async def assert_outputs( + self, + response: ClientResponse, + metric_name: str, + help_text: str, + increments: int, + ) -> None: + self.assertIn( + CONTENT_TYPE_PLAIN_0_0_4, + response.headers.getall(hdrs.CONTENT_TYPE), + ) + output = await response.text() + self.assert_metrics(output, metric_name, help_text, increments) + + async def validate_metrics( + self, + metric_name: str, + help_text: str, + increments: int, + ) -> None: + """ + AIOHTTP handler serves the metrics from the provided registry. + """ + self.increment_metrics(metric_name, help_text, increments) + async with self.client.get("/metrics") as response: + response.raise_for_status() + await self.assert_outputs(response, metric_name, help_text, increments) + + async def test_report_metrics_1(self): + await self.validate_metrics("counter", "A counter", 2) + + async def test_report_metrics_2(self): + await self.validate_metrics("counter", "Another counter", 3) + + async def test_report_metrics_3(self): + await self.validate_metrics("requests", "Number of requests", 5) + + async def test_report_metrics_4(self): + await self.validate_metrics("failed_requests", "Number of failed requests", 7) + + async def test_gzip(self): + # Increment a metric. + metric_name = "counter" + help_text = "A counter" + increments = 2 + self.increment_metrics(metric_name, help_text, increments) + + async with self.client.get( + "/metrics", + auto_decompress=False, + headers={hdrs.ACCEPT_ENCODING: "gzip"}, + ) as response: + response.raise_for_status() + self.assertIn(hdrs.CONTENT_ENCODING, response.headers) + self.assertIn("gzip", response.headers.getall(hdrs.CONTENT_ENCODING)) + body = await response.read() + output = gzip.decompress(body).decode("utf8") + self.assert_metrics(output, metric_name, help_text, increments) + + async def test_gzip_disabled(self): + # Increment a metric. + metric_name = "counter" + help_text = "A counter" + increments = 2 + self.increment_metrics(metric_name, help_text, increments) + + async with self.client.get( + "/metrics_uncompressed", + auto_decompress=False, + headers={hdrs.ACCEPT_ENCODING: "gzip"}, + ) as response: + response.raise_for_status() + self.assertNotIn(hdrs.CONTENT_ENCODING, response.headers) + output = await response.text() + self.assert_metrics(output, metric_name, help_text, increments) + + async def test_openmetrics_encoding(self): + """Response content type is application/openmetrics-text when appropriate Accept header is in request""" + async with self.client.get( + "/metrics", + auto_decompress=False, + headers={hdrs.ACCEPT: "application/openmetrics-text; version=1.0.0"}, + ) as response: + response.raise_for_status() + self.assertEqual( + response.headers.getone(hdrs.CONTENT_TYPE).split(";", maxsplit=1)[0], + "application/openmetrics-text", + ) + + async def test_plaintext_encoding(self): + """Response content type is text/plain when Accept header is missing in request""" + async with self.client.get("/metrics") as response: + response.raise_for_status() + self.assertEqual( + response.headers.getone(hdrs.CONTENT_TYPE).split(";", maxsplit=1)[0], + "text/plain", + ) + + async def test_qs_parsing(self): + """Only metrics that match the 'name[]' query string param appear""" + + metrics = [("asdf", "first test metric", 1), ("bsdf", "second test metric", 2)] + + for m in metrics: + self.increment_metrics(*m) + + for i_1 in range(len(metrics)): + async with self.client.get( + "/metrics", + params={"name[]": f"{metrics[i_1][0]}_total"}, + ) as response: + output = await response.text() + self.assert_metrics(output, *metrics[i_1]) + + for i_2 in range(len(metrics)): + if i_1 == i_2: + continue + + self.assert_not_metrics(output, *metrics[i_2]) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 86431d21..d4933cec 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -1,19 +1,11 @@ +import asyncio import gzip -from unittest import skipUnless, TestCase +from unittest import TestCase -from prometheus_client import CollectorRegistry, Counter -from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4 - -try: - # Python >3.5 only - import asyncio +from asgiref.testing import ApplicationCommunicator - from asgiref.testing import ApplicationCommunicator - - from prometheus_client import make_asgi_app - HAVE_ASYNCIO_AND_ASGI = True -except ImportError: - HAVE_ASYNCIO_AND_ASGI = False +from prometheus_client import CollectorRegistry, Counter, make_asgi_app +from prometheus_client.exposition import CONTENT_TYPE_PLAIN_0_0_4 def setup_testing_defaults(scope): @@ -33,7 +25,6 @@ def setup_testing_defaults(scope): class ASGITest(TestCase): - @skipUnless(HAVE_ASYNCIO_AND_ASGI, "Don't have asyncio/asgi installed.") def setUp(self): self.registry = CollectorRegistry() self.captured_status = None diff --git a/tox.ini b/tox.ini index 40337027..2c9873ec 100644 --- a/tox.ini +++ b/tox.ini @@ -3,11 +3,13 @@ envlist = coverage-clean,py{3.9,3.10,3.11,3.12,3.13,py3.9,3.9-nooptionals},cover [testenv] deps = + asgiref coverage pytest pytest-benchmark attrs {py3.9,pypy3.9}: twisted + {py3.9,pypy3.9}: aiohttp commands = coverage run --parallel -m pytest {posargs} [testenv:py3.9-nooptionals] @@ -44,6 +46,7 @@ commands = [testenv:mypy] deps = pytest + aiohttp asgiref mypy==0.991 skip_install = true From 378510b8ae91d23383cd1c7e0be180b374a1c84c Mon Sep 17 00:00:00 2001 From: Hazel Shen Date: Tue, 28 Oct 2025 23:56:43 +0800 Subject: [PATCH 12/30] Add remove_matching() method for metric label deletion (#1121) * Add remove_matching() method for metric label deletion Signed-off-by: Hazel * Rename function name, and the parameter's name Signed-off-by: Hazel * Make remove_by_labels() consistent with remove(): return None Signed-off-by: Hazel --------- Signed-off-by: Hazel Co-authored-by: Hazel --- prometheus_client/metrics.py | 33 +++++++++++++++++++++++++++++++++ tests/test_core.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index b9f25ffc..39daac2d 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -203,6 +203,39 @@ def remove(self, *labelvalues: Any) -> None: if labelvalues in self._metrics: del self._metrics[labelvalues] + def remove_by_labels(self, labels: dict[str, str]) -> None: + """Remove all series whose labelset partially matches the given labels.""" + if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: + warnings.warn( + "Removal of labels has not been implemented in multi-process mode yet.", + UserWarning + ) + + if not self._labelnames: + raise ValueError('No label names were set when constructing %s' % self) + + if not isinstance(labels, dict): + raise TypeError("labels must be a dict of {label_name: label_value}") + + if not labels: + return # no operation + + invalid = [k for k in labels.keys() if k not in self._labelnames] + if invalid: + raise ValueError( + 'Unknown label names: %s; expected %s' % (invalid, self._labelnames) + ) + + pos_filter = {self._labelnames.index(k): str(v) for k, v in labels.items()} + + with self._lock: + # list(...) to avoid "dictionary changed size during iteration" + for lv in list(self._metrics.keys()): + if all(lv[pos] == want for pos, want in pos_filter.items()): + # pop with default avoids KeyError if concurrently removed + self._metrics.pop(lv, None) + + def clear(self) -> None: """Remove all labelsets from the metric""" if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: diff --git a/tests/test_core.py b/tests/test_core.py index 284bce09..c7c9c14f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -630,6 +630,40 @@ def test_labels_coerced_to_string(self): self.counter.remove(None) self.assertEqual(None, self.registry.get_sample_value('c_total', {'l': 'None'})) + def test_remove_by_labels(self): + from prometheus_client import Counter + + c = Counter('c2', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + c.labels('acme', '/checkout').inc() + c.labels('globex', '/').inc() + + ret = c.remove_by_labels({'tenant': 'acme'}) + self.assertIsNone(ret) + + self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/'})) + self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/checkout'})) + self.assertEqual(1, self.registry.get_sample_value('c2_total', {'tenant': 'globex', 'endpoint': '/'})) + + + def test_remove_by_labels_invalid_label_name(self): + from prometheus_client import Counter + c = Counter('c3', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + with self.assertRaises(ValueError): + c.remove_by_labels({'badkey': 'x'}) + + + def test_remove_by_labels_empty_is_noop(self): + from prometheus_client import Counter + c = Counter('c4', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + + ret = c.remove_by_labels({}) + self.assertIsNone(ret) + # Ensure the series is still present + self.assertEqual(1, self.registry.get_sample_value('c4_total', {'tenant': 'acme', 'endpoint': '/'})) + def test_non_string_labels_raises(self): class Test: __str__ = None From 1783ca87acbed1d45ebaa124b7b22244f9c9c2e8 Mon Sep 17 00:00:00 2001 From: Naoyuki Sano Date: Wed, 29 Oct 2025 00:58:27 +0900 Subject: [PATCH 13/30] Add support for Python 3.14 (#1142) * Add Python version 3.14 to CircleCI config Signed-off-by: Naoyuki Sano * Update tox.ini Signed-off-by: Naoyuki Sano * Add support for Python 3.14 in pyproject.toml Signed-off-by: Naoyuki Sano * Update pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Naoyuki Sano --------- Signed-off-by: Naoyuki Sano Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .circleci/config.yml | 1 + pyproject.toml | 1 + tox.ini | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4eaf808f..f29bd265 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,6 +80,7 @@ workflows: - "3.11" - "3.12" - "3.13" + - "3.14" - test_nooptionals: matrix: parameters: diff --git a/pyproject.toml b/pyproject.toml index 2078c314..1d8527a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: System :: Monitoring", diff --git a/tox.ini b/tox.ini index 2c9873ec..ccf95cc2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = coverage-clean,py{3.9,3.10,3.11,3.12,3.13,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy +envlist = coverage-clean,py{3.9,3.10,3.11,3.12,3.13,3.14,py3.9,3.9-nooptionals},coverage-report,flake8,isort,mypy [testenv] deps = From e8f8bae6554de11ebffffcc878ab19abd67528f2 Mon Sep 17 00:00:00 2001 From: Hazel Shen Date: Tue, 18 Nov 2025 04:52:35 +0800 Subject: [PATCH 14/30] fix(multiprocess): avoid double-building child metric names (#1035) (#1146) * fix(multiprocess): avoid double-building child metric names (#1035) Signed-off-by: hazel-shen * test: ensure child metrics retain parent namespace/subsystem/unit Signed-off-by: hazel-shen --------- Signed-off-by: hazel-shen --- prometheus_client/metrics.py | 22 ++++++- tests/test_multiprocess.py | 110 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 39daac2d..4c53b26b 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -109,6 +109,10 @@ def __init__(self: T, registry: Optional[CollectorRegistry] = REGISTRY, _labelvalues: Optional[Sequence[str]] = None, ) -> None: + + self._original_name = name + self._namespace = namespace + self._subsystem = subsystem self._name = _build_full_name(self._type, name, namespace, subsystem, unit) self._labelnames = _validate_labelnames(self, labelnames) self._labelvalues = tuple(_labelvalues or ()) @@ -176,13 +180,25 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: labelvalues = tuple(str(l) for l in labelvalues) with self._lock: if labelvalues not in self._metrics: + + original_name = getattr(self, '_original_name', self._name) + namespace = getattr(self, '_namespace', '') + subsystem = getattr(self, '_subsystem', '') + unit = getattr(self, '_unit', '') + + child_kwargs = dict(self._kwargs) if self._kwargs else {} + for k in ('namespace', 'subsystem', 'unit'): + child_kwargs.pop(k, None) + self._metrics[labelvalues] = self.__class__( - self._name, + original_name, documentation=self._documentation, labelnames=self._labelnames, - unit=self._unit, + namespace=namespace, + subsystem=subsystem, + unit=unit, _labelvalues=labelvalues, - **self._kwargs + **child_kwargs ) return self._metrics[labelvalues] diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index 77fd3d81..e7ca154e 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -396,6 +396,116 @@ def test_remove_clear_warning(self): assert "Removal of labels has not been implemented" in str(w[0].message) assert issubclass(w[-1].category, UserWarning) assert "Clearing labels has not been implemented" in str(w[-1].message) + + def test_child_name_is_built_once_with_namespace_subsystem_unit(self): + """ + Repro for #1035: + In multiprocess mode, child metrics must NOT rebuild the full name + (namespace/subsystem/unit) a second time. The exported family name should + be built once, and Counter samples should use "_total". + """ + from prometheus_client import Counter + + class CustomCounter(Counter): + def __init__( + self, + name, + documentation, + labelnames=(), + namespace="mydefaultnamespace", + subsystem="mydefaultsubsystem", + unit="", + registry=None, + _labelvalues=None + ): + # Intentionally provide non-empty defaults to trigger the bug path. + super().__init__( + name=name, + documentation=documentation, + labelnames=labelnames, + namespace=namespace, + subsystem=subsystem, + unit=unit, + registry=registry, + _labelvalues=_labelvalues) + + # Create a Counter with explicit namespace/subsystem/unit + c = CustomCounter( + name='m', + documentation='help', + labelnames=('status', 'method'), + namespace='ns', + subsystem='ss', + unit='seconds', # avoid '_total_total' confusion + registry=None, # not registered in local registry in multiprocess mode + ) + + # Create two labeled children + c.labels(status='200', method='GET').inc() + c.labels(status='404', method='POST').inc() + + # Collect from the multiprocess collector initialized in setUp() + metrics = {m.name: m for m in self.collector.collect()} + + # Family name should be built once (no '_total' in family name) + expected_family = 'ns_ss_m_seconds' + self.assertIn(expected_family, metrics, f"missing family {expected_family}") + + # Counter samples must use '_total' + mf = metrics[expected_family] + sample_names = {s.name for s in mf.samples} + self.assertTrue( + all(name == expected_family + '_total' for name in sample_names), + f"unexpected sample names: {sample_names}" + ) + + # Ensure no double-built prefix sneaks in (the original bug) + bad_prefix = 'mydefaultnamespace_mydefaultsubsystem_' + all_names = {mf.name, *sample_names} + self.assertTrue( + all(not n.startswith(bad_prefix) for n in all_names), + f"found double-built name(s): {[n for n in all_names if n.startswith(bad_prefix)]}" + ) + + def test_child_preserves_parent_context_for_subclasses(self): + """ + Ensure child metrics preserve parent's namespace/subsystem/unit information + so that subclasses can correctly use these parameters in their logic. + """ + class ContextAwareCounter(Counter): + def __init__(self, + name, + documentation, + labelnames=(), + namespace="", + subsystem="", + unit="", + **kwargs): + self.context = { + 'namespace': namespace, + 'subsystem': subsystem, + 'unit': unit + } + super().__init__(name, documentation, + labelnames=labelnames, + namespace=namespace, + subsystem=subsystem, + unit=unit, + **kwargs) + + parent = ContextAwareCounter('m', 'help', + labelnames=['status'], + namespace='prod', + subsystem='api', + unit='seconds', + registry=None) + + child = parent.labels(status='200') + + # Verify that child retains parent's context + self.assertEqual(child.context['namespace'], 'prod') + self.assertEqual(child.context['subsystem'], 'api') + self.assertEqual(child.context['unit'], 'seconds') class TestMmapedDict(unittest.TestCase): From a264ec0d85600decfb0681d00ed1566186bebfb3 Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Wed, 26 Nov 2025 19:39:29 +0000 Subject: [PATCH 15/30] Don't interleave histogram metrics in multi-process collector (#1148) The OpenMetrics exposition format requires that samples for a given Metric (i.e. metric name and label set) are not interleaved, but the way that the multi-process collector handled accumulating histogram metrics could end up interleaving them. Restructure it slightly to guarantee that all the samples for a given Metric are kept together. Fixes: #1147 Signed-off-by: Colin Watson --- prometheus_client/multiprocess.py | 51 +++++++++++++--------- tests/test_multiprocess.py | 70 +++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py index 2682190a..db55874e 100644 --- a/prometheus_client/multiprocess.py +++ b/prometheus_client/multiprocess.py @@ -88,32 +88,42 @@ def _parse_key(key): @staticmethod def _accumulate_metrics(metrics, accumulate): for metric in metrics.values(): - samples = defaultdict(float) - sample_timestamps = defaultdict(float) + samples = defaultdict(lambda: defaultdict(float)) + sample_timestamps = defaultdict(lambda: defaultdict(float)) buckets = defaultdict(lambda: defaultdict(float)) - samples_setdefault = samples.setdefault for s in metric.samples: name, labels, value, timestamp, exemplar, native_histogram_value = s + + if ( + metric.type == 'gauge' + and metric._multiprocess_mode in ( + 'min', 'livemin', + 'max', 'livemax', + 'sum', 'livesum', + 'mostrecent', 'livemostrecent', + ) + ): + labels = tuple(l for l in labels if l[0] != 'pid') + if metric.type == 'gauge': - without_pid_key = (name, tuple(l for l in labels if l[0] != 'pid')) if metric._multiprocess_mode in ('min', 'livemin'): - current = samples_setdefault(without_pid_key, value) + current = samples[labels].setdefault((name, labels), value) if value < current: - samples[without_pid_key] = value + samples[labels][(name, labels)] = value elif metric._multiprocess_mode in ('max', 'livemax'): - current = samples_setdefault(without_pid_key, value) + current = samples[labels].setdefault((name, labels), value) if value > current: - samples[without_pid_key] = value + samples[labels][(name, labels)] = value elif metric._multiprocess_mode in ('sum', 'livesum'): - samples[without_pid_key] += value + samples[labels][(name, labels)] += value elif metric._multiprocess_mode in ('mostrecent', 'livemostrecent'): - current_timestamp = sample_timestamps[without_pid_key] + current_timestamp = sample_timestamps[labels][name] timestamp = float(timestamp or 0) if current_timestamp < timestamp: - samples[without_pid_key] = value - sample_timestamps[without_pid_key] = timestamp + samples[labels][(name, labels)] = value + sample_timestamps[labels][name] = timestamp else: # all/liveall - samples[(name, labels)] = value + samples[labels][(name, labels)] = value elif metric.type == 'histogram': # A for loop with early exit is faster than a genexpr @@ -127,10 +137,10 @@ def _accumulate_metrics(metrics, accumulate): break else: # did not find the `le` key # _sum/_count - samples[(name, labels)] += value + samples[labels][(name, labels)] += value else: # Counter and Summary. - samples[(name, labels)] += value + samples[labels][(name, labels)] += value # Accumulate bucket values. if metric.type == 'histogram': @@ -143,14 +153,17 @@ def _accumulate_metrics(metrics, accumulate): ) if accumulate: acc += value - samples[sample_key] = acc + samples[labels][sample_key] = acc else: - samples[sample_key] = value + samples[labels][sample_key] = value if accumulate: - samples[(metric.name + '_count', labels)] = acc + samples[labels][(metric.name + '_count', labels)] = acc # Convert to correct sample format. - metric.samples = [Sample(name_, dict(labels), value) for (name_, labels), value in samples.items()] + metric.samples = [] + for _, samples_by_labels in samples.items(): + for (name_, labels), value in samples_by_labels.items(): + metric.samples.append(Sample(name_, dict(labels), value)) return metrics.values() def collect(self): diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index e7ca154e..c2f71d26 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -276,10 +276,8 @@ def add_label(key, value): Sample('g', add_label('pid', '1'), 1.0), ]) - metrics['h'].samples.sort( - key=lambda x: (x[0], float(x[1].get('le', 0))) - ) expected_histogram = [ + Sample('h_sum', labels, 6.0), Sample('h_bucket', add_label('le', '0.005'), 0.0), Sample('h_bucket', add_label('le', '0.01'), 0.0), Sample('h_bucket', add_label('le', '0.025'), 0.0), @@ -296,7 +294,66 @@ def add_label(key, value): Sample('h_bucket', add_label('le', '10.0'), 2.0), Sample('h_bucket', add_label('le', '+Inf'), 2.0), Sample('h_count', labels, 2.0), - Sample('h_sum', labels, 6.0), + ] + + self.assertEqual(metrics['h'].samples, expected_histogram) + + def test_collect_histogram_ordering(self): + pid = 0 + values.ValueClass = MultiProcessValue(lambda: pid) + labels = {i: i for i in 'abcd'} + + def add_label(key, value): + l = labels.copy() + l[key] = value + return l + + h = Histogram('h', 'help', labelnames=['view'], registry=None) + + h.labels(view='view1').observe(1) + + pid = 1 + + h.labels(view='view1').observe(5) + h.labels(view='view2').observe(1) + + metrics = {m.name: m for m in self.collector.collect()} + + expected_histogram = [ + Sample('h_sum', {'view': 'view1'}, 6.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.005'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.01'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.025'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.05'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.075'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.1'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.25'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.5'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.75'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '1.0'}, 1.0), + Sample('h_bucket', {'view': 'view1', 'le': '2.5'}, 1.0), + Sample('h_bucket', {'view': 'view1', 'le': '5.0'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '7.5'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '10.0'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '+Inf'}, 2.0), + Sample('h_count', {'view': 'view1'}, 2.0), + Sample('h_sum', {'view': 'view2'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.005'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.01'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.025'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.05'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.075'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.1'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.25'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.5'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.75'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '1.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '2.5'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '5.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '7.5'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '10.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '+Inf'}, 1.0), + Sample('h_count', {'view': 'view2'}, 1.0), ] self.assertEqual(metrics['h'].samples, expected_histogram) @@ -347,10 +404,8 @@ def add_label(key, value): m.name: m for m in self.collector.merge(files, accumulate=False) } - metrics['h'].samples.sort( - key=lambda x: (x[0], float(x[1].get('le', 0))) - ) expected_histogram = [ + Sample('h_sum', labels, 6.0), Sample('h_bucket', add_label('le', '0.005'), 0.0), Sample('h_bucket', add_label('le', '0.01'), 0.0), Sample('h_bucket', add_label('le', '0.025'), 0.0), @@ -366,7 +421,6 @@ def add_label(key, value): Sample('h_bucket', add_label('le', '7.5'), 0.0), Sample('h_bucket', add_label('le', '10.0'), 0.0), Sample('h_bucket', add_label('le', '+Inf'), 0.0), - Sample('h_sum', labels, 6.0), ] self.assertEqual(metrics['h'].samples, expected_histogram) From 13df12421e1ba9c621246b9084229e24fda4074e Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Wed, 26 Nov 2025 19:41:33 +0000 Subject: [PATCH 16/30] Relax registry type annotations for exposition (#1149) * Turn Collector into a Protocol We require Python >= 3.9 now, so there's no reason to avoid this any more. Signed-off-by: Colin Watson * Relax registry type annotations for exposition Anything with a suitable `collect` method will do: for instance, it's sometimes useful to be able to define a class whose `collect` method yields all metrics from a registry whose names have a given prefix, and such a class doesn't need to inherit from `CollectorRegistry`. Signed-off-by: Colin Watson --------- Signed-off-by: Colin Watson --- prometheus_client/aiohttp/exposition.py | 4 ++-- prometheus_client/asgi.py | 4 ++-- prometheus_client/bridge/graphite.py | 4 ++-- prometheus_client/exposition.py | 28 ++++++++++++------------- prometheus_client/registry.py | 13 +++++------- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/prometheus_client/aiohttp/exposition.py b/prometheus_client/aiohttp/exposition.py index 914fb26f..c1ae254d 100644 --- a/prometheus_client/aiohttp/exposition.py +++ b/prometheus_client/aiohttp/exposition.py @@ -4,11 +4,11 @@ from aiohttp.typedefs import Handler from ..exposition import _bake_output -from ..registry import CollectorRegistry, REGISTRY +from ..registry import Collector, REGISTRY def make_aiohttp_handler( - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, disable_compression: bool = False, ) -> Handler: """Create a aiohttp handler which serves the metrics from a registry.""" diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index affd9844..6e527ca9 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -2,10 +2,10 @@ from urllib.parse import parse_qs from .exposition import _bake_output -from .registry import CollectorRegistry, REGISTRY +from .registry import Collector, REGISTRY -def make_asgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: bool = False) -> Callable: +def make_asgi_app(registry: Collector = REGISTRY, disable_compression: bool = False) -> Callable: """Create a ASGI app which serves the metrics from a registry.""" async def prometheus_app(scope, receive, send): diff --git a/prometheus_client/bridge/graphite.py b/prometheus_client/bridge/graphite.py index 8cadbedc..235324b2 100755 --- a/prometheus_client/bridge/graphite.py +++ b/prometheus_client/bridge/graphite.py @@ -8,7 +8,7 @@ from timeit import default_timer from typing import Callable, Tuple -from ..registry import CollectorRegistry, REGISTRY +from ..registry import Collector, REGISTRY # Roughly, have to keep to what works as a file name. # We also remove periods, so labels can be distinguished. @@ -48,7 +48,7 @@ def run(self): class GraphiteBridge: def __init__(self, address: Tuple[str, int], - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, timeout_seconds: float = 30, _timer: Callable[[], float] = time.time, tags: bool = False, diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 0d471707..9cb74faa 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -19,7 +19,7 @@ from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer from .openmetrics import exposition as openmetrics -from .registry import CollectorRegistry, REGISTRY +from .registry import Collector, REGISTRY from .utils import floatToGoString, parse_version __all__ = ( @@ -118,7 +118,7 @@ def _bake_output(registry, accept_header, accept_encoding_header, params, disabl return '200 OK', headers, output -def make_wsgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: bool = False) -> Callable: +def make_wsgi_app(registry: Collector = REGISTRY, disable_compression: bool = False) -> Callable: """Create a WSGI app which serves the metrics from a registry.""" def prometheus_app(environ, start_response): @@ -223,7 +223,7 @@ def _get_ssl_ctx( def start_wsgi_server( port: int, addr: str = '0.0.0.0', - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, certfile: Optional[str] = None, keyfile: Optional[str] = None, client_cafile: Optional[str] = None, @@ -252,12 +252,12 @@ class TmpServer(ThreadingWSGIServer): start_http_server = start_wsgi_server -def generate_latest(registry: CollectorRegistry = REGISTRY, escaping: str = openmetrics.UNDERSCORES) -> bytes: +def generate_latest(registry: Collector = REGISTRY, escaping: str = openmetrics.UNDERSCORES) -> bytes: """ Generates the exposition format using the basic Prometheus text format. Params: - registry: CollectorRegistry to export data from. + registry: Collector to export data from. escaping: Escaping scheme used for metric and label names. Returns: UTF-8 encoded string containing the metrics in text format. @@ -330,7 +330,7 @@ def sample_line(samples): return ''.join(output).encode('utf-8') -def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], bytes], str]: +def choose_encoder(accept_header: str) -> Tuple[Callable[[Collector], bytes], str]: # Python client library accepts a narrower range of content-types than # Prometheus does. accept_header = accept_header or '' @@ -408,7 +408,7 @@ def gzip_accepted(accept_encoding_header: str) -> bool: class MetricsHandler(BaseHTTPRequestHandler): """HTTP handler that gives metrics from ``REGISTRY``.""" - registry: CollectorRegistry = REGISTRY + registry: Collector = REGISTRY def do_GET(self) -> None: # Prepare parameters @@ -429,7 +429,7 @@ def log_message(self, format: str, *args: Any) -> None: """Log nothing.""" @classmethod - def factory(cls, registry: CollectorRegistry) -> type: + def factory(cls, registry: Collector) -> type: """Returns a dynamic MetricsHandler class tied to the passed registry. """ @@ -444,7 +444,7 @@ def factory(cls, registry: CollectorRegistry) -> type: return MyMetricsHandler -def write_to_textfile(path: str, registry: CollectorRegistry, escaping: str = openmetrics.ALLOWUTF8, tmpdir: Optional[str] = None) -> None: +def write_to_textfile(path: str, registry: Collector, escaping: str = openmetrics.ALLOWUTF8, tmpdir: Optional[str] = None) -> None: """Write metrics to the given path. This is intended for use with the Node exporter textfile collector. @@ -592,7 +592,7 @@ def tls_auth_handler( def push_to_gateway( gateway: str, job: str, - registry: CollectorRegistry, + registry: Collector, grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, @@ -603,7 +603,7 @@ def push_to_gateway( 'http://pushgateway.local', or 'pushgateway.local'. Scheme defaults to 'http' if none is provided `job` is the job label to be attached to all pushed metrics - `registry` is an instance of CollectorRegistry + `registry` is a Collector, normally an instance of CollectorRegistry `grouping_key` please see the pushgateway documentation for details. Defaults to None `timeout` is how long push will attempt to connect before giving up. @@ -641,7 +641,7 @@ def push_to_gateway( def pushadd_to_gateway( gateway: str, job: str, - registry: Optional[CollectorRegistry], + registry: Optional[Collector], grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, @@ -652,7 +652,7 @@ def pushadd_to_gateway( 'http://pushgateway.local', or 'pushgateway.local'. Scheme defaults to 'http' if none is provided `job` is the job label to be attached to all pushed metrics - `registry` is an instance of CollectorRegistry + `registry` is a Collector, normally an instance of CollectorRegistry `grouping_key` please see the pushgateway documentation for details. Defaults to None `timeout` is how long push will attempt to connect before giving up. @@ -702,7 +702,7 @@ def _use_gateway( method: str, gateway: str, job: str, - registry: Optional[CollectorRegistry], + registry: Optional[Collector], grouping_key: Optional[Dict[str, Any]], timeout: Optional[float], handler: Callable, diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index 8de4ce91..9934117d 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -1,24 +1,21 @@ -from abc import ABC, abstractmethod import copy from threading import Lock -from typing import Dict, Iterable, List, Optional +from typing import Dict, Iterable, List, Optional, Protocol from .metrics_core import Metric -# Ideally this would be a Protocol, but Protocols are only available in Python >= 3.8. -class Collector(ABC): - @abstractmethod +class Collector(Protocol): def collect(self) -> Iterable[Metric]: - pass + """Collect metrics.""" -class _EmptyCollector(Collector): +class _EmptyCollector: def collect(self) -> Iterable[Metric]: return [] -class CollectorRegistry(Collector): +class CollectorRegistry: """Metric collector registry. Collectors must have a no-argument method 'collect' that returns a list of From 7b9959209492c06968785c66bc6ea2316d156f91 Mon Sep 17 00:00:00 2001 From: ritesh-avesha <104001014+ritesh-avesha@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:30:45 +0530 Subject: [PATCH 17/30] Added compression support in pushgateway (#1144) * feat(): Added compression support in pushgateway Signed-off-by: ritesh-avesha * fix(): Incorporated changes for PR review comments Signed-off-by: ritesh-avesha * fix(): Incorporated changes for PR review comments, lint issues Signed-off-by: ritesh-avesha * fix(): lint issues Signed-off-by: ritesh-avesha --------- Signed-off-by: ritesh-avesha --- docs/content/exporting/pushgateway.md | 14 ++++++ prometheus_client/exposition.py | 66 +++++++++++++++++++++++---- tests/test_exposition.py | 25 ++++++++++ tox.ini | 1 + 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/docs/content/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md index bf5eb112..d9f9a945 100644 --- a/docs/content/exporting/pushgateway.md +++ b/docs/content/exporting/pushgateway.md @@ -54,6 +54,20 @@ g.set_to_current_time() push_to_gateway('localhost:9091', job='batchA', registry=registry, handler=my_auth_handler) ``` +# Compressing data before sending to pushgateway +Pushgateway (version >= 1.5.0) supports gzip and snappy compression (v > 1.6.0). This can help in network constrained environments. +To compress a push request, set the `compression` argument to `'gzip'` or `'snappy'`: +```python +push_to_gateway( + 'localhost:9091', + job='batchA', + registry=registry, + handler=my_auth_handler, + compression='gzip', +) +``` +Snappy compression requires the optional [`python-snappy`](https://github.com/andrix/python-snappy) package. + TLS Auth is also supported when using the push gateway with a special handler. ```python diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 9cb74faa..ca06d916 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -9,7 +9,9 @@ import ssl import sys import threading -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import ( + Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union, +) from urllib.error import HTTPError from urllib.parse import parse_qs, quote_plus, urlparse from urllib.request import ( @@ -22,6 +24,13 @@ from .registry import Collector, REGISTRY from .utils import floatToGoString, parse_version +try: + import snappy # type: ignore + SNAPPY_AVAILABLE = True +except ImportError: + snappy = None # type: ignore + SNAPPY_AVAILABLE = False + __all__ = ( 'CONTENT_TYPE_LATEST', 'CONTENT_TYPE_PLAIN_0_0_4', @@ -46,6 +55,7 @@ """Content type of the latest format""" CONTENT_TYPE_LATEST = CONTENT_TYPE_PLAIN_1_0_0 +CompressionType = Optional[Literal['gzip', 'snappy']] class _PrometheusRedirectHandler(HTTPRedirectHandler): @@ -596,6 +606,7 @@ def push_to_gateway( grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, + compression: CompressionType = None, ) -> None: """Push metrics to the given pushgateway. @@ -632,10 +643,12 @@ def push_to_gateway( failure. 'content' is the data which should be used to form the HTTP Message Body. + `compression` selects the payload compression. Supported values are 'gzip' + and 'snappy'. Defaults to None (no compression). This overwrites all metrics with the same job and grouping_key. This uses the PUT HTTP method.""" - _use_gateway('PUT', gateway, job, registry, grouping_key, timeout, handler) + _use_gateway('PUT', gateway, job, registry, grouping_key, timeout, handler, compression) def pushadd_to_gateway( @@ -645,6 +658,7 @@ def pushadd_to_gateway( grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, + compression: CompressionType = None, ) -> None: """PushAdd metrics to the given pushgateway. @@ -663,10 +677,12 @@ def pushadd_to_gateway( will be carried out by a default handler. See the 'prometheus_client.push_to_gateway' documentation for implementation requirements. + `compression` selects the payload compression. Supported values are 'gzip' + and 'snappy'. Defaults to None (no compression). This replaces metrics with the same name, job and grouping_key. This uses the POST HTTP method.""" - _use_gateway('POST', gateway, job, registry, grouping_key, timeout, handler) + _use_gateway('POST', gateway, job, registry, grouping_key, timeout, handler, compression) def delete_from_gateway( @@ -706,6 +722,7 @@ def _use_gateway( grouping_key: Optional[Dict[str, Any]], timeout: Optional[float], handler: Callable, + compression: CompressionType = None, ) -> None: gateway_url = urlparse(gateway) # See https://bugs.python.org/issue27657 for details on urlparse in py>=3.7.6. @@ -715,24 +732,53 @@ def _use_gateway( gateway = gateway.rstrip('/') url = '{}/metrics/{}/{}'.format(gateway, *_escape_grouping_key("job", job)) - data = b'' - if method != 'DELETE': - if registry is None: - registry = REGISTRY - data = generate_latest(registry) - if grouping_key is None: grouping_key = {} url += ''.join( '/{}/{}'.format(*_escape_grouping_key(str(k), str(v))) for k, v in sorted(grouping_key.items())) + data = b'' + headers: List[Tuple[str, str]] = [] + if method != 'DELETE': + if registry is None: + registry = REGISTRY + data = generate_latest(registry) + data, headers = _compress_payload(data, compression) + else: + # DELETE requests still need Content-Type header per test expectations + headers = [('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)] + if compression is not None: + raise ValueError('Compression is not supported for DELETE requests.') + handler( url=url, method=method, timeout=timeout, - headers=[('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)], data=data, + headers=headers, data=data, )() +def _compress_payload(data: bytes, compression: CompressionType) -> Tuple[bytes, List[Tuple[str, str]]]: + headers = [('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)] + if compression is None: + return data, headers + + encoding = compression.lower() + if encoding == 'gzip': + headers.append(('Content-Encoding', 'gzip')) + return gzip.compress(data), headers + if encoding == 'snappy': + if not SNAPPY_AVAILABLE: + raise RuntimeError('Snappy compression requires the python-snappy package to be installed.') + headers.append(('Content-Encoding', 'snappy')) + compressor = snappy.StreamCompressor() + compressed = compressor.compress(data) + flush = getattr(compressor, 'flush', None) + if callable(flush): + compressed += flush() + return compressed, headers + raise ValueError(f"Unsupported compression type: {compression}") + + def _escape_grouping_key(k, v): if v == "": # Per https://github.com/prometheus/pushgateway/pull/346. diff --git a/tests/test_exposition.py b/tests/test_exposition.py index 3dd5e378..aceff738 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,3 +1,4 @@ +import gzip from http.server import BaseHTTPRequestHandler, HTTPServer import os import threading @@ -404,6 +405,30 @@ def test_push_with_trailing_slash(self): self.assertNotIn('//', self.requests[0][0].path) + def test_push_with_gzip_compression(self): + push_to_gateway(self.address, "my_job", self.registry, compression='gzip') + request, body = self.requests[0] + self.assertEqual(request.headers.get('content-encoding'), 'gzip') + decompressed = gzip.decompress(body) + self.assertEqual(decompressed, b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + + def test_push_with_snappy_compression(self): + snappy = pytest.importorskip('snappy') + push_to_gateway(self.address, "my_job", self.registry, compression='snappy') + request, body = self.requests[0] + self.assertEqual(request.headers.get('content-encoding'), 'snappy') + decompressor = snappy.StreamDecompressor() + decompressed = decompressor.decompress(body) + flush = getattr(decompressor, 'flush', None) + if callable(flush): + decompressed += flush() + self.assertEqual(decompressed, b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + + def test_push_with_invalid_compression(self): + with self.assertRaisesRegex(ValueError, 'Unsupported compression type'): + push_to_gateway(self.address, "my_job", self.registry, compression='brotli') + self.assertEqual(self.requests, []) + def test_instance_ip_grouping_key(self): self.assertTrue('' != instance_ip_grouping_key()['instance']) diff --git a/tox.ini b/tox.ini index ccf95cc2..45a6baf3 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ deps = attrs {py3.9,pypy3.9}: twisted {py3.9,pypy3.9}: aiohttp + {py3.9}: python-snappy commands = coverage run --parallel -m pytest {posargs} [testenv:py3.9-nooptionals] From e1cdc203b1cf5f15c7b9a64d79fccc7907a62ca3 Mon Sep 17 00:00:00 2001 From: Julie Rymer Date: Mon, 5 Jan 2026 22:59:43 +0100 Subject: [PATCH 18/30] Add Django exporter (#1088) (#1143) Signed-off-by: Julie Rymer --- docs/content/exporting/http/django.md | 47 +++++++++++++++++++++++++ mypy.ini | 2 +- prometheus_client/django/__init__.py | 5 +++ prometheus_client/django/exposition.py | 44 +++++++++++++++++++++++ pyproject.toml | 3 ++ tests/test_django.py | 48 ++++++++++++++++++++++++++ tox.ini | 1 + 7 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 docs/content/exporting/http/django.md create mode 100644 prometheus_client/django/__init__.py create mode 100644 prometheus_client/django/exposition.py create mode 100644 tests/test_django.py diff --git a/docs/content/exporting/http/django.md b/docs/content/exporting/http/django.md new file mode 100644 index 00000000..a900a3a2 --- /dev/null +++ b/docs/content/exporting/http/django.md @@ -0,0 +1,47 @@ +--- +title: Django +weight: 5 +--- + +To use Prometheus with [Django](https://www.djangoproject.com/) you can use the provided view class +to add a metrics endpoint to your app. + +```python +# urls.py + +from django.urls import path +from prometheus_client.django import PrometheusDjangoView + +urlpatterns = [ + # ... any other urls that you want + path("metrics/", PrometheusDjangoView.as_view(), name="prometheus-metrics"), + # ... still more urls +] +``` + +By default, Multiprocessing support is activated if environment variable `PROMETHEUS_MULTIPROC_DIR` is set. +You can override this through the view arguments: + +```python +from django.conf import settings + +urlpatterns = [ + path( + "metrics/", + PrometheusDjangoView.as_view( + multiprocess_mode=settings.YOUR_SETTING # or any boolean value + ), + name="prometheus-metrics", + ), +] +``` + +Full multiprocessing instructions are provided [here]({{< ref "/multiprocess" >}}). + +# django-prometheus + +The included `PrometheusDjangoView` is useful if you want to define your own metrics from scratch. + +An external package called [django-prometheus](https://github.com/django-commons/django-prometheus/) +can be used instead if you want to get a bunch of ready-made monitoring metrics for your Django application +and easily benefit from utilities such as models monitoring. diff --git a/mypy.ini b/mypy.ini index fe372d07..3aa142c1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -exclude = prometheus_client/decorator.py|prometheus_client/twisted|tests/test_twisted.py +exclude = prometheus_client/decorator.py|prometheus_client/twisted|tests/test_twisted.py|prometheus_client/django|tests/test_django.py implicit_reexport = False disallow_incomplete_defs = True diff --git a/prometheus_client/django/__init__.py b/prometheus_client/django/__init__.py new file mode 100644 index 00000000..280dbfb0 --- /dev/null +++ b/prometheus_client/django/__init__.py @@ -0,0 +1,5 @@ +from .exposition import PrometheusDjangoView + +__all__ = [ + "PrometheusDjangoView", +] diff --git a/prometheus_client/django/exposition.py b/prometheus_client/django/exposition.py new file mode 100644 index 00000000..085e8fcf --- /dev/null +++ b/prometheus_client/django/exposition.py @@ -0,0 +1,44 @@ +import os + +from django.http import HttpResponse +from django.views import View + +import prometheus_client +from prometheus_client import multiprocess +from prometheus_client.exposition import _bake_output +from prometheus_client.registry import registry + + +class PrometheusDjangoView(View): + multiprocess_mode: bool = "PROMETHEUS_MULTIPROC_DIR" in os.environ or "prometheus_multiproc_dir" in os.environ + registry: prometheus_client.CollectorRegistry = None + + def get(self, request, *args, **kwargs): + if self.registry is None: + if self.multiprocess_mode: + self.registry = prometheus_client.CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + else: + self.registry = prometheus_client.REGISTRY + accept_header = request.headers.get("Accept") + accept_encoding_header = request.headers.get("Accept-Encoding") + # Bake output + status, headers, output = _bake_output( + registry=self.registry, + accept_header=accept_header, + accept_encoding_header=accept_encoding_header, + params=request.GET, + disable_compression=False, + ) + status = int(status.split(" ")[0]) + return HttpResponse( + output, + status=status, + headers=headers, + ) + + def options(self, request, *args, **kwargs): + return HttpResponse( + status=200, + headers={"Allow": "OPTIONS,GET"}, + ) diff --git a/pyproject.toml b/pyproject.toml index 1d8527a0..3961482f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ twisted = [ aiohttp = [ "aiohttp", ] +django = [ + "django", +] [project.urls] Homepage = "https://github.com/prometheus/client_python" diff --git a/tests/test_django.py b/tests/test_django.py new file mode 100644 index 00000000..659bb3f6 --- /dev/null +++ b/tests/test_django.py @@ -0,0 +1,48 @@ +from unittest import skipUnless + +from prometheus_client import CollectorRegistry, Counter, generate_latest +from prometheus_client.openmetrics.exposition import ALLOWUTF8 + +try: + import django + from django.test import RequestFactory, TestCase + + from prometheus_client.django import PrometheusDjangoView + + HAVE_DJANGO = True +except ImportError: + from unittest import TestCase + + HAVE_DJANGO = False + +else: + from django.conf import settings + + if not settings.configured: + settings.configure( + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[], + ) + django.setup() + + +class MetricsResourceTest(TestCase): + @skipUnless(HAVE_DJANGO, "Don't have django installed.") + def setUp(self): + self.registry = CollectorRegistry() + self.factory = RequestFactory() + + def test_reports_metrics(self): + c = Counter('cc', 'A counter', registry=self.registry) + c.inc() + + request = self.factory.get("/metrics") + + response = PrometheusDjangoView.as_view(registry=self.registry)(request) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, generate_latest(self.registry, ALLOWUTF8)) diff --git a/tox.ini b/tox.ini index 45a6baf3..992bd0a7 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ deps = attrs {py3.9,pypy3.9}: twisted {py3.9,pypy3.9}: aiohttp + {py3.9,pypy3.9}: django {py3.9}: python-snappy commands = coverage run --parallel -m pytest {posargs} From c5024d310fbfcba45a5e9db62e337a3a7930ea16 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Mon, 12 Jan 2026 13:10:03 -0700 Subject: [PATCH 19/30] Release 0.24.0 Signed-off-by: Chris Marchbanks --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3961482f..d45ef12d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.23.1" +version = "0.24.0" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From 6f0e967c1f7a408b75861d6833a8d303874be95d Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Wed, 14 Jan 2026 16:23:36 +0100 Subject: [PATCH 20/30] Pass correct registry to MultiProcessCollector (#1152) `registry` does not exists in prometheus_client.registry, as that causes an ImportError the test was skipped in the 3.9 scenario. Signed-off-by: Jelle van der Waa --- prometheus_client/django/exposition.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/prometheus_client/django/exposition.py b/prometheus_client/django/exposition.py index 085e8fcf..71fc8d8a 100644 --- a/prometheus_client/django/exposition.py +++ b/prometheus_client/django/exposition.py @@ -6,7 +6,6 @@ import prometheus_client from prometheus_client import multiprocess from prometheus_client.exposition import _bake_output -from prometheus_client.registry import registry class PrometheusDjangoView(View): @@ -17,7 +16,7 @@ def get(self, request, *args, **kwargs): if self.registry is None: if self.multiprocess_mode: self.registry = prometheus_client.CollectorRegistry() - multiprocess.MultiProcessCollector(registry) + multiprocess.MultiProcessCollector(self.registry) else: self.registry = prometheus_client.REGISTRY accept_header = request.headers.get("Accept") From f417f6ea8f058165a1934e368fed245e91aafc14 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 14 Jan 2026 08:24:50 -0700 Subject: [PATCH 21/30] Release 0.24.1 Signed-off-by: Chris Marchbanks --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d45ef12d..ed3ef389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.24.0" +version = "0.24.1" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From a8541354519d04852d24688845f1d2d495eef59c Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Wed, 21 Jan 2026 16:41:10 -0700 Subject: [PATCH 22/30] Migrate to Github Actions (#1153) * Migrate to Github Actions * Pin github actions versions --------- Signed-off-by: Chris Marchbanks --- .circleci/config.yml | 93 ----------------------- .github/workflows/ci.yaml | 110 ++++++++++++++++++++++++++++ .github/workflows/github-pages.yaml | 18 +++-- 3 files changed, 121 insertions(+), 100 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/ci.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index f29bd265..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,93 +0,0 @@ ---- -version: 2.1 - -executors: - python: - docker: - - image: cimg/python:3.9 - -jobs: - flake8_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e flake8 - isort_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e isort - mypy_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e mypy - test: - parameters: - python: - type: string - docker: - - image: cimg/python:<< parameters.python >> - environment: - TOXENV: "py<< parameters.python >>" - steps: - - checkout - - run: echo 'export PATH=$HOME/.local/bin:$PATH' >> $BASH_ENV - - run: pip install --user tox "virtualenv<20.22.0" - - run: tox - test_nooptionals: - parameters: - python: - type: string - docker: - - image: cimg/python:<< parameters.python >> - environment: - TOXENV: "py<< parameters.python >>-nooptionals" - steps: - - checkout - - run: pip install tox - - run: tox - test_pypy: - parameters: - python: - type: string - docker: - - image: pypy:<< parameters.python >> - environment: - TOXENV: "pypy<< parameters.python >>" - steps: - - checkout - - run: pip install tox - - run: tox - - -workflows: - version: 2 - client_python: - jobs: - - flake8_lint - - isort_lint - - mypy_lint - - test: - matrix: - parameters: - python: - - "3.9.18" - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - test_nooptionals: - matrix: - parameters: - python: - - "3.9" - - test_pypy: - matrix: - parameters: - python: - - "3.9" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..a7e4e094 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +permissions: + contents: read + +jobs: + flake8_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run flake8 + run: tox -e flake8 + + isort_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run isort + run: tox -e isort + + mypy_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run mypy + run: tox -e mypy + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install --user tox "virtualenv<20.22.0" + echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Set tox environment + id: toxenv + run: | + VERSION="${{ matrix.python-version }}" + # Extract major.minor version (strip patch if present) + TOX_VERSION=$(echo "$VERSION" | cut -d. -f1,2) + echo "toxenv=py${TOX_VERSION}" >> $GITHUB_OUTPUT + - name: Run tests + run: tox + env: + TOXENV: ${{ steps.toxenv.outputs.toxenv }} + + test_nooptionals: + runs-on: ubuntu-latest + env: + PYTHON_VERSION: '3.9' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install tox + run: pip install tox + - name: Run tests without optional dependencies + run: tox + env: + TOXENV: py${{ env.PYTHON_VERSION }}-nooptionals + + test_pypy: + runs-on: ubuntu-latest + env: + PYTHON_VERSION: '3.9' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up PyPy + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: pypy-${{ env.PYTHON_VERSION }} + - name: Install tox + run: pip install tox + - name: Run tests with PyPy + run: tox + env: + TOXENV: pypy${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index 621f2d73..d8db8cbc 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -11,9 +11,6 @@ on: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write - id-token: write - actions: read # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -32,6 +29,9 @@ jobs: runs-on: ubuntu-latest env: HUGO_VERSION: 0.145.0 + permissions: + pages: write + id-token: write steps: - name: Install Hugo CLI run: | @@ -40,13 +40,13 @@ jobs: #- name: Install Dart Sass # run: sudo snap install dart-sass - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: submodules: recursive fetch-depth: 0 - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" working-directory: ./docs @@ -62,7 +62,7 @@ jobs: --baseURL "${{ steps.pages.outputs.base_url }}/" working-directory: ./docs - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: ./docs/public @@ -73,7 +73,11 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build + permissions: + pages: write + id-token: write + actions: read steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 From 1cf53feae63b6ecb0bd76eee80582a0fba957e09 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:04:33 -0800 Subject: [PATCH 23/30] Fix server shutdown documentation (#1155) Add server.server_close() call to shutdown example to properly release the port. Without this call, attempting to restart the server on the same port results in "Address already in use" error. Fixes #1068 Signed-off-by: Varun Chawla --- docs/content/exporting/http/_index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md index dc1b8f2c..f7a6aac6 100644 --- a/docs/content/exporting/http/_index.md +++ b/docs/content/exporting/http/_index.md @@ -24,6 +24,7 @@ to shutdown the server gracefully: ```python server, t = start_http_server(8000) server.shutdown() +server.server_close() t.join() ``` From 671f75c6f1f04838995fadd57cda21beee01838b Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:05:29 -0800 Subject: [PATCH 24/30] Fix spaces in grouping key values for push_to_gateway (#1156) Use base64 encoding for grouping key values containing spaces, similar to how values with slashes are handled. This prevents spaces from being converted to '+' signs by quote_plus(). Fixes #1064 Signed-off-by: Varun Chawla --- prometheus_client/exposition.py | 3 ++- tests/test_exposition.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index ca06d916..2d402a0f 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -783,8 +783,9 @@ def _escape_grouping_key(k, v): if v == "": # Per https://github.com/prometheus/pushgateway/pull/346. return k + "@base64", "=" - elif '/' in v: + elif '/' in v or ' ' in v: # Added in Pushgateway 0.9.0. + # Use base64 encoding for values containing slashes or spaces return k + "@base64", base64.urlsafe_b64encode(v.encode("utf-8")).decode("utf-8") else: return k, quote_plus(v) diff --git a/tests/test_exposition.py b/tests/test_exposition.py index aceff738..a3c97820 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -301,6 +301,13 @@ def test_push_with_groupingkey_empty_label(self): self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_PLAIN_0_0_4) self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + def test_push_with_groupingkey_with_spaces(self): + push_to_gateway(self.address, "my_job", self.registry, {'label': 'value with spaces'}) + self.assertEqual(self.requests[0][0].command, 'PUT') + self.assertEqual(self.requests[0][0].path, '/metrics/job/my_job/label@base64/dmFsdWUgd2l0aCBzcGFjZXM=') + self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_PLAIN_0_0_4) + self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + def test_push_with_complex_groupingkey(self): push_to_gateway(self.address, "my_job", self.registry, {'a': 9, 'b': 'a/ z'}) self.assertEqual(self.requests[0][0].command, 'PUT') From 8673912276bdca7ddbca5d163eb11422b546bffb Mon Sep 17 00:00:00 2001 From: Mathias Kende Date: Wed, 18 Feb 2026 21:56:45 +0100 Subject: [PATCH 25/30] Support MultiProcessCollector in RestrictedRegistry. (#1150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support MultiProcessCollector in RestrictedRegistry. This change makes it so that the RestrictedRegistry will always attempt to collect metrics from a collector for which it couldn’t find any metrics name. Although this can be used generally, this is meant to be used with MultiProcessCollector. This changes the current behavior of the code but should be somehow safe as it enables filtering in case where it was not working previously. If this is an issue, an alternative approach with an explicit flag could be used (set either in the MultiProcessCollector or in the registry). The intent here is to allow collecting a subset of metrics from production fastapi servers (running in multiprocess mode). So not having to change the library usage in these servers is advantageous to have filtering work out-of-the-box with this change. Signed-off-by: Mathias Kende * Make the new support for collectors without names be explicit. This adds a parameters to the constructor of CollectorRegistry to allow that new behavior rather than make it be the default. Signed-off-by: Mathias Kende * Fix comments Signed-off-by: Mathias Kende --------- Signed-off-by: Mathias Kende --- docs/content/multiprocess/_index.md | 7 +++++-- prometheus_client/registry.py | 9 +++++++-- tests/test_asgi.py | 29 +++++++++++++++++++++++++++ tests/test_core.py | 18 +++++++++++++++++ tests/test_multiprocess.py | 31 ++++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md index 33507cd9..42ea6a67 100644 --- a/docs/content/multiprocess/_index.md +++ b/docs/content/multiprocess/_index.md @@ -10,9 +10,12 @@ it's common to have processes rather than threads to handle large workloads. To handle this the client library can be put in multiprocess mode. This comes with a number of limitations: -- Registries can not be used as normal, all instantiated metrics are exported +- Registries can not be used as normal: + - all instantiated metrics are collected - Registering metrics to a registry later used by a `MultiProcessCollector` may cause duplicate metrics to be exported + - Filtering on metrics works if and only if the constructor was called with + `support_collectors_without_names=True` and it but might be inefficient. - Custom collectors do not work (e.g. cpu and memory metrics) - Gauges cannot use `set_function` - Info and Enum metrics do not work @@ -49,7 +52,7 @@ MY_COUNTER = Counter('my_counter', 'Description of my counter') # Expose metrics. def app(environ, start_response): - registry = CollectorRegistry() + registry = CollectorRegistry(support_collectors_without_names=True) multiprocess.MultiProcessCollector(registry) data = generate_latest(registry) status = '200 OK' diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index 9934117d..c2b55d15 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -23,12 +23,15 @@ class CollectorRegistry: exposition formats. """ - def __init__(self, auto_describe: bool = False, target_info: Optional[Dict[str, str]] = None): + def __init__(self, auto_describe: bool = False, target_info: Optional[Dict[str, str]] = None, + support_collectors_without_names: bool = False): self._collector_to_names: Dict[Collector, List[str]] = {} self._names_to_collectors: Dict[str, Collector] = {} self._auto_describe = auto_describe self._lock = Lock() self._target_info: Optional[Dict[str, str]] = {} + self._support_collectors_without_names = support_collectors_without_names + self._collectors_without_names: List[Collector] = [] self.set_target_info(target_info) def register(self, collector: Collector) -> None: @@ -43,6 +46,8 @@ def register(self, collector: Collector) -> None: for name in names: self._names_to_collectors[name] = collector self._collector_to_names[collector] = names + if self._support_collectors_without_names and not names: + self._collectors_without_names.append(collector) def unregister(self, collector: Collector) -> None: """Remove a collector from the registry.""" @@ -145,7 +150,7 @@ def __init__(self, names: Iterable[str], registry: CollectorRegistry): self._registry = registry def collect(self) -> Iterable[Metric]: - collectors = set() + collectors = set(self._registry._collectors_without_names) target_info_metric = None with self._registry._lock: if 'target_info' in self._name_set and self._registry._target_info: diff --git a/tests/test_asgi.py b/tests/test_asgi.py index d4933cec..6e795e21 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -223,3 +223,32 @@ def test_qs_parsing(self): asyncio.new_event_loop().run_until_complete( self.communicator.wait() ) + + def test_qs_parsing_multi(self): + """Only metrics that match the 'name[]' query string param appear""" + + app = make_asgi_app(self.registry) + metrics = [ + ("asdf", "first test metric", 1), + ("bsdf", "second test metric", 2), + ("csdf", "third test metric", 3) + ] + + for m in metrics: + self.increment_metrics(*m) + + self.seed_app(app) + self.scope['query_string'] = "&".join([f"name[]={m[0]}_total" for m in metrics[0:2]]).encode("utf-8") + self.send_default_request() + + outputs = self.get_all_output() + response_body = outputs[1] + output = response_body['body'].decode('utf8') + + self.assert_metrics(output, *metrics[0]) + self.assert_metrics(output, *metrics[1]) + self.assert_not_metrics(output, *metrics[2]) + + asyncio.new_event_loop().run_until_complete( + self.communicator.wait() + ) diff --git a/tests/test_core.py b/tests/test_core.py index c7c9c14f..66492c6f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1024,6 +1024,24 @@ def test_restricted_registry_does_not_call_extra(self): self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) mock_collector.collect.assert_not_called() + def test_restricted_registry_ignore_no_names_collectors(self): + from unittest.mock import MagicMock + registry = CollectorRegistry() + mock_collector = MagicMock() + mock_collector.describe.return_value = [] + registry.register(mock_collector) + self.assertEqual(list(registry.restricted_registry(['metric']).collect()), []) + mock_collector.collect.assert_not_called() + + def test_restricted_registry_collects_no_names_collectors(self): + from unittest.mock import MagicMock + registry = CollectorRegistry(support_collectors_without_names=True) + mock_collector = MagicMock() + mock_collector.describe.return_value = [] + registry.register(mock_collector) + self.assertEqual(list(registry.restricted_registry(['metric']).collect()), []) + mock_collector.collect.assert_called() + def test_restricted_registry_does_not_yield_while_locked(self): registry = CollectorRegistry(target_info={'foo': 'bar'}) Summary('s', 'help', registry=registry).observe(7) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index c2f71d26..ee0c7423 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -52,7 +52,7 @@ def setUp(self): self.tempdir = tempfile.mkdtemp() os.environ['PROMETHEUS_MULTIPROC_DIR'] = self.tempdir values.ValueClass = MultiProcessValue(lambda: 123) - self.registry = CollectorRegistry() + self.registry = CollectorRegistry(support_collectors_without_names=True) self.collector = MultiProcessCollector(self.registry) @property @@ -358,6 +358,35 @@ def add_label(key, value): self.assertEqual(metrics['h'].samples, expected_histogram) + def test_restrict(self): + pid = 0 + values.ValueClass = MultiProcessValue(lambda: pid) + labels = {i: i for i in 'abcd'} + + def add_label(key, value): + l = labels.copy() + l[key] = value + return l + + c = Counter('c', 'help', labelnames=labels.keys(), registry=None) + g = Gauge('g', 'help', labelnames=labels.keys(), registry=None) + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + + pid = 1 + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + + metrics = {m.name: m for m in self.registry.restricted_registry(['c_total']).collect()} + + self.assertEqual(metrics.keys(), {'c'}) + + self.assertEqual( + metrics['c'].samples, [Sample('c_total', labels, 2.0)] + ) + def test_collect_preserves_help(self): pid = 0 values.ValueClass = MultiProcessValue(lambda: pid) From daa1626eaf705318013b708954499b0a049088c8 Mon Sep 17 00:00:00 2001 From: Krishna <107162115+k1chik@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:57:24 -0400 Subject: [PATCH 26/30] docs: add API reference for all metric types (#1159) * docs: add API reference for all metric types Adds constructor parameter tables, method documentation, and runnable real-world examples for Counter, Gauge, Histogram, Summary, Info, and Enum. The _index.md quick-pick table now covers all six types. Also fixes labels.md which was missing remove(), remove_by_labels(), and clear() -- the metric pages were already linking to it for those methods. Closes #1021 Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> * docs: address review feedback on counter example and table header Revert quick-start counter example to use 'my_failures' (no _total suffix) since the library appends it automatically. Rename 'Value goes' column to 'Behavior' in the metric type overview table for clarity across all six types. Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> * docs: rename table column to 'Update model' Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> --------- Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> --- docs/content/instrumenting/_index.md | 16 ++- docs/content/instrumenting/counter.md | 109 ++++++++++++++++-- docs/content/instrumenting/enum.md | 88 +++++++++++++- docs/content/instrumenting/gauge.md | 146 ++++++++++++++++++++++-- docs/content/instrumenting/histogram.md | 116 +++++++++++++++++-- docs/content/instrumenting/info.md | 75 +++++++++++- docs/content/instrumenting/labels.md | 33 +++++- docs/content/instrumenting/summary.md | 97 +++++++++++++++- 8 files changed, 638 insertions(+), 42 deletions(-) diff --git a/docs/content/instrumenting/_index.md b/docs/content/instrumenting/_index.md index 13bbc6b6..1b013d58 100644 --- a/docs/content/instrumenting/_index.md +++ b/docs/content/instrumenting/_index.md @@ -3,10 +3,20 @@ title: Instrumenting weight: 2 --- -Four types of metric are offered: Counter, Gauge, Summary and Histogram. -See the documentation on [metric types](http://prometheus.io/docs/concepts/metric_types/) +Six metric types are available. Pick based on what your value does: + +| Type | Update model | Use for | +|------|-----------|---------| +| [Counter](counter/) | only up | requests served, errors, bytes sent | +| [Gauge](gauge/) | up and down | queue depth, active connections, memory usage | +| [Histogram](histogram/) | observations in buckets | request latency, request size — when you need quantiles in queries | +| [Summary](summary/) | observations (count + sum) | request latency, request size — when average is enough | +| [Info](info/) | static key-value pairs | build version, environment metadata | +| [Enum](enum/) | one of N states | task state, lifecycle phase | + +See the Prometheus documentation on [metric types](https://prometheus.io/docs/concepts/metric_types/) and [instrumentation best practices](https://prometheus.io/docs/practices/instrumentation/#counter-vs-gauge-summary-vs-histogram) -on how to use them. +for deeper guidance on choosing between Histogram and Summary. ## Disabling `_created` metrics diff --git a/docs/content/instrumenting/counter.md b/docs/content/instrumenting/counter.md index 94618025..4876b612 100644 --- a/docs/content/instrumenting/counter.md +++ b/docs/content/instrumenting/counter.md @@ -3,8 +3,10 @@ title: Counter weight: 1 --- -Counters go up, and reset when the process restarts. +A Counter tracks a value that only ever goes up. Use it for things you count — requests +served, errors raised, bytes sent. When the process restarts, the counter resets to zero. +If your value can go down, use a [Gauge](../gauge/) instead. ```python from prometheus_client import Counter @@ -18,17 +20,110 @@ exposing the time series for counter, a `_total` suffix will be added. This is for compatibility between OpenMetrics and the Prometheus text format, as OpenMetrics requires the `_total` suffix. -There are utilities to count exceptions raised: +## Constructor + +```python +Counter(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A `_total` suffix is appended automatically when exposing the time series. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='requests_total' +# produces: myapp_http_requests_total +Counter('requests_total', 'Total requests', namespace='myapp', subsystem='http') +``` + +## Methods + +### `inc(amount=1, exemplar=None)` + +Increment the counter by the given amount. The amount must be non-negative. + +```python +c.inc() # increment by 1 +c.inc(5) # increment by 5 +c.inc(0.7) # fractional increments are allowed +``` + +To attach trace context to an observation, pass an `exemplar` dict. Exemplars are +only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details. + +```python +c.inc(exemplar={'trace_id': 'abc123'}) +``` + +### `reset()` + +Reset the counter to zero. Use this when a logical process restarts without +restarting the actual Python process. + +```python +c.reset() +``` + +### `count_exceptions(exception=Exception)` + +Count exceptions raised in a block of code or function. Can be used as a +decorator or context manager. Increments the counter each time an exception +of the given type is raised. ```python @c.count_exceptions() def f(): - pass + pass with c.count_exceptions(): - pass + pass -# Count only one type of exception +# Count only a specific exception type with c.count_exceptions(ValueError): - pass -``` \ No newline at end of file + pass +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking HTTP requests by method and status code in a web application: + +```python +from prometheus_client import Counter, start_http_server + +REQUESTS = Counter( + 'requests_total', + 'Total HTTP requests received', + labelnames=['method', 'status'], + namespace='myapp', +) +EXCEPTIONS = Counter( + 'exceptions_total', + 'Total unhandled exceptions', + labelnames=['handler'], + namespace='myapp', +) + +def handle_request(method, handler): + with EXCEPTIONS.labels(handler=handler).count_exceptions(): + # ... process the request ... + status = '200' + REQUESTS.labels(method=method, status=status).inc() + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces time series like `myapp_requests_total{method="GET",status="200"}`. diff --git a/docs/content/instrumenting/enum.md b/docs/content/instrumenting/enum.md index 102091a1..b1e6169a 100644 --- a/docs/content/instrumenting/enum.md +++ b/docs/content/instrumenting/enum.md @@ -3,11 +3,95 @@ title: Enum weight: 6 --- -Enum tracks which of a set of states something is currently in. +Enum tracks which of a fixed set of states something is currently in. Only one state is active at a time. Use it for things like task state machines or lifecycle phases. ```python from prometheus_client import Enum e = Enum('my_task_state', 'Description of enum', states=['starting', 'running', 'stopped']) e.state('running') -``` \ No newline at end of file +``` + +Enum exposes one time series per state: +- `{=""}` — 1 if this is the current state, 0 otherwise + +The first listed state is the default. + +Note: Enum metrics do not work in multiprocess mode. + +## Constructor + +```python +Enum(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, states=[]) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). The metric name itself cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Not supported — raises `ValueError`. Enum metrics cannot have a unit. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `states` | `List[str]` | required | The complete list of valid states. Must be non-empty. The first entry is the initial state. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='worker', name='state' +# produces: myapp_worker_state +Enum('state', 'Worker state', states=['idle', 'running', 'error'], namespace='myapp', subsystem='worker') +``` + +## Methods + +### `state(state)` + +Set the current state. The value must be one of the strings passed in the `states` list. Raises `ValueError` if the state is not recognized. + +```python +e.state('running') +e.state('stopped') +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking the lifecycle state of a background worker: + +```python +from prometheus_client import Enum, start_http_server + +WORKER_STATE = Enum( + 'worker_state', + 'Current state of the background worker', + states=['idle', 'running', 'error'], + namespace='myapp', +) + +def process_job(): + WORKER_STATE.state('running') + try: + # ... do work ... + pass + except Exception: + WORKER_STATE.state('error') + raise + finally: + WORKER_STATE.state('idle') + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_worker_state{myapp_worker_state="idle"} 0.0 +myapp_worker_state{myapp_worker_state="running"} 1.0 +myapp_worker_state{myapp_worker_state="error"} 0.0 +``` diff --git a/docs/content/instrumenting/gauge.md b/docs/content/instrumenting/gauge.md index 0b1529e9..43168a68 100644 --- a/docs/content/instrumenting/gauge.md +++ b/docs/content/instrumenting/gauge.md @@ -3,7 +3,8 @@ title: Gauge weight: 2 --- -Gauges can go up and down. +A Gauge tracks a value that can go up and down. Use it for things you sample at a +point in time — active connections, queue depth, memory usage, temperature. ```python from prometheus_client import Gauge @@ -13,24 +14,145 @@ g.dec(10) # Decrement by given value g.set(4.2) # Set to a given value ``` -There are utilities for common use cases: +## Constructor ```python -g.set_to_current_time() # Set to current unixtime +Gauge(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, multiprocess_mode='all') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `multiprocess_mode` | `str` | `'all'` | How to aggregate this gauge across multiple processes. See [Multiprocess mode](../../multiprocess/). Options: `all`, `liveall`, `min`, `livemin`, `max`, `livemax`, `sum`, `livesum`, `mostrecent`, `livemostrecent`. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='db', name='connections_active' +# produces: myapp_db_connections_active +Gauge('connections_active', 'Active DB connections', namespace='myapp', subsystem='db') +``` + +## Methods + +### `inc(amount=1)` + +Increment the gauge by the given amount. + +```python +g.inc() # increment by 1 +g.inc(3) # increment by 3 +``` + +Note: raises `RuntimeError` if `multiprocess_mode` is `mostrecent` or `livemostrecent`. + +### `dec(amount=1)` + +Decrement the gauge by the given amount. + +```python +g.dec() # decrement by 1 +g.dec(3) # decrement by 3 +``` + +Note: raises `RuntimeError` if `multiprocess_mode` is `mostrecent` or `livemostrecent`. + +### `set(value)` + +Set the gauge to the given value. + +```python +g.set(42.5) +``` + +### `set_to_current_time()` + +Set the gauge to the current Unix timestamp in seconds. Useful for tracking +when an event last occurred. -# Increment when entered, decrement when exited. +```python +g.set_to_current_time() +``` + +### `track_inprogress()` + +Increment the gauge when a block of code or function is entered, and decrement +it when exited. Can be used as a decorator or context manager. + +```python @g.track_inprogress() -def f(): - pass +def process_job(): + pass with g.track_inprogress(): - pass + pass +``` + +### `time()` + +Set the gauge to the duration in seconds of the most recent execution of a +block of code or function. Unlike `Histogram.time()` and `Summary.time()`, +which accumulate all observations, this overwrites the gauge with the latest +duration each time. Can be used as a decorator or context manager. + +```python +@g.time() +def process(): + pass + +with g.time(): + pass +``` + +### `set_function(f)` + +Bind a callback function that returns the gauge value. The function is called +each time the metric is scraped. All other methods become no-ops after calling +this. + +```python +queue = [] +g.set_function(lambda: len(queue)) ``` -A Gauge can also take its value from a callback: +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking active database connections and queue depth: ```python -d = Gauge('data_objects', 'Number of objects') -my_dict = {} -d.set_function(lambda: len(my_dict)) -``` \ No newline at end of file +from prometheus_client import Gauge, start_http_server + +ACTIVE_CONNECTIONS = Gauge( + 'connections_active', + 'Number of active database connections', + namespace='myapp', +) +QUEUE_SIZE = Gauge( + 'job_queue_size', + 'Number of jobs waiting in the queue', + namespace='myapp', +) + +job_queue = [] +QUEUE_SIZE.set_function(lambda: len(job_queue)) + +def acquire_connection(): + ACTIVE_CONNECTIONS.inc() + +def release_connection(): + ACTIVE_CONNECTIONS.dec() + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` diff --git a/docs/content/instrumenting/histogram.md b/docs/content/instrumenting/histogram.md index cb85f183..8975d859 100644 --- a/docs/content/instrumenting/histogram.md +++ b/docs/content/instrumenting/histogram.md @@ -3,8 +3,9 @@ title: Histogram weight: 4 --- -Histograms track the size and number of events in buckets. -This allows for aggregatable calculation of quantiles. +A Histogram samples observations and counts them in configurable buckets. Use it +when you want to track distributions — request latency, response sizes — and need +to calculate quantiles (p50, p95, p99) in your queries. ```python from prometheus_client import Histogram @@ -12,16 +13,113 @@ h = Histogram('request_latency_seconds', 'Description of histogram') h.observe(4.7) # Observe 4.7 (seconds in this case) ``` -The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds. -They can be overridden by passing `buckets` keyword argument to `Histogram`. +A Histogram exposes three time series per metric: +- `_bucket{le=""}` — count of observations with value ≤ le (cumulative) +- `_sum` — sum of all observed values +- `_count` — total number of observations -There are utilities for timing code: +## Constructor + +```python +Histogram(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, buckets=DEFAULT_BUCKETS) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Note: `le` is reserved and cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `buckets` | `Sequence[float]` | `DEFAULT_BUCKETS` | Upper bounds of the histogram buckets. Must be in ascending order. `+Inf` is always appended automatically. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='request_duration_seconds' +# produces: myapp_http_request_duration_seconds +Histogram('request_duration_seconds', 'Latency', namespace='myapp', subsystem='http') +``` + +Default buckets are intended to cover typical web/RPC request latency in seconds and are +accessible as `Histogram.DEFAULT_BUCKETS`: + +``` +.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, +Inf +``` + +To override with buckets tuned to your workload: + +```python +h = Histogram('request_latency_seconds', 'Latency', buckets=[.1, .5, 1, 2, 5]) +``` + +## Methods + +### `observe(amount, exemplar=None)` + +Record a single observation. The amount is typically positive or zero. + +```python +h.observe(0.43) # observe 430ms +``` + +To attach trace context to an observation, pass an `exemplar` dict. Exemplars are +only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details. + +```python +h.observe(0.43, exemplar={'trace_id': 'abc123'}) +``` + +### `time()` + +Observe the duration in seconds of a block of code or function and add it to the +histogram. Every call accumulates — unlike `Gauge.time()`, which only keeps the +most recent duration. Can be used as a decorator or context manager. ```python @h.time() -def f(): - pass +def process(): + pass with h.time(): - pass -``` \ No newline at end of file + pass +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking HTTP request latency with custom buckets tuned to the workload: + +```python +from prometheus_client import Histogram, start_http_server + +REQUEST_LATENCY = Histogram( + 'request_duration_seconds', + 'HTTP request latency', + labelnames=['method', 'endpoint'], + namespace='myapp', + buckets=[.01, .05, .1, .25, .5, 1, 2.5, 5], +) + +def handle_request(method, endpoint): + with REQUEST_LATENCY.labels(method=method, endpoint=endpoint).time(): + # ... handle the request ... + pass + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces time series like: +``` +myapp_request_duration_seconds_bucket{method="GET",endpoint="/api/users",le="0.1"} 42 +myapp_request_duration_seconds_sum{method="GET",endpoint="/api/users"} 3.7 +myapp_request_duration_seconds_count{method="GET",endpoint="/api/users"} 50 +``` diff --git a/docs/content/instrumenting/info.md b/docs/content/instrumenting/info.md index 6334d92b..6e369de7 100644 --- a/docs/content/instrumenting/info.md +++ b/docs/content/instrumenting/info.md @@ -3,10 +3,83 @@ title: Info weight: 5 --- -Info tracks key-value information, usually about a whole target. +Info tracks key-value pairs that describe a target — build version, configuration, or environment metadata. The values are static: once set, the metric outputs a single time series with all key-value pairs as labels and a constant value of 1. ```python from prometheus_client import Info i = Info('my_build_version', 'Description of info') i.info({'version': '1.2.3', 'buildhost': 'foo@bar'}) ``` + +Info exposes one time series per metric: +- `_info{="", ...}` — always 1; the key-value pairs become labels + +Note: Info metrics do not work in multiprocess mode. + +## Constructor + +```python +Info(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A `_info` suffix is appended automatically when exposing the time series. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Keys passed to `.info()` must not overlap with these label names. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Not supported — raises `ValueError`. Info metrics cannot have a unit. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='build' +# produces: myapp_http_build_info +Info('build', 'Build information', namespace='myapp', subsystem='http') +``` + +## Methods + +### `info(val)` + +Set the key-value pairs for this metric. `val` must be a `dict[str, str]` — both keys and values must be strings. Keys must not overlap with the metric's label names and values cannot be `None`. Calling `info()` again overwrites the previous value. + +```python +i.info({'version': '1.4.2', 'revision': 'abc123', 'branch': 'main'}) +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Exposing application build metadata so dashboards can join on version: + +```python +from prometheus_client import Info, start_http_server + +BUILD_INFO = Info( + 'build', + 'Application build information', + namespace='myapp', +) + +BUILD_INFO.info({ + 'version': '1.4.2', + 'revision': 'abc123def456', + 'branch': 'main', + 'build_date': '2024-01-15', +}) + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_build_info{branch="main",build_date="2024-01-15",revision="abc123def456",version="1.4.2"} 1.0 +``` diff --git a/docs/content/instrumenting/labels.md b/docs/content/instrumenting/labels.md index ebf80b56..39ad29c8 100644 --- a/docs/content/instrumenting/labels.md +++ b/docs/content/instrumenting/labels.md @@ -5,8 +5,8 @@ weight: 7 All metrics can have labels, allowing grouping of related time series. -See the best practices on [naming](http://prometheus.io/docs/practices/naming/) -and [labels](http://prometheus.io/docs/practices/instrumentation/#use-labels). +See the best practices on [naming](https://prometheus.io/docs/practices/naming/) +and [labels](https://prometheus.io/docs/practices/instrumentation/#use-labels). Taking a counter as an example: @@ -35,4 +35,33 @@ from prometheus_client import Counter c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) c.labels('get', '/') c.labels('post', '/submit') +``` + +## Removing labelsets + +### `remove(*labelvalues)` + +Remove a specific labelset from the metric. Values must be passed in the same +order as `labelnames` were declared. + +```python +c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) +c.labels('get', '/').inc() +c.remove('get', '/') +``` + +### `remove_by_labels(labels)` + +Remove all labelsets that partially match the given dict of label names and values. + +```python +c.remove_by_labels({'method': 'get'}) # removes all labelsets where method='get' +``` + +### `clear()` + +Remove all labelsets from the metric at once. + +```python +c.clear() ``` \ No newline at end of file diff --git a/docs/content/instrumenting/summary.md b/docs/content/instrumenting/summary.md index fa407496..55428ecb 100644 --- a/docs/content/instrumenting/summary.md +++ b/docs/content/instrumenting/summary.md @@ -3,7 +3,12 @@ title: Summary weight: 3 --- -Summaries track the size and number of events. +A Summary samples observations and tracks the total count and sum. Use it when +you want to track the size or duration of events and compute averages, but do not +need per-bucket breakdown or quantiles in your Prometheus queries. + +The Python client does not compute quantiles locally. If you need p50/p95/p99, +use a [Histogram](../histogram/) instead. ```python from prometheus_client import Summary @@ -11,15 +16,95 @@ s = Summary('request_latency_seconds', 'Description of summary') s.observe(4.7) # Observe 4.7 (seconds in this case) ``` -There are utilities for timing code: +A Summary exposes two time series per metric: +- `_count` — total number of observations +- `_sum` — sum of all observed values + +## Constructor + +```python +Summary(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Note: `quantile` is reserved and cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='worker', name='task_duration_seconds' +# produces: myapp_worker_task_duration_seconds +Summary('task_duration_seconds', 'Task duration', namespace='myapp', subsystem='worker') +``` + +## Methods + +### `observe(amount)` + +Record a single observation. The amount is typically positive or zero. + +```python +s.observe(0.43) # observe 430ms +s.observe(1024) # observe 1024 bytes +``` + +### `time()` + +Observe the duration in seconds of a block of code or function and add it to the +summary. Every call accumulates — unlike `Gauge.time()`, which only keeps the +most recent duration. Can be used as a decorator or context manager. ```python @s.time() -def f(): - pass +def process(): + pass with s.time(): - pass + pass +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking the duration of background tasks: + +```python +from prometheus_client import Summary, start_http_server + +TASK_DURATION = Summary( + 'task_duration_seconds', + 'Time spent processing background tasks', + labelnames=['task_type'], + namespace='myapp', +) + +def run_task(task_type, task): + with TASK_DURATION.labels(task_type=task_type).time(): + # ... run the task ... + pass + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_task_duration_seconds_count{task_type="email"} 120 +myapp_task_duration_seconds_sum{task_type="email"} 48.3 ``` -The Python client doesn't store or expose quantile information at this time. \ No newline at end of file +You can compute the average duration in PromQL as: +``` +rate(myapp_task_duration_seconds_sum[5m]) / rate(myapp_task_duration_seconds_count[5m]) +``` From 2cd1738fb880fd0e6aa9a02ec48585128388a6cd Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Thu, 9 Apr 2026 13:50:44 -0600 Subject: [PATCH 27/30] Release 0.25.0 Signed-off-by: Chris Marchbanks --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ed3ef389..336cfb4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.24.1" +version = "0.25.0" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From 130a4e8a3f80e75122ea9f646b063a6f21db9b42 Mon Sep 17 00:00:00 2001 From: Krishna <107162115+k1chik@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:11:33 -0400 Subject: [PATCH 28/30] docs: add API reference for pushgateway, textfile, and multiprocess (#1162) Closes #1161 Adds parameter tables and formal API reference sections to three pages that previously had examples but no parameter documentation. pushgateway.md: documents push_to_gateway, pushadd_to_gateway, delete_from_gateway, instance_ip_grouping_key, and all four built-in handlers (default, basic_auth, tls_auth, passthrough_redirect). textfile.md: documents write_to_textfile with all four parameters including the previously undocumented escaping and tmpdir, plus atomic write semantics and error behavior. multiprocess/_index.md: documents MultiProcessCollector constructor and mark_process_dead with parameter tables including the previously undocumented path parameter on both. Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> --- docs/content/exporting/pushgateway.md | 106 ++++++++++++++++++++++++++ docs/content/exporting/textfile.md | 22 +++++- docs/content/multiprocess/_index.md | 50 ++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/docs/content/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md index d9f9a945..6060c0bf 100644 --- a/docs/content/exporting/pushgateway.md +++ b/docs/content/exporting/pushgateway.md @@ -85,3 +85,109 @@ g = Gauge('job_last_success_unixtime', 'Last time a batch job successfully finis g.set_to_current_time() push_to_gateway('localhost:9091', job='batchA', registry=registry, handler=my_auth_handler) ``` + +## API Reference + +### `push_to_gateway(gateway, job, registry, grouping_key=None, timeout=30, handler=default_handler, compression=None)` + +Pushes metrics to the pushgateway, replacing all metrics with the same job and grouping key. +Uses the HTTP `PUT` method. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. If no scheme is provided, `http://` is assumed. | +| `job` | `str` | required | Value for the `job` label attached to all pushed metrics. | +| `registry` | `Collector` | required | Registry whose metrics are pushed. Typically a `CollectorRegistry` instance. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. See the [Pushgateway documentation](https://github.com/prometheus/pushgateway/blob/master/README.md) for details. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. See [Handlers](#handlers) below. | +| `compression` | `Optional[str]` | `None` | Compress the payload before sending. Accepts `'gzip'` or `'snappy'`. Snappy requires the [`python-snappy`](https://github.com/andrix/python-snappy) package. | + +### `pushadd_to_gateway(gateway, job, registry, grouping_key=None, timeout=30, handler=default_handler, compression=None)` + +Pushes metrics to the pushgateway, replacing only metrics with the same name, job, and grouping key. +Uses the HTTP `POST` method. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. | +| `job` | `str` | required | Value for the `job` label attached to all pushed metrics. | +| `registry` | `Optional[Collector]` | required | Registry whose metrics are pushed. Pass `None` to use the default `REGISTRY`. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. | +| `compression` | `Optional[str]` | `None` | Compress the payload. Accepts `'gzip'` or `'snappy'`. | + +### `delete_from_gateway(gateway, job, grouping_key=None, timeout=30, handler=default_handler)` + +Deletes metrics from the pushgateway for the given job and grouping key. +Uses the HTTP `DELETE` method. Has no `registry` or `compression` parameters. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. | +| `job` | `str` | required | Value for the `job` label identifying the group to delete. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. | + +### `instance_ip_grouping_key()` + +Returns a grouping key dict with the `instance` label set to the IP address of the current host. +Takes no parameters. + +```python +from prometheus_client.exposition import instance_ip_grouping_key + +push_to_gateway('localhost:9091', job='batchA', registry=registry, + grouping_key=instance_ip_grouping_key()) +``` + +## Handlers + +A handler is a callable with the signature: + +```python +def my_handler(url, method, timeout, headers, data): + # url: str — full request URL + # method: str — HTTP method (PUT, POST, DELETE) + # timeout: Optional[float] — seconds before aborting, or None + # headers: List[Tuple[str, str]] — HTTP headers to include + # data: bytes — request body + ... + return callable_that_performs_the_request +``` + +The handler must return a no-argument callable that performs the actual HTTP request and raises +an exception (e.g. `IOError`) on failure. Three built-in handlers are available in +`prometheus_client.exposition`: + +### `default_handler` + +Standard HTTP/HTTPS handler. Used by default in all push functions. + +### `basic_auth_handler(url, method, timeout, headers, data, username=None, password=None)` + +Wraps `default_handler` and adds an HTTP Basic Auth header. + +| Extra parameter | Type | Default | Description | +|----------------|------|---------|-------------| +| `username` | `Optional[str]` | `None` | HTTP Basic Auth username. | +| `password` | `Optional[str]` | `None` | HTTP Basic Auth password. | + +### `tls_auth_handler(url, method, timeout, headers, data, certfile, keyfile, cafile=None, protocol=ssl.PROTOCOL_TLS_CLIENT, insecure_skip_verify=False)` + +Performs the request over HTTPS using TLS client certificate authentication. + +| Extra parameter | Type | Default | Description | +|----------------|------|---------|-------------| +| `certfile` | `str` | required | Path to the client certificate PEM file. | +| `keyfile` | `str` | required | Path to the client private key PEM file. | +| `cafile` | `Optional[str]` | `None` | Path to a CA certificate file for server verification. Uses system defaults if not set. | +| `protocol` | `int` | `ssl.PROTOCOL_TLS_CLIENT` | SSL/TLS protocol version. | +| `insecure_skip_verify` | `bool` | `False` | Skip server certificate verification. Use only in controlled environments. | + +### `passthrough_redirect_handler` + +Like `default_handler` but automatically follows redirects for all HTTP methods, including `PUT` +and `POST`. Use only when you control or trust the source of redirect responses. diff --git a/docs/content/exporting/textfile.md b/docs/content/exporting/textfile.md index 80360e46..cb2571af 100644 --- a/docs/content/exporting/textfile.md +++ b/docs/content/exporting/textfile.md @@ -20,4 +20,24 @@ write_to_textfile('/configured/textfile/path/raid.prom', registry) ``` A separate registry is used, as the default registry may contain other metrics -such as those from the Process Collector. \ No newline at end of file +such as those from the Process Collector. + +## API Reference + +### `write_to_textfile(path, registry, escaping='allow-utf-8', tmpdir=None)` + +Writes metrics from the registry to a file in Prometheus text format. + +The file is written atomically: metrics are first written to a temporary file in the same +directory as `path` (or in `tmpdir` if provided), then renamed into place. This prevents the +Node exporter from reading a partially written file. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | `str` | required | Destination file path. Must end in `.prom` for the Node exporter textfile collector to process it. | +| `registry` | `Collector` | required | Registry whose metrics are written. | +| `escaping` | `str` | `'allow-utf-8'` | Escaping scheme for metric and label names. Accepted values: `'allow-utf-8'`, `'underscores'`, `'dots'`, `'values'`. | +| `tmpdir` | `Optional[str]` | `None` | Directory for the temporary file used during the atomic write. Defaults to the same directory as `path`. If provided, must be on the same filesystem as `path`. | + +Returns `None`. Raises an exception if the file cannot be written; the temporary file is cleaned +up automatically on failure. \ No newline at end of file diff --git a/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md index 42ea6a67..f7befef8 100644 --- a/docs/content/multiprocess/_index.md +++ b/docs/content/multiprocess/_index.md @@ -96,3 +96,53 @@ from prometheus_client import Gauge # Example gauge IN_PROGRESS = Gauge("inprogress_requests", "help", multiprocess_mode='livesum') ``` + +## API Reference + +### `MultiProcessCollector(registry, path=None)` + +Collector that aggregates metrics written by all processes in the multiprocess directory. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | required | Registry to register with. Pass a registry created inside the request context to avoid duplicate metrics. | +| `path` | `Optional[str]` | `None` | Path to the directory containing the per-process metric files. Defaults to the `PROMETHEUS_MULTIPROC_DIR` environment variable. | + +Raises `ValueError` if `path` is not set or does not point to an existing directory. + +```python +from prometheus_client import multiprocess, CollectorRegistry + +def app(environ, start_response): + registry = CollectorRegistry(support_collectors_without_names=True) + multiprocess.MultiProcessCollector(registry) + ... +``` + +To use a custom path instead of the environment variable: + +```python +collector = multiprocess.MultiProcessCollector(registry, path='/var/run/prom') +``` + +### `mark_process_dead(pid, path=None)` + +Removes the per-process metric files for a dead process. Call this from your process manager +when a worker exits to prevent stale `live*` gauge values from accumulating. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `pid` | `int` | required | PID of the process that has exited. | +| `path` | `Optional[str]` | `None` | Path to the multiprocess directory. Defaults to the `PROMETHEUS_MULTIPROC_DIR` environment variable. | + +Returns `None`. Only removes files for `live*` gauge modes (e.g. `livesum`, `liveall`); files +for non-live modes are left in place so their last values remain visible until the directory is +wiped on restart. + +```python +# Gunicorn config +from prometheus_client import multiprocess + +def child_exit(server, worker): + multiprocess.mark_process_dead(worker.pid) +``` From e75a74f7a9000f414ddacefd6797d560d4bb6731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Vokr=C3=A1=C4=8Dko?= Date: Fri, 24 Apr 2026 23:07:17 +0200 Subject: [PATCH 29/30] Expose measured duration on Timer context manager (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assigning the .time() context manager (with ... as t) now yields a Timer whose .duration attribute holds the observed value in seconds after the block exits. This lets callers reuse the measurement (logging, further metrics) without calling default_timer() a second time. Signed-off-by: Lukáš Vokráčko --- docs/content/instrumenting/gauge.md | 4 ++++ docs/content/instrumenting/histogram.md | 4 ++++ docs/content/instrumenting/summary.md | 4 ++++ prometheus_client/context_managers.py | 5 +++-- tests/test_core.py | 8 ++++++++ 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/content/instrumenting/gauge.md b/docs/content/instrumenting/gauge.md index 43168a68..62294944 100644 --- a/docs/content/instrumenting/gauge.md +++ b/docs/content/instrumenting/gauge.md @@ -108,6 +108,10 @@ def process(): with g.time(): pass + +with g.time() as t: + pass +print(t.duration) # observed time in seconds. ``` ### `set_function(f)` diff --git a/docs/content/instrumenting/histogram.md b/docs/content/instrumenting/histogram.md index 8975d859..fa0ffe1a 100644 --- a/docs/content/instrumenting/histogram.md +++ b/docs/content/instrumenting/histogram.md @@ -86,6 +86,10 @@ def process(): with h.time(): pass + +with h.time() as t: + pass +print(t.duration) # observed time in seconds. ``` ## Labels diff --git a/docs/content/instrumenting/summary.md b/docs/content/instrumenting/summary.md index 55428ecb..714dfd2f 100644 --- a/docs/content/instrumenting/summary.md +++ b/docs/content/instrumenting/summary.md @@ -68,6 +68,10 @@ def process(): with s.time(): pass + +with s.time() as t: + pass +print(t.duration) # observed time in seconds. ``` ## Labels diff --git a/prometheus_client/context_managers.py b/prometheus_client/context_managers.py index 3988ec22..3e8d7ced 100644 --- a/prometheus_client/context_managers.py +++ b/prometheus_client/context_managers.py @@ -55,6 +55,7 @@ class Timer: def __init__(self, metric, callback_name): self._metric = metric self._callback_name = callback_name + self.duration = None def _new_timer(self): return self.__class__(self._metric, self._callback_name) @@ -65,9 +66,9 @@ def __enter__(self): def __exit__(self, typ, value, traceback): # Time can go backwards. - duration = max(default_timer() - self._start, 0) + self.duration = max(default_timer() - self._start, 0) callback = getattr(self._metric, self._callback_name) - callback(duration) + callback(self.duration) def labels(self, *args, **kw): self._metric = self._metric.labels(*args, **kw) diff --git a/tests/test_core.py b/tests/test_core.py index 66492c6f..cdf32bfa 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -378,6 +378,14 @@ def test_block_decorator_with_label(self): metric.labels('foo') self.assertEqual(1, value('s_with_labels_count', {'label1': 'foo'})) + def test_timer_duration_exposed(self): + with self.summary.time() as t: + time.sleep(0.01) + self.assertIsNotNone(t.duration) + self.assertGreater(t.duration, 0) + recorded_sum = self.registry.get_sample_value('s_sum') + self.assertEqual(t.duration, recorded_sum) + def test_timer_not_observable(self): s = Summary('test', 'help', labelnames=('label',), registry=self.registry) From 482656c8c07b78668adb5f3171dfe71ccc2396b2 Mon Sep 17 00:00:00 2001 From: Krishna <107162115+k1chik@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:10:21 -0400 Subject: [PATCH 30/30] docs: add API reference for CollectorRegistry and custom collector classes (#1169) Closes #1163 collector/custom.md: Collector protocol section (collect/describe), value vs labels mutual exclusivity note, full constructor and add_metric tables for GaugeMetricFamily, CounterMetricFamily, SummaryMetricFamily, HistogramMetricFamily, and InfoMetricFamily, plus a runnable real-world example. collector/_index.md: constructor parameter tables for ProcessCollector, PlatformCollector, and GCCollector, with exported metrics listed for each. registry/_index.md (new): CollectorRegistry constructor and all public methods (register, unregister, collect, restricted_registry, get_sample_value, set_target_info, get_target_info), the global REGISTRY instance, and examples for isolated registry usage and registry=None. All code examples verified by running them in Python. Signed-off-by: k1chik <107162115+k1chik@users.noreply.github.com> --- docs/content/collector/_index.md | 77 +++++++++- docs/content/collector/custom.md | 250 ++++++++++++++++++++++++++++++- docs/content/registry/_index.md | 141 +++++++++++++++++ 3 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 docs/content/registry/_index.md diff --git a/docs/content/collector/_index.md b/docs/content/collector/_index.md index 957c8ba9..85c6f12f 100644 --- a/docs/content/collector/_index.md +++ b/docs/content/collector/_index.md @@ -18,8 +18,8 @@ ProcessCollector(namespace='mydaemon', pid=lambda: open('/var/run/daemon.pid').r # Platform Collector The client also automatically exports some metadata about Python. If using Jython, -metadata about the JVM in use is also included. This information is available as -labels on the `python_info` metric. The value of the metric is 1, since it is the +metadata about the JVM in use is also included. This information is available as +labels on the `python_info` metric. The value of the metric is 1, since it is the labels that carry information. # Disabling Default Collector metrics @@ -33,4 +33,75 @@ import prometheus_client prometheus_client.REGISTRY.unregister(prometheus_client.GC_COLLECTOR) prometheus_client.REGISTRY.unregister(prometheus_client.PLATFORM_COLLECTOR) prometheus_client.REGISTRY.unregister(prometheus_client.PROCESS_COLLECTOR) -``` \ No newline at end of file +``` + +## API Reference + +### ProcessCollector + +```python +ProcessCollector(namespace='', pid=lambda: 'self', proc='/proc', registry=REGISTRY) +``` + +Collects process metrics from `/proc`. Only available on Linux. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `namespace` | `str` | `''` | Prefix added to all metric names, e.g. `'mydaemon'` produces `mydaemon_process_cpu_seconds_total`. | +| `pid` | `Callable[[], int or str]` | `lambda: 'self'` | Callable that returns the PID to monitor. `'self'` monitors the current process. | +| `proc` | `str` | `'/proc'` | Path to the proc filesystem. Useful for testing or containerised environments with a non-standard mount point. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration. | + +Metrics exported: + +| Metric | Description | +|--------|-------------| +| `process_cpu_seconds_total` | Total user and system CPU time in seconds. | +| `process_virtual_memory_bytes` | Virtual memory size in bytes. | +| `process_resident_memory_bytes` | Resident memory size in bytes. | +| `process_start_time_seconds` | Start time since Unix epoch in seconds. | +| `process_open_fds` | Number of open file descriptors. | +| `process_max_fds` | Maximum number of open file descriptors. | + +The module-level `PROCESS_COLLECTOR` is the default instance registered with `REGISTRY`. + +### PlatformCollector + +```python +PlatformCollector(registry=REGISTRY, platform=None) +``` + +Exports Python runtime metadata as a `python_info` gauge metric with labels. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration. | +| `platform` | module | `None` | Override the `platform` module. Intended for testing. | + +Labels on `python_info`: `version`, `implementation`, `major`, `minor`, `patchlevel`. +On Jython, additional labels are added: `jvm_version`, `jvm_release`, `jvm_vendor`, `jvm_name`. + +The module-level `PLATFORM_COLLECTOR` is the default instance registered with `REGISTRY`. + +### GCCollector + +```python +GCCollector(registry=REGISTRY) +``` + +Exports Python garbage collector statistics. Only active on CPython (skipped silently on +other implementations). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. | + +Metrics exported: + +| Metric | Description | +|--------|-------------| +| `python_gc_objects_collected_total` | Objects collected during GC, by generation. | +| `python_gc_objects_uncollectable_total` | Uncollectable objects found during GC, by generation. | +| `python_gc_collections_total` | Number of times each generation was collected. | + +The module-level `GC_COLLECTOR` is the default instance registered with `REGISTRY`. diff --git a/docs/content/collector/custom.md b/docs/content/collector/custom.md index bc6a021c..62c0180a 100644 --- a/docs/content/collector/custom.md +++ b/docs/content/collector/custom.md @@ -35,4 +35,252 @@ not implemented and the CollectorRegistry was created with `auto_describe=True` (which is the case for the default registry) then `collect` will be called at registration time instead of `describe`. If this could cause problems, either implement a proper `describe`, or if that's not practical have `describe` -return an empty list. \ No newline at end of file +return an empty list. + +## Collector protocol + +A collector is any object that implements a `collect` method. Optionally it +can also implement `describe`. + +### `collect()` + +Returns an iterable of metric family objects (`GaugeMetricFamily`, +`CounterMetricFamily`, etc.). Called every time the registry is scraped. + +### `describe()` + +Returns an iterable of metric family objects used only to determine the metric +names the collector produces. Samples on the returned objects are ignored. If +not implemented and the registry has `auto_describe=True`, `collect` is called +at registration time instead. + +## value vs labels + +Every metric family constructor accepts either inline data or `labels`, but not +both. The inline data parameter name varies by type: `value` for Gauge, Counter, +and Info; `count_value`/`sum_value` for Summary; `buckets` for Histogram. + +- Pass inline data to emit a single unlabelled metric directly from the constructor. +- Pass `labels` (a sequence of label names) and then call `add_metric` one or + more times to emit labelled metrics. + +```python +# single unlabelled value +GaugeMetricFamily('my_gauge', 'Help text', value=7) + +# labelled metrics via add_metric +g = GaugeMetricFamily('my_gauge', 'Help text', labels=['region']) +g.add_metric(['us-east-1'], 3) +g.add_metric(['eu-west-1'], 5) +``` + +## API Reference + +### GaugeMetricFamily + +```python +GaugeMetricFamily(name, documentation, value=None, labels=None, unit='') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output. | +| `value` | `float` | `None` | Emit a single unlabelled sample with this value. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `value`. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | + +#### `add_metric(labels, value, timestamp=None)` + +Add a labelled sample to the metric family. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values in the same order as the `labels` constructor argument. | +| `value` | `float` | The gauge value. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | + +```python +g = GaugeMetricFamily('temperature_celsius', 'Temperature by location', labels=['location']) +g.add_metric(['living_room'], 21.5) +g.add_metric(['basement'], 18.0) +yield g +``` + +### CounterMetricFamily + +```python +CounterMetricFamily(name, documentation, value=None, labels=None, created=None, unit='', exemplar=None) +``` + +If `name` ends with `_total`, the suffix is stripped automatically so the +metric is stored without it and the `_total` suffix is added on exposition. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A trailing `_total` is stripped and re-added on exposition. | +| `documentation` | `str` | required | Help text. | +| `value` | `float` | `None` | Emit a single unlabelled sample. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `value`. | +| `created` | `float` | `None` | Unix timestamp the counter was created at. Only used when `value` is set. | +| `unit` | `str` | `''` | Optional unit suffix. | +| `exemplar` | `Exemplar` | `None` | Exemplar for the single-value form. Only used when `value` is set. | + +#### `add_metric(labels, value, created=None, timestamp=None, exemplar=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `value` | `float` | The counter value. | +| `created` | `float` | Optional Unix timestamp the counter was created at. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | +| `exemplar` | `Exemplar` | Optional exemplar. See [Exemplars](../../instrumenting/exemplars/). | + +```python +c = CounterMetricFamily('http_requests_total', 'HTTP requests by status', labels=['status']) +c.add_metric(['200'], 1200) +c.add_metric(['404'], 43) +c.add_metric(['500'], 7) +yield c +``` + +### SummaryMetricFamily + +```python +SummaryMetricFamily(name, documentation, count_value=None, sum_value=None, labels=None, unit='') +``` + +`count_value` and `sum_value` must always be provided together or not at all. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text. | +| `count_value` | `int` | `None` | Observation count for a single unlabelled metric. Must be paired with `sum_value`. | +| `sum_value` | `float` | `None` | Observation sum for a single unlabelled metric. Must be paired with `count_value`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `count_value`/`sum_value`. | +| `unit` | `str` | `''` | Optional unit suffix. | + +#### `add_metric(labels, count_value, sum_value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `count_value` | `int` | The number of observations. | +| `sum_value` | `float` | The sum of all observed values. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | + +```python +s = SummaryMetricFamily('rpc_duration_seconds', 'RPC duration', labels=['method']) +s.add_metric(['get'], count_value=1000, sum_value=53.2) +s.add_metric(['put'], count_value=400, sum_value=28.7) +yield s +``` + +### HistogramMetricFamily + +```python +HistogramMetricFamily(name, documentation, buckets=None, sum_value=None, labels=None, unit='') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text. | +| `buckets` | `Sequence` | `None` | Bucket data for a single unlabelled metric. Each entry is a `(le, value)` pair or `(le, value, exemplar)` triple. Must include a `+Inf` bucket. Mutually exclusive with `labels`. | +| `sum_value` | `float` | `None` | Observation sum. Cannot be set without `buckets`. Omitted for histograms with negative buckets. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `buckets`. | +| `unit` | `str` | `''` | Optional unit suffix. | + +#### `add_metric(labels, buckets, sum_value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `buckets` | `Sequence` | Bucket data. Each entry is a `(le, value)` pair or `(le, value, exemplar)` triple. Must be sorted and include `+Inf`. | +| `sum_value` | `float` or `None` | The sum of all observed values. Pass `None` for histograms with negative buckets. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp. | + +```python +h = HistogramMetricFamily('request_size_bytes', 'Request sizes', labels=['handler']) +h.add_metric( + ['api'], + buckets=[('100', 5), ('1000', 42), ('+Inf', 50)], + sum_value=18350.0, +) +yield h +``` + +### InfoMetricFamily + +```python +InfoMetricFamily(name, documentation, value=None, labels=None) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. The `_info` suffix is added automatically on exposition. | +| `documentation` | `str` | required | Help text. | +| `value` | `Dict[str, str]` | `None` | Key-value label pairs for a single unlabelled info metric. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names for the outer grouping. Use with `add_metric`. Mutually exclusive with `value`. | + +#### `add_metric(labels, value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Outer label values (from the `labels` constructor argument). | +| `value` | `Dict[str, str]` | Key-value label pairs that form the info payload. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp. | + +```python +# single unlabelled info metric +yield InfoMetricFamily('build', 'Build metadata', value={'version': '1.2.3', 'commit': 'abc123'}) + +# labelled: one info metric per service +i = InfoMetricFamily('service_build', 'Per-service build info', labels=['service']) +i.add_metric(['auth'], {'version': '2.0.1', 'commit': 'def456'}) +i.add_metric(['api'], {'version': '1.9.0', 'commit': 'ghi789'}) +yield i +``` + +## Real-world example + +Proxying metrics from an external source: + +```python +from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily, REGISTRY +from prometheus_client.registry import Collector +from prometheus_client import start_http_server + +# Simulated external data source +_QUEUE_STATS = { + 'orders': {'depth': 14, 'processed': 9821}, + 'notifications': {'depth': 3, 'processed': 45210}, +} + +class QueueCollector(Collector): + def collect(self): + depth = GaugeMetricFamily( + 'queue_depth', + 'Current number of messages waiting in the queue', + labels=['queue'], + ) + processed = CounterMetricFamily( + 'queue_messages_processed_total', + 'Total messages processed from the queue', + labels=['queue'], + ) + for name, stats in _QUEUE_STATS.items(): + depth.add_metric([name], stats['depth']) + processed.add_metric([name], stats['processed']) + yield depth + yield processed + +REGISTRY.register(QueueCollector()) + +if __name__ == '__main__': + start_http_server(8000) + import time + while True: + time.sleep(1) +``` diff --git a/docs/content/registry/_index.md b/docs/content/registry/_index.md new file mode 100644 index 00000000..0d554535 --- /dev/null +++ b/docs/content/registry/_index.md @@ -0,0 +1,141 @@ +--- +title: Registry +weight: 8 +--- + +A `CollectorRegistry` holds all the collectors whose metrics are exposed when +the registry is scraped. The global default registry is `REGISTRY`, which all +metric constructors register with automatically unless told otherwise. + +```python +from prometheus_client import REGISTRY, CollectorRegistry + +# Use the default global registry +from prometheus_client import Counter +c = Counter('my_counter', 'A counter') # registered with REGISTRY automatically + +# Create an isolated registry, e.g. for testing +r = CollectorRegistry() +c2 = Counter('my_counter', 'A counter', registry=r) +``` + +## Constructor + +```python +CollectorRegistry(auto_describe=False, target_info=None, support_collectors_without_names=False) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `auto_describe` | `bool` | `False` | If `True`, calls `collect()` on a collector at registration time if the collector does not implement `describe()`. Used to detect duplicate metric names. The default `REGISTRY` is created with `auto_describe=True`. | +| `target_info` | `Dict[str, str]` | `None` | Key-value labels to attach as a `target_info` metric. Equivalent to calling `set_target_info` after construction. | +| `support_collectors_without_names` | `bool` | `False` | If `True`, allows registering collectors that produce no named metrics (i.e. whose `describe()` returns an empty list). | + +## Methods + +### `register(collector)` + +Register a collector with this registry. Raises `ValueError` if any of the +metric names the collector produces are already registered. + +```python +from prometheus_client.registry import Collector + +class MyCollector(Collector): + def collect(self): + ... + +REGISTRY.register(MyCollector()) +``` + +### `unregister(collector)` + +Remove a previously registered collector. + +```python +from prometheus_client import GC_COLLECTOR +REGISTRY.unregister(GC_COLLECTOR) +``` + +### `collect()` + +Yield all metrics from every registered collector. Also yields the +`target_info` metric if one has been set. + +```python +for metric in REGISTRY.collect(): + print(metric.name, metric.type) +``` + +### `restricted_registry(names)` + +Return a view of this registry that only exposes the named metrics. Useful +for partial scrapes. See [Restricted registry](../restricted-registry/) for +usage with `generate_latest` and the built-in HTTP server. + +```python +from prometheus_client import generate_latest + +subset = REGISTRY.restricted_registry(['python_info', 'process_cpu_seconds_total']) +output = generate_latest(subset) +``` + +### `get_sample_value(name, labels=None)` + +Return the current value of a single sample, or `None` if not found. Intended +for use in unit tests; not efficient for production use. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Full sample name including any suffix (e.g. `'my_counter_total'`). | +| `labels` | `Dict[str, str]` | `{}` | Label key-value pairs to match. An empty dict matches an unlabelled sample. | + +```python +from prometheus_client import Counter, CollectorRegistry + +r = CollectorRegistry() +c = Counter('requests_total', 'Total requests', registry=r) +c.inc(3) + +assert r.get_sample_value('requests_total') == 3.0 +``` + +### `set_target_info(labels)` + +Set or replace the target metadata labels exposed as a `target_info` metric. +Pass `None` to remove the target info metric. + +```python +REGISTRY.set_target_info({'env': 'production', 'region': 'us-east-1'}) +``` + +### `get_target_info()` + +Return the current target info labels as a `Dict[str, str]`, or `None` if not set. + +```python +info = REGISTRY.get_target_info() +``` + +## The global REGISTRY + +`REGISTRY` is the module-level default instance, created as: + +```python +REGISTRY = CollectorRegistry(auto_describe=True) +``` + +All metric constructors (`Counter`, `Gauge`, etc.) register with `REGISTRY` +by default. Pass `registry=None` to skip registration, or pass a different +`CollectorRegistry` instance to use a custom registry. + +```python +from prometheus_client import Counter, CollectorRegistry + +# skip global registration — useful in tests +c = Counter('my_counter', 'A counter', registry=None) + +# register with a custom registry +r = CollectorRegistry() +c2 = Counter('my_counter', 'A counter', registry=r) +```