Skip to content

Commit 703b6f1

Browse files
authored
Merge pull request #34 from mubbi/feature/api-logger
feat(api logger): added api logger to track requests and responses
2 parents 99a2216 + ef63e33 commit 703b6f1

File tree

6 files changed

+289
-1
lines changed

6 files changed

+289
-1
lines changed

.env.docker.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,5 @@ SANCTUM_ACCESS_TOKEN_EXPIRATION=15
9191
SANCTUM_REFRESH_TOKEN_EXPIRATION=43200
9292
# Optional: Sanctum stateful domains for SPA authentication
9393
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000
94+
95+
API_LOGGER_ENABLED=true

app/Http/Middleware/ApiLogger.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Middleware;
6+
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Auth;
10+
use Illuminate\Support\Facades\Log;
11+
use Symfony\Component\HttpFoundation\Response;
12+
13+
class ApiLogger
14+
{
15+
public function handle(Request $request, Closure $next): Response
16+
{
17+
if (! config('api-logger.enabled')) {
18+
$response = $next($request);
19+
if (! ($response instanceof Response)) {
20+
$content = is_string($response) ? $response : (is_array($response) ? (json_encode($response) ?: '') : '');
21+
$response = new Response($content);
22+
}
23+
24+
return $response;
25+
}
26+
27+
// Get or generate a request ID for tracing
28+
$requestId = $request->headers->get('X-Request-Id') ?? uniqid('req_', true);
29+
$startTime = microtime(true);
30+
$response = $next($request);
31+
if (! ($response instanceof Response)) {
32+
$content = is_string($response) ? $response : (is_array($response) ? (json_encode($response) ?: '') : '');
33+
$response = new Response($content);
34+
}
35+
$endTime = microtime(true);
36+
37+
$user = Auth::user();
38+
$userId = $user ? $user->id : null;
39+
$ip = $request->ip();
40+
$method = $request->method();
41+
$uri = $request->getRequestUri();
42+
/** @var array<string, array<int, string>|string> $rawHeaders */
43+
$rawHeaders = $request->headers->all();
44+
$headers = $this->maskHeaders($rawHeaders);
45+
46+
$rawBody = $request->all();
47+
$body = $this->maskBody($this->castArrayKeysToString($rawBody));
48+
49+
$status = $response->getStatusCode();
50+
51+
$responseContent = $this->getResponseContent($response);
52+
$responseBody = is_array($responseContent)
53+
? $this->maskBody($this->castArrayKeysToString($responseContent))
54+
: $responseContent;
55+
$duration = round(($endTime - $startTime) * 1000, 2);
56+
57+
Log::info('API Request', [
58+
'request_id' => $requestId,
59+
'user_id' => $userId,
60+
'ip' => $ip,
61+
'method' => $method,
62+
'uri' => $uri,
63+
'headers' => $headers,
64+
'body' => $body,
65+
'response_status' => $status,
66+
'response_body' => $responseBody,
67+
'duration_ms' => $duration,
68+
]);
69+
70+
return $response;
71+
}
72+
73+
/**
74+
* @param array<mixed, mixed> $array
75+
* @return array<string, mixed>
76+
*/
77+
private function castArrayKeysToString(array $array): array
78+
{
79+
$result = [];
80+
foreach ($array as $key => $value) {
81+
$result[(string) $key] = $value;
82+
}
83+
84+
return $result;
85+
}
86+
87+
/**
88+
* @param array<string, array<int, string|null>|string|null> $headers
89+
* @return array<string, mixed>
90+
*/
91+
protected function maskHeaders(array $headers): array
92+
{
93+
$masked = [];
94+
$maskKeys = (array) config('api-logger.masked_headers', []);
95+
foreach ($headers as $key => $value) {
96+
if (in_array(strtolower($key), $maskKeys, true)) {
97+
$masked[$key] = '***MASKED***';
98+
} else {
99+
$masked[$key] = $value;
100+
}
101+
}
102+
103+
return $masked;
104+
}
105+
106+
/**
107+
* @param array<string, mixed> $body
108+
* @return array<string, mixed>
109+
*/
110+
protected function maskBody(array $body): array
111+
{
112+
$maskKeys = (array) config('api-logger.masked_body_keys', []);
113+
foreach ($body as $key => &$value) {
114+
if (in_array(strtolower($key), $maskKeys, true)) {
115+
$value = '***MASKED***';
116+
}
117+
}
118+
unset($value);
119+
120+
return $body;
121+
}
122+
123+
/**
124+
* @param Response|string|array<string, mixed> $response
125+
* @return array<string, mixed>|string
126+
*/
127+
protected function getResponseContent(Response|string|array $response): array|string
128+
{
129+
if ($response instanceof Response) {
130+
$content = $response->getContent();
131+
$json = is_string($content) ? json_decode($content, true) : null;
132+
133+
return is_array($json) ? $this->castArrayKeysToString($json) : (is_string($content) ? $content : '');
134+
}
135+
136+
return is_array($response) ? $this->castArrayKeysToString($response) : $response;
137+
}
138+
}

