M22: implement operational observability
This commit is contained in:
@@ -25,6 +25,13 @@ OIDC_ALLOWED_EMAIL_DOMAINS=
|
|||||||
OIDC_AUTO_PROVISION=true
|
OIDC_AUTO_PROVISION=true
|
||||||
OIDC_DEFAULT_ROLE=rental_employee
|
OIDC_DEFAULT_ROLE=rental_employee
|
||||||
|
|
||||||
|
# Observability: JSON logs are always enabled. Set a token only if /metrics is exposed
|
||||||
|
# outside the private Compose network; Prometheus can send it as a bearer token.
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
METRICS_BEARER_TOKEN=
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=change-me-before-start
|
||||||
|
|
||||||
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||||
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||||
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||||
|
|||||||
@@ -2520,3 +2520,20 @@ evidence yet."
|
|||||||
- Evidence: focused authentication **13 passed with zero warnings**; frontend production
|
- Evidence: focused authentication **13 passed with zero warnings**; frontend production
|
||||||
build passed; ruff clean. Exact next action: implement structured request logging,
|
build passed; ruff clean. Exact next action: implement structured request logging,
|
||||||
correlation, metrics, dashboards and alerts.
|
correlation, metrics, dashboards and alerts.
|
||||||
|
|
||||||
|
## M22 — operational observability (2026-08-10)
|
||||||
|
|
||||||
|
- Added UUID request correlation propagated through response headers, structured API
|
||||||
|
errors and machine-readable JSON request logs. Logs include UTC time, route, method,
|
||||||
|
status, latency and client IP; Docker rotates bounded 10 MB files.
|
||||||
|
- Added Prometheus metrics for request rate/status, duration buckets, in-flight requests,
|
||||||
|
database readiness and persisted outbox state separated into real and synthetic
|
||||||
|
scenarios. `/metrics` supports constant-time Bearer protection if exposed beyond the
|
||||||
|
private Compose network.
|
||||||
|
- Added an optional pinned Prometheus/Grafana Compose profile, provisioned datasource,
|
||||||
|
six-panel operational dashboard and six validated alert rules. Real failures and
|
||||||
|
backlogs alert; the deliberate demo retry does not.
|
||||||
|
- Evidence: focused observability **5 passed without warnings**; ruff/mypy clean;
|
||||||
|
Prometheus `promtool` accepted the scrape config and all six rules; merged Compose and
|
||||||
|
Grafana dashboard JSON validate. Exact next action: automate verified backups,
|
||||||
|
retention and restore-readiness checks.
|
||||||
|
|||||||
@@ -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_allowed_email_domains: str = ""
|
||||||
oidc_auto_provision: bool = True
|
oidc_auto_provision: bool = True
|
||||||
oidc_default_role: str = "rental_employee"
|
oidc_default_role: str = "rental_employee"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
metrics_bearer_token: str = ""
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@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
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ from app.api.routers import (
|
|||||||
integrations,
|
integrations,
|
||||||
knowledge,
|
knowledge,
|
||||||
mcp_integrations,
|
mcp_integrations,
|
||||||
|
observability,
|
||||||
search,
|
search,
|
||||||
users,
|
users,
|
||||||
vehicles,
|
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.config import PRODUCT_NAME, get_settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.errors import AppError, error_body
|
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
|
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
request_logger = logging.getLogger("mobilityops.request")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -53,6 +65,34 @@ app.add_middleware(
|
|||||||
https_only=settings.session_cookie_secure,
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
|
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
|
||||||
@@ -63,21 +103,26 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
@app.exception_handler(AppError)
|
@app.exception_handler(AppError)
|
||||||
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
|
def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
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)
|
@app.exception_handler(HTTPException)
|
||||||
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
|
def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content=error_body(
|
content=error_body(
|
||||||
code=str(exc.status_code),
|
code=str(exc.status_code),
|
||||||
message=str(exc.detail),
|
message=str(exc.detail),
|
||||||
correlation_id=str(uuid.uuid4()),
|
correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
|
||||||
details={},
|
details={},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -101,10 +146,12 @@ def readiness() -> JSONResponse:
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
db.execute(text("SELECT 1"))
|
db.execute(text("SELECT 1"))
|
||||||
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
|
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
|
||||||
return JSONResponse(
|
DATABASE_READY.set(0)
|
||||||
status_code=503,
|
DATABASE_READY.set(1)
|
||||||
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
return JSONResponse(
|
||||||
)
|
status_code=503,
|
||||||
|
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
||||||
|
)
|
||||||
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
|
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(search.router)
|
||||||
app.include_router(integration_status.router)
|
app.include_router(integration_status.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
app.include_router(observability.router)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ dependencies = [
|
|||||||
"httpx>=0.27,<1",
|
"httpx>=0.27,<1",
|
||||||
"httpx2>=2.10,<3",
|
"httpx2>=2.10,<3",
|
||||||
"authlib>=1.6,<2",
|
"authlib>=1.6,<2",
|
||||||
"itsdangerous>=2.2,<3"
|
"itsdangerous>=2.2,<3",
|
||||||
|
"prometheus-client>=0.24,<1"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ pathspec==1.1.1
|
|||||||
# via mypy
|
# via mypy
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
# via pytest
|
# via pytest
|
||||||
|
prometheus-client==0.26.0
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
psycopg[binary]==3.3.4
|
psycopg[binary]==3.3.4
|
||||||
# via mobilityops-api (pyproject.toml)
|
# via mobilityops-api (pyproject.toml)
|
||||||
psycopg-binary==3.3.4
|
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")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.7.1
|
||||||
|
profiles: ["observability"]
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=30d
|
||||||
|
- --web.enable-lifecycle
|
||||||
|
volumes:
|
||||||
|
- ./deploy/observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./deploy/observability/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||||
|
- mobilityops-prometheus:/prometheus
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:19090:9090"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [mobilityops]
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:12.2.0
|
||||||
|
profiles: ["observability"]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-before-start}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./deploy/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- mobilityops-grafana:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:13000:3000"
|
||||||
|
depends_on: [prometheus]
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [mobilityops]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mobilityops-prometheus:
|
||||||
|
mobilityops-grafana:
|
||||||
@@ -52,6 +52,13 @@ services:
|
|||||||
OIDC_ALLOWED_EMAIL_DOMAINS: ${OIDC_ALLOWED_EMAIL_DOMAINS:-}
|
OIDC_ALLOWED_EMAIL_DOMAINS: ${OIDC_ALLOWED_EMAIL_DOMAINS:-}
|
||||||
OIDC_AUTO_PROVISION: ${OIDC_AUTO_PROVISION:-true}
|
OIDC_AUTO_PROVISION: ${OIDC_AUTO_PROVISION:-true}
|
||||||
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee}
|
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
ports:
|
ports:
|
||||||
- "8128:8000"
|
- "8128:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
groups:
|
||||||
|
- name: mobilityops
|
||||||
|
rules:
|
||||||
|
- alert: MobilityOpsApiDown
|
||||||
|
expr: up{job="mobilityops-api"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: MobilityOps API metrics target is down
|
||||||
|
- alert: MobilityOpsDatabaseNotReady
|
||||||
|
expr: mobilityops_database_ready == 0
|
||||||
|
for: 2m
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: MobilityOps database readiness is failing
|
||||||
|
- alert: MobilityOpsHighErrorRate
|
||||||
|
expr: sum(rate(mobilityops_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(mobilityops_http_requests_total[5m])), 0.001) > 0.05
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: More than 5% of MobilityOps requests return 5xx
|
||||||
|
- alert: MobilityOpsHighLatency
|
||||||
|
expr: histogram_quantile(0.95, sum by (le) (rate(mobilityops_http_request_duration_seconds_bucket[5m]))) > 2
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: MobilityOps p95 API latency exceeds two seconds
|
||||||
|
- alert: MobilityOpsOutboxBacklog
|
||||||
|
expr: sum(mobilityops_outbox_events{status=~"pending|delivering",scenario="operational"}) > 10
|
||||||
|
for: 10m
|
||||||
|
labels: {severity: warning}
|
||||||
|
annotations:
|
||||||
|
summary: MobilityOps operational outbox backlog exceeds ten events
|
||||||
|
- alert: MobilityOpsOutboxFailure
|
||||||
|
expr: sum(mobilityops_outbox_events{status="failed",scenario="operational"}) > 0
|
||||||
|
for: 2m
|
||||||
|
labels: {severity: critical}
|
||||||
|
annotations:
|
||||||
|
summary: MobilityOps has a real failed outbox event
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"annotations": {"list": []},
|
||||||
|
"editable": false,
|
||||||
|
"panels": [
|
||||||
|
{"type":"stat","title":"API target","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"up{job=\"mobilityops-api\"}"}],"gridPos":{"h":6,"w":6,"x":0,"y":0}},
|
||||||
|
{"type":"stat","title":"Database ready","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"mobilityops_database_ready"}],"gridPos":{"h":6,"w":6,"x":6,"y":0}},
|
||||||
|
{"type":"stat","title":"Operational outbox backlog","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"sum(mobilityops_outbox_events{status=~\"pending|delivering\",scenario=\"operational\"})"}],"gridPos":{"h":6,"w":6,"x":12,"y":0}},
|
||||||
|
{"type":"stat","title":"Operational outbox failures","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"sum(mobilityops_outbox_events{status=\"failed\",scenario=\"operational\"})"}],"gridPos":{"h":6,"w":6,"x":18,"y":0}},
|
||||||
|
{"type":"timeseries","title":"Requests per second","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"sum by (status) (rate(mobilityops_http_requests_total[5m]))","legendFormat":"{{status}}"}],"gridPos":{"h":9,"w":12,"x":0,"y":6}},
|
||||||
|
{"type":"timeseries","title":"API latency percentiles","datasource":{"type":"prometheus","uid":"prometheus"},"targets":[{"expr":"histogram_quantile(0.50, sum by (le) (rate(mobilityops_http_request_duration_seconds_bucket[5m])))","legendFormat":"p50"},{"expr":"histogram_quantile(0.95, sum by (le) (rate(mobilityops_http_request_duration_seconds_bucket[5m])))","legendFormat":"p95"}],"gridPos":{"h":9,"w":12,"x":12,"y":6}}
|
||||||
|
],
|
||||||
|
"schemaVersion": 41,
|
||||||
|
"tags": ["mobilityops"],
|
||||||
|
"templating": {"list": []},
|
||||||
|
"time": {"from":"now-6h","to":"now"},
|
||||||
|
"title": "MobilityOps operational overview",
|
||||||
|
"uid": "mobilityops-overview",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: MobilityOps
|
||||||
|
folder: MobilityOps
|
||||||
|
type: file
|
||||||
|
disableDeletion: true
|
||||||
|
editable: false
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/alerts.yml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: mobilityops-api
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["api:8000"]
|
||||||
@@ -30,6 +30,30 @@ make test # isolated test project/database, all
|
|||||||
docker compose run --rm api ruff check . # clean
|
docker compose run --rm api ruff check . # clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
The API emits one JSON log record per request with UTC timestamp, method, route, status,
|
||||||
|
duration, client IP and a UUID correlation ID. A valid incoming `X-Correlation-Id` is
|
||||||
|
propagated into the response and API error body; invalid values are replaced. Docker log
|
||||||
|
rotation is capped at five 10 MB files.
|
||||||
|
|
||||||
|
`GET /metrics` exposes Prometheus request counters, latency histograms, in-flight work,
|
||||||
|
database readiness and operational-versus-synthetic outbox state. The endpoint is only
|
||||||
|
reachable inside the production Compose network. If it is exposed elsewhere, configure
|
||||||
|
`METRICS_BEARER_TOKEN` and send it as a Bearer token.
|
||||||
|
|
||||||
|
Start the optional pinned monitoring stack with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f compose.unraid.yaml -f compose.observability.yaml \
|
||||||
|
--profile observability up -d prometheus grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus listens on host loopback port 19090 and Grafana on loopback port 13000. Set a
|
||||||
|
unique `GRAFANA_ADMIN_PASSWORD` before first start. Provisioning includes the MobilityOps
|
||||||
|
overview dashboard and alerts for API/database outage, 5xx rate, p95 latency, real outbox
|
||||||
|
backlog and real outbox failures. Synthetic retry scenarios never trigger outbox alerts.
|
||||||
|
|
||||||
Never execute `pytest` inside the deployed API container: the acceptance fixtures reset
|
Never execute `pytest` inside the deployed API container: the acceptance fixtures reset
|
||||||
their database deliberately. `make test` uses `compose.test.yaml`, a fixed
|
their database deliberately. `make test` uses `compose.test.yaml`, a fixed
|
||||||
`mobilityops-test` Compose project and its own disposable PostgreSQL volume, and removes
|
`mobilityops-test` Compose project and its own disposable PostgreSQL volume, and removes
|
||||||
|
|||||||
Reference in New Issue
Block a user