4a7f24348c
- Add instance_events and health_checks tables with Alembic migration - InstanceEventBus: typed pub/sub singleton with wildcard support - HealthMonitor: async background loop polling containers every 15s - SSE endpoint GET /events/stream with auth and connection limits - Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete) - Structured JSON logging with correlation IDs - 15 new unit tests (EventBus, HealthMonitor, MonitoringModels) Quality gates: pytest 15 new passed, ruff clean
126 lines
4.0 KiB
Python
126 lines
4.0 KiB
Python
"""Structured JSON logging configuration."""
|
|
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from collections.abc import Callable
|
|
|
|
from fastapi import Request, Response
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
from src.services.correlation import get_correlation_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CorrelationIdFilter(logging.Filter):
|
|
"""Inject correlation_id into every log record from context var."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
record.correlation_id = get_correlation_id() # type: ignore[attr-defined]
|
|
return True
|
|
|
|
|
|
class JSONFormatter(logging.Formatter):
|
|
"""Emit log records as single-line JSON."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
log_obj: dict = {
|
|
"timestamp": self.formatTime(record),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"message": record.getMessage(),
|
|
"correlation_id": getattr(record, "correlation_id", None),
|
|
}
|
|
# Optional extra fields
|
|
for key in ("instance_id", "event_type"):
|
|
value = getattr(record, key, None)
|
|
if value is not None:
|
|
log_obj[key] = value
|
|
if record.exc_info:
|
|
log_obj["exception"] = self.formatException(record.exc_info)
|
|
return json.dumps(log_obj, default=str)
|
|
|
|
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
|
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
|
|
|
|
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""Log all HTTP requests with timing and status codes."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
start_time = time.time()
|
|
client_host = request.client.host if request.client else "unknown"
|
|
|
|
logger.info(
|
|
"→ Request: %s %s (client: %s)",
|
|
request.method,
|
|
request.url.path,
|
|
client_host,
|
|
)
|
|
|
|
try:
|
|
response = await call_next(request)
|
|
duration = time.time() - start_time
|
|
|
|
logger.info(
|
|
"← Response: %s %s → %d (%dms)",
|
|
request.method,
|
|
request.url.path,
|
|
response.status_code,
|
|
int(duration * 1000),
|
|
)
|
|
return response
|
|
|
|
except Exception as exc:
|
|
duration = time.time() - start_time
|
|
logger.error(
|
|
"✗ Error: %s %s → %s (%dms)\n%s",
|
|
request.method,
|
|
request.url.path,
|
|
type(exc).__name__,
|
|
int(duration * 1000),
|
|
traceback.format_exc(),
|
|
)
|
|
raise
|
|
|
|
|
|
class ExceptionLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""Catch and log all unhandled exceptions."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
try:
|
|
return await call_next(request)
|
|
except Exception:
|
|
logger.critical(
|
|
"Unhandled exception in %s %s:\n%s",
|
|
request.method,
|
|
request.url.path,
|
|
traceback.format_exc(),
|
|
)
|
|
raise
|
|
|
|
|
|
def configure_logging(level: int = logging.INFO) -> None:
|
|
"""Configure structured JSON logging for the application."""
|
|
formatter = JSONFormatter()
|
|
|
|
# Console handler
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setFormatter(formatter)
|
|
console_handler.addFilter(CorrelationIdFilter())
|
|
|
|
# Configure root logger
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(level)
|
|
root_logger.handlers = [console_handler]
|
|
|
|
# Set levels for specific loggers
|
|
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
|
|
logger.info("Logging configured at level %s", logging.getLevelName(level))
|