From ef63e33e7d58ef533d2cf0e77014f360f1a1c0c3 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Thu, 24 Jul 2025 22:33:14 +0500 Subject: [PATCH] feat(api logger): added api logger to track requests and responses --- .env.docker.example | 2 + app/Http/Middleware/ApiLogger.php | 138 +++++++++++++++++++++ bootstrap/app.php | 1 + config/api-logger.php | 21 ++++ routes/api_v1.php | 2 +- tests/Feature/Middleware/ApiLoggerTest.php | 126 +++++++++++++++++++ 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 app/Http/Middleware/ApiLogger.php create mode 100644 config/api-logger.php create mode 100644 tests/Feature/Middleware/ApiLoggerTest.php diff --git a/.env.docker.example b/.env.docker.example index 7774ec3..206f4dd 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -91,3 +91,5 @@ SANCTUM_ACCESS_TOKEN_EXPIRATION=15 SANCTUM_REFRESH_TOKEN_EXPIRATION=43200 # Optional: Sanctum stateful domains for SPA authentication SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000 + +API_LOGGER_ENABLED=true diff --git a/app/Http/Middleware/ApiLogger.php b/app/Http/Middleware/ApiLogger.php new file mode 100644 index 0000000..4beabb3 --- /dev/null +++ b/app/Http/Middleware/ApiLogger.php @@ -0,0 +1,138 @@ +headers->get('X-Request-Id') ?? uniqid('req_', true); + $startTime = microtime(true); + $response = $next($request); + if (! ($response instanceof Response)) { + $content = is_string($response) ? $response : (is_array($response) ? (json_encode($response) ?: '') : ''); + $response = new Response($content); + } + $endTime = microtime(true); + + $user = Auth::user(); + $userId = $user ? $user->id : null; + $ip = $request->ip(); + $method = $request->method(); + $uri = $request->getRequestUri(); + /** @var array|string> $rawHeaders */ + $rawHeaders = $request->headers->all(); + $headers = $this->maskHeaders($rawHeaders); + + $rawBody = $request->all(); + $body = $this->maskBody($this->castArrayKeysToString($rawBody)); + + $status = $response->getStatusCode(); + + $responseContent = $this->getResponseContent($response); + $responseBody = is_array($responseContent) + ? $this->maskBody($this->castArrayKeysToString($responseContent)) + : $responseContent; + $duration = round(($endTime - $startTime) * 1000, 2); + + Log::info('API Request', [ + 'request_id' => $requestId, + 'user_id' => $userId, + 'ip' => $ip, + 'method' => $method, + 'uri' => $uri, + 'headers' => $headers, + 'body' => $body, + 'response_status' => $status, + 'response_body' => $responseBody, + 'duration_ms' => $duration, + ]); + + return $response; + } + + /** + * @param array $array + * @return array + */ + private function castArrayKeysToString(array $array): array + { + $result = []; + foreach ($array as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + /** + * @param array|string|null> $headers + * @return array + */ + protected function maskHeaders(array $headers): array + { + $masked = []; + $maskKeys = (array) config('api-logger.masked_headers', []); + foreach ($headers as $key => $value) { + if (in_array(strtolower($key), $maskKeys, true)) { + $masked[$key] = '***MASKED***'; + } else { + $masked[$key] = $value; + } + } + + return $masked; + } + + /** + * @param array $body + * @return array + */ + protected function maskBody(array $body): array + { + $maskKeys = (array) config('api-logger.masked_body_keys', []); + foreach ($body as $key => &$value) { + if (in_array(strtolower($key), $maskKeys, true)) { + $value = '***MASKED***'; + } + } + unset($value); + + return $body; + } + + /** + * @param Response|string|array $response + * @return array|string + */ + protected function getResponseContent(Response|string|array $response): array|string + { + if ($response instanceof Response) { + $content = $response->getContent(); + $json = is_string($content) ? json_decode($content, true) : null; + + return is_array($json) ? $this->castArrayKeysToString($json) : (is_string($content) ? $content : ''); + } + + return is_array($response) ? $this->castArrayKeysToString($response) : $response; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 324c832..c874433 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,7 @@ $middleware->alias([ 'ability' => \App\Http\Middleware\CheckTokenAbility::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'api.logger' => \App\Http\Middleware\ApiLogger::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/api-logger.php b/config/api-logger.php new file mode 100644 index 0000000..2da8b02 --- /dev/null +++ b/config/api-logger.php @@ -0,0 +1,21 @@ + env('API_LOGGER_ENABLED', true), + + // Header keys to mask in logs + 'masked_headers' => [ + 'authorization', + 'cookie', + 'x-api-key', + ], + + // Body keys to mask in logs + 'masked_body_keys' => [ + 'password', + 'token', + 'access_token', + 'refresh_token', + ], +]; diff --git a/routes/api_v1.php b/routes/api_v1.php index a97186a..88ffd43 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -3,7 +3,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -Route::prefix('v1')->middleware(['throttle:api'])->group(function () { +Route::prefix('v1')->middleware(['throttle:api', 'api.logger'])->group(function () { Route::get('/', function (Request $request) { return 'Laravel Blog API V1 Root is working'; })->name('api.v1.status'); diff --git a/tests/Feature/Middleware/ApiLoggerTest.php b/tests/Feature/Middleware/ApiLoggerTest.php new file mode 100644 index 0000000..fb6fb0e --- /dev/null +++ b/tests/Feature/Middleware/ApiLoggerTest.php @@ -0,0 +1,126 @@ +post('/test-api-logger', function () { + return response()->json(['success' => true, 'token' => 'shouldbemasked']); + }); + } + + public function test_logs_request_and_response_with_masking() + { + Log::spy(); + $payload = [ + 'username' => 'testuser', + 'password' => 'supersecret', + ]; + $headers = [ + 'Authorization' => 'Bearer sometoken', + 'X-Request-Id' => 'test-request-id-123', + ]; + + $response = $this->postJson('/test-api-logger', $payload, $headers); + $response->assertOk(); + $response->assertJson(['success' => true]); + + Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) { + return $message === 'API Request' + && $context['request_id'] === $headers['X-Request-Id'] + && $context['body']['password'] === '***MASKED***' + && $context['headers']['authorization'] === '***MASKED***' + && $context['response_body']['token'] === '***MASKED***'; + })->once(); + } + + public function test_does_not_log_when_disabled() + { + Config::set('api-logger.enabled', false); + Log::spy(); + $response = $this->postJson('/test-api-logger', []); + $response->assertOk(); + Log::shouldNotHaveReceived('info'); + } + + public function test_logs_with_custom_masked_headers_and_body_keys() + { + Config::set('api-logger.masked_headers', ['authorization', 'x-custom-header']); + Config::set('api-logger.masked_body_keys', ['password', 'secret']); + Log::spy(); + $payload = [ + 'username' => 'testuser', + 'password' => 'supersecret', + 'secret' => 'topsecret', + ]; + $headers = [ + 'Authorization' => 'Bearer sometoken', + 'X-Custom-Header' => 'customvalue', + 'X-Request-Id' => 'custom-id-456', + ]; + $response = $this->postJson('/test-api-logger', $payload, $headers); + $response->assertOk(); + Log::shouldHaveReceived('info')->withArgs(function ($message, $context) { + return $context['headers']['authorization'] === '***MASKED***' + && $context['headers']['x-custom-header'] === '***MASKED***' + && $context['body']['password'] === '***MASKED***' + && $context['body']['secret'] === '***MASKED***'; + })->once(); + } + + public function test_logs_all_context_fields() + { + Log::spy(); + $payload = ['username' => 'testuser']; + $headers = [ + 'X-Request-Id' => 'all-fields-id', + ]; + $response = $this->postJson('/test-api-logger', $payload, $headers); + $response->assertOk(); + Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) { + return $context['request_id'] === $headers['X-Request-Id'] + && isset($context['ip']) + && $context['method'] === 'POST' + && str_contains($context['uri'], '/test-api-logger') + && $context['response_status'] === 200 + && is_numeric($context['duration_ms']); + })->once(); + } + + public function test_logs_non_json_response() + { + Route::middleware('api.logger')->get('/test-non-json', function () { + return 'plain text response'; + }); + Log::spy(); + $response = $this->get('/test-non-json'); + $response->assertOk(); + $response->assertSee('plain text response'); + Log::shouldHaveReceived('info')->withArgs(function ($message, $context) { + return $context['response_body'] === 'plain text response'; + })->once(); + } + + public function test_logs_with_empty_headers_and_body() + { + Log::spy(); + $response = $this->postJson('/test-api-logger', [], []); + $response->assertOk(); + Log::shouldHaveReceived('info')->withArgs(function ($message, $context) { + return is_array($context['headers']) && is_array($context['body']); + })->once(); + } +}