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:
printnã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.basicConfigsó 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, adicionelogging.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ãoTruedesabilitará todos os loggers criados antes dedictConfigrodar, incluindo ologgerno início de cada módulo que você importou. O resultado é que os logs desaparecem sem erro algum.- Handler só é anexado ao
root. Loggershopapenas define o nível, enquanto o registro se propaga para root para ser impresso. - Root está em
WARNINGpara bloquear logsINFObarulhentos de bibliotecas terceiras. Seu código na seçãoshopainda registra emINFO. - Nível é lido da variável de ambiente
LOG_LEVEL. Você pode ativarDEBUGquando 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:
_RESERVEDcontém atributos padrão de umLogRecord, por exemplopathname,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=strfazjson.dumpsnão falhar ao encontrar tipos comoDecimalouUUID.
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
extranão podem ter o mesmo nome que atributos já existentes deLogRecord. Por exemplo,extra={"name": "..."}ouextra={"message": "..."}lançarãoKeyError: "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
putdo registro naqueue. Esta operação é praticamente instantânea. QueueListenerroda em sua própria thread, retira registros e os envia aotarget, por exemplo umSysLogHandler.atexit.register(listener.stop)garante que os últimos logs sejam escritos antes do process sair.
Nota: Filtros como
RequestIdFilterdevem ser anexados aoQueueHandler, não ao handler de destino. O handler de destino roda na thread do listener, eContextVarnesta 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:
caploganexa um handler ao root logger. Se sua configuração definir"propagate": Falsepara loggershop, 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
basicConfigoudictConfigdurante import). Apenas configure no entry point, uma única vez. - Handler anexado duas vezes, por exemplo anexado tanto em
shopquanto em root enquantopropagateainda é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
Filterpara mascarar estes campos. - Usar
RotatingFileHandlerem 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 viaQueueHandlerpara um processo escritor único. - Registrar muito em
INFOem loops críticos. O custo de armazenamento e ruído aumenta muito rapidamente. Logs como esses devem estar emDEBUG. - Usar JSON no desenvolvimento local. JSON é difícil de ler a olho nu, então escolha o formatador por variável de ambiente:
plainpara dev,jsonpara 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.