メインコンテンツまでスキップ

ログ・監視とデプロイ — 何が起きたかを後から追えるようにする

第20章で、定期処理の失敗をメールで受け取る形を作りました。あの章で足したものは、既に届いています。

届いていないものが 4 つ残っています。

何が起きたときいま何が起きるか
500 が返ったときlaravel.log に 1 行残る。誰も見なければそれきり
ジョブが失敗したとき同じ。第17章で Log::error を書いたが、行き先は既定のログファイル
404 が連続したとき何も残らない。Laravel が既定で無視する例外に入っている
429 を返したとき何も残らない。レート制限の調整に使える情報が消えている

この章でやることは 2 つです。ログを「後から追える形」にすることと、デプロイ手順を 1 つにまとめることです。前半で置き場所と中身を決め、後半で本番へ出す手順を並べます。

この章で扱わないこと

Docker の構成、サーバーの構築、CI/CD パイプラインの組み方は扱いません。Laravel 側の設定に限定します。 ログの送信先も、config/logging.php にチャンネルを定義するところまでです。

ログはどこへ行くのか

設定は config/logging.php にあります。既定では stack チャンネルが使われ、その中に single が 1 つ入っています。

config/logging.php (抜粋)
'default' => env('LOG_CHANNEL', 'stack'),

'channels' => [

'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],

'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],

'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],

],

stack は複数のチャンネルを束ねるだけのドライバです。 Log::error() を 1 回呼ぶと、束ねた全チャンネルに配られます。

レベルはチャンネルごとに効く

配られるかどうかは、チャンネルごとの level が決めます。ここに罠があります。

Laravel が用意している slack チャンネルの定義を見てください。

config/logging.php (抜粋)
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],

既定のレベルが critical です。 LOG_LEVEL を設定していなければ、Log::error() は Slack に届きません。第17章で書いた Log::error('ジョブが失敗しました', ...) も同じです。

.env
LOG_STACK=daily,slack
LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

これだけでは足りません。チャンネル側の level を下げます。

config/logging.php
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'level' => 'error', // ← 既定の critical から下げる
'replace_placeholders' => true,
'processors' => [App\Logging\RedactSensitive::class], // ← 後の節で作る
],

processors は後の節で作るものです。 stack に束ねた各チャンネルへ個別に書きます。stack 側に書いても効きません。ログを実際に書くのは束ねられた側だからです。

env('LOG_LEVEL', 'critical') のままにしないでください。 LOG_LEVELsingledaily も見ています。1 つの環境変数で両方を動かすと、ファイルのレベルを debug に下げた瞬間に Slack へ全部流れます。

ファイルは daily にする

既定の single は 1 つのファイルに書き続けます。ローテーションが無いので、放っておくとディスクを埋めます。

.env
LOG_STACK=daily
LOG_DAILY_DAYS=14

daily は日付ごとにファイルを分け、days を過ぎたものを消します。14 日という値に根拠はありません。障害の調査で遡る範囲と、ディスクの余裕で決めます。

リクエストとジョブを 1 本の糸にする

ログが 1 行だけ残っていても、その前後に何が起きていたかは分かりません。同時に 50 リクエストが走っていれば、行は交互に混ざります。

第13章で、ミドルウェアの登録を扱ったときに 1 行だけ書きました。

bootstrap/app.php (第13章より)
$middleware->append(AddRequestId::class);

このクラスは、まだ実装していません。 ここで書きます。

Context で ID を通す

app/Http/Middleware/AddRequestId.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AddRequestId
{
public function handle(Request $request, Closure $next): Response
{
Context::add('request_id', (string) Str::uuid());

$response = $next($request);

$response->headers->set('X-Request-Id', Context::get('request_id'));

return $response;
}
}

Context に入れた値は、そのリクエストで書かれる全部のログ行に自動で付きます1Log::info() の呼び出し側で毎回渡す必要はありません。

