diff --git a/api.py b/api.py index 4bcbb42..1b8c072 100644 --- a/api.py +++ b/api.py @@ -198,6 +198,7 @@ app.add_middleware( free_paths=[ "/health", "/live", + "/metrics", "/ready", "/docs", "/openapi.json", @@ -249,7 +250,7 @@ def add_error_handlers(app: FastAPI) -> None: add_error_handlers(app) # Public paths that don't need authentication -_AUTH_PUBLIC_PATHS: set[str] = {"/health", "/live", "/ready", "/docs", "/openapi.json"} +_AUTH_PUBLIC_PATHS: set[str] = {"/health", "/live", "/metrics", "/ready", "/docs", "/openapi.json"} # Middleware: auth + rate limit + timing + request logging @@ -387,6 +388,31 @@ class PryHttpMiddleware: app.add_middleware(PryHttpMiddleware) +# ── Metrics ── + + +@app.get("/metrics", tags=["System"], summary="Prometheus metrics endpoint") +async def metrics() -> Response: + """Export Prometheus metrics. + + When prometheus_client is installed, returns the native Prometheus + text exposition format. Otherwise returns a JSON fallback with the + current counter values. + """ + from observability import _has_prometheus, get_metrics_output + + if _has_prometheus: + body, content_type = get_metrics_output() + return Response(content=body, media_type=content_type) + return JSONResponse( + content={ + "prometheus_available": False, + "message": "Install prometheus-client for full metrics", + } + ) + + + diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..28266ab --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Rug Munch Media LLC +# +# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper +# Licensed under MIT. See LICENSE. +"""Tests for the /metrics endpoint and Prometheus instrumentation.""" + +import pytest +from fastapi.testclient import TestClient + +from api import app +from observability import SCRAPE_COUNT, track_scrape + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_metrics_returns_200(client: TestClient) -> None: + resp = client.get("/metrics") + assert resp.status_code == 200 + + +def test_metrics_content_type_prometheus(client: TestClient) -> None: + resp = client.get("/metrics") + assert resp.headers.get("content-type", "").startswith("text/plain") + + +def test_metrics_contains_expected_metric_names(client: TestClient) -> None: + """Verify HELP lines for pry_requests_total and pry_scrapes_total appear.""" + resp = client.get("/metrics") + assert "# HELP pry_requests_total" in resp.text + assert "# HELP pry_scrapes_total" in resp.text + + +def test_metrics_contains_scrapes_counter_after_track(client: TestClient) -> None: + with track_scrape(method="test_meth"): + pass + resp = client.get("/metrics") + assert "pry_scrapes_total" in resp.text + + +def test_scrape_counters_increment_after_track() -> None: + if SCRAPE_COUNT is None: + pytest.skip("prometheus_client not available") + + with track_scrape(method="test_metrics_inc"): + pass + + found = False + for sample in SCRAPE_COUNT.collect(): + for s in sample.samples: + if s.labels.get("method") == "test_metrics_inc": + assert s.value >= 1 + found = True + assert found