diff --git a/ecom-store/.gitignore b/ecom-store/.gitignore new file mode 100644 index 0000000..a101ab9 --- /dev/null +++ b/ecom-store/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +.env \ No newline at end of file diff --git a/ecom-store/README.md b/ecom-store/README.md new file mode 100644 index 0000000..6097b89 --- /dev/null +++ b/ecom-store/README.md @@ -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 diff --git a/ecom-store/utils/__init__.py b/ecom-store/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecom-store/utils/example_usage.py b/ecom-store/utils/example_usage.py new file mode 100644 index 0000000..c0f73dc --- /dev/null +++ b/ecom-store/utils/example_usage.py @@ -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") \ No newline at end of file diff --git a/ecom-store/utils/m5log.py b/ecom-store/utils/m5log.py new file mode 100644 index 0000000..8c4a7e0 --- /dev/null +++ b/ecom-store/utils/m5log.py @@ -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 = ( + "{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) \ No newline at end of file