Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request
· 11 min read · Boring Observability
Verified against Laravel 13.24 · Guzzle 7.15
Most Laravel apps spend a good share of every request waiting on someone else's server: the payment provider, the shipping API, the CRM, S3. When one of them gets slow, you see your own response times rise and have to work out whose fault it was. Instrumenting the outgoing side answers that directly, and in Laravel nearly all of that traffic goes through Guzzle, which gives you a very good place to hook in.
This post builds that hook. We start with a Guzzle middleware registered once on Laravel's HTTP client, look at what the client's own events can and can't tell you, cover the SDKs that build their own Guzzle client, and then deal with the case that most logging setups miss: the request that never got a response at all. At the end, we compare logs with metrics and point at the two packages we maintain for this.
Key takeaways#
- Register a Guzzle middleware with
Http::globalMiddleware(). It runs for everyHttp::call, including each request inHttp::pool(), and it is the one hook that sees the request, the response and the failure. - Record from Guzzle's
on_statsoption, not from the response.on_statsfires for every transfer, including a timeout or a refused connection, with the transfer time and cURL's error number. A handler that only looks at responses never sees those. - Laravel's
ConnectionFailedevent carries no timing. It holds the request and the exception, and the only way to tell a timeout from a DNS failure is to read the exception's message. - SDKs with their own Guzzle client are invisible to
Http::globalMiddleware(). The AWS SDK (and with it the S3 filesystem driver) is one. Push the same middleware onto their handler stack. - You're counting transfers, not calls. A redirect produces one transfer per hop and
Http::retry(3)up to three, and each one passes through the middleware.
Where your outgoing requests go#
Laravel's Http facade is a wrapper around Guzzle. Every call through it builds a Guzzle handler stack,
and Laravel lets you add to that stack for every request the application makes. That covers your own integration
code, Http::pool(), and any package that uses Laravel's client internally.
It doesn't cover code that creates a GuzzleHttp\Client of its own. Many SDKs do, because they are written
to work outside Laravel too. Those requests go through a separate stack that Laravel never sees, so they need
instrumenting separately. The mechanism is the same in both cases, which is why it's worth writing the middleware as a
plain Guzzle middleware rather than something Laravel-specific.
A Guzzle middleware for every Http:: call#
A Guzzle middleware is a function that takes the next handler and returns a new one. The handler receives the request and the request options and returns a promise. Here is one that logs every transfer:
namespace App\Http;
use GuzzleHttp\TransferStats;
use Illuminate\Support\Facades\Log;
use Psr\Http\Message\RequestInterface;
use Throwable;
class LogOutgoingRequests
{
public function __invoke(callable $handler): callable
{
return function (RequestInterface $request, array $options) use ($handler) {
$previous = $options['on_stats'] ?? null;
$options['on_stats'] = function (TransferStats $stats) use ($previous) {
try {
$response = $stats->getResponse();
$errno = $stats->getHandlerErrorData();
Log::channel('outbound')->info('http.out', [
'method' => $stats->getRequest()->getMethod(),
'host' => $stats->getEffectiveUri()->getHost(),
'path' => $stats->getEffectiveUri()->getPath(),
'status' => $response?->getStatusCode(),
'ms' => (int) round(($stats->getTransferTime() ?? 0) * 1000),
'curl_error' => $response === null && is_int($errno) ? $errno : null,
]);
} catch (Throwable $e) {
report($e);
}
if ($previous) {
$previous($stats);
}
};
return $handler($request, $options);
};
}
}
Register it once, in a service provider:
use App\Http\LogOutgoingRequests;
use Illuminate\Support\Facades\Http;
public function boot(): void
{
Http::globalMiddleware(new LogOutgoingRequests);
}
The middleware doesn't touch the promise. All the recording happens in on_stats, an option that
Guzzle's cURL handler calls once per transfer, after the transfer has finished, whatever the outcome. We keep any
on_stats callback the caller already set and call it afterwards, so a request that passes its own
on_stats still gets it.
The try block matters more than it looks. Guzzle treats an exception thrown from the stats callback as
a failure of the request itself, so a full disk or a misconfigured log channel would turn every outgoing call in the
app into an exception. Instrumentation should never be able to break the thing it observes.
Laravel's client doesn't throw on a 4xx or 5xx unless you call throw() or exhaust a
retry(). A plain Guzzle client does, through its http_errors middleware, but that sits
outside the middleware you pushed. Your middleware sees the 500 as an ordinary response before it becomes a
ServerException, so recording status codes there works the same whichever client made the call.
What Laravel's HTTP client events give you#
Laravel also dispatches three events from its client, and a listener is less code than a middleware. They are worth knowing, because they are where most "log every outgoing request" answers start:
| Event | Fires when | Carries |
|---|---|---|
RequestSending |
Before every attempt | The request |
ResponseReceived |
A response arrived, of any status | The request and the response. $response->transferStats holds Guzzle's TransferStats, so the timing is there. |
ConnectionFailed |
No response arrived | The request and a ConnectionException. Nothing else. |
The successful path is well covered. The failed path isn't. ConnectionFailed has no transfer time, so you
can't tell a request that failed instantly from one that held a worker for 30 seconds. And the cause is only in the
exception message, as a string like cURL error 28: Connection timed out after 1001 milliseconds, which you
would have to parse to count timeouts separately from DNS failures.
The events also exist only on Laravel's client. The SDK traffic in the next section never dispatches them, so an events-based setup can't be extended to cover it. The middleware can.
SDKs that bring their own Guzzle client#
We checked which common PHP SDKs depend on Guzzle directly. The AWS SDK, Google's API client and Resend's SDK all
do, and each builds its own GuzzleHttp\Client. That includes every S3 call made through Laravel's
filesystem, because the S3 driver uses the AWS SDK. A test with a plain new Client() confirms that
Http::globalMiddleware() never sees these requests.
The fix is to push the same middleware onto a handler stack and give the SDK a client built on it:
use App\Http\LogOutgoingRequests;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
$stack = HandlerStack::create();
$stack->push(new LogOutgoingRequests);
$client = new Client(['handler' => $stack]);
How you hand that client over depends on the SDK. Most accept one through a constructor option or a factory method.
openai-php/client, for example, takes it through OpenAI::factory()->withHttpClient($client).
SDKs built on PSR-18 discovery, like openai-php/client and Mailgun's, will pick Guzzle up if it's installed
but still construct their own instance, so the global middleware doesn't reach them either.
Not every SDK uses Guzzle. Stripe's and Twilio's PHP SDKs ship their own cURL client and don't require Guzzle at
all, so a Guzzle middleware won't see their traffic. Check the SDK's composer.json before assuming
your middleware covers it.
The requests that never got a response#
The outages that hurt most often don't produce a 500 at all. The vendor's load balancer stops answering and your requests hang
until the timeout, or a DNS change goes wrong and the hostname stops resolving. There is no response to log in
either case, and an instrumentation layer that hangs off the response, such as a then() on the promise
or a response middleware, has nothing to record.
on_stats still fires. We tested it against Laravel 13.24 and Guzzle 7.15 with a closed port, a hostname
under .invalid, an unroutable address and a server that answered too slowly. In each case the callback ran once, with
hasResponse() returning false and cURL's error number available from getHandlerErrorData():
| What happened | cURL error | Transfer time | Laravel throws |
|---|---|---|---|
| Connection refused | 7 | ~0 s | ConnectionException |
| DNS lookup failed | 6 | The lookup time | ConnectionException |
Connect timeout (connectTimeout(1)) |
28 | 1.00 s | ConnectionException |
Response timeout (timeout(1)) |
28 | 1.00 s | ConnectionException |
The transfer time on a timeout is how long your code waited, roughly the timeout you configured. Keep those rows in your latency data rather than filtering them out. A p95 that ignores timed-out calls will look healthy during exactly the incident you wanted it for. TLS failures arrive the same way under their own error numbers: 35 for a failed handshake, 60 for a certificate that won't verify.
Redirects and retries each count as a transfer#
The middleware sits inside Guzzle's redirect handling, so a request that is redirected once produces two transfers,
and on_stats fires for both. Laravel's retry() goes further and sends the request through
the whole stack again for each attempt. Http::retry(3) against a host refusing connections logged three
transfers and dispatched three RequestSending and ConnectionFailed pairs. Against an
endpoint returning 500 it logged three transfers too.
That is the right thing to record, since each attempt had its own latency and its own way of failing. It does mean your transfer count will be higher than the number of calls your code made. When a vendor bills per request, or a rate limit counts attempts, the transfer count is the number they see.
Logs or metrics?#
Everything so far writes one log line per transfer, which answers questions about a particular call: what happened to the refund request for order 8134, and what did the vendor say? It's poor at trends. Working out the p95 latency to one host over the last week from log lines means a log pipeline that can aggregate, and most can't do it cheaply.
Metrics are the other way round. A duration histogram labelled by host and outcome gives you latency percentiles and error rates per vendor at almost no storage cost, and it can drive alerts. It can't tell you about any single call. Most teams end up wanting both, because you use them at different moments: the chart shows you something is wrong, and the individual records show you what.
If you build the metrics yourself from the middleware above, the label to watch is the path. Every distinct value
becomes its own series, and /v1/orders/8134 and /v1/orders/8135 are different values.
Replace numeric and UUID segments with a placeholder before using the path as a label, or leave the path out and
label by host only.
Prometheus metrics with httptheus#
httptheus is the metrics half
of this post, packaged. It's free and MIT-licensed, and it's built the way this post describes: a Guzzle middleware
registered with Http::globalMiddleware(), recording from on_stats. Install it and there is
nothing else to wire up:
composer require boring-o11y/httptheus
It exports two metrics. httptheus_client_request_duration_seconds is a histogram labelled by host,
method, endpoint and status class, where a transfer with no response gets status_class="error", so
timeouts stay in your latency percentiles. httptheus_client_request_errors_total counts only transfers
that produced no response, with a reason label (timeout, dns,
connection_refused, tls, network or other) read from the cURL
error number:
# p95 latency to one vendor
histogram_quantile(0.95, sum by (le) (
rate(httptheus_client_request_duration_seconds_bucket{host="api.stripe.com"}[5m])
))
# timeouts per second, by host
sum by (host) (rate(httptheus_client_request_errors_total{reason="timeout"}[5m]))
Paths are normalised into the endpoint label before they are recorded, with an allow-list if you want
a hard cap on cardinality. If you already run spatie/laravel-prometheus, the metrics appear on its
/prometheus route with no configuration. Otherwise they're served at /httptheus/metrics.
It also ships a Grafana dashboard, and SDK clients are covered by pushing its RecordHttpMetrics
middleware onto their stack, as above.
A record of every call, with Requizon#
The log-line half is where we think a dashboard does better than a log channel. When a vendor integration starts
failing, the questions are which calls failed, how, since when, and what the vendor sent back. A grep
through JSON logs answers them slowly, and only if you logged the response body, which you probably shouldn't do for
every call.
Requizon is the dashboard we built for that. It uses the same
on_stats hook, stores each transfer in your own MySQL database and serves a Horizon-style UI at
/requizon. Each call is classified as a connection error, an HTTP error or an application error, and a
failed call keeps its response body so you can read what the vendor actually said.
Application errors are the case metrics can't catch: an API that answers 200 OK and puts the failure in
the body. You register one detector that knows what failure looks like for each API, and those calls stop counting as
successes. Request parameters are stored with anything that looks like a credential redacted, and headers are never
stored. What Requizon records lists exactly what
goes into a row, and how it compares with Nightwatch and
Telescope covers where it overlaps with the tools you may already run.
SDK traffic is handled the way it is everywhere else in this post:
use BoringO11y\Requizon\Guzzle\RecordHttpRequests;
$stack = HandlerStack::create();
$stack->push(app(RecordHttpRequests::class));
$client = new Client(['handler' => $stack]);
httptheus and Requizon read the same hook and can run side by side. If your integrations are the kind that go wrong in ways a status code doesn't show, the rate limits and backoff in rate-limited APIs and Laravel queues are the natural next read.
Frequently asked questions
How do I log every outgoing HTTP request in Laravel?
Register a Guzzle middleware with Http::globalMiddleware() in a service provider. It runs for every call made through Laravel's HTTP client, including Http::pool(). Record from Guzzle's on_stats request option rather than from the response, because on_stats also fires for timeouts, DNS failures and refused connections, where there is no response to log.
Why doesn't Http::globalMiddleware() see my SDK's requests?
Because the SDK builds its own GuzzleHttp\Client, with its own handler stack, and never goes through Laravel's HTTP client. The AWS SDK, and with it the S3 filesystem driver, Google's API client and Resend's SDK all do this. Push the same middleware onto a HandlerStack and give the SDK a client built on it. Stripe's and Twilio's SDKs use their own cURL client, so a Guzzle middleware can't see them at all.
How can I tell a timeout from a DNS failure in Laravel's HTTP client?
Both throw a ConnectionException, and the ConnectionFailed event carries only the request and that exception. The cause is cURL's error number: 28 for a timeout, 6 for a DNS failure, 7 for a refused connection. Guzzle's TransferStats exposes it through getHandlerErrorData() in an on_stats callback, along with the transfer time. Otherwise it only appears inside the exception message.
Does Http::retry() count as one request or several?
Several. Each attempt goes through the whole Guzzle handler stack again, so Http::retry(3) against a failing host produces three transfers, three on_stats callbacks and three RequestSending events. Redirects are counted the same way, one transfer per hop, because middleware you add sits inside Guzzle's redirect handling.
Keep reading
10 min read
Dispatching Laravel Jobs After the Transaction Commits
Why Laravel jobs dispatched in a transaction can run before it commits, and how afterCommit behaves with...
13 min read
Laravel Horizon in Prometheus: Why Exported Throughput Reads Low
Why Horizon's metrics undercount in Prometheus, and a small counter exporter for Laravel queues with PromQL,...