From 3a51798606247f4c66efb859f3079b9b53a0fcd1 Mon Sep 17 00:00:00 2001 From: daavoo Date: Wed, 16 Sep 2026 15:30:19 +0200 Subject: [PATCH] build(metrics)!: make prometheus-client an optional extra prometheus-client was a core dependency imported on every startup, whether or not anything scraped. enable_metrics gates only the middleware and the /metrics route, so a deployment that never scrapes still paid the import, the ProcessCollector registration and 14 always-incrementing counters nothing could read. Most of the import cost is prometheus_client.exposition pulling in http.server and wsgiref.simple_server, which nothing else here needs. It moves to a metrics extra. gateway/metrics.py becomes the seam: it re-exports Counter, Gauge, Histogram, CollectorRegistry, ProcessCollector and generate_latest, real when the extra is installed and no-op stands-in when it is not, so the declarations and the record_* helpers stay callable either way and no call site needs a guard. Types are taken from the real library under TYPE_CHECKING, which the dev group installs, so mypy checks the declarations against it regardless of the runtime environment. enable_metrics with the extra absent refuses to start rather than registering a scrape that answers with an empty body, which would read as a broken exporter rather than a missing install. BREAKING CHANGE: a source install that sets enable_metrics must now install the metrics extra (pip install gateway[metrics]) or the gateway refuses to start. The Docker image installs it, so image-based deployments are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- Dockerfile | 4 +- docs/configuration.md | 2 +- docs/dashboard.md | 5 +- docs/deployment.md | 4 + pyproject.toml | 13 +- src/gateway/main.py | 22 ++++ src/gateway/metrics.py | 113 ++++++++++++++++-- .../unit/test_metrics_optional_dependency.py | 106 ++++++++++++++++ uv.lock | 10 +- 9 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_metrics_optional_dependency.py diff --git a/Dockerfile b/Dockerfile index 281e395c81..fe03b4ba72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,9 @@ RUN pip install --no-cache-dir --upgrade pip uv COPY pyproject.toml uv.lock ./ COPY src ./src -RUN uv sync --frozen --no-dev +# --extra metrics: the image is the self-hosted default, where a Prometheus +# scrape is expected to be one config flag away rather than a reinstall. +RUN uv sync --frozen --no-dev --extra metrics FROM python:3.14-slim AS runtime diff --git a/docs/configuration.md b/docs/configuration.md index 9436a8a145..088083e73d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,7 +79,7 @@ the corresponding startup value after the database is available. | `public_catalog` | Serve the model catalog to visitors without a session. Defaults to `false`. | | `public_catalog_rate_limit_per_minute` | Anonymous catalog reads per client address per minute. Defaults to 60. | | `rate_limit_rpm` | Per-user request limit. Unset disables it. | -| `enable_metrics` | Serve Prometheus metrics at `/metrics`. | +| `enable_metrics` | Serve Prometheus metrics at `/metrics`. Needs the `metrics` extra (`pip install gateway[metrics]`), which the Docker image installs; setting this without it refuses to start. | | `enable_docs` | Serve OpenAPI, Swagger UI, and ReDoc. | | `mode` | `standalone`, `hosted`, or `hybrid`. See [Modes](modes.md). | diff --git a/docs/dashboard.md b/docs/dashboard.md index 528adab913..f73b78ccf7 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -168,7 +168,10 @@ models and the model that served the response. Imported usage is labeled by source and does not consume a budget. Use Prometheus at `/metrics` for process-level monitoring when -`enable_metrics` is enabled. +`enable_metrics` is enabled. The scrape needs the `metrics` extra +(`pip install gateway[metrics]`); the Docker image installs it, and a +source install that sets `enable_metrics` without it refuses to start rather +than serving an empty scrape. ## Organization diff --git a/docs/deployment.md b/docs/deployment.md index 2992ef36c7..ef6a5518a1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -19,6 +19,10 @@ A durable standalone deployment should: The default SQLite database is intended for evaluation and single-node local use. +A `/metrics` scrape needs the `metrics` extra (`pip install gateway[metrics]`), +which the Docker image installs. A source install that sets `enable_metrics` +without it refuses to start rather than serving an empty scrape. + ### Watch the connection pool On PostgreSQL the gateway serves requests from a fixed pool of database diff --git a/pyproject.toml b/pyproject.toml index 16f08d78d6..754390556c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ dependencies = [ "opentelemetry-api>=1.39.1", "pillow>=12.3.0", "pypdfium2>=4.30.0", - "prometheus-client>=0.20.0", "psycopg2-binary>=2.9.9", "pydantic-settings>=2.14.2", "python-dotenv>=1.0.0", @@ -108,10 +107,22 @@ ocr = [ s3 = [ "boto3>=1.42.0", ] +# Prometheus scrape at /metrics. Optional so a deployment that never scrapes +# pays neither the import (prometheus_client.exposition pulls in http.server and +# wsgiref.simple_server, which nothing else here needs) nor the collection; when +# absent the metric objects in gateway.metrics are no-ops and `enable_metrics` +# refuses to start. The Docker image installs it. +metrics = [ + "prometheus-client>=0.20.0", +] [dependency-groups] dev = [ "boto3-stubs[s3]", + # The metrics extra, which the dev group installs unconditionally: the tests + # that pin the exposed metric families need the real library, and mypy checks + # gateway.metrics against it rather than against its no-op fallback. + "prometheus-client>=0.20.0", # Validating payloads against the published code-execution contract # (docs/public/code-execution-openapi.yaml), in its conformance script and # the test that keeps the spec and the doc in agreement. Nothing at runtime diff --git a/src/gateway/main.py b/src/gateway/main.py index 60df149ad7..4137882dd7 100644 --- a/src/gateway/main.py +++ b/src/gateway/main.py @@ -291,6 +291,27 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - return response +def _validate_metrics_support(config: GatewayConfig) -> None: + """Refuse to start when metrics are asked for but the extra is not installed. + + ``prometheus-client`` is an optional extra, and without it the metric objects + in :mod:`gateway.metrics` fall back to no-ops. Registering ``/metrics`` on top + of those would answer a scrape with an empty body, which reads as a broken + exporter rather than a missing install, so say which it is here instead. + """ + if not config.enable_metrics: + return + + from gateway.metrics import PROMETHEUS_AVAILABLE + + if not PROMETHEUS_AVAILABLE: + msg = ( + "enable_metrics is set but prometheus-client is not installed. " + "Install it with: pip install gateway[metrics]" + ) + raise ValueError(msg) + + def _validate_platform_config(config: GatewayConfig) -> None: config.validate_mode_selection() if not config.is_hybrid_mode: @@ -591,6 +612,7 @@ def create_app(config: GatewayConfig) -> FastAPI: _validate_platform_config(config) _warn_if_hosted_has_no_data_plane(config) + _validate_metrics_support(config) # A set-but-invalid OTARI_SECRET_KEY must not silently pass startup and then # break provider-credential storage at request time. Fail fast here instead. validate_secret_key() diff --git a/src/gateway/metrics.py b/src/gateway/metrics.py index 839e29633d..8f6b4471aa 100644 --- a/src/gateway/metrics.py +++ b/src/gateway/metrics.py @@ -1,32 +1,119 @@ """Prometheus registry, metric types, and HTTP request instrumentation for the gateway. -The metric types are re-exported so that code declaring a metric need not depend on ``prometheus_client`` directly. +The metric types are re-exported so that code declaring a metric need not depend +on ``prometheus_client`` directly. That re-export is also what makes the library +an optional extra (``gateway[metrics]``): when it is absent the names below are +no-op stands-in, so every declaration and every increment elsewhere still runs +unguarded. A deployment that does not scrape pays neither the import +(``prometheus_client.exposition`` pulls in ``http.server`` and +``wsgiref.simple_server``, which nothing else here needs) nor the collection, and +one that asks for a scrape (``enable_metrics``) is refused at startup rather than +served an empty body; see ``_validate_metrics_support`` in :mod:`gateway.main`. """ from __future__ import annotations import time -from typing import TYPE_CHECKING - -from prometheus_client import ( - CollectorRegistry, - Counter, - Gauge, - Histogram, - ProcessCollector, - generate_latest, -) -from prometheus_client.core import GaugeMetricFamily -from prometheus_client.registry import Collector +from typing import TYPE_CHECKING, Any + from starlette.responses import Response from gateway.core.config import API_ROOT, API_VERSION if TYPE_CHECKING: + # Types come from the real library, which the dev group always installs, so + # the declarations below are checked against it whether or not the runtime + # environment has the extra. + from prometheus_client import ( + CollectorRegistry, + Counter, + Gauge, + Histogram, + ProcessCollector, + generate_latest, + ) + from prometheus_client.core import GaugeMetricFamily + from prometheus_client.registry import Collector from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send + PROMETHEUS_AVAILABLE = True +else: + try: + from prometheus_client import ( + CollectorRegistry, + Counter, + Gauge, + Histogram, + ProcessCollector, + generate_latest, + ) + from prometheus_client.core import GaugeMetricFamily + from prometheus_client.registry import Collector + + PROMETHEUS_AVAILABLE = True + except ImportError: + PROMETHEUS_AVAILABLE = False + + class _NoopMetric: + """Accepts every call a Counter, Gauge, or Histogram takes, and records nothing. + + ``labels()`` returns the same object rather than a child so a chained + ``.labels(...).inc()`` works without allocating per label set. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def labels(self, *args: Any, **kwargs: Any) -> _NoopMetric: + return self + + def inc(self, amount: float = 1) -> None: + pass + + def dec(self, amount: float = 1) -> None: + pass + + def set(self, value: float) -> None: + pass + + def observe(self, amount: float) -> None: + pass + + Counter = Gauge = Histogram = _NoopMetric + + class GaugeMetricFamily(_NoopMetric): + """Stands in for the custom-collector sample type. + + A collector that builds one still runs; nothing collects it, since + ``generate_latest`` below yields an empty body. + """ + + def add_metric(self, *args: Any, **kwargs: Any) -> None: + pass + + class Collector: + """Base class for the custom collectors, so their ``collect`` still type-checks.""" + + class CollectorRegistry: # noqa: D101 + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def register(self, collector: Any) -> None: + pass + + def unregister(self, collector: Any) -> None: + pass + + class ProcessCollector: # noqa: D101 + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def generate_latest(registry: Any = None) -> bytes: # noqa: D103 + return b"" + __all__ = [ + "PROMETHEUS_AVAILABLE", "REGISTRY", "Collector", "Counter", diff --git a/tests/unit/test_metrics_optional_dependency.py b/tests/unit/test_metrics_optional_dependency.py new file mode 100644 index 0000000000..4f1e0ebc47 --- /dev/null +++ b/tests/unit/test_metrics_optional_dependency.py @@ -0,0 +1,106 @@ +"""Tests for the optional ``prometheus-client`` extra, with no database required. + +``prometheus-client`` ships in the dev group, so the absent case cannot be +observed by importing it here; the first test runs it in a subprocess with the +module blocked, which is also what the OSS edition smoke gate exercises for real +(it installs no extras). +""" + +import subprocess +import sys +import textwrap + +import pytest + +from gateway.core.config import GatewayConfig +from gateway.main import _validate_metrics_support + +_WITHOUT_PROMETHEUS = textwrap.dedent( + """ + import sys + + # None in sys.modules makes the import statement raise ImportError, which is + # the branch gateway.metrics falls back on. + sys.modules["prometheus_client"] = None + + import gateway.metrics as m + + assert m.PROMETHEUS_AVAILABLE is False, "expected the fallback branch" + + # Each metric is declared beside the code that increments it, so the absent + # case has to hold across those modules rather than in gateway.metrics alone. + from gateway.api import deps + from gateway.api.routes import _pipeline, _platform + from gateway.core import database + from gateway import rate_limit + from gateway.services import budget_service, log_writer + + # Every recorder stays callable, so a caller on a hot path needs no guard. + _pipeline.record_tokens("prov", "model", 100, 50) + _pipeline.record_cost("prov", "model", 0.25) + _pipeline.record_inline_cost_settlement("attached") + _platform.record_abandoned_attempt("prov", "model", "timeout", 0) + deps.record_auth_failure("invalid_key") + rate_limit.RATE_LIMIT_HITS.inc() + budget_service.BUDGET_EXCEEDED.inc() + log_writer.QUEUE_DEPTH.set(3) + log_writer.BATCH_SIZE.labels(writer="usage").observe(12) + log_writer.ROWS.labels(writer="usage", result="ok").inc() + + # labels() returns the same stand-in rather than a child, so chaining works. + assert _pipeline.TOKENS.labels(provider="a", model="b", type="input") is _pipeline.TOKENS + + # The custom pool collector subclasses Collector and is registered at import + # time; both have to survive the fallback, and its samples still build. + collector = database._PoolCollector() + m.REGISTRY.register(collector) + + assert m.generate_latest(m.REGISTRY) == b"" + + print("OK") + """ +) + + +def test_metrics_module_imports_without_prometheus_client() -> None: + result = subprocess.run( + [sys.executable, "-c", _WITHOUT_PROMETHEUS], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + assert "OK" in result.stdout + + +def test_prometheus_is_available_in_the_dev_environment() -> None: + """Guards the inverse of the test above: the dev group must install the extra. + + Without it the family-pinning tests in ``test_gateway_metrics.py`` would pass + against no-op metrics that expose nothing. + """ + from gateway.metrics import PROMETHEUS_AVAILABLE + + assert PROMETHEUS_AVAILABLE is True + + +def test_enable_metrics_without_prometheus_refuses_to_start(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("gateway.metrics.PROMETHEUS_AVAILABLE", False) + config = GatewayConfig(enable_metrics=True) + + with pytest.raises(ValueError, match=r"pip install gateway\[metrics\]"): + _validate_metrics_support(config) + + +def test_metrics_disabled_without_prometheus_starts(monkeypatch: pytest.MonkeyPatch) -> None: + """The default (metrics off, extra absent) is the silent no-op path, not an error.""" + monkeypatch.setattr("gateway.metrics.PROMETHEUS_AVAILABLE", False) + config = GatewayConfig(enable_metrics=False) + + _validate_metrics_support(config) + + +def test_enable_metrics_with_prometheus_starts() -> None: + _validate_metrics_support(GatewayConfig(enable_metrics=True)) diff --git a/uv.lock b/uv.lock index a7bfbd98a9..cacfab8504 100644 --- a/uv.lock +++ b/uv.lock @@ -1048,7 +1048,6 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-proto" }, { name = "pillow" }, - { name = "prometheus-client" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "pypdfium2" }, @@ -1063,6 +1062,9 @@ dependencies = [ ] [package.optional-dependencies] +metrics = [ + { name = "prometheus-client" }, +] ocr = [ { name = "numpy" }, { name = "rapidocr-onnxruntime" }, @@ -1078,6 +1080,7 @@ dev = [ { name = "moto", extra = ["s3"] }, { name = "mypy" }, { name = "packaging" }, + { name = "prometheus-client" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -1112,7 +1115,7 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.39.1" }, { name = "opentelemetry-proto", specifier = ">=1.39.1" }, { name = "pillow", specifier = ">=12.3.0" }, - { name = "prometheus-client", specifier = ">=0.20.0" }, + { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" }, { name = "psycopg2-binary", specifier = ">=2.9.9" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pypdfium2", specifier = ">=4.30.0" }, @@ -1126,7 +1129,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "webauthn", specifier = ">=2.5.0" }, ] -provides-extras = ["ocr", "s3"] +provides-extras = ["metrics", "ocr", "s3"] [package.metadata.requires-dev] dev = [ @@ -1135,6 +1138,7 @@ dev = [ { name = "moto", extras = ["s3"], specifier = ">=5.0.0" }, { name = "mypy" }, { name = "packaging", specifier = ">=24.0" }, + { name = "prometheus-client", specifier = ">=0.20.0" }, { name = "pytest", specifier = ">=9.0.3,<10" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.2.1,<8" },