feat(cloud-api): add correlated lifecycle logging

This commit is contained in:
2026-07-12 18:37:51 +08:00
parent a5de7399f8
commit 8131c0124b
7 changed files with 256 additions and 1 deletions
+36
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI, status
from fastapi import Request
from fastapi.responses import JSONResponse
from cloud.auth import create_auth_provider
@@ -20,6 +21,13 @@ from cloud.control_config import (
from cloud.database import CloudDatabase
from cloud.internal_api.api import create_internal_router
from cloud.plugins import PluginRegistry
from cloud.observability import (
CORRELATION_HEADER,
bind_correlation_id,
new_correlation_id,
normalize_correlation_id,
reset_correlation_id,
)
from cloud.pool import DevicePool
from cloud.scheduler import TaskScheduler
from cloud.schema import require_current_schema
@@ -136,6 +144,28 @@ def create_app(
app = FastAPI(title="Device Cloud API", lifespan=lifespan)
@app.middleware("http")
async def correlation_logging(request: Request, call_next):
correlation_id = normalize_correlation_id(
request.headers.get(CORRELATION_HEADER)
)
correlation_token = bind_correlation_id(correlation_id)
try:
response = await call_next(request)
logger.info(
"cloud request completed",
extra={
"correlation_id": correlation_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
},
)
response.headers[CORRELATION_HEADER] = correlation_id
return response
finally:
reset_correlation_id(correlation_token)
@app.get("/health/live")
def health_live() -> dict[str, str]:
return {"status": "live"}
@@ -198,6 +228,7 @@ async def _run_scheduler_loop(
interval_seconds: float,
) -> None:
while not stop.is_set():
correlation_token = bind_correlation_id(new_correlation_id())
try:
services.scheduler.assign()
except Exception:
@@ -205,6 +236,8 @@ async def _run_scheduler_loop(
"cloud lifecycle iteration failed",
extra={"worker": "scheduler"},
)
finally:
reset_correlation_id(correlation_token)
if await _wait_for_stop(stop, interval_seconds):
return
@@ -217,6 +250,7 @@ async def _run_lease_reaper_loop(
max_attempts: int,
) -> None:
while not stop.is_set():
correlation_token = bind_correlation_id(new_correlation_id())
try:
services.repository.reap_expired_leases(
now=utc_now(),
@@ -227,6 +261,8 @@ async def _run_lease_reaper_loop(
"cloud lifecycle iteration failed",
extra={"worker": "lease_reaper"},
)
finally:
reset_correlation_id(correlation_token)
if await _wait_for_stop(stop, interval_seconds):
return
+37
View File
@@ -255,6 +255,43 @@ def test_readiness_reports_stopped_worker() -> None:
assert response.json()["checks"]["workers"] is False
def test_request_correlation_id_is_propagated_without_sensitive_headers(
monkeypatch,
tmp_path,
) -> None:
request_logs: list[dict[str, object]] = []
def record_info(_message: str, *, extra: dict[str, object]) -> None:
request_logs.append(extra)
monkeypatch.setattr(app_module.logger, "info", record_info)
app = create_app(
config=CloudControlConfig(
database_url=f"sqlite:///{(tmp_path / 'correlation.sqlite3').as_posix()}"
)
)
with TestClient(app) as client:
response = client.get(
"/health/live",
headers={
"X-Correlation-ID": "request-123",
"Authorization": "Bearer never-log-this",
},
)
assert response.headers["X-Correlation-ID"] == "request-123"
assert request_logs == [
{
"correlation_id": "request-123",
"method": "GET",
"path": "/health/live",
"status_code": 200,
}
]
assert "never-log-this" not in repr(request_logs)
def test_production_app_rejects_missing_credentials() -> None:
with pytest.raises(CloudConfigurationError, match="credential"):
create_app(