diff --git a/Dockerfile b/Dockerfile index e96c729..9a3f902 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,13 +15,13 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev + uv sync --frozen --no-install-project --no-dev --extra metrics # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching ADD . /app RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev + uv sync --frozen --no-dev --extra metrics # Runtime stage FROM python:3.13-slim diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 57d8a73..ae82c3c 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -160,9 +160,12 @@ The application is configurable via environment variables. "^/docs/oauth2-redirect": ["GET"], "^/healthz": ["GET"], "^/_mgmt/ping": ["GET"], - "^/_mgmt/health": ["GET"] + "^/_mgmt/health": ["GET"], + "^/_mgmt/metrics": ["GET"] } ``` + > [!NOTE] + > `/_mgmt/metrics` is only served when the `metrics` extra is installed (`pip install stac-auth-proxy[metrics]`); otherwise it's automatically dropped from `PUBLIC_ENDPOINTS` at startup. ### `ENABLE_AUTHENTICATION_EXTENSION` diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index cdafeef..252c48d 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -53,6 +53,8 @@ docker run -p 8000:8000 \ ```bash pip install stac-auth-proxy ``` + > [!TIP] + > To expose a Prometheus `/_mgmt/metrics` endpoint, install the `metrics` extra instead: `pip install stac-auth-proxy[metrics]`. 2. Set environment variables: ```bash export UPSTREAM_URL=https://your-stac-api.com/stac diff --git a/pyproject.toml b/pyproject.toml index 98878ad..d06a16b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ docs = [ lambda = [ "mangum>=0.19.0", ] +metrics = [ + "prometheus-fastapi-instrumentator>=8.0.2", +] [tool.coverage.run] branch = true @@ -101,6 +104,7 @@ dev = [ "pytest>=8.3.3", "ruff>=0.0.238", "starlette-cramjam>=0.4.0", + "prometheus-fastapi-instrumentator>=8.0.2", "types-simplejson", "types-attrs", ] diff --git a/src/stac_auth_proxy/app.py b/src/stac_auth_proxy/app.py index 535a0db..1c56d0c 100644 --- a/src/stac_auth_proxy/app.py +++ b/src/stac_auth_proxy/app.py @@ -6,6 +6,7 @@ """ import logging +import re from typing import Any, Optional import httpx @@ -16,6 +17,7 @@ from .config import Settings from .handlers import HealthzHandler, ReverseProxyHandler, SwaggerUI from .lifespan import build_lifespan +from .metrics import METRICS_AVAILABLE, instrument_app from .middleware import ( AddProcessTimeHeaderMiddleware, AuthenticationExtensionMiddleware, @@ -85,6 +87,14 @@ def configure_app( prefix=settings.healthz_prefix, ) + if METRICS_AVAILABLE and r"^/_mgmt/metrics" in settings.public_endpoints: + excluded_handlers = [r"/_mgmt/"] + if settings.healthz_prefix: + excluded_handlers.append(re.escape(settings.healthz_prefix)) + instrument_app(app, excluded_handlers=excluded_handlers) + else: + settings.public_endpoints.pop(r"^/_mgmt/metrics", None) + # # Middleware (order is important, last added = first to run) # diff --git a/src/stac_auth_proxy/config.py b/src/stac_auth_proxy/config.py index 5ac4ffa..9c11ad9 100644 --- a/src/stac_auth_proxy/config.py +++ b/src/stac_auth_proxy/config.py @@ -111,6 +111,7 @@ class Settings(BaseSettings): r"^/healthz": ["GET"], r"^/_mgmt/ping": ["GET"], r"^/_mgmt/health": ["GET"], + r"^/_mgmt/metrics": ["GET"], } private_endpoints: EndpointMethodsWithScope = { # https://github.com/stac-api-extensions/collection-transaction/blob/v1.0.0-beta.1/README.md#methods diff --git a/src/stac_auth_proxy/metrics.py b/src/stac_auth_proxy/metrics.py new file mode 100644 index 0000000..ee32d0b --- /dev/null +++ b/src/stac_auth_proxy/metrics.py @@ -0,0 +1,94 @@ +"""Optional Prometheus metrics for STAC operations.""" + +import re +from typing import Any, Optional, Sequence + +METRICS_AVAILABLE = False + +OPERATIONS = [ # ordered; first match wins + (r"^/$", {"GET": "landing"}), + (r"^/conformance$", {"GET": "conformance"}), + (r"^/search$", {"GET": "search", "POST": "search"}), + (r"^/collections$", {"GET": "list_collections", "POST": "create_collection"}), + ( + r"^/collections/[^/]+$", + { + "GET": "get_collection", + "PUT": "edit_collection", + "PATCH": "edit_collection", + "DELETE": "delete_collection", + }, + ), + (r"^/collections/[^/]+/items$", {"GET": "list_items", "POST": "create_item"}), + ( + r"^/collections/[^/]+/items/[^/]+$", + { + "GET": "get_item", + "PUT": "edit_item", + "PATCH": "edit_item", + "DELETE": "delete_item", + }, + ), + (r"^/collections/[^/]+/bulk_items$", {"POST": "bulk"}), +] +_COMPILED = [(re.compile(p), m) for p, m in OPERATIONS] + + +def classify_operation(method: str, path: str) -> str: + """Map a request to a low-cardinality STAC operation name.""" + for pattern, methods in _COMPILED: + if pattern.match(path): + return methods.get(method.upper(), "unknown") + return "unknown" + + +def instrument_app( + app: Any, + excluded_handlers: Optional[Sequence[str]] = None, +) -> None: + """Instrument a FastAPI app and expose Prometheus metrics when available.""" + if not METRICS_AVAILABLE: + return + _instrument_app(app, list(excluded_handlers or [])) + + +try: + from prometheus_client import Counter, Histogram + from prometheus_fastapi_instrumentator import Instrumentator + from prometheus_fastapi_instrumentator.metrics import Info + + REQUESTS = Counter( + "http_requests_total", + "Total HTTP requests by STAC operation.", + labelnames=("operation", "method", "status"), + ) + LATENCY = Histogram( + "http_request_duration_seconds", + "HTTP request latency by STAC operation.", + labelnames=("operation", "method"), + buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, float("inf")), + ) + + def record_stac_metrics(info: Info) -> None: + """Record request count and latency using STAC operation labels.""" + operation = classify_operation(info.request.method, info.request.url.path) + REQUESTS.labels(operation, info.method, info.modified_status).inc() + LATENCY.labels(operation, info.method).observe(info.modified_duration) + + def _instrument_app(app: Any, excluded_handlers: list[str]) -> None: + ( + Instrumentator( + should_group_status_codes=True, + excluded_handlers=excluded_handlers, + ) + .add(record_stac_metrics) + .instrument(app) + .expose(app, endpoint="/_mgmt/metrics", include_in_schema=False) + ) + + METRICS_AVAILABLE = True + +except ImportError: + + def _instrument_app(app: Any, excluded_handlers: list[str]) -> None: + return diff --git a/tests/test_configure_app.py b/tests/test_configure_app.py index 2ba7e6b..6a9d819 100644 --- a/tests/test_configure_app.py +++ b/tests/test_configure_app.py @@ -1,8 +1,12 @@ """Tests for configuring an external FastAPI application.""" +import pytest from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient +import stac_auth_proxy.app as app_module from stac_auth_proxy import Settings, configure_app +from stac_auth_proxy.metrics import classify_operation def get_flattened_routes(app: FastAPI | APIRouter, prefix=""): @@ -65,3 +69,76 @@ def test_configure_app_excludes_proxy_route(): routes = get_flattened_routes(app) assert settings.healthz_prefix in routes assert "/{path:path}" not in routes + + +def test_metrics_endpoint_returns_prometheus_output(): + """Metrics returns Prometheus exposition format when enabled in PUBLIC_ENDPOINTS.""" + app = FastAPI() + settings = Settings( + upstream_url="https://example.com", + oidc_discovery_url="https://example.com/.well-known/openid-configuration", + wait_for_upstream=False, + check_conformance=False, + default_public=True, + ) + + configure_app(app, settings) + app.add_api_route("/collections", lambda: {"collections": []}, methods=["GET"]) + client = TestClient(app) + assert client.get("/collections").status_code == 200 + response = client.get("/_mgmt/metrics") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert "# HELP" in response.text + assert ( + 'http_requests_total{method="GET",operation="list_collections",status="2xx"}' + in response.text + ) + assert ( + 'http_request_duration_seconds_count{method="GET",operation="list_collections"}' + in response.text + ) + + +@pytest.mark.parametrize( + ("method", "path", "expected"), + [ + ("GET", "/", "landing"), + ("GET", "/conformance", "conformance"), + ("GET", "/search", "search"), + ("POST", "/search", "search"), + ("GET", "/collections", "list_collections"), + ("POST", "/collections", "create_collection"), + ("GET", "/collections/sentinel-2", "get_collection"), + ("PUT", "/collections/sentinel-2", "edit_collection"), + ("DELETE", "/collections/sentinel-2", "delete_collection"), + ("GET", "/collections/sentinel-2/items", "list_items"), + ("POST", "/collections/sentinel-2/items", "create_item"), + ("GET", "/collections/sentinel-2/items/abc", "get_item"), + ("DELETE", "/collections/sentinel-2/items/abc", "delete_item"), + ("POST", "/collections/sentinel-2/bulk_items", "bulk"), + ("GET", "/unknown", "unknown"), + ("POST", "/conformance", "unknown"), + ], +) +def test_classify_operation(method, path, expected): + """STAC paths map to low-cardinality operation names.""" + assert classify_operation(method, path) == expected + + +def test_metrics_endpoint_skipped_without_instrumentator(monkeypatch): + """Metrics route and public endpoint are omitted when the extra is absent.""" + monkeypatch.setattr(app_module, "METRICS_AVAILABLE", False) + app = FastAPI() + settings = Settings( + upstream_url="https://example.com", + oidc_discovery_url="https://example.com/.well-known/openid-configuration", + wait_for_upstream=False, + check_conformance=False, + ) + + configure_app(app, settings) + + assert "/_mgmt/metrics" not in get_flattened_routes(app) + assert r"^/_mgmt/metrics" not in settings.public_endpoints diff --git a/uv.lock b/uv.lock index a05fd43..dee2a6b 100644 --- a/uv.lock +++ b/uv.lock @@ -1577,6 +1577,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "8.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/e9/2065686d1dfa62296fdc158b6e8fd25b0cb3dca09b0632cabeb5ae81fe4d/prometheus_fastapi_instrumentator-8.0.2.tar.gz", hash = "sha256:3c252e748151768a7aefd66824a04a870144f71de48a67aed211749a9ca2a548", size = 21342, upload-time = "2026-06-23T09:39:31.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/c7/fa2b3f469a2e6001b829e0d6bc8680755349aa1329a87bf48731e9d5d30a/prometheus_fastapi_instrumentator-8.0.2-py3-none-any.whl", hash = "sha256:746002ec1e2c58b93f61444e1d104de959a9463a6a3f1c8909ac3757e16c3866", size = 20549, upload-time = "2026-06-23T09:39:32.616Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2015,12 +2037,16 @@ docs = [ lambda = [ { name = "mangum" }, ] +metrics = [ + { name = "prometheus-fastapi-instrumentator" }, +] [package.dev-dependencies] dev = [ { name = "jwcrypto" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "prometheus-fastapi-instrumentator" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -2048,19 +2074,21 @@ requires-dist = [ { name = "mkdocs-api-autonav", marker = "extra == 'docs'", specifier = ">=0.3.0" }, { name = "mkdocs-material", extras = ["imaging"], marker = "extra == 'docs'", specifier = ">=9.6.16" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.30.0" }, + { name = "prometheus-fastapi-instrumentator", marker = "extra == 'metrics'", specifier = ">=8.0.2" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "starlette", specifier = ">=1.0.1" }, { name = "starlette-cramjam", specifier = ">=0.4.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] -provides-extras = ["docs", "lambda"] +provides-extras = ["docs", "lambda", "metrics"] [package.metadata.requires-dev] dev = [ { name = "jwcrypto", specifier = ">=1.5.6" }, { name = "mypy", specifier = ">=1.3.0" }, { name = "pre-commit", specifier = ">=3.5.0" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=8.0.2" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.25.1" }, { name = "pytest-cov", specifier = ">=5.0.0" },