mirror of
https://github.com/evroon/bracket.git
synced 2026-06-11 10:15:19 -04:00
Add Prometheus metrics (#372)
This commit is contained in:
10
README.md
10
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'])
|
||||
|
||||
103
backend/bracket/models/metrics.py
Normal file
103
backend/bracket/models/metrics.py
Normal file
@@ -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()
|
||||
11
backend/bracket/routes/metrics.py
Normal file
11
backend/bracket/routes/metrics.py
Normal file
@@ -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())
|
||||
7
backend/tests/integration_tests/api/metrics_test.py
Normal file
7
backend/tests/integration_tests/api/metrics_test.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user