SaneLog

· laravel, request-id, queues, correlation

Laravel request ID logging across queue jobs and workers

Log::shareContext doesn't reach queued jobs. How to put a request ID on every Laravel log line, including Horizon workers, with Context.

A customer says checkout failed at 9:14. The web server logged a 402. A queue worker on another machine logged a card decline forty milliseconds earlier. The mail server queued the “payment failed” email a second later. Three servers, three log files, one story, and nothing tying the lines together except the timestamps and your memory of how the code works.

The fix is a correlation id: one value, created when the request arrives and written on every line that request causes, on whatever machine the line is written. Laravel does this well, with one trap that catches almost everyone.

The trap: shared context stays behind

The first attempt usually goes in a middleware or a service provider:

Log::shareContext([
    'request_id' => (string) Str::uuid(),
]);

Every line written during the request now carries request_id. Then the request dispatches a job, Horizon picks it up on the queue server, and the job’s lines have no request_id at all.

That isn’t a bug. Log::shareContext adds context to the loggers in the current PHP process. A queued job runs in a different process, often on a different machine, and starts with nothing.

It catches people constantly, and it isn’t only request ids. Context set in a service provider shows up on controller logs and vanishes from jobs run under Horizon. The same thing happens to the user id an audit log depends on, once work moves into queued jobs, scheduled commands and job chains. Anything that should follow a piece of work across processes has to travel with it.

The fix: Laravel’s Context

Laravel 11 added the Context facade for exactly this. Values you add to Context during a request are stored with any job dispatched during that request, and restored before the job runs. Laravel also adds them to every log line automatically, in the web process and in the worker.

A middleware that sets the id:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;

class AssignRequestId
{
    public function handle(Request $request, Closure $next)
    {
        $requestId = $request->header('X-Request-Id') ?: (string) Str::uuid();

        Context::add('request_id', $requestId);
        Context::add('user_id', $request->user()?->id);

        $response = $next($request);
        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

And register it for every request in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\AssignRequestId::class);
})

Two choices in there are deliberate:

  • Reuse an incoming X-Request-Id. If your load balancer or nginx already creates one (nginx has $request_id), pass it through so the access log and your application lines share the same value.
  • Send it back in the response. When a customer sends you a screenshot of an error, the id is in their browser’s network tab, and support can hand it straight to you.

You don’t change any Log:: calls. Laravel writes Context values in a second JSON block at the end of each line, after the line’s own context. A line from the web server:

production.INFO: Charge created {"order_id":88231} {"request_id":"4f2c91a0-...","user_id":1812}

And from the queue worker, on another machine, a second later:

production.ERROR: Charge failed: card_declined {"order_id":88231} {"request_id":"4f2c91a0-...","user_id":1812}

Same id on both. That’s the whole point.

On Laravel 10 or earlier, a job middleware can do the same job: put the id in the job’s payload when it is dispatched, and call Log::withContext with it when the job runs. Either way, the id has to travel with the job, because the worker starts with nothing.

Reading it back

The id only helps if every server’s lines are in one place. If each server still keeps its own laravel.log, you’re back to SSH and grep on three machines. This post covers sending them all to one place with Laravel’s syslog channel.

Once they are together, the id is the fastest way through:

  • Filter by the field, not the text. SaneLog turns the JSON at the end of a line into fields you can filter on. With Context in use, that last block is the one holding request_id and user_id, so those become fields, even on exception lines. Open the error, click the request_id value, choose Filter for this value, and you see that request’s lines from the web server, the worker and the mailer, in order.
  • Look around it. The request’s own lines tell you what your code did. Show surrounding events on the decline shows what every server logged in that second, which is where you find out the payment gateway had been timing out for two minutes before this customer arrived.
  • Share it. The filtered view has its own URL, and every line has a permalink. Both go into the incident thread as they are.
  • Ask your coding agent. With an agent connected over MCP, “what did request 4f2c91a0 touch, and what was going on around it?” runs the same steps and comes back with the two lines that matter.

What belongs in Context

request_id and user_id cover most debugging. Add only the ids you actually search by:

  • job_id and the job’s class name, set in a job middleware, so retries of the same job line up.
  • tenant_id or account_id in a multi-tenant app.
  • The deployed version or git commit, set once at boot, so “did this start after the deploy?” is a filter, not an argument.

Don’t add the whole request. Headers and request bodies in every line are a privacy problem and the fastest way to use up a log allowance. Secrets don’t belong in a log line at all. Personal data shouldn’t either, and a log tool that detects it on arrival is a backstop, not the plan.

In short

  • Log::shareContext only reaches the current process, and queued jobs run in another one.
  • Context::add in a middleware puts the id on every line of the request and of every job it dispatches.
  • Reuse an incoming X-Request-Id, and return it in the response.
  • With every server’s lines in one place, the id turns a three-server mystery into a single filtered view.

← all posts