feat(ecom-store): adicionar logger centralizado M5 (m5log)
Wrapper do loguru com 3 modos de saida para o motor e-commerce: - Texto (default): console colorido + arquivos rotacionados em /root/ecommerce/logs/, retencao 30/90 dias, compressao gzip - JSON padrao (M5_LOG_JSON=1): mesmo formato de arquivo, com serialize=True do loguru - JSON clean (M5_LOG_JSON_CLEAN=1): sink customizado sem campo 'text' e com exception.traceback_str como string unica, otimizado para ingestao em Loki/Elastic/n8n API publica: - get_logger(name) -> logger bindado com module + context - bind_context(**kwargs) -> context manager para request_id/agent - log_exception(log, message, **kwargs) -> captura traceback estruturado - configure() -> idempotente, controla os 3 modos via env vars Acompanha example_usage.py com 4 cenarios reais: 1. Sync marketplace (com bind_context por SKU) 2. Agente async (Telegram -> Amazon sync) 3. Pedido com erro de NF-e (log_exception com sefaz_status) 4. Job batch Black Friday (progresso a cada N SKUs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
33fb270ecf
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
18
README.md
Normal file
18
README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# E-commerce Store — Estado Operacional
|
||||
|
||||
## Cadastros
|
||||
|
||||
### Produtos
|
||||
Placeholders para cadastros de produto (SKU, preço, marketplace).
|
||||
|
||||
### NF-e
|
||||
Placeholder para integração NF-e (não implementado ainda).
|
||||
|
||||
### Marketplaces
|
||||
Placeholder para estado por marketplace (estoque, preços, pedidos).
|
||||
|
||||
## Notas
|
||||
|
||||
- Arquivos JSON neste diretório = estado operacional
|
||||
- Backup automático via cron job
|
||||
- Integração com motor e-commerce via API
|
||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
147
utils/example_usage.py
Normal file
147
utils/example_usage.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""
|
||||
Exemplo de uso real do logger M5 em diferentes cenarios.
|
||||
|
||||
Cobre os 4 casos que vao aparecer no motor e-commerce:
|
||||
1. Script de sincronizacao com marketplace
|
||||
2. Agente async com contexto de execucao
|
||||
3. Worker de pedido com tratamento de erro
|
||||
4. Job batch com progresso
|
||||
|
||||
Rodar:
|
||||
/root/ecommerce/scripts/venv/bin/python utils/example_usage.py
|
||||
M5_LOG_JSON=1 /root/ecommerce/scripts/venv/bin/python utils/example_usage.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from utils.m5log import get_logger, bind_context, log_exception, configure
|
||||
|
||||
|
||||
def cenario_1_sync_marketplace() -> None:
|
||||
"""Sync pontual de produtos para um marketplace (ex: TikTok Shop)."""
|
||||
log = get_logger(__name__)
|
||||
|
||||
log.info("Iniciando sincronizacao com marketplace", marketplace="tiktok_shop")
|
||||
time.sleep(0.05)
|
||||
|
||||
produtos = [
|
||||
{"sku": "SKU-001", "preco": 99.90, "estoque": 12},
|
||||
{"sku": "SKU-002", "preco": 149.00, "estoque": 0},
|
||||
{"sku": "SKU-003", "preco": 49.90, "estoque": 200},
|
||||
]
|
||||
|
||||
for p in produtos:
|
||||
if p["estoque"] == 0:
|
||||
log.warning("Produto sem estoque, pulando", sku=p["sku"])
|
||||
continue
|
||||
|
||||
with bind_context(sku=p["sku"], marketplace="tiktok_shop"):
|
||||
log.info(
|
||||
"Produto sincronizado",
|
||||
preco=p["preco"],
|
||||
estoque=p["estoque"],
|
||||
)
|
||||
|
||||
log.info("Sincronizacao concluida", total=len(produtos), enviados=2)
|
||||
|
||||
|
||||
def cenario_2_agente_async() -> None:
|
||||
"""Agente do Telegram processando comando do usuario."""
|
||||
log = get_logger(__name__)
|
||||
|
||||
comando = {"user_id": 5511999999999, "texto": "/sync amazon"}
|
||||
|
||||
log.info("Comando recebido", **comando)
|
||||
|
||||
with bind_context(
|
||||
agent="amazon-sync",
|
||||
user_id=comando["user_id"],
|
||||
request_id="req-7f3a2b",
|
||||
):
|
||||
log.info("Delegando para agente especializado")
|
||||
|
||||
for step in ["auth", "catalog", "pricing", "stock"]:
|
||||
log.info("Etapa concluida", step=step)
|
||||
time.sleep(0.02)
|
||||
|
||||
log.info("Agente finalizou")
|
||||
|
||||
|
||||
def cenario_3_pedido_com_erro() -> None:
|
||||
"""Processamento de pedido com erro de NF-e (deve gerar log estruturado)."""
|
||||
log = get_logger(__name__)
|
||||
|
||||
pedido_id = "PED-2026-00042"
|
||||
with bind_context(pedido_id=pedido_id, canal="mercado_livre"):
|
||||
log.info("Pedido recebido")
|
||||
|
||||
try:
|
||||
nota = {"numero": None, "serie": "1"}
|
||||
if nota["numero"] is None:
|
||||
raise ValueError("NF-e sem numero atribuido pela SEFAZ")
|
||||
|
||||
log.info("NF-e emitida", **nota)
|
||||
|
||||
except Exception:
|
||||
log_exception(
|
||||
log,
|
||||
"Falha ao emitir NF-e",
|
||||
sefaz_status="rejeitado",
|
||||
tentativa=1,
|
||||
)
|
||||
|
||||
log.info("Pedido finalizado com erro registrado")
|
||||
|
||||
|
||||
def cenario_4_job_batch() -> None:
|
||||
"""Job em lote com progresso (ex: recálculo de preços Black Friday)."""
|
||||
log = get_logger(__name__)
|
||||
|
||||
log.info("Job batch iniciado", evento="black_friday_2026")
|
||||
|
||||
with bind_context(job="recalcular_precos", batch_id="bf-2026-1"):
|
||||
total_skus = 250
|
||||
processados = 0
|
||||
erros = 0
|
||||
|
||||
for i in range(1, total_skus + 1):
|
||||
try:
|
||||
if i % 47 == 0:
|
||||
raise ConnectionError("API do marketplace timeout")
|
||||
processados += 1
|
||||
except Exception:
|
||||
erros += 1
|
||||
log_exception(log, "Falha ao recalcular preco", sku=f"SKU-{i:05d}")
|
||||
|
||||
if i % 50 == 0:
|
||||
log.info(
|
||||
"Progresso",
|
||||
processados=processados,
|
||||
erros=erros,
|
||||
percentual=round(i / total_skus * 100, 1),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"Job batch finalizado",
|
||||
total=total_skus,
|
||||
sucessos=processados,
|
||||
erros=erros,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
configure()
|
||||
|
||||
print("\n--- 1. Sync marketplace ---\n")
|
||||
cenario_1_sync_marketplace()
|
||||
|
||||
print("\n--- 2. Agente async ---\n")
|
||||
cenario_2_agente_async()
|
||||
|
||||
print("\n--- 3. Pedido com erro ---\n")
|
||||
cenario_3_pedido_com_erro()
|
||||
|
||||
print("\n--- 4. Job batch ---\n")
|
||||
cenario_4_job_batch()
|
||||
|
||||
print("\n--- FIM ---\n")
|
||||
303
utils/m5log.py
Normal file
303
utils/m5log.py
Normal file
@ -0,0 +1,303 @@
|
||||
"""
|
||||
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)
|
||||
Loading…
Reference in New Issue
Block a user