Skip to content

Commit c292332

Browse files
feat: add configurable logging levels (fixes #584) (#1066)
* feat: add configurable logging levels (fixes #584) * fix: remove unused error_classification_middleware import * fix: correct import ordering and formatting in main.py --------- Co-authored-by: Darshan G K <122042809+imDarshanGK@users.noreply.github.com>
1 parent 2aaa0fd commit c292332

6 files changed

Lines changed: 353 additions & 3 deletions

File tree

.env.example

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,18 @@ EMAIL_FROM=noreply@example.com
5858

5959
# Digest / Notification System
6060
DIGEST_ENABLED=false
61-
DIGEST_BASE_URL=http://localhost:8000
61+
DIGEST_BASE_URL=http://localhost:8000
62+
# ── Logging ───────────────────────────────────────────────────
63+
# Global default level applied to all backend components.
64+
# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL
65+
LOG_LEVEL=INFO
66+
# Optional per-component overrides — uncomment to enable verbose logs for
67+
# just one part of the system without changing the global default.
68+
# See backend/app/logging_config.py for the full list of component names.
69+
# LOG_LEVEL_AI_PROVIDER=DEBUG
70+
# LOG_LEVEL_SCHEDULER=WARNING
71+
# LOG_LEVEL_CACHE=DEBUG
72+
# LOG_LEVEL_EMAIL=DEBUG
73+
# LOG_LEVEL_UPLOAD=DEBUG
74+
LOG_FORMAT=%(asctime)s %(levelname)s %(name)s: %(message)s
75+
LOG_JSON=false

backend/app/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ class Settings:
7070
llm_timeout_seconds: int = _int_env("LLM_TIMEOUT_SECONDS", 30)
7171
llm_max_retries: int = _int_env("LLM_MAX_RETRIES", 3)
7272
llm_retry_backoff: float = _float_env("LLM_RETRY_BACKOFF", 1.0)
73+
# ── Logging ──────────────────────────────────────────────────
74+
# Global default level applied to the "app" logger tree.
75+
log_level: str = os.getenv("LOG_LEVEL", "INFO")
76+
# Optional per-component overrides, e.g.:
77+
# LOG_LEVEL_AI_PROVIDER=DEBUG
78+
# LOG_LEVEL_SCHEDULER=WARNING
79+
# LOG_LEVEL_CACHE=DEBUG
80+
# See logging_config.py for the full list of supported component names.
81+
log_format: str = os.getenv(
82+
"LOG_FORMAT", "%(asctime)s %(levelname)s %(name)s: %(message)s"
83+
)
84+
log_json: bool = _bool_env("LOG_JSON", False)
7385

7486
# ── Email / Digest ──────────────────────────────────────────
7587
smtp_host: str = os.getenv("SMTP_HOST", "")

backend/app/logging_config.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
Centralized logging configuration for QyverixAI backend.
3+
4+
Supports a global default log level plus per-component overrides, all
5+
configurable via environment variables — no code changes required to
6+
change verbosity in production or while debugging a specific module.
7+
8+
Usage
9+
-----
10+
LOG_LEVEL=INFO # global default for everything under "app"
11+
LOG_LEVEL_AI_PROVIDER=DEBUG # verbose logs only for ai_provider.py
12+
LOG_LEVEL_SCHEDULER=WARNING # quiet down the scheduler
13+
LOG_LEVEL_CACHE=DEBUG
14+
LOG_FORMAT="%(asctime)s %(levelname)s %(name)s: %(message)s"
15+
LOG_JSON=false # set true for structured JSON logs
16+
17+
Call ``configure_logging()`` once at application startup (already wired
18+
into ``main.py``'s lifespan). Each module continues to use the standard
19+
``logging.getLogger(__name__)`` pattern — no per-module code changes are
20+
needed for this feature to take effect.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import logging
27+
import logging.config
28+
import os
29+
from datetime import UTC, datetime
30+
31+
from .config import settings
32+
33+
# Maps a short, human-friendly component name (used in the
34+
# LOG_LEVEL_<COMPONENT> env var) to the actual logger name used in the
35+
# codebase via logging.getLogger(__name__) or logging.getLogger("...").
36+
#
37+
# Add a new entry here whenever a new component should support its own
38+
# independent log level.
39+
COMPONENT_LOGGER_MAP: dict[str, str] = {
40+
"api": "ai_assistant.api",
41+
"ai_provider": "ai_provider",
42+
"llm_analysis": "ai_assistant.api",
43+
"cache": "ai_assistant.api",
44+
"scheduler": "app.services.scheduler",
45+
"email": "app.services.email_service",
46+
"error_tracking": "ai_assistant.api",
47+
"upload": "app.routers.upload_file",
48+
"file_validator": "app.utils.file_validator",
49+
"main": "app.main",
50+
}
51+
52+
_VALID_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
53+
54+
55+
def _normalise_level(raw: str | None, fallback: str) -> str:
56+
"""Validate a level string, falling back to ``fallback`` if invalid."""
57+
if not raw:
58+
return fallback
59+
level = raw.strip().upper()
60+
return level if level in _VALID_LEVELS else fallback
61+
62+
63+
def _collect_component_overrides() -> dict[str, str]:
64+
"""Read all ``LOG_LEVEL_<COMPONENT>`` environment variables.
65+
66+
Returns a mapping of actual logger name -> level string, ready to be
67+
merged into the logging dictConfig.
68+
"""
69+
overrides: dict[str, str] = {}
70+
for component, logger_name in COMPONENT_LOGGER_MAP.items():
71+
env_key = f"LOG_LEVEL_{component.upper()}"
72+
raw_value = os.getenv(env_key)
73+
if raw_value is None:
74+
continue
75+
level = _normalise_level(raw_value, settings.log_level)
76+
overrides[logger_name] = level
77+
return overrides
78+
79+
80+
class _JsonFormatter(logging.Formatter):
81+
"""Minimal structured JSON log formatter (opt-in via LOG_JSON=true)."""
82+
83+
def format(self, record: logging.LogRecord) -> str:
84+
payload = {
85+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
86+
"level": record.levelname,
87+
"logger": record.name,
88+
"message": record.getMessage(),
89+
}
90+
if record.exc_info:
91+
payload["exception"] = self.formatException(record.exc_info)
92+
return json.dumps(payload)
93+
94+
95+
def get_effective_levels() -> dict[str, str]:
96+
"""Return the resolved log level for every known component.
97+
98+
Useful for the /health or /metrics endpoints, and for tests, to verify
99+
the configuration that was actually applied.
100+
"""
101+
default_level = _normalise_level(settings.log_level, "INFO")
102+
overrides = _collect_component_overrides()
103+
resolved: dict[str, str] = {}
104+
for component, logger_name in COMPONENT_LOGGER_MAP.items():
105+
resolved[component] = overrides.get(logger_name, default_level)
106+
return resolved
107+
108+
109+
def configure_logging() -> None:
110+
"""Apply global + per-component logging configuration.
111+
112+
Safe to call multiple times (e.g. in tests) — each call fully replaces
113+
the previous logging configuration via dictConfig's incremental=False
114+
default, avoiding duplicate handlers.
115+
"""
116+
default_level = _normalise_level(settings.log_level, "INFO")
117+
overrides = _collect_component_overrides()
118+
119+
formatter_name = "json" if settings.log_json else "standard"
120+
121+
loggers_config: dict[str, dict] = {
122+
"app": {
123+
"level": default_level,
124+
"handlers": ["console"],
125+
"propagate": False,
126+
}
127+
}
128+
# ai_assistant.api and ai_provider are historical logger names used
129+
# directly (not nested under "app"), so they need explicit entries too.
130+
for logger_name in set(COMPONENT_LOGGER_MAP.values()):
131+
if logger_name.startswith("app."):
132+
continue
133+
loggers_config[logger_name] = {
134+
"level": overrides.get(logger_name, default_level),
135+
"handlers": ["console"],
136+
"propagate": False,
137+
}
138+
139+
# Apply per-component overrides onto the "app.*" tree.
140+
for logger_name, level in overrides.items():
141+
if logger_name.startswith("app."):
142+
loggers_config[logger_name] = {
143+
"level": level,
144+
"handlers": ["console"],
145+
"propagate": False,
146+
}
147+
148+
logging_dict_config = {
149+
"version": 1,
150+
"disable_existing_loggers": False,
151+
"formatters": {
152+
"standard": {
153+
"format": settings.log_format,
154+
},
155+
"json": {
156+
"()": _JsonFormatter,
157+
},
158+
},
159+
"handlers": {
160+
"console": {
161+
"class": "logging.StreamHandler",
162+
"formatter": formatter_name,
163+
},
164+
},
165+
"root": {
166+
"level": default_level,
167+
"handlers": ["console"],
168+
},
169+
"loggers": loggers_config,
170+
}
171+
172+
logging.config.dictConfig(logging_dict_config)
173+
174+
summary_logger = logging.getLogger("app.logging_config")
175+
if overrides:
176+
override_summary = ", ".join(f"{k}={v}" for k, v in overrides.items())
177+
summary_logger.info(
178+
"Logging configured — default=%s, overrides: %s",
179+
default_level,
180+
override_summary,
181+
)
182+
else:
183+
summary_logger.info(
184+
"Logging configured — default=%s, no overrides", default_level
185+
)

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from fastapi.responses import JSONResponse
1616
from fastapi.staticfiles import StaticFiles
1717

18+
from .logging_config import configure_logging
1819
from .observability import initialise_app_info, prometheus_metrics_middleware
1920
from .routers import admin, analyze, auth, chat, collaboration, debugging, explanation
2021
from .routers import health as health_router
@@ -54,6 +55,7 @@ def rate_limit_headers(remaining: int) -> dict[str, str]:
5455
# ── Lifespan ──────────────────────────────────────────────────────────────────
5556
@asynccontextmanager
5657
async def lifespan(app: FastAPI):
58+
configure_logging()
5759
await database.init_db()
5860
print("🚀 QyverixAI backend starting…")
5961
initialise_app_info(

backend/app/routers/health.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
from sqlalchemy import text
2929

3030
from ..database import engine
31+
from ..logging_config import get_effective_levels
3132
from ..schemas import LivenessResponse, ReadinessResponse
3233

33-
3434
router = APIRouter(prefix="/healthz", tags=["System"])
3535

3636

@@ -64,7 +64,11 @@ def _check_database(timeout_seconds: float = 2.0) -> tuple[bool, str | None, flo
6464
conn.execute(text("SELECT 1"))
6565
return True, None, (time.perf_counter() - start) * 1000.0
6666
except Exception as exc: # noqa: BLE001 — we genuinely want every failure mode.
67-
return False, f"{type(exc).__name__}: {exc}", (time.perf_counter() - start) * 1000.0
67+
return (
68+
False,
69+
f"{type(exc).__name__}: {exc}",
70+
(time.perf_counter() - start) * 1000.0,
71+
)
6872

6973

7074
@router.get(
@@ -102,3 +106,21 @@ async def readiness(response: Response) -> ReadinessResponse:
102106
status="ok" if overall_ok else "degraded",
103107
checks=checks,
104108
)
109+
110+
111+
# ── Logging diagnostics ───────────────────────────────────────────────────────
112+
@router.get(
113+
"/log-levels",
114+
summary="Effective logging levels per component",
115+
description=(
116+
"Returns the currently active log level for each known backend "
117+
"component. Useful to confirm LOG_LEVEL / LOG_LEVEL_<COMPONENT> "
118+
"environment variables took effect after a deploy or restart. "
119+
"Logging levels are read at process startup — changing the level "
120+
"for a running process requires a restart, since Python's logging "
121+
"module is configured once via dictConfig in this app."
122+
),
123+
include_in_schema=False,
124+
)
125+
async def log_levels() -> dict[str, str]:
126+
return get_effective_levels()

0 commit comments

Comments
 (0)