Operating It

Logs on stderr as JSON, exceptions routed to a tracker with context attached, and the handful of numbers that follow a release.

SyntaxContext::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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · php
<?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.

Example · php
<?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.

Example · ini
; .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

  • Be the first to comment on this lesson.