From 7619604324e72e7b366b749648dcefb1cbcf153a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Wed, 11 Mar 2026 16:31:28 +0530 Subject: [PATCH] Harden logging config with safer overrides and optional JSON/file sinks --- backend/api/linkedin_image_generation.py | 4 +- backend/app.py | 4 +- backend/logging_config.py | 390 +++++++++++++----- backend/main.py | 4 +- .../services/llm_providers/gemini_provider.py | 5 - backend/start_alwrity_backend.py | 4 +- 6 files changed, 287 insertions(+), 124 deletions(-) diff --git a/backend/api/linkedin_image_generation.py b/backend/api/linkedin_image_generation.py index 0bf77270..1848c0bf 100644 --- a/backend/api/linkedin_image_generation.py +++ b/backend/api/linkedin_image_generation.py @@ -2,7 +2,6 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Depends from pydantic import BaseModel from typing import List, Optional, Dict, Any import json -import logging # Import our LinkedIn image generation services from services.linkedin.image_generation import LinkedInImageGenerator, LinkedInImageStorage @@ -11,8 +10,7 @@ from services.onboarding.api_key_manager import APIKeyManager from middleware.auth_middleware import get_current_user # Set up logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +from loguru import logger # Initialize router router = APIRouter(prefix="/api/linkedin", tags=["linkedin-image-generation"]) diff --git a/backend/app.py b/backend/app.py index 0b190fb9..15712d87 100644 --- a/backend/app.py +++ b/backend/app.py @@ -49,8 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback) load_dotenv() # CWD .env (fallback) # Set up clean logging for end users -from logging_config import setup_clean_logging -setup_clean_logging() +from logging_config import configure_logging +configure_logging(bootstrap_source="asgi-import") # Import middleware from middleware.auth_middleware import get_current_user diff --git a/backend/logging_config.py b/backend/logging_config.py index 539db62e..4e9bccd3 100644 --- a/backend/logging_config.py +++ b/backend/logging_config.py @@ -1,128 +1,298 @@ -""" -Logging configuration for ALwrity backend. -Provides clean logging setup for end users vs developers. -""" +"""Centralized, production-ready logging configuration for the ALwrity backend.""" +from __future__ import annotations + +import asyncio +import json import logging import os import sys +from typing import Dict, Optional, Tuple + from loguru import logger +_LOGGING_CONFIGURED = False -def setup_clean_logging(): - """Set up clean logging for end users.""" - verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" - - # Always remove all existing handlers first to prevent conflicts - logger.remove() - - if not verbose_mode: - # Suppress verbose logging for end users - be more aggressive - logging.getLogger('sqlalchemy.engine').setLevel(logging.CRITICAL) - logging.getLogger('sqlalchemy.pool').setLevel(logging.CRITICAL) - logging.getLogger('sqlalchemy.dialects').setLevel(logging.CRITICAL) - logging.getLogger('sqlalchemy.orm').setLevel(logging.CRITICAL) - logging.getLogger('sqlalchemy').setLevel(logging.CRITICAL) - logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.CRITICAL) - - # Suppress service initialization logs - logging.getLogger('services').setLevel(logging.WARNING) - logging.getLogger('api').setLevel(logging.WARNING) - logging.getLogger('models').setLevel(logging.WARNING) - - # Suppress specific noisy loggers - noisy_loggers = [ - 'linkedin_persona_service', - 'facebook_persona_service', - 'core_persona_service', - 'persona_analysis_service', - 'ai_service_manager', - 'ai_engine_service', - 'website_analyzer', - 'competitor_analyzer', - 'keyword_researcher', - 'content_gap_analyzer', - 'onboarding_data_service', - 'comprehensive_user_data', - 'strategy_data', - 'gap_analysis_data', - 'phase1_steps', - 'daily_schedule_generator', - 'gsc_service', - 'wordpress_oauth', - 'data_filter', - 'source_mapper', - 'grounding_engine', - 'blog_content_seo_analyzer', - 'linkedin_service', - 'citation_manager', - 'content_analyzer', - 'linkedin_prompt_generator', - 'linkedin_image_storage', - 'hallucination_detector', - 'writing_assistant', - 'onboarding_data_service', - 'enhanced_linguistic_analyzer', - 'persona_quality_improver', - 'logging_middleware', - 'exa_service', - 'step3_research_service', - 'sitemap_service', - 'linkedin_image_generator', - 'linkedin_prompt_generator', - 'linkedin_image_storage', - 'router_manager', - 'frontend_serving', - 'database', - 'user_business_info', - 'auth_middleware', - 'pricing_service', - 'create_billing_tables' - ] - - for logger_name in noisy_loggers: - logging.getLogger(logger_name).setLevel(logging.WARNING) - - # Configure loguru to be less verbose (only show warnings and errors) - def warning_only_filter(record): - return record["level"].name in ["WARNING", "ERROR", "CRITICAL"] +DEFAULT_LOG_OVERRIDES: Dict[str, str] = { + "sqlalchemy": "ERROR", + "sqlalchemy.engine": "ERROR", + "sqlalchemy.pool": "ERROR", + "uvicorn.access": "WARNING", + "watchfiles": "WARNING", + "httpx": "WARNING", + "urllib3": "WARNING", + "apscheduler": "INFO", +} - logger.add( - sys.stdout.write, - level="WARNING", - format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n", - filter=warning_only_filter - ) - # Add a focused sink to surface Story Video Generation INFO logs in console - def video_generation_filter(record): - msg = record.get("message", "") - name = record.get("name", "") - service = record.get("extra", {}).get("service") - return ( - "[StoryVideoGeneration]" in msg - or "services.story_writer.video_generation_service" in name - or "[video_gen]" in msg - or service == "video_generation_service" - or "services.llm_providers.main_video_generation" in name +VIDEO_SERVICE_NAMES = { + "video_generation_service", + "services.story_writer.video_generation_service", + "services.llm_providers.main_video_generation", +} + + +class InterceptHandler(logging.Handler): + """Forward standard-library logging records into Loguru sinks.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + stdlib_extra = { + key: value + for key, value in record.__dict__.items() + if key + not in { + "name", "msg", "args", "levelname", "levelno", "pathname", "filename", + "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", + "created", "msecs", "relativeCreated", "thread", "threadName", "processName", + "process", "message", "asctime" + } + } + + log = logger.bind(stdlib_logger=record.name, **stdlib_extra) + log.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _parse_level_overrides() -> Dict[str, str]: + overrides = dict(DEFAULT_LOG_OVERRIDES) + raw_overrides = os.getenv("ALWRITY_LOG_LEVEL_OVERRIDES", "").strip() + if not raw_overrides: + return overrides + + for pair in raw_overrides.split(","): + pair = pair.strip() + if not pair or "=" not in pair: + continue + logger_name, level = pair.split("=", 1) + logger_name = logger_name.strip() + level = level.strip().upper() + if logger_name and level: + overrides[logger_name] = level + + return overrides + + +def _resolve_log_level(level_name: str, default: int = logging.INFO) -> Tuple[int, bool]: + try: + return logging._checkLevel(level_name), True + except (TypeError, ValueError): + return default, False + + +def _apply_logger_overrides(verbose_mode: bool) -> None: + root_level = logging.DEBUG if verbose_mode else logging.INFO + logging.getLogger().setLevel(root_level) + + for logger_name, level_name in _parse_level_overrides().items(): + level_no, valid = _resolve_log_level(level_name) + if not valid: + logger.warning( + "Invalid log level override '{}' for logger '{}'; defaulting to INFO", + level_name, + logger_name, ) + logging.getLogger(logger_name).setLevel(level_no) + + +def _serialize_record(record: Dict) -> str: + payload = { + "time": record["time"].isoformat(), + "level": record["level"].name, + "name": record["name"], + "function": record["function"], + "line": record["line"], + "message": record["message"], + "extra": record.get("extra", {}), + } + if record.get("exception"): + payload["exception"] = str(record["exception"]) + return json.dumps(payload, default=str) + + +def _base_log_format(verbose_mode: bool) -> str: + if verbose_mode: + return ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "{name}:{function}:{line} | " + "rid={extra[request_id]} jid={extra[job_id]} uid={extra[user_id]} | " + "{message}" + ) + + return ( + "{time:HH:mm:ss} | " + "{level: <8} | " + "{name}:{line} | " + "{message}" + ) + + +def _patch_record(record: Dict) -> Dict: + extra = record.setdefault("extra", {}) + extra.setdefault("request_id", "-") + extra.setdefault("job_id", "-") + extra.setdefault("user_id", "-") + return record + + +def _video_generation_filter(record: Dict) -> bool: + message = record.get("message", "") + name = record.get("name", "") + service_name = record.get("extra", {}).get("service") + return ( + "[StoryVideoGeneration]" in message + or "[video_gen]" in message + or service_name in VIDEO_SERVICE_NAMES + or any(service in name for service in VIDEO_SERVICE_NAMES) + ) + + +def _configure_loguru_sinks(verbose_mode: bool) -> None: + logger.remove() + + logger.configure(patcher=_patch_record) + + log_json = _env_bool("ALWRITY_LOG_JSON", default=False) + console_format = _serialize_record if log_json else _base_log_format(verbose_mode) + + logger.add( + sys.stdout, + level="DEBUG" if verbose_mode else "WARNING", + format=console_format, + backtrace=True, + diagnose=verbose_mode, + enqueue=True, + ) + + enable_video_focus = _env_bool("ALWRITY_ENABLE_VIDEO_LOG_FOCUS", default=not verbose_mode) + if enable_video_focus and not verbose_mode: logger.add( - sys.stdout.write, + sys.stdout, level="INFO", - format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n", - filter=video_generation_filter + format=console_format, + filter=_video_generation_filter, + enqueue=True, ) - else: - # In verbose mode, show all log levels with detailed formatting + + log_file = os.getenv("ALWRITY_LOG_FILE", "").strip() + if log_file: logger.add( - sys.stdout.write, - level="DEBUG", - format="{time:HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}\n" + log_file, + level="DEBUG" if verbose_mode else "INFO", + format=console_format, + rotation=os.getenv("ALWRITY_LOG_ROTATION", "50 MB"), + retention=os.getenv("ALWRITY_LOG_RETENTION", "14 days"), + enqueue=True, + backtrace=True, + diagnose=verbose_mode, ) - + + +def _configure_stdlib_intercept(verbose_mode: bool) -> None: + intercept_handler = InterceptHandler() + root_logger = logging.getLogger() + root_logger.handlers = [intercept_handler] + root_logger.setLevel(logging.DEBUG if verbose_mode else logging.INFO) + + for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "gunicorn", "gunicorn.error"): + target_logger = logging.getLogger(name) + target_logger.handlers = [intercept_handler] + target_logger.propagate = False + + logging.captureWarnings(True) + + +def _register_exception_hooks() -> None: + def _excepthook(exc_type, exc_value, exc_traceback): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + logger.opt(exception=(exc_type, exc_value, exc_traceback)).critical("Uncaught exception") + + def _async_exception_handler(loop, context): + exc = context.get("exception") + if exc: + logger.opt(exception=exc).error("Unhandled asyncio exception") + else: + logger.error("Unhandled asyncio exception: {}", context.get("message", context)) + + sys.excepthook = _excepthook + + try: + loop = asyncio.get_running_loop() + loop.set_exception_handler(_async_exception_handler) + except RuntimeError: + pass + + +def configure_logging(*, verbose_mode: Optional[bool] = None, force: bool = False, bootstrap_source: str = "unknown") -> bool: + """Configure Loguru + stdlib logging in one place. + + Environment variables: + - ALWRITY_VERBOSE=true|false + - ALWRITY_LOG_LEVEL_OVERRIDES="sqlalchemy=ERROR,uvicorn.access=WARNING" + - ALWRITY_ENABLE_VIDEO_LOG_FOCUS=true|false + - ALWRITY_LOG_JSON=true|false + - ALWRITY_LOG_FILE=/path/to/backend.log + - ALWRITY_LOG_ROTATION=50 MB + - ALWRITY_LOG_RETENTION=14 days + """ + global _LOGGING_CONFIGURED + + if _LOGGING_CONFIGURED and not force: + return os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" + + if verbose_mode is None: + verbose_mode = _env_bool("ALWRITY_VERBOSE", default=False) + + os.environ["ALWRITY_VERBOSE"] = "true" if verbose_mode else "false" + + _configure_loguru_sinks(verbose_mode) + _configure_stdlib_intercept(verbose_mode) + _apply_logger_overrides(verbose_mode) + _register_exception_hooks() + + logger.bind(source=bootstrap_source).info( + "Logging configured (verbose={}, source={})", + verbose_mode, + bootstrap_source, + ) + + _LOGGING_CONFIGURED = True return verbose_mode -def get_uvicorn_log_level(): - """Get appropriate uvicorn log level based on verbose mode.""" - verbose_mode = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true" + +def bind_logger_context(*, request_id: Optional[str] = None, job_id: Optional[str] = None, user_id: Optional[str] = None): + """Return a context-bound logger for request/job/user correlation.""" + return logger.bind( + request_id=request_id or "-", + job_id=job_id or "-", + user_id=user_id or "-", + ) + + +def setup_clean_logging() -> bool: + """Backward-compatible wrapper for existing imports.""" + return configure_logging(bootstrap_source="setup_clean_logging") + + +def get_uvicorn_log_level() -> str: + """Get uvicorn log level based on verbose mode.""" + verbose_mode = _env_bool("ALWRITY_VERBOSE", default=False) return "debug" if verbose_mode else "warning" diff --git a/backend/main.py b/backend/main.py index 7f5ee43f..f6b59c61 100644 --- a/backend/main.py +++ b/backend/main.py @@ -49,8 +49,8 @@ load_dotenv(project_root / '.env') # root .env (fallback) load_dotenv() # CWD .env (fallback) # Set up clean logging for end users -from logging_config import setup_clean_logging -setup_clean_logging() +from logging_config import configure_logging +configure_logging(bootstrap_source="asgi-import") # Import middleware from middleware.auth_middleware import get_current_user diff --git a/backend/services/llm_providers/gemini_provider.py b/backend/services/llm_providers/gemini_provider.py index 6d40aa78..eb470473 100644 --- a/backend/services/llm_providers/gemini_provider.py +++ b/backend/services/llm_providers/gemini_provider.py @@ -76,7 +76,6 @@ else: load_dotenv() print(f"No .env found at {env_path}, using current directory") -from loguru import logger from utils.logger_utils import get_service_logger # Use service-specific logger to avoid conflicts @@ -93,10 +92,6 @@ import re from typing import Optional, Dict, Any -# Configure standard logging -import logging -logging.basicConfig(level=logging.INFO, format='[%(asctime)s-%(levelname)s-%(module)s-%(lineno)d]- %(message)s') -logger = logging.getLogger(__name__) def get_gemini_api_key() -> str: """Get Gemini API key with proper error handling.""" diff --git a/backend/start_alwrity_backend.py b/backend/start_alwrity_backend.py index 58286dd6..e297e13d 100644 --- a/backend/start_alwrity_backend.py +++ b/backend/start_alwrity_backend.py @@ -216,7 +216,7 @@ def start_backend(enable_reload=False, production_mode=False): print("=" * 50) # Set up clean logging for end users - from logging_config import setup_clean_logging, get_uvicorn_log_level + from logging_config import configure_logging, get_uvicorn_log_level # Video stack preflight (diagnostics + version assert) try: from services.story_writer.video_preflight import ( @@ -228,7 +228,7 @@ def start_backend(enable_reload=False, production_mode=False): log_video_stack_diagnostics = None assert_supported_moviepy = None - verbose_mode = setup_clean_logging() + verbose_mode = configure_logging(verbose_mode=verbose_mode, bootstrap_source="start_alwrity_backend") uvicorn_log_level = get_uvicorn_log_level() # Log diagnostics and assert versions (fail fast if misconfigured)