Skip to content

Commit 4403b7e

Browse files
committed
add stacks like Prometheus and Sentry to monitoring app
1 parent 44df8de commit 4403b7e

11 files changed

Lines changed: 304 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies = [
2121
"orjson>=3.11.4",
2222
"passlib[bcrypt]>=1.7.4",
2323
"pre-commit>=4.5.0",
24+
"prometheus-fastapi-instrumentator>=7.1.0",
2425
"psycopg-binary>=3.2.13",
2526
"pydantic-settings>=2.12.0",
2627
"pydantic[email]>=2.12.5",
@@ -31,6 +32,7 @@ dependencies = [
3132
"python-jose[cryptography]>=3.5.0",
3233
"python-multipart>=0.0.20",
3334
"ruff>=0.14.7",
35+
"sentry-sdk>=2.47.0",
3436
"types-python-jose>=3.5.0.20250531",
3537
"uvicorn[standard]>=0.38.0",
3638
]

src/app.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
11
from fastapi import FastAPI
22
from fastapi.middleware.cors import CORSMiddleware
33
from fastapi.responses import ORJSONResponse
4+
from fastapi.staticfiles import StaticFiles
45

56
from api.routers import api_router
6-
from core.config import settings
7+
from core.config import MEDIA_ROOT, MEDIA_URL, settings
78
from core.exceptions import register_error_handler
89
from core.lifespan import lifespan
910
from core.logger import configure_logger
11+
from core.monitoring import router as monitoring_router
12+
from core.prometheus import MetricsMiddleware
13+
from core.sentry import init_sentry
14+
from middlewares.request_id_middleware import RequestIDMiddleware
1015

1116

1217
def create_app() -> FastAPI:
1318
"""Create and configure the FastAPI application."""
19+
# Configure logger
20+
configure_logger()
21+
22+
# Initialize Sentry (if configured)
23+
init_sentry()
24+
1425
app = FastAPI(
1526
title=settings.APP_NAME,
1627
version=settings.APP_VERSION,
1728
description=settings.APP_DESCRIPTION,
1829
lifespan=lifespan,
1930
docs_url="/docs" if settings.ENV != "prod" else None,
2031
redoc_url="/redoc" if settings.ENV != "prod" else None,
32+
swagger_ui_parameters={"defaultModelsExpandDepth": -1},
2133
openapi_url="/openapi.json" if settings.ENV != "prod" else None,
2234
default_response_class=ORJSONResponse,
2335
)
36+
# Request ID middleware (must be first)
37+
app.add_middleware(RequestIDMiddleware)
2438

25-
# Configure logger
26-
configure_logger()
27-
28-
# Register exception handlers
29-
register_error_handler(app)
30-
31-
# Include API router
32-
app.include_router(api_router, prefix="/api/v1")
39+
# Only add metrics middleware in environments where Prometheus scraping is expected
40+
if settings.PROMETHEUS_ENABLED:
41+
app.add_middleware(MetricsMiddleware)
3342

3443
# Set up CORS middleware
3544
app.add_middleware(
@@ -40,4 +49,20 @@ def create_app() -> FastAPI:
4049
allow_headers=["*"],
4150
)
4251

52+
# Include routers
53+
app.include_router(api_router, prefix="/api/v1")
54+
app.include_router(router=monitoring_router) # /monitoring/*
55+
56+
# Register error handlers
57+
register_error_handler(app)
58+
59+
# Include API router
60+
61+
# Static files (media)
62+
try:
63+
app.mount(MEDIA_URL, StaticFiles(directory=MEDIA_ROOT), name="media")
64+
except Exception:
65+
# don't crash if media doesn't exists at startup
66+
pass
67+
4368
return app

src/core/config.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ class BaseAppSettings(BaseSettings):
4444

4545
@property
4646
def DATABASE_URL(self) -> str:
47-
if self.ENV == "dev":
48-
return f"sqlite+aiosqlite:///{BASE_DIR / self.DB_NAME}.db"
47+
if self.ENV == "dev" or self.DB_ENGINE == "sqlite":
48+
db_path = BASE_DIR / f"{self.DB_NAME}.db"
49+
return f"sqlite+aiosqlite:///{db_path}"
50+
# Postgres
4951
return (
5052
"postgresql+asyncpg://"
5153
f"{self.DB_USER}:{self.DB_PASSWORD}@"
@@ -69,7 +71,7 @@ def DATABASE_URL(self) -> str:
6971
def CELERY_BROKER_URL(self):
7072
if self.BROKER == "redis":
7173
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
72-
74+
# rabbitmq
7375
return (
7476
"amqp://"
7577
f"{self.RABBITMQ_USER}:{self.RABBITMQ_PASSWORD}"
@@ -108,6 +110,14 @@ def OAUTH_PROVIDERS(self):
108110
}
109111
}
110112

113+
# Sentry DSN
114+
SENTRY_DSN: str | None = None
115+
116+
# Prometheus
117+
PROMETHEUS_ENABLED: bool = True
118+
PROMETHEUS_PATH: str = "/metrics"
119+
PROMETHEUS_METRICS_KEY: str = "secret"
120+
111121
# Pydantic config
112122
model_config = SettingsConfigDict(
113123
validate_assignment=True,

src/core/database.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212

1313
@cache
1414
def get_async_db_engine() -> AsyncEngine:
15+
url = settings.DATABASE_URL
16+
if not url:
17+
raise RuntimeError("DATABASE_URL is not configured")
1518
return create_async_engine(
16-
settings.DATABASE_URL,
17-
echo=settings.ENV == "dev",
19+
url,
20+
echo=(settings.ENV == "dev"),
1821
future=True,
1922
)
2023

src/core/monitoring.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# src/core/monitoring.py
2+
import time
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Header, Response
6+
from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest
7+
from pydantic import BaseModel
8+
9+
from core.config import settings
10+
11+
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
12+
13+
14+
class HealthcheckResponse(BaseModel):
15+
timestamp: int
16+
17+
18+
class StatusResponse(BaseModel):
19+
app: str = "ok"
20+
21+
22+
class VersionResponse(BaseModel):
23+
version: str
24+
25+
26+
@router.get("/healthcheck", summary="Проверить доступность сервиса")
27+
def ping() -> HealthcheckResponse:
28+
return HealthcheckResponse(timestamp=int(time.time()))
29+
30+
31+
@router.get("/status", summary="Проверить статус используемых сервисов")
32+
async def status() -> StatusResponse:
33+
# optionally add DB/Redis checks here (non-blocking or with timeout)
34+
return StatusResponse()
35+
36+
37+
@router.get("/version", summary="Проверить версию приложения")
38+
def get_version() -> VersionResponse:
39+
# safe fallback if package not installed
40+
try:
41+
ver = settings.APP_VERSION
42+
except Exception:
43+
ver = "unknown"
44+
return VersionResponse(version=ver)
45+
46+
47+
@router.get("/metrics", summary="Получить метрики Prometheus", include_in_schema=False)
48+
async def metrics(key: Annotated[str | None, Header()] = None) -> Response:
49+
"""
50+
Protected exposition of Prometheus metrics.
51+
By default requires header 'X-Prometheus-Key' == settings.prometheus_metrics_key.
52+
In debug/local you can allow empty key by setting prometheus_metrics_key to empty string.
53+
"""
54+
# header name from client is 'X-Prometheus-Key'
55+
expected = settings.PROMETHEUS_METRICS_KEY or ""
56+
if expected and key != expected:
57+
return Response(status_code=403)
58+
59+
data = generate_latest(REGISTRY)
60+
return Response(content=data, media_type=CONTENT_TYPE_LATEST)

src/core/prometheus.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import time
2+
from collections.abc import Awaitable, Callable
3+
from dataclasses import dataclass
4+
from functools import cache
5+
6+
from fastapi import Request, Response
7+
from prometheus_client import Counter, Gauge, Histogram
8+
from starlette.middleware.base import BaseHTTPMiddleware
9+
10+
from core.config import settings
11+
12+
13+
@dataclass
14+
class Metrics:
15+
request_count: Counter
16+
request_latency: Histogram
17+
inprogress_requests: Gauge
18+
19+
20+
@cache
21+
def get_metrics() -> Metrics:
22+
prefix = settings.APP_NAME.replace("-", "_")
23+
# Use a didicated registry via default REGISTRY, okay for most setups.
24+
return Metrics(
25+
request_count=Counter(
26+
f"{prefix}_http_requests_total",
27+
"Total number of HTTP requests",
28+
["method", "endpoint", "status_code"],
29+
),
30+
request_latency=Histogram(
31+
f"{prefix}_http_request_duration_seconds",
32+
"HTTP request latency (seconds)",
33+
["method", "endpoint", "status_code"],
34+
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
35+
),
36+
inprogress_requests=Gauge(
37+
f"{prefix}_inprogress_requests",
38+
"Number of in-progress requests",
39+
["endpoint"],
40+
),
41+
)
42+
43+
44+
def _get_route_path(request: Request) -> str:
45+
"""
46+
Return the route template path if available (e.g. "/api/users/{user_id}"),
47+
otherwise fallback to raw path (but avoid querystring).
48+
"""
49+
route = request.scope.get("route")
50+
try:
51+
# Starlette/fastapi Route has .path attribute
52+
if hasattr(route, "path"):
53+
return route.path # type: ignore
54+
except Exception:
55+
pass
56+
# Fallback: use the raw path but strip any high-cardinality parts carefully
57+
return request.url.path
58+
59+
60+
class MetricsMiddleware(BaseHTTPMiddleware):
61+
async def dispatch(
62+
self,
63+
request: Request,
64+
call_next: Callable[[Request], Awaitable[Response]],
65+
) -> Response:
66+
67+
# Do not collect for non-api or static endpoints (configurable)
68+
path = request.url.path
69+
if not path.startswith("/api"):
70+
return await call_next(request)
71+
72+
metrics = get_metrics()
73+
endpoint = _get_route_path(request)
74+
75+
# inprogress gauge
76+
metrics.inprogress_requests.labels(endpoint=endpoint).inc()
77+
start_time = time.time()
78+
79+
try:
80+
response = await call_next(request)
81+
finally:
82+
# always decrement even on exceptions
83+
metrics.inprogress_requests.labels(endpoint=endpoint).dec()
84+
85+
process_time = time.time() - start_time
86+
status = getattr(response, "status_code", 500)
87+
88+
labels = {
89+
"method": request.method,
90+
"endpoint": endpoint,
91+
"status_code": str(status),
92+
}
93+
metrics.request_count.labels(**labels).inc()
94+
metrics.request_latency.labels(**labels).observe(process_time)
95+
96+
return response

src/core/sentry.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# src/core/sentry.py
2+
import logging
3+
4+
from sentry_sdk import init as sentry_init
5+
from sentry_sdk.integrations.fastapi import FastApiIntegration
6+
from sentry_sdk.integrations.redis import RedisIntegration
7+
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
8+
9+
from core.config import settings
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def init_sentry() -> None:
15+
dsn = settings.SENTRY_DSN
16+
if not dsn:
17+
logger.info("Sentry DSN not provided — skipping Sentry init")
18+
return
19+
20+
sentry_init(
21+
dsn=dsn,
22+
environment=settings.ENV,
23+
integrations=[
24+
FastApiIntegration(),
25+
SqlalchemyIntegration(),
26+
RedisIntegration(),
27+
],
28+
traces_sample_rate=0.1 if settings.ENV == "prod" else 0.5,
29+
profiles_sample_rate=0.0 if settings.ENV == "dev" else 0.01,
30+
send_default_pii=False, # avoid leaking user PII by default
31+
debug=False,
32+
)
33+
logger.info("Sentry initialized")

src/main.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import uvicorn
22

3+
from app import create_app
34
from core.config import settings
45

56
if __name__ == "__main__":
7+
app = create_app()
8+
69
uvicorn.run(
7-
"app:create_app",
10+
app=app,
811
host=settings.SERVER_HOST,
912
port=settings.SERVER_PORT,
10-
reload=settings.RELOAD,
13+
reload=settings.RELOAD and settings.ENV == "dev",
1114
)

src/middlewares/__init__.py

Whitespace-only changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import uuid
2+
3+
from starlette.middleware.base import BaseHTTPMiddleware
4+
from starlette.responses import Response
5+
6+
REQUEST_ID_HEADER = "X-Request-ID"
7+
8+
9+
class RequestIDMiddleware(BaseHTTPMiddleware):
10+
async def dispatch(self, request, call_next):
11+
request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
12+
# attach to scope for latter usage
13+
request.state.request_id = request_id
14+
15+
response: Response = await call_next(request)
16+
response.headers[REQUEST_ID_HEADER] = request_id
17+
return response

0 commit comments

Comments
 (0)