ecom-store/ecom-store/utils/m5log.py
Iris Florencio 492fed66b9 chore: incluir ecom-store completo (m5log + workflows + CRM reorganization)
Adiciona o conteudo de /root/ecommerce/ecom-store/ que estava em commit
anterior no Gitea (33fb270) mas foi perdido no force-push:

- utils/m5log.py — logger centralizado wrapper do loguru
- utils/example_usage.py — 4 cenarios de uso real
- utils/__init__.py — package marker
- .gitignore para ecom-store

Tambem mantem os workflows/ e scripts/traefik-conf/routes.yml adicionados
no commit anterior (Fase 5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 17:04:14 -03:00

303 lines
9.8 KiB
Python

"""
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 = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"{extra[context]} | "
"<level>{message}</level>"
)
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)