構造化ログに格納するトレースバックのフォーマット

structlogのドキュメントを読んでいると、 structured tracebacksというものが登場する。 structured logging が良いものなのだから、structured tracebacksも良いものなのだろうか?

API Reference - structlog 24.1.0 documentation

structlog.processors.dict_tracebacks(logger, name, event_dict)

Replace an exc_info field with an exception field containing structured tracebacks suitable for, e.g., JSON output.

これを使うと、次のように1フレーム1 dictの形式でスタックトレースが出力される。

"frames": [{"filename": "/Users/inada-n/notes/structlog-test/traceback_sample.py", "lineno": 17, "name": "<module>", "line": "", "locals": {"__name__": "__main__", "__doc__": "None",....

デフォルトではローカル変数が表示されてしまっているので、ローカルでデバッグするくらいならいいかもしれないが、構造化ログに入れてObservability Platformに送信するにはデカすぎる。

dict_tracebacks の代わりに ExceptionRendererExceptionDictTransformer を使って自分で設定すればローカル変数を表示しないことは可能だが、それでもプレインテキストのトレースバックに比べると大幅に大きくなってしまう。

なぜ構造化ログが使いたかったのかと言えば、SQLライクな構文でログをフィルタリングするのに便利だったり、ログから値を集計するのに便利だったりするからだ。トレースバックの中身まで構造化して、この目的に寄与するとは思えない。と言うことで、個人的に strucutred tracebacks は却下だ。

構造化ログにどのように例外情報を格納するかについては、OpenTelemetryのConventionが今後の標準になっていくだろうから、それに従うのが一番だろう。

opentelemetry.io

"exception.type" に型を、 "exception.message" にメッセージを、そして "exception.traceback" に、(1フレーム1文字列の配列などではなく)ただ1つの文字列としてスタックトレースを書くことが推奨されている。構造化ログのメリットから考えても、型とメッセージをトレースバックと分けて格納する設計には納得できる。

さらにスタックトレースのフォーマットについては "in the natural representation for the language runtime." と書かれていて、言語の標準的なフォーマットを使うことが推奨されている。

Pythonの標準のtracebackがあまり構造化ログに入れてObservability Platformに送信するのに適していない件は先の記事で書いた。とりあえず当面はその記事で言及した PYTHONNODEBUGRANGES=1 環境変数chain=False オプションを使ってトレースバックの肥大化を避けるのがいいだろう。しかしそれだけでは、他の言語の標準的なトレースバックフォーマットが most recet call first なのに対してPythonのものがmost recent call lastなのは解決できてない。Web UIでログの中のトレースバックを見る時にはスクロールが必要になるだろう。

methane.hatenablog.jp

より良いログフォーマットとして、この記事で紹介していた minitraceback も試してみてもらえると嬉しい。ドキュメントはまだないけれど、APIはほぼ標準tracebackのサブセットになっているので使い方には困らないだろう。

将来のことを考えると標準化が重要なので、 disscuss.python.org で標準の traceback モジュールに構造化ログとObservability Platformに適した形式でで出力するためのオプションを追加する提案をしているところだ。(時期的にPython 3.13に間に合わすのは厳しいけれども。)

discuss.python.org

uWSGIで大きいログが壊れないようにする設定

uWSGIがファイルやパイプにログを書くときは、各ワーカープロセスが直接ファイルやパイプに書く場合と、一旦masterプロセスにログを送ってmasterプロセスからログを書く場合があります。

masterプロセスにログを送る設定は --log-master ですが、このオプションを暗黙的に有効にするオプションがあります。 UWSGI_OPT_LOG_MASTERUWSGI_OPT_REQ_LOG_MASTER というフラグが指定された場合に log_master が設定されるようになっていて、uwsgi-2.0ブランチではこれらのオプション(エイリアスを含む)にそのフラグが指定されています。

touch-logrotate
touch-logreopen
log-syslog
log-socket
req-logger
logger-req
logger
threaded-logger
log-encoder
log-req-encoder
log-drain
log-filter
log-route
log-req-route
log-alarm
alarm-log
not-log-alarm
not-alarm-log
log-master
log-maxsize

他にもネットワーク経由で外にログを飛ばすようなプラグインがlog_masterを有効にすることもあるので、なにかプラグインを使っている場合は確認してください。

log_masterを有効にしていない場合は、ログの出力先がファイルなら1回のwriteで書かれるサイズは実質的に無制限にアトミックに書かれ、他のスレッドやプロセスからのwrite()と混ざらないと思います。参考に、Linuxの man wirte(2)には次のように書かれていて、Linux 3.14からは問題ないようです。あまりに大きい場合は途中でシグナルが来た時にどうなるかわかりませんが、バッファに乗る範囲のログなら気にしなくてもいいんじゃないかな。

       According to POSIX.1-2008/SUSv4 Section XSI 2.9.7 ("Thread
       Interactions with Regular File Operations"):

           All of the following functions shall be atomic with respect
           to each other in the effects specified in POSIX.1-2008 when
           they operate on regular files or symbolic links: ...

       Among the APIs subsequently listed are write() and writev(2).
       And among the effects that should be atomic across threads (and
       processes) are updates of the file offset.  However, before Linux
       3.14, this was not the case: if two processes that share an open
       file description (see open(2)) perform a write() (or writev(2))
       at the same time, then the I/O operations were not atomic with
       respect to updating the file offset, with the result that the
       blocks of data output by the two processes might (incorrectly)
       overlap.  This problem was fixed in Linux 3.14.

write(2) - Linux manual page

パイプの場合は一度に書き込めるサイズはPIPE_BUFで、POSIXで512バイト、Linuxでは4096バイトになります。これを超える場合は一度でwriteできない可能性があり、1行のログをループで複数回write()する可能性があるので、他のプロセスが書いたログが1行の間に割り込む可能性があります。ログを標準出力等からパイプに書きたい場合は、 --log-master を指定した方が壊れにくくなります。

   PIPE_BUF
       POSIX.1 says that writes of less than PIPE_BUF bytes must be
       atomic: the output data is written to the pipe as a contiguous
       sequence.  Writes of more than PIPE_BUF bytes may be nonatomic:
       the kernel may interleave the data with data written by other
       processes.  POSIX.1 requires PIPE_BUF to be at least 512 bytes.
       (On Linux, PIPE_BUF is 4096 bytes.)

pipe(7) - Linux manual page

つぎは log_master が有効な場合です。この場合、 --log-master-stream を指定していなければUnixドメインソケットでパケット型の通信をします。Linuxの場合は SOCK_SEQPACKET が使われます。

// core/logging.c の create_logpipe()
#if defined(SOCK_SEQPACKET) && defined(__linux__)
        if (socketpair(AF_UNIX, SOCK_SEQPACKET, 0, uwsgi.shared->worker_log_pipe)) {
#else
        if (socketpair(AF_UNIX, SOCK_DGRAM, 0, uwsgi.shared->worker_log_pipe)) {
#endif

システムコールとして送受信できるサイズはソケットバッファに制限され net.core.wmem_default, net.core.rmem_default がデフォルトですが、実際には uWSGI の log_master_bufsize のデフォルト値 8192 バイトがログ1行を壊れずに書けるデフォルトの上限になります。uWSGIがこのサイズの送受信バッファを持っていて、このサイズを超える部分は分割ではなく破棄されるためです。

なるべくログを壊したくない場合は、 --log-master-bufsize オプションにもっと大きい値を指定できまず。ソケットバッファが log-master-bufsize より小さい場合はsetsockoptでソケットバッファも大きくするので、net.core.wmem_default, net.core.rmem_default よりも大きい値を指定できます。 --log-master-bufsize のドキュメントには64bit整数と書かれていますが、 setsockopt を呼び出す時にint型を利用しているので、実質的な最大値は 231-1 になります。

ログ転送ミドルウェアがログを読み込む時にも1レコードの最大サイズを指定することが多いと思うので、それと合わせて、普段の数倍のデカいスタックトレースが生成されてもログを取りこぼさない程度の大きさを指定するといいでしょう。

構造化ログ用のコンパクトなトレースバック minitraceback

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 で利用する方法などを紹介します。

Goのerrorがスタックトレースを含まない理由

Twitterでこんな記事を見かけたので。

zenn.dev

ジェネリクスの件もそうですが、Goの言語設計は現実主義なのになにか特別なポリシーによるものだと宗教化されてしまって、ファンには勝手に崇拝されてアンチにはディスられがちだなーと感じます。

Goのエラー処理を改善する実験プロジェクトxerrorsがGo本体のerrorsにマージされた時、 errors.New()スタックトレースを取得していました。しかしGo 1.13がリリースされる前に削除されました。

削除された理由の1つは、今までの errors.New() のパフォーマンスに依存していたコードの速度が低下しアロケーションが増えることです。

github.com

しかし、これが理由だと今まで思ってたのですが、実際にはもう1つより重要な理由がありました。エラーのフォーマットです。エラーに複数のフォーマットを持たせようという提案が、経験不足を理由に延期されたのです。

Go 1.13 のステータスを説明するコメント

複数の異なるフォーマットの扱いが仕様化されてないということは、スタックトレースの情報をエラーにつけてもそれを表示する手段がありません。すでに使われている表示手段は後方互換性のために変えられないので、スタックトレース付きでエラー表示をするための追加仕様が必要なのです。表示する手段が存在しないのに、単に遅くてアロケーションが多いだけのスタックトレース付きエラーを標準ライブラリに入れる必要はありません。

ということで、Go標準でスタックトレース情報付きのエラーを提供することは、エラーのフォーマットを拡張する提案の中に含まれる形で現在もドラフトになっています。

go.googlesource.com

ということで、Goの開発者やGooglerが何か特別なポリシーや宗教上の理由でスタックトレースをエラーに付けていないわけではありません。単に影響範囲がデカい言語仕様だとかコアライブラリの新規APIに対する仕様追加に極めて慎重で時間をかけてるだけでした。エラーにスタックトレースをつけるライブラリを使っても、Go Wayに違反して善きGopherになれなくなるなんて事は全くないので、安心して使えば良いと思います。

その際は他のライブラリの選定の時と同じく、信頼性のあるソフトウェアを参考にしつつ、依存ライブラリの数やシンプルさ、将来Goの標準ライブラリに追加された時に移行しやすそうかなどを考えて選べば良いでしょう。 特に1つのライブラリをお勧めできるほどGoのエコシステムに詳しいわけではありませんが一例として、CockroachDBは(名前が最悪なのはともかく)Goで書かれた信頼性の高いソフトウェアなので、 github.com/cockroachdb/errors は参考になるかもしれませんし、分散システムを作るのでなければ複雑すぎるかもしれません。

追記

上で紹介したドラフト以外にも、エラーにスタックトレースをつけるための複数の提案がTwitter上で紹介されていました。

github.com

github.com

structlogとloggingの併用方法について

Pythonでアプリケーションのロギングライブラリとして標準ライブラリのlogging以外を使うときは、loggingを使ってる既存のコードをどうするかを考えないといけない。アプリケーション自体の中身を全部一気に書き換えるとしても、依存ライブラリがloggingを使ってるかもしれないからだ。

structlogのドキュメントにもそのためのページが用意されている。

www.structlog.org

最初に出ている選択肢として統合しない(Don't Integrate)と言うものがある。これについては一番最後に紹介する。

残りの3つの選択肢は、 structlog の Logger が logging.Logger の .info() 等のメソッドを呼び出す形をとり、その後の処理は logging のカスタマイズで対応する。

Rendering Within structlog

structlogでJSONにまでフォーマットしてしまい、それをmessageとしてloggingに渡すと言うもの。 logger.info(message) の message 部分にJSONがそのまま入る形だ。

これはloggingのフォーマットを "%(message)s" だけにしておかないとJSONの前後にログレベルとかタイムスタンプとかが付いて JSON lines ではなくなってしまう。だがこのフォーマットでは既存のloggingを使ったログをJSONにできない上に、情報が少なすぎる。

これはちょっと扱いにくいので、選択肢から外す。

Rendering Using logging-based Formatters

こちらは、 logger.info("hello", extra={"key":12}) のようにmessage以外のkey-valueをextraとして渡して、 loggingのFormatterでJSONにするという手順だ。

ここで紹介されているJSONにするためのFormatterが python-json-logger だ。 structlogは完全にloggingのフロントエンドとしてのみ利用するので、必要な設定も自動で追加する項目などにしぼり、フォーマットに関する設定はpython-json-logger側になる。

依存ライブラリが増えるのと、この構成を理解して設定するのが若干手間だけれども、非常にうまく統合されるのでこれは有力な選択肢だ。

ただし、せっかく structlog の使い方を調べて理解したのに、フォーマット関連の知識が全く使い物にならなくなってしまって新たに調べ直す必要があるのは残念な点ではある。

Rendering Using structlog-based Formatters Within logging

こちらは、構成としては先の "Rendering Using logging-based Formatters" と同じになる。ただしFormatterとしてstructlogが提供している structlog.stdlib.ProcessorFormatter を使うので、structlogのLoggerとProcessorFormatterがより高度に連携できる。

JSON化するときのフォーマットも、通常のstructlogを設定するときと同じようにprocessorsでできるから、依存ライブラリを増やさなくてもいい上にstructlogを調査したときに覚えた設定方法がそのまま使えるのも魅力だ。

一方で設定が複雑になる部分もあり、 structlogのBoundLoggerの中、logging.Loggerに渡す前に行う処理は structlog.configure()processors= に、 ProcessorFormatter に来たログのうち、フロントエンドがstructlogではない通常のログに対して行う前処理は ProcessorFormatter()foreign_pre_chain= に、最後に全てのログに対して行う処理は ProcessorFormatter()processors= にと、考えを整理して正しい場所で処理する必要がある。

複雑になる分だけ、パフォーマンスも落ちやすい。パフォーマンスについては最後に簡単なベンチマーク結果を紹介する。

Don't Integrate

これは structlog から logging.Logger の .info() などのメソッドを呼び出すのではなく、両者がそれぞれにログをフォーマットして出力する、一番シンプルな方法だ。 別々とはいえ、 logging.StreamHandler と structlog.Write に同じオブジェクト(sys.stdoutかsys.stderr)を渡せば、最終的な出力は同じ場所にできる。

この場合、 ログが行の途中で混ざらないように、出力ファイルの .write() メソッドのアトミック性に依存する必要がある。そのために structlog.PrintLogger は使えないことに注意が必要だ。PrintLoggerは print(message, file=file, flush=True) をするのだけれど、このとき print の内部では file.write(message); file.write("\n"); file.flush() を行うので、マルチスレッドでログを書いたときにログ本体と改行の間に他のログが混ざってしまう可能性がある。マルチスレッドプログラムでprintを使っているとよく目にする壊れ方だ。

さて、別々に設定するとはいえ、最終的な出力をJSON linesに統一したいのであれば、結局 logging のフォーマットをJSONにしないといけない。そのためには上のpython-json-loggerか structlog.stdlib.ProcessorFormatter を使うことになる。

この場合だと、 structlog.stdlib.ProcessorFormatter を使った方が、structlog側のログとlogging側のログを同じ形に揃えやすいし、依存ライブラリも減らせるのでおすすめだ。

ベンチマーク

次の関数を、いろいろな構成のloggerを使って実行するだけのベンチマークをしてみた。

def hello(logger):
    logger.warning("hello")  # {"foo": "bar", "event": "hello"}
    logger.info("goodby %s", "world")  # {"arg": "world", "event": "goodby"}

なお、ベンチマーク中にstructlogが不自然に遅いケースがあったため調べて次の最適化を行った。

1つ目のPRはstructlogがログに呼び出し元のファイル名、行、関数名などを埋め込むCallsiteParameterAdderを高速化するもので、これを使うかどうかでstructlogの性能が大きく変わるので、構成の違いに加えてCallsiteParameterAdderの有無も変えて計測している。すでに取り込まれたので、次のリリースからは下のベンチマークと同じ速度まで速くなるはずだ。

2つ目のPRは、CallsiteParameterAdderやstdlib統合するときに使うfindCaller()というメソッドで使っている処理を高速化するもの。こちらは高速化効果が小さい上に、マルチスレッド+何かの組み合わせの時によくわからないバグが発生する可能性があるだとかで、取り込まれなさそうだ。

まずはloggingと統合する方のベンチマークから。

ソース

結果:

CALLSITE=False N=100000
stdlib: 20145ns
stdlib+ProcessorFormatter: 35324ns
structlog+stdlib+ProcessorFormatter: 45063ns

CALLSITE=True N=100000
stdlib: 19964ns
stdlib+ProcessorFormatter: 52585ns
structlog+stdlib+ProcessorFormatter: 60687ns
  • CALLSITEはCallsiteParameterAdderの有無
  • stdlibは標準ライブラリのLoggerとFormatterのみ(JSONではない)
  • stdlib+ProcessorFormatterはFormatterをProcessorFormatterに置き換えたもの(JSON
  • そしてstructlog+stdlib+ProcessorFormatterはstructlogのLogger経由でstdlib+ProcessorFormatterを呼び出すもの

structlog+stdlib+ProcessorFormatterはオーバーヘッドが大きく、またCallsiteParameterAdderも遅いのがわかるだろう。これでも上のPRで大分速くなったんだけどね…

次にloggingと分離したまま、同一ファイルに出力する方のベンチマーク

ソース

結果:

CALLSITE=False ORJSON=False N=100000
stdlib: 16008ns
stdlib+ProcessorFormatter: 30076ns
stdlib+json_formatter: 26234ns
structlog: 11394ns

CALLSITE=True ORJSON=False N=100000
stdlib: 16217ns
stdlib+ProcessorFormatter: 33315ns
stdlib+json_formatter: 25804ns
structlog: 19898ns

# ORJSONはstructlogにのみ関係するのでそれだけ掲載
CALLSITE=True ORJSON=True N=100000
structlog: 15110ns

CALLSITE=False ORJSON=True N=100000
structlog: 6830ns
  • CALLSITE, stdlib, stdlib+ProcessorFormatter の意味はさっきの統合版と同じ
  • stdlib+json_formatterはloggingのFormatterにpython-json-loggerを使ってカスタマイズしてなるべく出力を似せたもの
  • structlogは、loggingと統合しないときのstructlogの性能。フォーマットはstdlib+ProcessorFormatterと同じ
  • ORJSONはJSONのシリアライザにorjsonを使ったもの。高速だが、シリアライズ結果がbytesになるので、stdlib+ProcessorFormatterでは使いにくい。

まず目につくのは、stdlibの速度が統合版の20usから16usに速くなってることだ。これはstdlib統合版がloggingが使うLoggerクラスを置き換えて、呼び出し元を調べる findCaller() を置き換えたものだ。loggingのfindCaller()はlogging内のメソッドをスキップしてそれ以外の最初の呼び出し元を探すのだけれども、structlog版はlogging, structlog, その他設定でスキップすると指定されたprefixで始まるモジュールを飛ばすので遅くなっている。

ちなみにloggingはLoggerがLogRecordを作ってHandlerに渡すという処理になっていて、findCaller()はHandlerで呼び出し元が必要かどうかに関係なく呼ばれるので、CALLSITEがTrueでもFalseでも非統合版の方が速くなっている。

CALLSITE=Falseのときの速度は structlog < stdlib < stdlib+json_formatter < stdlib+ProcessorFormatter の順になっている。loggingを使う時はProcessorFormatterよりjson_formatterの方が速いが、一番多いログをstructlogで出力するのであればProcessorFormatterの遅さも許容範囲で、むしろ設定がstructlogと合わせやすいメリットが大きいだろう。

CALLSITE=Trueの場合はJSONでない素のloggingに負けるが、それでも統合版のloggingと同程度の速度は出ている。JSONリアライザをorjsonにすることで素のloggingと同じ速度まで高速化できて、さらにCALLSITE=Falseと組み合わせれば素のloggingより倍くらい速くなる。

以上の結果から、構成の複雑さと性能、依存ライブラリ(python-json-logger)を増やさないことなどを天秤にかけると、loggingとstructlogを統合しないままloggingのFormatterにProcessorFormatterを使うのはかなり良い選択肢に思える。

structlogのドキュメントではloggingとstructlogを統合しない選択肢についてはかなりあっさりとしか書かれていないので、もう少し追記する提案をしてみるつもりだ。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。