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

# 環境
- PHP 7.4.1
- Laravel Framework 6.14.0

# 結論
このような形式のログを出力できるようにします。（実際にログに出るときは1行です）

```json
{
    "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を追加します。
カスタムフォーマッタクラス自体は次の手順で作成します。

```php: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形式です。

```php
{"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
```

```php: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. ログ出力テスト
適当なコントローラなどで呼び出します。

```php: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行です。

**ユーザ認証前**

```json
{
    "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": ""
}
```

**ユーザ認証済み**

```json
{
    "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にならないのですが、いいやり方が思いつかず。
現状、ログをプログラムなどで読み込むときは別途エスケープが必要になります。
コメントにてエスケープの方法を募集しています。~~
<font color="Red">※4/21(火) 自力で解決したので以下に追記しました。</font>

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

```php: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;
    }
}
```

```diff:app/Logging/CustomizeFormatter.php
- $lineFormatter = new LineFormatter($format, $this->dateFormat, true, true);
+ $lineFormatter = new CustomLineFormatter($format, $this->dateFormat, true, true);
```

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

**ユーザ認証前**

```json
{
    "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": ""
}
```

**ユーザ認証済み**

```json
{
    "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のところでエスケープする方が良さそうです。