Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
648640e1c8 feat(api): expose /metrics endpoint as Prometheus text format
The /metrics endpoint was wired in observability.py (REQUEST_COUNT,
REQUEST_LATENCY, SCRAPE_COUNT, SCRAPE_LATENCY, etc.) but never exposed
as an HTTP route. This commit:

- Adds @app.get("/metrics") returning Prometheus text exposition format
  when prometheus_client is installed, JSON fallback otherwise
- Adds /metrics to _AUTH_PUBLIC_PATHS so it works without PRY_API_KEY
- Adds /metrics to X402Middleware.free_paths so scrapers can poll
- Adds tests/test_metrics.py covering 200, content-type, and counter increments

Closes the Cinnabox feat/metrics-endpoint work (uncommitted). Brings
observability story from 80% to 100%.

Audit items 9 and 13.
Tests: 598 passed, 1 skipped.
2026-07-06 10:32:03 +02:00
2 changed files with 84 additions and 1 deletions

28
api.py
View file

@ -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",
}
)

57
tests/test_metrics.py Normal file
View file

@ -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