レスポンスヘッダにも同じ値を返しています。利用者から「さっき失敗しました」と言われたときに、ヘッダの値を聞けばログを引けるようにするためです。

キューへは自動で運ばれる

Context を選んだ理由がここにあります。

Log::withContext(['request_id' => $id]); // リクエストの中だけ
Context::add('request_id', $id); // キューのジョブにも届く

Log::withContext() はチャンネルにコンテキストを足すだけで、別プロセスで動くワーカーには届きませんContext はジョブを積むときに中身を捕まえ、ジョブが実行されるときに復元します2

第16章で作った NotifyWarehouse が失敗したとき、そのログには注文を受け付けたリクエストの request_id が付いています。API のログとワーカーのログが、1 つの ID で繋がります。

スケジューラの子プロセスには届きません

第20章で確認したとおり、Schedule::command() は Symfony の Process で別プロセスの php artisan を起動します。Context はここを越えません。 定期処理のログには request_id が付かないので、そちら側は「コマンド名 + 実行時刻」で追うことになります。

error_id との関係

第9章で、500 を返すときに error_id を発行しました。request_id とは役割が違います。

ID単位利用者に見せるか
request_id1 リクエスト (とそこから積まれたジョブ)レスポンスヘッダで返す
error_id1 つの例外レスポンス本文で返す

1 リクエストの中で例外が 2 回起きれば error_id は 2 つ出ますが、request_id は 1 つです。問い合わせで受け取るのは error_id、そこから前後を辿るのが request_id という使い分けになります。

第9章の実装は、Context を入れたことで 1 行短くできます。

bootstrap/app.php
$errorId = (string) Str::uuid();

Context::add('error_id', $errorId); // ← ログにも自動で付く

Log::error($e->getMessage(), ['exception' => $e]); // error_id を手で渡さない

何を残し、何を残さないか

第9章で、想定内のエラーと想定外のエラーを分けました。同章の表は「想定内は残さない (または info レベル)」と書いています。ここで後者を採ります。

想定内は info の行き先へ

第9章の本実装は、在庫不足をログから外していました。

bootstrap/app.php (第9章より)
$exceptions->report(function (InsufficientStockException $e) {
return false;
});

false を返すと既定のログスタックに流れません。在庫不足は 1 行も残りません。 人気商品が品切れした日に数千件のエラーが出て、本当に調べたい例外が埋もれるのを避けるためでした。

ただし「発生数を知りたい」という要望は正当です。両方を満たす形にします。

bootstrap/app.php
$exceptions->report(function (InsufficientStockException $e) {
Log::channel('daily')->info('在庫不足', [
'product_id' => $e->productId,
'requested' => $e->requested,
'available' => $e->available,
]);

return false;
});

Log::channel('daily') で行き先を名指ししています。 Log::info() と書くと既定の stack へ行き、Slack を束ねていればそちらにも流れます。想定内のエラーを Slack に流しても意味がないので、ファイルだけに落とします。

return false はそのまま残します。この行が無いと、report の後で既定のスタックにも error として流れます。 抑止と記録は別の話です。

404 は既定で無視される

第13章で「同じ利用者から 404 が連続するのは異常」と書きました。記録しようとすると、まず壁があります。

Laravel は 404 を既定で報告しません。 404 / 403 (オリジン不一致) / 419 (CSRF) は、フレームワークが無視する例外の一覧に入っています3report() を書いても呼ばれません。

外すには stopIgnoring() を呼びます。

bootstrap/app.php
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;

$exceptions->stopIgnoring([
ModelNotFoundException::class, // findOrFail が投げる
HttpException::class, // abort() と 429
]);

$exceptions->report(function (ModelNotFoundException $e) {
Log::channel('daily')->info('404', [
'path' => request()->path(),
'ip' => request()->ip(),
]);

return false;
});

