From 1c46667f560cd603d3ee78c9ff786c6ad5d028fd Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Sun, 3 May 2026 08:51:18 +0100 Subject: [PATCH] feat(api-logs): persist response body on failed external calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiLogger now stores the upstream response body to `api_logs.response_body` whenever the call failed (non-2xx response or a RequestException carrying a response). Successful 2xx responses remain null so the table stays small on busy services like fuel:poll and oil:fetch. Truncated at 64 KB. The column is mediumText so a future cap raise needs no schema change. Captures: - 4xx and 5xx response bodies verbatim - Body extracted from RequestException via `$e->response->body()` when callers use `Http::throw()` Does not capture: - ConnectionException (no response existed) - Generic Throwable from the closure (same reason) Motivation: the LLM overlay's "skipped — no verified citations" path left no forensic trail to debug. With this, the next time anything routed through ApiLogger fails — Anthropic 429s, FRED 5xx, Fuel Finder errors — the failed body is queryable directly: SELECT response_body FROM api_logs WHERE service = ? AND status_code >= 400 ORDER BY id DESC LIMIT 1; Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Models/ApiLog.php | 2 +- app/Services/ApiLogger.php | 30 +++++++++++- ...45_add_response_body_to_api_logs_table.php | 34 ++++++++++++++ tests/Unit/ApiLoggerTest.php | 46 +++++++++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_05_03_074845_add_response_body_to_api_logs_table.php diff --git a/app/Models/ApiLog.php b/app/Models/ApiLog.php index b92e3ef..e9f30f9 100644 --- a/app/Models/ApiLog.php +++ b/app/Models/ApiLog.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])] +#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error', 'response_body'])] class ApiLog extends Model { /** @use HasFactory */ diff --git a/app/Services/ApiLogger.php b/app/Services/ApiLogger.php index 39ef8fa..6fe5bbd 100644 --- a/app/Services/ApiLogger.php +++ b/app/Services/ApiLogger.php @@ -3,18 +3,29 @@ namespace App\Services; use App\Models\ApiLog; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; use Illuminate\Support\Str; use Throwable; class ApiLogger { + /** + * Cap the stored response body. MEDIUMTEXT can hold ~16MB, but + * persisting more than 64KB is rarely useful for debugging and + * blows up the row size on busy services. + */ + private const int RESPONSE_BODY_CAP = 65_536; + /** * Execute an HTTP request and log it to api_logs. * * The callable must return an Illuminate\Http\Client\Response. * Exceptions are logged and re-thrown so the caller handles them. * + * Persists the response body to `api_logs.response_body` ONLY when + * the call failed (non-2xx) or threw. Truncates to RESPONSE_BODY_CAP. + * * @param callable(): Response $request */ public function send(string $service, string $method, string $url, callable $request): Response @@ -22,19 +33,28 @@ class ApiLogger $start = microtime(true); $statusCode = null; $error = null; + $responseBody = null; try { $response = $request(); $statusCode = $response->status(); if ($response->failed()) { - $error = Str::limit($response->body(), 1000); + $body = $response->body(); + $error = Str::limit($body, 1000); + $responseBody = $this->truncate($body); } return $response; } catch (Throwable $e) { $error = $e->getMessage(); + // RequestException carries the response, ConnectionException + // doesn't. Pull the body when it's available. + if ($e instanceof RequestException) { + $responseBody = $this->truncate($e->response->body()); + } + throw $e; } finally { ApiLog::create([ @@ -44,7 +64,15 @@ class ApiLogger 'status_code' => $statusCode, 'duration_ms' => (int) round((microtime(true) - $start) * 1000), 'error' => $error, + 'response_body' => $responseBody, ]); } } + + private function truncate(string $body): string + { + return strlen($body) > self::RESPONSE_BODY_CAP + ? substr($body, 0, self::RESPONSE_BODY_CAP) + : $body; + } } diff --git a/database/migrations/2026_05_03_074845_add_response_body_to_api_logs_table.php b/database/migrations/2026_05_03_074845_add_response_body_to_api_logs_table.php new file mode 100644 index 0000000..4c0c607 --- /dev/null +++ b/database/migrations/2026_05_03_074845_add_response_body_to_api_logs_table.php @@ -0,0 +1,34 @@ +mediumText('response_body') + ->nullable() + ->after('error') + ->comment('Response body, populated only on failure. Truncated to ~64KB.'); + }); + } + + public function down(): void + { + Schema::table('api_logs', function (Blueprint $table) { + $table->dropColumn('response_body'); + }); + } +}; diff --git a/tests/Unit/ApiLoggerTest.php b/tests/Unit/ApiLoggerTest.php index 1337dba..561d834 100644 --- a/tests/Unit/ApiLoggerTest.php +++ b/tests/Unit/ApiLoggerTest.php @@ -3,6 +3,7 @@ use App\Models\ApiLog; use App\Services\ApiLogger; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); @@ -73,3 +74,48 @@ it('upcases the method', function (): void { expect(ApiLog::first()->method)->toBe('GET'); }); + +it('does NOT store response_body for successful 2xx responses', function (): void { + Http::fake(['https://example.com/ok' => Http::response(['large' => str_repeat('x', 5000)])]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/ok', fn () => Http::get('https://example.com/ok')); + + expect(ApiLog::first()->response_body)->toBeNull(); +}); + +it('stores response_body when status code is 4xx', function (): void { + Http::fake(['https://example.com/bad' => Http::response('{"error":"bad request"}', 400)]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/bad', fn () => Http::get('https://example.com/bad')); + + expect(ApiLog::first()->response_body)->toBe('{"error":"bad request"}'); +}); + +it('stores response_body when status code is 5xx', function (): void { + Http::fake(['https://example.com/boom' => Http::response('Internal Server Error', 500)]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/boom', fn () => Http::get('https://example.com/boom')); + + expect(ApiLog::first()->response_body)->toBe('Internal Server Error'); +}); + +it('truncates response_body at the 64KB cap', function (): void { + $hugeBody = str_repeat('A', 80_000); + Http::fake(['https://example.com/huge' => Http::response($hugeBody, 502)]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/huge', fn () => Http::get('https://example.com/huge')); + + $log = ApiLog::first(); + expect(strlen($log->response_body))->toBe(65_536); +}); + +it('captures response_body when an HTTP RequestException is thrown', function (): void { + Http::fake(['https://example.com/throw' => Http::response('upstream details', 502)]); + + expect(fn () => $this->apiLogger->send( + 'test_service', 'GET', 'https://example.com/throw', + fn () => Http::throw()->get('https://example.com/throw') + ))->toThrow(RequestException::class); + + expect(ApiLog::first()->response_body)->toBe('upstream details'); +});