bootstrap/app.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
$middleware->alias([
1818
'ability' => \App\Http\Middleware\CheckTokenAbility::class,
1919
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
20+
'api.logger' => \App\Http\Middleware\ApiLogger::class,
2021
]);
2122
})
2223
->withExceptions(function (Exceptions $exceptions): void {

config/api-logger.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
return [
4+
// Enable or disable API logging
5+
'enabled' => env('API_LOGGER_ENABLED', true),
6+
7+
// Header keys to mask in logs
8+
'masked_headers' => [
9+
'authorization',
10+
'cookie',
11+
'x-api-key',
12+
],
13+
14+
// Body keys to mask in logs
15+
'masked_body_keys' => [
16+
'password',
17+
'token',
18+
'access_token',
19+
'refresh_token',
20+
],
21+
];

routes/api_v1.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use Illuminate\Http\Request;
44
use Illuminate\Support\Facades\Route;
55

6-
Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
6+
Route::prefix('v1')->middleware(['throttle:api', 'api.logger'])->group(function () {
77
Route::get('/', function (Request $request) {
88
return 'Laravel Blog API V1 Root is working';
99
})->name('api.v1.status');
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature\Middleware;
6+
7+
use Illuminate\Support\Facades\Config;
8+
use Illuminate\Support\Facades\Log;
9+
use Illuminate\Support\Facades\Route;
10+
use Tests\TestCase;
11+
12+
class ApiLoggerTest extends TestCase
13+
{
14+
protected function setUp(): void
15+
{
16+
parent::setUp();
17+
// Ensure logging is enabled for tests
18+
Config::set('api-logger.enabled', true);
19+
// Create a test route using the middleware
20+
Route::middleware('api.logger')->post('/test-api-logger', function () {
21+
return response()->json(['success' => true, 'token' => 'shouldbemasked']);
22+
});
23+
}
24+
25+
public function test_logs_request_and_response_with_masking()
26+
{
27+
Log::spy();
28+
$payload = [
29+
'username' => 'testuser',
30+
'password' => 'supersecret',
31+
];
32+
$headers = [
33+
'Authorization' => 'Bearer sometoken',
34+
'X-Request-Id' => 'test-request-id-123',
35+
];
36+
37+
$response = $this->postJson('/test-api-logger', $payload, $headers);
38+
$response->assertOk();
39+
$response->assertJson(['success' => true]);
40+
41+
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) {
42+
return $message === 'API Request'
43+
&& $context['request_id'] === $headers['X-Request-Id']
44+
&& $context['body']['password'] === '***MASKED***'
45+
&& $context['headers']['authorization'] === '***MASKED***'
46+
&& $context['response_body']['token'] === '***MASKED***';
47+
})->once();
48+
}
49+
50+
public function test_does_not_log_when_disabled()
51+
{
52+
Config::set('api-logger.enabled', false);
53+
Log::spy();
54+
$response = $this->postJson('/test-api-logger', []);
55+
$response->assertOk();
56+
Log::shouldNotHaveReceived('info');
57+
}
58+
59+
public function test_logs_with_custom_masked_headers_and_body_keys()
60+
{
61+
Config::set('api-logger.masked_headers', ['authorization', 'x-custom-header']);
62+
Config::set('api-logger.masked_body_keys', ['password', 'secret']);
63+
Log::spy();
64+
$payload = [
65+
'username' => 'testuser',
66+
'password' => 'supersecret',
67+
'secret' => 'topsecret',
68+
];
69+
$headers = [
70+
'Authorization' => 'Bearer sometoken',
71+
'X-Custom-Header' => 'customvalue',
72+
'X-Request-Id' => 'custom-id-456',
73+
];
74+
$response = $this->postJson('/test-api-logger', $payload, $headers);
75+
$response->assertOk();
76+
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
77+
return $context['headers']['authorization'] === '***MASKED***'
78+
&& $context['headers']['x-custom-header'] === '***MASKED***'
79+
&& $context['body']['password'] === '***MASKED***'
80+
&& $context['body']['secret'] === '***MASKED***';
81+
})->once();
82+
}
83+
84+
public function test_logs_all_context_fields()
85+
{
86+
Log::spy();
87+
$payload = ['username' => 'testuser'];
88+
$headers = [
89+
'X-Request-Id' => 'all-fields-id',
90+
];
91+
$response = $this->postJson('/test-api-logger', $payload, $headers);
92+
$response->assertOk();
93+
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) {
94+
return $context['request_id'] === $headers['X-Request-Id']
95+
&& isset($context['ip'])
96+
&& $context['method'] === 'POST'
97+
&& str_contains($context['uri'], '/test-api-logger')
98+
&& $context['response_status'] === 200
99+
&& is_numeric($context['duration_ms']);
100+
})->once();
101+
}
102+
103+
public function test_logs_non_json_response()
104+
{
105+
Route::middleware('api.logger')->get('/test-non-json', function () {
106+
return 'plain text response';
107+
});
108+
Log::spy();
109+
$response = $this->get('/test-non-json');
110+
$response->assertOk();
111+
$response->assertSee('plain text response');
112+
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
113+
return $context['response_body'] === 'plain text response';
114+
})->once();
115+
}
116+
117+
public function test_logs_with_empty_headers_and_body()
118+
{
119+
Log::spy();
120+
$response = $this->postJson('/test-api-logger', [], []);
121+
$response->assertOk();
122+
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
123+
return is_array($context['headers']) && is_array($context['body']);
124+
})->once();
125+
}
126+
}

0 commit comments

Comments
 (0)