134 lines
4.1 KiB
Python
134 lines
4.1 KiB
Python
"""
|
|
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
|