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:
80
api/app/Http/Middleware/BindRequestLogContext.php
Normal file
80
api/app/Http/Middleware/BindRequestLogContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
161
api/tests/Feature/Observability/RequestIdRoundTripTest.php
Normal file
161
api/tests/Feature/Observability/RequestIdRoundTripTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user