triangular_arbitrage_bot/common/log.py

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