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.
This commit is contained in:
parent
090f284c37
commit
648640e1c8
2 changed files with 84 additions and 1 deletions
28
api.py
28
api.py
|
|
@ -198,6 +198,7 @@ app.add_middleware(
|
||||||
free_paths=[
|
free_paths=[
|
||||||
"/health",
|
"/health",
|
||||||
"/live",
|
"/live",
|
||||||
|
"/metrics",
|
||||||
"/ready",
|
"/ready",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
|
|
@ -249,7 +250,7 @@ def add_error_handlers(app: FastAPI) -> None:
|
||||||
add_error_handlers(app)
|
add_error_handlers(app)
|
||||||
|
|
||||||
# Public paths that don't need authentication
|
# 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
|
# Middleware: auth + rate limit + timing + request logging
|
||||||
|
|
@ -387,6 +388,31 @@ class PryHttpMiddleware:
|
||||||
|
|
||||||
app.add_middleware(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
57
tests/test_metrics.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue