Files
simbarag/tests/unit/test_metrics.py
T
ryan 0478ee7568 Add Prometheus metrics endpoint for chat response observability
Tracks end-to-end response time and request counts (by stream mode and
result) via prometheus_client. Exposes GET /metrics behind a bearer
token (METRICS_TOKEN env var) so Prometheus can scrape it without
exposing raw data publicly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-15 22:57:12 -04:00

110 lines
3.8 KiB
Python

"""Tests for the /metrics endpoint (token gate) and the metrics helpers."""
import os
import time
from unittest.mock import patch
import pytest
from quart import Quart
import app as app_module
from metrics import CHAT_REQUESTS_TOTAL, render_metrics, time_response
def _make_app() -> Quart:
"""Build a minimal Quart app exposing only the production /metrics handler.
Reuses the real handler from ``app.py`` but registers it on a fresh app
without the full app's ``@app.while_serving`` lifespan, so no Tortoise DB
connection is attempted and the route can be tested hermetically.
"""
minimal = Quart(__name__)
minimal.add_url_rule(
"/metrics",
view_func=app_module.app.view_functions["metrics_endpoint"],
methods=["GET"],
)
return minimal
def _counter_value(stream: str, result: str) -> float:
return CHAT_REQUESTS_TOTAL.labels(stream=stream, result=result)._value.get()
class TestMetricsEndpoint:
async def test_403_when_token_unset(self):
app = _make_app()
with patch.dict(os.environ, {}, clear=True):
async with app.test_client() as client:
resp = await client.get("/metrics")
assert resp.status_code == 403
async def test_403_when_token_empty(self):
app = _make_app()
with patch.dict(os.environ, {"METRICS_TOKEN": ""}):
async with app.test_client() as client:
resp = await client.get("/metrics")
assert resp.status_code == 403
async def test_403_wrong_token(self):
app = _make_app()
with patch.dict(os.environ, {"METRICS_TOKEN": "expected-secret"}):
async with app.test_client() as client:
resp = await client.get(
"/metrics",
headers={"Authorization": "Bearer wrong-secret"},
)
assert resp.status_code == 403
async def test_403_missing_auth_header(self):
app = _make_app()
with patch.dict(os.environ, {"METRICS_TOKEN": "expected-secret"}):
async with app.test_client() as client:
resp = await client.get("/metrics")
assert resp.status_code == 403
async def test_200_correct_token_and_metric_present(self):
app = _make_app()
with patch.dict(os.environ, {"METRICS_TOKEN": "expected-secret"}):
with time_response(stream=False):
time.sleep(0.001)
async with app.test_client() as client:
resp = await client.get(
"/metrics",
headers={"Authorization": "Bearer expected-secret"},
)
assert resp.status_code == 200
assert "text/plain" in resp.content_type
body = await resp.get_data(as_text=True)
assert "raggr_chat_response_seconds" in body
class TestTimeResponse:
def test_counts_success(self):
before = _counter_value("false", "success")
with time_response(stream=False):
pass
after = _counter_value("false", "success")
assert after == before + 1
def test_counts_error_on_exception(self):
before = _counter_value("false", "error")
with pytest.raises(RuntimeError):
with time_response(stream=False):
raise RuntimeError("boom")
after = _counter_value("false", "error")
assert after == before + 1
def test_observes_histogram_value(self):
with time_response(stream=False):
time.sleep(0.001)
out = render_metrics().decode()
assert "raggr_chat_response_seconds" in out
def test_stream_labels_distinct(self):
before = _counter_value("true", "success")
with time_response(stream=True):
pass
after = _counter_value("true", "success")
assert after == before + 1