Requizon

What gets recorded

Everything through the Http facade, anything on a Guzzle stack you instrument, SDKs that find their client through discovery once you allow it, and nothing on a host you ignore.

Requizon records at the transport. It adds one middleware to Laravel's HTTP client, and the same middleware can be pushed onto any Guzzle handler stack. Whatever passes through it is recorded unless its host is on the ignore list. Nothing at the call site decides. SDKs that find their client through discovery can be recorded too, once you allow it.

Laravel's HTTP client#

With instrument.laravel_client on (the default), Requizon registers its middleware as global middleware on Illuminate\Http\Client\Factory. Every request the factory builds carries it, which covers:

  • Http::get(), Http::post() and every other call through the facade
  • Http::pool(), where each request in the pool is recorded on its own
  • A Factory injected through the container rather than the facade
  • Packages and SDKs that make their calls through Laravel's HTTP client

The factory is only hooked when something resolves it, so a request that makes no outbound calls never constructs the HTTP client. Under Octane the middleware is installed once per factory, not once per request. How global middleware on the HTTP client sits beside request and job middleware is in Laravel middleware for requests, queued jobs and the HTTP client.

Transfers, not calls#

The middleware sits inside Guzzle's redirect handling, and Laravel's retry() sends the whole request again, as does Guzzle's Middleware::retry() pushed with withMiddleware(). A call that follows two redirects is recorded as three rows, and a call retried twice is recorded as three rows. Each hop had its own latency and could fail in its own way, so each gets a row. Expect the dashboard's totals to be higher than the number of calls your code thinks it made.

The one exception is Guzzle's cURL handler resending a request it could not complete, such as a POST that met a 307 or a connection that dropped before any response. That is one row, timed across every attempt, because your code never saw the attempts that were tried again.

Guzzle clients and SDKs#

An SDK that builds its own Guzzle client never touches Laravel's HTTP client, so it is not recorded until you push the middleware onto its handler stack:

use BoringO11y\Requizon\Guzzle\RecordHttpRequests;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;

$stack = HandlerStack::create();
$stack->push(app(RecordHttpRequests::class));

$client = new Client(['handler' => $stack]);

Most SDKs that accept a Guzzle client or handler take it as a constructor option. Resolve the middleware from the container with app(), as above, so it uses the same recorder and configuration as everything else.

Pushing it onto a stack whose requests also pass through the Laravel client's copy is safe. The first copy marks the request's options, the second sees the mark and steps aside, and the transfer is recorded once.

Guzzle 7 and 8 are both supported. To record only the stacks you instrument by hand, turn the global hook off:

REQUIZON_INSTRUMENT_LARAVEL=false

SDKs that use client discovery#

Some SDKs never build a client you could reach. They ask php-http/discovery for whatever PSR-18 client the application has installed. Requizon can answer that question with a client that records, so their calls are recorded without a handler stack to push onto:

composer require php-http/discovery
REQUIZON_DISCOVERY=true

It is off by default, because it changes which client third-party code receives, and that is not a thing to do to an application uninvited. Calls caught this way go through the same naming, ignore_hosts and redaction as every other call. Two things are worth doing before you turn it on:

  • Map the host to the name its other calls use. If the same API is also called through Http::, give its host an apis entry, or the two can land under different names.
  • Put your error reporter in ignore_hosts. Requizon reports its own failures through your exception handler, so recording the reporter's calls is a loop.
SDKs
RecordedThose that resolve a synchronous PSR-18 client through discovery
Not recordedThose handed a client, or that construct their own (AWS, Sentry v4). Use the Guzzle middleware where they accept a handler
Not recordedThose not built on Guzzle or PSR-18 at all. Stripe ships its own cURL client
Not recordedAsync clients resolved through HttpAsyncClientDiscovery

The client those libraries get is a plain Guzzle client wrapped in the recorder. If your application relies on discovery resolving something else, hand Requizon the original, without Requizon's middleware on it or each call is recorded twice:

use BoringO11y\Requizon\Requizon;

Requizon::discoverUsing(fn () => new Symfony\Component\HttpClient\Psr18Client);

The recording client is registered as early as Requizon's service provider runs. A library that already discovered a client in an earlier provider's register() is holding that one, and keeps it.