NotFoundHttpException では拾えません。 この連載の 404 は第12章以降ずっと findOrFail から出ています。findOrFail が投げるのは ModelNotFoundException で、NotFoundHttpException への差し替えはレスポンスを作る側で起きますreport に渡るのは差し替え前の例外です4

stopIgnoring() はクラス名の完全一致で外します。 無視される一覧に載っているのは ModelNotFoundExceptionHttpException で、それぞれ別のエントリです。片方を外しても、もう片方は無視され続けます。

419 はこれでは拾えません

CSRF トークンの不一致で返る 419 も「既定で無視される」側にありますが、投げられるのは TokenMismatchException です。HttpException とは別のエントリなので、上の設定では外れません。

stopIgnoring に渡すクラスは、投げられる例外そのものを書いてください。レスポンスのステータスコードから逆算すると外れます。

HttpException を外す影響は 404 より広いです。 abort(403)abort(405) もすべて報告対象になります。report() で受けていない種類は既定のスタックへ error として流れるので、入れた直後にログの量を確認してください。

第9章の renderHttpExceptionInterface を早期 return しているので、レスポンスの形は変わりません。 変わるのはログだけです。

429 は同じ経路で拾える

レート制限が発動したときに投げられる ThrottleRequestsException は、Symfony の TooManyRequestsHttpException を継承しています。HttpException の子孫なので、上で HttpException を外したことで報告対象に入っています。report を足すだけです。

bootstrap/app.php
use Illuminate\Http\Exceptions\ThrottleRequestsException;

$exceptions->report(function (ThrottleRequestsException $e) {
Log::channel('daily')->info('レート制限', [
'path' => request()->path(),
'ip' => request()->ip(),
'user_id' => request()->user()?->id,
]);

return false;
});

第13章で「429 の発生数をログに残して、後から調整できるようにしてください」と書いたのがこれです。user_id を残しているのは、RateLimiter::for() が利用者ごとに絞る形を採っていたからです。誰が引っかかっているかが分からないと、閾値を上げるべきか、その利用者が異常なのかを判断できません。

レベルの対応表

ここまでで、3 つのレベルの使い分けが決まりました。

レベル何を入れるか行き先
error想定外。対応が要る (500 / ジョブ失敗)daily + slack
warning想定内だが頻度を見たい (外部 API の一時的な失敗)daily
info記録として残す (在庫不足 / 404 / 429)daily のみ

error だけが人に届きます。 それ以外は「あとから集計するために置いてある」ものです。この線を引かないと、Slack が鳴り続けて誰も見なくなります。

機密情報を落とす

第11章で、ログにトークンを書いてしまう例を挙げました。

❌ Bad: 第11章より
Log::info('ログイン成功', ['token' => $token->plainTextToken]);

呼び出し側で渡さないのがいちばん確実です。 ただしそれは規律の問題で、仕組みではありません。人が増えれば、いつか誰かが書きます。

プロセッサで落とす

Monolog は、ログを書く前に中身を加工するプロセッサを挟めます。

app/Logging/RedactSensitive.php
<?php

namespace App\Logging;

use Monolog\LogRecord;

class RedactSensitive
{
private const KEYS = ['token', 'password', 'secret', 'authorization', 'api_key'];

public function __invoke(LogRecord $record): LogRecord
{
return $record->with(context: $this->redact($record->context));
}

private function redact(array $context): array
{
foreach ($context as $key => $value) {
if (in_array(strtolower((string) $key), self::KEYS, true)) {
$context[$key] = '[redacted]';
} elseif (is_array($value)) {
$context[$key] = $this->redact($value);
}
}

return $context;
}
}

チャンネルに登録します。

config/logging.php
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'processors' => [App\Logging\RedactSensitive::class],
],

キーの名前で落としています。 値の形 (JWT らしい文字列など) で判定する方法もありますが、誤検出したときに何が消えたか分からなくなります。キー名なら、落ちた場所が [redacted] として残ります。

再帰しているのは、ネストした配列に入るからです。 ['user' => ['token' => '...']] の形で渡されても落ちます。

