ECSに載っているLaravelでCloudWatchLogsに吐かれるスタックトレースを含めたログを一つにまとめるためにしたこと

## 発生した問題

ECS上のlaravelのログ監視を導入した際にスタックトレースも含めてCloudWatchで吐くようにするとログが長い場合に下記のように分割されてしまっていたので検索などがしづらかった

![s1joapopyrpKERQ1739240298_1739240313.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/362153/4146cdbc-3f1a-08f3-6333-a7f6fe755062.png)


## 対策内容

### ステップ 1: カスタム tap クラス（フォーマッター）の作成
**1** **目的** Laravel の Logger インスタンス生成後に、各ハンドラーのフォーマッターを上書きするためのクラスを作成します

**2** **コード例** 
```php
<?php

declare(strict_types=1);

namespace App\Logging\Formatter;

use Illuminate\Log\Logger;
use Monolog\Formatter\LineFormatter;

final class CustomLineFormatter
{
    public function __invoke(Logger $logger): void
    {
        // フォーマット文字列：日時、チャネル、レベル、メッセージ、context、extra
        $formatter = new LineFormatter(
            format: null,
            dateFormat: 'Y-m-d H:i:s',
            allowInlineLineBreaks: false,  // 改行を無効化し、1行にまとめる
            ignoreEmptyContextAndExtra: false,
            includeStacktraces: true       // 例外発生時にスタックトレースを含める
        );

        foreach ($logger->getHandlers() as $handler) {
            $handler->setFormatter($formatter);
        }
    }
}
```

### ポイント
* **allowInlineLineBreaks: false**
  * 改行をスペースなどに置換し、ログ全体を1行にまとめる。CloudWatch Logs 上で1行に統一されるため、検索や集計が容易になる
* **includeStacktraces: true**
  - エラー発生時にスタックトレースを出力することで、問題の詳細な原因追跡が可能になる。

## ステップ 2: Laravel のログ設定に tap クラスを登録
**1** **目的** 作成した tap クラスを、対象のログチャネルに適用してカスタムフォーマットを有効化します。
**2** **設定例** 
```php
'stderr' => [
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'debug'),
    'handler' => StreamHandler::class,
    'tap' => [App\Logging\Formatter\CustomLineFormatter::class],
    'with' => [
        'stream' => 'php://stderr',
    ],
],
```

※ ちなみにtapは下記の部分でインスタンス作成後にloggerの設定が適用されていることがわかる

```php
    /**
     * Apply the configured taps for the logger.
     *
     * @param  string  $name
     * @param  \Illuminate\Log\Logger  $logger
     * @return \Illuminate\Log\Logger
     */
    protected function tap($name, Logger $logger)
    {
        foreach ($this->configurationFor($name)['tap'] ?? [] as $tap) {
            [$class, $arguments] = $this->parseTap($tap);

            $this->app->make($class)->__invoke($logger, ...explode(',', $arguments));
        }

        return $logger;
    }

    /**
     * Parse the given tap class string into a class name and arguments string.
     *
     * @param  string  $tap
     * @return array
     */
    protected function parseTap($tap)
    {
        return str_contains($tap, ':') ? explode(':', $tap, 2) : [$tap, ''];
    }
```

## ステップ 3: ECS タスク定義で CloudWatch Logs の設定を行う
**1** **目的** ECS タスク定義で CloudWatch Logs にログを送信し、かつ複数行（例: スタックトレース）のログを1つのまとまったイベントに結合する。
**2** **コード例** (ecsタスク定義の記述例）
```json
"logConfiguration": {
    "logDriver": "awslogs",
    "options": {
        "awslogs-group": "{{ tfstate `data.aws_cloudwatch_log_group.php.name` }}",
        "awslogs-region": "{{ must_env `AWS_REGION` }}",
        "awslogs-stream-prefix": "ecs",
        "awslogs-multiline-pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"
    }
}
```

### ポイント解説
* **awslogs-multiline-pattern** → 正規表現 "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}" は、ログ行の先頭が「YYYY-MM-DD HH:MM:SS」で始まる場合を判定し、新しいログイベントの開始とみなす。 
  * これにより、スタックトレースなど複数行にわたるログが1つのまとまったイベントとして CloudWatch Logs に送信される。

# ref

[PHPのロギングmonologを理解する](https://reffect.co.jp/php/monolog-to-understand)
[ECSからCloudWatch Logsに出力されるスタックトレースが分割されるので対策した話](https://qiita.com/meto/items/e78ae8e02fda77a8c735)