Monologでログ設計:ハンドラとチャンネルを使いこなす
概要
PHPのデファクトなロギングライブラリ Monolog は、「ハンドラ(Handler)」「フォーマッタ(Formatter)」「プロセッサ(Processor)」「チャンネル(Channel)」という4つの部品の組み合わせで動きます。この構造を理解すると、「アプリのログ」「監査ログ」「外部通知」といった性質の違うログを、破綻なく整理して出力できるようになります。
本記事はMonolog入門として、それぞれの役割と組み合わせ方を、動くコード例とともに整理します。PHP 8系 / Monolog 3系を前提とします。
Monologの基本構造
Monologの中心は Logger インスタンスです。ここに「どこへ出すか(Handler)」「どう整形するか(Formatter)」「何を付け足すか(Processor)」を差し込みます。
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
// "app" がチャンネル名(=このLoggerの識別子)
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler(__DIR__ . '/logs/app.log', Level::Debug));
$logger->info('ユーザーがログインしました', ['user_id' => 123]);
$logger->error('決済に失敗しました', ['order_id' => 456]);
第2引数の Level::Debug は「このハンドラが処理する最低レベル」です。Monolog 3ではレベルは Monolog\Level の enum になりました(2系の Logger::DEBUG 定数から変わった点に注意)。
ログレベルは重要度の低い順に Debug / Info / Notice / Warning / Error / Critical / Alert / Emergency の8段階(PSR-3準拠)です。
ハンドラ(Handler):どこへ出すか
ハンドラは「ログの行き先」を決める部品です。1つのLoggerに複数積めます。代表的なものは以下です。
StreamHandler— ファイルやphp://stderrへ書き出す(最も基本)RotatingFileHandler— 日付ごとにファイルを分割し、保持日数を指定できるSyslogHandler— OSのsyslogへSlackWebhookHandler— Slackへ通知NullHandler— 何もしない(テストや無効化に)
複数ハンドラを積むと「登録した逆順」に評価されます。これを利用して「詳細はファイル、重大なものだけ通知」といった振り分けができます。
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\SlackWebhookHandler;
$logger = new Logger('app');
// すべてのログ(Debug以上)を日次ローテーションでファイルへ
$logger->pushHandler(new RotatingFileHandler(
__DIR__ . '/logs/app.log',
maxFiles: 14,
level: Level::Debug
));
// Critical以上だけSlackへ通知
$logger->pushHandler(new SlackWebhookHandler(
webhookUrl: getenv('SLACK_WEBHOOK_URL'),
channel: '#alerts',
level: Level::Critical
));
$logger->info('バッチ開始'); // → ファイルのみ
$logger->critical('DB接続不能'); // → ファイル + Slack
FingersCrossedHandler:普段は静かに、事が起きたら全部吐く
入門で便利さを体感しやすいのが FingersCrossedHandler です。普段はログをバッファに溜めておき、一定レベル(アクティベーション条件)を超えたら、それまで溜めていた分をまとめて内側のハンドラへ流します。
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new FingersCrossedHandler(
handler: new StreamHandler(__DIR__ . '/logs/app.log', Level::Debug),
activationStrategy: Level::Error // Errorが出たら発火
));
$logger->debug('リクエスト受信 GET /orders');
$logger->debug('SQL発行 SELECT ...');
$logger->info('在庫チェック OK');
// ここまでは何も書き込まれない(バッファに溜まっているだけ)
$logger->error('決済APIがタイムアウト');
// → この瞬間、上のdebug/infoも含めて全部ファイルに書き込まれる
正常時はディスクを汚さず、エラー発生時だけ「そこに至るまでの詳細な文脈」がまとめて残るため、調査効率が大きく上がります。
フォーマッタ(Formatter):どう整形するか
出力の見た目を決めるのがフォーマッタです。ハンドラごとに設定できます。構造化ログ(JSON)にしておくと、後段のログ基盤で検索・集計しやすくなります。
use Monolog\Formatter\JsonFormatter;
use Monolog\Formatter\LineFormatter;
$fileHandler = new StreamHandler(__DIR__ . '/logs/app.log', Level::Debug);
$fileHandler->setFormatter(new JsonFormatter()); // 1行1JSON
$stdout = new StreamHandler('php://stdout', Level::Info);
$stdout->setFormatter(new LineFormatter(
format: "[%datetime%] %channel%.%level_name%: %message% %context%\n",
dateFormat: 'Y-m-d H:i:s',
allowInlineLineBreaks: true
));
プロセッサ(Processor):全ログに共通情報を足す
プロセッサは、すべてのログレコードに横断的な情報を付与します。リクエストIDやユーザーIDを毎回手で書かずに済みます。
use Monolog\Processor\WebProcessor;
use Monolog\Processor\PsrLogMessageProcessor;
$logger->pushProcessor(new WebProcessor());
$logger->pushProcessor(new PsrLogMessageProcessor());
$requestId = bin2hex(random_bytes(8));
$logger->pushProcessor(function (array $record) use ($requestId) {
$record['extra']['request_id'] = $requestId;
return $record;
});
チャンネル(Channel):ログを性質で分ける
チャンネルとは new Logger('チャンネル名') の名前のことで、「そのログがどの用途・関心事に属するか」を表すラベルです。1つのアプリで性質の異なるログを別チャンネルに分けるのが設計の勘所です。
function makeChannel(string $name, string $file, Level $level): Logger {
$logger = new Logger($name);
$handler = new RotatingFileHandler($file, 30, $level);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
return $logger;
}
$app = makeChannel('app', __DIR__ . '/logs/app.log', Level::Debug);
$audit = makeChannel('audit', __DIR__ . '/logs/audit.log', Level::Info);
$security = makeChannel('security', __DIR__ . '/logs/security.log', Level::Info);
$app->info('ページを表示');
$audit->info('権限変更', ['actor' => 'user:1', 'target' => 'user:9', 'to' => 'admin']);
$security->warning('ログイン連続失敗', ['ip_masked' => '203.0.113.x', 'count' => 5]);
出力されたJSONには "channel":"audit" のようにチャンネル名が入るため、後段の検索で channel = "security" と絞り込めます。
Laravelでの位置づけ
Laravelは内部でMonologを使っており、config/logging.php の channels がMonologのチャンネル/ハンドラ設定に対応します。stack ドライバは複数チャンネルの束ね(=複数ハンドラ相当)です。
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
'ignore_exceptions' => false,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'level' => 'critical',
],
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit.log'),
'level' => 'info',
'days' => 90,
],
],
Log::info('通常ログ');
Log::channel('audit')->info('権限変更', [...]);
daily = RotatingFileHandler、slack = SlackWebhookHandler に相当する、と対応づけると理解が早いです。
確認方法
- スクリプトを実行し、
logs/配下に各ファイルが生成されることを確認する。 FingersCrossedHandlerの例では、error()を呼ぶ前後で書き込みが発生するか(=バッファされているか)を確認する。cat logs/app.log | jq .で整形し、channel/level_name/extra.request_idが含まれることを確認する。
注意点
- パスワード・トークン・個人情報はcontextに含めない。必要なら独自プロセッサでマスキングする。
- Slackなど外部通知ハンドラのレベルは高めに設定する(Debugで送ると通知が洪水になる)。
- ローテーションの保持日数は要件(監査・法令)に合わせる。監査ログは短くしすぎない。
- Monolog 3系ではレベルがenum化されており、2系の記法と非互換の箇所がある。バージョンを合わせる。
- ハンドラは登録の逆順に評価される点に注意する。
まとめ
Monologは「Handler(どこへ)」「Formatter(どう)」「Processor(何を足す)」「Channel(どの関心事か)」の分解で理解できます。まずは用途別のチャンネル分け、レベルによる振り分け、JSON化、FingersCrossedHandler の4点を押さえると、実運用に耐えるログ設計の土台ができます。
