Logging em Python: configuração para produção

1. Visão geral

print() é suficiente quando você escreve um script pequeno. Mas em produção é diferente: você precisa filtrar log por nível, agregar logs em um sistema centralizado, rastrear um request através de várias linhas de log, e a escrita de log não deve deixar a aplicação lenta.

Este artigo guia a configuração do módulo logging padrão do Python para produção. Após ler, você será capaz de montar uma configuração com logger por módulo, dictConfig, log em formato JSON, request_id anexado automaticamente, escrita assíncrona com QueueHandler e testes com pytest. Todos os exemplos usam Python 3.12 e apenas as bibliotecas padrão. Apenas a parte de testes requer pytest adicional.

2. Por que print e basicConfig não são suficientes

Veja um serviço que processa pedidos escrito de forma “rápido e pronto”:

import logging

logging.basicConfig(level=logging.INFO)

def place_order(order_id: str, amount: float) -> None:
    print(f"Pedindo {order_id}")
    logging.info(f"Pedido {order_id} valor={amount}")

place_order("ORD-1001", 250_000)
Pedindo ORD-1001
INFO:root:Pedido ORD-1001 valor=250000

Este código tem três problemas:

  • print não tem nível, não tem timestamp e não pode ser desabilitado pela configuração.
  • logging.info(...) escreve no root logger. Você não sabe de qual módulo veio a linha de log e não pode ajustar nível para cada parte específica.
  • basicConfig só funciona na primeira chamada, se o root logger ainda não tiver handler. Se alguma biblioteca chamar antes de você, sua configuração será ignorada sem nenhum aviso.

3. Logger por módulo com getLogger(__name__)

Primeira regra: cada módulo cria um logger próprio, nomeado pelo __name__.

# shop/orders.py
import logging

logger = logging.getLogger(__name__)  # nome do logger: "shop.orders"

class OrderService:
    def place_order(self, order_id: str, quantity: int) -> None:
        logger.info("Pedindo %s com quantidade %d", order_id, quantity)
        if quantity > 50:
            logger.warning("Pedido grande %s: quantidade=%d", order_id, quantity)

Os nomes de logger formam uma hierarquia por pontos. shop.orders é filho de shop, e shop é filho de root. Um registro sobe do logger filho para o logger pai (chamado de propagação). Por isso, você só precisa anexar handler no root ou em shop, depois ajustar nível para cada ramo. Por exemplo, você pode colocar shop.payments em DEBUG e deixar o resto em INFO.

Note também a forma de passar parâmetros: use %s com argumentos ao invés de f-string. Quando o nível está desabilitado, a string não será formatada, economizando processamento para aqueles logs que nunca serão impressos. Além disso, ferramentas de agregação de logs conseguem agrupar linhas pelo mesmo template de mensagem.

Nota: Em bibliotecas (pacotes que você distribui para outros usarem), não configure handlers. Apenas chame getLogger(__name__) e deixe a aplicação decidir. Se necessário, adicione logging.getLogger("sua_lib").addHandler(logging.NullHandler()).

4. Configuração centralizada com dictConfig

Em produção, você deve reunir toda a configuração em um local e chamá-la uma única vez quando a aplicação inicializa. logging.config.dictConfig é perfeito para isso.

# shop/logging_config.py
import logging.config
import os

def setup_logging() -> None:
    level = os.getenv("LOG_LEVEL", "INFO")
    logging.config.dictConfig({
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "plain": {
                "format": "%(asctime)s %(levelname)-8s %(name)s - %(message)s",
            },
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "stream": "ext://sys.stdout",
                "formatter": "plain",
            },
        },
        "loggers": {
            "shop": {"level": level},
            "urllib3": {"level": "WARNING"},
        },
        "root": {"level": "WARNING", "handlers": ["console"]},
    })

Pontos principais na configuração:

  • "disable_existing_loggers": False é obrigatório na prática. O valor padrão True desabilitará todos os loggers criados antes de dictConfig rodar, incluindo o logger no início de cada módulo que você importou. O resultado é que os logs desaparecem sem erro algum.
  • Handler só é anexado ao root. Logger shop apenas define o nível, enquanto o registro se propaga para root para ser impresso.
  • Root está em WARNING para bloquear logs INFO barulhentos de bibliotecas terceiras. Seu código na seção shop ainda registra em INFO.
  • Nível é lido da variável de ambiente LOG_LEVEL. Você pode ativar DEBUG quando investigar problemas sem precisar modificar o código.

