agnes-the-ai-analyst/app/logging_config.py
Vojtech 38f6b639d2
feat(observability): request_id end-to-end + dev debug toolbar + centralized logging (#136)
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.
2026-04-29 22:54:21 +02:00

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)