feat(logging): add structured JSON logging for production, human-readable text for development
This commit is contained in:
67
backend/app/logging_config.py
Normal file
67
backend/app/logging_config.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Structured JSON logging configuration.
|
||||||
|
|
||||||
|
In **production** (``AEGIS_ENV=production``), emits one JSON object per
|
||||||
|
line so that log aggregators (ELK, CloudWatch, Datadog) can ingest them
|
||||||
|
without custom parsing.
|
||||||
|
|
||||||
|
In **development** (default), uses a human-readable text format for
|
||||||
|
comfortable local work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class _JSONFormatter(logging.Formatter):
|
||||||
|
"""Emit each log record as a single-line JSON object."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict = {
|
||||||
|
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.exc_info and record.exc_info[1] is not None:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
extra = getattr(record, "_extra", None)
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
|
||||||
|
return json.dumps(payload, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
_DEV_FORMAT = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure the root logger based on the environment."""
|
||||||
|
is_production = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||||
|
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
if root.handlers:
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(level)
|
||||||
|
|
||||||
|
if is_production:
|
||||||
|
handler.setFormatter(_JSONFormatter())
|
||||||
|
else:
|
||||||
|
handler.setFormatter(logging.Formatter(_DEV_FORMAT))
|
||||||
|
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||||
@@ -47,10 +47,9 @@ from app.jobs.mitre_sync_job import start_scheduler, scheduler
|
|||||||
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||||
|
|
||||||
# ── Logging ───────────────────────────────────────────────────────────────
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
logging.basicConfig(
|
from app.logging_config import setup_logging
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",
|
setup_logging()
|
||||||
)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|||||||
Reference in New Issue
Block a user