プロセッサが見るのは context だけです

Log::info("トークンは {$token} です") のように書いた場合、値は落ちません。 プロセッサが見るのは context だけです。$record->message を書き換える手もありますが、何がトークンかを文字列から判定することになります。

機密情報は context に渡すという規約を先に決めてください。仕組みで守れるのはそこまでです。

Context の hidden とは別物です

Context には addHidden() があり、こちらに入れた値はログに出ません5

Context::addHidden('access_token', $token); // ログには出ない

これはマスクではありません。 「Context に入れた値をログに出さない」機能で、Log::info(..., ['token' => ...]) のように直接渡された値には効きません。上の Bad 例は塞げません。

使い分けはこうです。リクエストをまたいで持ち回りたい機密値は addHiddenログに書かれてしまった値を落とすのはプロセッサです。

構造化して送る

ここまでのログは、既定の形式で書かれています。

[2026-08-26 10:15:32] production.ERROR: サーバー側で問題が発生しました。 {"exception":"[object] (RuntimeException...)"} {"request_id":"9f8e...","error_id":"3a2b..."}

人が読むには十分です。機械で集計するには向きません。 タイムスタンプとレベルは行の先頭にあり、コンテキストは行末の JSON にあり、その 2 つは別の書式です。

JSON で 1 行にする

Monolog の formatter を差し替えます。チャンネル定義に tap を書くと、Monolog のインスタンスを加工できます。

app/Logging/UseJsonFormatter.php
<?php

namespace App\Logging;

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

class UseJsonFormatter
{
public function __invoke(Logger $logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(new JsonFormatter());
}
}
}
config/logging.php
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'handler_with' => ['stream' => 'php://stderr'],
'tap' => [App\Logging\UseJsonFormatter::class],
'processors' => [App\Logging\RedactSensitive::class],
'level' => env('LOG_LEVEL', 'debug'),
],

1 行が 1 つの JSON になります。

{"message":"サーバー側で問題が発生しました。","context":{"exception":"..."},"level":400,"level_name":"ERROR","channel":"production","datetime":"2026-08-26T10:15:32+09:00","extra":{"request_id":"9f8e...","error_id":"3a2b..."}}

request_id で検索できます。 ログ収集の側で extra.request_id を条件に並べれば、1 リクエストとそこから積まれたジョブのログが揃います。

標準エラー出力へ出す理由

送信先を php://stderr にしています。ファイルに書かず、プロセスの標準エラー出力へ流す形です。

コンテナで動かす場合、ログをファイルに書くとコンテナが消えたときに一緒に消えます。標準出力と標準エラー出力は実行基盤が拾ってくれるので、そこへ出すのが素直です。CloudWatch Logs も、コンテナの出力を集める形で繋がります。

.env (コンテナで動かす場合)
LOG_STACK=stderr

ファイルとコンテナのどちらで動かすかで、束ねるチャンネルを変えます。

環境LOG_STACK
ローカルsingle (1 ファイルで追いやすい)
ステージング・本番 (サーバー)daily,slack
本番 (コンテナ)stderr,slack

外部サービスへ送る

第9章で「ログの送信先はファイル・Sentry・CloudWatch などへの振り分けを第21章で扱う」と書きました。チャンネルの定義まででほぼ終わっています。

CloudWatch は上の stderr で繋がります。Sentry のように専用のパッケージを入れる場合は、パッケージがチャンネルを 1 つ足すので、それを stack に入れます

.env
LOG_STACK=stderr,sentry

パッケージの導入手順そのものは、この連載では扱いません。Laravel 側から見ると「チャンネルが 1 つ増える」だけで、stack の考え方は変わらないためです。

チャンネルを通らない出力もあります

第20章で、定期処理の出力をファイルへ追記しました。

routes/console.php (第20章より)
Schedule::command('images:prune-orphans')
->dailyAt('03:10')
->appendOutputTo(storage_path('logs/prune-orphans.log'));

