Files
crewli/api/app/Http/Middleware/BindRequestLogContext.php
bert.hausmans 4a8bb97764 feat: BindRequestLogContext middleware + X-Request-Id round-trip
WS-7 PR-2 commit 3. RFC §3.13.

- app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log
  line written during the request with request_id, organisation_id,
  user_id, and route name. Sets X-Request-Id on the response so the
  SPA can correlate to backend log lines via one click.
- Client-supplied X-Request-Id is honoured only if it parses as a ULID
  via Str::isUlid. Junk input (empty, non-ULID) is rejected and a
  fresh ULID is generated server-side.
- Registered as a global api-group middleware via the prepend list so
  it runs before authentication. Unauthenticated 4xx responses still
  carry the X-Request-Id header.
- Test count: 1523 to 1532. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:28:50 +02:00

81 lines
2.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Organisation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Structured-logging context binder (RFC-WS-7 §3.13). Tags every Laravel
* log line written during this request with request_id, organisation_id,
* user_id, and route name. Round-trips X-Request-Id with the response so
* the SPA can correlate to backend log lines via one click.
*/
final class BindRequestLogContext
{
public function handle(Request $request, Closure $next): Response
{
$requestId = $this->resolveRequestId($request);
$request->attributes->set('observability.request_id', $requestId);
Log::withContext(array_filter([
'request_id' => $requestId,
'organisation_id' => $this->resolveOrganisationId($request),
'user_id' => $request->user()?->getAuthIdentifier(),
'route' => $request->route()?->getName(),
], static fn ($v) => $v !== null && $v !== ''));
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
return $response;
}
private function resolveRequestId(Request $request): string
{
$supplied = $request->header('X-Request-Id');
if (is_string($supplied) && Str::isUlid($supplied)) {
return $supplied;
}
return (string) Str::ulid();
}
private function resolveOrganisationId(Request $request): ?string
{
$portalEvent = $request->attributes->get('portal_event');
if ($portalEvent instanceof Event) {
return $portalEvent->organisation_id;
}
$route = $request->route();
if ($route === null) {
return null;
}
$org = $route->parameter('organisation');
if ($org instanceof Organisation) {
return $org->id;
}
if (is_string($org) && $org !== '') {
return $org;
}
$event = $route->parameter('event');
if ($event instanceof Event) {
return $event->organisation_id;
}
return null;
}
}