""" Shared logging configuration for all components. Provides configure_logging() which sets up structlog with JSON output to stdout and an optional plain-text file handler. All components (fh_ob, oe_em, executor) call this at startup before any other logging. """ import asyncio import logging import sys from pathlib import Path from typing import Optional import structlog class _AsyncFileHandler(logging.Handler): """Non-blocking file handler that queues log records for async writing.""" def __init__(self, filepath: Path) -> None: super().__init__() self._filepath = filepath self._queue: Optional[asyncio.Queue] = None self._task: Optional[asyncio.Task] = None def _ensure_loop(self) -> None: if self._queue is None: self._queue = asyncio.Queue(maxsize=4096) loop = asyncio.get_running_loop() self._task = loop.create_task(self._writer_loop()) async def _writer_loop(self) -> None: loop = asyncio.get_running_loop() log_file = self._filepath def _write(msg: str) -> None: with open(log_file, "a") as f: f.write(msg + "\n") while True: record = await self._queue.get() try: msg = self.format(record) await loop.run_in_executor(None, _write, msg) except Exception: pass self._queue.task_done() def emit(self, record: logging.LogRecord) -> None: self._ensure_loop() try: self._queue.put_nowait(record) except asyncio.QueueFull: pass def close(self) -> None: """Override stdlib Handler.close() — no-op, use async _flush() instead.""" pass def flush(self) -> None: """Override stdlib Handler.flush() — no-op, queue is non-blocking.""" pass async def _flush(self) -> None: """Wait for all queued records to be written.""" if self._queue: await self._queue.join() _async_file_handler: Optional[_AsyncFileHandler] = None def configure_logging(level: str = "INFO", log_file: Path | None = None) -> None: """ Configure structlog with JSON output to stdout and optional file handler. Uses stdlib logging as the backend so that standard-library integrations (e.g. uvicorn, aiohttp) produce structured JSON too. Parameters ---------- level : str Log level string (DEBUG, INFO, WARNING, ERROR). log_file : Path or None If set, a FileHandler is added to the root logger writing the same JSON lines to disk. """ global _async_file_handler logging.basicConfig( level=getattr(logging, level.upper()), format="%(message)s", handlers=[], ) root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, level.upper())) root_logger.handlers.clear() console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(logging.Formatter("%(message)s")) console_handler.setLevel(getattr(logging, level.upper())) root_logger.addHandler(console_handler) structlog.configure( wrapper_class=structlog.make_filtering_bound_logger( getattr(logging, level.upper()) ), context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, processors=[ structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.processors.JSONRenderer(), ], ) if log_file: _async_file_handler = _AsyncFileHandler(log_file) _async_file_handler.setFormatter(logging.Formatter("%(message)s")) _async_file_handler.setLevel(getattr(logging, level.upper())) root_logger.addHandler(_async_file_handler) root_logger.propagate = False async def close_logging() -> None: """Flush and close the async file handler.""" global _async_file_handler if _async_file_handler: await _async_file_handler._flush() _async_file_handler = None