Add Prometheus metrics (#372)

This commit is contained in:
Erik Vroon
2023-12-03 16:45:35 +01:00
committed by GitHub
parent c8177603c3
commit b21840ee2d
7 changed files with 159 additions and 6 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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'])

View 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()

View 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())

View 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

View File

@@ -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,