Ignoring hosts#

ignore_hosts is a list of Str::is() patterns matched against the request's hostname, without the scheme or port. A matching call is not recorded at all: no row, no failure detection, no body read.

// config/requizon.php
'ignore_hosts' => [
    '*.datadoghq.com',
    '*.ingest.sentry.io',
    'logs.example.internal',
    'localhost',
],
Put your telemetry backends here first

An exporter that ships logs, spans or metrics over HTTP through Laravel's client is outbound traffic like any other. Left in, it records a row for every batch it ships, and on a busy application the telemetry becomes the largest API on the dashboard.

Hosts are the only thing the ignore list matches. To drop some paths of an API while keeping others, collapse them with paths.patterns instead.

How a call is recorded#

Recording hooks Guzzle's on_stats option, which reports every transfer, including one that got no response at all, which is how a DNS failure or a timeout gets a row. The row is written once the call has settled, before your code receives the result, so it records what your code actually got. The duration is cURL's own transfer time, so a request that waited in a pool() behind the concurrency limit is not charged for the wait. We built a smaller version by hand in logging and timing every outgoing request with a Guzzle middleware and on_stats.

If your code already passed an on_stats callback, it still runs, and it runs first.

What a recorded call holds#

A row carries the API name, host, normalised path, the path as it was called, method, status code, transfer duration and failure classification, and then what the two messages said: the query string in query_params, the request body in request_params, the response body of a failed call (or of a success, once you ask), and whichever headers header capture is set to keep.

The query string and the body are stored separately, whatever the method. A POST to /v1/charges?expand[]=customer keeps its expand, a HEAD or OPTIONS keeps its query, and a GET that sends a JSON body, as search APIs often do, keeps the body. The Details panel shows the two as Query and Body. Both are stored by parameter name and both go through redaction, per-API rules and the callback included.

Headers are stored under recording.headers, which takes a mode per direction: out of the box request headers are not kept at all and response headers are kept for failures — the same calls whose response body is kept. The Headers page covers the modes, the only and except filters, and the names that are masked.

Recording never breaks the call#

An exception escaping the recorder could otherwise turn into a failure of the request it was observing. So every step of recording is wrapped: an exception is passed to your application's exception handler and then swallowed, and if the exception handler itself throws (a logging channel that cannot be built during a database outage, say), that is swallowed too. The price is that a problem inside Requizon shows up in your error tracker, not as a failed call.

The promise fallback#

A few handlers never invoke on_stats: some SDK shims, and some of the stub shapes Http::fake() accepts. With instrument.promise_fallback on, Requizon notices when a transfer finished without stats and records it anyway, timed by the wall clock. That time includes any wait in a request pool, so treat it as a guarantee the call is not lost rather than as a measurement. The same goes for an attempt whose stats carry no transfer time, as Http::fake()'s do: with the fallback off, it counts as 0 ms.

In your test suite#

Http::fake() responses go through the same middleware, so a test suite with Requizon enabled writes rows to requizon_http_requests, timed by the clock as no transfer took place. Turn it off for tests:

<!-- phpunit.xml -->
<env name="REQUIZON_ENABLED" value="false"/>

What is never recorded#

Anything that goes through neither Guzzle nor client discovery: file_get_contents() on a URL, raw cURL, Symfony's HttpClient used directly, and SDKs built on another transport. Requizon has no hook in those, and cannot see them.

Common questions

Does Requizon record calls made by SDKs that use their own Guzzle client?

Not on its own. An SDK that builds its own Guzzle client never passes through Laravel's HTTP client, so push BoringO11y\Requizon\Guzzle\RecordHttpRequests onto that client's handler stack. It is the same middleware the Laravel client uses, and a transfer that passes through it twice is still recorded once. An SDK that asks php-http/discovery for a PSR-18 client instead is recorded once you set REQUIZON_DISCOVERY=true.

How do I stop Requizon recording calls to my logging or metrics backend?

Add the host to ignore_hosts in config/requizon.php. Entries are Str::is() patterns such as *.datadoghq.com, and matching calls are not recorded at all. Without it, an exporter shipping telemetry over HTTP generates the very traffic it reports on.

Install Requizon today.

Checkout ends with your license key, and the installation guide takes it from there.