このファイルは Monolog を通りません。 コマンドの標準出力をシェルのリダイレクトで書くだけなので、stackslack を足してもそこへは出ず、プロセッサも効きません。JSON にもなりません。

定期処理の出力を監視へ載せたいなら、コマンドの中で Log:: を呼ぶ形に寄せます。第20章の emailOutputOnFailure も同じ位置づけで、あちらは「終了コードが 0 以外ならメールを送る」だけの独立した仕組みです。

経路チャンネルを通るか
Log::error() などの呼び出し通る (プロセッサも formatter も効く)
sendOutputTo / appendOutputTo通らない (生のテキスト)
emailOutputOnFailure通らない (終了コードで分岐するだけ)

第20章で「メールとファイルは、どちらも誰かが見に行く前提」と書いたのは、この違いがあるからです。「向こうから来る」形にできるのは、チャンネルを通る経路だけです。

デプロイ手順

ここまでの章で「デプロイ手順に入れてください」と書いたものが 5 つあります。1 つにまとめます。

毎回のデプロイで実行するものが 4 つ、環境を作るときに 1 回だけのものが 2 つです。混ぜると事故が起きます。

毎回のデプロイ

# 1. 依存を入れる (開発用は除く)
composer install --no-dev --optimize-autoloader

# 2. マイグレーションを流す
php artisan migrate --force

# 3. 設定をキャッシュする
php artisan config:cache

# 4. ルートをキャッシュする
php artisan route:cache

# 5. ワーカーを入れ替える
php artisan queue:restart

順序に意味があります。

migrate --force--force は、第3章で扱ったとおり「本番環境でも実行してよい」の確認を省くフラグです。対話的な確認を待つとデプロイが止まるので必要になります。

config:cachemigrate の後に置きます。第2章で扱ったとおり、このコマンドを実行すると .env が読まれなくなります。設定を変えたときは、.env を更新してから config:cache を実行する順です。

接続先を変えたときは順序が逆になります

同じディレクトリで git pull する方式では、前回のデプロイで作った bootstrap/cache/config.php が残っています。 migrate --force はそれを読みます。接続先を変えた直後のデプロイでは、古い接続先へマイグレーションを流します。

.env の DB 設定を変えたときは、php artisan config:clear を先に打つか、config:cachemigrate の前へ移してください。設定を変えていないデプロイでは、上の順序で問題ありません。

env() を config の外で呼んでいると、ここで壊れます

config:cache を実行した後、env() はサーバーレベルの環境変数か null を返します。第2章で「env()config/ のファイルの中だけで呼ぶ」と決めたのは、この手順があるからです。

ローカルでは config:cache を実行しないので、規則を破ったコードは本番でだけ null を掴みます。デプロイして初めて分かる壊れ方になります。

queue:restart は最後です。第17章で「この 1 行を忘れると、デプロイしたのに何も変わりません」と書いたものです。ワーカーは起動時にコードを読み込むので、新しいコードを配置しても、動いているワーカーは古いコードを実行し続けます。

キャッシュを作る前に queue:restart を打つと、再起動したワーカーが古い設定キャッシュを読みます。 キャッシュの更新が全部済んでから入れ替えます。

環境を作るときに 1 回

# 公開ディスクへのリンクを張る
php artisan storage:link

# スケジューラを cron に登録する
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

「1 回だけ」が成り立つ条件があります。 リポジトリを 1 箇所に置いて、そこで git pull する方式なら 1 回で済みます。

releases/20260826_101532/ のようなディレクトリを作って current シンボリックリンクを張り替える方式では、public/storage は毎リリース消えます。新しいリリースディレクトリには存在しないためです。

デプロイ方式storage:link
同じディレクトリで git pull環境構築時に 1 回
リリースごとにディレクトリを作る毎回のデプロイに入れる

第19章で「このコマンドをデプロイ手順に入れてください」と書いたのは、この事情があるからです。自分のデプロイ方式がどちらかを確かめてから、上の表のどちらに置くかを決めてください。

cron の登録は、サーバーを作るときの 1 回で足ります。routes/console.php に定期処理を足しても、cron の行は変わりません。第20章でそういう形にしました。

止まっていることに気づく

第2章で、bootstrap/app.phphealth: '/up' が入っていることを見ました。

bootstrap/app.php (第2章より)
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)

/up にアクセスすると、アプリケーションが起動できているかを確認できます。外部の監視サービスから定期的に叩くのが使い方です。

/up が答えられないこと

/up が 200 を返しても、分からないことがあります。

  • キューのワーカーが動いているか — 止まっていてもリクエストは処理できます
  • スケジューラが動いているか — cron が止まっていても /up は 200 です

第20章の最後で「schedule:run が止まっていることには気づけません」と書きました。ここに答えを置きます。

最後に成功した時刻を残す

定期処理が走ったことを記録して、その記録が古くなったら知らせます。

routes/console.php
Schedule::command('orders:cancel-stale')
->everyFiveMinutes()
->withoutOverlapping(10)
->onOneServer()
->onSuccess(fn () => Cache::forever('heartbeat:cancel-stale', now()->toIso8601String()));

onSuccess() は、コマンドが終了コード 0 で終わったときに呼ばれます。時刻をキャッシュに書いておきます。

読み出す側は、/up とは別のエンドポイントにします。

routes/api.php
Route::get('/health/scheduler', function () {
$last = Cache::get('heartbeat:cancel-stale');

if ($last === null || Carbon::parse($last)->lt(now()->subMinutes(15))) {
return response()->json(['status' => 'stale', 'last_success' => $last], 503);
}

return response()->json(['status' => 'ok', 'last_success' => $last]);
});

5 分周期の処理に対して 15 分を閾値にしています。1 回の取りこぼしでは鳴らさないためです。連続 3 回失敗すれば異常と見なせます。

第2章で決めたとおり、この連載のルートは routes/api.php だけです。第4章で apiPrefix: 'api/v1' を設定したので、実際のパスは /api/v1/health/scheduler になります。監視サービスにはこちらを登録してください。

判定を定期処理の中に置かないでください

「時刻が古くなったら通知する」処理を Schedule::command() として登録すると、cron が止まった状況ではその判定自体が動きません。 死活監視は、監視される側の外に置く必要があります。

この形なら、外部の監視サービスが /health/scheduler を叩いて 503 を見ます。Laravel 側でできるのは「聞かれたら答えられる状態にしておく」ところまでです。

テスト

ログのテストは 2 つの層に分かれます。ファサードで止まる層と、Monolog の中で動く層です。同じ書き方では両方を見られません。

何が記録されたかを見る

tests/Feature/LoggingTest.php
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;

pest()->use(RefreshDatabase::class);

test('在庫不足は info で記録され error には出ない', function () {
Log::shouldReceive('channel')->with('daily')->andReturnSelf();
Log::shouldReceive('info')->once();
Log::shouldNotReceive('error');

$stock = Stock::factory()->soldOut()->create();

$this->postJson('/api/v1/orders', [
// 在庫ゼロの商品を 1 件注文する
])->assertStatus(409);
});

andReturnSelf() が要ります。 Log::channel('daily')->info(...) は 2 段の呼び出しなので、channel() の mock が自分自身を返さないと null に対して info() を呼ぶことになります。

shouldNotReceive('error') が本体です。 reportreturn false を消すと、既定のスタックへ error として流れてここで落ちます。第9章で決めた「想定内は error に出さない」が、この 1 行で固定されます。

プロセッサを直接呼ぶ

プロセッサは Monolog の中で動くので、ファサードの mock には届きません。 クラスを直接呼びます。

tests/Unit/RedactSensitiveTest.php
use App\Logging\RedactSensitive;
use Monolog\Level;
use Monolog\LogRecord;

