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