Help us understand the problem. What is going on with this article?

【Laravel】ログのフォーマットを変更してIPアドレスやユーザー名などを出力する

環境

  • PHP 7.4.1
  • Laravel Framework 6.14.0

結論

このような形式のログを出力できるようにします。(実際にログに出るときは1行です)

{
    "timestamp": "2020-04-20 09:54:08.425",
    "channel": "local",
    "level": "INFO",
    "memoryUsage": "4 MB",
    "ipAddress": "192.168.10.1",
    "userId": "1",
    "userName": "hoge",
    "class": "App\\Http\\Controllers\\TestController",
    "function": "index",
    "line": "10",
    "message": "test",
    "context": ""
}

目的

ただログのフォーマットを変更したかっただけなのに、簡潔に分かりやすくまとまっている記事が無かった。
結局自分で調べて時間を浪費したため、同じ轍を踏む方々が少なくなるようにしたい。
また、JSON形式で出力するようにしたため、稚拙ではあるが知見を公開する。

手順

1. カスタムフォーマッタの指定

このへんは公式ドキュメントに記載があります。

Monologチャンネルの上級カスタマイズ
https://readouble.com/laravel/6.x/ja/logging.html#advanced-monolog-channel-customization

デフォルト値であるstackはsingleを使うため、singleにtapを追加します。
カスタムフォーマッタクラス自体は次の手順で作成します。

config/logging.php
// 前略

'default' => env('LOG_CHANNEL', 'stack'),

// 中略

'stack' => [
    'driver' => 'stack',
    'channels' => ['single'],
    'ignore_exceptions' => false,
],

'single' => [
    'driver' => 'single',
    'tap' => [App\Logging\CustomizeFormatter::class], // ← 追記
    'path' => storage_path('logs/laravel.log'),
    'level' => 'debug',
],

// 後略

2. カスタムフォーマッタクラスの作成

tapで指定したCustomizeFormatter.phpを作成します。
分かりやすいようにコメントを入れているので参考にしてください。

下記のコードで作っているformatは最終的に結合されて以下の文字列になります。JSON形式です。

{"timestamp": "%datetime%", "channel": "%channel%", "level": "%level_name%", "memoryUsage": "%extra.memory_usage%", "ipAddress": "%extra.ip%", "userId": "%extra.userid%", "userName": "%extra.username%", "class": "%extra.class%", "function": "%extra.function%", "line": "%extra.line%", "message": "%message%", "context": "%context%"}\n
app/Logging/CustomizeFormatter.php
<?php

namespace App\Logging;

use Monolog\Formatter\LineFormatter;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\WebProcessor;
use Monolog\Processor\MemoryUsageProcessor;
use Monolog\Logger;
use Illuminate\Support\Facades\Auth;

class CustomizeFormatter
{
    private $dateFormat = 'Y-m-d H:i:s.v';

    public function __invoke($logger)
    {
        // フォーマットの修正がしやすいように配列を用いる
        $format = '{'.
            implode(', ', [
                '"timestamp": "%datetime%"',
                '"channel": "%channel%"',
                '"level": "%level_name%"',
                '"memoryUsage": "%extra.memory_usage%"',
                '"ipAddress": "%extra.ip%"',
                '"userId": "%extra.userid%"',
                '"userName": "%extra.username%"',
                '"class": "%extra.class%"',
                '"function": "%extra.function%"',
                '"line": "%extra.line%"',
                '"message": "%message%"',
                '"context": "%context%"',
            ])
            .'}'.PHP_EOL;

        // ログのフォーマットと日付のフォーマットを指定する
        $lineFormatter = new LineFormatter($format, $this->dateFormat, true, true);
        // IntrospectionProcessorを使うとextraフィールドが使えるようになる
        $ip = new IntrospectionProcessor(Logger::DEBUG, ['Illuminate\\']);
        // WebProcessorを使うとextra.ipが使えるようになる
        $wp = new WebProcessor();
        // MemoryUsageProcessorを使うとextra.memory_usageが使えるようになる
        $mup = new MemoryUsageProcessor();

        foreach ($logger->getHandlers() as $handler) {
            $handler->setFormatter($lineFormatter);
            // pushProcessorするとextra情報をログに埋め込んでくれる
            $handler->pushProcessor($ip);
            $handler->pushProcessor($wp);
            $handler->pushProcessor($mup);
            // addExtraFields()を呼び出す。extra.useridとextra.usernameをログに埋め込んでくれる
            $handler->pushProcessor([$this, 'addExtraFields']);
        }
    }

    public function addExtraFields(array $record): array
    {
        $user = Auth::user();
        $record['extra']['userid'] = $user->id ?? null;
        $record['extra']['username'] = $user ? $user->name : '未ログイン';
        return $record;
    }
}

3. ログ出力テスト

適当なコントローラなどで呼び出します。

app/Http/Controllers/TestController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Log;

class TestController extends Controller
{
    public function index() {
        Log::Info('test');
    }
}

