diff --git a/README.md b/README.md index 57c6ebe..822cd18 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ You may override its configuration in your `.env` - the following environment va - `HTTP_CLIENT_GLOBAL_LOGGER_RESPONSE_FORMAT` (string) - `HTTP_CLIENT_GLOBAL_LOGGER_OBFUSCATE_ENABLED` (bool) - `HTTP_CLIENT_GLOBAL_LOGGER_OBFUSCATE_REPLACEMENT` (string) +- `HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_ENABLED` (bool) +- `HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_LIMIT` (int) +- `HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_CONTENT_TYPE_WHITELIST` (string) -(look into `config/http-client-global-logger.php` for further configuration and explanation) +(look into `config/http-client-global-logger.php` for defaults, further configuration, and explanation) ## Features @@ -45,6 +48,8 @@ Using the logger will log both the request and response of an external HTTP requ - Multi-line log records that contain full request/response information (including all headers and body) - Logging into separate logfile `http-client.log`. You're free to override this and use your own logging channel or just log to a different logfile. - Full support of [Guzzle MessageFormatter](https://github.com/guzzle/guzzle/blob/master/src/MessageFormatter.php) variable substitutions for highly customized log messages. +- Basic obfuscation of credentials in HTTP Client requests +- Trimming of response body content to a certain length with support for Content-Type whitelisting - **Variant 1: Global logging** (default) - Zero-configuration: Global logging is enabled by default in this package. - Simple and performant implementation using `RequestSending` / `ResponseReceived` event listeners @@ -166,6 +171,7 @@ Both packages provide a different feature set and have those advantages: - auto-configured log channel `http-client` to log to a separate `http-client.log` file - Full support of [Guzzle MessageFormatter](https://github.com/guzzle/guzzle/blob/master/src/MessageFormatter.php) variable substitutions for highly customized log messages. - basic obfuscation of credentials in HTTP Client requests + - trimming of response body content - [bilfeldt/laravel-http-client-logger](https://github.com/bilfeldt/laravel-http-client-logger) - conditional logging using `logWhen($condition)` - filtering of logs by HTTP response codes diff --git a/config/http-client-global-logger.php b/config/http-client-global-logger.php index 2e0de76..c9652b2 100644 --- a/config/http-client-global-logger.php +++ b/config/http-client-global-logger.php @@ -107,4 +107,24 @@ 'pass,password,token,apikey,access_token,refresh_token,client_secret' )), ], + + /* + |-------------------------------------------------------------------------- + | Trim response body + |-------------------------------------------------------------------------- + | + | Trim response body to a certain length. This is useful when you are logging + | large responses, and you don't want to fill up your log files. + | + | NOTE the leading comma in trim_response_body.content_type_whitelist default value: + | it's there to whitelist empty content types (e.g. when no Content-Type header is set). + */ + 'trim_response_body' => [ + 'enabled' => env('HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_ENABLED', false), + 'limit' => env('HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_LIMIT', 200), + 'content_type_whitelist' => explode(',', env( + 'HTTP_CLIENT_GLOBAL_LOGGER_TRIM_RESPONSE_BODY_CONTENT_TYPE_WHITELIST', + ',application/json' + )), + ], ]; diff --git a/src/Listeners/LogResponseReceived.php b/src/Listeners/LogResponseReceived.php index 6229a61..fe4f879 100644 --- a/src/Listeners/LogResponseReceived.php +++ b/src/Listeners/LogResponseReceived.php @@ -3,9 +3,13 @@ namespace Onlime\LaravelHttpClientGlobalLogger\Listeners; use GuzzleHttp\MessageFormatter; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; use Illuminate\Http\Client\Events\ResponseReceived; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Onlime\LaravelHttpClientGlobalLogger\EventHelper; +use Psr\Http\Message\MessageInterface; use Saloon\Laravel\Events\SentSaloonRequest; class LogResponseReceived @@ -22,7 +26,44 @@ public function handle(ResponseReceived|SentSaloonRequest $event): void $formatter = new MessageFormatter(config('http-client-global-logger.format.response')); Log::channel(config('http-client-global-logger.channel'))->info($formatter->format( EventHelper::getPsrRequest($event), - EventHelper::getPsrResponse($event), + $this->trimBody(EventHelper::getPsrResponse($event)) )); } + + /** + * Trim the response body when it's too long. + */ + private function trimBody(Response $psrResponse): Response|MessageInterface + { + // Check if trimming is enabled + if (! config('http-client-global-logger.trim_response_body.enabled')) { + return $psrResponse; + } + + // E.g.: application/json; charset=utf-8 => application/json + $contentTypeHeader = Str::of($psrResponse->getHeaderLine('Content-Type')) + ->before(';') + ->trim() + ->lower() + ->value(); + + $whiteListedContentTypes = array_map( + fn (string $type) => trim(strtolower($type)), + config('http-client-global-logger.trim_response_body.content_type_whitelist') + ); + + // Check if the content type is whitelisted + if (in_array($contentTypeHeader, $whiteListedContentTypes)) { + return $psrResponse; + } + + $limit = config('http-client-global-logger.trim_response_body.limit'); + + // Check if the body size exceeds the limit + return ($psrResponse->getBody()->getSize() <= $limit) + ? $psrResponse + : $psrResponse->withBody(Utils::streamFor( + Str::limit($psrResponse->getBody(), $limit) + )); + } } diff --git a/tests/HttpClientLoggerTest.php b/tests/HttpClientLoggerTest.php index 3b10f87..c2331a0 100644 --- a/tests/HttpClientLoggerTest.php +++ b/tests/HttpClientLoggerTest.php @@ -2,21 +2,29 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Mockery\MockInterface; use Onlime\LaravelHttpClientGlobalLogger\HttpClientLogger; use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; -it('can add a global request middleware to log the requests', function () { - Http::globalRequestMiddleware( - fn (RequestInterface $psrRequest) => $psrRequest->withHeader('X-Test', 'test') - ); - +function setupLogger(): MockInterface +{ HttpClientLogger::addRequestMiddleware(); $logger = Mockery::mock(LoggerInterface::class); Log::shouldReceive('channel')->with('http-client')->andReturn($logger); + return $logger; +} + +it('can add a global request middleware to log the requests', function () { + Http::globalRequestMiddleware( + fn (RequestInterface $psrRequest) => $psrRequest->withHeader('X-Test', 'test') + ); + + $logger = setupLogger(); + $logger->shouldReceive('info')->withArgs(function ($message) { expect($message) ->toContain('REQUEST: GET https://example.com') @@ -35,3 +43,83 @@ Http::fake()->get('https://example.com'); }); + +it('can trim the body response', function (array $config, string $contentType, bool $shouldTrim, bool $addCharsetToContentType) { + config(['http-client-global-logger.trim_response_body' => $config]); + + $logger = setupLogger(); + + $logger->shouldReceive('info')->withArgs(function ($message) { + expect($message)->toContain('REQUEST: GET https://example.com'); + return true; + })->once(); + + $logger->shouldReceive('info')->withArgs(function ($message) use ($shouldTrim) { + expect($message)->toContain($shouldTrim ? 'verylongbo...' : 'verylongbody'); + return true; + })->once(); + + Http::fake([ + '*' => Http::response('verylongbody', 200, [ + 'Content-Type' => $contentType.($addCharsetToContentType ? '; charset=UTF-8' : ''), + ]), + ])->get('https://example.com'); +})->with( + [ + 'disabled' => [ + 'config' => [ + 'enabled' => false, + 'limit' => 10, + 'content_type_whitelist' => ['application/json'], + ], + 'contentType' => 'application/octet-stream', + 'shouldTrim' => false, + ], + 'below_limit' => [ + 'config' => [ + 'enabled' => true, + 'limit' => 20, + 'content_type_whitelist' => ['application/json'], + ], + 'contentType' => 'application/octet-stream', + 'shouldTrim' => false, + ], + 'content_type_whitelisted' => [ + 'config' => [ + 'enabled' => true, + 'limit' => 10, + 'content_type_whitelist' => ['application/octet-stream'], + ], + 'contentType' => 'application/octet-stream', + 'shouldTrim' => false, + ], + 'trim' => [ + 'config' => [ + 'enabled' => true, + 'limit' => 10, + 'content_type_whitelist' => ['application/json'], + ], + 'contentType' => 'application/octet-stream', + 'shouldTrim' => true, + ], + 'no_content_type_trim' => [ + 'config' => [ + 'enabled' => true, + 'limit' => 10, + 'content_type_whitelist' => ['application/octet-stream'], + ], + 'contentType' => '', + 'shouldTrim' => true, + ], + 'no_content_type_whitelisted' => [ + 'config' => [ + 'enabled' => true, + 'limit' => 10, + 'content_type_whitelist' => ['', 'application/octet-stream'], + ], + 'contentType' => '', + 'shouldTrim' => false, + ], + ], + [true, false] +);