M22: implement operational observability
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.observability import OUTBOX_EVENTS
|
||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
|
||||
router = APIRouter(tags=["observability"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _refresh_database_metrics() -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(
|
||||
OutboxEvent.delivery_status,
|
||||
(OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"),
|
||||
func.count(),
|
||||
).group_by(OutboxEvent.delivery_status, "demo")
|
||||
).all()
|
||||
OUTBOX_EVENTS.clear()
|
||||
for status, is_demo, count in rows:
|
||||
OUTBOX_EVENTS.labels(str(status), "synthetic" if is_demo else "operational").set(count)
|
||||
|
||||
|
||||
@router.get("/metrics", include_in_schema=False)
|
||||
def metrics(authorization: str | None = Header(default=None)) -> Response:
|
||||
if settings.metrics_bearer_token:
|
||||
supplied = authorization.removeprefix("Bearer ") if authorization else ""
|
||||
if not hmac.compare_digest(supplied, settings.metrics_bearer_token):
|
||||
raise HTTPException(status_code=401, detail="Metrics token required")
|
||||
_refresh_database_metrics()
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
@@ -61,6 +61,8 @@ class Settings(BaseSettings):
|
||||
oidc_allowed_email_domains: str = ""
|
||||
oidc_auto_provision: bool = True
|
||||
oidc_default_role: str = "rental_employee"
|
||||
log_level: str = "INFO"
|
||||
metrics_bearer_token: str = ""
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Request
|
||||
from prometheus_client import Counter, Gauge, Histogram
|
||||
|
||||
correlation_id_context: ContextVar[str] = ContextVar("correlation_id", default="")
|
||||
|
||||
HTTP_REQUESTS = Counter(
|
||||
"mobilityops_http_requests_total",
|
||||
"Completed MobilityOps HTTP requests.",
|
||||
("method", "route", "status"),
|
||||
)
|
||||
HTTP_DURATION = Histogram(
|
||||
"mobilityops_http_request_duration_seconds",
|
||||
"MobilityOps HTTP request duration.",
|
||||
("method", "route"),
|
||||
buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
|
||||
)
|
||||
HTTP_IN_PROGRESS = Gauge(
|
||||
"mobilityops_http_requests_in_progress",
|
||||
"MobilityOps HTTP requests currently executing.",
|
||||
)
|
||||
OUTBOX_EVENTS = Gauge(
|
||||
"mobilityops_outbox_events",
|
||||
"Persisted outbox events by state and scenario type.",
|
||||
("status", "scenario"),
|
||||
)
|
||||
DATABASE_READY = Gauge(
|
||||
"mobilityops_database_ready",
|
||||
"Whether the canonical PostgreSQL database answered the most recent readiness probe.",
|
||||
)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, object] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"level": record.levelname.lower(),
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
correlation_id = correlation_id_context.get()
|
||||
if correlation_id:
|
||||
payload["correlation_id"] = correlation_id
|
||||
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
|
||||
value = getattr(record, key, None)
|
||||
if value is not None:
|
||||
payload[key] = value
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(level.upper())
|
||||
|
||||
|
||||
def correlation_id_for(request: Request) -> str:
|
||||
candidate = request.headers.get("X-Correlation-Id", "").strip()
|
||||
try:
|
||||
return str(uuid.UUID(candidate)) if candidate else str(uuid.uuid4())
|
||||
except ValueError:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def route_label(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
path = getattr(route, "path", None)
|
||||
return str(path or request.url.path)
|
||||
|
||||
|
||||
def request_started() -> float:
|
||||
HTTP_IN_PROGRESS.inc()
|
||||
return time.perf_counter()
|
||||
|
||||
|
||||
def request_finished(request: Request, status_code: int, started_at: float) -> float:
|
||||
duration = time.perf_counter() - started_at
|
||||
route = route_label(request)
|
||||
HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc()
|
||||
HTTP_DURATION.labels(request.method, route).observe(duration)
|
||||
HTTP_IN_PROGRESS.dec()
|
||||
return duration
|
||||
+56
-8
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -19,6 +20,7 @@ from app.api.routers import (
|
||||
integrations,
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
observability,
|
||||
search,
|
||||
users,
|
||||
vehicles,
|
||||
@@ -28,9 +30,19 @@ from app.api.routers.auth import bootstrap_initial_admin
|
||||
from app.core.config import PRODUCT_NAME, get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError, error_body
|
||||
from app.core.observability import (
|
||||
DATABASE_READY,
|
||||
configure_logging,
|
||||
correlation_id_context,
|
||||
correlation_id_for,
|
||||
request_finished,
|
||||
request_started,
|
||||
)
|
||||
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
request_logger = logging.getLogger("mobilityops.request")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -53,6 +65,34 @@ app.add_middleware(
|
||||
https_only=settings.session_cookie_secure,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_observability(request: Request, call_next):
|
||||
correlation_id = correlation_id_for(request)
|
||||
request.state.correlation_id = correlation_id
|
||||
token = correlation_id_context.set(correlation_id)
|
||||
started_at = request_started()
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
response.headers["X-Correlation-Id"] = correlation_id
|
||||
return response
|
||||
finally:
|
||||
duration = request_finished(request, status_code, started_at)
|
||||
request_logger.info(
|
||||
"request_completed",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_ip": request.client.host if request.client else None,
|
||||
},
|
||||
)
|
||||
correlation_id_context.reset(token)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
|
||||
@@ -63,21 +103,26 @@ app.add_middleware(
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
|
||||
def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details),
|
||||
content=error_body(
|
||||
exc.code,
|
||||
exc.message,
|
||||
request.state.correlation_id,
|
||||
exc.details,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
|
||||
def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_body(
|
||||
code=str(exc.status_code),
|
||||
message=str(exc.detail),
|
||||
correlation_id=str(uuid.uuid4()),
|
||||
correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
|
||||
details={},
|
||||
),
|
||||
)
|
||||
@@ -101,10 +146,12 @@ def readiness() -> JSONResponse:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
||||
)
|
||||
DATABASE_READY.set(0)
|
||||
DATABASE_READY.set(1)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
||||
)
|
||||
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
|
||||
|
||||
|
||||
@@ -135,3 +182,4 @@ app.include_router(mcp_integrations.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(observability.router)
|
||||
|
||||
@@ -17,7 +17,8 @@ dependencies = [
|
||||
"httpx>=0.27,<1",
|
||||
"httpx2>=2.10,<3",
|
||||
"authlib>=1.6,<2",
|
||||
"itsdangerous>=2.2,<3"
|
||||
"itsdangerous>=2.2,<3",
|
||||
"prometheus-client>=0.24,<1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -76,6 +76,8 @@ pathspec==1.1.1
|
||||
# via mypy
|
||||
pluggy==1.6.0
|
||||
# via pytest
|
||||
prometheus-client==0.26.0
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
psycopg[binary]==3.3.4
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
psycopg-binary==3.3.4
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.core.observability import JsonFormatter
|
||||
|
||||
|
||||
def test_request_correlation_id_is_echoed_and_used_in_errors(client):
|
||||
correlation_id = str(uuid.uuid4())
|
||||
response = client.get(
|
||||
"/api/v1/dashboard",
|
||||
headers={"X-Correlation-Id": correlation_id},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.headers["X-Correlation-Id"] == correlation_id
|
||||
assert response.json()["error"]["correlation_id"] == correlation_id
|
||||
|
||||
|
||||
def test_invalid_request_correlation_id_is_replaced(client):
|
||||
response = client.get("/health/live", headers={"X-Correlation-Id": "not-a-uuid"})
|
||||
assert response.status_code == 200
|
||||
assert uuid.UUID(response.headers["X-Correlation-Id"])
|
||||
|
||||
|
||||
def test_metrics_expose_http_database_and_outbox_state(client):
|
||||
client.get("/health/ready")
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "mobilityops_http_requests_total" in response.text
|
||||
assert "mobilityops_database_ready 1.0" in response.text
|
||||
assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text
|
||||
|
||||
|
||||
def test_metrics_token_is_enforced_when_configured(client, monkeypatch):
|
||||
import app.api.routers.observability as router
|
||||
|
||||
monkeypatch.setattr(router.settings, "metrics_bearer_token", "metrics-secret")
|
||||
assert client.get("/metrics").status_code == 401
|
||||
assert (
|
||||
client.get("/metrics", headers={"Authorization": "Bearer metrics-secret"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_json_formatter_emits_machine_readable_fields():
|
||||
record = logging.LogRecord("mobilityops.test", logging.INFO, __file__, 1, "ready", (), None)
|
||||
record.status_code = 200
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
assert payload["message"] == "ready"
|
||||
assert payload["status_code"] == 200
|
||||
assert payload["timestamp"].endswith("+00:00")
|
||||
Reference in New Issue
Block a user