2021/02/07 追記 例外時にエラー内容とスタックトレースを含めてロギングする方法を以下の記事で紹介しています。
FastAPIで例外時にエラーとスタックトレースをロギングする
FastAPI に触れる機会があって、ロギングについてあれこれ考えています。Learn Microservices Logging Best Practices にあるようにリクエスト毎にコンテキストをロギングした方が良さそうです。さらに詳細に調べたい場合に備えてリクエストボディとレスポンスボディの中身もロギングした方が良い場合もあります。
Nginx などをリバースプロキシとして置くことでロギングはできるかと思いますが、今回は FastAPI のみでロギングできる方法を調べてみました。
FastAPI とは
FastAPI は Python のウェブフレームワークです。パフォーマンスの高さなどで最近注目されており、API を実装するのに向いています。
サンプルアプリ
GitHub に置いています。
API の中身はドキュメントにあるチュートリアルレベルです。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): name: str price: float is_offer: bool = None @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q} @app.post("/items/") def post_item(item: Item): return {"item_name": item.name, "item_price": item.price} @app.put("/items/{item_id}") def update_item(item_id: int, item: Item): return {"item_name": item.name, "item_id": item_id}
Middleware を使う方法
Middleware はリクエストを受け取ってレスポンスを返す間に処理をすることができます。ドキュメントには処理時間をカスタムヘッダ(X-Process-Time
)に入れる例が載っています。
import time from fastapi import FastAPI, Request app = FastAPI() @app.middleware("http") async def add_process_time_header(request: Request, call_next): start_time = time.time() response = await call_next(request) process_time = time.time() - start_time response.headers["X-Process-Time"] = str(process_time) return response
これを応用して、リクエスト毎に ID を振ってログ出力している例が以下の記事で紹介されています。
FastAPI logging. Here is how I setup my python logging… | by Philippe Girard | Medium
ただ、この方法だとリクエストとレスポンスの中身を取り出すことができません(少なくとも私には難しいです)。Stack Overflow を見るとルーターをカスタマイズする方法が紹介されていました。
python - Get starlette request body in the middleware context - Stack Overflow
カスタムルータを実装する方法
ドキュメントによると、アプリケーションで処理される前にリクエストボディを取得したり操作する場合にこのケースが良いとのことです。
ユースケースは以下のとおりです。まさしく3つ目が今回やりたかったことなのでこの方法を採用しました。
- msgpack など非 JSON リクエストボディを JSON に変換する
- gzip で圧縮されたリクエストボディを解凍する
- すべてのリクエストボディをロギングする
以下にスクリプトを載せます。ただし、ドキュメントにある通り、アドバンスドな機能なので、各自責任の上でご利用ください。
import time import datetime import sys import json from logging import getLogger, StreamHandler, DEBUG from typing import Callable from fastapi import FastAPI, Request, Response from fastapi.routing import APIRoute from pydantic import BaseModel logger = getLogger(__name__) handler = StreamHandler(sys.stdout) handler.setLevel(DEBUG) logger.addHandler(handler) logger.setLevel(DEBUG) class LoggingContextRoute(APIRoute): def get_route_handler(self) -> Callable: original_route_handler = super().get_route_handler() async def custom_route_handler(request: Request) -> Response: """ 時間計測 """ before = time.time() response: Response = await original_route_handler(request) duration = round(time.time() - before, 4) record = {} time_local = datetime.datetime.fromtimestamp(before) record["time_local"] = time_local.strftime("%Y/%m/%d %H:%M:%S%Z") if await request.body(): record["request_body"] = (await request.body()).decode("utf-8") record["request_headers"] = { k.decode("utf-8"): v.decode("utf-8") for (k, v) in request.headers.raw } record["remote_addr"] = request.client.host record["request_uri"] = request.url.path record["request_method"] = request.method record["request_time"] = str(duration) record["status"] = response.status_code record["response_body"] = response.body.decode("utf-8") record["response_headers"] = { k.decode("utf-8"): v.decode("utf-8") for (k, v) in response.headers.raw } logger.info(json.dumps(record)) return response return custom_route_handler app = FastAPI() app.router.route_class = LoggingContextRoute class Item(BaseModel): name: str price: float is_offer: bool = None @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q} @app.post("/items/") def post_item(item: Item): return {"item_name": item.name, "item_price": item.price} @app.put("/items/{item_id}") def update_item(item_id: int, item: Item): return {"item_name": item.name, "item_id": item_id}
APIRoute
クラスを継承して get_route_handler()
をオーバーライドすることでルータをカスタマイズしています。取得したリクエストボディなどを JSON 形式にしてロギングしています。フォーマット名は Nginx のログ形式 を参考にしています。
time_local: リクエストを受け取った日時 request_body: リクエストボディ request_headers: リクエストヘッダ remote_addr: アクセス元 IP アドレス request_uri: リクエストパス request_method: リクエストメソッド request_time: リクエストの処理時間(秒) status: HTTPステータスコード response_body: レスポンスボディ response_headers: レスポンスヘッダ
ログ出力の確認
curl でリクエストしてみてみます。
# GET $ curl localhost:8000/ $ curl localhost:8000/items/12?foo=bar # POST $ curl -X POST "http://127.0.0.1:8000/items/" \ -H "accept: application/json" \ -H "Content-Type: application/json" \ -d "{\"name\":\"foo\",\"price\":1234,\"is_offer\":true}" # PUT curl -X PUT "http://127.0.0.1:8000/items/5678" \ -H "accept: application/json" \ -H "Content-Type: application/json" \ -d "{\"name\":\"foo\",\"price\":100,\"is_offer\":true}"
ログは以下のとおりです。uvicorn のアクセスログも出ていますが、取得したいログが出ていることがわかります。
{"time_local": "2021/01/31 10:29:45", "request_headers": {"host": "localhost:8000", "user-agent": "curl/7.68.0", "accept": "*/*"}, "remote_addr": "127.0.0.1", "request_uri": "/", "request_method": "GET", "request_time": "0.0008", "status": 200, "response_body": "{\"Hello\":\"World\"}", "response_headers": {"content-length": "17", "content-type": "application/json"}} INFO: 127.0.0.1:37996 - "GET / HTTP/1.1" 200 OK {"time_local": "2021/01/31 10:29:45", "request_headers": {"host": "localhost:8000", "user-agent": "curl/7.68.0", "accept": "*/*"}, "remote_addr": "127.0.0.1", "request_uri": "/items/12", "request_method": "GET", "request_time": "0.0005", "status": 200, "response_body": "{\"item_id\":12,\"q\":null}", "response_headers": {"content-length": "23", "content-type": "application/json"}} INFO: 127.0.0.1:37998 - "GET /items/12?foo=bar HTTP/1.1" 200 OK {"time_local": "2021/01/31 10:29:45", "request_body": "{\"name\":\"foo\",\"price\":1234,\"is_offer\":true}", "request_headers": {"host": "127.0.0.1:8000", "user-agent": "curl/7.68.0", "accept": "application/json", "content-type": "application/json", "content-length": "43"}, "remote_addr": "127.0.0.1", "request_uri": "/items/", "request_method": "POST", "request_time": "0.0005", "status": 200, "response_body": "{\"item_name\":\"foo\",\"item_price\":1234.0}", "response_headers": {"content-length": "39", "content-type": "application/json"}} INFO: 127.0.0.1:38000 - "POST /items/ HTTP/1.1" 200 OK {"time_local": "2021/01/31 10:29:45", "request_body": "{\"name\":\"foo\",\"price\":100,\"is_offer\":true}", "request_headers": {"host": "127.0.0.1:8000", "user-agent": "curl/7.68.0", "accept": "application/json", "content-type": "application/json", "content-length": "42"}, "remote_addr": "127.0.0.1", "request_uri": "/items/5678", "request_method": "PUT", "request_time": "0.0004", "status": 200, "response_body": "{\"item_name\":\"foo\",\"item_id\":5678}", "response_headers": {"content-length": "34", "content-type": "application/json"}} INFO: 127.0.0.1:38002 - "PUT /items/5678 HTTP/1.1" 200 OK
今後
上の方法だけでは例外処理などによるスタックトレースはロギングされない(JSON 形式に出力されない)ので改良の余地はあります。