diff --git a/README.md b/README.md index def68e97..882a07a7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ The backend architecture: - [Installation](./docs/user_guide/install.md) - [Getting Started](./docs/user_guide/quick_start.md) +## Logs + +Parallax mirrors every log message to both the terminal and a rotating log file stored at `logs/parallax.log` inside your project directory (created automatically). Override the location via `PARALLAX_LOG_DIR` or `PARALLAX_LOG_FILE`, and tweak rotation with `PARALLAX_LOG_MAX_BYTES` and `PARALLAX_LOG_BACKUP_COUNT`. + ## Contributing We warmly welcome contributions of all kinds! For guidelines on how to get involved, please refer to our [Contributing Guide](./docs/CONTRIBUTING.md). diff --git a/src/parallax_utils/logging_config.py b/src/parallax_utils/logging_config.py index 7cb7f799..db2c258c 100644 --- a/src/parallax_utils/logging_config.py +++ b/src/parallax_utils/logging_config.py @@ -4,12 +4,26 @@ import os import sys import threading +from logging.handlers import RotatingFileHandler +from pathlib import Path from typing import Optional +from parallax_utils.file_util import get_project_root + __all__ = ["get_logger", "use_parallax_log_handler", "set_log_level"] _init_lock = threading.Lock() _default_handler: logging.Handler | None = None +_file_handler: logging.Handler | None = None + +DEFAULT_LOG_DIR_NAME = "logs" +DEFAULT_LOG_FILE_NAME = "parallax.log" +LOG_FILE_ENV = "PARALLAX_LOG_FILE" +LOG_DIR_ENV = "PARALLAX_LOG_DIR" +LOG_MAX_BYTES_ENV = "PARALLAX_LOG_MAX_BYTES" +LOG_BACKUP_COUNT_ENV = "PARALLAX_LOG_BACKUP_COUNT" +DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024 +DEFAULT_LOG_BACKUP_COUNT = 5 class _Ansi: @@ -69,12 +83,69 @@ def __init__(self, prefixes): def filter(self, rec: logging.LogRecord) -> bool: return any(rec.name.startswith(p) for p in self._prefixes) - _default_handler.addFilter(_ModuleFilter(target_module_prefix)) - root.addHandler(_default_handler) + handlers = [] + if _default_handler is not None: + handlers.append(_default_handler) + if _file_handler is not None: + handlers.append(_file_handler) + + for handler in handlers: + handler.addFilter(_ModuleFilter(target_module_prefix)) + root.addHandler(handler) + + +def _safe_int_from_env(var_name: str, default: int) -> int: + raw_value = os.getenv(var_name) + if raw_value is None: + return default + try: + parsed = int(raw_value) + return parsed if parsed > 0 else default + except (TypeError, ValueError): + return default + + +def _resolve_log_file_path() -> Path: + env_file = os.getenv(LOG_FILE_ENV) + if env_file: + path = Path(env_file).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + return path + + custom_dir = os.getenv(LOG_DIR_ENV) + if custom_dir: + directory = Path(custom_dir).expanduser() + else: + try: + directory = get_project_root() / DEFAULT_LOG_DIR_NAME + except Exception: + directory = Path.cwd() / DEFAULT_LOG_DIR_NAME + directory.mkdir(parents=True, exist_ok=True) + return directory / DEFAULT_LOG_FILE_NAME + + +def _create_file_handler(formatter: logging.Formatter) -> logging.Handler | None: + try: + log_path = _resolve_log_file_path() + except Exception: + return None + + max_bytes = _safe_int_from_env(LOG_MAX_BYTES_ENV, DEFAULT_LOG_MAX_BYTES) + backup_count = _safe_int_from_env(LOG_BACKUP_COUNT_ENV, DEFAULT_LOG_BACKUP_COUNT) + try: + handler = RotatingFileHandler( + log_path, + maxBytes=max_bytes, + backupCount=backup_count, + ) + handler.setFormatter(formatter) + return handler + except Exception: + return None def _initialize_if_necessary(): - global _default_handler + global _default_handler, _file_handler with _init_lock: if _default_handler is not None: @@ -88,6 +159,7 @@ def _initialize_if_necessary(): formatter = CustomFormatter(fmt=fmt, style="{", datefmt="%b %d %H:%M:%S") _default_handler = logging.StreamHandler(stream=sys.stdout) _default_handler.setFormatter(formatter) + _file_handler = _create_file_handler(formatter) # root level from env or INFO logging.getLogger().setLevel("INFO")