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