diff --git a/SECURITY.md b/SECURITY.md
index fed02d8..5e6f976 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/)
diff --git a/debian/patches/0002-Update-pyproject.toml.patch b/debian/patches/0002-Update-pyproject.toml.patch
index fe631fd..f4a0060 100644
--- a/debian/patches/0002-Update-pyproject.toml.patch
+++ b/debian/patches/0002-Update-pyproject.toml.patch
@@ -3,7 +3,7 @@ Index: python3-prometheus-client/pyproject.toml
--- python3-prometheus-client.orig/pyproject.toml
+++ python3-prometheus-client/pyproject.toml
@@ -7,11 +7,7 @@ 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"
diff --git a/docs/content/collector/_index.md b/docs/content/collector/_index.md
index 957c8ba..85c6f12 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 bc6a021..c197910 100644
--- a/docs/content/collector/custom.md
+++ b/docs/content/collector/custom.md
@@ -35,4 +35,265 @@ 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.
+
+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
+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
+
+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
+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. |
+
+Single unlabelled info metric:
+
+```python
+yield InfoMetricFamily('build', 'Build metadata', value={'version': '1.2.3', 'commit': 'abc123'})
+```
+
+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'})
+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/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md
index d9f9a94..6060c0b 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 80360e4..cb2571a 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/instrumenting/gauge.md b/docs/content/instrumenting/gauge.md
index 43168a6..6229494 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 8975d85..fa0ffe1 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 55428ec..714dfd2 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/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md
index 42ea6a6..cd12993 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
@@ -96,3 +102,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)
+```
diff --git a/docs/content/registry/_index.md b/docs/content/registry/_index.md
new file mode 100644
index 0000000..0d55453
--- /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)
+```
diff --git a/prometheus_client/context_managers.py b/prometheus_client/context_managers.py
index 3988ec2..3e8d7ce 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/prometheus_client/core.py b/prometheus_client/core.py
index 60f93ce..045e90a 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/exposition.py b/prometheus_client/exposition.py
index 2d402a0..0b63f6f 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/prometheus_client/metrics.py b/prometheus_client/metrics.py
index 4c53b26..e3fe532 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:
@@ -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",
@@ -767,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,
@@ -777,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/prometheus_client/openmetrics/exposition.py b/prometheus_client/openmetrics/exposition.py
index 5e69e46..5a7711b 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/prometheus_client/openmetrics/parser.py b/prometheus_client/openmetrics/parser.py
index d967e83..0c5c9c4 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/prometheus_client/registry.py b/prometheus_client/registry.py
index c2b55d1..63f8ab0 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
@@ -55,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/prometheus_client/utils.py b/prometheus_client/utils.py
index 87b75ca..52c852a 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/prometheus_client/values.py b/prometheus_client/values.py
index 6ff85e3..16c745e 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/pyproject.toml b/pyproject.toml
index 336cfb4..8b39c12 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"
diff --git a/tests/certs/client-cert.pem b/tests/certs/client-cert.pem
new file mode 100644
index 0000000..5a05419
--- /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 0000000..e218a00
--- /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 0000000..bbcada2
--- /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 0000000..e1f1167
--- /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 0000000..68f2b80
--- /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/openmetrics/test_exposition.py b/tests/openmetrics/test_exposition.py
index a3ed0d6..a849f5f 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))
diff --git a/tests/openmetrics/test_parser.py b/tests/openmetrics/test_parser.py
index aeaa6ed..79a2158 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
diff --git a/tests/test_asgi.py b/tests/test_asgi.py
index 6e795e2..028dac2 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_core.py b/tests/test_core.py
index 66492c6..3aa19c2 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,
)
@@ -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)
@@ -378,6 +384,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)
@@ -581,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):
@@ -908,44 +935,59 @@ 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)
+ 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):
diff --git a/tests/test_exposition.py b/tests/test_exposition.py
index a3c9782..1885480 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",
diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py
index ee0c742..eab93f5 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 49c4dc8..8436dd0 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__':
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..50eac33
--- /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))