# Dev Debug Toolbar + Centralized Logging Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `fastapi-debug-toolbar` (gated by `DEBUG=1`) with a custom DuckDBPanel, and replace 23 scattered `logging.basicConfig` calls with a single centralized `setup_logging()` helper that uses `RichHandler` in dev and JSON in prod. **Architecture:** Phase 1–4 introduce `app/logging_config.py` and migrate every entrypoint (services, scripts, app, connectors) to call `setup_logging(__name__)`. Phase 5 mounts `DebugToolbarMiddleware` behind a `DEBUG=1` env gate so production never imports the toolbar. Phase 6 adds a custom `DuckDBPanel` with an `InstrumentedConnection` wrapper that hooks `src/db.py`'s three `duckdb.connect()` call sites. Phase 7 documents the `DEBUG=1` flag in `.env.template`, `CHANGELOG.md`, and `docs/development.md`. **Tech Stack:** Python 3.11+, FastAPI, Starlette, DuckDB, `rich>=13` (already a dep), `fastapi-debug-toolbar>=0.6.3` (new dev-only dep, pulls Jinja2 + pyinstrument), pytest with coverage. **Working directory for execution:** `/Users/vrysanek/foundry-ai/agnes-the-ai-analyst/.worktrees/main-dev-logging/` on branch `vr/dev-logging` (tracks `origin/main`). All paths below are relative to this worktree root. --- ## File Structure ### New files | Path | Responsibility | |------|----------------| | `app/logging_config.py` | `setup_logging()`, `_JSONFormatter`, `_derive_slug()`, `request_id_var` ContextVar | | `app/middleware/__init__.py` | empty package marker | | `app/middleware/request_id.py` | `RequestIdMiddleware` (Starlette `BaseHTTPMiddleware`) | | `app/debug/__init__.py` | empty package marker | | `app/debug/duckdb_panel.py` | `DuckDBPanel`, `InstrumentedConnection`, `record_query`, `get_request_store` | | `app/debug/templates/panels/duckdb.html` | Jinja template rendering query table | | `tests/test_logging_config.py` | unit tests for `setup_logging` and slug derivation | | `tests/test_request_id_middleware.py` | unit tests for request-id middleware | | `tests/test_duckdb_panel.py` | unit tests for `InstrumentedConnection` | | `tests/test_toolbar_integration.py` | integration tests for toolbar + DuckDBPanel | | `docs/development.md` | brief usage doc covering `DEBUG=1` and toolbar | ### Modified files | Path | Change | |------|--------| | `app/main.py` | call `setup_logging("app")`, mount `RequestIdMiddleware` always, mount `DebugToolbarMiddleware` only if `DEBUG=1` | | `app/api/sync.py:105` | delete rogue module-level `logging.basicConfig` | | `src/db.py` | add `_maybe_instrument()` helper; wrap returns of `get_system_db()`, `get_analytics_db()`, `get_analytics_db_readonly()` | | `src/catalog_export.py` | replace module-level `basicConfig` with `setup_logging(__name__)` (only when run as script) | | `src/profiler.py` | replace module-level `basicConfig` with `setup_logging(__name__)` | | `services/scheduler/__main__.py` | replace `basicConfig` with `setup_logging(__name__)` | | `services/ws_gateway/gateway.py` | replace `basicConfig` with `setup_logging(__name__)` | | `services/telegram_bot/bot.py` | replace `basicConfig` with `setup_logging(__name__)` | | `services/corporate_memory/collector.py` | replace `basicConfig` with `setup_logging(__name__)` | | `services/session_collector/collector.py` | replace `basicConfig` with `setup_logging(__name__)` | | `services/verification_detector/__main__.py` | replace `basicConfig` with `setup_logging(__name__)` | | `connectors/keboola/extractor.py` | remove module-level `_logging.basicConfig` (only run via CLI block at bottom) | | `connectors/jira/transform.py` | remove module-level `basicConfig` | | `connectors/jira/incremental_transform.py` | remove module-level `basicConfig` | | `connectors/jira/extract_init.py` | replace `basicConfig` with `setup_logging(__name__)` (only inside `__main__` block) | | `connectors/jira/scripts/poll_sla.py` | replace with `setup_logging(__name__)` | | `connectors/jira/scripts/backfill.py` | replace with `setup_logging(__name__)` | | `connectors/jira/scripts/backfill_sla.py` | replace with `setup_logging(__name__)` | | `connectors/jira/scripts/backfill_remote_links.py` | replace with `setup_logging(__name__)` | | `connectors/jira/scripts/consistency_check.py` | replace with `setup_logging(__name__)` | | `scripts/migrate_json_to_duckdb.py` | replace with `setup_logging(__name__)` | | `scripts/migrate_metrics_to_duckdb.py` | replace with `setup_logging(__name__)` | | `scripts/migrate_parquets_to_extracts.py` | replace with `setup_logging(__name__)` | | `scripts/migrate_registry_to_duckdb.py` | replace with `setup_logging(__name__)` | | `scripts/generate_sample_data.py` | replace with `setup_logging(__name__)` | | `pyproject.toml` | add `fastapi-debug-toolbar>=0.6.3` to `[project.optional-dependencies].dev` | | `config/.env.template` | document `DEBUG=1` toggle | | `CHANGELOG.md` | new `### Added` entry under `## [Unreleased]` | ### Discovered facts (verified during plan writing) - `src/db.py` has three `duckdb.connect()` call sites: lines 413, 423, 581/587. Three public helpers: `get_system_db()` (line 394, returns `_system_db_conn.cursor()`), `get_analytics_db()` (line 419), `get_analytics_db_readonly()` (line 573). - `services/scheduler/__main__.py:31`, `services/verification_detector/__main__.py:49`, `services/corporate_memory/collector.py:52`, `services/session_collector/collector.py:36`, `services/telegram_bot/bot.py:55`, `services/ws_gateway/gateway.py:214` — all have multi-line `basicConfig` calls. - `app/api/sync.py:105` has a **module-level** `basicConfig` inside an api file — bug, will be removed (root logger is configured by the app entrypoint). - 23 total `basicConfig` call sites enumerated. --- ## Task 1: Centralized logging module (`app/logging_config.py`) — Phase P1 **Files:** - Create: `app/logging_config.py` - Test: `tests/test_logging_config.py` - [ ] **Step 1.1: Write the failing tests** Create `tests/test_logging_config.py`: ```python import json import logging import os from io import StringIO import pytest from app.logging_config import ( _CONFIGURED, _derive_slug, _JSONFormatter, request_id_var, setup_logging, ) @pytest.fixture(autouse=True) def _reset_logging(monkeypatch): """Reset global logging state between tests.""" import app.logging_config as lc lc._CONFIGURED = False monkeypatch.delenv("DEBUG", raising=False) monkeypatch.delenv("LOG_LEVEL", raising=False) yield lc._CONFIGURED = False logging.getLogger().handlers.clear() def test_dev_uses_rich_handler(monkeypatch): monkeypatch.setenv("DEBUG", "1") setup_logging("app") handlers = logging.getLogger().handlers assert len(handlers) == 1 from rich.logging import RichHandler assert isinstance(handlers[0], RichHandler) def test_prod_uses_json_formatter(): setup_logging("app") handlers = logging.getLogger().handlers assert len(handlers) == 1 assert isinstance(handlers[0], logging.StreamHandler) assert isinstance(handlers[0].formatter, _JSONFormatter) def test_idempotent(): setup_logging("app") setup_logging("app") setup_logging("app") assert len(logging.getLogger().handlers) == 1 def test_log_level_from_env(monkeypatch): monkeypatch.setenv("LOG_LEVEL", "DEBUG") setup_logging("app") assert logging.getLogger().level == logging.DEBUG def test_log_level_default_prod(): setup_logging("app") assert logging.getLogger().level == logging.INFO def test_log_level_default_dev(monkeypatch): monkeypatch.setenv("DEBUG", "1") setup_logging("app") assert logging.getLogger().level == logging.DEBUG def test_slug_explicit_short_name(): assert _derive_slug("scheduler") == "scheduler" def test_slug_strips_services_prefix(): assert _derive_slug("services.scheduler.__main__") == "scheduler" def test_slug_keeps_nested_module(): assert _derive_slug("services.corporate_memory.collector") == "corporate_memory.collector" def test_slug_strips_app_prefix(): assert _derive_slug("app.main") == "app" def test_slug_strips_connectors_prefix(): assert _derive_slug("connectors.jira.transform") == "jira.transform" def test_slug_explicit_app(): assert _derive_slug("app") == "app" def test_json_formatter_includes_service_field(): rec = logging.LogRecord( name="test", level=logging.INFO, pathname=__file__, lineno=1, msg="hello world", args=(), exc_info=None, ) fmt = _JSONFormatter(service="myservice") line = fmt.format(rec) parsed = json.loads(line) assert parsed["service"] == "myservice" assert parsed["msg"] == "hello world" assert parsed["lvl"] == "INFO" assert parsed["logger"] == "test" assert "ts" in parsed def test_json_formatter_includes_request_id_when_set(): fmt = _JSONFormatter(service="app") rec = logging.LogRecord( name="t", level=logging.INFO, pathname=__file__, lineno=1, msg="m", args=(), exc_info=None, ) token = request_id_var.set("abc123") try: line = fmt.format(rec) finally: request_id_var.reset(token) parsed = json.loads(line) assert parsed["request_id"] == "abc123" def test_json_formatter_omits_request_id_when_unset(): fmt = _JSONFormatter(service="app") rec = logging.LogRecord( name="t", level=logging.INFO, pathname=__file__, lineno=1, msg="m", args=(), exc_info=None, ) line = fmt.format(rec) parsed = json.loads(line) assert "request_id" not in parsed def test_json_formatter_includes_exception(): fmt = _JSONFormatter(service="app") try: raise ValueError("boom") except ValueError: import sys rec = logging.LogRecord( name="t", level=logging.ERROR, pathname=__file__, lineno=1, msg="oops", args=(), exc_info=sys.exc_info(), ) line = fmt.format(rec) parsed = json.loads(line) assert "exc" in parsed assert "ValueError: boom" in parsed["exc"] def test_setup_logging_emits_parsable_json_in_prod(capsys): setup_logging("app") logging.getLogger("test").info("hello %s", "world") out = capsys.readouterr().err parsed = json.loads(out.strip().splitlines()[-1]) assert parsed["msg"] == "hello world" assert parsed["service"] == "app" def test_setup_logging_silences_uvicorn_access_in_prod(): setup_logging("app") assert logging.getLogger("uvicorn.access").level == logging.WARNING def test_setup_logging_keeps_uvicorn_access_in_dev(monkeypatch): monkeypatch.setenv("DEBUG", "1") setup_logging("app") assert logging.getLogger("uvicorn.access").level == logging.INFO ``` - [ ] **Step 1.2: Run tests, verify they fail** ```bash uv run pytest tests/test_logging_config.py -q ``` Expected: collection error or `ImportError: cannot import name 'setup_logging'` because the module does not exist yet. - [ ] **Step 1.3: Implement `app/logging_config.py`** Create the file: ```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 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.logging import RichHandler handler: logging.Handler = RichHandler( rich_tracebacks=True, tracebacks_show_locals=False, show_time=True, show_path=True, markup=False, force_terminal=True, ) 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. 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") return s or "app" 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) ``` - [ ] **Step 1.4: Run tests, verify they pass** ```bash uv run pytest tests/test_logging_config.py -q ``` Expected: all tests pass. If any fail, fix before proceeding. - [ ] **Step 1.5: Run quality checks** ```bash ruff format app/logging_config.py tests/test_logging_config.py ruff check app/logging_config.py tests/test_logging_config.py --fix ``` Expected: no warnings. - [ ] **Step 1.6: Commit** ```bash git add app/logging_config.py tests/test_logging_config.py git commit -m "feat(logging): add centralized setup_logging with dev/prod handlers" ``` --- ## Task 2: Request-ID middleware (`app/middleware/request_id.py`) — Phase P2 **Files:** - Create: `app/middleware/__init__.py` (empty) - Create: `app/middleware/request_id.py` - Test: `tests/test_request_id_middleware.py` - [ ] **Step 2.1: Create empty package marker** ```bash mkdir -p app/middleware : > app/middleware/__init__.py ``` - [ ] **Step 2.2: Write the failing tests** Create `tests/test_request_id_middleware.py`: ```python import re from fastapi import FastAPI from fastapi.testclient import TestClient from app.logging_config import request_id_var from app.middleware.request_id import RequestIdMiddleware def _make_app() -> FastAPI: app = FastAPI() app.add_middleware(RequestIdMiddleware) @app.get("/echo-rid") def echo_rid(): return {"rid": request_id_var.get()} return app def test_assigns_id_when_missing(): client = TestClient(_make_app()) resp = client.get("/echo-rid") assert resp.status_code == 200 rid = resp.headers["x-request-id"] assert re.fullmatch(r"[0-9a-f]{12}", rid) assert resp.json()["rid"] == rid def test_passes_through_provided_id(): client = TestClient(_make_app()) resp = client.get("/echo-rid", headers={"X-Request-ID": "given-1234"}) assert resp.status_code == 200 assert resp.headers["x-request-id"] == "given-1234" assert resp.json()["rid"] == "given-1234" def test_request_id_resets_after_request(): client = TestClient(_make_app()) client.get("/echo-rid") assert request_id_var.get() is None ``` - [ ] **Step 2.3: Run tests, verify they fail** ```bash uv run pytest tests/test_request_id_middleware.py -q ``` Expected: `ModuleNotFoundError: No module named 'app.middleware.request_id'`. - [ ] **Step 2.4: Implement the middleware** Create `app/middleware/request_id.py`: ```python """Request-ID middleware. Assigns or propagates X-Request-ID per request.""" from __future__ import annotations import uuid from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response from app.logging_config import request_id_var class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next) -> Response: 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 ``` - [ ] **Step 2.5: Run tests, verify they pass** ```bash uv run pytest tests/test_request_id_middleware.py -q ``` Expected: all 3 tests pass. - [ ] **Step 2.6: Run quality checks** ```bash ruff format app/middleware/ tests/test_request_id_middleware.py ruff check app/middleware/ tests/test_request_id_middleware.py --fix ``` - [ ] **Step 2.7: Commit** ```bash git add app/middleware/ tests/test_request_id_middleware.py git commit -m "feat(http): add RequestIdMiddleware for cross-log correlation" ``` --- ## Task 3: Wire `setup_logging` + `RequestIdMiddleware` into `app/main.py` — Phase P2 (continued) **Files:** - Modify: `app/main.py` (add `setup_logging` call at top of module before any other import that emits logs; add `RequestIdMiddleware`) - Modify: `app/api/sync.py:105` (delete rogue `basicConfig`) - [ ] **Step 3.1: Read `app/main.py` to find safe insertion points** ```bash sed -n '1,40p' app/main.py ``` Expected: shows imports + module-level setup. Note the line where `app = FastAPI(...)` is created and where `app.include_router(...)` calls begin. - [ ] **Step 3.2: Add `setup_logging("app")` near the top of `app/main.py`** Locate the **first non-`from __future__` import** in `app/main.py`. Above the FastAPI import, add: ```python from app.logging_config import setup_logging setup_logging("app") ``` Keep it before any module that emits logs at import time. - [ ] **Step 3.3: Mount `RequestIdMiddleware` immediately after `app = FastAPI(...)` is created** Find the `app = FastAPI(...)` line. Immediately after it (before any `app.add_middleware(...)` or `app.include_router(...)`), add: ```python from app.middleware.request_id import RequestIdMiddleware app.add_middleware(RequestIdMiddleware) ``` - [ ] **Step 3.4: Delete rogue module-level `basicConfig` in `app/api/sync.py`** Read `app/api/sync.py` lines 100–110 to find the exact line and any logger usage: ```bash sed -n '100,115p' app/api/sync.py ``` Then delete the line: ```python logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") ``` If there is no other `import logging` usage in the file beyond this and a `logger = logging.getLogger(__name__)`, leave the import alone (the logger reference still needs it). - [ ] **Step 3.5: Run the full test suite** ```bash uv run pytest -q ``` Expected: all tests still pass. If any test was relying on the rogue `basicConfig` formatter from `sync.py`, it will surface here — fix by switching that test's expectations to use `caplog`. - [ ] **Step 3.6: Sanity-check the app starts** ```bash uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 3 curl -s -i http://localhost:8011/health 2>/dev/null | head -10 kill $APP_PID ``` Expected: response includes `x-request-id: <12 hex chars>` header. - [ ] **Step 3.7: Commit** ```bash git add app/main.py app/api/sync.py git commit -m "feat(app): wire setup_logging + RequestIdMiddleware in app/main.py" ``` --- ## Task 4: Migrate service entrypoints — Phase P3 Six service entrypoints currently call `logging.basicConfig(...)` at module top level. Replace each with `setup_logging(__name__)`. **Files:** - Modify: `services/scheduler/__main__.py` - Modify: `services/ws_gateway/gateway.py` - Modify: `services/telegram_bot/bot.py` - Modify: `services/corporate_memory/collector.py` - Modify: `services/session_collector/collector.py` - Modify: `services/verification_detector/__main__.py` - [ ] **Step 4.1: Migrate `services/scheduler/__main__.py`** Line 31 onward currently is: ```python logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s [scheduler] %(message)s", ) logger = logging.getLogger(__name__) ``` Replace the `logging.basicConfig(...)` block (multi-line) with: ```python from app.logging_config import setup_logging setup_logging(__name__) logger = logging.getLogger(__name__) ``` Keep the existing `import logging` and the `logger = ...` line. Remove `import os` only if it is unused after the change (verify by running ruff after). - [ ] **Step 4.2: Migrate `services/ws_gateway/gateway.py`** Find the `logging.basicConfig(...)` block at line 214 (inside an `if __name__ == "__main__":` or similar startup function). Replace: ```python logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) ``` with: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` - [ ] **Step 4.3: Migrate `services/telegram_bot/bot.py`** Find the multi-line `logging.basicConfig(...)` block at line 55. Replace the entire block with: ```python from app.logging_config import setup_logging setup_logging(__name__) logger = logging.getLogger("notify-bot") # keep existing logger name to preserve log filters ``` Preserve any pre-existing custom logger name. - [ ] **Step 4.4: Migrate `services/corporate_memory/collector.py`** Find the multi-line `logging.basicConfig(...)` block at line 52. Replace with: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` Keep any subsequent `logger = logging.getLogger(...)` line. - [ ] **Step 4.5: Migrate `services/session_collector/collector.py`** Find the multi-line `logging.basicConfig(...)` block at line 36. Replace with: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` - [ ] **Step 4.6: Migrate `services/verification_detector/__main__.py`** Find the multi-line `logging.basicConfig(...)` block at line 49 (inside the `__main__` function, NOT module-level). Replace inline: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` - [ ] **Step 4.7: Run the full test suite** ```bash uv run pytest -q ``` Expected: all tests pass. Service modules' tests (if any) may need `monkeypatch.setattr("app.logging_config._CONFIGURED", False)` to reset state. - [ ] **Step 4.8: Smoke-test each service starts** ```bash # Scheduler should start, log a single line, then idle timeout 3 uv run python -m services.scheduler 2>&1 | head -5 || true # Ws gateway should start timeout 3 uv run python -m services.ws_gateway 2>&1 | head -5 || true ``` Expected: each service emits a startup log line in JSON (no `DEBUG=1`) or rich color (`DEBUG=1 timeout 3 uv run …`). - [ ] **Step 4.9: Run quality checks** ```bash ruff format services/ ruff check services/ --fix ``` - [ ] **Step 4.10: Commit** ```bash git add services/ git commit -m "refactor(services): migrate entrypoints to centralized setup_logging" ``` --- ## Task 5: Migrate `connectors/`, `src/`, `scripts/` — Phase P4 These 14 files have module-level or `__main__`-block `basicConfig` calls. The migration rules differ by location: - **Module-level `basicConfig` in a library file** (e.g. `connectors/jira/transform.py`): just **remove** it — library modules should never configure root logger; the entrypoint that imports them is responsible. - **`basicConfig` inside `if __name__ == "__main__":` block**: **replace** with `setup_logging(__name__)`. **Files:** - Modify: `src/catalog_export.py`, `src/profiler.py` - Modify: `connectors/keboola/extractor.py` - Modify: `connectors/jira/transform.py`, `incremental_transform.py`, `extract_init.py` - Modify: `connectors/jira/scripts/poll_sla.py`, `backfill.py`, `backfill_sla.py`, `backfill_remote_links.py`, `consistency_check.py` - Modify: `scripts/migrate_json_to_duckdb.py`, `migrate_metrics_to_duckdb.py`, `migrate_parquets_to_extracts.py`, `migrate_registry_to_duckdb.py`, `generate_sample_data.py` - [ ] **Step 5.1: Determine library vs entrypoint per file** For each file, run: ```bash grep -nE 'if __name__ == "__main__"|basicConfig' ``` If `basicConfig` is **outside** any `if __name__` block AND the file is imported by other modules → it is a library file. Remove the `basicConfig` line entirely. If `basicConfig` is **inside** `if __name__ == "__main__"` OR the file is exclusively run as a script (the `scripts/` dir, `connectors/jira/scripts/`) → replace with `setup_logging(__name__)`. | File | Verdict | Action | |------|---------|--------| | `src/catalog_export.py` | library + has `if __name__` block | check: if `basicConfig` is inside `__main__`, replace; else remove | | `src/profiler.py` | library | remove | | `connectors/keboola/extractor.py:289` | library, has CLI block at bottom | the `_logging.basicConfig` is inside the CLI block — replace with `setup_logging(__name__)` | | `connectors/jira/transform.py:20` | library | **remove** (module-level) | | `connectors/jira/incremental_transform.py:38` | library | **remove** (module-level) | | `connectors/jira/extract_init.py:124` | inside `if __name__ == "__main__"` | replace | | `connectors/jira/scripts/*.py` (5 files) | scripts | replace | | `scripts/migrate_*.py` (4 files) | scripts | replace | | `scripts/generate_sample_data.py:1035` | inside `if __name__ == "__main__"` | replace | - [ ] **Step 5.2: Apply removals (library files)** For each library file with module-level `basicConfig`: `connectors/jira/transform.py:20`: - delete the line `logging.basicConfig(level=logging.INFO)`. `connectors/jira/incremental_transform.py:38`: - delete the line `logging.basicConfig(level=logging.INFO)`. `src/profiler.py:28`: - delete the multi-line `logging.basicConfig(...)` call. `src/catalog_export.py:43`: - inspect: if outside any `__main__` block, delete the multi-line `basicConfig`; if inside, do step 5.3 instead. ```bash sed -n '40,55p' src/catalog_export.py sed -n '25,35p' src/profiler.py ``` - [ ] **Step 5.3: Apply replacements (entrypoint files)** For each entrypoint file, replace the `basicConfig(...)` block with: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` Files: `connectors/keboola/extractor.py:289` (note: uses `_logging` alias — replace the call inside the same scope), `connectors/jira/extract_init.py:124`, all 5 `connectors/jira/scripts/*.py`, all 4 `scripts/migrate_*.py`, `scripts/generate_sample_data.py:1035`. For `connectors/keboola/extractor.py`: ```python # was: # _logging.basicConfig(level=_logging.INFO, format="%(levelname)s: %(message)s") from app.logging_config import setup_logging setup_logging(__name__) ``` - [ ] **Step 5.4: Verify no `basicConfig` left in production code** ```bash grep -rEn 'logging\.basicConfig|_logging\.basicConfig' \ src/ app/ services/ connectors/ scripts/ \ --include="*.py" | grep -v "tests/" || echo "CLEAN" ``` Expected: prints `CLEAN`. If anything matches, decide library vs entrypoint and fix. - [ ] **Step 5.5: Run the full test suite** ```bash uv run pytest -q ``` Expected: all tests pass. - [ ] **Step 5.6: Smoke-test one CLI script** ```bash DATA_DIR=/tmp/agnes-smoke uv run python -m scripts.migrate_metrics_to_duckdb --help 2>&1 | head -10 || true ``` Expected: usage message appears with no double-config warnings. - [ ] **Step 5.7: Run quality checks** ```bash ruff format src/ connectors/ scripts/ ruff check src/ connectors/ scripts/ --fix ``` - [ ] **Step 5.8: Commit** ```bash git add src/ connectors/ scripts/ git commit -m "refactor(logging): remove scattered basicConfig calls across libs and scripts" ``` --- ## Task 6: Add `fastapi-debug-toolbar` dev dep + wire middleware behind `DEBUG=1` — Phase P5 **Files:** - Modify: `pyproject.toml` (add dev dep) - Modify: `app/main.py` (mount `DebugToolbarMiddleware` only if `DEBUG=1`) - [ ] **Step 6.1: Add the dev dependency** Edit `pyproject.toml` `[project.optional-dependencies].dev` block: ```toml [project.optional-dependencies] dev = [ "pytest>=9.0.0", "pytest-timeout>=2.0.0", "pytest-xdist>=3.0.0", "faker>=24.0.0", "anthropic>=0.30.0", "openai>=1.30.0", "jsonschema>=4.0.0", "fastapi-debug-toolbar>=0.6.3", ] ``` Also mirror under `[tool.uv].dev-dependencies` if present: ```toml [tool.uv] dev-dependencies = [ "pytest>=9.0.0", "pytest-timeout>=2.0.0", "pytest-xdist>=3.0.0", "faker>=24.0.0", "anthropic>=0.30.0", "openai>=1.30.0", "fastapi-debug-toolbar>=0.6.3", ] ``` - [ ] **Step 6.2: Install the new dep** ```bash uv pip install ".[dev]" ``` Expected: installs `fastapi-debug-toolbar`, `Jinja2` (already a dep), `pyinstrument`. - [ ] **Step 6.3: Wire `DebugToolbarMiddleware` in `app/main.py`** Locate the `app.add_middleware(RequestIdMiddleware)` line added in Task 3. Immediately after it, add: ```python import os DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes") if DEBUG: try: from debug_toolbar.middleware import DebugToolbarMiddleware app.add_middleware( DebugToolbarMiddleware, panels=[ "debug_toolbar.panels.headers.HeadersPanel", "debug_toolbar.panels.routes.RoutesPanel", "debug_toolbar.panels.settings.SettingsPanel", "debug_toolbar.panels.versions.VersionsPanel", "debug_toolbar.panels.timer.TimerPanel", "debug_toolbar.panels.logging.LoggingPanel", "debug_toolbar.panels.profiling.ProfilingPanel", ], settings={"REFRESH_INTERVAL": 5000}, ) except ImportError: import logging logging.getLogger(__name__).warning( "DEBUG=1 but fastapi-debug-toolbar not installed; toolbar disabled" ) ``` The `DuckDBPanel` is added in Task 8, not here. Also adjust the `app = FastAPI(...)` call to honor `DEBUG`: ```python app = FastAPI(debug=DEBUG, ...existing kwargs...) ``` - [ ] **Step 6.4: Manual browser verification** ```bash DEBUG=1 uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 3 ``` Use `playwright-cli` for verification (per `browser-automation.md`): ```bash playwright-cli -s=$PILOT_SESSION_ID open http://localhost:8011/dashboard playwright-cli -s=$PILOT_SESSION_ID snapshot # Expect to see a "djdt" or toolbar tab in the accessibility tree playwright-cli -s=$PILOT_SESSION_ID screenshot /tmp/toolbar.png playwright-cli -s=$PILOT_SESSION_ID close kill $APP_PID ``` Expected: screenshot shows toolbar tab on right edge of the page. If the route requires auth, set `LOCAL_DEV_MODE=1` too. If you don't have playwright-cli, use this curl + grep verification: ```bash DEBUG=1 uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 3 LOCAL_DEV_MODE=1 curl -s http://localhost:8011/dashboard 2>/dev/null | grep -i "fastDebug\|debug.*toolbar" | head -3 kill $APP_PID ``` Expected: at least one match showing the toolbar HTML was injected. - [ ] **Step 6.5: Verify toolbar is OFF without DEBUG** ```bash uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 3 curl -s http://localhost:8011/dashboard 2>/dev/null | grep -ci "fastDebug" || echo "no toolbar (correct)" kill $APP_PID ``` Expected: `no toolbar (correct)`. - [ ] **Step 6.6: Verify under `docker compose up`** Compose runs uvicorn under a non-TTY stdout, which is the case `force_terminal=True` exists for. This step catches docker-only failure modes that uvicorn-direct misses (env propagation, log multiplexing). ```bash DEBUG=1 LOCAL_DEV_MODE=1 docker compose up -d app sleep 8 docker compose logs --tail=30 app | grep -iE "rich|debug|toolbar|color" || true docker compose exec -T app sh -lc 'python -c "import os; print(\"DEBUG=\", os.environ.get(\"DEBUG\"))"' curl -s http://localhost:8000/health -i 2>/dev/null | head -5 docker compose down ``` Expected: - Compose log lines appear with ANSI color codes (e.g. `\e[`) — proves `force_terminal=True` is doing its job. - The python eval prints `DEBUG= 1`. - `/health` returns 200 with `x-request-id` header. If colors are missing, `force_terminal=True` is being overridden — check whether the compose service has `tty: true` set, or whether stdout is being piped through a process that strips ANSI. - [ ] **Step 6.7: Run the full test suite** ```bash uv run pytest -q ``` Expected: all tests pass. - [ ] **Step 6.8: Commit** ```bash git add pyproject.toml app/main.py uv.lock git commit -m "feat(dev): mount fastapi-debug-toolbar behind DEBUG=1" ``` --- ## Task 7: `InstrumentedConnection` — Phase P6 (part 1) **Files:** - Create: `app/debug/__init__.py` (empty) - Create: `app/debug/duckdb_panel.py` (just `InstrumentedConnection` and helpers — `DuckDBPanel` itself comes in Task 8) - Test: `tests/test_duckdb_panel.py` - [ ] **Step 7.1: Create empty package marker** ```bash mkdir -p app/debug/templates/panels : > app/debug/__init__.py ``` - [ ] **Step 7.2: Write the failing tests** Create `tests/test_duckdb_panel.py`: ```python import duckdb import pytest from app.debug.duckdb_panel import ( InstrumentedConnection, _request_store, get_request_store, record_query, ) @pytest.fixture def store_token(): """Provide a fresh request-scoped store.""" token = _request_store.set([]) yield _request_store.reset(token) @pytest.fixture def conn(): return duckdb.connect(":memory:") def test_records_query(store_token, conn): inst = InstrumentedConnection(conn, "system") inst.execute("SELECT 1") store = get_request_store() assert len(store) == 1 q = store[0] assert q.db == "system" assert q.sql == "SELECT 1" assert q.error is None assert q.ms >= 0 def test_records_query_with_params(store_token, conn): inst = InstrumentedConnection(conn, "analytics") inst.execute("SELECT $1::INT", [42]) store = get_request_store() assert len(store) == 1 assert store[0].params == [42] def test_records_error(store_token, conn): inst = InstrumentedConnection(conn, "system") with pytest.raises(duckdb.Error): inst.execute("SELECT FROM bogus_syntax") store = get_request_store() assert len(store) == 1 assert store[0].error is not None assert "bogus_syntax" in store[0].error or "syntax" in store[0].error.lower() def test_db_tag_preserved(store_token): a = duckdb.connect(":memory:") b = duckdb.connect(":memory:") InstrumentedConnection(a, "system").execute("SELECT 1") InstrumentedConnection(b, "analytics").execute("SELECT 2") store = get_request_store() assert {q.db for q in store} == {"system", "analytics"} def test_no_op_outside_request(conn): """When _request_store is None (outside a debug request), do not raise.""" assert get_request_store() is None inst = InstrumentedConnection(conn, "system") inst.execute("SELECT 1") # must not raise assert get_request_store() is None def test_passthrough_attributes(conn): """Wrapper must delegate non-execute methods to the real connection.""" inst = InstrumentedConnection(conn, "system") inst.execute("CREATE TABLE t (x INT)") inst.execute("INSERT INTO t VALUES (1), (2), (3)") rows = inst.execute("SELECT x FROM t ORDER BY x").fetchall() assert rows == [(1,), (2,), (3,)] def test_cursor_returns_instrumented(store_token, conn): """A cursor() call returns an InstrumentedConnection wrapping the real cursor.""" inst = InstrumentedConnection(conn, "system") cur = inst.cursor() assert isinstance(cur, InstrumentedConnection) cur.execute("SELECT 99") store = get_request_store() assert len(store) == 1 assert store[0].db == "system" def test_record_query_no_op_when_store_none(): """record_query is safe to call when no store is set.""" record_query("system", "SELECT 1", None, 0.0, None) # must not raise ``` - [ ] **Step 7.3: Run tests, verify they fail** ```bash uv run pytest tests/test_duckdb_panel.py -q ``` Expected: `ImportError: cannot import name 'InstrumentedConnection'`. - [ ] **Step 7.4: Implement `InstrumentedConnection` and helpers** Create `app/debug/duckdb_panel.py` (panel class will be added in Task 8): ```python """Per-request DuckDB query capture for the dev debug toolbar. `InstrumentedConnection` wraps a `duckdb.DuckDBPyConnection` and records every `.execute()` call into a contextvar-scoped store. The toolbar's `DuckDBPanel` reads that store at response time. When the contextvar is unset (outside a debug request, or in prod), all recording is a no-op — `_maybe_instrument()` in `src/db.py` returns the raw connection, so the wrapper isn't even constructed in prod paths. """ from __future__ import annotations import contextvars import time from dataclasses import dataclass from typing import Any import duckdb @dataclass class Query: db: str 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 the current request's query buffer, or None outside a debug request.""" 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: 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: Any, **kwargs: Any): started = time.perf_counter() err: str | None = None result = None try: if params is not None: result = self._real.execute(sql, params, *args, **kwargs) else: result = 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 cursor(self) -> "InstrumentedConnection": return InstrumentedConnection(self._real.cursor(), self._db) def __getattr__(self, name: str) -> Any: return getattr(self._real, name) ``` - [ ] **Step 7.5: Run tests, verify they pass** ```bash uv run pytest tests/test_duckdb_panel.py -q ``` Expected: all 8 tests pass. - [ ] **Step 7.6: Run quality checks** ```bash ruff format app/debug/ tests/test_duckdb_panel.py ruff check app/debug/ tests/test_duckdb_panel.py --fix ``` - [ ] **Step 7.7: Commit** ```bash git add app/debug/__init__.py app/debug/duckdb_panel.py tests/test_duckdb_panel.py git commit -m "feat(debug): add InstrumentedConnection for per-request DuckDB query capture" ``` --- ## Task 8: `DuckDBPanel` + Jinja template + integration in `src/db.py` — Phase P6 (part 2) **Files:** - Modify: `app/debug/duckdb_panel.py` (append `DuckDBPanel` class) - Create: `app/debug/templates/panels/duckdb.html` - Modify: `src/db.py` (add `_maybe_instrument`; wrap returns) - Modify: `app/main.py` (add `app.debug.duckdb_panel.DuckDBPanel` to the panels list) - Test: `tests/test_toolbar_integration.py` - [ ] **Step 8.1: Append `DuckDBPanel` to `app/debug/duckdb_panel.py`** Add at the bottom of the file: ```python # Toolbar Panel (only imported when DEBUG=1, so import inside the class block) try: from debug_toolbar.panels import Panel from debug_toolbar.types import ServerTiming, Stats class DuckDBPanel(Panel): """fastapi-debug-toolbar panel rendering captured DuckDB queries.""" title = "DuckDB" template = "panels/duckdb.html" @property def nav_subtitle(self) -> str: store = get_request_store() or [] total_ms = sum(q.ms for q in store) return f"{len(store)} queries · {total_ms:.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))] except ImportError: # fastapi-debug-toolbar not installed in this environment (e.g. prod). # The class is only referenced when DEBUG=1, where the dep is required. DuckDBPanel = None # type: ignore[assignment, misc] ``` - [ ] **Step 8.2: Create the Jinja template** Create `app/debug/templates/panels/duckdb.html`: ```jinja

DuckDB queries — {{ stats.queries|length }} ({{ "%.1f"|format(stats.total_ms) }} ms total)

{% if stats.by_db %}

By DB: {% for db, ms in stats.by_db.items() %}{{ db }}: {{ "%.1f"|format(ms) }} ms{% if not loop.last %} · {% endif %}{% endfor %}

{% endif %} {% for q in stats.queries %} {% endfor %}
#DBmsrowsSQLparams
{{ 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 }}
``` - [ ] **Step 8.3: Wrap connections in `src/db.py`** The file has three `duckdb.connect()` call sites: lines 413, 423, 581, 587. The three public helpers return: - `get_system_db()` — returns `_system_db_conn.cursor()` (line 416). Wrap the cursor. - `get_analytics_db()` — returns the connection directly (line 423). Wrap it. - `get_analytics_db_readonly()` — two return paths (lines 586, 587 onward). Wrap both. Add this helper near the top of `src/db.py` (just after the imports): ```python import os as _os_for_debug _DEBUG = _os_for_debug.environ.get("DEBUG", "").lower() in ("1", "true", "yes") def _maybe_instrument(con, db_tag: str): """Wrap a duckdb connection with InstrumentedConnection when DEBUG=1, else return as-is.""" if not _DEBUG: return con from app.debug.duckdb_panel import InstrumentedConnection return InstrumentedConnection(con, db_tag) ``` Then modify each helper: **`get_system_db()`** at line 394 — the `return _system_db_conn.cursor()` becomes: ```python return _maybe_instrument(_system_db_conn.cursor(), "system") ``` **`get_analytics_db()`** at line 419 — the `return duckdb.connect(str(db_path))` becomes: ```python return _maybe_instrument(duckdb.connect(str(db_path)), "analytics") ``` **`get_analytics_db_readonly()`** at line 573 — both return statements (line 586 with `return conn` and line 587 with `conn = duckdb.connect(str(db_path), read_only=True)` followed by ATTACH + final `return conn`) get the wrap applied at the **final** return only: The function ends with something like `return conn` after ATTACH loop. Replace that final `return conn` with `return _maybe_instrument(conn, "analytics_ro")`. For the early-return path (lines 581–586) where the file doesn't exist: replace the early `return conn` with `return _maybe_instrument(conn, "analytics_ro")`. ```bash sed -n '580,605p' src/db.py # verify exact return sites before editing ``` - [ ] **Step 8.4: Mount `DuckDBPanel` in `app/main.py`** Edit the panel list created in Task 6.3. Add the DuckDBPanel string to the list: ```python panels=[ "debug_toolbar.panels.headers.HeadersPanel", "debug_toolbar.panels.routes.RoutesPanel", "debug_toolbar.panels.settings.SettingsPanel", "debug_toolbar.panels.versions.VersionsPanel", "debug_toolbar.panels.timer.TimerPanel", "debug_toolbar.panels.logging.LoggingPanel", "debug_toolbar.panels.profiling.ProfilingPanel", "app.debug.duckdb_panel.DuckDBPanel", ], ``` The toolbar resolves panels by import string. Confirm `app/debug/duckdb_panel.py` exposes `DuckDBPanel` at module level (it does — see Step 8.1). - [ ] **Step 8.5: Configure Jinja template lookup** The toolbar needs to find `panels/duckdb.html`. Wire the template path by adding a conftest-style env or by passing the path to the middleware via `settings`. The simplest path: copy/symlink the template into the toolbar's expected lookup. Verify by reading `fastapi-debug-toolbar` docs: ```bash uv run python -c "import debug_toolbar.middleware as m; import inspect; print(inspect.getfile(m))" ``` Then check how panels with custom templates register lookup paths. If the toolbar uses Jinja `PackageLoader`, set `template = "panels/duckdb.html"` AND ensure `app/debug/templates/` is on its lookup path. The standard way (per upstream) is to set the `template` attribute and rely on the panel's own package; since `DuckDBPanel` lives in `app.debug.duckdb_panel`, the toolbar should resolve `app/debug/templates/panels/duckdb.html` automatically via package introspection. If the toolbar fails to find the template, add this class attribute on `DuckDBPanel`: ```python template_dirs = ["app/debug/templates"] ``` Verify by hitting an HTML route with `DEBUG=1` and inspecting the rendered toolbar HTML. - [ ] **Step 8.6: Write the integration tests** Create `tests/test_toolbar_integration.py`: ```python import os import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @pytest.fixture def reset_logging_state(): import app.logging_config as lc lc._CONFIGURED = False yield lc._CONFIGURED = False import logging logging.getLogger().handlers.clear() @pytest.fixture def app_with_toolbar(monkeypatch, reset_logging_state): monkeypatch.setenv("DEBUG", "1") # Reload app.main fresh so DEBUG gate is honored import importlib import app.main as main_mod importlib.reload(main_mod) return main_mod.app @pytest.fixture def app_no_toolbar(monkeypatch, reset_logging_state): monkeypatch.delenv("DEBUG", raising=False) import importlib import app.main as main_mod importlib.reload(main_mod) return main_mod.app @pytest.mark.integration def test_toolbar_html_present_when_debug(app_with_toolbar): client = TestClient(app_with_toolbar) # Hit any HTML response endpoint (the dashboard or login page) resp = client.get("/dashboard", follow_redirects=False) if resp.status_code in (302, 401): pytest.skip("Dashboard requires auth in this env; visual verify covered separately") assert resp.status_code == 200 body = resp.text.lower() assert "djdt" in body or "fastdebug" in body, "Toolbar markup not injected" @pytest.mark.integration def test_no_toolbar_when_debug_off(app_no_toolbar): client = TestClient(app_no_toolbar) resp = client.get("/dashboard", follow_redirects=False) if resp.status_code in (302, 401): return body = resp.text.lower() assert "djdt" not in body assert "fastdebug" not in body @pytest.mark.integration def test_request_id_header_always_present(app_no_toolbar): client = TestClient(app_no_toolbar) resp = client.get("/health") assert "x-request-id" in resp.headers ``` - [ ] **Step 8.7: Run integration tests** ```bash uv run pytest tests/test_toolbar_integration.py -q ``` Expected: tests pass (or skip when auth required). If `dashboard` is hidden behind login, test against another HTML route (`/login` or `/setup`) — adjust the fixture to a route known to render HTML without auth. - [ ] **Step 8.8: Manual browser verification with playwright-cli** ```bash DEBUG=1 LOCAL_DEV_MODE=1 uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 4 playwright-cli -s=$PILOT_SESSION_ID open http://localhost:8011/admin/access playwright-cli -s=$PILOT_SESSION_ID snapshot playwright-cli -s=$PILOT_SESSION_ID screenshot /tmp/toolbar-duckdb.png playwright-cli -s=$PILOT_SESSION_ID close kill $APP_PID ls -la /tmp/toolbar-duckdb.png ``` Open `/tmp/toolbar-duckdb.png` (or read with the Read tool — it accepts images). Confirm: - Toolbar tab visible on right edge - Clicking opens panels list including **DuckDB** - DuckDB panel shows queries with `system` / `analytics_ro` tags If toolbar tab not visible: check uvicorn logs for `fastapi-debug-toolbar not installed` warning or template-loader errors. - [ ] **Step 8.9: Run the full test suite** ```bash uv run pytest -q --cov=app.logging_config --cov=app.middleware --cov=app.debug --cov-fail-under=80 ``` Expected: tests pass and coverage on the new modules ≥ 80%. - [ ] **Step 8.10: Verify DuckDBPanel under `docker compose up`** ```bash DEBUG=1 LOCAL_DEV_MODE=1 docker compose up -d app sleep 8 # Trigger a request that hits the DB curl -s http://localhost:8000/dashboard -o /tmp/dash.html docker compose logs --tail=40 app | head -40 grep -i "duckdb\|djdt" /tmp/dash.html | head -5 docker compose down ``` Expected: `dash.html` contains the toolbar HTML with the DuckDB panel slot. Logs show DB queries when DEBUG=1 (debug-level log output captures execute calls indirectly through any `logger.debug` in src/db.py). - [ ] **Step 8.11: Run quality checks** ```bash ruff format app/ src/db.py tests/test_toolbar_integration.py ruff check app/ src/db.py tests/test_toolbar_integration.py --fix ``` - [ ] **Step 8.12: Commit** ```bash git add app/debug/ src/db.py app/main.py tests/test_toolbar_integration.py git commit -m "feat(debug): add DuckDBPanel rendering per-request queries with db tag" ``` --- ## Task 9: Documentation, `.env.template`, CHANGELOG — Phase P7 **Files:** - Modify: `config/.env.template` (or `.env.template` at repo root — verify location) - Create: `docs/development.md` - Modify: `CHANGELOG.md` - [ ] **Step 9.1: Locate the env template** ```bash ls -la config/.env.template .env.template 2>/dev/null ``` The CLAUDE.md references `config/.env.template`. Verify path before editing. - [ ] **Step 9.2: Document `DEBUG=1` in env template** Append to `config/.env.template` (or wherever it lives): ```bash # === Local development === # DEBUG=1 enables: # - FastAPI debug=True (richer 500 pages) # - rich.logging.RichHandler (colored, with tracebacks) # - fastapi-debug-toolbar mounted at right edge of HTML pages # - DuckDB query capture in the toolbar # Never set in production. Keep separate from LOCAL_DEV_MODE (auth bypass). # DEBUG=1 ``` - [ ] **Step 9.3: Create developer docs** Create `docs/development.md`: ```markdown # Development guide ## Logging All processes (FastAPI app, scheduler, telegram_bot, ws_gateway, corporate_memory, session_collector, CLI scripts) use `app.logging_config.setup_logging` to configure the root logger. Each entrypoint calls it once: ```python from app.logging_config import setup_logging setup_logging(__name__) ``` Library modules just do `logger = logging.getLogger(__name__)` — they never configure root. | Env | Handler | Format | |-----|---------|--------| | `DEBUG=1` | `rich.logging.RichHandler` | colored, clickable file:line, pretty tracebacks | | (default) | stdlib `StreamHandler` | JSON to stderr (`ts`, `lvl`, `logger`, `service`, `msg`, optional `request_id`) | `LOG_LEVEL` overrides the level (default `DEBUG` when `DEBUG=1`, else `INFO`). ## Request correlation `RequestIdMiddleware` is mounted unconditionally on the FastAPI app. It assigns or propagates `X-Request-ID` and exposes it via the `request_id_var` ContextVar so the JSON formatter and the debug-toolbar logging panel see the same id. ## Debug toolbar Set `DEBUG=1` to mount `fastapi-debug-toolbar`. Visit any HTML page (e.g. `/admin/access`, `/dashboard`, `/login`) and click the small toolbar tab on the right edge. Panels: - **Headers** — request/response headers - **Routes** — registered FastAPI routes - **Settings** — Pydantic / instance-config values - **Versions** — installed package versions - **Timer** — request duration - **Logging** — log records emitted during the request - **Profiling** — `pyinstrument` flame graph - **DuckDB** — every `con.execute(sql, params)` from `src/db.py`, tagged by `system` / `analytics` / `analytics_ro`, with timing and row count JSON-only endpoints (Swagger UI at `/docs`) replay the most recent request's panels via a cookie-based mechanism. The toolbar is **never imported in production**: the import is gated by `if DEBUG: ...` in `app/main.py`, and `fastapi-debug-toolbar` lives only in the dev optional-dependency group. ## Running locally ```bash uv pip install ".[dev]" DEBUG=1 LOCAL_DEV_MODE=1 uv run uvicorn app.main:app --reload --port 8000 ``` Open `http://localhost:8000/dashboard`. ``` - [ ] **Step 9.4: Update CHANGELOG.md** Read the top of `CHANGELOG.md`: ```bash sed -n '1,30p' CHANGELOG.md ``` If a `## [Unreleased]` heading exists at the top, add bullets under it. If not, create one above the topmost released version. Add under `### Added`: ```markdown ## [Unreleased] ### Added - Dev debug toolbar gated by `DEBUG=1`. Mounts `fastapi-debug-toolbar` with panels for headers, routes, settings, versions, timer, logging, profiling, and a custom DuckDB panel that captures every `con.execute()` from `src/db.py` (tagged by `system` / `analytics` / `analytics_ro`). See `docs/development.md`. - `X-Request-ID` request header / response header on every FastAPI response, plus a `request_id` field in JSON logs for cross-process correlation. - Centralized `app.logging_config.setup_logging()` — replaces 23 scattered `logging.basicConfig(...)` calls. Uses `rich.logging.RichHandler` in dev (`DEBUG=1`) and JSON to stderr in prod. ### Changed - All service entrypoints (`services/scheduler/__main__.py`, `ws_gateway`, `telegram_bot`, `corporate_memory`, `session_collector`, `verification_detector`) and CLI scripts under `scripts/` and `connectors/jira/scripts/` now call `setup_logging(__name__)` instead of inline `basicConfig`. Library modules no longer configure root logger at import time. ### Fixed - Removed rogue module-level `logging.basicConfig` from `app/api/sync.py` that was reconfiguring root logger every time the api module was imported. ``` - [ ] **Step 9.5: Run the full test suite one more time** ```bash uv run pytest -q ``` Expected: all tests pass. - [ ] **Step 9.6: Final verification** Confirm no remaining `basicConfig` outside tests: ```bash grep -rEn 'logging\.basicConfig|_logging\.basicConfig' \ src/ app/ services/ connectors/ scripts/ \ --include="*.py" | grep -v "tests/" || echo "CLEAN" ``` Expected: `CLEAN`. Confirm `DEBUG=1` smoke test: ```bash DEBUG=1 LOCAL_DEV_MODE=1 uv run uvicorn app.main:app --port 8011 & APP_PID=$! sleep 4 curl -s http://localhost:8011/health -i 2>/dev/null | head -5 kill $APP_PID ``` Expected: response with `x-request-id` header. Server log line is rich-colored. - [ ] **Step 9.7: Commit** ```bash git add config/.env.template docs/development.md CHANGELOG.md git commit -m "docs: document DEBUG=1 toggle and centralized logging" ``` - [ ] **Step 9.8: Push branch** ```bash git push -u origin vr/dev-logging ``` Open a PR via `gh pr create` (out of scope for this plan — the user runs PR creation separately). --- ## Self-Review Checklist (already applied during plan writing) - [x] **Spec coverage:** every section in the spec maps to one of Tasks 1–9. - logging_config + RichHandler → Task 1 - RequestIdMiddleware → Tasks 2–3 - migration of 23 basicConfig calls → Tasks 3, 4, 5 - DuckDBPanel + InstrumentedConnection → Tasks 7–8 - production safeguards (DEBUG gate, dev-only dep) → Task 6 - docs / CHANGELOG / .env.template → Task 9 - [x] **Placeholder scan:** no TBD, TODO, or "implement later" left in the plan. - [x] **Type consistency:** `Query` dataclass used in tests and in `DuckDBPanel.generate_stats`; `InstrumentedConnection.cursor()` returns `InstrumentedConnection` (matches test). - [x] **Files-not-to-modify rule:** plan does NOT touch `connectors/jira/file_lock.py`, `connectors/jira/transform.py` is library-level — only the `basicConfig` line is removed (plan respects "core logic" stability), `services/ws_gateway/` is touched only at the entrypoint `gateway.py:214` for the `basicConfig` swap (no logic change). --- **Plan complete and saved to `docs/superpowers/plans/2026-04-29-dev-debug-toolbar.md`.**