test('機密情報を落とす', function () {
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: 'テスト',
context: ['token' => 'secret-value', 'user_id' => 1],
);

$result = (new RedactSensitive())($record);

expect($result->context['token'])->toBe('[redacted]');
expect($result->context['user_id'])->toBe(1);
});

チャンネルに登録されているかは、これでも確認できません。 config/logging.phpprocessors から抜けても、このテストは通ります。設定ファイルの中身はテストの外にあるので、目で確かめてください。

本番で効く注意点

LOG_LEVEL を 1 つの変数で使い回さないでください。 single / daily / slack が同じ env('LOG_LEVEL') を見ていると、ファイルのレベルを下げた瞬間に Slack へ全部流れます。Slack 側は値を直書きするか、別の変数にします。

stopIgnoring(HttpException::class) は範囲が広いです。 429 を拾うために入れると、abort(403)abort(405) も報告対象になります。report で受けていない種類が error として既定のスタックへ流れるので、入れた直後にログの量を確認してください。 419 は別のクラス (TokenMismatchException) なので、これでは外れません。

config:cache を実行したら、.env の変更は効きません。 設定を変えたら必ず config:cache を打ち直します。ローカルでは実行しないので、この差が事故になります。

プロセッサはメッセージ本文を見ません。 機密情報を文字列に埋め込んだログは落ちません。「機密情報は context に渡す」という規約を、仕組みではなく人で守る部分として残ります。

dailydays を過ぎたログは消えます。 障害の調査で 1 か月前を見たいことがあります。長期の保管が要るなら、ログ収集の側で持ってください。ファイルのローテーションは保管ではありません。

死活監視のエンドポイントは認証の外に置きます。 /health/schedulerauth:sanctum の内側に置くと、監視サービスがトークンを持つことになります。第13章のレート制限からも外してください。監視は 1 分ごとに叩きます。

まとめ

  • ログの設定は config/logging.phpstack は複数チャンネルを束ねるだけで、流れるかどうかはチャンネルごとの level が決める
  • slack の既定レベルは critical。下げないと Log::error() は届かない
  • single ではなく daily を使う。ローテーションが無いとディスクを埋める
  • リクエスト ID は Context で通すLog::withContext() は別プロセスのワーカーに届かない
  • Context の値は全ログ行に自動で付き、キューのジョブへも運ばれる。API とワーカーのログが 1 つの ID で繋がる
  • スケジューラの子プロセスには届かない。定期処理はコマンド名と時刻で追う
  • request_id は 1 リクエスト、error_id は 1 例外。問い合わせで受け取るのは後者、辿るのは前者
  • 想定内のエラーは report() の中で Log::channel('daily')->info() してから return false抑止と記録は両立できる
  • 404 は Laravel が既定で無視するfindOrFail の 404 は ModelNotFoundException なので stopIgnoring にはそのクラスを渡す。ステータスコードから逆算すると外れる
  • report に渡るのは差し替え前の例外NotFoundHttpException への変換はレスポンスを作る側で起きる
  • error だけを人へ届け、warninginfo は集計用に置く。この線を引かないと Slack が鳴り続けて誰も見なくなる
  • 機密情報は Monolog のプロセッサで落とす。Context::addHidden() は「Context に入れた値を出さない」機能で、直接渡された値には効かない
  • プロセッサが見るのは context だけ。メッセージ本文に埋め込んだ値は落ちない
  • コンテナならログはファイルに書かず php://stderr へ。コンテナが消えるとファイルも消える
  • デプロイは composer installmigrate --forceconfig:cacheroute:cachequeue:restartキャッシュを作り終えてからワーカーを入れ替える
  • storage:link が「1 回だけ」で済むかはデプロイ方式で決まる。リリースごとにディレクトリを作る方式では毎回要る
  • /up は「アプリが起動できるか」しか答えない。ワーカーとスケジューラの死活は別に用意する
  • 死活の判定を定期処理の中に置かない。cron が止まった状況では判定自体が動かない

次に読む

次章 まとめ — 本番リリース前チェックリスト は最終章です。ここまでの 21 章で決めたことを、本番へ出す前に確認する 1 枚のリストにまとめます。この章で並べたデプロイ手順も、その一部として入ります。どの章で何を決めたかを辿れる形にするので、読み返すときの入口としても使えます。

練習問題

次のログ設定には問題があります。指摘してください
config/logging.php
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
],

'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 3,
],

'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'level' => env('LOG_LEVEL', 'debug'),
],

解答例

slackleveldebug になっています。 LOG_LEVEL を設定していなければ既定値の debug が入り、すべてのログが Slack へ流れますLog::info()Log::debug() も届きます。本文で扱ったとおり、これが起きると通知は数分で無意味になります。

dailyslack同じ環境変数を見ているのが原因です。ファイル側を debug にしたいのは正当な要求なので、Slack 側を分けます。

days が 3 です。 3 日で消えます。金曜の夜に起きた障害を月曜に調べようとすると、もう残っていません。土日を挟んで調査できる長さが要ります。

processors が無いので、機密情報が落ちません。 本文で作った RedactSensitive は、チャンネルごとに登録します。stack に書いても効きません — stack は束ねるだけで、ログを書くのは束ねられた側です。

ignore_exceptions が省略されています。 既定値は false なので動作は変わりませんが、stack の中の 1 チャンネルが例外を投げたときの扱いを明示しておくほうが読み手に親切です。Slack の webhook が落ちているときにログ全体が失敗するかどうかが、この値で決まります。

「デプロイしたのに新しいコードが動いていない」と報告されました。何から確認しますか

解答例

症状で切り分けられます。 どこが古いままなのかで、原因が違います。

API のレスポンスが古いなら、route:cacheconfig:cache です。 ルートを追加・変更したのにキャッシュを作り直していなければ、古い定義が使われます。設定も同じです。デプロイ手順に入っているかを確認します。

ジョブの挙動が古いなら queue:restart です。 本文で扱ったとおり、ワーカーは起動時にコードを読み込みます。API は新しく、ジョブだけ古いという形になるのが特徴です。第17章でこの 1 行を扱ったのは、この症状がいちばん分かりにくいからです。

定期処理が古いなら、routes/console.php の変更が配置されているかを見ます。 cron の行は変わらないので、そちらは疑わなくてよいです。

全部が古いなら、コードが配置されていません。 シンボリックリンクの張り替えが失敗している、git pull が別のブランチを引いている、といった手前の問題です。

確認の順番は、php artisan about が早いです。 キャッシュの有無が一覧で出ます。ここで「Config: CACHED」と出ているのに設定が古いなら、キャッシュを作り直していないと分かります。

なお、この症状を次から出さないようにするには、デプロイ手順をスクリプトにして手で打たないようにします。ただしそこから先は CI/CD の話なので、この連載では扱いません。

Footnotes

  1. Laravel 13 公式ドキュメント Context より。「Information added to the context is automatically appended as metadata to any log entries that are written throughout the request.」

  2. 同ドキュメントより。「When the job is dispatched, any information currently stored in the context is captured and shared with the job.」復元についても同じ節に書かれています。

  3. Laravel 13 公式ドキュメント Error Handling より。「Laravel automatically ignores certain HTTP exceptions, such as 404, 403 (origin mismatches), and 419 (invalid CSRF tokens).」

  4. Illuminate\Foundation\Exceptions\Handlerreport()mapException() を通した例外を見ます。ModelNotFoundExceptionNotFoundHttpException の変換は prepareException() で、こちらは render() 側から呼ばれます (framework 13.x のソース)。

  5. Laravel 13 公式ドキュメント Context の Hidden Context より。「Hidden context stores sensitive data that is not appended to logs」。