Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand Down
5 changes: 4 additions & 1 deletion docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/gateway/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
113 changes: 100 additions & 13 deletions src/gateway/metrics.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
106 changes: 106 additions & 0 deletions tests/unit/test_metrics_optional_dependency.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading