CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release sections collapsed to one, stale v1->v35 schema history dropped (it lives in CHANGELOG), marketplace endpoint internals and verbose process sections moved out or tightened. New focused docs: - docs/RELEASING.md - release process, deploy workflows, CI quirks (RELEASE_TEMPLATE.md folded in as an appendix) - docs/marketplace.md - marketplace ingestion + re-serving internals - docs/README.md - documentation index by audience, linked from README.md and CLAUDE.md Archived under docs/archive/: docs/superpowers/ (52 historical planning artifacts), HACKATHON.md, pd-ps-comments.md, security-audit-2026-04.md, future/NOTIFICATIONS.md. Removed the docs/auto-install.md stub. Fixed dangling links in connectors/jira/README.md and dev_docs/README.md, repointed code/doc references to archived paths.
1811 lines
58 KiB
Markdown
1811 lines
58 KiB
Markdown
# 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' <file>
|
||
```
|
||
|
||
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
|
||
<h4>DuckDB queries — {{ stats.queries|length }} ({{ "%.1f"|format(stats.total_ms) }} ms total)</h4>
|
||
{% if stats.by_db %}
|
||
<p>By DB:
|
||
{% for db, ms in stats.by_db.items() %}<code>{{ db }}</code>: {{ "%.1f"|format(ms) }} ms{% if not loop.last %} · {% endif %}{% endfor %}
|
||
</p>
|
||
{% endif %}
|
||
<table class="djdt-table">
|
||
<thead>
|
||
<tr><th>#</th><th>DB</th><th>ms</th><th>rows</th><th>SQL</th><th>params</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for q in stats.queries %}
|
||
<tr class="{{ 'djdt-error' if q.error else '' }}">
|
||
<td>{{ loop.index }}</td>
|
||
<td>{{ q.db }}</td>
|
||
<td>{{ "%.2f"|format(q.ms) }}</td>
|
||
<td>{{ q.rows if q.rows is not none else '—' }}</td>
|
||
<td>
|
||
<pre>{{ q.sql }}</pre>
|
||
{% if q.error %}<div class="djdt-error">{{ q.error }}</div>{% endif %}
|
||
</td>
|
||
<td><code>{{ q.params }}</code></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
```
|
||
|
||
- [ ] **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`.**
|