From b21840ee2db5cf6dcddf79f7695013fb73b1d520 Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Sun, 3 Dec 2023 16:45:35 +0100 Subject: [PATCH] Add Prometheus metrics (#372) --- README.md | 10 +- backend/Pipfile | 4 +- backend/bracket/app.py | 21 +++- backend/bracket/models/metrics.py | 103 ++++++++++++++++++ backend/bracket/routes/metrics.py | 11 ++ .../integration_tests/api/metrics_test.py | 7 ++ backend/tests/integration_tests/api/shared.py | 9 ++ 7 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 backend/bracket/models/metrics.py create mode 100644 backend/bracket/routes/metrics.py create mode 100644 backend/tests/integration_tests/api/metrics_test.py diff --git a/README.md b/README.md index bba9c20a..e6b723fd 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ You can do the same but replace the user and database name with: The database URL can be specified per environment in the `.env` files (see [config](#config)). +Read the full documentation about setting up Bracket [in the docs](https://evroon.github.io/bracket/docs/getting-started/installation). + ## Config Copy [ci.env](backend/ci.env) to `prod.env` and fill in the values: - `PG_DSN`: The URL of the PostgreSQL database @@ -79,8 +81,10 @@ Copy [ci.env](backend/ci.env) to `prod.env` and fill in the values: - `ADMIN_EMAIL` and `ADMIN_PASSWORD`: The credentials of the admin user, which is created when initializing the database +Read more about configuration [in the docs](https://evroon.github.io/bracket/docs/getting-started/configuration). -## Running the frontend and backend + +## Running the frontend and backend for development The following starts the frontend and backend for local development: ### Frontend ```bash @@ -95,3 +99,7 @@ pipenv install -d pipenv shell ./run.sh ``` + +# License +Bracket is licensed under AGPL-v3.0 +See [LICENSE](LICENSE) diff --git a/backend/Pipfile b/backend/Pipfile index 4eb79308..a865d7d7 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -34,8 +34,8 @@ black = ">=22.12.0" isort = ">=5.11.4" mypy = ">=1.3.1" pylint = ">=2.15.10" -pytest = ">=7.2.0" -pytest-asyncio = ">=0.20.3" +pytest = "7.4.2" +pytest-asyncio = "0.20.3" pytest-cov = ">=4.0.0" pytest-xdist = ">=3.2.1" ruff = ">=0.0.292" diff --git a/backend/bracket/app.py b/backend/bracket/app.py index bec69ca9..8cf05e05 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -1,20 +1,23 @@ +import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from starlette.exceptions import HTTPException +from starlette.middleware.base import RequestResponseEndpoint from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response from starlette.staticfiles import StaticFiles from bracket.config import Environment, config, environment, init_sentry from bracket.database import database +from bracket.models.metrics import RequestDefinition, get_request_metrics from bracket.routes import ( auth, clubs, courts, matches, + metrics, players, rounds, stage_items, @@ -58,6 +61,17 @@ app.add_middleware( ) +@app.middleware("http") +async def add_process_time_header(request: Request, call_next: RequestResponseEndpoint) -> Response: + start_time = time.time() + request_metrics = get_request_metrics() + request_metrics.request_count[RequestDefinition.from_request(request)] += 1 + response = await call_next(request) + process_time = time.time() - start_time + request_metrics.response_time[RequestDefinition.from_request(request)] = process_time + return response + + @app.get('/ping', summary="Healthcheck ping") async def ping() -> str: return 'ping' @@ -75,6 +89,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes app.mount("/static", StaticFiles(directory="static"), name="static") +app.include_router(metrics.router, tags=['metrics']) app.include_router(auth.router, tags=['auth']) app.include_router(clubs.router, tags=['clubs']) app.include_router(courts.router, tags=['courts']) diff --git a/backend/bracket/models/metrics.py b/backend/bracket/models/metrics.py new file mode 100644 index 00000000..31168986 --- /dev/null +++ b/backend/bracket/models/metrics.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections import defaultdict +from enum import auto +from functools import cache +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from bracket.utils.http import HTTPMethod +from bracket.utils.types import EnumAutoStr + +if TYPE_CHECKING: + from starlette.requests import Request + + +class PrometheusMetricType(EnumAutoStr): + counter = auto() + gauge = auto() + summary = auto() + histogram = auto() + + +class RequestDefinition(BaseModel): + url: str + method: HTTPMethod + + @staticmethod + def from_request(request: Request) -> RequestDefinition: + return RequestDefinition( + url=str(request.url.path), + method=HTTPMethod(request.method), + ) + + def to_value_lookup(self, value: float) -> tuple[dict[str, str], float]: + return {'url': self.url, 'method': self.method.value}, value + + def __hash__(self) -> int: + return str.__hash__(f'{self.method}-{self.url}') + + +class MetricDefinition(BaseModel): + name: str + description: str + type_: PrometheusMetricType + + def format_for_prometheus(self, value: float) -> str: + return ( + f'# HELP {self.name} {self.description}\n' + f'# TYPE {self.name} {self.type_.value}\n' + f'{self.name} {value:.06f}\n' + ) + + def format_for_prometheus_per_label(self, values: list[tuple[dict[str, str], float]]) -> str: + result = f'# HELP {self.name} {self.description}\n# TYPE {self.name} {self.type_.value}\n' + for labels, value in values: + key_value = ','.join( + [f'{label}="{label_value}"' for label, label_value in labels.items()] + ) + result += f'{self.name}{{{key_value}}} {value}\n' + + return result + + +METRIC_DEFINITIONS = [ + MetricDefinition( + name='bracket_response_time', + description='Latest response time per endpoint', + type_=PrometheusMetricType.gauge, + ), + MetricDefinition( + name='bracket_request_count', + description='Requests count per endpoint', + type_=PrometheusMetricType.counter, + ), + MetricDefinition( + name='bracket_version', + description='Requests count per endpoint', + type_=PrometheusMetricType.counter, + ), +] + + +class RequestMetrics(BaseModel): + response_time: dict[RequestDefinition, float] = defaultdict(float) + request_count: dict[RequestDefinition, int] = defaultdict(int) + + def to_prometheus(self) -> str: + metrics = [ + METRIC_DEFINITIONS[0].format_for_prometheus_per_label( + [m.to_value_lookup(v) for m, v in self.response_time.items()] + ), + METRIC_DEFINITIONS[1].format_for_prometheus_per_label( + [m.to_value_lookup(v) for m, v in self.request_count.items()] + ), + METRIC_DEFINITIONS[2].format_for_prometheus(1.0), + ] + return '\n'.join(metrics) + + +@cache +def get_request_metrics() -> RequestMetrics: + return RequestMetrics() diff --git a/backend/bracket/routes/metrics.py b/backend/bracket/routes/metrics.py new file mode 100644 index 00000000..55dbbeff --- /dev/null +++ b/backend/bracket/routes/metrics.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from fastapi.responses import PlainTextResponse + +from bracket.models.metrics import get_request_metrics + +router = APIRouter() + + +@router.get("/metrics", response_class=PlainTextResponse) +async def get_metrics() -> PlainTextResponse: + return PlainTextResponse(get_request_metrics().to_prometheus()) diff --git a/backend/tests/integration_tests/api/metrics_test.py b/backend/tests/integration_tests/api/metrics_test.py new file mode 100644 index 00000000..beeea065 --- /dev/null +++ b/backend/tests/integration_tests/api/metrics_test.py @@ -0,0 +1,7 @@ +from bracket.utils.http import HTTPMethod +from tests.integration_tests.api.shared import send_request_raw + + +async def test_metrics_endpoint(startup_and_shutdown_uvicorn_server: None) -> None: + text_response = await send_request_raw(HTTPMethod.GET, 'metrics') + assert 'HELP bracket_response_time' in text_response diff --git a/backend/tests/integration_tests/api/shared.py b/backend/tests/integration_tests/api/shared.py index 93f6e449..64621db8 100644 --- a/backend/tests/integration_tests/api/shared.py +++ b/backend/tests/integration_tests/api/shared.py @@ -86,6 +86,15 @@ async def send_request( return response +async def send_request_raw(method: HTTPMethod, endpoint: str) -> str: + async with aiohttp.ClientSession() as session: + async with session.request( + method=method.value, + url=get_root_uvicorn_url() + endpoint, + ) as resp: + return await resp.text() + + async def send_auth_request( method: HTTPMethod, endpoint: str,