# Dev Debug Toolbar + Centralized Logging — Design **Date:** 2026-04-29 **Status:** Draft (awaiting user review) **Owner:** vrysanek ## Goal Give developers a Flask-DebugToolbar-style panel UI in the FastAPI app for local dev, plus consistent log output across the FastAPI app and 5 background services. Make it cheap to leave on in dev, free to ship in production. Two scopes, one design: 1. **Debug toolbar** in the FastAPI app — visible only in dev, exposing per-request panels: timer, headers, routes, settings, versions, **logging records**, **pyinstrument profiler**, and a custom **DuckDB queries panel**. 2. **Centralized logging** — replace 20+ scattered `logging.basicConfig(...)` calls across `app/`, `services/`, `connectors/`, `src/`, `scripts/` with a single `setup_logging()` helper that uses `rich.logging.RichHandler` in dev and JSON to stdout in prod. Out of scope: production observability, log shipping, distributed tracing, structured logging refactor of every `logger.info("foo")` call site. --- ## Non-goals / explicit rejections - **Not** building an in-house toolbar (rejected: 1–2 days build time when `fastapi-debug-toolbar` covers 80% of the need). - **Not** introducing `loguru` or `structlog` (rejected: requires rewriting 40+ files using stdlib `logging`). - **Not** auto-deriving service slug via `sys._getframe(1)` magic (rejected after Gemini adversarial review: brittle on PyPy, decorators, indirect calls). Use explicit `setup_logging(__name__)`. - **Not** instrumenting DuckDB queries in production (rejected: per-query overhead, only needed for dev panel). --- ## Activation One environment variable: `DEBUG`. | `DEBUG` | FastAPI `debug=` | Toolbar middleware | Logging handler | DuckDB instrumentation | |---------|------------------|--------------------|-----------------|------------------------| | unset / `0` | `False` | not mounted, not imported | `StreamHandler` + JSON formatter | no-op (raw `duckdb.Connection`) | | `1` | `True` | mounted | `RichHandler` (color, tracebacks, links) | `InstrumentedConnection` records queries per request | Orthogonal to `LOCAL_DEV_MODE` (auth bypass) — they do different things, both can be set independently. --- ## Architecture ``` ┌───────────────────────────────────────────────────────────────┐ │ FastAPI app process │ │ │ │ app/main.py │ │ ├── setup_logging("app") │ │ ├── FastAPI(debug=DEBUG) │ │ ├── app.add_middleware(RequestIdMiddleware) │ │ └── if DEBUG: │ │ app.add_middleware(DebugToolbarMiddleware, │ │ panels=[Headers, Routes, Settings, Versions, │ │ Timer, Logging, Profiling, │ │ ★ DuckDBPanel ★]) │ │ │ │ app/logging_config.py ← setup_logging, _JSONFormatter │ │ app/middleware/request_id.py ← RequestIdMiddleware │ │ app/debug/duckdb_panel.py ← DuckDBPanel, │ │ InstrumentedConnection │ │ src/db.py (modified) ← _maybe_instrument helper │ └───────────────────────────────────────────────────────────────┘ Background services (scheduler, telegram_bot, ws_gateway, corporate_memory, session_collector): └── each entrypoint: setup_logging(__name__) → kills 20+ scattered basicConfig calls → unified format (Rich in dev, JSON in prod) ``` ### Component boundaries | Component | Public interface | Depends on | |-----------|------------------|------------| | `app/logging_config.py` | `setup_logging(service=None, level=None)`, `request_id_var: ContextVar[str\|None]` | stdlib `logging`, `rich`, env vars | | `app/middleware/request_id.py` | `RequestIdMiddleware` (Starlette `BaseHTTPMiddleware`) | `request_id_var` | | `app/debug/duckdb_panel.py` | `DuckDBPanel` (toolbar Panel), `InstrumentedConnection` (`duckdb.DuckDBPyConnection`-compatible), `record_query()`, `get_request_store()` | `fastapi-debug-toolbar`, `duckdb`, `contextvars` | | `src/db.py` (changed) | unchanged signatures; internal `_maybe_instrument(con, db_tag)` returns raw or wrapped connection based on `DEBUG` | `app.debug.duckdb_panel` (lazy import behind `DEBUG`) | A library module never calls `setup_logging()`. Only entrypoints (`__main__.py`, `main.py`, top-level CLI scripts). --- ## Detailed design ### `app/logging_config.py` ```python 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 def setup_logging(service: str | None = None, level: str | None = None) -> None: """Configure root logger. Idempotent. Call once per process at entrypoint. Pass `__name__` (preferred) or an explicit short service slug. """ 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.logging import RichHandler handler = RichHandler( rich_tracebacks=True, tracebacks_show_locals=False, show_time=True, show_path=True, markup=False, force_terminal=True, # docker compose: stdout not a TTY but still want color ) handler.setFormatter(logging.Formatter("[%(name)s] %(message)s")) else: handler = logging.StreamHandler() handler.setFormatter(_JSONFormatter(service=slug)) 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. setup_logging("app") -> "app" setup_logging("services.corporate_memory.collector") -> "corporate_memory.collector" setup_logging("services.scheduler.__main__") -> "scheduler" setup_logging("__main__") (direct script run) -> derived from caller's __file__ """ if service and not service.startswith("_") and service != "__main__": s = service.removeprefix("services.").removeprefix("connectors.").removeprefix("app.") s = s.removesuffix(".__main__").removesuffix(".main") return s or "app" # Fallback: parse caller's __file__ 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 = { "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) ``` Adversarial flaws addressed (per Gemini review 2026-04-29): | Flaw | Mitigation | |------|------------| | `sys._getframe` brittleness | Used only as a fallback when caller can't supply `__name__`; primary path is explicit `setup_logging(__name__)` | | `root.handlers.clear()` wipes uvicorn handlers | Replaced with `logging.basicConfig(force=True)` — semantically equivalent but well-defined | | RichHandler color-detection in docker | `force_terminal=True` — color is intentional in dev, dev never goes to log shippers | | Path leakage via `show_path=True` | Dev only. Prod uses `_JSONFormatter`, no source paths | | Non-idempotent | `_CONFIGURED` sentinel, repeated calls become no-ops | ### `app/middleware/request_id.py` ```python import uuid from starlette.middleware.base import BaseHTTPMiddleware from app.logging_config import request_id_var class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:12] token = request_id_var.set(rid) try: response = await call_next(request) finally: request_id_var.reset(token) response.headers["x-request-id"] = rid return response ``` Mounted unconditionally (cheap, useful in prod for correlation too). Sits **before** `DebugToolbarMiddleware` so request id is set when the toolbar captures. ### `app/debug/duckdb_panel.py` ```python from __future__ import annotations import contextvars import time from dataclasses import dataclass from typing import Any import duckdb from debug_toolbar.panels import Panel from debug_toolbar.types import ServerTiming, Stats @dataclass class _Query: db: str # "system" | "analytics" sql: str params: Any ms: float rows: int | None error: str | None = None _request_store: contextvars.ContextVar[list[_Query] | None] = contextvars.ContextVar( "duckdb_panel_store", default=None ) def get_request_store() -> list[_Query] | None: return _request_store.get() def record_query(db: str, sql: str, params: Any, started: float, rows: int | None, error: str | None = None) -> None: store = _request_store.get() if store is None: # outside debug request: no-op return store.append(_Query( db=db, sql=sql, params=params, ms=(time.perf_counter() - started) * 1000.0, rows=rows, error=error, )) class InstrumentedConnection: """duckdb.DuckDBPyConnection-compatible wrapper that records queries.""" def __init__(self, real: duckdb.DuckDBPyConnection, db_tag: str) -> None: self._real = real self._db = db_tag def execute(self, sql: str, params: Any = None, *args, **kwargs): started = time.perf_counter() err: str | None = None result = None try: result = (self._real.execute(sql, params, *args, **kwargs) if params is not None else self._real.execute(sql, *args, **kwargs)) return result except Exception as e: err = repr(e) raise finally: rows: int | None = None try: if result is not None and hasattr(result, "rowcount"): rows = result.rowcount except Exception: pass record_query(self._db, sql, params, started, rows, err) def __getattr__(self, name): # delegate everything else return getattr(self._real, name) class DuckDBPanel(Panel): title = "DuckDB" template = "panels/duckdb.html" @property def nav_subtitle(self) -> str: store = get_request_store() or [] return f"{len(store)} queries · {sum(q.ms for q in store):.1f} ms" async def process_request(self, request): _request_store.set([]) return await super().process_request(request) async def generate_stats(self, request, response) -> Stats | None: store = get_request_store() or [] return { "queries": [q.__dict__ for q in store], "total_ms": sum(q.ms for q in store), "by_db": {db: sum(q.ms for q in store if q.db == db) for db in {q.db for q in store}}, } async def generate_server_timing(self, request, response) -> ServerTiming: store = get_request_store() or [] return [("DuckDB", "DuckDB queries", sum(q.ms for q in store))] ``` Template (`app/debug/templates/panels/duckdb.html`): ```jinja
By DB:
{% for db, ms in stats.by_db.items() %}{{ db }}: {{ "%.1f"|format(ms) }} ms{% if not loop.last %} · {% endif %}{% endfor %}
| # | DB | ms | rows | SQL | params |
|---|---|---|---|---|---|
| {{ loop.index }} | {{ q.db }} | {{ "%.2f"|format(q.ms) }} | {{ q.rows if q.rows is not none else '—' }} |
{{ q.sql }}
{% if q.error %}{{ q.error }} {% endif %}
|
{{ q.params }} |