diff --git a/apps/coordinator-api/src/app/main.py b/apps/coordinator-api/src/app/main.py index b801f4fd..1d33a626 100755 --- a/apps/coordinator-api/src/app/main.py +++ b/apps/coordinator-api/src/app/main.py @@ -91,11 +91,13 @@ except ImportError: print("WARNING: ML ZK proofs router not available (missing dependencies)") from aitbc import get_logger -from .app_logging import configure_logging -from .middleware.request_id import RequestIDMiddleware -from .middleware.performance import PerformanceLoggingMiddleware -from .middleware.validation import RequestValidationMiddleware -from .middleware.error_handler import ErrorHandlerMiddleware +from aitbc.logging import configure_logging +from aitbc import ( + RequestIDMiddleware, + PerformanceLoggingMiddleware, + RequestValidationMiddleware, + ErrorHandlerMiddleware, +) from .exceptions import AITBCError, ErrorResponse # Configure structured logging diff --git a/packages/py/aitbc-core/pyproject.toml b/packages/py/aitbc-core/pyproject.toml index ad4ff89c..a84c44db 100644 --- a/packages/py/aitbc-core/pyproject.toml +++ b/packages/py/aitbc-core/pyproject.toml @@ -13,7 +13,9 @@ dependencies = [ "fastapi>=0.104.0", "uvicorn>=0.24.0", "redis>=5.0.0", - "pydantic>=2.5.0" + "pydantic>=2.5.0", + "structlog>=23.0.0", + "starlette>=0.27.0", ] [build-system] diff --git a/packages/py/aitbc-core/src/aitbc/__init__.py b/packages/py/aitbc-core/src/aitbc/__init__.py index b3e1dc8b..07703c91 100644 --- a/packages/py/aitbc-core/src/aitbc/__init__.py +++ b/packages/py/aitbc-core/src/aitbc/__init__.py @@ -3,5 +3,20 @@ AITBC Core Utilities """ from . import logging # noqa: F811 — aitbc.logging submodule, not stdlib +from .logging import configure_logging, get_logger +from .middleware import ( + RequestIDMiddleware, + PerformanceLoggingMiddleware, + RequestValidationMiddleware, + ErrorHandlerMiddleware, +) -__all__ = ["logging"] +__all__ = [ + "logging", + "configure_logging", + "get_logger", + "RequestIDMiddleware", + "PerformanceLoggingMiddleware", + "RequestValidationMiddleware", + "ErrorHandlerMiddleware", +] diff --git a/packages/py/aitbc-core/src/aitbc/logging.py b/packages/py/aitbc-core/src/aitbc/logging.py index bcb2236d..32a66c82 100644 --- a/packages/py/aitbc-core/src/aitbc/logging.py +++ b/packages/py/aitbc-core/src/aitbc/logging.py @@ -7,6 +7,7 @@ Provides JSON-formatted structured logging for all AITBC services. import json import logging import sys +import structlog from datetime import datetime, timezone from typing import Optional @@ -85,3 +86,36 @@ def get_audit_logger(service_name: str, env: str = "production") -> logging.Logg """Get or create an audit logger for a service.""" audit_name = f"{service_name}.audit" return setup_logger(name=audit_name, service_name=service_name, env=env) + + +def configure_logging(level: str = "INFO") -> None: + """Configure structlog for structured logging""" + structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.processors.JSONRenderer() + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + wrapper_class=structlog.stdlib.BoundLogger, + ) + + # Configure standard logging to use structlog + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, level.upper()), + ) + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + """Get a structured logger instance""" + return structlog.get_logger(name) diff --git a/packages/py/aitbc-core/src/aitbc/middleware/__init__.py b/packages/py/aitbc-core/src/aitbc/middleware/__init__.py new file mode 100644 index 00000000..1bfdb9eb --- /dev/null +++ b/packages/py/aitbc-core/src/aitbc/middleware/__init__.py @@ -0,0 +1,15 @@ +""" +Shared middleware for AITBC services +""" + +from .request_id import RequestIDMiddleware +from .performance import PerformanceLoggingMiddleware +from .validation import RequestValidationMiddleware +from .error_handler import ErrorHandlerMiddleware + +__all__ = [ + "RequestIDMiddleware", + "PerformanceLoggingMiddleware", + "RequestValidationMiddleware", + "ErrorHandlerMiddleware", +] diff --git a/packages/py/aitbc-core/src/aitbc/middleware/error_handler.py b/packages/py/aitbc-core/src/aitbc/middleware/error_handler.py new file mode 100644 index 00000000..b00fd685 --- /dev/null +++ b/packages/py/aitbc-core/src/aitbc/middleware/error_handler.py @@ -0,0 +1,61 @@ +""" +Standardized error response middleware for FastAPI +""" + +from typing import Callable + +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from aitbc.logging import get_logger + +logger = get_logger(__name__) + + +class ErrorHandlerMiddleware(BaseHTTPMiddleware): + """Middleware to standardize error responses""" + + async def dispatch(self, request: Request, call_next: Callable) -> JSONResponse: + try: + response = await call_next(request) + return response + except HTTPException as e: + logger.warning( + "HTTP exception", + status_code=e.status_code, + detail=e.detail, + path=request.url.path, + method=request.method, + ) + return JSONResponse( + status_code=e.status_code, + content={ + "error": { + "type": "http_error", + "message": e.detail, + "status_code": e.status_code, + "path": request.url.path, + } + }, + ) + except Exception as e: + logger.error( + "Unhandled exception", + error=str(e), + path=request.url.path, + method=request.method, + exc_info=True, + ) + return JSONResponse( + status_code=500, + content={ + "error": { + "type": "internal_error", + "message": "An internal server error occurred", + "status_code": 500, + "path": request.url.path, + } + }, + ) diff --git a/packages/py/aitbc-core/src/aitbc/middleware/performance.py b/packages/py/aitbc-core/src/aitbc/middleware/performance.py new file mode 100644 index 00000000..dc76df12 --- /dev/null +++ b/packages/py/aitbc-core/src/aitbc/middleware/performance.py @@ -0,0 +1,41 @@ +""" +Performance logging middleware for tracking request timing +""" + +import time +from typing import Callable + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from aitbc.logging import get_logger + +logger = get_logger(__name__) + + +class PerformanceLoggingMiddleware(BaseHTTPMiddleware): + """Middleware to log request performance metrics""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + start_time = time.perf_counter() + + # Process request + response = await call_next(request) + + # Calculate duration + duration = time.perf_counter() - start_time + + # Log performance metrics + logger.info( + "Request performance", + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=round(duration * 1000, 2), + ) + + # Add performance header + response.headers["X-Process-Time"] = f"{duration:.3f}" + + return response diff --git a/packages/py/aitbc-core/src/aitbc/middleware/request_id.py b/packages/py/aitbc-core/src/aitbc/middleware/request_id.py new file mode 100644 index 00000000..95fc8184 --- /dev/null +++ b/packages/py/aitbc-core/src/aitbc/middleware/request_id.py @@ -0,0 +1,54 @@ +""" +Request ID correlation middleware for structured logging +""" + +import uuid +from typing import Callable + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from aitbc.logging import get_logger + +logger = get_logger(__name__) + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """Middleware to add request ID to all requests for correlation""" + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + self.header_name = "X-Request-ID" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Generate or retrieve request ID + request_id = request.headers.get(self.header_name) or str(uuid.uuid4()) + + # Add request ID to request state for use in endpoints + request.state.request_id = request_id + + # Bind request ID to logger context + logger = get_logger(__name__).bind(request_id=request_id) + + # Log request start + logger.info( + "Incoming request", + method=request.method, + path=request.url.path, + client=request.client.host if request.client else "unknown", + ) + + # Process request + response = await call_next(request) + + # Add request ID to response headers + response.headers[self.header_name] = request_id + + # Log request completion + logger.info( + "Request completed", + status_code=response.status_code, + ) + + return response diff --git a/packages/py/aitbc-core/src/aitbc/middleware/validation.py b/packages/py/aitbc-core/src/aitbc/middleware/validation.py new file mode 100644 index 00000000..9d387467 --- /dev/null +++ b/packages/py/aitbc-core/src/aitbc/middleware/validation.py @@ -0,0 +1,66 @@ +""" +Request validation middleware for FastAPI +""" + +from typing import Callable + +from fastapi import Request, HTTPException +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from aitbc.logging import get_logger + +logger = get_logger(__name__) + + +class RequestValidationMiddleware(BaseHTTPMiddleware): + """Middleware to validate incoming requests""" + + def __init__( + self, + app: ASGIApp, + max_request_size: int = 10 * 1024 * 1024, # 10MB default + max_response_size: int = 10 * 1024 * 1024, # 10MB default + ) -> None: + super().__init__(app) + self.max_request_size = max_request_size + self.max_response_size = max_response_size + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Validate request size + content_length = request.headers.get("content-length") + if content_length: + try: + size = int(content_length) + if size > self.max_request_size: + logger.warning( + "Request too large", + content_length=size, + max_size=self.max_request_size, + client=request.client.host if request.client else "unknown", + ) + raise HTTPException( + status_code=413, + detail=f"Request too large. Maximum size is {self.max_request_size} bytes", + ) + except ValueError: + logger.warning("Invalid content-length header", content_length=content_length) + + # Process request + response = await call_next(request) + + # Validate response size + response_size = len(response.body) + if response_size > self.max_response_size: + logger.warning( + "Response too large", + response_size=response_size, + max_size=self.max_response_size, + path=request.url.path, + ) + raise HTTPException( + status_code=500, + detail="Response too large", + ) + + return response diff --git a/pyproject.toml b/pyproject.toml index d9b42c0a..d448b08e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,9 @@ version = "v0.3.3" description = "AI Agent Compute Network - Main Project" authors = ["AITBC Team"] +[tool.poetry.packages] +aitbc-core = { path = "packages/py/aitbc-core", develop = true } + [tool.poetry.dependencies] python = ">=3.13,<3.14" # Core Web Framework