Operating It
Logs on stderr as JSON, exceptions routed to a tracker with context attached, and the handful of numbers that follow a release.
Context::add('trace_id', (string) Str::uuid());After the release, all you have is what the application tells you. The work is making it tell you in a shape something else can read.
Logs to the standard streams
A container should not write to storage/logs: the file leaves with the container and nothing collects it. config/logging.php ships a stderr channel, a Monolog StreamHandler pointed at php://stderr. Set LOG_CHANNEL=stderr to use it, LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter for one JSON object per line, and LOG_LEVEL to decide how much gets through.
Errors, with the surrounding facts
The withExceptions() closure in bootstrap/app.php is where reporting is configured: $exceptions->report() hands an exception to a tracker, dontReport() silences a class, level() changes its severity, context() attaches values to every exception log. The report($e) helper records something you caught and handled. Context::add() attaches values to every log line for the rest of the request, and they are carried into any job dispatched from it.
The numbers after a release
Five: the 5xx rate, p95 latency on your slowest route, how long a job waits before being picked up, rows arriving in failed_jobs, and worker restarts. Watch them for one full queue cycle rather than one minute, because a release breaks jobs later than it breaks requests. An alert should name the next action, not the symptom.
Example
When to use it
- Getting Laravel's logs into a container log collector instead of a file nobody reads.
- Deciding whether the error spike that started ten minutes ago came from the release.
- Tying one customer's failed checkout to the queued job that threw.
- Cutting an alert that fires nightly and is always ignored.
More examples
Where reporting is configured
This replaces the App\Exceptions\Handler class that older Laravel versions used; since Laravel 11 the configuration lives in bootstrap/app.php. dontReport() is the lever that decides whether your alerting is worth reading. level() matters for the same reason: if everything is an error, nothing is.
<?php
// bootstrap/app.php (the withExceptions closure)
use App\Exceptions\PaymentDeclined;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Support\Facades\Auth;
use PDOException;
use Psr\Log\LogLevel;
use Throwable;
->withExceptions(function (Exceptions $exceptions): void {
// Values added to every exception log this application writes.
// 'app.release' is a key you add to config/app.php yourself, fed by
// the image tag or commit sha.
$exceptions->context(fn () => [
'release' => config('app.release'),
'user_id' => Auth::id(),
]);
// Hand every reported exception to the tracker as well. Returning
// nothing lets it continue to the normal logging stack too.
$exceptions->report(function (Throwable $e): void {
app('sentry')->captureException($e);
});
// A declined card is a business outcome, not an incident.
$exceptions->dontReport(PaymentDeclined::class);
// A lost database connection should not sit at 'error' with the rest.
$exceptions->level(PDOException::class, LogLevel::CRITICAL);
})A trace id on every line
Register it in the web and api middleware groups in bootstrap/app.php. The payoff is that one failed checkout, the four log lines it wrote, and the job it queued all carry the same trace_id, so a support ticket with a timestamp becomes a single search. Str::uuid() returns a UUID object, hence the cast.
<?php
// app/Http/Middleware/AddRequestContext.php
// Generated with: php artisan make:middleware AddRequestContext
declare(strict_types=1);
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 AddRequestContext
{
public function handle(Request $request, Closure $next): Response
{
// Appended as metadata to every log entry for the rest of this
// request, and carried into any job dispatched from it.
Context::add('trace_id', $request->header('X-Request-Id') ?? (string) Str::uuid());
Context::add('route', $request->route()?->getName());
// Available to the application, never written to a log line.
Context::addHidden('account_token', $request->header('X-Account-Token'));
return $next($request);
}
}The logging environment, and an alert that says what to do
LOG_LEVEL=info rather than debug keeps the volume affordable while still recording what the application chose to say. The comment block is the point of the example: an alert that only states a number gets muted, while one that names the first command gets acted on by whoever is holding the pager.
; .env on the server -- or the ConfigMap, which is the same thing
LOG_CHANNEL=stderr
LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter
LOG_LEVEL=info
LOG_DEPRECATIONS_CHANNEL=stderr
; Alert: shop-web 5xx rate above 2% for 5 minutes
;
; Means: requests are failing since release ${RELEASE}.
; Check: the 5xx lines in the last 10 minutes, grouped by route.
; First do: kubectl rollout undo deploy/shop-web
; Then: search the trace_id of one failing request to find the
; job or query that threw.
; Owner: #shop-oncall. Runbook: wiki/shop/5xx
Discussion