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>
303 lines
9.8 KiB
Python
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) |