Como Criar Logs Estruturados e Rastreáveis em Aplicações FastAPI
Mensagens de log são fundamentais no monitoramento de aplicações em produção, mas geralmente essas mensagens são inadequadas. Este artigo mostra como melhorar a geração de mensagem de logs em aplicações FastAPI usando o Loguru como módulo de logging, formatação JSON e a identificação de mensagens de log por meio de IDs de requisição.
Introdução
Os logs são a principal fonte de informação sobre o comportamento da aplicação em produção. São cruciais na identificação de problemas, na rastreabilidade de eventos e no monitoramento do desempenho de aplicações. No entanto, a maioria das aplicações registra logs de forma inadequada. Geralmente, os logs são gerados em formato de texto simples, sem estrutura definida, o que dificulta o processamento e a análise. Também é comum a falta de um elo de ligação entre as mensagens de logs, impossibilitando o rastreamento do processamento de uma requisição específica em uma aplicação web, por exemplo.
O objetivo deste artigo é melhorar a geração de mensagem de logs em aplicações FastAPI. Para isso, implantaremos as seguintes funcionalidades:
- Uso do Loguru em substituição do módulo padrão de logging do Python. Loguru é uma boa alternativa porque é mais simples de configurar e usar.
- Logs no formato JSON. O formato JSON é fácil de ler e possui uma estrutura bem definida que facilita o processamento. É bem aceito na integração com serviços centralizados de logging e com ferramentas de linha de comando tais como jq.
- Associação de toda mensagem de log a um ID de requisição para possibilitar o rastreamento de todo o processamento de uma resposta.
- Registro do tempo total de processamento de cada requisição. Essa informação será importante para o monitoramento do desempenho da aplicação.
- Registro dos casos de erro e exceções da aplicação no log para auxiliar na depuração de problemas.
Logs em Formato JSON
No loguru
, a serialização de mensagem de log em JSON é feita através do parâmetro serialize=True
durante a inclusão do handler
:
$ python -m venv .venv $ source .venv/bin/activate (.venv) $ pip install loguru (.venv) $ python Python 3.11.4 (main, Aug 10 2023, 20:08:21) [GCC 12.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import sys >>> from loguru import logger >>> logger.remove() >>> logger.add(sys.stderr, serialize=True) 1 >>> logger.info('Hello World', request_id='abcdef') {"text": "2023-10-16 07:22:49.234 | INFO | __main__:<module>:1 - Hello World\n", "record": {"elapsed": {"repr": "4:50:14.048257", "seconds": 17414.048257}, "exception": null, "extra": {"request_id": "abcdef"}, "file": {"name": "<stdin>", "path": "<stdin>"}, "function": "<module>", "level": {"icon": "i", "name": "INFO", "no": 20}, "line": 1, "message": "Hello World", "module": "<stdin>", "name": "__main__", "process": {"id": 834569, "name": "MainProcess"}, "thread": {"id": 140172067380096, "name": "MainThread"}, "timestamp": {"repr": "2023-10-16 07:22:49.234603-03:00", "timestamp": 1697451769.234603}}}
Abaixo, temos o mesmo log, mas em uma formatação melhorada:
{ "text": "2023-10-16 07:22:49.234 | INFO | __main__:<module>:1 - Hello World\n", "record": { "elapsed": { "repr": "4:50:14.048257", "seconds": 17414.048257 }, "exception": null, "extra": { "request_id": "abcdef" }, "file": { "name": "<stdin>", "path": "<stdin>" }, "function": "<module>", "level": { "icon": "i", "name": "INFO", "no": 20 }, "line": 1, "message": "Hello World", "module": "<stdin>", "name": "__main__", "process": { "id": 834569, "name": "MainProcess" }, "thread": { "id": 140172067380096, "name": "MainThread" }, "timestamp": { "repr": "2023-10-16 07:22:49.234603-03:00", "timestamp": 1697451769.234603 } } }
Há alguns problemas com essa estrutura:
- Há muitos níveis de aninhamento. Isso dificulta a leitura e o processamento dos logs.
- Alguns campos não são necessários tais como
process
ethread
. - O campo
elapsed
não representa o tempo de resposta de uma requisição e sim o tempo desde o início da aplicação. - O campo
request_id
está no campoextra
mas ficaria melhor se estivesse no primeiro nível. - O campo
level
representa o nível da mensagem através de um dicionário com os camposicon
,name
eno
. Seria melhor se fosse um único campolevel
com o valor contido emname
. - A origem da mensagem de log está apresentado no campo
text
e também desmembrada em diferentes campos derecord
. Seria melhor se fosse um único camposource
com o valor<file.name>:<function>:<line>
, tal qual aparece inicialmente.
Vamos resolver esses problemas através de uma serialização customizada de mensagens de log.
Primeira Versão da Serialização Customizada
A recomendação da documentação do Loguru para customizar a serialização de logs é criar uma função específica de serialização e associá-la a um sink. Isto será feito em um novo módulo de nome logging.py
:
A função serialize
recebe um dicionário record
com os campos da mensagem de log e retorna uma string com a mensagem serializada em JSON.
Os campos extra
do registro original são apresentados no primeiro nível da mensagem de log (linha 16).
A conversão para JSON é feita pela biblioteca orjson por ser mais rápida que a biblioteca padrão json (linha 17). A chamada à função orjson.dumps
usa str
como função default para serializar tipos não suportados. Nem sempre os resultados são os melhores possíveis, mas evita que o log seja interrompido por uma exceção de serialização.
O resultado para o comando logger.info('Hello World', request_id='abcdef')
agora é:
{"timestamp": "2023-10-16T11:01:03.223737-03:00", "level": "INFO", "message": "Hello World", "source": "<stdin>:<module>:1", "request_id": "abcdef"}
Esta primeira versão da função serialize
corrige os problemas listados anteriormente, mas ainda é possível melhorar um pouco mais a função para que produza uma formatação mais amigável durante o desenvolvimento, com endentação e sintaxe colorida.
Segunda Versão com Endentação e Colorização
A segunda versão da função serialize
é:
Durante o desenvolvimento, quando a variável de configuração DEBUG
é True
, a formatação do JSON é melhorada com endentação e sintaxe colorida.
A endentação é feita com a adição da opção OPT_INDENT_2
às opções de serialização do orjson
(linha 14 e 15).
A sintaxe colorida é feita pela função highlight
da biblioteca Pygments (linha 28). O estilo de formatação é definido através da variável de configuração PYGMENTS_STYLE
(linha 12), que pode ser alterada de acordo com a preferência do desenvolvedor criando uma variável de ambiente com o mesmo nome.
A saída para o comando logger.info('Hello World', request_id='abcdef')
fica assim durante o desenvolvimento e testes:
Terceira e Última Versão da Serialização Customizada
Ainda há mais um ajuste a ser feito. Em caso de exceção, a mensagem de log deve conter o traceback
da exceção. A formatação do traceback
será feita pela biblioteca stackprinter, que mostra um contexto melhorado da exceção com os valores das variáveis próximas:
Ajustes no Projeto FastAPI Mínimo
O gabarito do projeto FastAPI Mínimo precisa de alguns ajustes para adotar as melhorias apresentadas. Primeiro, orjson
, pygments
e stackprinter
devem ser adicionados à lista de dependências do projeto:
Em segundo lugar, a chamada à init_loguru
deve ser feita durante a iniciação da aplicação, em resources.py
:
Com essas alterações, as mensagem de log passam a ser geradas no formato JSON. A próxima etapa é incluir o identificador da requisição em todas as mensagens de log.
Rastreamento de Requisições no Log
O processamento da resposta a uma requisição é registrado no log em várias mensagens diferentes, intercaladas com mensagens de outros processamentos concorrentes. Se gerarmos um identificador único para cada requisição e usarmos esse identificador nas mensagens de log subsequentes, poderemos filtrar e agrupar as mensagens de log relacionadas para rastrear todo o processamento.
Para uma aplicação FastAPI, uma boa maneira de gerar o identificador da requisição é através de um middleware. A implementação do middleware
apresentada a seguir cria um ID aleatório para cada requisição e o inclui no contexto do logger
para que seja incluído em todas as mensagens de log do mesmo processamento. Além disso, também registra o tempo de processamento de cada requisição.
Vamos analisar o código nas próximas seções.
Identificador da Requisição
O identificador da requisição é gerado através da função secrets.token_urlsafe, que retorna um texto aleatório codificado em Base64
.
O tamanho do identificador não precisa ser muito grande pois não será usado para criptografia. Usando um valor padrão de 8
bytes para a variável de configuração REQUEST_ID_LENGTH
, temos 18.446.744.073.709.551.616
combinações possíveis, o que é suficiente para um identificador aleatório.
Repassando o Identificador da Requisição para Chamadas Subsequentes ao Logger
Na linha 21, o identificador da requisição é incluído no contexto do logger
através da função contextualize. Dessa forma, todas as chamadas a logger
na mesma thread
ou tarefa assíncrona recebem automaticamente o identificador da requisição dentro do campo extra
, sem a necessidade de passá-lo explicitamente como parâmetro. Esse campo extra
é depois expandido para o primeiro nível na função serialize
, conforme apresentado na seção anterior.
Mensagem de Log da Requisição
Ao final do processamento da requisição, é apresentada uma mensagem de log com o tempo de processamento e os dados da requisição e da resposta. Ao invés de extrair as informações da requisição e da resposta diretamente dos objetos request
e response
, é usado um objeto AccessLogAtoms para fazer o trabalho pesado (linha 30).
Registrando o Middleware na Aplicação
O middleware
deve ser registrado na aplicação para que seja ativado. No projeto FastAPI mínimo, isso é feito no arquivo main.py
:
A inclusão do middleware
é feita através do método add_middleware da aplicação FastAPI, passando a função log_request_middleware
como parâmetro dispatch
(linha 22).
Mensagem de Log com o Middleware Registrado
Considerando o endpoint /hello
do projeto FastAPI mínimo:
Com o middleware
implementado e registrado na aplicação, as mensagens de log para uma chamada ao endpoint /hello
ficam assim:
{ "timestamp": "2023-10-18T07:23:59.274914+00:00", "level": "INFO", "message": "Hello world!", "source": "hello.py:hello_world:9", "request_id": "M5L1Dftkkog" } { "timestamp": "2023-10-18T07:23:59.275833+00:00", "level": "INFO", "message": "log request", "source": "middleware.py:log_request_middleware:31", "request_id": "M5L1Dftkkog", "remote_ip": "192.168.112.3:47140", "schema": "http", "protocol": "2", "method": "GET", "path_with_query": "/hello", "status_code": 200, "response_length": "25", "elapsed": 0.0012085437774658203, "referer": "-", "user_agent": "HTTPie/3.2.2" }
A primeira mensagem de log é gerada na chamada ao endpoint /hello
, conforme indicado pelo campo source
. A segunda é a mensagem de log final do processamento, gerada no log_request_middleware
.
Registrando Erros de Validação no Log
Um erro de validação 422
é gerado quando o endpoint recebe parâmetros inválidos, que não podem ser processados. Seja o endpoint abaixo que calcula a divisão de dois números inteiros:
Chamar esse endpoint com um float
no lugar de um int
gera um erro de validação:
$ httpx --http2 --no-verify 'https://localhost/api/divide?a=1.5&b=2' HTTP/2 422 Unprocessable Entity alt-svc: h3=":443"; ma=2592000 content-type: application/json date: Thu, 19 Oct 2023 08:05:14 GMT server: Caddy server: hypercorn-h2 x-processed-time: 0.0010986328125 x-request-id: Gw5vFSCiHFM content-length: 201 { "detail": [ { "type": "int_parsing", "loc": [ "query", "a" ], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "1.5", "url": "https://errors.pydantic.dev/2.4/v/int_parsing" } ] }
Mesmo com o middleware
de log de requisição implementado, o erro de validação não é registrado no log. Temos apenas a mensagem de log final do processamento da requisição:
{ "timestamp": "2023-10-19T08:05:14.601434+00:00", "level": "INFO", "message": "log request", "source": "middleware.py:log_request_middleware:30", "request_id": "Gw5vFSCiHFM", "remote_ip": "192.168.128.3:37830", "schema": "http", "protocol": "2", "method": "GET", "path_with_query": "/divide?a=1.5&b=2", "status_code": 422, "response_length": "201", "elapsed": 0.0010986328125, "referer": "-", "user_agent": "python-httpx/0.24.1" }
Registrar erros de validação no log pode ser útil para depurar problemas de validação durante o desenvolvimento e testes. Isso será feito através de um exception handler
que interceptará as exceções do tipo RequestValidationError
. A ideia é apenas registrar o erro de validação no log e depois chamar o exception handler
padrão do FastAPI :
Uma chamada a /divide?a=1.5&b=2
agora gera a seguinte mensagem de log:
{ "timestamp": "2023-10-19T08:05:14.600671+00:00", "level": "INFO", "message": "request validation exception", "source": "exception_handlers.py:request_validation_exception_handler:17", "request_id": "Gw5vFSCiHFM", "method": "GET", "path_with_query": "/divide?a=1.5&b=2", "detail": [ { "type": "int_parsing", "loc": [ "query", "a" ], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "1.5", "url": "https://errors.pydantic.dev/2.4/v/int_parsing" } ] }
Registrando Exceções Não Tratadas no Log
Um outro tipo de erro que pode ocorrer durante o processamento de uma requisição é uma exceção não tratada. Por exemplo, se o endpoint /divide
for chamado com o parâmetro b=0
, será gerada uma exceção ZeroDivisionError
. O cliente recebe uma resposta 500
com uma mensagem de erro genérica:
$ httpx --http2 --no-verify 'https://localhost/api/divide?a=1&b=0' HTTP/2 500 Internal Server Error alt-svc: h3=":443"; ma=2592000 content-type: text/plain; charset=utf-8 date: Thu, 19 Oct 2023 20:58:55 GMT server: Caddy server: hypercorn-h2 content-length: 21 Internal Server Error
Na configuração atual, a aplicação FastAPI não registra a exceção no log, mas a exceção é repassada para o servidor ASGI, que a cria uma mensagem de log do servidor:
[2023-10-19 20:58:53 +0000] [10] [INFO] Running on http://0.0.0.0:5000 (CTRL + C to quit) [2023-10-19 20:58:55 +0000] [10] [ERROR] Error in ASGI Framework Traceback (most recent call last): File "/venv/lib/python3.11/site-packages/starlette/middleware/base.py", line 108, in __call__ response = await self.dispatch_func(request, call_next) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/app/fastapi_minimum/middleware.py", line 22, in log_request_middleware response = await call_next(request) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/starlette/middleware/base.py", line 84, in call_next raise app_exc File "/venv/lib/python3.11/site-packages/starlette/middleware/base.py", line 70, in coro await self.app(scope, receive_or_disconnect, send_no_error) File "/venv/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__ raise exc File "/venv/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__ await self.app(scope, receive, sender) File "/venv/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__ raise e File "/venv/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__ await self.app(scope, receive, send) File "/venv/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__ await route.handle(scope, receive, send) File "/venv/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle await self.app(scope, receive, send) File "/venv/lib/python3.11/site-packages/starlette/routing.py", line 66, in app response = await func(request) ^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/fastapi/routing.py", line 273, in app raw_response = await run_endpoint_function( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/fastapi/routing.py", line 190, in run_endpoint_function return await dependant.call(**values) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/app/fastapi_minimum/routers/hello.py", line 15, in divide return a / b ~~^~~ ZeroDivisionError: division by zero ...
O problema é que não há conexão entre a mensagem de log e a requisição, tornando difícil rastrear a requisição. O ideal é que a mensagem de log da exceção contenha o identificador da requisição tal como nas mensagens de log anteriores.
Normalmente, criaríamos um exception handler
para registrar no log as exceções não tratadas:
O exception handler
acima cria a mensagem de log com o identificador da requisição e a exceção, mas não evita que a exceção seja repassada para o servidor ASGI
, que continua gerando uma mensagem de log duplicada e descontextualizada (veja esse link para mais detalhes).
Para evitar isso, vamos capturar exceções não tratadas no próprio middleware
de log de requisição:
Exceções não-tratadas são capturadas no bloco try..except
(linha 23 a 27) e a exceção fica armazenada na variável exception
para ser usada posteriormente na mensagem de log. A resposta padrão para exceções não-tratadas é uma resposta 500
com uma mensagem de erro genérica (linha 27).
O tipo da mensagem de log da requisição vai depender se houve exceção ou não. Se não houve exceção, a mensagem de log é gerada com o nível INFO
(linha 48). Se houve exceção, a mensagem de log é gerada com o nível ERROR
(linha 50) e a exceção incluída através do método opt, que permite alterar a configuração de uma chamada específica ao logger
.
O traceback
da exceção não-tratada é apresentada junto com a mensagem de log da requisição no campo exception
:
{ "timestamp": "2023-10-20T21:18:55.045870+00:00", "level": "ERROR", "message": "Unhandled exception", "source": "middleware.py:log_request_middleware:50", "request_id": "zv9ueo-i1Hg", "remote_ip": "192.168.144.3:36196", "schema": "http", "protocol": "2", "method": "GET", "path_with_query": "/divide?a=1&b=0", "status_code": 500, "response_length": "34", "elapsed": 0.0005097389221191406, "referer": "-", "user_agent": "python-httpx/0.24.1", "exception": "File \"/venv/lib/python3.11/site-packages/starlette/middleware/base.py\", line 78, in call_next\n 31 async def call_next(request: Request) -> Response:\n (...)\n 74 task_group.start_soon(close_recv_stream_on_response_sent)\n 75 task_group.start_soon(coro)..." }
Testes Automatizados
Para garantir que as mensagens de log estão sendo geradas corretamente, vamos criar alguns testes automatizados em test_logging.py
.
Fixture de Aplicação para Testes
A primeira parte do arquivo test_logging.py
é apresentada abaixo:
As linhas 8 a 19 definem os campos que devem estar presentes em cada tipo de mensagem de log. Esses campos serão usados nos testes para verificar se as mensagens de log estão sendo geradas corretamente.
A função logging_client
(linha 23 a 56) é uma fixture que cria uma nova aplicação com os endpoints necessários para os testes, adiciona o middleware
e o exception handler
de logging e retorna um cliente HTTP
para essa aplicação.
O endpoint /info
retorna uma resposta simples (linhas 38 a 40). Representa uma chamada normal a um endpoint sem exceções ou erros de validação.
/divide
recebe dois parâmetros inteiros a
e b
e retorna o resultado da divisão de a
por b
(linhas 42 a 44). Esse endpoint será usado tanto para testar erros de validação, recebendo um float
ao invés de um inteiro, quanto para exceções não tratadas, ao receber b=0
e gerar uma exceção ZeroDivisionError
.
A função init_loguru
(linha 51) inicializa o logger
com a serialização customizada. Pode haver outras iniciações no lifespan
da aplicação principal, mas apenas essa é necessária para os testes de logging.
Teste de Formato JSON de Mensagens de Log
Na segunda parte do mesmo arquivo, temos o primeiro teste que verifica se a mensagem de log é gerada no formato JSON e se contém os campos esperados:
capsys é uma fixture do pytest que captura a saída padrão e de erro.
As linhas 65 a 67 substituem a função highlight
no módulo logging
por uma função que retorna a entrada sem alterações. Isso evita que a saída para o terminal seja colorida durante o teste, o que dificultaria a comparação com o JSON esperado.
A mensagem de log deve conter pelo menos os campos de request_log_fields
(linha 73).
Como não é uma exceção, o nível deve ser INFO
(linha 74) e o campo exception
não deve estar presente (linha 75).
Teste de Mensagem de Log para Erro de Validação
Na terceira parte de test_logging.py
, temos o teste que verifica se a mensagem de log é gerada corretamente para um erro de validação:
A resposta à requisição que provoca o erro de validação gera duas mensagem de log: uma para a exceção e outra para a requisição. As mensagens estão separadas pela sequência de caracteres \n}\n{\n
(linha 93).
A primeira mensagem é a da exceção. Uma das condições do teste é que o campo detail
da mensagem de log seja igual ao campo detail
da resposta da requisição.
A segunda mensagem é a da requisição. A verificação dos campos é a mesma do teste anterior.
Teste de Mensagem de Log para Exceção Não Tratada
Na quarta parte de test_logging.py
, temos o teste que verifica se a mensagem de log é gerada corretamente para uma exceção não tratada:
A resposta da requisição é uma resposta 500
com a mensagem de erro genérica (linhas 119 e 120). A mensagem de log deve conter pelo menos os campos de exception_log_fields
(linha 123) e o nível de log deve ser ERROR
(linha 124).
Teste de Tipos Não Serializáveis no Log
Na quinta e última parte de test_logging.py
, temos o teste que verifica se tipos que não são serializáveis não causam erros no log:
Apesar de não serem serializáveis para JSON por padrão, os tipos Path
, datetime
e set
não devem causar erros no log porque são convertidos para str
antes de serem serializados, conforme definido na função serialize
em logging.py
.
Conclusão
FastAPI é um dos frameworks mais produtivos e eficientes para desenvolver REST APIs em Python. No entanto, o registro de mensagens de log não faz parte das funcionalidades nativas do FastAPI e precisa ser implementado à parte.
A solução apresentada neste artigo começa com a configuração do Loguru para gerar mensagens de log no formato JSON. A partir daí, criamos configurações adicionais ao FastAPI baseadas em middleware
e um exception handler
para gerar mensagens de log com identificadores únicos para cada requisição, registrar o tempo de processamento de cada resposta e registrar erros de validação e exceções não tratadas. Essas configurações foram testadas e validadas através de testes automatizados.
O resultado final é um sistema de log eficiente e flexível, que pode ser usado como base para o desenvolvimento de qualquer aplicação FastAPI. Ajustes podem ser feitos para atender às necessidades específicas de cada projeto, mas as necessidades básicas de monitoramento e depuração já estão cobertas.
O código apresentado pode ser usado com poucas modificações em outros projetos FastAPI. Mas se você quiser começar um novo projeto FastAPI com essas funcionalidades, a melhor opção é usar o gabarito disponibilizado no GitHub. Para instanciar um novo projeto, é necessário usar o cookiecutter. Recomendo combiná-lo com pipx:
Referências
- A Complete Guide to Logging in Python with Loguru
- FastAPI: Experiment Middleware feature
- OWASP - Logging Cheat Sheet
- Logging in Python: A Comparison of the Top 6 Libraries
Próximo artigo: Desenvolvendo uma Aplicação em FastAPI para Compartilhamento de Arquivos - Parte 1
Artigo anterior: Empacotamento e Distribuição do Projeto FastAPI Mínimo
Comentários
Comments powered by Disqus