""" M5 E-commerce — Logger centralizado (wrapper do loguru). Padroniza saida de logs para todo o motor e-commerce: - Console colorido para desenvolvimento - Arquivo rotacionado para producao (em /root/ecommerce/logs/) - Contexto automatico (modulo, agente, request_id quando aplicavel) - Saida em JSON para ingestao por n8n / Loki / Elastic Uso basico: from utils.m5log import get_logger log = get_logger(__name__) log.info("Pedido criado", order_id=123, channel="mercado_livre") Uso com contexto (agents / requests): from utils.m5log import bind_context, get_logger with bind_context(agent="mercadolivre-sync", request_id="abc-123"): log = get_logger(__name__) log.info("Sincronizando produtos") # ja vem com agent + request_id Configuracao via env: M5_LOG_LEVEL=DEBUG|INFO|WARNING|ERROR (default: INFO) M5_LOG_DIR=/root/ecommerce/logs (default) M5_LOG_JSON=1 (forca saida JSON nos arquivos) M5_LOG_JSON_CLEAN=1 (remove campo 'text' e adiciona exception.traceback_str como string unica; recomendado pra Loki/Elastic) NOTA sobre o nome: o arquivo NAO se chama logging.py porque sombrearia o modulo stdlib 'logging' que o proprio loguru importa internamente, gerando circular import. Use m5log.py em qualquer lugar do projeto. """ from __future__ import annotations import os import sys import time from contextlib import contextmanager from pathlib import Path from typing import Iterator, Optional from loguru import logger as _loguru_logger DEFAULT_LOG_DIR = Path("/root/ecommerce/logs") DEFAULT_LEVEL = "INFO" LOG_FORMAT_CONSOLE = ( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " "{level: <8} | " "{name}:{function}:{line} | " "{extra[context]} | " "{message}" ) LOG_FORMAT_FILE = ( "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " "{name}:{function}:{line} | {extra[context]} | {message}" ) def _resolve_log_dir() -> Path: log_dir = Path(os.getenv("M5_LOG_DIR", str(DEFAULT_LOG_DIR))) log_dir.mkdir(parents=True, exist_ok=True) return log_dir def _resolve_level() -> str: return os.getenv("M5_LOG_LEVEL", DEFAULT_LEVEL).upper() class _JsonFileSink: """Sink customizado: escreve 1 linha JSON por evento, sem campo 'text'. Diferenca do serialize=True nativo do loguru: - Remove campo 'text' (que misturava formatacao plain com JSON) - Adiciona exception.traceback_str como string unica (sem \\n), permitindo queries textuais em Loki/Elastic/n8n Suporta rotacao diaria, retencao e compressao gzip (igual ao FileSink nativo do loguru, mas aplicado a este sink). Parametros: path_template: Path com placeholder {date} que sera substituido pela data atual. Ex: /logs/m5-{date}.log -> /logs/m5-2026-07-06.log retention_days: dias para manter arquivos antigos (None = infinito) compress: se True, compacta arquivos rotacionados com gzip """ def __init__( self, path_template: Path, retention_days: Optional[int] = None, compress: bool = False, ) -> None: self._path_template = Path(path_template) self._path_template.parent.mkdir(parents=True, exist_ok=True) self._retention_days = retention_days self._compress = compress self._current_date: Optional[str] = None self._current_path: Optional[Path] = None self._fh = None def __call__(self, message) -> None: import json as _json rec = message.record current_date = rec["time"].strftime("%Y-%m-%d") self._rotate_if_needed(current_date) payload = { "elapsed": rec["elapsed"].total_seconds() if rec.get("elapsed") else 0.0, "exception": self._format_exception(rec.get("exception")), "extra": dict(rec.get("extra") or {}), "file": {"name": rec["file"].name, "path": rec["file"].path}, "function": rec["function"], "level": { "icon": rec["level"].icon, "name": rec["level"].name, "no": rec["level"].no, }, "line": rec["line"], "message": rec["message"], "module": rec["module"], "name": rec["name"], "process": {"id": rec["process"].id, "name": rec["process"].name}, "thread": {"id": rec["thread"].id, "name": rec["thread"].name}, "time": { "repr": str(rec["time"]), "timestamp": rec["time"].timestamp(), }, } self._fh.write(_json.dumps(payload, ensure_ascii=False, default=str) + "\n") self._fh.flush() def _rotate_if_needed(self, current_date: str) -> None: if self._current_date == current_date and self._fh is not None: return if self._fh is not None: self._fh.close() if self._compress and self._current_path is not None: self._gzip_file(self._current_path) self._current_date = current_date self._current_path = Path( str(self._path_template).replace("{date}", current_date) ) self._fh = open(self._current_path, "a", encoding="utf-8") self._enforce_retention() def _enforce_retention(self) -> None: if self._retention_days is None or self._path_template.parent is None: return from datetime import datetime, timedelta log_dir = self._path_template.parent prefix = self._path_template.stem.split("{date}")[0] suffix = self._path_template.suffix cutoff = datetime.now() - timedelta(days=self._retention_days) for f in log_dir.glob(f"{prefix}*{suffix}*"): try: date_part = f.stem.replace(prefix, "").split(".")[0] file_date = datetime.strptime(date_part, "%Y-%m-%d") if file_date < cutoff: f.unlink() except (ValueError, OSError): continue @staticmethod def _gzip_file(path: Path) -> None: import gzip import shutil gz_path = path.with_suffix(path.suffix + ".gz") with open(path, "rb") as f_in, gzip.open(gz_path, "wb") as f_out: shutil.copyfileobj(f_in, f_out) path.unlink() @staticmethod def _format_exception(exc) -> dict | None: if not exc: return None import traceback as _tb tb_obj = exc.traceback tb_str = "" if tb_obj is not None: if isinstance(tb_obj, str): tb_str = tb_obj else: tb_str = "".join( _tb.format_exception(exc.type, exc.value, tb_obj) ) return { "type": exc.type.__name__ if exc.type else None, "value": str(exc.value) if exc.value else None, "traceback": bool(tb_obj), "traceback_str": tb_str.replace("\n", " ↵ "), } _configured = False def configure(log_dir: Optional[Path] = None, level: Optional[str] = None) -> None: """Configura o loguru uma unica vez. Chamadas adicionais sao idempotentes.""" global _configured if _configured: return log_dir = Path(log_dir) if log_dir else _resolve_log_dir() level = (level or _resolve_level()).upper() _loguru_logger.remove() _loguru_logger.add( sys.stderr, level=level, format=LOG_FORMAT_CONSOLE, backtrace=False, diagnose=False, colorize=True, ) json_mode = os.getenv("M5_LOG_JSON") == "1" json_clean = os.getenv("M5_LOG_JSON_CLEAN") == "1" if json_clean: _loguru_logger.add( _JsonFileSink( log_dir / "m5-{date}.log", retention_days=30, compress=True, ), level=level, backtrace=True, diagnose=False, ) _loguru_logger.add( _JsonFileSink( log_dir / "m5-errors-{date}.log", retention_days=90, compress=True, ), level="ERROR", backtrace=True, diagnose=False, ) else: file_kwargs = dict( rotation="00:00", retention="30 days", compression="gz", enqueue=True, backtrace=True, diagnose=False, ) if json_mode: file_kwargs["serialize"] = True else: file_kwargs["format"] = LOG_FORMAT_FILE _loguru_logger.add( log_dir / "m5-{time:YYYY-MM-DD}.log", level=level, **file_kwargs, ) error_kwargs = dict(file_kwargs) error_kwargs["retention"] = "90 days" error_kwargs["level"] = "ERROR" _loguru_logger.add( log_dir / "m5-errors-{time:YYYY-MM-DD}.log", **error_kwargs, ) _configured = True def get_logger(name: str): """Retorna logger com nome do modulo e contexto vazio como default.""" if not _configured: configure() return _loguru_logger.bind(module=name, context="-") @contextmanager def bind_context(**kwargs) -> Iterator[None]: """Context manager que anexa campos extras (agent, request_id, etc.) aos logs. Exemplo: with bind_context(agent="tiktok-shop", request_id="r-42"): log = get_logger(__name__) log.info("Processando pedido") # inclui agent e request_id """ if not _configured: configure() with _loguru_logger.contextualize(**kwargs): yield def log_exception(log, message: str, **kwargs) -> None: """Helper para logar excecao com traceback + campos extras.""" log.opt(exception=True).error(message, **kwargs)