From 4a8bb97764c31998f2e87bf6f38bd8b1bc04a596 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 6 May 2026 09:28:50 +0200 Subject: [PATCH] feat: BindRequestLogContext middleware + X-Request-Id round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Http/Middleware/BindRequestLogContext.php | 80 +++++++++ api/bootstrap/app.php | 4 + .../Observability/RequestIdRoundTripTest.php | 161 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 api/app/Http/Middleware/BindRequestLogContext.php create mode 100644 api/tests/Feature/Observability/RequestIdRoundTripTest.php diff --git a/api/app/Http/Middleware/BindRequestLogContext.php b/api/app/Http/Middleware/BindRequestLogContext.php new file mode 100644 index 00000000..54514499 --- /dev/null +++ b/api/app/Http/Middleware/BindRequestLogContext.php @@ -0,0 +1,80 @@ +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; + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index ea84f8fa..4f63a011 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -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([ diff --git a/api/tests/Feature/Observability/RequestIdRoundTripTest.php b/api/tests/Feature/Observability/RequestIdRoundTripTest.php new file mode 100644 index 00000000..379cf42f --- /dev/null +++ b/api/tests/Feature/Observability/RequestIdRoundTripTest.php @@ -0,0 +1,161 @@ +|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 $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 $parameters + */ + public function __call($method, $parameters) + { + return null; + } + }); + } + + /** + * @param array $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)); + } +}