structlogのBoundLoggerについて (1/n)

structlogを採用した場合、アプリ側がログを書くために使うインスタンスはBoundLoggerかそれをラップしたクラスのインスタンスになります。このクラスは重要なのでstructlogのドキュメントにもこのクラスのためのページがあります。

www.structlog.org

structlogを本格的にカスタマイズして使っていく場合は、まずこのクラスを理解する必要があります。このクラスの動作と実装について解説していきます。

BoundLogger の役割

https://www.structlog.org/en/stable/_images/BoundLogger.svg

BoundLoggerは、コンテキスト変数を管理し、 .debug() などのログメソッドが呼ばれたらそれを処理して "wrapped logger" と呼ばれるクラスのメソッドを呼び出すのが役割です。まずはその役割を示す例を見てみましょう。

>>> import structlog
>>> logger = structlog.get_logger()  # (1)
>>> log = logger.bind(a=1)   # (2)
>>> structlog.get_context(logger)
{}
>>> structlog.get_context(log)
{'a': 1}
>>> log.debug("hello", target="world")  # (3)
2024-06-13 18:42:23 [debug    ] hello                          a=1 target=world

(1) で structlog のデフォルト構成で BoundLogger (実際にはそれをラップしているクラス) のインスタンスを作っています。

(2) で a=1 というコンテキストをbindして、新たなBoundLoggerを作っています。これで logger と log は、同一のプロセッサーや wrapped logger を共有していますが、異なるコンテキストを持つBoundLoggerインスタンスになります。 structlog.get_context() にBoundLoggerを渡せばロガーが持っているコンテキストが異なることを確認できます。

(3) で .debug() というログメソッドを呼び出しました。このメソッドはキーワード引数とコンテキスト変数、そしてログメッセージからイベントを作り、それを processor で処理してから wrapped logger に渡します。デフォルトの wrapped logger は単に print() を実行するだけの PrintLogger です。デフォルトのprocessorが、ターミナル出力向けに色をつけたりカラムに揃えたりするようになっているので、このように綺麗な出力が得られます。

出力例

コンテキスト管理

ここからstructlogのソースコードを読んでいきましょう。BoundLoggerの主な実装はBoundLoggerBaseにあります。

structlog/src/structlog/_base.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

WrappedLogger = Any  # typing.py で定義されている

class BoundLoggerBase:

    _logger: WrappedLogger

    def __init__(
        self,
        logger: WrappedLogger,
        processors: Iterable[Processor],
        context: Context,
    ):
        self._logger = logger
        self._processors = processors
        self._context = context

ここでは引数で与えられた logger (wrapped logger) と processors, context を単純に持つだけのクラスを作っています。

    def bind(self, **new_values: Any) -> BoundLoggerBase:
        """
        Return a new logger with *new_values* added to the existing ones.
        """
        return self.__class__(
            self._logger,
            self._processors,
            self._context.__class__(self._context, **new_values),
        )

コンテキスト変数を追加した新たな BoundLogger を作って返します。 logger や processor は同一のものが利用されるので、BoundLoggerのインスタンスが分かれていてもログのフォーマットや出力は同一のものになります。

ログメソッドの提供

.debug().info() のようなログメソッドはBoundLoggerBaseではなく、それを継承した具象クラスで実装されます。

具象クラスには大きく二つあります。1つ目は標準ライブラリの logging.Logger と互換性の高い structlog.stdlib.BoundLogger です。次のように普通にログメソッドを定義し、 self._proxy_to_logger() に転送しています。

structlog/src/structlog/stdlib.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

class BoundLogger(BoundLoggerBase):
...
    def debug(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        return self._proxy_to_logger("debug", event, *args, **kw)

    def info(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        return self._proxy_to_logger("info", event, *args, **kw)
...

もう一つの具象クラスは FilteringBoundLogger です。このクラスは実際には具象クラスではなく structlog.typing で定義されているだけの静的型で、実際にはフィルタリングするログレベルごとにクラスが生成されています。

structlog/src/structlog/_native.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:

    def make_method(
        level: int,
    ) -> tuple[Callable[..., Any], Callable[..., Any]]:
        if level < min_level:
            return _nop, _anop   # !!! min_level 未満のログレベルに対応するログメソッドは return None するだけのメソッドを使う
        ...
        meth.__name__ = name
        ameth.__name__ = f"a{name}"
        return meth, ameth

    meths: dict[str, Callable[..., Any]] = {"log": log, "alog": alog}
    for lvl, name in LEVEL_TO_NAME.items():
        meths[name], meths[f"a{name}"] = make_method(lvl)
    ...
    return type(
        f"BoundLoggerFilteringAt{LEVEL_TO_NAME.get(min_level, 'Notset').capitalize()}",
        (BoundLoggerBase,),
        meths,
    )


# Pre-create all possible filters to make them pickleable.
BoundLoggerFilteringAtNotset = _make_filtering_bound_logger(NOTSET)
BoundLoggerFilteringAtDebug = _make_filtering_bound_logger(DEBUG)
BoundLoggerFilteringAtInfo = _make_filtering_bound_logger(INFO)
BoundLoggerFilteringAtWarning = _make_filtering_bound_logger(WARNING)
BoundLoggerFilteringAtError = _make_filtering_bound_logger(ERROR)
BoundLoggerFilteringAtCritical = _make_filtering_bound_logger(CRITICAL)

ちょっとトリッキーですが、現在のログレベルをチェックする if 文などがなく、指定したログレベル未満のログメソッドの実装が単に return None するだけの実装になっているので高速です。デフォルトで structlog.get_logger() した時に得られるBoundLoggerは最低ログレベルを指定しないですべてのログを出力する BoundLoggerFilteringAtNotset になります。

動的なログレベルによるフィルタリングの実装がありませんが、 processorsでフィルタリングするか、あるいは wrapped logger 自体が logging.Logger のようなレベル付きloggerになっている場合はそちらにフィルタリングを任せることになります。

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