以下、ログを分かりやすいように整形して表示しています。実際にログに出るときは1行です。

ユーザ認証前

{
    "timestamp": "2020-04-20 09:54:08.425",
    "channel": "local",
    "level": "INFO",
    "memoryUsage": "4 MB",
    "ipAddress": "192.168.10.1",
    "userId": "NULL",
    "userName": "未ログイン",
    "class": "App\Http\Controllers\TestController",
    "function": "index",
    "line": "10",
    "message": "test",
    "context": ""
}

ユーザ認証済み

{
    "timestamp": "2020-04-20 09:54:08.425",
    "channel": "local",
    "level": "INFO",
    "memoryUsage": "4 MB",
    "ipAddress": "192.168.10.1",
    "userId": "1",
    "userName": "hoge",
    "class": "App\Http\Controllers\TestController",
    "function": "index",
    "line": "10",
    "message": "test",
    "context": ""
}

本当はバックスラッシュをエスケープしなければ厳密にはJSONにならないのですが、いいやり方が思いつかず。
現状、ログをプログラムなどで読み込むときは別途エスケープが必要になります。
コメントにてエスケープの方法を募集しています。

※4/21(火) 自力で解決したので以下に追記しました。

4. ログのバックスラッシュをエスケープする

そのままだとclassのバックスラッシュがエスケープされておらずJSON形式でないため、LineFormatterを改造したCustomLineFormatterを作成し、バックスラッシュをエスケープします。
MonologにはJsonFormatterもあるのですが、私には扱いにくかったため、LineFormatterを使うことにしました。

app/Logging/CustomLineFormatter.php
<?php

namespace App\Logging;

use Monolog\Formatter\LineFormatter;
use Monolog\Formatter\NormalizerFormatter;

class CustomLineFormatter extends LineFormatter
{
    public function format(array $record): string
    {
        $vars = NormalizerFormatter::format($record);

        $output = $this->format;

        foreach ($vars['extra'] as $var => $val) {
            if (false !== strpos($output, '%extra.'.$var.'%')) {
                $output = str_replace('%extra.'.$var.'%', $this->stringify($val), $output);
                unset($vars['extra'][$var]);
            }
        }

        foreach ($vars['context'] as $var => $val) {
            if (false !== strpos($output, '%context.'.$var.'%')) {
                $output = str_replace('%context.'.$var.'%', $this->stringify($val), $output);
                unset($vars['context'][$var]);
            }
        }

        if ($this->ignoreEmptyContextAndExtra) {
            if (empty($vars['context'])) {
                unset($vars['context']);
                $output = str_replace('%context%', '', $output);
            }

            if (empty($vars['extra'])) {
                unset($vars['extra']);
                $output = str_replace('%extra%', '', $output);
            }
        }

        foreach ($vars as $var => $val) {
            if (false !== strpos($output, '%'.$var.'%')) {
                $output = str_replace('%'.$var.'%', $this->stringify($val), $output);
            }
        }

        // remove leftover %extra.xxx% and %context.xxx% if any
        if (false !== strpos($output, '%')) {
            $output = preg_replace('/%(?:extra|context)\..+?%/', '', $output);
        }

        $output = str_replace('\\', '\\\\', $output); // ←オリジナルにここを追加しています

        return $output;
    }
}
app/Logging/CustomizeFormatter.php
- $lineFormatter = new LineFormatter($format, $this->dateFormat, true, true);
+ $lineFormatter = new CustomLineFormatter($format, $this->dateFormat, true, true);

5. ログ出力テスト(2回目)

ユーザ認証前

{
    "timestamp": "2020-04-20 09:54:08.425",
    "channel": "local",
    "level": "INFO",
    "memoryUsage": "4 MB",
    "ipAddress": "192.168.10.1",
    "userId": "NULL",
    "userName": "未ログイン",
    "class": "App\\Http\\Controllers\\TestController",
    "function": "index",
    "line": "10",
    "message": "test",
    "context": ""
}

ユーザ認証済み

{
    "timestamp": "2020-04-20 09:54:08.425",
    "channel": "local",
    "level": "INFO",
    "memoryUsage": "4 MB",
    "ipAddress": "192.168.10.1",
    "userId": "1",
    "userName": "hoge",
    "class": "App\\Http\\Controllers\\TestController",
    "function": "index",
    "line": "10",
    "message": "test",
    "context": ""
}

解決したかに思えましたが、シングルクォートやダブルクォートがユーザ名やメッセージに入ってくると同じようにエスケープが必要になるのでこの方法は微妙ですね。。
str_replaceのところでエスケープする方が良さそうです。

_hiro_dev
ツイ消しツール「ツイ消し職人 ( https://twikeshi.net )」の開発者 / 25歳フリーランス / 応用情報技術者 / CCNA / Laravel / Vue.js / SQL / 声優オタク / 温泉オタク / サウナー / 技術で世の中をもっと便利にする
https://hir0.dev/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした