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 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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) +``` From 2c84c2a545e8a48f1c9af4f57ac13472591e4216 Mon Sep 17 00:00:00 2001 From: Krishna <107162115+k1chik@users.noreply.github.com> Date: Mon, 4 May 2026 11:42:38 -0400 Subject: [PATCH 06/22] docs: follow-up fixes for collect() generator examples (#1169) (#1172) * docs: clarify collect() generator usage and API Reference snippet context Add a note to the collect() protocol section explaining that yield is idiomatic (generator iterates lazily, no state between scrapes) and a preamble to the API Reference section clarifying that code snippets belong inside a collect() method. Follows up on review feedback in #1169. Signed-off-by: k1chik * docs: split InfoMetricFamily example into two separate blocks The single block with two yield statements looked like one collect() yielding both patterns. Split into labelled prose + code pairs to make clear they are alternatives, not sequential yields. Signed-off-by: k1chik --------- Signed-off-by: k1chik --- docs/content/collector/custom.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/content/collector/custom.md b/docs/content/collector/custom.md index 62c0180a..c1979109 100644 --- a/docs/content/collector/custom.md +++ b/docs/content/collector/custom.md @@ -47,6 +47,11 @@ can also implement `describe`. Returns an iterable of metric family objects (`GaugeMetricFamily`, `CounterMetricFamily`, etc.). Called every time the registry is scraped. +Using `yield` is the idiomatic way to implement `collect()` — it turns the method +into a generator, which the registry iterates lazily without building an intermediate +list first. Each scrape calls `collect()` fresh, so no state carries over between +scrapes. + ### `describe()` Returns an iterable of metric family objects used only to determine the metric @@ -76,6 +81,10 @@ g.add_metric(['eu-west-1'], 5) ## API Reference +The examples below show usage inside a `collect()` method body. Each snippet is +meant to be placed within a custom collector class as shown in the example at the +top of this page. + ### GaugeMetricFamily ```python @@ -232,11 +241,15 @@ InfoMetricFamily(name, documentation, value=None, labels=None) | `value` | `Dict[str, str]` | Key-value label pairs that form the info payload. | | `timestamp` | `float` or `Timestamp` | Optional Unix timestamp. | +Single unlabelled info metric: + ```python -# single unlabelled info metric yield InfoMetricFamily('build', 'Build metadata', value={'version': '1.2.3', 'commit': 'abc123'}) +``` + +Labelled — one info metric per service: -# labelled: one info metric per service +```python 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'}) From 6133347b651fd83a82f0109ff5abadb2de8c3a7c Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 7 May 2026 02:35:55 +0900 Subject: [PATCH 07/22] Use specific exception for duplicate timeseries (#1074) Use sub-class of ValueError instead of ValueError, so that we can distinguish issues caused by wrong input (like invalid name format) from duplicate metrics being registered into the same registry. Signed-off-by: Takashi Kajinami --- prometheus_client/core.py | 3 ++- prometheus_client/registry.py | 14 ++++++++---- tests/test_core.py | 42 +++++++++++++++++------------------ 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/prometheus_client/core.py b/prometheus_client/core.py index 60f93ce1..045e90ab 100644 --- a/prometheus_client/core.py +++ b/prometheus_client/core.py @@ -4,7 +4,7 @@ HistogramMetricFamily, InfoMetricFamily, Metric, StateSetMetricFamily, SummaryMetricFamily, UnknownMetricFamily, UntypedMetricFamily, ) -from .registry import CollectorRegistry, REGISTRY +from .registry import CollectorRegistry, DuplicateTimeseries, REGISTRY from .samples import BucketSpan, Exemplar, NativeHistogram, Sample, Timestamp __all__ = ( @@ -12,6 +12,7 @@ 'CollectorRegistry', 'Counter', 'CounterMetricFamily', + 'DuplicateTimeseries', 'Enum', 'Exemplar', 'Gauge', diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index c2b55d15..d4cfc273 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -1,6 +1,6 @@ import copy from threading import Lock -from typing import Dict, Iterable, List, Optional, Protocol +from typing import Dict, Iterable, List, Optional, Protocol, Set from .metrics_core import Metric @@ -15,6 +15,14 @@ def collect(self) -> Iterable[Metric]: return [] +class DuplicateTimeseries(ValueError): + def __init__(self, duplicates: Set[str]): + msg = 'Duplicated timeseries in CollectorRegistry: {}'.format( + duplicates) + super().__init__(msg) + self.duplicates: Set[str] = duplicates + + class CollectorRegistry: """Metric collector registry. @@ -40,9 +48,7 @@ def register(self, collector: Collector) -> None: names = self._get_names(collector) duplicates = set(self._names_to_collectors).intersection(names) if duplicates: - raise ValueError( - 'Duplicated timeseries in CollectorRegistry: {}'.format( - duplicates)) + raise DuplicateTimeseries(duplicates) for name in names: self._names_to_collectors[name] = collector self._collector_to_names[collector] = names diff --git a/tests/test_core.py b/tests/test_core.py index cdf32bfa..cee4bfb0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,8 +7,8 @@ from prometheus_client import metrics from prometheus_client.core import ( - CollectorRegistry, Counter, CounterMetricFamily, Enum, Gauge, - GaugeHistogramMetricFamily, GaugeMetricFamily, Histogram, + CollectorRegistry, Counter, CounterMetricFamily, DuplicateTimeseries, Enum, + Gauge, GaugeHistogramMetricFamily, GaugeMetricFamily, Histogram, HistogramMetricFamily, Info, InfoMetricFamily, Metric, Sample, StateSetMetricFamily, Summary, SummaryMetricFamily, UntypedMetricFamily, ) @@ -916,41 +916,41 @@ class TestCollectorRegistry(unittest.TestCase): def test_duplicate_metrics_raises(self): registry = CollectorRegistry() Counter('c_total', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'c_total', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'c_total', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'c_created', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Counter, 'c_total', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'c_total', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'c_created', 'help', registry=registry) Gauge('g_created', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'g_created', 'help', registry=registry) - self.assertRaises(ValueError, Counter, 'g', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'g_created', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Counter, 'g', 'help', registry=registry) Summary('s', 'help', registry=registry) - self.assertRaises(ValueError, Summary, 's', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 's_created', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 's_sum', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 's_count', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Summary, 's', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 's_created', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 's_sum', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 's_count', 'help', registry=registry) # We don't currently expose quantiles, but let's prevent future # clashes anyway. - self.assertRaises(ValueError, Gauge, 's', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 's', 'help', registry=registry) Histogram('h', 'help', registry=registry) - self.assertRaises(ValueError, Histogram, 'h', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Histogram, 'h', 'help', registry=registry) # Clashes aggaint various suffixes. - self.assertRaises(ValueError, Summary, 'h', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'h_count', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'h_sum', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'h_bucket', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'h_created', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Summary, 'h', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'h_count', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'h_sum', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'h_bucket', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'h_created', 'help', registry=registry) # The name of the histogram itself is also taken. - self.assertRaises(ValueError, Gauge, 'h', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'h', 'help', registry=registry) Info('i', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 'i_info', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 'i_info', 'help', registry=registry) def test_unregister_works(self): registry = CollectorRegistry() s = Summary('s', 'help', registry=registry) - self.assertRaises(ValueError, Gauge, 's_count', 'help', registry=registry) + self.assertRaises(DuplicateTimeseries, Gauge, 's_count', 'help', registry=registry) registry.unregister(s) Gauge('s_count', 'help', registry=registry) From c0e416da2c959dfc5d9dd67b356efb663dcbd0ec Mon Sep 17 00:00:00 2001 From: PrometheusBot Date: Wed, 3 Jun 2026 01:18:37 +0200 Subject: [PATCH 08/22] Update common Prometheus files (#1179) Signed-off-by: prombot --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index fed02d85..5e6f976d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,4 +3,4 @@ The Prometheus security policy, including how to report vulnerabilities, can be found here: - +[https://prometheus.io/docs/operating/security/](https://prometheus.io/docs/operating/security/) From ff51c4892cf42d69b9be87d0357ed85725ffe98d Mon Sep 17 00:00:00 2001 From: Om Pathak Date: Tue, 23 Jun 2026 14:30:02 -0400 Subject: [PATCH 09/22] docs: note WSL filesystem caveat for MultiProcessCollector (#1183) Signed-off-by: Om Pathak Co-authored-by: Om Pathak --- docs/content/multiprocess/_index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md index f7befef8..cd129930 100644 --- a/docs/content/multiprocess/_index.md +++ b/docs/content/multiprocess/_index.md @@ -35,6 +35,12 @@ between process/Gunicorn runs (before startup is recommended). This environment variable should be set from a start-up shell script, and not directly from Python (otherwise it may not propagate to child processes). +Note: on Windows Subsystem for Linux (WSL), set `PROMETHEUS_MULTIPROC_DIR` to a +Linux-native filesystem path (e.g. `/tmp` or `/home/`) rather than a +Windows-mounted path (e.g. `/mnt/c/...`). On Windows-mounted filesystems the +per-process metric files can be written with an incorrect internal offset, +causing the collector to silently read no data. + **2. Metrics collector**: The application must initialize a new `CollectorRegistry`, and store the From 7819d80b87934465ee39fda23f5cbf979fd1f5ab Mon Sep 17 00:00:00 2001 From: Om Pathak Date: Tue, 23 Jun 2026 21:17:16 -0400 Subject: [PATCH 10/22] Make clear() a no-op for label-less metrics instead of raising AttributeError (#1184) Fixes #707. clear() unconditionally acquired self._lock, which is never created for metrics with no labelnames, causing an AttributeError. Per discussion in the issue (csmarchbanks, roidelapluie, sdfordham, tomprince), a label-less metric has no labelsets to clear, so clear() now treats this as a no-op rather than raising. Signed-off-by: Om Pathak Co-authored-by: Om Pathak --- prometheus_client/metrics.py | 2 ++ tests/test_core.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 4c53b26b..a33bac9f 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -254,6 +254,8 @@ def remove_by_labels(self, labels: dict[str, str]) -> None: def clear(self) -> None: """Remove all labelsets from the metric""" + if not self._labelnames: + return if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: warnings.warn( "Clearing labels has not been implemented in multi-process mode yet", diff --git a/tests/test_core.py b/tests/test_core.py index cee4bfb0..f339b6df 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -59,6 +59,12 @@ def test_reset(self): def test_repr(self): self.assertEqual(repr(self.counter), "prometheus_client.metrics.Counter(c)") + def test_clear_without_labels_is_noop(self): + self.counter.inc() + self.assertEqual(1, self.registry.get_sample_value('c_total')) + self.counter.clear() # should not raise + self.assertEqual(1, self.registry.get_sample_value('c_total')) + def test_negative_increment_raises(self): self.assertRaises(ValueError, self.counter.inc, -1) From a4bbec9edc7bf936af3cf9e829e59ad16083d10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Deveaux?= Date: Tue, 30 Jun 2026 03:48:59 +0200 Subject: [PATCH 11/22] Use object instead of Any for labels() parameters (#1186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Grégoire Deveaux --- prometheus_client/metrics.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index a33bac9f..4c79c583 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -135,7 +135,7 @@ def __init__(self: T, if registry: registry.register(self) - def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: + def labels(self: T, *labelvalues: object, **labelkwargs: object) -> T: """Return the child for the given labelset. All metrics can have labels, allowing grouping of related time series. @@ -173,13 +173,13 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: if labelkwargs: if sorted(labelkwargs) != sorted(self._labelnames): raise ValueError('Incorrect label names') - labelvalues = tuple(str(labelkwargs[l]) for l in self._labelnames) + str_labelvalues = tuple(str(labelkwargs[l]) for l in self._labelnames) else: if len(labelvalues) != len(self._labelnames): raise ValueError('Incorrect label count') - labelvalues = tuple(str(l) for l in labelvalues) + str_labelvalues = tuple(str(l) for l in labelvalues) with self._lock: - if labelvalues not in self._metrics: + if str_labelvalues not in self._metrics: original_name = getattr(self, '_original_name', self._name) namespace = getattr(self, '_namespace', '') @@ -190,17 +190,17 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: for k in ('namespace', 'subsystem', 'unit'): child_kwargs.pop(k, None) - self._metrics[labelvalues] = self.__class__( + self._metrics[str_labelvalues] = self.__class__( original_name, documentation=self._documentation, labelnames=self._labelnames, namespace=namespace, subsystem=subsystem, unit=unit, - _labelvalues=labelvalues, + _labelvalues=str_labelvalues, **child_kwargs ) - return self._metrics[labelvalues] + return self._metrics[str_labelvalues] def remove(self, *labelvalues: Any) -> None: if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: From a96f6f4bc00eeaa97827b8d49be8ce9c434b5a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarom=C3=ADr=20Wysoglad?= Date: Thu, 2 Jul 2026 17:30:57 +0200 Subject: [PATCH 12/22] Add TLS version parameters for start_wsgi_server (#1178) This adds the tls_min_version and tls_max_version parameters for the start_wsgi_server (and start_http_server), allowing to restrict the min/max TLS version negotiated for accessing the server. New unit tests under TestWsgiTLS were added, which test the version parameters as well as the rest of TLS capabilities for the start_wsgi_server (TLS on/off, mTLS), since those previously didn't have any test coverage. Signed-off-by: Jaromir Wysoglad --- prometheus_client/exposition.py | 20 ++++- tests/certs/client-cert.pem | 17 ++++ tests/certs/client-key.pem | 28 ++++++ tests/certs/server-ca.pem | 19 +++++ tests/certs/server-cert.pem | 19 +++++ tests/certs/server-key.pem | 28 ++++++ tests/test_exposition.py | 146 +++++++++++++++++++++++++++++++- 7 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 tests/certs/client-cert.pem create mode 100644 tests/certs/client-key.pem create mode 100644 tests/certs/server-ca.pem create mode 100644 tests/certs/server-cert.pem create mode 100644 tests/certs/server-key.pem diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 2d402a0f..0b63f6f6 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -196,6 +196,8 @@ def _get_ssl_ctx( cafile: Optional[str] = None, capath: Optional[str] = None, client_auth_required: bool = False, + tls_min_version: Optional[ssl.TLSVersion] = None, + tls_max_version: Optional[ssl.TLSVersion] = None ) -> ssl.SSLContext: """Load context supports SSL.""" ssl_cxt = ssl.SSLContext(protocol=protocol) @@ -227,6 +229,11 @@ def _get_ssl_ctx( raise exc_type(f"Cannot load server certificate file {certfile!r} or " f"its private key file {keyfile!r}: {msg}") + if tls_min_version is not None: + ssl_cxt.minimum_version = tls_min_version + if tls_max_version is not None: + ssl_cxt.maximum_version = tls_max_version + return ssl_cxt @@ -240,6 +247,8 @@ def start_wsgi_server( client_capath: Optional[str] = None, protocol: int = ssl.PROTOCOL_TLS_SERVER, client_auth_required: bool = False, + tls_min_version: Optional[ssl.TLSVersion] = None, + tls_max_version: Optional[ssl.TLSVersion] = None ) -> Tuple[WSGIServer, threading.Thread]: """Starts a WSGI server for prometheus metrics as a daemon thread.""" @@ -250,7 +259,16 @@ class TmpServer(ThreadingWSGIServer): app = make_wsgi_app(registry) httpd = make_server(addr, port, app, TmpServer, handler_class=_SilentHandler) if certfile and keyfile: - context = _get_ssl_ctx(certfile, keyfile, protocol, client_cafile, client_capath, client_auth_required) + context = _get_ssl_ctx( + certfile, + keyfile, + protocol, + client_cafile, + client_capath, + client_auth_required, + tls_min_version, + tls_max_version + ) httpd.socket = context.wrap_socket(httpd.socket, server_side=True) t = threading.Thread(target=httpd.serve_forever) t.daemon = True diff --git a/tests/certs/client-cert.pem b/tests/certs/client-cert.pem new file mode 100644 index 00000000..5a054199 --- /dev/null +++ b/tests/certs/client-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICrzCCAZcCFCVu7nbOAxRNKBYa2cl22rdRCtvfMA0GCSqGSIb3DQEBCwUAMBIx +EDAOBgNVBAMMB1Rlc3QgQ0EwHhcNMjYwNTI2MTQyMTU0WhcNMzYwNTIzMTQyMTU0 +WjAWMRQwEgYDVQQDDAt0ZXN0LWNsaWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAJM2/f+8BBKjAlSF/9eiuB2444A2g6V007U5shZhBuPC9cNDxGKM +W1WT3QsgvxOdagdaANkpufqHcYixgFhx/v3lSEzlzd3uXyFMOiK7BdiPsctkqlWZ +VGIuUPpWwvJHWS4R5V1nYNCVsgyZB9XGThl7IknQzBK+tkY2GepqPQXyx1/AP7aB +AlTVBx3r7jTWvkrzvAdrcevrjhOOJUbPmgoiiEGSQeZSMvkdLERujvu5Y3wno2Mg +vcHJxCJwZ5y0RakmTzyAZLHke9lMavgt9F5yEA8G/8SnnXy6HrUp6B6I8Z1eLnof +b3mjUwiGxqDwEVBQHfMtOH6uC7ZE6zbNB1cCAwEAATANBgkqhkiG9w0BAQsFAAOC +AQEAJBchyhT2iyg42qi3uUE1NeCcEb/gM82LeihZbDd38ItUdU7TFqk7wEwsUNJk +k1uwNFVlyMGbHD1IvCAS4L8l/9uPaDG4DmLZ42shFRCaABNEFlKtGPa+YNuhFJ5z +DZKaLaJp8BKpvmoH+iPmsoCDlADwWmLgbdeFBGnHRuOnJBSmEEjQFrnz3jKrX6Lk ++IxVX5Rdp9xOKHBJkj99mgseEYZQk2YFFBCzHX7NNl6wBk/usKJoJeaOPhl9eOGK +VaUOfEdO5NuTRf9nPOORzqFtW3ErNjNjPjKN8VppHtXhRO6dWsmzGnmjVChxoZWC +H0rRJtGcab5HWf94laJilCj7Cw== +-----END CERTIFICATE----- diff --git a/tests/certs/client-key.pem b/tests/certs/client-key.pem new file mode 100644 index 00000000..e218a006 --- /dev/null +++ b/tests/certs/client-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCTNv3/vAQSowJU +hf/XorgduOOANoOldNO1ObIWYQbjwvXDQ8RijFtVk90LIL8TnWoHWgDZKbn6h3GI +sYBYcf795UhM5c3d7l8hTDoiuwXYj7HLZKpVmVRiLlD6VsLyR1kuEeVdZ2DQlbIM +mQfVxk4ZeyJJ0MwSvrZGNhnqaj0F8sdfwD+2gQJU1Qcd6+401r5K87wHa3Hr644T +jiVGz5oKIohBkkHmUjL5HSxEbo77uWN8J6NjIL3BycQicGectEWpJk88gGSx5HvZ +TGr4LfRechAPBv/Ep518uh61KegeiPGdXi56H295o1MIhsag8BFQUB3zLTh+rgu2 +ROs2zQdXAgMBAAECggEAATafUlJzkCRtelKJFiG+YGmr37HTVPOeY8HVe29noKH0 +kkbxNpoPOKiEK7l53wiu8oo7M+RZpucjOEFfnEWtmIchbkIoomR6vpSubVHa+FAl +jYEcvEw2u1ZuuW7Uotg+s8KsVXWVgTKdVJLq/cfpezaeGjtRK0hiH+MF71OFLD2I +UoszlVbTI9FAP+xwuFSJO4xyOirz2VmqgYvQd+qTuuPU2ZjPHFbBUXm6JDpchGJk +WdPp/7qEWKFwDufvgkA5rCFxwsiReQ9HfOS2f4l+7eg2uyjXAClYTt/lYq9PK1Ut +sk/R1Gq5C4S8G0f04Jk8J2bQKS57oRALfaJEps5LUQKBgQDPCPID4w9TnAhWoHtR +L5ps02KLi52sw9F3EVedVX2BjMM/jvRwtzg8I8iaWAE1iL0t8lDQRxbUcgNyWRvi +0/WG/2IESVlciqd4XuITLthj1PDIpIM2iCjQZpZKDqe9bVRPx/AY+UNV1aLSCEbF +xGS+uYoQRGpmiSYnRaQzIzgn0QKBgQC2CDOD1/1sEVFbfsWJTaAM3YjsQ1I5mXFI +HhoWpMKBUogWBXp9dzO4Ae/iRo0QviVUUY2bHlJjCoaQ0FzuiieZIhbOwHG2Qtf3 +JzmUaOSMecwsTeM05XHciwY+sWU/Udw7EzDhVpOHPZR31LKeapchUJGnofnOdkcY +zaHEwiuupwKBgQC6I0bD698Zws1UZRC6G1xxv1N4NtxaOewXawYktHoUgaQBftuS +g4gRufJfogPkR74ekx/JQkDqXF9w7WC+/OZgqzdKt0+afia3eEc2DAYNK6QYIKC/ +5IcdZz5z8t0o2CTXXeEl8uVxRJQQ1dQbdslFGLdijMBE08XzxQ8t0tpoIQKBgH09 +U0QovME3gQQ0SnBXKgDwAp6bCt16RshZfZWKshAL2nlcN5RPCRRWsNa7t56HVGOY +4JaS3BgsS70ivm2YO/pNy+df3FyLzM7M+/6x1F0aB3GL/QCNxDL6q8dCgeh4x88V +OxIuYL4xjg6MFoCL0YMoTa5J8PctxWi5Qc1/0lINAoGASyTZT8emfSDW0+kpqiYw +y+4ftFxqYPAVCf2IWGeQL8TrfkxUiJ7r4Pu5VK9nuYvMR/u4mvnJSG6F1NuJhxzY +4kUnoOnPhITLZjUvNE/xQEuhiJndiehZgSj0JAU6MqGa4pZOxZfqPAfhEF62b5wx +6Wlh7JxQAM+6agEfM3/OS3Y= +-----END PRIVATE KEY----- diff --git a/tests/certs/server-ca.pem b/tests/certs/server-ca.pem new file mode 100644 index 00000000..bbcada29 --- /dev/null +++ b/tests/certs/server-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUMrjGc/qUt+rpFb14OvBFePSMQRIwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNjA1MjYxNDIxNTNaFw0zNjA1MjMx +NDIxNTNaMBIxEDAOBgNVBAMMB1Rlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDFC2wPWOqoFHIYXASjL06yUnG0SlqLqw4oZphdj/q4pbyYPcva +QKI8m4u7Tq/l8JQmbd0sHqGMVYLmACX5ygkppzepz/bVgDeij7RztUgDjJwvUxAC +SEAss0dcE19P57j5ad24xmyV2iP0RK7oXnjapDrH1fhqvIyfybqRxt+50NODRh1t +z471240lDBPOG3ReRZ06dYEpzYaq3PQPatPJnaLGOmsf2NQ8sETTK35vcTMZrXsr +vzrftUCKn4DRyyZ58GE1VpevbVi8z/vHzWBYpRcHTZvfnOz12ijCd2wvnEtTu8TO ++GZS5j84KSF4AI7FlhDMPAS3/dhSLzXgnd4lAgMBAAGjUzBRMB0GA1UdDgQWBBRi +IztvE2ErRLmziv1XxxHCbism8DAfBgNVHSMEGDAWgBRiIztvE2ErRLmziv1XxxHC +bism8DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAjBA50Oadg +Fx6d/cxzHCEd29BluqxjfzYc4TAeTP4NSQPWKXM7BgIqZbHDS/IAK/Xfd1raVCm5 +yv2v4Pe+0fTkezUk5uZjtgZoH5+o4aQL9GdbLO93F4rxzZhpoY92iaXAsoDEntRO +YyDnxLna6csiH4hyvr6Q8Yih/lDysw7DB1jozkFeZtX4ZsVFpsDnYLa5OQjJErpw +9GCM0NEzEW6HlqblsAuBv3DHavUAzfR4obD+Md60BRMxwC5Otl63sS99y81ycs2S +ffW0rLDtgB9hShCXBNeZkGsPrLwBr00nK7bvGaZwOU0Ysuxg+elP3cOXD2UcW7lc +Q+ZBinkQEFpB +-----END CERTIFICATE----- diff --git a/tests/certs/server-cert.pem b/tests/certs/server-cert.pem new file mode 100644 index 00000000..e1f11670 --- /dev/null +++ b/tests/certs/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIUJW7uds4DFE0oFhrZyXbat1EK294wDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNjA1MjYxNDIxNTNaFw0zNjA1MjMx +NDIxNTNaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAOl3Gnz9y+OHr3MIetuX7a/Bvo/lYkwTnmQpT4YC1ycalM0+ +M4wCewrbpn5RoNbH4j+oB6URuiLWlhk3SP9hOwbVbt4rOyCaulAVa5G0B46eqkG6 +ZDi9EWkt+L7Zvfwe+MbdG25xmRumkvc6iv8b+/0mM3t5cm3E8BHpwPi6kfoFmFHn +qREDfI4406tBkbSYUkRQL8qZvSWMo5HgIYmgrkKiWuZNV4bNYKHUOyaOqWvgn8En +ZxrlGt2ezHif/SFz+EaYJZkjBDJ5rMbwxl1BAVZKpSubKt5U59zXLkuachZF5sAY +sEd/LoZxF23/qP5y16C4mVzBYO/0z2udNOW9YAcCAwEAAaNeMFwwGgYDVR0RBBMw +EYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBSUEOHINPcDxtBsfNF5xSTUSqfF +vDAfBgNVHSMEGDAWgBRiIztvE2ErRLmziv1XxxHCbism8DANBgkqhkiG9w0BAQsF +AAOCAQEALxRf0TSusmJXO9pj5t3Njxc6VS+Ts/MnmE1NTloCCkVMEfYYzqROWHME +LOCg2YSnqX6S6Gwk3zjBSuT7aA4SNQ3lD9HndRYa5k+6/6qunnz5Q/g205GJ97us +HqkvdDjLE7lGmM5pIVjoyeMOWiQ6+EOtMt0CmL0nfqJ0DsDUZHVB7NB+MW20EVmC +XiXr52SuvKHDIms3QFkZWOi+scOKleQnvEVU7VqrQamKNtf8fxxGa3/AvjLLJ1ra +q9eB590eajBDdg50FttYLwyA/yb6cqrfIMfrHRj3R//yE2avtUkrKN6FgCtqgpoa +ZsIk6qEmFQWUTglyLwhk0f6m21FKAA== +-----END CERTIFICATE----- diff --git a/tests/certs/server-key.pem b/tests/certs/server-key.pem new file mode 100644 index 00000000..68f2b805 --- /dev/null +++ b/tests/certs/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDpdxp8/cvjh69z +CHrbl+2vwb6P5WJME55kKU+GAtcnGpTNPjOMAnsK26Z+UaDWx+I/qAelEboi1pYZ +N0j/YTsG1W7eKzsgmrpQFWuRtAeOnqpBumQ4vRFpLfi+2b38HvjG3RtucZkbppL3 +Oor/G/v9JjN7eXJtxPAR6cD4upH6BZhR56kRA3yOONOrQZG0mFJEUC/Kmb0ljKOR +4CGJoK5ColrmTVeGzWCh1Dsmjqlr4J/BJ2ca5Rrdnsx4n/0hc/hGmCWZIwQyeazG +8MZdQQFWSqUrmyreVOfc1y5LmnIWRebAGLBHfy6GcRdt/6j+cteguJlcwWDv9M9r +nTTlvWAHAgMBAAECggEAJ2/ZlxyOGPC+J+naSwbWfTZ2mLsQSDaWLmg2CTaonm/k +i+kCbxeqLjLdZIAoca+RHdyl8fHVJfZmo3rNx2nmvShHkprt4XuRll6P7axiDGrr +6q9wJ490hfZgiuigKZsXvgvyis0ApoWUVNPcT+yru978mlJxDG7UeMoqMTne18NQ +jKw8zTPrcDnSqxdzNs9hGcM/RYDAnNM1jFqnmJpJF9nTMf9gDOYQMJCb7yUZVOj/ +ccyc2AjTdp6corXZpSqiYHz4UwfZ0Wvf7BXwBAOxdYnM6qlZXc+RKZJYnlUbSNHZ +IRkIZXsRILAIgpL1fAhMAQodyFMjNU9wQAkva6j49QKBgQD8ohxWR3nKg9KXhSTU +7sv1E5WzUCEAX5gJTSRx9S8lAAotGEByaTO/pWcYtpo+jXukukERhDdnsxR7SJAf +7jOYLUQgqFgZXD/U7vaCNuYoRG0E1MwSqZnVIXOpZW2z6j0PwcbXLJVeLDpDctn1 +Ga7VvYiv7/KuvRSQfkSOrH8USwKBgQDsk5lLSOQb7Ke53gfHjeGMU4646JcNDFnD +hWtXQujABwQZmSDWudCvsLWwDr0O0kUDqDcPCEMhbNYo388DwowzHnE35Tzmzo5D +R/YZ+Mh+UuW+e5gLxdmn7Z1xENKft/4ceOkeBBExQumZYYsaNVA7znzotaPSjRfH +J36QHsG1tQKBgQC9NMBaUf/CD4ZaWqpiG1J/gyJ8AEgnGnEojjD8dC/R2zzD10T1 +KxtJrhwPozrUHGx8y83Ny6MfNDzjtE3UzDayAzzh5JLOs4tO84WFso4fnFe15ZXN +aF5BBGO2e7N0qrr+oRdFsitQM3mTaGIashiCFghYFDJCcnQDX74CyOgIDwKBgCht +JHHf99LpwtOZJF0uWo9/K9FfNYiuRpyJrQkRTvKZgFLbfugSgp2zJaj7K8Vfmxl/ +4kC4WbhZf9MmQ5rR4OFPX2t8ycZrH5ZRsrVHdQNZKRc+yYGhgosWqKPMiyFt8Idv +Be7yJPn1BDQInhuRZq+BnoipmV/+akTG8/Kuvs1NAoGALLN5lPRdZdTvjoiougt9 +MxqGfBR9H8PfAo/Eu8Et5Otln3P1Vl3SgeiwDGVb59avfBQ5N4UecTHMp/2jbxOw +w/AzvF9LMLtXKdyqnOeBfP2xgbEZ9chLeoePEYkATpQfgjs7qmzK+mwZin5EyjFa +tqn7AnX5AnDRtPIC10Z05rA= +-----END PRIVATE KEY----- diff --git a/tests/test_exposition.py b/tests/test_exposition.py index a3c97820..1885480f 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,9 +1,11 @@ import gzip from http.server import BaseHTTPRequestHandler, HTTPServer import os +import ssl import threading import time import unittest +import urllib import pytest @@ -16,7 +18,7 @@ from prometheus_client.core import GaugeHistogramMetricFamily, Timestamp from prometheus_client.exposition import ( basic_auth_handler, choose_encoder, default_handler, MetricsHandler, - passthrough_redirect_handler, tls_auth_handler, + passthrough_redirect_handler, start_wsgi_server, tls_auth_handler, ) import prometheus_client.openmetrics.exposition as openmetrics @@ -633,6 +635,148 @@ def test_prom_no_version(self): self.assert_is_prom(exp) +class TestWsgiTLS(unittest.TestCase): + def setUp(self): + self.certs_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), 'certs' + ) + self.httpd = None + self.t = None + + def tearDown(self): + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() + self.t.join() + + def _assert_tls_connection( + self, + server_kwargs, + use_server_tls=True, + client_tls_kwargs=None, + request_tls_version=ssl.TLSVersion.TLSv1_3, + expect_exception=None + ): + self.httpd, self.t = start_wsgi_server(port=0, **server_kwargs) + port = self.httpd.server_address[1] + + if use_server_tls: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.minimum_version = request_tls_version + ctx.maximum_version = request_tls_version + ctx.load_verify_locations( + os.path.join(self.certs_dir, "server-ca.pem") + ) + + if client_tls_kwargs is not None: + ctx.load_cert_chain(**client_tls_kwargs) + + url = f"https://localhost:{port}/metrics" + else: + ctx = None + url = f"http://localhost:{port}/metrics" + + if expect_exception is not None: + self.assertRaises( + expect_exception, + urllib.request.urlopen, + url, + context=ctx + ) + else: + response = urllib.request.urlopen(url, context=ctx) + self.assertEqual(response.status, 200) + + def test_tls_disabled(self): + self._assert_tls_connection(server_kwargs={}, use_server_tls=False) + + def test_tls_enabled(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + } + self._assert_tls_connection(server_kwargs) + + def test_tls_untrusted_server_cert_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "cert.pem"), + "keyfile": os.path.join(self.certs_dir, "key.pem"), + } + self._assert_tls_connection( + server_kwargs, + expect_exception=urllib.error.URLError + ) + + def test_tls_versions_configured_correctly(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_min_version": ssl.TLSVersion.TLSv1_2, + "tls_max_version": ssl.TLSVersion.TLSv1_3, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_2 + ) + + def test_tls_using_lower_version_than_min_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_min_version": ssl.TLSVersion.TLSv1_3, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_2, + expect_exception=urllib.error.URLError + ) + + def test_tls_using_higher_version_than_max_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "tls_max_version": ssl.TLSVersion.TLSv1_2, + } + self._assert_tls_connection( + server_kwargs, + request_tls_version=ssl.TLSVersion.TLSv1_3, + expect_exception=urllib.error.URLError + ) + + def test_mtls_enabled(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "client_auth_required": True, + "client_cafile": os.path.join(self.certs_dir, "server-ca.pem"), + } + client_tls_kwargs = { + "certfile": os.path.join(self.certs_dir, "client-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "client-key.pem") + } + self._assert_tls_connection( + server_kwargs, + client_tls_kwargs=client_tls_kwargs + ) + + def test_mtls_untrusted_client_cert_raises(self): + server_kwargs = { + "certfile": os.path.join(self.certs_dir, "server-cert.pem"), + "keyfile": os.path.join(self.certs_dir, "server-key.pem"), + "client_auth_required": True, + "client_cafile": os.path.join(self.certs_dir, "server-cert.pem"), + } + client_tls_kwargs = { + "certfile": os.path.join(self.certs_dir, "cert.pem"), + "keyfile": os.path.join(self.certs_dir, "key.pem") + } + self._assert_tls_connection( + server_kwargs, + client_tls_kwargs=client_tls_kwargs, + expect_exception=ssl.SSLError + ) + + @pytest.mark.parametrize("scenario", [ { "name": "empty string", From a39a697c805576781da71f70d1aa78c972ba9288 Mon Sep 17 00:00:00 2001 From: sean-kim05 Date: Wed, 15 Jul 2026 14:58:17 -0700 Subject: [PATCH 13/22] Validate Enum arguments before registering the collector (#1189) Enum.__init__ called super().__init__() -- which registers the collector in the CollectorRegistry -- before validating that states is non-empty and that the metric name does not overlap a label name. When either check failed, the ValueError was raised as expected, but a half-built Enum (whose _states was never assigned) had already been registered. That left the registry in a broken state: the name was permanently taken, so recreating the metric raised 'Duplicated timeseries', and any subsequent scrape crashed with AttributeError: 'Enum' object has no attribute '_states' when _child_samples iterated self._states. A realistic trigger is building the states list from configuration that turns out to be empty. Gauge and Histogram already validate before calling super().__init__(); this moves Enum's two guards ahead of registration to match, so a failed constructor leaves the registry untouched. Add test_failed_init_does_not_pollute_registry, which asserts that after two failed Enum constructions the name is still free, the metric can be created, and the registry scrapes cleanly. Signed-off-by: Sean Kim --- prometheus_client/metrics.py | 8 ++++---- tests/test_core.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 4c79c583..e3fe5323 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -769,6 +769,10 @@ def __init__(self, _labelvalues: Optional[Sequence[str]] = None, states: Optional[Sequence[str]] = None, ): + if name in labelnames: + raise ValueError(f'Overlapping labels for Enum metric: {name}') + if not states: + raise ValueError(f'No states provided for Enum metric: {name}') super().__init__( name=name, documentation=documentation, @@ -779,10 +783,6 @@ def __init__(self, registry=registry, _labelvalues=_labelvalues, ) - if name in labelnames: - raise ValueError(f'Overlapping labels for Enum metric: {name}') - if not states: - raise ValueError(f'No states provided for Enum metric: {name}') self._kwargs['states'] = self._states = states def _metric_init(self) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index f339b6df..2d17d5c5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -595,6 +595,19 @@ def test_overlapping_labels(self): with pytest.raises(ValueError): Enum('e', 'help', registry=None, labelnames=['e']) + def test_failed_init_does_not_pollute_registry(self): + registry = CollectorRegistry() + # A validation failure in __init__ must not leave a half-built collector + # registered: otherwise the name stays permanently taken and any later + # scrape of the registry crashes on the missing _states attribute. + with pytest.raises(ValueError): + Enum('task_state', 'help', states=None, registry=registry) + with pytest.raises(ValueError): + Enum('task_state', 'help', states=['a'], labelnames=['task_state'], registry=registry) + # The name is still free, so a correct definition registers and scrapes. + Enum('task_state', 'help', states=['a', 'b'], registry=registry) + self.assertEqual(1, registry.get_sample_value('task_state', {'task_state': 'a'})) + class TestMetricWrapper(unittest.TestCase): def setUp(self): From fb2351fe0463581b85ac38f7fc870f2c564b6795 Mon Sep 17 00:00:00 2001 From: sean-kim05 Date: Wed, 15 Jul 2026 15:01:13 -0700 Subject: [PATCH 14/22] Do not emit a leading zero in floatToGoString exponents >= 10 (#1190) floatToGoString reproduces Go's strconv.FormatFloat(f, 'g', -1, 64), which pads a float's exponent to a minimum of two digits. The exponent was formatted as 'e+0{dot - 1}', which hard-codes a single leading zero and only produces the right width while dot - 1 is a single digit. Once the exponent reaches two digits (values >= 1e10, whose repr is still plain decimal) it over-pads, e.g. 1e10 became '1e+010' instead of Go's '1e+10'. That affects any emitted sample value or le/quantile bucket boundary at or above 1e10. Use '{dot - 1:02d}' so the exponent is zero-padded to a minimum of two digits and not beyond, matching Go. Add tests/test_utils.py covering both the previously-correct single-digit exponents and the two-digit exponents that regressed. Signed-off-by: Sean Kim --- prometheus_client/utils.py | 2 +- tests/test_utils.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils.py diff --git a/prometheus_client/utils.py b/prometheus_client/utils.py index 87b75ca8..52c852ab 100644 --- a/prometheus_client/utils.py +++ b/prometheus_client/utils.py @@ -21,7 +21,7 @@ def floatToGoString(d): # We only need to care about positive values for le/quantile. if d > 0 and dot > 6: mantissa = f'{s[0]}.{s[1:dot]}{s[dot + 1:]}'.rstrip('0.') - return f'{mantissa}e+0{dot - 1}' + return f'{mantissa}e+{dot - 1:02d}' return s diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..50eac33c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,18 @@ +import unittest + +from prometheus_client.utils import floatToGoString + + +class TestFloatToGoString(unittest.TestCase): + def test_exponent_two_digits_has_no_leading_zero(self): + # floatToGoString mirrors Go's strconv.FormatFloat(f, 'g', -1, 64), + # which pads the exponent to a minimum of two digits. A two-digit + # exponent must not gain a spurious leading zero. + self.assertEqual('1e+10', floatToGoString(1e10)) + self.assertEqual('1e+15', floatToGoString(1e15)) + self.assertEqual('1.234567890123e+12', floatToGoString(1234567890123.0)) + + def test_exponent_one_digit_is_zero_padded(self): + # Single-digit exponents keep the two-digit zero padding. + self.assertEqual('1e+06', floatToGoString(1e6)) + self.assertEqual('1.234567e+06', floatToGoString(1234567.0)) From 75603d7871d6343ca3118134613ea07fc06205ee Mon Sep 17 00:00:00 2001 From: PrometheusBot Date: Thu, 16 Jul 2026 00:07:45 +0200 Subject: [PATCH 15/22] Update common Prometheus files (#1194) Signed-off-by: prombot --- .github/workflows/approve-workflows.yml | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/approve-workflows.yml diff --git a/.github/workflows/approve-workflows.yml b/.github/workflows/approve-workflows.yml new file mode 100644 index 00000000..adbcb481 --- /dev/null +++ b/.github/workflows/approve-workflows.yml @@ -0,0 +1,27 @@ +--- +### +# This action is synced from https://github.com/prometheus/prometheus +### +name: Approve pending workflows + +on: + issue_comment: + types: [created] + +permissions: read-all + +jobs: + approve: + if: >- + github.event.issue.pull_request && + github.event.comment.body == '/workflow-approve' && + github.repository_owner == 'prometheus' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + pull-requests: write + steps: + - uses: prometheus/promci/approve_workflows@370e8c15dcec50043cbe66f2f34633d9efc0a190 + with: + github_token: ${{ github.token }} From d0b497ffe069865537e55af52cb00c329d58a6f0 Mon Sep 17 00:00:00 2001 From: PrometheusBot Date: Tue, 21 Jul 2026 09:29:15 +0200 Subject: [PATCH 16/22] Update common Prometheus files (#1195) Signed-off-by: prombot --- .github/workflows/approve-workflows.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/approve-workflows.yml b/.github/workflows/approve-workflows.yml index adbcb481..f372012f 100644 --- a/.github/workflows/approve-workflows.yml +++ b/.github/workflows/approve-workflows.yml @@ -15,13 +15,13 @@ jobs: if: >- github.event.issue.pull_request && github.event.comment.body == '/workflow-approve' && - github.repository_owner == 'prometheus' + (github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community') runs-on: ubuntu-latest permissions: actions: write contents: read pull-requests: write steps: - - uses: prometheus/promci/approve_workflows@370e8c15dcec50043cbe66f2f34633d9efc0a190 + - uses: prometheus/promci/approve_workflows@370e8c15dcec50043cbe66f2f34633d9efc0a190 # v0.9.0 with: github_token: ${{ github.token }} From 769b4150e0970f664095cba30e872dbac45ba9b0 Mon Sep 17 00:00:00 2001 From: MELVIN CHACKO JOSE <165891174+MelvinjoseC@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:48:40 +0530 Subject: [PATCH 17/22] fix: make test suite pass on Windows and Python 3.12+ (#1185) * fix: make test suite pass on Windows and Python 3.12+ - Fix event loop issues in ASGI tests under newer asgiref by wrapping communicator setup in an async coroutine run on the loop. - Fix mmap file locking (PermissionError: [WinError 32]) on Windows in multiprocess tests by closing open database handles before unlinking/removing directories. - Skip case-sensitive env var deprecation warning assertion on Windows where environment variables are case-insensitive. - Conditionally define the parser benchmark test to avoid failure when pytest-benchmark is not installed. Signed-off-by: MelvinjoseC <165891174+MelvinjoseC@users.noreply.github.com> * fix: resolve lint/typing issues and add Windows CI coverage Signed-off-by: MelvinjoseC <165891174+MelvinjoseC@users.noreply.github.com> --------- Signed-off-by: MelvinjoseC <165891174+MelvinjoseC@users.noreply.github.com> --- .github/workflows/ci.yaml | 14 ++++++++++++++ prometheus_client/values.py | 24 ++++++++++++++++++++++++ tests/test_asgi.py | 27 ++++++++++++++++----------- tests/test_multiprocess.py | 22 ++++++++++++++++++---- tests/test_parser.py | 18 +++++++++++------- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a7e4e094..f925b88c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -108,3 +108,17 @@ jobs: run: tox env: TOXENV: pypy${{ env.PYTHON_VERSION }} + + test_windows: + runs-on: windows-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.12' + - name: Install dependencies + run: pip install tox "virtualenv<20.22.0" + - name: Run tests + run: python -m tox -e py3.12 + diff --git a/prometheus_client/values.py b/prometheus_client/values.py index 6ff85e3b..16c745ed 100644 --- a/prometheus_client/values.py +++ b/prometheus_client/values.py @@ -1,9 +1,18 @@ import os from threading import Lock +from typing import Callable, List import warnings from .mmap_dict import mmap_key, MmapedDict +_multi_process_cleanups: List[Callable[[], None]] = [] + + +def close_all_multiprocess_files(): + for cleanup in _multi_process_cleanups: + cleanup() + _multi_process_cleanups.clear() + class MutexValue: """A float protected by a mutex.""" @@ -52,6 +61,13 @@ def MultiProcessValue(process_identifier=os.getpid): # This avoids the need to also have mutexes in __MmapDict. lock = Lock() + def cleanup(): + for f in files.values(): + f.close() + files.clear() + values.clear() + _multi_process_cleanups.append(cleanup) + class MmapedValue: """A float protected by a mutex backed by a per-process mmaped file.""" @@ -122,6 +138,14 @@ def get_exemplar(self): # TODO: Implement exemplars for multiprocess mode. return None + @classmethod + def close_all_files(cls): + with lock: + for f in files.values(): + f.close() + files.clear() + values.clear() + return MmapedValue diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 6e795e21..028dac2b 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -32,19 +32,24 @@ def setUp(self): # Setup ASGI scope self.scope = {} setup_testing_defaults(self.scope) + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) self.communicator = None def tearDown(self): if self.communicator: - asyncio.new_event_loop().run_until_complete( + self.loop.run_until_complete( self.communicator.wait() ) + self.loop.close() def seed_app(self, app): - self.communicator = ApplicationCommunicator(app, self.scope) + async def _init(): + self.communicator = ApplicationCommunicator(app, self.scope) + self.loop.run_until_complete(_init()) def send_input(self, payload): - asyncio.new_event_loop().run_until_complete( + self.loop.run_until_complete( self.communicator.send_input(payload) ) @@ -52,7 +57,7 @@ def send_default_request(self): self.send_input({"type": "http.request", "body": b""}) def get_output(self): - output = asyncio.new_event_loop().run_until_complete( + output = self.loop.run_until_complete( self.communicator.receive_output(0) ) return output @@ -148,9 +153,9 @@ def test_gzip(self): increments = 2 self.increment_metrics(metric_name, help_text, increments) app = make_asgi_app(self.registry) - self.seed_app(app) # Send input with gzip header. self.scope["headers"] = [(b"accept-encoding", b"gzip")] + self.seed_app(app) self.send_input({"type": "http.request", "body": b""}) # Assert outputs are compressed. outputs = self.get_all_output() @@ -164,9 +169,9 @@ def test_gzip_disabled(self): self.increment_metrics(metric_name, help_text, increments) # Disable compression explicitly. app = make_asgi_app(self.registry, disable_compression=True) - self.seed_app(app) # Send input with gzip header. self.scope["headers"] = [(b"accept-encoding", b"gzip")] + self.seed_app(app) self.send_input({"type": "http.request", "body": b""}) # Assert outputs are not compressed. outputs = self.get_all_output() @@ -175,8 +180,8 @@ def test_gzip_disabled(self): def test_openmetrics_encoding(self): """Response content type is application/openmetrics-text when appropriate Accept header is in request""" app = make_asgi_app(self.registry) - self.seed_app(app) self.scope["headers"] = [(b"Accept", b"application/openmetrics-text; version=1.0.0")] + self.seed_app(app) self.send_input({"type": "http.request", "body": b""}) content_type = self.get_response_header_value('Content-Type').split(";")[0] @@ -204,8 +209,8 @@ def test_qs_parsing(self): 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.seed_app(app) self.send_default_request() outputs = self.get_all_output() @@ -220,7 +225,7 @@ def test_qs_parsing(self): self.assert_not_metrics(output, *metrics[i_2]) - asyncio.new_event_loop().run_until_complete( + self.loop.run_until_complete( self.communicator.wait() ) @@ -237,8 +242,8 @@ def test_qs_parsing_multi(self): 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.seed_app(app) self.send_default_request() outputs = self.get_all_output() @@ -249,6 +254,6 @@ def test_qs_parsing_multi(self): self.assert_metrics(output, *metrics[1]) self.assert_not_metrics(output, *metrics[2]) - asyncio.new_event_loop().run_until_complete( + self.loop.run_until_complete( self.communicator.wait() ) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index ee0c7423..eab93f50 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -24,21 +24,26 @@ def setUp(self): def tearDown(self): os.environ.pop('prometheus_multiproc_dir', None) os.environ.pop('PROMETHEUS_MULTIPROC_DIR', None) + values.close_all_multiprocess_files() values.ValueClass = MutexValue shutil.rmtree(self.tempdir) def test_deprecation_warning(self): os.environ['prometheus_multiproc_dir'] = self.tempdir with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") values.ValueClass = get_value_class() registry = CollectorRegistry() collector = MultiProcessCollector(registry) Counter('c', 'help', registry=None) assert os.environ['PROMETHEUS_MULTIPROC_DIR'] == self.tempdir - assert len(w) == 1 - assert issubclass(w[-1].category, DeprecationWarning) - assert "PROMETHEUS_MULTIPROC_DIR" in str(w[-1].message) + if os.name != 'nt': + assert len(w) == 1 + assert issubclass(w[-1].category, DeprecationWarning) + assert "PROMETHEUS_MULTIPROC_DIR" in str(w[-1].message) + else: + assert len(w) == 0 def test_mark_process_dead_respects_lowercase(self): os.environ['prometheus_multiproc_dir'] = self.tempdir @@ -61,8 +66,9 @@ def _value_class(self): def tearDown(self): del os.environ['PROMETHEUS_MULTIPROC_DIR'] - shutil.rmtree(self.tempdir) + values.close_all_multiprocess_files() values.ValueClass = MutexValue + shutil.rmtree(self.tempdir) def test_counter_adds(self): c1 = Counter('c', 'help', registry=None) @@ -119,6 +125,7 @@ def test_gauge_liveall(self): g2.set(2) self.assertEqual(1, self.registry.get_sample_value('g', {'pid': '123'})) self.assertEqual(2, self.registry.get_sample_value('g', {'pid': '456'})) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(None, self.registry.get_sample_value('g', {'pid': '123'})) self.assertEqual(2, self.registry.get_sample_value('g', {'pid': '456'})) @@ -140,6 +147,7 @@ def test_gauge_livemin(self): g1.set(1) g2.set(2) self.assertEqual(1, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(2, self.registry.get_sample_value('g')) @@ -160,6 +168,7 @@ def test_gauge_livemax(self): g1.set(2) g2.set(1) self.assertEqual(2, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(1, self.registry.get_sample_value('g')) @@ -171,6 +180,7 @@ def test_gauge_sum(self): g1.set(1) g2.set(2) self.assertEqual(3, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(3, self.registry.get_sample_value('g')) @@ -182,6 +192,7 @@ def test_gauge_livesum(self): g1.set(1) g2.set(2) self.assertEqual(3, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(2, self.registry.get_sample_value('g')) @@ -192,6 +203,7 @@ def test_gauge_mostrecent(self): g2.set(2) g1.set(1) self.assertEqual(1, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(1, self.registry.get_sample_value('g')) @@ -202,6 +214,7 @@ def test_gauge_livemostrecent(self): g2.set(2) g1.set(1) self.assertEqual(1, self.registry.get_sample_value('g')) + values.close_all_multiprocess_files() mark_process_dead(123, os.environ['PROMETHEUS_MULTIPROC_DIR']) self.assertEqual(2, self.registry.get_sample_value('g')) @@ -626,6 +639,7 @@ def test_corruption_detected(self): list(self.d.read_all_values()) def tearDown(self): + self.d.close() os.unlink(self.tempfile) diff --git a/tests/test_parser.py b/tests/test_parser.py index 49c4dc8c..8436dd0c 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,3 +1,4 @@ +import importlib.util import math import unittest @@ -375,8 +376,11 @@ def collect(self): self.assertEqual(text.encode('utf-8'), generate_latest(registry, ALLOWUTF8)) -def test_benchmark_text_string_to_metric_families(benchmark): - text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. +HAS_BENCHMARK = importlib.util.find_spec("pytest_benchmark") is not None + +if HAS_BENCHMARK: + def test_benchmark_text_string_to_metric_families(benchmark): + text = """# HELP go_gc_duration_seconds A summary of the GC invocation durations. # TYPE go_gc_duration_seconds summary go_gc_duration_seconds{quantile="0"} 0.013300656000000001 go_gc_duration_seconds{quantile="0.25"} 0.013638736 @@ -422,11 +426,11 @@ def test_benchmark_text_string_to_metric_families(benchmark): hist_sum 2 """ - @benchmark - def _(): - # We need to convert the generator to a full list in order to - # accurately measure the time to yield everything. - return list(text_string_to_metric_families(text)) + @benchmark + def _(): + # We need to convert the generator to a full list in order to + # accurately measure the time to yield everything. + return list(text_string_to_metric_families(text)) if __name__ == '__main__': From 5b094790a85d8d024b442c7adea9b0ca5ccd8335 Mon Sep 17 00:00:00 2001 From: sean-kim05 Date: Fri, 24 Jul 2026 12:20:56 -0700 Subject: [PATCH 18/22] Parse the native histogram sum as a float (#1192) _parse_nh_struct parsed the sum field with int(), so a native histogram whose sum is not integral failed to parse with ValueError: invalid literal for int() with base 10: '100.5' NativeHistogram.sum_value is a float and the OpenMetrics writer emits the sum verbatim (e.g. sum:100.5), so the library could not parse its own output for the common case of a non-integer sum. Parse sum with float() to match; count, schema and zero_count remain int as they are genuinely integers. Add test_native_histogram_float_sum, which parses a native histogram with a fractional sum and checks it round-trips to sum_value 100.5. Signed-off-by: Sean Kim --- prometheus_client/openmetrics/parser.py | 2 +- tests/openmetrics/test_parser.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py index d967e83b..0c5c9c41 100644 --- a/prometheus_client/openmetrics/parser.py +++ b/prometheus_client/openmetrics/parser.py @@ -315,7 +315,7 @@ def _parse_nh_struct(text): deltas = dict(re_deltas.findall(text)) count_value = int(items['count']) - sum_value = int(items['sum']) + sum_value = float(items['sum']) schema = int(items['schema']) zero_threshold = float(items['zero_threshold']) zero_count = int(items['zero_count']) diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py index aeaa6ed6..79a2158c 100644 --- a/tests/openmetrics/test_parser.py +++ b/tests/openmetrics/test_parser.py @@ -218,6 +218,18 @@ def test_native_histogram(self): hfm.add_sample("nativehistogram", None, None, 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))) self.assertEqual([hfm], families) + def test_native_histogram_float_sum(self): + families = text_string_to_metric_families("""# TYPE nativehistogram histogram +# HELP nativehistogram Is a basic example of a native histogram +nativehistogram {count:24,sum:100.5,schema:0,zero_threshold:0.001,zero_count:4,positive_spans:[0:2,1:2],negative_spans:[0:2,1:2],positive_deltas:[2,1,-3,3],negative_deltas:[2,1,-2,3]} +# EOF +""") + families = list(families) + + hfm = HistogramMetricFamily("nativehistogram", "Is a basic example of a native histogram") + hfm.add_sample("nativehistogram", None, None, None, None, NativeHistogram(24, 100.5, 0, 0.001, 4, (BucketSpan(0, 2), BucketSpan(1, 2)), (BucketSpan(0, 2), BucketSpan(1, 2)), (2, 1, -3, 3), (2, 1, -2, 3))) + self.assertEqual([hfm], families) + def test_native_histogram_utf8(self): families = text_string_to_metric_families("""# TYPE "native{histogram" histogram # HELP "native{histogram" Is a basic example of a native histogram From 6e22fb551bdc9b4d81e73791dc1644cca2221bb1 Mon Sep 17 00:00:00 2001 From: sean-kim05 Date: Fri, 24 Jul 2026 12:23:54 -0700 Subject: [PATCH 19/22] Remove nameless collectors from the registry on unregister (#1191) When a collector is registered under support_collectors_without_names with no metric names, register() appends it to _collectors_without_names in addition to the usual bookkeeping. unregister() only cleaned up _collector_to_names and _names_to_collectors, so the collector stayed in _collectors_without_names and kept being collected. A normal collect() no longer returned the collector's metrics, but RestrictedRegistry.collect() -- which seeds its collector set from _collectors_without_names -- still did, so an unregistered collector's samples reappeared under restricted_registry(). unregister() now also drops the collector from that list. Add test_unregister_removes_no_names_collector, asserting a nameless collector is no longer collected by a restricted registry after it has been unregistered. Signed-off-by: Sean Kim --- prometheus_client/registry.py | 2 ++ tests/test_core.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index d4cfc273..63f8ab03 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -61,6 +61,8 @@ def unregister(self, collector: Collector) -> None: for name in self._collector_to_names[collector]: del self._names_to_collectors[name] del self._collector_to_names[collector] + if collector in self._collectors_without_names: + self._collectors_without_names.remove(collector) def _get_names(self, collector): """Get names of timeseries the collector produces and clashes with.""" diff --git a/tests/test_core.py b/tests/test_core.py index 2d17d5c5..3aa19c24 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -973,6 +973,21 @@ def test_unregister_works(self): registry.unregister(s) Gauge('s_count', 'help', registry=registry) + def test_unregister_removes_no_names_collector(self): + registry = CollectorRegistry(support_collectors_without_names=True) + + class NamelessCollector: + def collect(self): + return [GaugeMetricFamily('foo', 'help', value=42)] + + collector = NamelessCollector() + registry.register(collector) + registry.unregister(collector) + # A nameless collector must be removed from the collectors-without-names + # list too, otherwise a restricted registry keeps collecting it after it + # was unregistered. + self.assertEqual([], list(registry.restricted_registry(['foo']).collect())) + def custom_collector(self, metric_family, registry): class CustomCollector: def collect(self): From fb072c2d312bd55cce11f27e7efbc1b0104e2a56 Mon Sep 17 00:00:00 2001 From: sean-kim05 Date: Fri, 24 Jul 2026 12:30:29 -0700 Subject: [PATCH 20/22] Fix operator precedence allowing exemplars on any metric type (#1188) _is_valid_exemplar_metric() guarded histograms with if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name: Because 'and' binds tighter than 'or', this parsed as (metric.type in ('histogram') and sample.name.endswith('_bucket')) or (sample.name == metric.name) so the trailing 'sample.name == metric.name' clause fired for every metric type. As a result the OpenMetrics writer emitted exemplars on gauges, info, stateset, summary and untyped metrics whenever a sample name equalled the metric name -- output that the library's own OpenMetrics parser rejects (only histogram/gaugehistogram buckets, counter _total, and native histograms may carry exemplars). Group the histogram condition correctly so the same-name clause (which exists to allow native-histogram exemplars) only applies to histograms. While here, replace the 'metric.type in (...)' single-string membership checks with '==' -- they were doing substring matching, not the intended equality. Add test_gauge_exemplar, which asserts a gauge sample carrying an exemplar raises ValueError, matching the existing untyped/non-bucket exemplar tests. Signed-off-by: Sean Kim --- prometheus_client/openmetrics/exposition.py | 4 ++-- tests/openmetrics/test_exposition.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py index 5e69e463..5a7711b0 100644 --- a/prometheus_client/openmetrics/exposition.py +++ b/prometheus_client/openmetrics/exposition.py @@ -25,9 +25,9 @@ def _is_valid_exemplar_metric(metric, sample): if metric.type == 'counter' and sample.name.endswith('_total'): return True - if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'): + if metric.type == 'gaugehistogram' and sample.name.endswith('_bucket'): return True - if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name: + if metric.type == 'histogram' and (sample.name.endswith('_bucket') or sample.name == metric.name): return True return False diff --git a/tests/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py index a3ed0d6e..a849f5fa 100644 --- a/tests/openmetrics/test_exposition.py +++ b/tests/openmetrics/test_exposition.py @@ -347,6 +347,21 @@ def collect(self): with self.assertRaises(ValueError): generate_latest(self.registry) + def test_gauge_exemplar(self) -> None: + class MyCollector: + def collect(self): + metric = Metric("gg", "A gauge", 'gauge') + # A sample whose name equals the metric name must not be + # treated as exemplar-eligible just because it matches; + # only histogram/gaugehistogram buckets, counter _total, and + # native histograms may carry exemplars. + metric.add_sample("gg", {}, 1, None, Exemplar({'a': 'b'}, 0.5)) + yield metric + + self.registry.register(MyCollector()) + with self.assertRaises(ValueError): + generate_latest(self.registry) + def test_gaugehistogram(self) -> None: self.custom_collector( GaugeHistogramMetricFamily('gh', 'help', buckets=[('1.0', 4), ('+Inf', (5))], gsum_value=7)) From 9b6b971f903e7a08f88da5a95048b7a2a28f55a5 Mon Sep 17 00:00:00 2001 From: Chris Marchbanks Date: Fri, 24 Jul 2026 13:33:30 -0600 Subject: [PATCH 21/22] Release v0.26.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 336cfb4f..8b39c12d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.25.0" +version = "0.26.0" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" From 209834673397d48340e3b3bde6dfd4383087a359 Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Thu, 13 Aug 2026 23:59:29 +0200 Subject: [PATCH 22/22] Add missing imports in fastapi-gunicorn.md (#1201) Signed-off-by: Frank Sauerburger --- docs/content/exporting/http/fastapi-gunicorn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/exporting/http/fastapi-gunicorn.md b/docs/content/exporting/http/fastapi-gunicorn.md index 148a36d7..6d0ec329 100644 --- a/docs/content/exporting/http/fastapi-gunicorn.md +++ b/docs/content/exporting/http/fastapi-gunicorn.md @@ -23,7 +23,7 @@ For Multiprocessing support, use this modified code snippet. Full multiprocessin ```python from fastapi import FastAPI -from prometheus_client import make_asgi_app +from prometheus_client import make_asgi_app, CollectorRegistry, multiprocess app = FastAPI(debug=False)