117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import AsyncIterator, Callable, Iterable
|
|
from contextlib import AbstractAsyncContextManager
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
|
from govoplan_core.core.registry import PlatformRegistry
|
|
from govoplan_core.db.query_metrics import collect_query_metrics
|
|
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
|
|
|
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
|
logger = logging.getLogger("govoplan.request")
|
|
|
|
|
|
def _slow_request_threshold_ms() -> float:
|
|
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
|
|
try:
|
|
return float(raw)
|
|
except ValueError:
|
|
return 500.0
|
|
|
|
|
|
def create_govoplan_app(
|
|
*,
|
|
title: str,
|
|
version: str,
|
|
registry: PlatformRegistry,
|
|
api_router: APIRouter | None = None,
|
|
lifespan: LifespanFactory | None = None,
|
|
cors_origins: Iterable[str] = (),
|
|
health_payload: dict[str, Any] | None = None,
|
|
) -> FastAPI:
|
|
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
|
app.state.govoplan_registry = registry
|
|
slow_request_threshold_ms = _slow_request_threshold_ms()
|
|
|
|
@app.middleware("http")
|
|
async def request_correlation_context(request: Request, call_next):
|
|
correlation_id = (
|
|
normalize_trace_id(request.headers.get("x-correlation-id"))
|
|
or normalize_trace_id(request.headers.get("x-request-id"))
|
|
or new_event_id()
|
|
)
|
|
with event_context(correlation_id=correlation_id):
|
|
response = await call_next(request)
|
|
response.headers["X-Correlation-ID"] = correlation_id
|
|
return response
|
|
|
|
@app.middleware("http")
|
|
async def slow_request_logging(request: Request, call_next):
|
|
started_at = time.perf_counter()
|
|
with collect_query_metrics() as db_metrics:
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception:
|
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
|
|
logger.warning(
|
|
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
|
|
request.method,
|
|
request.url.path,
|
|
elapsed_ms,
|
|
db_metrics.query_count,
|
|
db_metrics.total_ms,
|
|
db_metrics.slowest_ms,
|
|
db_metrics.error_count,
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
|
|
logger.warning(
|
|
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
|
|
request.method,
|
|
request.url.path,
|
|
response.status_code,
|
|
elapsed_ms,
|
|
db_metrics.query_count,
|
|
db_metrics.total_ms,
|
|
db_metrics.slowest_ms,
|
|
db_metrics.error_count,
|
|
)
|
|
return response
|
|
|
|
app.middleware("http")(conditional_json_get_middleware)
|
|
|
|
origins = [item.strip() for item in cors_origins if item.strip()]
|
|
if origins:
|
|
if "*" in origins:
|
|
raise ValueError("CORS wildcard origin '*' cannot be used with credentialed requests; configure explicit origins.")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
if api_router is not None:
|
|
app.include_router(api_router)
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
payload = {"status": "ok"}
|
|
if health_payload:
|
|
payload.update(health_payload)
|
|
return payload
|
|
|
|
return app
|