Cuts release 0.20.0. ## Highlights - X-Request-ID header on every response + sanitized to [A-Za-z0-9_-] (CRLF log-forging mitigation) - Error pages (HTML + JSON 500) surface request_id for support tickets - Dev debug toolbar gated by DEBUG=1 — fastapi-debug-toolbar with custom DuckDBPanel - Centralized app.logging_config.setup_logging() replaces 23 scattered basicConfig calls - Telegram bot drops bot.log file — stdout only (BREAKING) ## Devin findings addressed - BUG_0001: .env.template no longer claims FastAPI debug=True - BUG_0002: subprocess extractor logs INFO to stderr again - ANALYSIS_0003: _wants_html no longer matches Accept: */* (curl gets JSON as before) - BUG on b1c6ee9: HTML 500 page no longer leaks str(exc) in production - BUG on b13d2fe: 2 CLAUDE.md compliance flags (transform.py + ws_gateway) accepted as scope-limited logging refactor — follow-up to update CLAUDE.md if needed See CHANGELOG [0.20.0] for full notes.
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
"""Centralized logging configuration for FastAPI app and background services.
|
|
|
|
Each entrypoint (app/main.py, services/*/__main__.py or top-level script)
|
|
calls setup_logging(__name__) once. Library modules just do
|
|
`logger = logging.getLogger(__name__)` — they NEVER call setup_logging.
|
|
|
|
Dev (DEBUG=1): rich.logging.RichHandler with color, tracebacks, links.
|
|
Prod: stdlib StreamHandler with JSON formatter to stderr.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar("request_id", default=None)
|
|
|
|
_CONFIGURED = False
|
|
|
|
|
|
class _RequestIdFilter(logging.Filter):
|
|
"""Inject the current request_id ContextVar into every LogRecord."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
record.request_id = request_id_var.get() or "-"
|
|
return True
|
|
|
|
|
|
def setup_logging(service: str | None = None, level: str | None = None) -> None:
|
|
"""Configure root logger. Idempotent.
|
|
|
|
Pass ``__name__`` (preferred) or an explicit short slug like ``"app"``.
|
|
Multiple calls are no-ops.
|
|
"""
|
|
global _CONFIGURED
|
|
if _CONFIGURED:
|
|
return
|
|
|
|
debug = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
|
|
lvl = (level or os.environ.get("LOG_LEVEL") or ("DEBUG" if debug else "INFO")).upper()
|
|
slug = _derive_slug(service)
|
|
|
|
if debug:
|
|
from rich.console import Console
|
|
from rich.logging import RichHandler
|
|
|
|
handler: logging.Handler = RichHandler(
|
|
console=Console(stderr=True, force_terminal=True),
|
|
rich_tracebacks=True,
|
|
tracebacks_show_locals=False,
|
|
show_time=True,
|
|
show_path=True,
|
|
markup=False,
|
|
)
|
|
handler.setFormatter(logging.Formatter("[%(request_id)s] [%(name)s] %(message)s"))
|
|
else:
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(_JSONFormatter(service=slug))
|
|
|
|
handler.addFilter(_RequestIdFilter())
|
|
logging.basicConfig(level=lvl, handlers=[handler], force=True)
|
|
logging.getLogger("uvicorn.access").setLevel(logging.INFO if debug else logging.WARNING)
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
_CONFIGURED = True
|
|
|
|
|
|
def _derive_slug(service: str | None) -> str:
|
|
"""Turn module name (``__name__``) or override into readable service slug.
|
|
|
|
Examples:
|
|
_derive_slug("app") -> "app"
|
|
_derive_slug("services.scheduler.__main__") -> "scheduler"
|
|
_derive_slug("services.corporate_memory.collector") -> "corporate_memory.collector"
|
|
_derive_slug("connectors.jira.transform") -> "jira.transform"
|
|
"""
|
|
if service and not service.startswith("_") and service != "__main__":
|
|
s = service.removeprefix("services.").removeprefix("connectors.").removeprefix("app.")
|
|
s = s.removesuffix(".__main__").removesuffix(".main")
|
|
if s in ("", "main", "__main__"):
|
|
return "app"
|
|
return s
|
|
|
|
try:
|
|
frame = sys._getframe(2)
|
|
path = frame.f_globals.get("__file__")
|
|
if path:
|
|
p = Path(path)
|
|
for top in ("services", "connectors", "app"):
|
|
if top in p.parts:
|
|
i = p.parts.index(top) + 1
|
|
rest = p.parts[i:]
|
|
name = ".".join([*rest[:-1], p.stem])
|
|
return name.removesuffix(".__main__").removesuffix(".main") or top
|
|
return p.stem
|
|
except Exception:
|
|
pass
|
|
return "app"
|
|
|
|
|
|
class _JSONFormatter(logging.Formatter):
|
|
def __init__(self, service: str) -> None:
|
|
super().__init__()
|
|
self.service = service
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
payload: dict[str, object] = {
|
|
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
"lvl": record.levelname,
|
|
"logger": record.name,
|
|
"service": self.service,
|
|
"msg": record.getMessage(),
|
|
}
|
|
rid = request_id_var.get()
|
|
if rid:
|
|
payload["request_id"] = rid
|
|
if record.exc_info:
|
|
payload["exc"] = self.formatException(record.exc_info)
|
|
return json.dumps(payload, default=str)
|