※この記事は後編です。問題提起およびロガーの作成・使用の実装方針は前編をご覧ください。
ロギング環境設定
ロギング環境設定は通常、実行プログラムの冒頭で実施します。アプリケーションであればアプリケーション起動直後、ライブラリのモジュール実行1であればモジュール実行の冒頭、Pythonスクリプト実行 (python <ファイル名>) であればスクリプトの冒頭です。
そのため、ロギング環境設定実施のコードを書く人はロガーの作成・使用とは異なり、下記の表(再掲)の「ロギング環境設定 (デフォルト設定)」列の人になります。このロギング環境設定でカスタム設定をロードできるようにすることで、「ロギング環境設定 (カスタム設定)」列の人の思い通りに柔軟に設定できるようにします。
| 開発する物 | ロガーへのメッセージ送信 | ロギング環境設定 (デフォルト設定) | ロギング環境設定 (カスタム設定) |
|---|---|---|---|
| アプリケーション | アプリ開発者 | アプリ開発者 | アプリ運用者 |
| ライブラリ2 | ライブラリ開発者 | アプリ3開発者 | アプリ3運用者 |
| ライブラリのモジュール実行1 | ライブラリ開発者 | ライブラリ開発者 | ライブラリ利用者 |
ライブラリ内では、「モジュール実行」の場合を除いてロギング環境設定をほとんどしません。インポートするだけでロギング環境設定が変更されてしまうようなライブラリがあると、どのような設定がされるか予測困難になり、利用者にとって不要なログが氾濫したり、逆に必要なログが出なくなる原因になるためです。
ライブラリのためのロギングの設定 もご覧ください。
カスタム設定ロードのための根幹機能
ここまで「カスタム設定をロードする」と簡単に言ってきましたが、実際、logging モジュールにそのための機能がすでにあるため簡単にできます。
logging モジュールにはロギング環境設定を適用するための関数が2種類あります。ここでは、そのうちの一つ logging.config.dictConfig を使用することにします。この関数は以下のように使用でき、環境設定辞書スキーマ に沿って書かれたJSONファイルを読み込むことで設定を適用できます。
import json
import logging.config
logging_conf_path = "logging_config.json"
with open(logging_conf_path) as fp:
conf = json.load(fp)
logging.config.dictConfig(conf)
環境設定辞書スキーマ のページでは JSON ではなく YAML 形式で説明されていますが、YAML 形式のファイルをロードする場合は PyYAML のインストールが必要です。
この記事では、アプリ運用者・ライブラリモジュール実行の利用者などによるカスタム設定のロードを実現するために、設定ファイルのパス (上の例でいう logging_conf_path) をいつどうやって受け取るか、デフォルトとしてどのような設定を適用するかを考えていきます。
カスタム設定ファイルのパスの受け取り
では、アプリ運用者・ライブラリモジュール実行の利用者が用意したカスタム設定ファイルのパスはどのように受け取れるでしょうか。「あらゆるログを出せるようにするため、ログ設定は実行の冒頭、早い段階で完了したい」という要件を考慮すると、次のような方法が考えられます。
- 環境変数
- コマンド引数
- 固定値 (ファイルパスをコードに記載)
- 他、アプリケーション固有の設定ファイル
ここでは、最も汎用的と思われるコマンド引数+環境変数+固定値を例示します。
フレームワークを使用してアプリケーションを作成している場合など、コマンド引数を使用しづらい場合もあります。その場合は環境変数やフレームワーク固有の設定手段を使用しましょう。
また、アプリケーション固有で冒頭に情報を得られる手段があれば、それもカスタム設定ファイルパスの指定やカスタム設定の内容そのものとして使用できるかもしれません。
コマンド引数+環境変数+固定値でカスタム設定ファイルパスを指定
下記のコード例はライブラリのモジュール実行の例です。次のように動作します。
- 実行は
python -m mymodule [OPTIONS ...]で実行する - オプションとして
--log <ファイルパス>が指定されると、そのパスをカスタム設定ファイルとして扱う - オプション
--logが無い場合、環境変数MYMODULE_LOG_CONFの値をカスタム設定ファイルパスとして扱う - オプション
--logも環境変数MYMODULE_LOG_CONFもない場合、カレントディレクトリのmymodule_log_conf.jsonをカスタム設定ファイルとして扱う - カスタム設定ファイルパスにファイルが存在しない場合、デフォルトのロギング環境設定をする
import argparse
import logging
import logging.config
import json
import os
logger = logging.getLogger("mymodule")
# コマンド引数解析
parser = argparse.ArgumentParser()
parser.add_argument(
"--log",
default=os.environ.get("MYMODULE_LOG_CONF", "mymodule_log_conf.json"),
)
args = parser.parse_args()
# ロギング環境設定
if os.path.exists(args.log):
with open(args.log) as fp:
conf = json.load(fp)
logging.config.dictConfig(conf)
# カスタム設定をロードしても有効にしたいデフォルト設定があればここで実施する
logger.debug("ロギング環境設定を完了しました。設定ファイル=%s", args.log)
else:
# 後述のデフォルト設定をここで実施する
logger.debug("ロギング環境設定を完了しました。設定ファイル=%s", "None")
動作を試してみたい方は、上記の mymodule/__main__.py と下記のカスタム設定ファイル sample_conf.json を用意して python -m mymodule --log sample_conf.json 等を実行してみてください。
{
"version": 1,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "brief",
"stream": "ext://sys.stdout"
}
},
"formatters": {
"brief": {
"format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S"
}
},
"loggers": {
"mymodule": {
"level": "DEBUG",
"handlers": [
"console"
]
}
}
}
カスタムフォーマッタ作成
ここまでの施策で、アプリ運用者やライブラリ利用者が logging.config.dictConfig で自由にロギング環境設定をできるようになりました。これだけでも十分なのですが、さらに logging モジュールで提供されない設定も可能にしたい場合は、カスタムクラスを作成します。
カスタムクラスを作成して提供できるのは、おもにハンドラ (handler)、フォーマッタ (formatter)、フィルタ (filter) です。先述のサンプル (sample_conf.json) ではハンドラとして logging.StreamHandler を、フォーマッタとしてデフォルトの logging.Formatter を使用していますが、これらはどちらも logging モジュールで用意されているクラスです。カスタムハンドラやカスタムフォーマッタ、カスタムフィルタを作成しておくことで、logging モジュールが用意している物の代わりに使用できるようになります。
参考: 公式のドキュメント
例外情報を省略できるカスタムフォーマッタ
例として、ここでは例外情報を省略できるカスタムフォーマッタを作成します。前編ではログ送信の際に exc_info を積極的に使用することを促しましたが、ログを設定する利用者にとっては不要なこともあるかもしれません。そのような利用者のために、例外情報を出力しないようにできるフォーマッタを作成します。
フォーマッタは次のようになります。
class ExceptionFormatter(logging.Formatter):
_remove_exc: bool
def __init__(self, *args, remove_exc: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self._remove_exc = remove_exc
def formatException(self, exc_info):
if self._remove_exc:
return None
else:
return logging.Formatter.formatException(self, exc_info)
# こちらは exc_info ではなく stack_info が指定されたときの動作
def formatStack(self, stack_info):
if self._remove_exc:
return None
else:
return logging.Formatter.formatStack(self, stack_info)
ほとんどの機能はデフォルトのフォーマッタ logging.Formatter と同一なので、継承します。
__init__ 引数でキーワード引数 remove_exc を受け付けていますが、この値は後述のようにカスタム設定ファイルで指定することができます。この値はオーバーライドした formatException() (例外情報の出力文字列化) で使用され、False であれば従来と同じ動作になりますが、True であれば例外情報は文字列化されず、出力されません。
上の例では formatStack() も formatException() の設定に連動させていますが、必ずしもそれが良いとは限りません。formatStack() はログ送信時に stack_info を指定したとき、ログ送信時点のスタックトレースをログ出力するために呼び出されますが、上の例では「例外情報を出したくないときはログ送信時点のスタックトレースも出したくない」と決めつけています。
__init__ のキーワード引数の数に制限はないので、引数を増やせばより細かく指定を受け入れ、制御することも可能です。
上のカスタムフォーマッタ ExceptionFormatter を使う設定のカスタム設定ファイルの例は次の通りです。
{
"version": 1,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "brief",
"stream": "ext://sys.stdout"
}
},
"formatters": {
"brief": {
"()": "mymodule.logging.ExceptionFormatter",
"remove_exc": true,
"format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S"
}
},
"loggers": {
"mymodule": {
"level": "DEBUG",
"handlers": [
"console"
]
}
}
}
formatters 以外は先述のサンプルと変わりません。フォーマッタの箇所は以下の変更をしました。
-
"()"の値として、使用するフォーマッタの名前をインポートに必要なモジュール名付きで指定します。これにより、指定したクラスの__init__()が呼び出されます - カスタムフォーマッタの
__init__()に渡したいキーワード引数"remove_exc"の値を指定します
同じ目的を、「exc_info, exc_text, stack_info をNoneで上書きするカスタムフィルタを作る」ことでも達成できます。とくに Python3.12 からはログレコードをフィルタが複製して編集できるようになったため、例外情報を出したい他のハンドラを邪魔せずにカスタムフィルタで例外情報を削除できるようになりました。
Python3.11まではフィルタによる exc_info の削除が他のハンドラに影響しないようにするにはハンドラもカスタムするなど工夫が必要なので、フォーマッタをカスタムする方が楽だと思います。