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>
This commit is contained in:
2026-05-06 09:28:50 +02:00
parent b1d5bcda76
commit 4a8bb97764
3 changed files with 245 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
<?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;
}
}

View File

@@ -30,6 +30,10 @@ return Application::configure(basePath: dirname(__DIR__))
// Read httpOnly auth cookie and inject as Authorization header (before Sanctum)
$middleware->api(prepend: [
\App\Http\Middleware\CookieBearerToken::class,
// RFC-WS-7 §3.13 — structured logging context + X-Request-Id
// round-trip. Runs early so unauthenticated 4xx responses
// still carry a request_id header.
\App\Http\Middleware\BindRequestLogContext::class,
]);
$middleware->alias([

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class RequestIdRoundTripTest extends TestCase
{
use RefreshDatabase;
private const VALID_ULID_PATTERN = '/^[0-9A-HJKMNP-TV-Z]{26}$/';
/**
* Captured Log::withContext payload from BindRequestLogContext.
*
* @var array<string, mixed>|null
*/
private static ?array $capturedLogContext = null;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
self::$capturedLogContext = null;
// Spy on Log::withContext so we can assert the structured payload.
Log::swap(new class extends \Illuminate\Log\LogManager
{
public function __construct() {}
/**
* @param array<string, mixed> $context
*/
public function withContext(array $context = []): \Illuminate\Log\Logger
{
RequestIdRoundTripTest::recordContext($context);
return $this->driver();
}
public function driver($driver = null): \Illuminate\Log\Logger
{
return new \Illuminate\Log\Logger(new \Psr\Log\NullLogger);
}
/**
* @param array<int, mixed> $parameters
*/
public function __call($method, $parameters)
{
return null;
}
});
}
/**
* @param array<string, mixed> $context
*/
public static function recordContext(array $context): void
{
self::$capturedLogContext = array_merge(self::$capturedLogContext ?? [], $context);
}
public function test_response_has_x_request_id_header_when_none_supplied(): void
{
$response = $this->getJson('/api/v1/');
$response->assertOk();
$requestId = $response->headers->get('X-Request-Id');
$this->assertNotNull($requestId);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $requestId);
}
public function test_response_has_x_request_id_header_when_client_supplied_valid_ulid(): void
{
$supplied = (string) Str::ulid();
$response = $this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
$this->assertSame($supplied, $response->headers->get('X-Request-Id'));
}
public function test_server_generates_when_client_supplies_invalid_ulid(): void
{
$response = $this->getJson('/api/v1/', ['X-Request-Id' => 'not-a-ulid-at-all']);
$emitted = $response->headers->get('X-Request-Id');
$this->assertNotSame('not-a-ulid-at-all', $emitted);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
}
public function test_server_generates_when_client_supplies_empty_string(): void
{
$response = $this->getJson('/api/v1/', ['X-Request-Id' => '']);
$emitted = $response->headers->get('X-Request-Id');
$this->assertNotNull($emitted);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
}
public function test_log_context_has_request_id(): void
{
$supplied = (string) Str::ulid();
$this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
$this->assertSame($supplied, self::$capturedLogContext['request_id'] ?? null);
}
public function test_log_context_has_user_id_and_org_when_authenticated_organisation_route(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$org->users()->attach($user, ['role' => 'org_admin']);
$user->assignRole('org_admin');
Sanctum::actingAs($user);
$this->getJson('/api/v1/organisations/'.$org->id.'/dashboard-stats');
$this->assertSame($org->id, self::$capturedLogContext['organisation_id'] ?? null);
$this->assertSame($user->id, self::$capturedLogContext['user_id'] ?? null);
}
public function test_log_context_route_matches_named_route(): void
{
$this->getJson('/api/v1/');
// The health-check route at /api/v1/ has no name; expectation is
// simply that the key is absent (filtered out for null) rather
// than carrying a misleading default.
$this->assertArrayNotHasKey('route', self::$capturedLogContext ?? []);
}
public function test_unauthenticated_request_still_gets_request_id(): void
{
// Hitting an authenticated route unauthenticated yields 401 — but
// the request_id middleware still runs.
$response = $this->getJson('/api/v1/auth/me');
$response->assertStatus(401);
$this->assertNotNull($response->headers->get('X-Request-Id'));
}
public function test_request_id_is_valid_ulid_format(): void
{
$response = $this->getJson('/api/v1/');
$emitted = $response->headers->get('X-Request-Id');
$this->assertSame(26, strlen((string) $emitted));
$this->assertTrue(Str::isUlid((string) $emitted));
}
}