Zeals TECH BLOG

チャットボットでネットにおもてなし革命を起こす、チャットコマース『Zeals』を開発する株式会社Zealsの技術やエンジニア文化について発信します。現在本ブログは更新されておりません。新ブログ: https://medium.com/zeals-tech-blog

JSON log formatterを使ってPythonのlogが複数行分割される問題を防ぐ

f:id:ssabcire:20200806170224p:plain

こんにちは!Zeals で Python をメインに書いている新卒 1 年目のソフトウェアエンジニアの玉城です。

Python で Cloud Logging(旧: Stackdriver Logging)Datadog にログを出力するとき、複数行に分割されてしまい、元のログイベントとの関連付けが難しくなり困ったことはないでしょうか。

例えば、以下のようにログを書いたときに

logger.info("test\n I want to eat sushi")

Cloud Logging にはこのように表示されてしまうといった感じです。
f:id:ssabcire:20200703103623p:plain

これを JSON フォーマットにすることで解決してみましょう。



What's JSON log formatter

Python でログを JSON フォーマットにするとき、柔軟性が高く設定できるJSON log formatterを使うのがおすすめです。
JSON log formatter は Python のログ出力を JSON フォーマットで出力できるパッケージです。 pypi.org

他に Python でよく使われる JSON フォーマッタにpython-json-loggerがありますが、こちらを使用するのは下記の理由です。

  1. JSON フィールドをカスタマイズするクラスが python-json-logger よりも直感的
  2. python-json-logger よりもドキュメントがわかりやすい



ではJSON log formatterをインストールしていきます。

pip install JSON-log-formatter

これを使って簡単に Web サーバーを作り、それを Google App Engine にあげてログを確認してみます。



File structure description

今回作成したファイルの構造はこのようになってます。

.
├── app.yaml
├── config
│   ├── __init__.py
│   ├── customised_log.py
│   └── log_config.py
├── main.py
├── requirements.txt
└── tests
    ├── __init__.py
    └── test_log.py

↓ のリポジトリに置いているので、よろしければご覧ください。

github.com


JSON customization using json_log_formatter

まずは、JSON-log-formatterを使ってログの設定をするcustomised_log.pyを見てみましょう。

from logging import LogRecord

import json_log_formatter


class CustomisedJSONFormatter(json_log_formatter.JSONFormatter):
    def json_record(self, message: str, extra: dict, record: LogRecord) -> dict:
        '''log output to JSON format
        https://github.com/marselester/json-log-formatter
        '''
        # https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
        extra['message'] = message
        extra['severity'] = record.levelname

        extra['logger_name'] = record.name
        extra['module'] = record.module

        if record.exc_info:
            extra['exc_info'] = self.formatException(record.exc_info)

        return extra

extra['hoge'] = ~~と辞書に追加していくことで、JSON のフィールドを設定することができます。 必ず欲しいものはこちらで宣言しておくといいでしょう。オプションで付けたいような場合は後ほど説明します。



Set a custom class

先ほど作成したcustomised_log.pyのクラスを、ユーザー定義オブジェクトで設定します。
5 行目を見てください。特殊キー'()'を使い、上記で作成したクラスを import し、フォーマッタを設定します。
ちなみにこちらは Python ファイル内で dict 形式で設定していますが、YAML で書いても動きます。

LOGGER = {
    'version': 1,
    'formatters': {
        'default': {
            '()': 'config.customised_log.CustomisedJSONFormatter'
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'INFO',
            'formatter': 'default',
            'stream': 'ext://sys.stdout'
        },
    },
    'loggers': {
        'zeals': {
            'level': 'DEBUG',
            'handlers': ['console']
        }
    }
}


では、本当に JSON フォーマットになっているのかどうかを試してみます。



Check logs with pytest

pytest を使って確認してみます。ユーザー定義オブジェクトで出力先を標準出力にしたので、標準出力をキャプチャします。

import json
from logging import config, getLogger

from config.log_config import LOGGER


def test_log(capsys):
    config.dictConfig(LOGGER)
    logger = getLogger('zeals')
    logger.warning('occur error')
    expected = json.dumps({
        "message": "occur error",
        "severity": "WARNING",
        "logger_name": "zeals",
        "module": "test_log",
    }) + "\n"
    out, err = capsys.readouterr()
    assert out == expected
    assert err == ''
    print(out)


出力を確認してみましょう。

pytest -s -v tests/test_log.py
tests/test_log.py::test_log {“message”: “occur error”, “severity”: “WARNING”, “logger_name”: “zeals”, “module”: “test_log”}

いい感じに JSON フォーマットになっています!

では、Python で簡単なサーバーを作って Google App Engine にアップしてみます。



Create a simple server in Python

下記が簡単なサーバーの例です。14 行目の logger.info()のように extra=<dict> とすることで、オプションのような形で JSON フォーマットにフィールドを追加することもできます。
また、18 行目の logger.exception のようにexc_info=Trueとすることで、トレースバックを JSON フィールドに加えることもできます。

from logging import config, getLogger

from flask import Flask

from config.log_config import LOGGER

app = Flask(__name__)


@app.route('/')
def hello():
    config.dictConfig(LOGGER)
    logger = getLogger('zeals')
    logger.info('test\n I want to eat sushi', {"id": 1234, "name": "tamaki"})
    try:
        raise ValueError('something wrong')
    except ValueError:
        logger.exception(f'Request failed.', exc_info=True)

    return 'Hello, World!'


if __name__ == '__main__':
    app.run()


これを Google App Engine に上げて、ログを表示してみます。
f:id:ssabcire:20200703102805p:plain


ログのテキストに改行を挟んでも、1 つのログメッセージとして記録されています。これは便利。
また、Cloud Logging は JSON 形式でログ出力をすると、jsonPayload に構造化ペイロードとして認識されます。
jsonPayloadの JSON フィールド 内を見てみると、オプションで extra に追加したidnameがきちんと付け加えられています。
このような形で JSON フィールドに加えることで、ログビューワとして検索しやすくなります。



おわりに

ちなみに、弊社では Python や GCP を使った開発をしています。興味を持っていただけましたら、ぜひ一度私とお話しましょう!🤝

https://jobs.forkwell.com/Zeals/jobs/4726