Pythonのデフォルトの例外+トレースバック表示は、主にターミナル表示向けに調整されています。(次のPython 3.13からはカラー表示にも対応する予定です。)
ターミナル表示用にフォーマットされたトレースバックは、(もちろんカラーを使わなければ)非構造化ログに書いても特に問題ありませんでした。しかし、1行に1つのJSONの形で構造化ログを書くときにはあまり適していない気がします。
- SQLAlchemyなどの複雑なライブラリを使うとトレースバックが巨大になり、ログ転送ツール等の1レコードあたり容量制限に気をつけないといけない。
- エラーとエラーの発生元が最後にくるフォーマットはログが上に流れているターミナルでは見やすいが、Web UIから見る時やJSONをそのまま見るときは最初にエラーが来て欲しい。
- 改行やクォートがJSONに入る時はエスケープされるので、なおさら見にくくなる。
試しに、SQLAlchemyでsqliteのファイルを開けなかった時のエラーのスタックトレースは次のようになります。
Traceback (most recent call last): File "/Users/inada-n/tmp/sample.py", line 74, in main conn = engine.connect() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3280, in connect return self._connection_cls(self) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 148, in __init__ Connection._handle_dbapi_exception_noconnection( File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 2444, in _handle_dbapi_exception_noconnection raise sqlalchemy_exception.with_traceback(exc_info[2]) from e File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 146, in __init__ self._dbapi_connection = engine.raw_connection() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3304, in raw_connection return self.pool.connect() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 449, in connect return _ConnectionFairy._checkout(self) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 1263, in _checkout fairy = _ConnectionRecord.checkout(pool) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 712, in checkout rec = pool._do_get() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/impl.py", line 179, in _do_get with util.safe_reraise(): File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 146, in __exit__ raise exc_value.with_traceback(exc_tb) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/impl.py", line 177, in _do_get return self._create_connection() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 390, in _create_connection return _ConnectionRecord(self) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 674, in __init__ self.__connect() File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 900, in __connect with util.safe_reraise(): File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/util/langhelpers.py", line 146, in __exit__ raise exc_value.with_traceback(exc_tb) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/pool/base.py", line 896, in __connect self.dbapi_connection = connection = pool._invoke_creator(self) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/create.py", line 643, in connect return dialect.connect(*cargs, **cparams) File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/default.py", line 617, in connect return self.loaded_dbapi.connect(*cargs, **cparams) sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file (Background on this error at: https://sqlalche.me/e/20/e3q8)
そして、実はこれでも短くしている方なのです。このトレースバックは PYTHONNODEBUGRANGES=1
という環境変数を設定して出力しましたが、この環境変数を設定しなかったら次のように行内の例外発生箇所を示すマークが付いて、1フレームごとに2行ではなく3行消費することがあります。
Traceback (most recent call last): File "/Users/inada-n/tmp/sample.py", line 74, in main conn = engine.connect() ^^^^^^^^^^^^^^^^ File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 3280, in connect return self._connection_cls(self) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/inada-n/tmp/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 148, in __init__
また、 chain=False
を設定して最終的にキャッチしたエラーのスタックトレースだけを表示していますが、デフォルトでは chain=True
なのでsqliteのファイルを開けなかった部分のスタックトレースも表示されて、合計で100行近くになります。
ある程度の見やすさを確保しつつ、構造化ログに入れることを考えたフォーマットを考えてみました。
- エラーとエラーの発生元を最初に持ってくる(標準ライブラリと逆)
- ファイルパスはsys.pathからの相対パスにする
- 行番号だけあれば調査は可能なので、ソースコードは表示しない。ただしヒントとして関数名だけは書いておく。
- トレースバック部分は1フレームに1つのNamedTupleで表現し、好きにカスタマイズできるようにする。
これで作ったトレースバックのサンプルがこれです。
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file (Background on this error at: https://sqlalche.me/e/20/e3q8) Traceback (most recent call first): sqlalchemy/engine/default.py:617 connect sqlalchemy/engine/create.py:643 connect sqlalchemy/pool/base.py:896 __connect sqlalchemy/util/langhelpers.py:146 __exit__ sqlalchemy/pool/base.py:900 __connect sqlalchemy/pool/base.py:674 __init__ sqlalchemy/pool/base.py:390 _create_connection sqlalchemy/pool/impl.py:177 _do_get sqlalchemy/util/langhelpers.py:146 __exit__ sqlalchemy/pool/impl.py:179 _do_get sqlalchemy/pool/base.py:712 checkout sqlalchemy/pool/base.py:1263 _checkout sqlalchemy/pool/base.py:449 connect sqlalchemy/engine/base.py:3304 raw_connection sqlalchemy/engine/base.py:146 __init__ sqlalchemy/engine/base.py:2444 _handle_dbapi_exception_noconnection sqlalchemy/engine/base.py:148 __init__ sqlalchemy/engine/base.py:3280 connect sample.py:74 main
JSONにするとこうなります。トレースバックの1行を1文字列にしているのでエスケープ文字が入らないですし、見やすいJSONリーダーなどがあれば十分にそのまま読むことができます。
"exception": ['sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) unable to open database file\n(Background on this error at: https://sqlalche.me/e/20/e3q8)'], "stacktrace": ["sqlalchemy/engine/default.py:617 connect", "sqlalchemy/engine/create.py:643 connect", "sqlalchemy/pool/base.py:896 __connect", "sqlalchemy/util/langhelpers.py:146 __exit__", "sqlalchemy/pool/base.py:900 __connect", "sqlalchemy/pool/base.py:674 __init__", "sqlalchemy/pool/base.py:390 _create_connection", "sqlalchemy/pool/impl.py:177 _do_get", "sqlalchemy/util/langhelpers.py:146 __exit__", "sqlalchemy/pool/impl.py:179 _do_get", "sqlalchemy/pool/base.py:712 checkout", "sqlalchemy/pool/base.py:1263 _checkout", "sqlalchemy/pool/base.py:449 connect", "sqlalchemy/engine/base.py:3304 raw_connection", "sqlalchemy/engine/base.py:146 __init__", "sqlalchemy/engine/base.py:2444 _handle_dbapi_exception_noconnection", "sqlalchemy/engine/base.py:148 __init__", "sqlalchemy/engine/base.py:3280 connect", "sample.py:74 main"]
とりあえずここまでを minitraceback という名前でPyPIに登録しておきました。 また次回の記事で structlog で利用する方法などを紹介します。