Chame a função no entry point:

# main.py
from shop.logging_config import setup_logging
from shop.orders import OrderService

setup_logging()
OrderService().place_order("ORD-1001", quantity=80)
2026-10-18 09:15:02,114 INFO     shop.orders - Pedindo ORD-1001 com quantidade 80
2026-10-18 09:15:02,114 WARNING  shop.orders - Pedido grande ORD-1001: quantidade=80

Escrever em stdout é a escolha correta ao rodar em container. Docker ou Kubernetes coletarão o log deste stream, então você não precisa gerenciar arquivos e rotação.

5. Log estruturado em JSON

Sistemas de agregação de logs como Loki, Elasticsearch ou CloudWatch conseguem buscar em JSON muito melhor do que em texto livre. Você pode escrever seu próprio formatador sem precisar de bibliotecas externas:

# shop/json_formatter.py
import json
import logging
from datetime import datetime, timezone

_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {"message", "asctime"}

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for key, value in record.__dict__.items():
            if key not in _RESERVED and not key.startswith("_"):
                payload[key] = value
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False, default=str)

Como funciona:

  • _RESERVED contém atributos padrão de um LogRecord, por exemplo pathname, lineno, args.
  • Qualquer outro atributo é considerado campo personalizado e adicionado ao JSON. Estes campos geralmente vêm do parâmetro extra= ou de um filtro (veja seção 6).
  • default=str faz json.dumps não falhar ao encontrar tipos como Decimal ou UUID.

Declare o formatador em dictConfig usando a chave especial "()":

"formatters": {
    "json": {"()": "shop.json_formatter.JsonFormatter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
    },
},

Adicione campos de negócio usando extra:

logger.info("Pagamento capturado", extra={"order_id": "ORD-1001", "amount": 250000})
{"ts": "2026-10-18T02:15:02.114+00:00", "level": "INFO", "logger": "shop.payments", "message": "Pagamento capturado", "order_id": "ORD-1001", "amount": 250000}

Nota: Chaves em extra não podem ter o mesmo nome que atributos já existentes de LogRecord. Por exemplo, extra={"name": "..."} ou extra={"message": "..."} lançarão KeyError: "Attempt to overwrite 'name' in LogRecord".

6. Anexar request_id automaticamente com contextvars

Passar extra={"request_id": ...} em cada chamada de log é verboso e fácil de esquecer. Uma forma melhor é armazenar request_id em uma ContextVar e deixar um Filter anexá-lo a cada registro.

# shop/request_context.py
import logging
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id_var.get()
        return True  # não bloqueia nenhum registro, apenas enriquece os dados

ContextVar é segura tanto para threads quanto para asyncio. Cada task ou request mantém seu próprio valor, então não há risco de um request “emprestar” o ID de outro.

Anexe o filtro ao handler, não ao logger. Um filtro anexado ao logger roda apenas para registros gerados diretamente daquele logger, não para registros que se propagam de loggers filhos.

"filters": {
    "request_id": {"()": "shop.request_context.RequestIdFilter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
        "filters": ["request_id"],
    },
},

Defina o valor no início de cada request, por exemplo em um middleware:

import uuid
from shop.request_context import request_id_var

def handle_request(order_id: str) -> None:
    token = request_id_var.set(uuid.uuid4().hex[:12])
    try:
        OrderService().place_order(order_id, quantity=3)
    finally:
        request_id_var.reset(token)
{"ts": "...", "level": "INFO", "logger": "shop.orders", "message": "Pedindo ORD-1002 com quantidade 3", "request_id": "9f2c1ab07d3e"}

Agora você só precisa filtrar por request_id para ver toda a jornada de um request.

7. Registrar exceções corretamente

logger = logging.getLogger("shop.payments")

class PaymentService:
    def charge(self, order_id: str, amount: int) -> bool:
        try:
            raise ConnectionError("timeout do gateway")
        except ConnectionError:
            logger.exception("Cobrança falhou para pedido %s", order_id)
            return False

logger.exception(...) registra no nível ERROR e anexa automaticamente o stack trace. Você só deve chamá-la dentro de um bloco except. Se quiser registrar em outro nível mas ainda com stack trace, use logger.warning("...", exc_info=True).

