- Backend: Implement Prometheus metrics collection
- Add prometheus-client dependency
- Create metrics utility module with HTTP request tracking counters, histograms, gauges
- Implement MetricsMiddleware to track request latency, count, and active requests
- Add /metrics endpoint to expose metrics in Prometheus text format
- Normalize paths to prevent cardinality explosion (e.g., /api/{id} for UUIDs)
- Exclude /metrics and /health from detailed tracking
- Frontend: Add web vitals and API metrics collection
- Install web-vitals library (v4.0.0) for Core Web Vitals tracking
- Create metrics utility module for FCP, LCP, CLS, INP, TTFB collection
- Implement useTrackedFetch hook for automatic API call metrics (method, endpoint, status, duration)
- Initialize web vitals tracking in App component on mount
- Provide exportMetrics() for sending metrics to backend
- Testing:
- Add comprehensive backend metrics tests (9 tests, 100% coverage)
- Add comprehensive frontend metrics tests (10 tests)
- All tests passing
- Documentation:
- Expand Docs/Observability.md with complete APM section
- Include metrics reference, integration examples (Prometheus, Datadog, NewRelic)
- Add troubleshooting guide and best practices for cardinality management
- Update Tasks.md to mark APM task as complete
Metrics exposed:
- bangui_http_requests_total: HTTP request count by method, endpoint, status
- bangui_http_request_duration_seconds: Request latency histogram
- bangui_http_active_requests: Active request gauge
- Web Vitals: CLS, FCP, INP, LCP, TTFB with ratings
- API metrics: endpoint, method, status, duration, timestamp
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
"""Tests for Prometheus metrics collection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from starlette.requests import Request
|
|
from starlette.responses import PlainTextResponse
|
|
|
|
from app.middleware.metrics import MetricsMiddleware, _normalize_path
|
|
from app.utils.metrics import get_metrics, http_request_count, http_request_latency, http_active_requests
|
|
|
|
|
|
class TestMetricsUtils:
|
|
"""Test metrics utility functions."""
|
|
|
|
def test_normalize_path_with_uuid(self) -> None:
|
|
"""Test path normalization with UUID."""
|
|
path = "/api/resource/550e8400-e29b-41d4-a716-446655440000"
|
|
normalized = _normalize_path(path)
|
|
assert normalized == "/api/{id}"
|
|
|
|
def test_normalize_path_with_numeric_id(self) -> None:
|
|
"""Test path normalization with numeric ID."""
|
|
path = "/api/resource/123"
|
|
normalized = _normalize_path(path)
|
|
assert normalized == "/api/{id}"
|
|
|
|
def test_normalize_path_without_id(self) -> None:
|
|
"""Test path without ID remains unchanged."""
|
|
path = "/api/resource"
|
|
normalized = _normalize_path(path)
|
|
assert normalized == "/api/resource"
|
|
|
|
def test_get_metrics_returns_bytes(self) -> None:
|
|
"""Test that get_metrics returns bytes."""
|
|
metrics = get_metrics()
|
|
assert isinstance(metrics, bytes)
|
|
assert b"bangui_http_requests_total" in metrics
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMetricsMiddleware:
|
|
"""Test metrics collection middleware."""
|
|
|
|
async def test_middleware_tracks_request_metrics(self) -> None:
|
|
"""Test middleware tracks request metrics."""
|
|
middleware = MetricsMiddleware(app=MagicMock())
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.method = "GET"
|
|
request.url.path = "/api/test"
|
|
|
|
response = PlainTextResponse("OK")
|
|
response.status_code = 200
|
|
|
|
call_next = AsyncMock(return_value=response)
|
|
|
|
result = await middleware.dispatch(request, call_next)
|
|
|
|
assert result == response
|
|
assert call_next.called
|
|
|
|
async def test_middleware_skips_metrics_endpoint(self) -> None:
|
|
"""Test middleware skips /metrics endpoint."""
|
|
middleware = MetricsMiddleware(app=MagicMock())
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.method = "GET"
|
|
request.url.path = "/metrics"
|
|
|
|
response = PlainTextResponse("metrics")
|
|
response.status_code = 200
|
|
|
|
call_next = AsyncMock(return_value=response)
|
|
|
|
result = await middleware.dispatch(request, call_next)
|
|
|
|
assert result == response
|
|
|
|
async def test_middleware_tracks_error_responses(self) -> None:
|
|
"""Test middleware tracks error response status codes."""
|
|
middleware = MetricsMiddleware(app=MagicMock())
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.method = "GET"
|
|
request.url.path = "/api/test"
|
|
|
|
response = PlainTextResponse("Not Found")
|
|
response.status_code = 404
|
|
|
|
call_next = AsyncMock(return_value=response)
|
|
|
|
result = await middleware.dispatch(request, call_next)
|
|
|
|
assert result == response
|
|
assert result.status_code == 404
|
|
|
|
async def test_middleware_handles_exceptions(self) -> None:
|
|
"""Test middleware handles exceptions during request processing."""
|
|
middleware = MetricsMiddleware(app=MagicMock())
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.method = "GET"
|
|
request.url.path = "/api/test"
|
|
|
|
call_next = AsyncMock(side_effect=RuntimeError("Test error"))
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await middleware.dispatch(request, call_next)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMetricsEndpoint:
|
|
"""Test the /metrics endpoint."""
|
|
|
|
async def test_metrics_endpoint_returns_prometheus_format(self) -> None:
|
|
"""Test metrics endpoint returns Prometheus format."""
|
|
from app.routers.metrics import get_application_metrics
|
|
|
|
response = await get_application_metrics()
|
|
|
|
assert response.status_code == 200
|
|
assert response.media_type.startswith("text/plain")
|
|
assert b"bangui_http_requests_total" in response.body
|