diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index f29bd265..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,93 +0,0 @@ ---- -version: 2.1 - -executors: - python: - docker: - - image: cimg/python:3.9 - -jobs: - flake8_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e flake8 - isort_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e isort - mypy_lint: - executor: python - steps: - - checkout - - run: pip install tox - - run: tox -e mypy - test: - parameters: - python: - type: string - docker: - - image: cimg/python:<< parameters.python >> - environment: - TOXENV: "py<< parameters.python >>" - steps: - - checkout - - run: echo 'export PATH=$HOME/.local/bin:$PATH' >> $BASH_ENV - - run: pip install --user tox "virtualenv<20.22.0" - - run: tox - test_nooptionals: - parameters: - python: - type: string - docker: - - image: cimg/python:<< parameters.python >> - environment: - TOXENV: "py<< parameters.python >>-nooptionals" - steps: - - checkout - - run: pip install tox - - run: tox - test_pypy: - parameters: - python: - type: string - docker: - - image: pypy:<< parameters.python >> - environment: - TOXENV: "pypy<< parameters.python >>" - steps: - - checkout - - run: pip install tox - - run: tox - - -workflows: - version: 2 - client_python: - jobs: - - flake8_lint - - isort_lint - - mypy_lint - - test: - matrix: - parameters: - python: - - "3.9.18" - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - test_nooptionals: - matrix: - parameters: - python: - - "3.9" - - test_pypy: - matrix: - parameters: - python: - - "3.9" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..a7e4e094 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +permissions: + contents: read + +jobs: + flake8_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run flake8 + run: tox -e flake8 + + isort_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run isort + run: tox -e isort + + mypy_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: '3.9' + - name: Install tox + run: pip install tox + - name: Run mypy + run: tox -e mypy + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install --user tox "virtualenv<20.22.0" + echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Set tox environment + id: toxenv + run: | + VERSION="${{ matrix.python-version }}" + # Extract major.minor version (strip patch if present) + TOX_VERSION=$(echo "$VERSION" | cut -d. -f1,2) + echo "toxenv=py${TOX_VERSION}" >> $GITHUB_OUTPUT + - name: Run tests + run: tox + env: + TOXENV: ${{ steps.toxenv.outputs.toxenv }} + + test_nooptionals: + runs-on: ubuntu-latest + env: + PYTHON_VERSION: '3.9' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install tox + run: pip install tox + - name: Run tests without optional dependencies + run: tox + env: + TOXENV: py${{ env.PYTHON_VERSION }}-nooptionals + + test_pypy: + runs-on: ubuntu-latest + env: + PYTHON_VERSION: '3.9' + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Set up PyPy + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version: pypy-${{ env.PYTHON_VERSION }} + - name: Install tox + run: pip install tox + - name: Run tests with PyPy + run: tox + env: + TOXENV: pypy${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/github-pages.yaml b/.github/workflows/github-pages.yaml index 621f2d73..d8db8cbc 100644 --- a/.github/workflows/github-pages.yaml +++ b/.github/workflows/github-pages.yaml @@ -11,9 +11,6 @@ on: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write - id-token: write - actions: read # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -32,6 +29,9 @@ jobs: runs-on: ubuntu-latest env: HUGO_VERSION: 0.145.0 + permissions: + pages: write + id-token: write steps: - name: Install Hugo CLI run: | @@ -40,13 +40,13 @@ jobs: #- name: Install Dart Sass # run: sudo snap install dart-sass - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: submodules: recursive fetch-depth: 0 - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" working-directory: ./docs @@ -62,7 +62,7 @@ jobs: --baseURL "${{ steps.pages.outputs.base_url }}/" working-directory: ./docs - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: ./docs/public @@ -73,7 +73,11 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build + permissions: + pages: write + id-token: write + actions: read steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/docs/content/collector/_index.md b/docs/content/collector/_index.md index 957c8ba9..85c6f12f 100644 --- a/docs/content/collector/_index.md +++ b/docs/content/collector/_index.md @@ -18,8 +18,8 @@ ProcessCollector(namespace='mydaemon', pid=lambda: open('/var/run/daemon.pid').r # Platform Collector The client also automatically exports some metadata about Python. If using Jython, -metadata about the JVM in use is also included. This information is available as -labels on the `python_info` metric. The value of the metric is 1, since it is the +metadata about the JVM in use is also included. This information is available as +labels on the `python_info` metric. The value of the metric is 1, since it is the labels that carry information. # Disabling Default Collector metrics @@ -33,4 +33,75 @@ import prometheus_client prometheus_client.REGISTRY.unregister(prometheus_client.GC_COLLECTOR) prometheus_client.REGISTRY.unregister(prometheus_client.PLATFORM_COLLECTOR) prometheus_client.REGISTRY.unregister(prometheus_client.PROCESS_COLLECTOR) -``` \ No newline at end of file +``` + +## API Reference + +### ProcessCollector + +```python +ProcessCollector(namespace='', pid=lambda: 'self', proc='/proc', registry=REGISTRY) +``` + +Collects process metrics from `/proc`. Only available on Linux. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `namespace` | `str` | `''` | Prefix added to all metric names, e.g. `'mydaemon'` produces `mydaemon_process_cpu_seconds_total`. | +| `pid` | `Callable[[], int or str]` | `lambda: 'self'` | Callable that returns the PID to monitor. `'self'` monitors the current process. | +| `proc` | `str` | `'/proc'` | Path to the proc filesystem. Useful for testing or containerised environments with a non-standard mount point. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration. | + +Metrics exported: + +| Metric | Description | +|--------|-------------| +| `process_cpu_seconds_total` | Total user and system CPU time in seconds. | +| `process_virtual_memory_bytes` | Virtual memory size in bytes. | +| `process_resident_memory_bytes` | Resident memory size in bytes. | +| `process_start_time_seconds` | Start time since Unix epoch in seconds. | +| `process_open_fds` | Number of open file descriptors. | +| `process_max_fds` | Maximum number of open file descriptors. | + +The module-level `PROCESS_COLLECTOR` is the default instance registered with `REGISTRY`. + +### PlatformCollector + +```python +PlatformCollector(registry=REGISTRY, platform=None) +``` + +Exports Python runtime metadata as a `python_info` gauge metric with labels. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration. | +| `platform` | module | `None` | Override the `platform` module. Intended for testing. | + +Labels on `python_info`: `version`, `implementation`, `major`, `minor`, `patchlevel`. +On Jython, additional labels are added: `jvm_version`, `jvm_release`, `jvm_vendor`, `jvm_name`. + +The module-level `PLATFORM_COLLECTOR` is the default instance registered with `REGISTRY`. + +### GCCollector + +```python +GCCollector(registry=REGISTRY) +``` + +Exports Python garbage collector statistics. Only active on CPython (skipped silently on +other implementations). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. | + +Metrics exported: + +| Metric | Description | +|--------|-------------| +| `python_gc_objects_collected_total` | Objects collected during GC, by generation. | +| `python_gc_objects_uncollectable_total` | Uncollectable objects found during GC, by generation. | +| `python_gc_collections_total` | Number of times each generation was collected. | + +The module-level `GC_COLLECTOR` is the default instance registered with `REGISTRY`. diff --git a/docs/content/collector/custom.md b/docs/content/collector/custom.md index bc6a021c..62c0180a 100644 --- a/docs/content/collector/custom.md +++ b/docs/content/collector/custom.md @@ -35,4 +35,252 @@ not implemented and the CollectorRegistry was created with `auto_describe=True` (which is the case for the default registry) then `collect` will be called at registration time instead of `describe`. If this could cause problems, either implement a proper `describe`, or if that's not practical have `describe` -return an empty list. \ No newline at end of file +return an empty list. + +## Collector protocol + +A collector is any object that implements a `collect` method. Optionally it +can also implement `describe`. + +### `collect()` + +Returns an iterable of metric family objects (`GaugeMetricFamily`, +`CounterMetricFamily`, etc.). Called every time the registry is scraped. + +### `describe()` + +Returns an iterable of metric family objects used only to determine the metric +names the collector produces. Samples on the returned objects are ignored. If +not implemented and the registry has `auto_describe=True`, `collect` is called +at registration time instead. + +## value vs labels + +Every metric family constructor accepts either inline data or `labels`, but not +both. The inline data parameter name varies by type: `value` for Gauge, Counter, +and Info; `count_value`/`sum_value` for Summary; `buckets` for Histogram. + +- Pass inline data to emit a single unlabelled metric directly from the constructor. +- Pass `labels` (a sequence of label names) and then call `add_metric` one or + more times to emit labelled metrics. + +```python +# single unlabelled value +GaugeMetricFamily('my_gauge', 'Help text', value=7) + +# labelled metrics via add_metric +g = GaugeMetricFamily('my_gauge', 'Help text', labels=['region']) +g.add_metric(['us-east-1'], 3) +g.add_metric(['eu-west-1'], 5) +``` + +## API Reference + +### GaugeMetricFamily + +```python +GaugeMetricFamily(name, documentation, value=None, labels=None, unit='') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output. | +| `value` | `float` | `None` | Emit a single unlabelled sample with this value. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `value`. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | + +#### `add_metric(labels, value, timestamp=None)` + +Add a labelled sample to the metric family. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values in the same order as the `labels` constructor argument. | +| `value` | `float` | The gauge value. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | + +```python +g = GaugeMetricFamily('temperature_celsius', 'Temperature by location', labels=['location']) +g.add_metric(['living_room'], 21.5) +g.add_metric(['basement'], 18.0) +yield g +``` + +### CounterMetricFamily + +```python +CounterMetricFamily(name, documentation, value=None, labels=None, created=None, unit='', exemplar=None) +``` + +If `name` ends with `_total`, the suffix is stripped automatically so the +metric is stored without it and the `_total` suffix is added on exposition. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A trailing `_total` is stripped and re-added on exposition. | +| `documentation` | `str` | required | Help text. | +| `value` | `float` | `None` | Emit a single unlabelled sample. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `value`. | +| `created` | `float` | `None` | Unix timestamp the counter was created at. Only used when `value` is set. | +| `unit` | `str` | `''` | Optional unit suffix. | +| `exemplar` | `Exemplar` | `None` | Exemplar for the single-value form. Only used when `value` is set. | + +#### `add_metric(labels, value, created=None, timestamp=None, exemplar=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `value` | `float` | The counter value. | +| `created` | `float` | Optional Unix timestamp the counter was created at. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | +| `exemplar` | `Exemplar` | Optional exemplar. See [Exemplars](../../instrumenting/exemplars/). | + +```python +c = CounterMetricFamily('http_requests_total', 'HTTP requests by status', labels=['status']) +c.add_metric(['200'], 1200) +c.add_metric(['404'], 43) +c.add_metric(['500'], 7) +yield c +``` + +### SummaryMetricFamily + +```python +SummaryMetricFamily(name, documentation, count_value=None, sum_value=None, labels=None, unit='') +``` + +`count_value` and `sum_value` must always be provided together or not at all. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text. | +| `count_value` | `int` | `None` | Observation count for a single unlabelled metric. Must be paired with `sum_value`. | +| `sum_value` | `float` | `None` | Observation sum for a single unlabelled metric. Must be paired with `count_value`. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `count_value`/`sum_value`. | +| `unit` | `str` | `''` | Optional unit suffix. | + +#### `add_metric(labels, count_value, sum_value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `count_value` | `int` | The number of observations. | +| `sum_value` | `float` | The sum of all observed values. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp for the sample. | + +```python +s = SummaryMetricFamily('rpc_duration_seconds', 'RPC duration', labels=['method']) +s.add_metric(['get'], count_value=1000, sum_value=53.2) +s.add_metric(['put'], count_value=400, sum_value=28.7) +yield s +``` + +### HistogramMetricFamily + +```python +HistogramMetricFamily(name, documentation, buckets=None, sum_value=None, labels=None, unit='') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text. | +| `buckets` | `Sequence` | `None` | Bucket data for a single unlabelled metric. Each entry is a `(le, value)` pair or `(le, value, exemplar)` triple. Must include a `+Inf` bucket. Mutually exclusive with `labels`. | +| `sum_value` | `float` | `None` | Observation sum. Cannot be set without `buckets`. Omitted for histograms with negative buckets. | +| `labels` | `Sequence[str]` | `None` | Label names. Use with `add_metric`. Mutually exclusive with `buckets`. | +| `unit` | `str` | `''` | Optional unit suffix. | + +#### `add_metric(labels, buckets, sum_value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Label values. | +| `buckets` | `Sequence` | Bucket data. Each entry is a `(le, value)` pair or `(le, value, exemplar)` triple. Must be sorted and include `+Inf`. | +| `sum_value` | `float` or `None` | The sum of all observed values. Pass `None` for histograms with negative buckets. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp. | + +```python +h = HistogramMetricFamily('request_size_bytes', 'Request sizes', labels=['handler']) +h.add_metric( + ['api'], + buckets=[('100', 5), ('1000', 42), ('+Inf', 50)], + sum_value=18350.0, +) +yield h +``` + +### InfoMetricFamily + +```python +InfoMetricFamily(name, documentation, value=None, labels=None) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. The `_info` suffix is added automatically on exposition. | +| `documentation` | `str` | required | Help text. | +| `value` | `Dict[str, str]` | `None` | Key-value label pairs for a single unlabelled info metric. Mutually exclusive with `labels`. | +| `labels` | `Sequence[str]` | `None` | Label names for the outer grouping. Use with `add_metric`. Mutually exclusive with `value`. | + +#### `add_metric(labels, value, timestamp=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `labels` | `Sequence[str]` | Outer label values (from the `labels` constructor argument). | +| `value` | `Dict[str, str]` | Key-value label pairs that form the info payload. | +| `timestamp` | `float` or `Timestamp` | Optional Unix timestamp. | + +```python +# single unlabelled info metric +yield InfoMetricFamily('build', 'Build metadata', value={'version': '1.2.3', 'commit': 'abc123'}) + +# labelled: one info metric per service +i = InfoMetricFamily('service_build', 'Per-service build info', labels=['service']) +i.add_metric(['auth'], {'version': '2.0.1', 'commit': 'def456'}) +i.add_metric(['api'], {'version': '1.9.0', 'commit': 'ghi789'}) +yield i +``` + +## Real-world example + +Proxying metrics from an external source: + +```python +from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily, REGISTRY +from prometheus_client.registry import Collector +from prometheus_client import start_http_server + +# Simulated external data source +_QUEUE_STATS = { + 'orders': {'depth': 14, 'processed': 9821}, + 'notifications': {'depth': 3, 'processed': 45210}, +} + +class QueueCollector(Collector): + def collect(self): + depth = GaugeMetricFamily( + 'queue_depth', + 'Current number of messages waiting in the queue', + labels=['queue'], + ) + processed = CounterMetricFamily( + 'queue_messages_processed_total', + 'Total messages processed from the queue', + labels=['queue'], + ) + for name, stats in _QUEUE_STATS.items(): + depth.add_metric([name], stats['depth']) + processed.add_metric([name], stats['processed']) + yield depth + yield processed + +REGISTRY.register(QueueCollector()) + +if __name__ == '__main__': + start_http_server(8000) + import time + while True: + time.sleep(1) +``` diff --git a/docs/content/exporting/http/_index.md b/docs/content/exporting/http/_index.md index dc1b8f2c..f7a6aac6 100644 --- a/docs/content/exporting/http/_index.md +++ b/docs/content/exporting/http/_index.md @@ -24,6 +24,7 @@ to shutdown the server gracefully: ```python server, t = start_http_server(8000) server.shutdown() +server.server_close() t.join() ``` diff --git a/docs/content/exporting/http/django.md b/docs/content/exporting/http/django.md new file mode 100644 index 00000000..a900a3a2 --- /dev/null +++ b/docs/content/exporting/http/django.md @@ -0,0 +1,47 @@ +--- +title: Django +weight: 5 +--- + +To use Prometheus with [Django](https://www.djangoproject.com/) you can use the provided view class +to add a metrics endpoint to your app. + +```python +# urls.py + +from django.urls import path +from prometheus_client.django import PrometheusDjangoView + +urlpatterns = [ + # ... any other urls that you want + path("metrics/", PrometheusDjangoView.as_view(), name="prometheus-metrics"), + # ... still more urls +] +``` + +By default, Multiprocessing support is activated if environment variable `PROMETHEUS_MULTIPROC_DIR` is set. +You can override this through the view arguments: + +```python +from django.conf import settings + +urlpatterns = [ + path( + "metrics/", + PrometheusDjangoView.as_view( + multiprocess_mode=settings.YOUR_SETTING # or any boolean value + ), + name="prometheus-metrics", + ), +] +``` + +Full multiprocessing instructions are provided [here]({{< ref "/multiprocess" >}}). + +# django-prometheus + +The included `PrometheusDjangoView` is useful if you want to define your own metrics from scratch. + +An external package called [django-prometheus](https://github.com/django-commons/django-prometheus/) +can be used instead if you want to get a bunch of ready-made monitoring metrics for your Django application +and easily benefit from utilities such as models monitoring. diff --git a/docs/content/exporting/pushgateway.md b/docs/content/exporting/pushgateway.md index bf5eb112..6060c0bf 100644 --- a/docs/content/exporting/pushgateway.md +++ b/docs/content/exporting/pushgateway.md @@ -54,6 +54,20 @@ g.set_to_current_time() push_to_gateway('localhost:9091', job='batchA', registry=registry, handler=my_auth_handler) ``` +# Compressing data before sending to pushgateway +Pushgateway (version >= 1.5.0) supports gzip and snappy compression (v > 1.6.0). This can help in network constrained environments. +To compress a push request, set the `compression` argument to `'gzip'` or `'snappy'`: +```python +push_to_gateway( + 'localhost:9091', + job='batchA', + registry=registry, + handler=my_auth_handler, + compression='gzip', +) +``` +Snappy compression requires the optional [`python-snappy`](https://github.com/andrix/python-snappy) package. + TLS Auth is also supported when using the push gateway with a special handler. ```python @@ -71,3 +85,109 @@ g = Gauge('job_last_success_unixtime', 'Last time a batch job successfully finis g.set_to_current_time() push_to_gateway('localhost:9091', job='batchA', registry=registry, handler=my_auth_handler) ``` + +## API Reference + +### `push_to_gateway(gateway, job, registry, grouping_key=None, timeout=30, handler=default_handler, compression=None)` + +Pushes metrics to the pushgateway, replacing all metrics with the same job and grouping key. +Uses the HTTP `PUT` method. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. If no scheme is provided, `http://` is assumed. | +| `job` | `str` | required | Value for the `job` label attached to all pushed metrics. | +| `registry` | `Collector` | required | Registry whose metrics are pushed. Typically a `CollectorRegistry` instance. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. See the [Pushgateway documentation](https://github.com/prometheus/pushgateway/blob/master/README.md) for details. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. See [Handlers](#handlers) below. | +| `compression` | `Optional[str]` | `None` | Compress the payload before sending. Accepts `'gzip'` or `'snappy'`. Snappy requires the [`python-snappy`](https://github.com/andrix/python-snappy) package. | + +### `pushadd_to_gateway(gateway, job, registry, grouping_key=None, timeout=30, handler=default_handler, compression=None)` + +Pushes metrics to the pushgateway, replacing only metrics with the same name, job, and grouping key. +Uses the HTTP `POST` method. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. | +| `job` | `str` | required | Value for the `job` label attached to all pushed metrics. | +| `registry` | `Optional[Collector]` | required | Registry whose metrics are pushed. Pass `None` to use the default `REGISTRY`. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. | +| `compression` | `Optional[str]` | `None` | Compress the payload. Accepts `'gzip'` or `'snappy'`. | + +### `delete_from_gateway(gateway, job, grouping_key=None, timeout=30, handler=default_handler)` + +Deletes metrics from the pushgateway for the given job and grouping key. +Uses the HTTP `DELETE` method. Has no `registry` or `compression` parameters. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `gateway` | `str` | required | URL of the pushgateway. | +| `job` | `str` | required | Value for the `job` label identifying the group to delete. | +| `grouping_key` | `Optional[Dict[str, Any]]` | `None` | Additional labels to identify the group. | +| `timeout` | `Optional[float]` | `30` | Seconds before the request is aborted. Pass `None` for no timeout. | +| `handler` | `Callable` | `default_handler` | Function that performs the HTTP request. | + +### `instance_ip_grouping_key()` + +Returns a grouping key dict with the `instance` label set to the IP address of the current host. +Takes no parameters. + +```python +from prometheus_client.exposition import instance_ip_grouping_key + +push_to_gateway('localhost:9091', job='batchA', registry=registry, + grouping_key=instance_ip_grouping_key()) +``` + +## Handlers + +A handler is a callable with the signature: + +```python +def my_handler(url, method, timeout, headers, data): + # url: str — full request URL + # method: str — HTTP method (PUT, POST, DELETE) + # timeout: Optional[float] — seconds before aborting, or None + # headers: List[Tuple[str, str]] — HTTP headers to include + # data: bytes — request body + ... + return callable_that_performs_the_request +``` + +The handler must return a no-argument callable that performs the actual HTTP request and raises +an exception (e.g. `IOError`) on failure. Three built-in handlers are available in +`prometheus_client.exposition`: + +### `default_handler` + +Standard HTTP/HTTPS handler. Used by default in all push functions. + +### `basic_auth_handler(url, method, timeout, headers, data, username=None, password=None)` + +Wraps `default_handler` and adds an HTTP Basic Auth header. + +| Extra parameter | Type | Default | Description | +|----------------|------|---------|-------------| +| `username` | `Optional[str]` | `None` | HTTP Basic Auth username. | +| `password` | `Optional[str]` | `None` | HTTP Basic Auth password. | + +### `tls_auth_handler(url, method, timeout, headers, data, certfile, keyfile, cafile=None, protocol=ssl.PROTOCOL_TLS_CLIENT, insecure_skip_verify=False)` + +Performs the request over HTTPS using TLS client certificate authentication. + +| Extra parameter | Type | Default | Description | +|----------------|------|---------|-------------| +| `certfile` | `str` | required | Path to the client certificate PEM file. | +| `keyfile` | `str` | required | Path to the client private key PEM file. | +| `cafile` | `Optional[str]` | `None` | Path to a CA certificate file for server verification. Uses system defaults if not set. | +| `protocol` | `int` | `ssl.PROTOCOL_TLS_CLIENT` | SSL/TLS protocol version. | +| `insecure_skip_verify` | `bool` | `False` | Skip server certificate verification. Use only in controlled environments. | + +### `passthrough_redirect_handler` + +Like `default_handler` but automatically follows redirects for all HTTP methods, including `PUT` +and `POST`. Use only when you control or trust the source of redirect responses. diff --git a/docs/content/exporting/textfile.md b/docs/content/exporting/textfile.md index 80360e46..cb2571af 100644 --- a/docs/content/exporting/textfile.md +++ b/docs/content/exporting/textfile.md @@ -20,4 +20,24 @@ write_to_textfile('/configured/textfile/path/raid.prom', registry) ``` A separate registry is used, as the default registry may contain other metrics -such as those from the Process Collector. \ No newline at end of file +such as those from the Process Collector. + +## API Reference + +### `write_to_textfile(path, registry, escaping='allow-utf-8', tmpdir=None)` + +Writes metrics from the registry to a file in Prometheus text format. + +The file is written atomically: metrics are first written to a temporary file in the same +directory as `path` (or in `tmpdir` if provided), then renamed into place. This prevents the +Node exporter from reading a partially written file. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | `str` | required | Destination file path. Must end in `.prom` for the Node exporter textfile collector to process it. | +| `registry` | `Collector` | required | Registry whose metrics are written. | +| `escaping` | `str` | `'allow-utf-8'` | Escaping scheme for metric and label names. Accepted values: `'allow-utf-8'`, `'underscores'`, `'dots'`, `'values'`. | +| `tmpdir` | `Optional[str]` | `None` | Directory for the temporary file used during the atomic write. Defaults to the same directory as `path`. If provided, must be on the same filesystem as `path`. | + +Returns `None`. Raises an exception if the file cannot be written; the temporary file is cleaned +up automatically on failure. \ No newline at end of file diff --git a/docs/content/instrumenting/_index.md b/docs/content/instrumenting/_index.md index 13bbc6b6..1b013d58 100644 --- a/docs/content/instrumenting/_index.md +++ b/docs/content/instrumenting/_index.md @@ -3,10 +3,20 @@ title: Instrumenting weight: 2 --- -Four types of metric are offered: Counter, Gauge, Summary and Histogram. -See the documentation on [metric types](http://prometheus.io/docs/concepts/metric_types/) +Six metric types are available. Pick based on what your value does: + +| Type | Update model | Use for | +|------|-----------|---------| +| [Counter](counter/) | only up | requests served, errors, bytes sent | +| [Gauge](gauge/) | up and down | queue depth, active connections, memory usage | +| [Histogram](histogram/) | observations in buckets | request latency, request size — when you need quantiles in queries | +| [Summary](summary/) | observations (count + sum) | request latency, request size — when average is enough | +| [Info](info/) | static key-value pairs | build version, environment metadata | +| [Enum](enum/) | one of N states | task state, lifecycle phase | + +See the Prometheus documentation on [metric types](https://prometheus.io/docs/concepts/metric_types/) and [instrumentation best practices](https://prometheus.io/docs/practices/instrumentation/#counter-vs-gauge-summary-vs-histogram) -on how to use them. +for deeper guidance on choosing between Histogram and Summary. ## Disabling `_created` metrics diff --git a/docs/content/instrumenting/counter.md b/docs/content/instrumenting/counter.md index 94618025..4876b612 100644 --- a/docs/content/instrumenting/counter.md +++ b/docs/content/instrumenting/counter.md @@ -3,8 +3,10 @@ title: Counter weight: 1 --- -Counters go up, and reset when the process restarts. +A Counter tracks a value that only ever goes up. Use it for things you count — requests +served, errors raised, bytes sent. When the process restarts, the counter resets to zero. +If your value can go down, use a [Gauge](../gauge/) instead. ```python from prometheus_client import Counter @@ -18,17 +20,110 @@ exposing the time series for counter, a `_total` suffix will be added. This is for compatibility between OpenMetrics and the Prometheus text format, as OpenMetrics requires the `_total` suffix. -There are utilities to count exceptions raised: +## Constructor + +```python +Counter(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A `_total` suffix is appended automatically when exposing the time series. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='requests_total' +# produces: myapp_http_requests_total +Counter('requests_total', 'Total requests', namespace='myapp', subsystem='http') +``` + +## Methods + +### `inc(amount=1, exemplar=None)` + +Increment the counter by the given amount. The amount must be non-negative. + +```python +c.inc() # increment by 1 +c.inc(5) # increment by 5 +c.inc(0.7) # fractional increments are allowed +``` + +To attach trace context to an observation, pass an `exemplar` dict. Exemplars are +only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details. + +```python +c.inc(exemplar={'trace_id': 'abc123'}) +``` + +### `reset()` + +Reset the counter to zero. Use this when a logical process restarts without +restarting the actual Python process. + +```python +c.reset() +``` + +### `count_exceptions(exception=Exception)` + +Count exceptions raised in a block of code or function. Can be used as a +decorator or context manager. Increments the counter each time an exception +of the given type is raised. ```python @c.count_exceptions() def f(): - pass + pass with c.count_exceptions(): - pass + pass -# Count only one type of exception +# Count only a specific exception type with c.count_exceptions(ValueError): - pass -``` \ No newline at end of file + pass +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking HTTP requests by method and status code in a web application: + +```python +from prometheus_client import Counter, start_http_server + +REQUESTS = Counter( + 'requests_total', + 'Total HTTP requests received', + labelnames=['method', 'status'], + namespace='myapp', +) +EXCEPTIONS = Counter( + 'exceptions_total', + 'Total unhandled exceptions', + labelnames=['handler'], + namespace='myapp', +) + +def handle_request(method, handler): + with EXCEPTIONS.labels(handler=handler).count_exceptions(): + # ... process the request ... + status = '200' + REQUESTS.labels(method=method, status=status).inc() + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces time series like `myapp_requests_total{method="GET",status="200"}`. diff --git a/docs/content/instrumenting/enum.md b/docs/content/instrumenting/enum.md index 102091a1..b1e6169a 100644 --- a/docs/content/instrumenting/enum.md +++ b/docs/content/instrumenting/enum.md @@ -3,11 +3,95 @@ title: Enum weight: 6 --- -Enum tracks which of a set of states something is currently in. +Enum tracks which of a fixed set of states something is currently in. Only one state is active at a time. Use it for things like task state machines or lifecycle phases. ```python from prometheus_client import Enum e = Enum('my_task_state', 'Description of enum', states=['starting', 'running', 'stopped']) e.state('running') -``` \ No newline at end of file +``` + +Enum exposes one time series per state: +- `{=""}` — 1 if this is the current state, 0 otherwise + +The first listed state is the default. + +Note: Enum metrics do not work in multiprocess mode. + +## Constructor + +```python +Enum(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, states=[]) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). The metric name itself cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Not supported — raises `ValueError`. Enum metrics cannot have a unit. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `states` | `List[str]` | required | The complete list of valid states. Must be non-empty. The first entry is the initial state. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='worker', name='state' +# produces: myapp_worker_state +Enum('state', 'Worker state', states=['idle', 'running', 'error'], namespace='myapp', subsystem='worker') +``` + +## Methods + +### `state(state)` + +Set the current state. The value must be one of the strings passed in the `states` list. Raises `ValueError` if the state is not recognized. + +```python +e.state('running') +e.state('stopped') +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking the lifecycle state of a background worker: + +```python +from prometheus_client import Enum, start_http_server + +WORKER_STATE = Enum( + 'worker_state', + 'Current state of the background worker', + states=['idle', 'running', 'error'], + namespace='myapp', +) + +def process_job(): + WORKER_STATE.state('running') + try: + # ... do work ... + pass + except Exception: + WORKER_STATE.state('error') + raise + finally: + WORKER_STATE.state('idle') + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_worker_state{myapp_worker_state="idle"} 0.0 +myapp_worker_state{myapp_worker_state="running"} 1.0 +myapp_worker_state{myapp_worker_state="error"} 0.0 +``` diff --git a/docs/content/instrumenting/gauge.md b/docs/content/instrumenting/gauge.md index 0b1529e9..62294944 100644 --- a/docs/content/instrumenting/gauge.md +++ b/docs/content/instrumenting/gauge.md @@ -3,7 +3,8 @@ title: Gauge weight: 2 --- -Gauges can go up and down. +A Gauge tracks a value that can go up and down. Use it for things you sample at a +point in time — active connections, queue depth, memory usage, temperature. ```python from prometheus_client import Gauge @@ -13,24 +14,149 @@ g.dec(10) # Decrement by given value g.set(4.2) # Set to a given value ``` -There are utilities for common use cases: +## Constructor ```python -g.set_to_current_time() # Set to current unixtime +Gauge(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, multiprocess_mode='all') +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `multiprocess_mode` | `str` | `'all'` | How to aggregate this gauge across multiple processes. See [Multiprocess mode](../../multiprocess/). Options: `all`, `liveall`, `min`, `livemin`, `max`, `livemax`, `sum`, `livesum`, `mostrecent`, `livemostrecent`. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='db', name='connections_active' +# produces: myapp_db_connections_active +Gauge('connections_active', 'Active DB connections', namespace='myapp', subsystem='db') +``` + +## Methods + +### `inc(amount=1)` + +Increment the gauge by the given amount. + +```python +g.inc() # increment by 1 +g.inc(3) # increment by 3 +``` + +Note: raises `RuntimeError` if `multiprocess_mode` is `mostrecent` or `livemostrecent`. + +### `dec(amount=1)` -# Increment when entered, decrement when exited. +Decrement the gauge by the given amount. + +```python +g.dec() # decrement by 1 +g.dec(3) # decrement by 3 +``` + +Note: raises `RuntimeError` if `multiprocess_mode` is `mostrecent` or `livemostrecent`. + +### `set(value)` + +Set the gauge to the given value. + +```python +g.set(42.5) +``` + +### `set_to_current_time()` + +Set the gauge to the current Unix timestamp in seconds. Useful for tracking +when an event last occurred. + +```python +g.set_to_current_time() +``` + +### `track_inprogress()` + +Increment the gauge when a block of code or function is entered, and decrement +it when exited. Can be used as a decorator or context manager. + +```python @g.track_inprogress() -def f(): - pass +def process_job(): + pass with g.track_inprogress(): - pass + pass +``` + +### `time()` + +Set the gauge to the duration in seconds of the most recent execution of a +block of code or function. Unlike `Histogram.time()` and `Summary.time()`, +which accumulate all observations, this overwrites the gauge with the latest +duration each time. Can be used as a decorator or context manager. + +```python +@g.time() +def process(): + pass + +with g.time(): + pass + +with g.time() as t: + pass +print(t.duration) # observed time in seconds. +``` + +### `set_function(f)` + +Bind a callback function that returns the gauge value. The function is called +each time the metric is scraped. All other methods become no-ops after calling +this. + +```python +queue = [] +g.set_function(lambda: len(queue)) ``` -A Gauge can also take its value from a callback: +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking active database connections and queue depth: ```python -d = Gauge('data_objects', 'Number of objects') -my_dict = {} -d.set_function(lambda: len(my_dict)) -``` \ No newline at end of file +from prometheus_client import Gauge, start_http_server + +ACTIVE_CONNECTIONS = Gauge( + 'connections_active', + 'Number of active database connections', + namespace='myapp', +) +QUEUE_SIZE = Gauge( + 'job_queue_size', + 'Number of jobs waiting in the queue', + namespace='myapp', +) + +job_queue = [] +QUEUE_SIZE.set_function(lambda: len(job_queue)) + +def acquire_connection(): + ACTIVE_CONNECTIONS.inc() + +def release_connection(): + ACTIVE_CONNECTIONS.dec() + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` diff --git a/docs/content/instrumenting/histogram.md b/docs/content/instrumenting/histogram.md index cb85f183..fa0ffe1a 100644 --- a/docs/content/instrumenting/histogram.md +++ b/docs/content/instrumenting/histogram.md @@ -3,8 +3,9 @@ title: Histogram weight: 4 --- -Histograms track the size and number of events in buckets. -This allows for aggregatable calculation of quantiles. +A Histogram samples observations and counts them in configurable buckets. Use it +when you want to track distributions — request latency, response sizes — and need +to calculate quantiles (p50, p95, p99) in your queries. ```python from prometheus_client import Histogram @@ -12,16 +13,117 @@ h = Histogram('request_latency_seconds', 'Description of histogram') h.observe(4.7) # Observe 4.7 (seconds in this case) ``` -The default buckets are intended to cover a typical web/rpc request from milliseconds to seconds. -They can be overridden by passing `buckets` keyword argument to `Histogram`. +A Histogram exposes three time series per metric: +- `_bucket{le=""}` — count of observations with value ≤ le (cumulative) +- `_sum` — sum of all observed values +- `_count` — total number of observations -There are utilities for timing code: +## Constructor + +```python +Histogram(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY, buckets=DEFAULT_BUCKETS) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Note: `le` is reserved and cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | +| `buckets` | `Sequence[float]` | `DEFAULT_BUCKETS` | Upper bounds of the histogram buckets. Must be in ascending order. `+Inf` is always appended automatically. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='request_duration_seconds' +# produces: myapp_http_request_duration_seconds +Histogram('request_duration_seconds', 'Latency', namespace='myapp', subsystem='http') +``` + +Default buckets are intended to cover typical web/RPC request latency in seconds and are +accessible as `Histogram.DEFAULT_BUCKETS`: + +``` +.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, +Inf +``` + +To override with buckets tuned to your workload: + +```python +h = Histogram('request_latency_seconds', 'Latency', buckets=[.1, .5, 1, 2, 5]) +``` + +## Methods + +### `observe(amount, exemplar=None)` + +Record a single observation. The amount is typically positive or zero. + +```python +h.observe(0.43) # observe 430ms +``` + +To attach trace context to an observation, pass an `exemplar` dict. Exemplars are +only rendered in OpenMetrics format. See [Exemplars](../exemplars/) for details. + +```python +h.observe(0.43, exemplar={'trace_id': 'abc123'}) +``` + +### `time()` + +Observe the duration in seconds of a block of code or function and add it to the +histogram. Every call accumulates — unlike `Gauge.time()`, which only keeps the +most recent duration. Can be used as a decorator or context manager. ```python @h.time() -def f(): - pass +def process(): + pass with h.time(): - pass -``` \ No newline at end of file + pass + +with h.time() as t: + pass +print(t.duration) # observed time in seconds. +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking HTTP request latency with custom buckets tuned to the workload: + +```python +from prometheus_client import Histogram, start_http_server + +REQUEST_LATENCY = Histogram( + 'request_duration_seconds', + 'HTTP request latency', + labelnames=['method', 'endpoint'], + namespace='myapp', + buckets=[.01, .05, .1, .25, .5, 1, 2.5, 5], +) + +def handle_request(method, endpoint): + with REQUEST_LATENCY.labels(method=method, endpoint=endpoint).time(): + # ... handle the request ... + pass + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces time series like: +``` +myapp_request_duration_seconds_bucket{method="GET",endpoint="/api/users",le="0.1"} 42 +myapp_request_duration_seconds_sum{method="GET",endpoint="/api/users"} 3.7 +myapp_request_duration_seconds_count{method="GET",endpoint="/api/users"} 50 +``` diff --git a/docs/content/instrumenting/info.md b/docs/content/instrumenting/info.md index 6334d92b..6e369de7 100644 --- a/docs/content/instrumenting/info.md +++ b/docs/content/instrumenting/info.md @@ -3,10 +3,83 @@ title: Info weight: 5 --- -Info tracks key-value information, usually about a whole target. +Info tracks key-value pairs that describe a target — build version, configuration, or environment metadata. The values are static: once set, the metric outputs a single time series with all key-value pairs as labels and a constant value of 1. ```python from prometheus_client import Info i = Info('my_build_version', 'Description of info') i.info({'version': '1.2.3', 'buildhost': 'foo@bar'}) ``` + +Info exposes one time series per metric: +- `_info{="", ...}` — always 1; the key-value pairs become labels + +Note: Info metrics do not work in multiprocess mode. + +## Constructor + +```python +Info(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. A `_info` suffix is appended automatically when exposing the time series. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Keys passed to `.info()` must not overlap with these label names. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Not supported — raises `ValueError`. Info metrics cannot have a unit. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='http', name='build' +# produces: myapp_http_build_info +Info('build', 'Build information', namespace='myapp', subsystem='http') +``` + +## Methods + +### `info(val)` + +Set the key-value pairs for this metric. `val` must be a `dict[str, str]` — both keys and values must be strings. Keys must not overlap with the metric's label names and values cannot be `None`. Calling `info()` again overwrites the previous value. + +```python +i.info({'version': '1.4.2', 'revision': 'abc123', 'branch': 'main'}) +``` + +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Exposing application build metadata so dashboards can join on version: + +```python +from prometheus_client import Info, start_http_server + +BUILD_INFO = Info( + 'build', + 'Application build information', + namespace='myapp', +) + +BUILD_INFO.info({ + 'version': '1.4.2', + 'revision': 'abc123def456', + 'branch': 'main', + 'build_date': '2024-01-15', +}) + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_build_info{branch="main",build_date="2024-01-15",revision="abc123def456",version="1.4.2"} 1.0 +``` diff --git a/docs/content/instrumenting/labels.md b/docs/content/instrumenting/labels.md index ebf80b56..39ad29c8 100644 --- a/docs/content/instrumenting/labels.md +++ b/docs/content/instrumenting/labels.md @@ -5,8 +5,8 @@ weight: 7 All metrics can have labels, allowing grouping of related time series. -See the best practices on [naming](http://prometheus.io/docs/practices/naming/) -and [labels](http://prometheus.io/docs/practices/instrumentation/#use-labels). +See the best practices on [naming](https://prometheus.io/docs/practices/naming/) +and [labels](https://prometheus.io/docs/practices/instrumentation/#use-labels). Taking a counter as an example: @@ -35,4 +35,33 @@ from prometheus_client import Counter c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) c.labels('get', '/') c.labels('post', '/submit') +``` + +## Removing labelsets + +### `remove(*labelvalues)` + +Remove a specific labelset from the metric. Values must be passed in the same +order as `labelnames` were declared. + +```python +c = Counter('my_requests_total', 'HTTP Failures', ['method', 'endpoint']) +c.labels('get', '/').inc() +c.remove('get', '/') +``` + +### `remove_by_labels(labels)` + +Remove all labelsets that partially match the given dict of label names and values. + +```python +c.remove_by_labels({'method': 'get'}) # removes all labelsets where method='get' +``` + +### `clear()` + +Remove all labelsets from the metric at once. + +```python +c.clear() ``` \ No newline at end of file diff --git a/docs/content/instrumenting/summary.md b/docs/content/instrumenting/summary.md index fa407496..714dfd2f 100644 --- a/docs/content/instrumenting/summary.md +++ b/docs/content/instrumenting/summary.md @@ -3,7 +3,12 @@ title: Summary weight: 3 --- -Summaries track the size and number of events. +A Summary samples observations and tracks the total count and sum. Use it when +you want to track the size or duration of events and compute averages, but do not +need per-bucket breakdown or quantiles in your Prometheus queries. + +The Python client does not compute quantiles locally. If you need p50/p95/p99, +use a [Histogram](../histogram/) instead. ```python from prometheus_client import Summary @@ -11,15 +16,99 @@ s = Summary('request_latency_seconds', 'Description of summary') s.observe(4.7) # Observe 4.7 (seconds in this case) ``` -There are utilities for timing code: +A Summary exposes two time series per metric: +- `_count` — total number of observations +- `_sum` — sum of all observed values + +## Constructor + +```python +Summary(name, documentation, labelnames=(), namespace='', subsystem='', unit='', registry=REGISTRY) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Metric name. | +| `documentation` | `str` | required | Help text shown in the `/metrics` output and Prometheus UI. | +| `labelnames` | `Iterable[str]` | `()` | Names of labels for this metric. See [Labels](../labels/). Note: `quantile` is reserved and cannot be used as a label name. | +| `namespace` | `str` | `''` | Optional prefix. | +| `subsystem` | `str` | `''` | Optional middle component. | +| `unit` | `str` | `''` | Optional unit suffix appended to the metric name. | +| `registry` | `CollectorRegistry` | `REGISTRY` | Registry to register with. Pass `None` to skip registration, which is useful in tests where you create metrics without wanting them in the global registry. | + +`namespace`, `subsystem`, and `name` are joined with underscores to form the full metric name: + +```python +# namespace='myapp', subsystem='worker', name='task_duration_seconds' +# produces: myapp_worker_task_duration_seconds +Summary('task_duration_seconds', 'Task duration', namespace='myapp', subsystem='worker') +``` + +## Methods + +### `observe(amount)` + +Record a single observation. The amount is typically positive or zero. + +```python +s.observe(0.43) # observe 430ms +s.observe(1024) # observe 1024 bytes +``` + +### `time()` + +Observe the duration in seconds of a block of code or function and add it to the +summary. Every call accumulates — unlike `Gauge.time()`, which only keeps the +most recent duration. Can be used as a decorator or context manager. ```python @s.time() -def f(): - pass +def process(): + pass with s.time(): - pass + pass + +with s.time() as t: + pass +print(t.duration) # observed time in seconds. ``` -The Python client doesn't store or expose quantile information at this time. \ No newline at end of file +## Labels + +See [Labels](../labels/) for how to use `.labels()`, `.remove()`, `.remove_by_labels()`, and `.clear()`. + +## Real-world example + +Tracking the duration of background tasks: + +```python +from prometheus_client import Summary, start_http_server + +TASK_DURATION = Summary( + 'task_duration_seconds', + 'Time spent processing background tasks', + labelnames=['task_type'], + namespace='myapp', +) + +def run_task(task_type, task): + with TASK_DURATION.labels(task_type=task_type).time(): + # ... run the task ... + pass + +if __name__ == '__main__': + start_http_server(8000) # exposes metrics at http://localhost:8000/metrics + # ... start your application ... +``` + +This produces: +``` +myapp_task_duration_seconds_count{task_type="email"} 120 +myapp_task_duration_seconds_sum{task_type="email"} 48.3 +``` + +You can compute the average duration in PromQL as: +``` +rate(myapp_task_duration_seconds_sum[5m]) / rate(myapp_task_duration_seconds_count[5m]) +``` diff --git a/docs/content/multiprocess/_index.md b/docs/content/multiprocess/_index.md index 33507cd9..f7befef8 100644 --- a/docs/content/multiprocess/_index.md +++ b/docs/content/multiprocess/_index.md @@ -10,9 +10,12 @@ it's common to have processes rather than threads to handle large workloads. To handle this the client library can be put in multiprocess mode. This comes with a number of limitations: -- Registries can not be used as normal, all instantiated metrics are exported +- Registries can not be used as normal: + - all instantiated metrics are collected - Registering metrics to a registry later used by a `MultiProcessCollector` may cause duplicate metrics to be exported + - Filtering on metrics works if and only if the constructor was called with + `support_collectors_without_names=True` and it but might be inefficient. - Custom collectors do not work (e.g. cpu and memory metrics) - Gauges cannot use `set_function` - Info and Enum metrics do not work @@ -49,7 +52,7 @@ MY_COUNTER = Counter('my_counter', 'Description of my counter') # Expose metrics. def app(environ, start_response): - registry = CollectorRegistry() + registry = CollectorRegistry(support_collectors_without_names=True) multiprocess.MultiProcessCollector(registry) data = generate_latest(registry) status = '200 OK' @@ -93,3 +96,53 @@ from prometheus_client import Gauge # Example gauge IN_PROGRESS = Gauge("inprogress_requests", "help", multiprocess_mode='livesum') ``` + +## API Reference + +### `MultiProcessCollector(registry, path=None)` + +Collector that aggregates metrics written by all processes in the multiprocess directory. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `registry` | `CollectorRegistry` | required | Registry to register with. Pass a registry created inside the request context to avoid duplicate metrics. | +| `path` | `Optional[str]` | `None` | Path to the directory containing the per-process metric files. Defaults to the `PROMETHEUS_MULTIPROC_DIR` environment variable. | + +Raises `ValueError` if `path` is not set or does not point to an existing directory. + +```python +from prometheus_client import multiprocess, CollectorRegistry + +def app(environ, start_response): + registry = CollectorRegistry(support_collectors_without_names=True) + multiprocess.MultiProcessCollector(registry) + ... +``` + +To use a custom path instead of the environment variable: + +```python +collector = multiprocess.MultiProcessCollector(registry, path='/var/run/prom') +``` + +### `mark_process_dead(pid, path=None)` + +Removes the per-process metric files for a dead process. Call this from your process manager +when a worker exits to prevent stale `live*` gauge values from accumulating. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `pid` | `int` | required | PID of the process that has exited. | +| `path` | `Optional[str]` | `None` | Path to the multiprocess directory. Defaults to the `PROMETHEUS_MULTIPROC_DIR` environment variable. | + +Returns `None`. Only removes files for `live*` gauge modes (e.g. `livesum`, `liveall`); files +for non-live modes are left in place so their last values remain visible until the directory is +wiped on restart. + +```python +# Gunicorn config +from prometheus_client import multiprocess + +def child_exit(server, worker): + multiprocess.mark_process_dead(worker.pid) +``` diff --git a/docs/content/registry/_index.md b/docs/content/registry/_index.md new file mode 100644 index 00000000..0d554535 --- /dev/null +++ b/docs/content/registry/_index.md @@ -0,0 +1,141 @@ +--- +title: Registry +weight: 8 +--- + +A `CollectorRegistry` holds all the collectors whose metrics are exposed when +the registry is scraped. The global default registry is `REGISTRY`, which all +metric constructors register with automatically unless told otherwise. + +```python +from prometheus_client import REGISTRY, CollectorRegistry + +# Use the default global registry +from prometheus_client import Counter +c = Counter('my_counter', 'A counter') # registered with REGISTRY automatically + +# Create an isolated registry, e.g. for testing +r = CollectorRegistry() +c2 = Counter('my_counter', 'A counter', registry=r) +``` + +## Constructor + +```python +CollectorRegistry(auto_describe=False, target_info=None, support_collectors_without_names=False) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `auto_describe` | `bool` | `False` | If `True`, calls `collect()` on a collector at registration time if the collector does not implement `describe()`. Used to detect duplicate metric names. The default `REGISTRY` is created with `auto_describe=True`. | +| `target_info` | `Dict[str, str]` | `None` | Key-value labels to attach as a `target_info` metric. Equivalent to calling `set_target_info` after construction. | +| `support_collectors_without_names` | `bool` | `False` | If `True`, allows registering collectors that produce no named metrics (i.e. whose `describe()` returns an empty list). | + +## Methods + +### `register(collector)` + +Register a collector with this registry. Raises `ValueError` if any of the +metric names the collector produces are already registered. + +```python +from prometheus_client.registry import Collector + +class MyCollector(Collector): + def collect(self): + ... + +REGISTRY.register(MyCollector()) +``` + +### `unregister(collector)` + +Remove a previously registered collector. + +```python +from prometheus_client import GC_COLLECTOR +REGISTRY.unregister(GC_COLLECTOR) +``` + +### `collect()` + +Yield all metrics from every registered collector. Also yields the +`target_info` metric if one has been set. + +```python +for metric in REGISTRY.collect(): + print(metric.name, metric.type) +``` + +### `restricted_registry(names)` + +Return a view of this registry that only exposes the named metrics. Useful +for partial scrapes. See [Restricted registry](../restricted-registry/) for +usage with `generate_latest` and the built-in HTTP server. + +```python +from prometheus_client import generate_latest + +subset = REGISTRY.restricted_registry(['python_info', 'process_cpu_seconds_total']) +output = generate_latest(subset) +``` + +### `get_sample_value(name, labels=None)` + +Return the current value of a single sample, or `None` if not found. Intended +for use in unit tests; not efficient for production use. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | required | Full sample name including any suffix (e.g. `'my_counter_total'`). | +| `labels` | `Dict[str, str]` | `{}` | Label key-value pairs to match. An empty dict matches an unlabelled sample. | + +```python +from prometheus_client import Counter, CollectorRegistry + +r = CollectorRegistry() +c = Counter('requests_total', 'Total requests', registry=r) +c.inc(3) + +assert r.get_sample_value('requests_total') == 3.0 +``` + +### `set_target_info(labels)` + +Set or replace the target metadata labels exposed as a `target_info` metric. +Pass `None` to remove the target info metric. + +```python +REGISTRY.set_target_info({'env': 'production', 'region': 'us-east-1'}) +``` + +### `get_target_info()` + +Return the current target info labels as a `Dict[str, str]`, or `None` if not set. + +```python +info = REGISTRY.get_target_info() +``` + +## The global REGISTRY + +`REGISTRY` is the module-level default instance, created as: + +```python +REGISTRY = CollectorRegistry(auto_describe=True) +``` + +All metric constructors (`Counter`, `Gauge`, etc.) register with `REGISTRY` +by default. Pass `registry=None` to skip registration, or pass a different +`CollectorRegistry` instance to use a custom registry. + +```python +from prometheus_client import Counter, CollectorRegistry + +# skip global registration — useful in tests +c = Counter('my_counter', 'A counter', registry=None) + +# register with a custom registry +r = CollectorRegistry() +c2 = Counter('my_counter', 'A counter', registry=r) +``` diff --git a/mypy.ini b/mypy.ini index fe372d07..3aa142c1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -exclude = prometheus_client/decorator.py|prometheus_client/twisted|tests/test_twisted.py +exclude = prometheus_client/decorator.py|prometheus_client/twisted|tests/test_twisted.py|prometheus_client/django|tests/test_django.py implicit_reexport = False disallow_incomplete_defs = True diff --git a/prometheus_client/aiohttp/exposition.py b/prometheus_client/aiohttp/exposition.py index 914fb26f..c1ae254d 100644 --- a/prometheus_client/aiohttp/exposition.py +++ b/prometheus_client/aiohttp/exposition.py @@ -4,11 +4,11 @@ from aiohttp.typedefs import Handler from ..exposition import _bake_output -from ..registry import CollectorRegistry, REGISTRY +from ..registry import Collector, REGISTRY def make_aiohttp_handler( - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, disable_compression: bool = False, ) -> Handler: """Create a aiohttp handler which serves the metrics from a registry.""" diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index affd9844..6e527ca9 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -2,10 +2,10 @@ from urllib.parse import parse_qs from .exposition import _bake_output -from .registry import CollectorRegistry, REGISTRY +from .registry import Collector, REGISTRY -def make_asgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: bool = False) -> Callable: +def make_asgi_app(registry: Collector = REGISTRY, disable_compression: bool = False) -> Callable: """Create a ASGI app which serves the metrics from a registry.""" async def prometheus_app(scope, receive, send): diff --git a/prometheus_client/bridge/graphite.py b/prometheus_client/bridge/graphite.py index 8cadbedc..235324b2 100755 --- a/prometheus_client/bridge/graphite.py +++ b/prometheus_client/bridge/graphite.py @@ -8,7 +8,7 @@ from timeit import default_timer from typing import Callable, Tuple -from ..registry import CollectorRegistry, REGISTRY +from ..registry import Collector, REGISTRY # Roughly, have to keep to what works as a file name. # We also remove periods, so labels can be distinguished. @@ -48,7 +48,7 @@ def run(self): class GraphiteBridge: def __init__(self, address: Tuple[str, int], - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, timeout_seconds: float = 30, _timer: Callable[[], float] = time.time, tags: bool = False, diff --git a/prometheus_client/context_managers.py b/prometheus_client/context_managers.py index 3988ec22..3e8d7ced 100644 --- a/prometheus_client/context_managers.py +++ b/prometheus_client/context_managers.py @@ -55,6 +55,7 @@ class Timer: def __init__(self, metric, callback_name): self._metric = metric self._callback_name = callback_name + self.duration = None def _new_timer(self): return self.__class__(self._metric, self._callback_name) @@ -65,9 +66,9 @@ def __enter__(self): def __exit__(self, typ, value, traceback): # Time can go backwards. - duration = max(default_timer() - self._start, 0) + self.duration = max(default_timer() - self._start, 0) callback = getattr(self._metric, self._callback_name) - callback(duration) + callback(self.duration) def labels(self, *args, **kw): self._metric = self._metric.labels(*args, **kw) diff --git a/prometheus_client/django/__init__.py b/prometheus_client/django/__init__.py new file mode 100644 index 00000000..280dbfb0 --- /dev/null +++ b/prometheus_client/django/__init__.py @@ -0,0 +1,5 @@ +from .exposition import PrometheusDjangoView + +__all__ = [ + "PrometheusDjangoView", +] diff --git a/prometheus_client/django/exposition.py b/prometheus_client/django/exposition.py new file mode 100644 index 00000000..71fc8d8a --- /dev/null +++ b/prometheus_client/django/exposition.py @@ -0,0 +1,43 @@ +import os + +from django.http import HttpResponse +from django.views import View + +import prometheus_client +from prometheus_client import multiprocess +from prometheus_client.exposition import _bake_output + + +class PrometheusDjangoView(View): + multiprocess_mode: bool = "PROMETHEUS_MULTIPROC_DIR" in os.environ or "prometheus_multiproc_dir" in os.environ + registry: prometheus_client.CollectorRegistry = None + + def get(self, request, *args, **kwargs): + if self.registry is None: + if self.multiprocess_mode: + self.registry = prometheus_client.CollectorRegistry() + multiprocess.MultiProcessCollector(self.registry) + else: + self.registry = prometheus_client.REGISTRY + accept_header = request.headers.get("Accept") + accept_encoding_header = request.headers.get("Accept-Encoding") + # Bake output + status, headers, output = _bake_output( + registry=self.registry, + accept_header=accept_header, + accept_encoding_header=accept_encoding_header, + params=request.GET, + disable_compression=False, + ) + status = int(status.split(" ")[0]) + return HttpResponse( + output, + status=status, + headers=headers, + ) + + def options(self, request, *args, **kwargs): + return HttpResponse( + status=200, + headers={"Allow": "OPTIONS,GET"}, + ) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 0d471707..2d402a0f 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -9,7 +9,9 @@ import ssl import sys import threading -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import ( + Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union, +) from urllib.error import HTTPError from urllib.parse import parse_qs, quote_plus, urlparse from urllib.request import ( @@ -19,9 +21,16 @@ from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer from .openmetrics import exposition as openmetrics -from .registry import CollectorRegistry, REGISTRY +from .registry import Collector, REGISTRY from .utils import floatToGoString, parse_version +try: + import snappy # type: ignore + SNAPPY_AVAILABLE = True +except ImportError: + snappy = None # type: ignore + SNAPPY_AVAILABLE = False + __all__ = ( 'CONTENT_TYPE_LATEST', 'CONTENT_TYPE_PLAIN_0_0_4', @@ -46,6 +55,7 @@ """Content type of the latest format""" CONTENT_TYPE_LATEST = CONTENT_TYPE_PLAIN_1_0_0 +CompressionType = Optional[Literal['gzip', 'snappy']] class _PrometheusRedirectHandler(HTTPRedirectHandler): @@ -118,7 +128,7 @@ def _bake_output(registry, accept_header, accept_encoding_header, params, disabl return '200 OK', headers, output -def make_wsgi_app(registry: CollectorRegistry = REGISTRY, disable_compression: bool = False) -> Callable: +def make_wsgi_app(registry: Collector = REGISTRY, disable_compression: bool = False) -> Callable: """Create a WSGI app which serves the metrics from a registry.""" def prometheus_app(environ, start_response): @@ -223,7 +233,7 @@ def _get_ssl_ctx( def start_wsgi_server( port: int, addr: str = '0.0.0.0', - registry: CollectorRegistry = REGISTRY, + registry: Collector = REGISTRY, certfile: Optional[str] = None, keyfile: Optional[str] = None, client_cafile: Optional[str] = None, @@ -252,12 +262,12 @@ class TmpServer(ThreadingWSGIServer): start_http_server = start_wsgi_server -def generate_latest(registry: CollectorRegistry = REGISTRY, escaping: str = openmetrics.UNDERSCORES) -> bytes: +def generate_latest(registry: Collector = REGISTRY, escaping: str = openmetrics.UNDERSCORES) -> bytes: """ Generates the exposition format using the basic Prometheus text format. Params: - registry: CollectorRegistry to export data from. + registry: Collector to export data from. escaping: Escaping scheme used for metric and label names. Returns: UTF-8 encoded string containing the metrics in text format. @@ -330,7 +340,7 @@ def sample_line(samples): return ''.join(output).encode('utf-8') -def choose_encoder(accept_header: str) -> Tuple[Callable[[CollectorRegistry], bytes], str]: +def choose_encoder(accept_header: str) -> Tuple[Callable[[Collector], bytes], str]: # Python client library accepts a narrower range of content-types than # Prometheus does. accept_header = accept_header or '' @@ -408,7 +418,7 @@ def gzip_accepted(accept_encoding_header: str) -> bool: class MetricsHandler(BaseHTTPRequestHandler): """HTTP handler that gives metrics from ``REGISTRY``.""" - registry: CollectorRegistry = REGISTRY + registry: Collector = REGISTRY def do_GET(self) -> None: # Prepare parameters @@ -429,7 +439,7 @@ def log_message(self, format: str, *args: Any) -> None: """Log nothing.""" @classmethod - def factory(cls, registry: CollectorRegistry) -> type: + def factory(cls, registry: Collector) -> type: """Returns a dynamic MetricsHandler class tied to the passed registry. """ @@ -444,7 +454,7 @@ def factory(cls, registry: CollectorRegistry) -> type: return MyMetricsHandler -def write_to_textfile(path: str, registry: CollectorRegistry, escaping: str = openmetrics.ALLOWUTF8, tmpdir: Optional[str] = None) -> None: +def write_to_textfile(path: str, registry: Collector, escaping: str = openmetrics.ALLOWUTF8, tmpdir: Optional[str] = None) -> None: """Write metrics to the given path. This is intended for use with the Node exporter textfile collector. @@ -592,10 +602,11 @@ def tls_auth_handler( def push_to_gateway( gateway: str, job: str, - registry: CollectorRegistry, + registry: Collector, grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, + compression: CompressionType = None, ) -> None: """Push metrics to the given pushgateway. @@ -603,7 +614,7 @@ def push_to_gateway( 'http://pushgateway.local', or 'pushgateway.local'. Scheme defaults to 'http' if none is provided `job` is the job label to be attached to all pushed metrics - `registry` is an instance of CollectorRegistry + `registry` is a Collector, normally an instance of CollectorRegistry `grouping_key` please see the pushgateway documentation for details. Defaults to None `timeout` is how long push will attempt to connect before giving up. @@ -632,19 +643,22 @@ def push_to_gateway( failure. 'content' is the data which should be used to form the HTTP Message Body. + `compression` selects the payload compression. Supported values are 'gzip' + and 'snappy'. Defaults to None (no compression). This overwrites all metrics with the same job and grouping_key. This uses the PUT HTTP method.""" - _use_gateway('PUT', gateway, job, registry, grouping_key, timeout, handler) + _use_gateway('PUT', gateway, job, registry, grouping_key, timeout, handler, compression) def pushadd_to_gateway( gateway: str, job: str, - registry: Optional[CollectorRegistry], + registry: Optional[Collector], grouping_key: Optional[Dict[str, Any]] = None, timeout: Optional[float] = 30, handler: Callable = default_handler, + compression: CompressionType = None, ) -> None: """PushAdd metrics to the given pushgateway. @@ -652,7 +666,7 @@ def pushadd_to_gateway( 'http://pushgateway.local', or 'pushgateway.local'. Scheme defaults to 'http' if none is provided `job` is the job label to be attached to all pushed metrics - `registry` is an instance of CollectorRegistry + `registry` is a Collector, normally an instance of CollectorRegistry `grouping_key` please see the pushgateway documentation for details. Defaults to None `timeout` is how long push will attempt to connect before giving up. @@ -663,10 +677,12 @@ def pushadd_to_gateway( will be carried out by a default handler. See the 'prometheus_client.push_to_gateway' documentation for implementation requirements. + `compression` selects the payload compression. Supported values are 'gzip' + and 'snappy'. Defaults to None (no compression). This replaces metrics with the same name, job and grouping_key. This uses the POST HTTP method.""" - _use_gateway('POST', gateway, job, registry, grouping_key, timeout, handler) + _use_gateway('POST', gateway, job, registry, grouping_key, timeout, handler, compression) def delete_from_gateway( @@ -702,10 +718,11 @@ def _use_gateway( method: str, gateway: str, job: str, - registry: Optional[CollectorRegistry], + registry: Optional[Collector], grouping_key: Optional[Dict[str, Any]], timeout: Optional[float], handler: Callable, + compression: CompressionType = None, ) -> None: gateway_url = urlparse(gateway) # See https://bugs.python.org/issue27657 for details on urlparse in py>=3.7.6. @@ -715,30 +732,60 @@ def _use_gateway( gateway = gateway.rstrip('/') url = '{}/metrics/{}/{}'.format(gateway, *_escape_grouping_key("job", job)) - data = b'' - if method != 'DELETE': - if registry is None: - registry = REGISTRY - data = generate_latest(registry) - if grouping_key is None: grouping_key = {} url += ''.join( '/{}/{}'.format(*_escape_grouping_key(str(k), str(v))) for k, v in sorted(grouping_key.items())) + data = b'' + headers: List[Tuple[str, str]] = [] + if method != 'DELETE': + if registry is None: + registry = REGISTRY + data = generate_latest(registry) + data, headers = _compress_payload(data, compression) + else: + # DELETE requests still need Content-Type header per test expectations + headers = [('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)] + if compression is not None: + raise ValueError('Compression is not supported for DELETE requests.') + handler( url=url, method=method, timeout=timeout, - headers=[('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)], data=data, + headers=headers, data=data, )() +def _compress_payload(data: bytes, compression: CompressionType) -> Tuple[bytes, List[Tuple[str, str]]]: + headers = [('Content-Type', CONTENT_TYPE_PLAIN_0_0_4)] + if compression is None: + return data, headers + + encoding = compression.lower() + if encoding == 'gzip': + headers.append(('Content-Encoding', 'gzip')) + return gzip.compress(data), headers + if encoding == 'snappy': + if not SNAPPY_AVAILABLE: + raise RuntimeError('Snappy compression requires the python-snappy package to be installed.') + headers.append(('Content-Encoding', 'snappy')) + compressor = snappy.StreamCompressor() + compressed = compressor.compress(data) + flush = getattr(compressor, 'flush', None) + if callable(flush): + compressed += flush() + return compressed, headers + raise ValueError(f"Unsupported compression type: {compression}") + + def _escape_grouping_key(k, v): if v == "": # Per https://github.com/prometheus/pushgateway/pull/346. return k + "@base64", "=" - elif '/' in v: + elif '/' in v or ' ' in v: # Added in Pushgateway 0.9.0. + # Use base64 encoding for values containing slashes or spaces return k + "@base64", base64.urlsafe_b64encode(v.encode("utf-8")).decode("utf-8") else: return k, quote_plus(v) diff --git a/prometheus_client/multiprocess.py b/prometheus_client/multiprocess.py index 2682190a..db55874e 100644 --- a/prometheus_client/multiprocess.py +++ b/prometheus_client/multiprocess.py @@ -88,32 +88,42 @@ def _parse_key(key): @staticmethod def _accumulate_metrics(metrics, accumulate): for metric in metrics.values(): - samples = defaultdict(float) - sample_timestamps = defaultdict(float) + samples = defaultdict(lambda: defaultdict(float)) + sample_timestamps = defaultdict(lambda: defaultdict(float)) buckets = defaultdict(lambda: defaultdict(float)) - samples_setdefault = samples.setdefault for s in metric.samples: name, labels, value, timestamp, exemplar, native_histogram_value = s + + if ( + metric.type == 'gauge' + and metric._multiprocess_mode in ( + 'min', 'livemin', + 'max', 'livemax', + 'sum', 'livesum', + 'mostrecent', 'livemostrecent', + ) + ): + labels = tuple(l for l in labels if l[0] != 'pid') + if metric.type == 'gauge': - without_pid_key = (name, tuple(l for l in labels if l[0] != 'pid')) if metric._multiprocess_mode in ('min', 'livemin'): - current = samples_setdefault(without_pid_key, value) + current = samples[labels].setdefault((name, labels), value) if value < current: - samples[without_pid_key] = value + samples[labels][(name, labels)] = value elif metric._multiprocess_mode in ('max', 'livemax'): - current = samples_setdefault(without_pid_key, value) + current = samples[labels].setdefault((name, labels), value) if value > current: - samples[without_pid_key] = value + samples[labels][(name, labels)] = value elif metric._multiprocess_mode in ('sum', 'livesum'): - samples[without_pid_key] += value + samples[labels][(name, labels)] += value elif metric._multiprocess_mode in ('mostrecent', 'livemostrecent'): - current_timestamp = sample_timestamps[without_pid_key] + current_timestamp = sample_timestamps[labels][name] timestamp = float(timestamp or 0) if current_timestamp < timestamp: - samples[without_pid_key] = value - sample_timestamps[without_pid_key] = timestamp + samples[labels][(name, labels)] = value + sample_timestamps[labels][name] = timestamp else: # all/liveall - samples[(name, labels)] = value + samples[labels][(name, labels)] = value elif metric.type == 'histogram': # A for loop with early exit is faster than a genexpr @@ -127,10 +137,10 @@ def _accumulate_metrics(metrics, accumulate): break else: # did not find the `le` key # _sum/_count - samples[(name, labels)] += value + samples[labels][(name, labels)] += value else: # Counter and Summary. - samples[(name, labels)] += value + samples[labels][(name, labels)] += value # Accumulate bucket values. if metric.type == 'histogram': @@ -143,14 +153,17 @@ def _accumulate_metrics(metrics, accumulate): ) if accumulate: acc += value - samples[sample_key] = acc + samples[labels][sample_key] = acc else: - samples[sample_key] = value + samples[labels][sample_key] = value if accumulate: - samples[(metric.name + '_count', labels)] = acc + samples[labels][(metric.name + '_count', labels)] = acc # Convert to correct sample format. - metric.samples = [Sample(name_, dict(labels), value) for (name_, labels), value in samples.items()] + metric.samples = [] + for _, samples_by_labels in samples.items(): + for (name_, labels), value in samples_by_labels.items(): + metric.samples.append(Sample(name_, dict(labels), value)) return metrics.values() def collect(self): diff --git a/prometheus_client/registry.py b/prometheus_client/registry.py index 8de4ce91..c2b55d15 100644 --- a/prometheus_client/registry.py +++ b/prometheus_client/registry.py @@ -1,24 +1,21 @@ -from abc import ABC, abstractmethod import copy from threading import Lock -from typing import Dict, Iterable, List, Optional +from typing import Dict, Iterable, List, Optional, Protocol from .metrics_core import Metric -# Ideally this would be a Protocol, but Protocols are only available in Python >= 3.8. -class Collector(ABC): - @abstractmethod +class Collector(Protocol): def collect(self) -> Iterable[Metric]: - pass + """Collect metrics.""" -class _EmptyCollector(Collector): +class _EmptyCollector: def collect(self) -> Iterable[Metric]: return [] -class CollectorRegistry(Collector): +class CollectorRegistry: """Metric collector registry. Collectors must have a no-argument method 'collect' that returns a list of @@ -26,12 +23,15 @@ class CollectorRegistry(Collector): exposition formats. """ - def __init__(self, auto_describe: bool = False, target_info: Optional[Dict[str, str]] = None): + def __init__(self, auto_describe: bool = False, target_info: Optional[Dict[str, str]] = None, + support_collectors_without_names: bool = False): self._collector_to_names: Dict[Collector, List[str]] = {} self._names_to_collectors: Dict[str, Collector] = {} self._auto_describe = auto_describe self._lock = Lock() self._target_info: Optional[Dict[str, str]] = {} + self._support_collectors_without_names = support_collectors_without_names + self._collectors_without_names: List[Collector] = [] self.set_target_info(target_info) def register(self, collector: Collector) -> None: @@ -46,6 +46,8 @@ def register(self, collector: Collector) -> None: for name in names: self._names_to_collectors[name] = collector self._collector_to_names[collector] = names + if self._support_collectors_without_names and not names: + self._collectors_without_names.append(collector) def unregister(self, collector: Collector) -> None: """Remove a collector from the registry.""" @@ -148,7 +150,7 @@ def __init__(self, names: Iterable[str], registry: CollectorRegistry): self._registry = registry def collect(self) -> Iterable[Metric]: - collectors = set() + collectors = set(self._registry._collectors_without_names) target_info_metric = None with self._registry._lock: if 'target_info' in self._name_set and self._registry._target_info: diff --git a/pyproject.toml b/pyproject.toml index 1d8527a0..336cfb4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "prometheus_client" -version = "0.23.1" +version = "0.25.0" description = "Python client for the Prometheus monitoring system." readme = "README.md" license = "Apache-2.0 AND BSD-2-Clause" @@ -47,6 +47,9 @@ twisted = [ aiohttp = [ "aiohttp", ] +django = [ + "django", +] [project.urls] Homepage = "https://github.com/prometheus/client_python" diff --git a/tests/test_asgi.py b/tests/test_asgi.py index d4933cec..6e795e21 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -223,3 +223,32 @@ def test_qs_parsing(self): asyncio.new_event_loop().run_until_complete( self.communicator.wait() ) + + def test_qs_parsing_multi(self): + """Only metrics that match the 'name[]' query string param appear""" + + app = make_asgi_app(self.registry) + metrics = [ + ("asdf", "first test metric", 1), + ("bsdf", "second test metric", 2), + ("csdf", "third test metric", 3) + ] + + for m in metrics: + self.increment_metrics(*m) + + self.seed_app(app) + self.scope['query_string'] = "&".join([f"name[]={m[0]}_total" for m in metrics[0:2]]).encode("utf-8") + self.send_default_request() + + outputs = self.get_all_output() + response_body = outputs[1] + output = response_body['body'].decode('utf8') + + self.assert_metrics(output, *metrics[0]) + self.assert_metrics(output, *metrics[1]) + self.assert_not_metrics(output, *metrics[2]) + + asyncio.new_event_loop().run_until_complete( + self.communicator.wait() + ) diff --git a/tests/test_core.py b/tests/test_core.py index c7c9c14f..cdf32bfa 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -378,6 +378,14 @@ def test_block_decorator_with_label(self): metric.labels('foo') self.assertEqual(1, value('s_with_labels_count', {'label1': 'foo'})) + def test_timer_duration_exposed(self): + with self.summary.time() as t: + time.sleep(0.01) + self.assertIsNotNone(t.duration) + self.assertGreater(t.duration, 0) + recorded_sum = self.registry.get_sample_value('s_sum') + self.assertEqual(t.duration, recorded_sum) + def test_timer_not_observable(self): s = Summary('test', 'help', labelnames=('label',), registry=self.registry) @@ -1024,6 +1032,24 @@ def test_restricted_registry_does_not_call_extra(self): self.assertEqual([m], list(registry.restricted_registry(['s_sum']).collect())) mock_collector.collect.assert_not_called() + def test_restricted_registry_ignore_no_names_collectors(self): + from unittest.mock import MagicMock + registry = CollectorRegistry() + mock_collector = MagicMock() + mock_collector.describe.return_value = [] + registry.register(mock_collector) + self.assertEqual(list(registry.restricted_registry(['metric']).collect()), []) + mock_collector.collect.assert_not_called() + + def test_restricted_registry_collects_no_names_collectors(self): + from unittest.mock import MagicMock + registry = CollectorRegistry(support_collectors_without_names=True) + mock_collector = MagicMock() + mock_collector.describe.return_value = [] + registry.register(mock_collector) + self.assertEqual(list(registry.restricted_registry(['metric']).collect()), []) + mock_collector.collect.assert_called() + def test_restricted_registry_does_not_yield_while_locked(self): registry = CollectorRegistry(target_info={'foo': 'bar'}) Summary('s', 'help', registry=registry).observe(7) diff --git a/tests/test_django.py b/tests/test_django.py new file mode 100644 index 00000000..659bb3f6 --- /dev/null +++ b/tests/test_django.py @@ -0,0 +1,48 @@ +from unittest import skipUnless + +from prometheus_client import CollectorRegistry, Counter, generate_latest +from prometheus_client.openmetrics.exposition import ALLOWUTF8 + +try: + import django + from django.test import RequestFactory, TestCase + + from prometheus_client.django import PrometheusDjangoView + + HAVE_DJANGO = True +except ImportError: + from unittest import TestCase + + HAVE_DJANGO = False + +else: + from django.conf import settings + + if not settings.configured: + settings.configure( + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[], + ) + django.setup() + + +class MetricsResourceTest(TestCase): + @skipUnless(HAVE_DJANGO, "Don't have django installed.") + def setUp(self): + self.registry = CollectorRegistry() + self.factory = RequestFactory() + + def test_reports_metrics(self): + c = Counter('cc', 'A counter', registry=self.registry) + c.inc() + + request = self.factory.get("/metrics") + + response = PrometheusDjangoView.as_view(registry=self.registry)(request) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, generate_latest(self.registry, ALLOWUTF8)) diff --git a/tests/test_exposition.py b/tests/test_exposition.py index 3dd5e378..a3c97820 100644 --- a/tests/test_exposition.py +++ b/tests/test_exposition.py @@ -1,3 +1,4 @@ +import gzip from http.server import BaseHTTPRequestHandler, HTTPServer import os import threading @@ -300,6 +301,13 @@ def test_push_with_groupingkey_empty_label(self): self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_PLAIN_0_0_4) self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + def test_push_with_groupingkey_with_spaces(self): + push_to_gateway(self.address, "my_job", self.registry, {'label': 'value with spaces'}) + self.assertEqual(self.requests[0][0].command, 'PUT') + self.assertEqual(self.requests[0][0].path, '/metrics/job/my_job/label@base64/dmFsdWUgd2l0aCBzcGFjZXM=') + self.assertEqual(self.requests[0][0].headers.get('content-type'), CONTENT_TYPE_PLAIN_0_0_4) + self.assertEqual(self.requests[0][1], b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + def test_push_with_complex_groupingkey(self): push_to_gateway(self.address, "my_job", self.registry, {'a': 9, 'b': 'a/ z'}) self.assertEqual(self.requests[0][0].command, 'PUT') @@ -404,6 +412,30 @@ def test_push_with_trailing_slash(self): self.assertNotIn('//', self.requests[0][0].path) + def test_push_with_gzip_compression(self): + push_to_gateway(self.address, "my_job", self.registry, compression='gzip') + request, body = self.requests[0] + self.assertEqual(request.headers.get('content-encoding'), 'gzip') + decompressed = gzip.decompress(body) + self.assertEqual(decompressed, b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + + def test_push_with_snappy_compression(self): + snappy = pytest.importorskip('snappy') + push_to_gateway(self.address, "my_job", self.registry, compression='snappy') + request, body = self.requests[0] + self.assertEqual(request.headers.get('content-encoding'), 'snappy') + decompressor = snappy.StreamDecompressor() + decompressed = decompressor.decompress(body) + flush = getattr(decompressor, 'flush', None) + if callable(flush): + decompressed += flush() + self.assertEqual(decompressed, b'# HELP g help\n# TYPE g gauge\ng 0.0\n') + + def test_push_with_invalid_compression(self): + with self.assertRaisesRegex(ValueError, 'Unsupported compression type'): + push_to_gateway(self.address, "my_job", self.registry, compression='brotli') + self.assertEqual(self.requests, []) + def test_instance_ip_grouping_key(self): self.assertTrue('' != instance_ip_grouping_key()['instance']) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index e7ca154e..ee0c7423 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -52,7 +52,7 @@ def setUp(self): self.tempdir = tempfile.mkdtemp() os.environ['PROMETHEUS_MULTIPROC_DIR'] = self.tempdir values.ValueClass = MultiProcessValue(lambda: 123) - self.registry = CollectorRegistry() + self.registry = CollectorRegistry(support_collectors_without_names=True) self.collector = MultiProcessCollector(self.registry) @property @@ -276,10 +276,8 @@ def add_label(key, value): Sample('g', add_label('pid', '1'), 1.0), ]) - metrics['h'].samples.sort( - key=lambda x: (x[0], float(x[1].get('le', 0))) - ) expected_histogram = [ + Sample('h_sum', labels, 6.0), Sample('h_bucket', add_label('le', '0.005'), 0.0), Sample('h_bucket', add_label('le', '0.01'), 0.0), Sample('h_bucket', add_label('le', '0.025'), 0.0), @@ -296,11 +294,99 @@ def add_label(key, value): Sample('h_bucket', add_label('le', '10.0'), 2.0), Sample('h_bucket', add_label('le', '+Inf'), 2.0), Sample('h_count', labels, 2.0), - Sample('h_sum', labels, 6.0), ] self.assertEqual(metrics['h'].samples, expected_histogram) + def test_collect_histogram_ordering(self): + pid = 0 + values.ValueClass = MultiProcessValue(lambda: pid) + labels = {i: i for i in 'abcd'} + + def add_label(key, value): + l = labels.copy() + l[key] = value + return l + + h = Histogram('h', 'help', labelnames=['view'], registry=None) + + h.labels(view='view1').observe(1) + + pid = 1 + + h.labels(view='view1').observe(5) + h.labels(view='view2').observe(1) + + metrics = {m.name: m for m in self.collector.collect()} + + expected_histogram = [ + Sample('h_sum', {'view': 'view1'}, 6.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.005'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.01'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.025'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.05'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.075'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.1'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.25'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.5'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '0.75'}, 0.0), + Sample('h_bucket', {'view': 'view1', 'le': '1.0'}, 1.0), + Sample('h_bucket', {'view': 'view1', 'le': '2.5'}, 1.0), + Sample('h_bucket', {'view': 'view1', 'le': '5.0'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '7.5'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '10.0'}, 2.0), + Sample('h_bucket', {'view': 'view1', 'le': '+Inf'}, 2.0), + Sample('h_count', {'view': 'view1'}, 2.0), + Sample('h_sum', {'view': 'view2'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.005'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.01'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.025'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.05'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.075'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.1'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.25'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.5'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '0.75'}, 0.0), + Sample('h_bucket', {'view': 'view2', 'le': '1.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '2.5'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '5.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '7.5'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '10.0'}, 1.0), + Sample('h_bucket', {'view': 'view2', 'le': '+Inf'}, 1.0), + Sample('h_count', {'view': 'view2'}, 1.0), + ] + + self.assertEqual(metrics['h'].samples, expected_histogram) + + def test_restrict(self): + pid = 0 + values.ValueClass = MultiProcessValue(lambda: pid) + labels = {i: i for i in 'abcd'} + + def add_label(key, value): + l = labels.copy() + l[key] = value + return l + + c = Counter('c', 'help', labelnames=labels.keys(), registry=None) + g = Gauge('g', 'help', labelnames=labels.keys(), registry=None) + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + + pid = 1 + + c.labels(**labels).inc(1) + g.labels(**labels).set(1) + + metrics = {m.name: m for m in self.registry.restricted_registry(['c_total']).collect()} + + self.assertEqual(metrics.keys(), {'c'}) + + self.assertEqual( + metrics['c'].samples, [Sample('c_total', labels, 2.0)] + ) + def test_collect_preserves_help(self): pid = 0 values.ValueClass = MultiProcessValue(lambda: pid) @@ -347,10 +433,8 @@ def add_label(key, value): m.name: m for m in self.collector.merge(files, accumulate=False) } - metrics['h'].samples.sort( - key=lambda x: (x[0], float(x[1].get('le', 0))) - ) expected_histogram = [ + Sample('h_sum', labels, 6.0), Sample('h_bucket', add_label('le', '0.005'), 0.0), Sample('h_bucket', add_label('le', '0.01'), 0.0), Sample('h_bucket', add_label('le', '0.025'), 0.0), @@ -366,7 +450,6 @@ def add_label(key, value): Sample('h_bucket', add_label('le', '7.5'), 0.0), Sample('h_bucket', add_label('le', '10.0'), 0.0), Sample('h_bucket', add_label('le', '+Inf'), 0.0), - Sample('h_sum', labels, 6.0), ] self.assertEqual(metrics['h'].samples, expected_histogram) diff --git a/tox.ini b/tox.ini index ccf95cc2..992bd0a7 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,8 @@ deps = attrs {py3.9,pypy3.9}: twisted {py3.9,pypy3.9}: aiohttp + {py3.9,pypy3.9}: django + {py3.9}: python-snappy commands = coverage run --parallel -m pytest {posargs} [testenv:py3.9-nooptionals]