Dois erros comuns:

  • logger.error(f"Cobrança falhou: {e}") perde o stack trace. Ao investigar, você só vê uma linha “timeout do gateway” sem saber onde ocorreu.
  • Registrar e relançar em cada camada. A mesma exceção aparecerá cinco vezes no log. Registre apenas onde você trata a exceção, ou seja, onde decide não relançar.

8. Não deixar logging bloquear a aplicação: QueueHandler

StreamHandler escrever em stdout geralmente é rápido. Mas handlers que enviam pela rede (syslog, HTTP, SMTP) ou escrevem em disco lento bloquearão a thread processando o request. Para evitar isso, coloque registros em uma fila e deixe uma thread em background escrever no destino real:

import atexit
import logging
import logging.handlers
import queue

def setup_async_logging(target: logging.Handler) -> None:
    log_queue: queue.Queue = queue.Queue(-1)
    queue_handler = logging.handlers.QueueHandler(log_queue)

    listener = logging.handlers.QueueListener(
        log_queue, target, respect_handler_level=True
    )
    listener.start()
    atexit.register(listener.stop)  # descarrega o resto ao desligar

    root = logging.getLogger()
    root.handlers = [queue_handler]

O fluxo de processamento funciona assim:

  • A thread da aplicação apenas faz put do registro na queue. Esta operação é praticamente instantânea.
  • QueueListener roda em sua própria thread, retira registros e os envia ao target, por exemplo um SysLogHandler.
  • atexit.register(listener.stop) garante que os últimos logs sejam escritos antes do process sair.

Nota: Filtros como RequestIdFilter devem ser anexados ao QueueHandler, não ao handler de destino. O handler de destino roda na thread do listener, e ContextVar nesta thread não carrega o valor do request original.

9. Testar logs com caplog do pytest

Avisos de negócio, como pedidos de quantidade grande, são comportamentos que devem ser testados. Adicione pytest às dependências de desenvolvimento:

pip install pytest
# tests/test_orders.py
import logging
from shop.orders import OrderService

def test_large_order_logs_warning(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2001", quantity=80)

    warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
    assert len(warnings) == 1
    assert warnings[0].name == "shop.orders"
    assert warnings[0].getMessage() == "Pedido grande ORD-2001: quantidade=80"

def test_small_order_does_not_warn(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2002", quantity=2)

    assert all(r.levelno < logging.WARNING for r in caplog.records)
tests/test_orders.py ..                                  [100%]
2 passed in 0.03s

caplog.at_level reduz temporariamente o nível do logger shop.orders dentro do bloco with. caplog.records contém os LogRecord originais, então você pode verificar nível, nome do logger e até campos extra se houver.

Nota: caplog anexa um handler ao root logger. Se sua configuração definir "propagate": False para logger shop, o teste não capturará nenhum registro.

10. Erros comuns e o que não fazer

  • Configurar logging no nível do módulo (chamar basicConfig ou dictConfig durante import). Apenas configure no entry point, uma única vez.
  • Handler anexado duas vezes, por exemplo anexado tanto em shop quanto em root enquanto propagate ainda é True. Cada linha de log será impressa duas vezes. Você só deve anexar handler em um nível.
  • Registrar dados sensíveis: senhas, tokens, números de cartão. Não registre objetos de request inteiros. Se for necessário, escreva um Filter para mascarar estes campos.
  • Usar RotatingFileHandler em aplicações multi-processo (Gunicorn com vários workers). Múltiplos processes rodando em um arquivo causarão perda de logs. Escreva em stdout, ou deixe cada worker enviar via QueueHandler para um processo escritor único.
  • Registrar muito em INFO em loops críticos. O custo de armazenamento e ruído aumenta muito rapidamente. Logs como esses devem estar em DEBUG.
  • Usar JSON no desenvolvimento local. JSON é difícil de ler a olho nu, então escolha o formatador por variável de ambiente: plain para dev, json para produção.

11. Conclusão

Uma boa configuração de logging para produção não é complexa, apenas precisa ser consistente. Use getLogger(__name__) em todos os módulos. Centralize a configuração em uma única chamada de dictConfig com disable_existing_loggers: False. Escreva JSON em stdout, anexe request_id via ContextVar e Filter, e use logger.exception para preservar stack traces. Quando o handler de destino é lento, mude para QueueHandler e QueueListener.

Por fim, considere logging como parte do comportamento do sistema e teste com caplog. Quando há um incidente às 2 da manhã, aquelas linhas de log estruturadas o ajudarão a encontrar a causa muito mais rapidamente.

pythonloggingproduçãoobservabilidade