Skip to content

Reporting exceptions

QuietGuard\Monitor\Reporter is the platform-neutral client. Adapters wire it to a host's exception and logging hooks; you can also drive it by hand.

Wiring a Reporter

The Reporter constructor takes a Config, an Http\HttpClient, a Support\Scrubber, a Payload\ExceptionPayloadBuilder, and an optional PSR LoggerInterface:

php
use QuietGuard\Monitor\Config;
use QuietGuard\Monitor\Reporter;
use QuietGuard\Monitor\Http\CurlHttpClient;
use QuietGuard\Monitor\Support\Scrubber;
use QuietGuard\Monitor\Payload\ExceptionPayloadBuilder;

$config = new Config(
    url: 'https://monitor.example.com',
    key: 'project-token',
    release: 'a1b2c3d',
);

$reporter = new Reporter(
    config: $config,
    http: new CurlHttpClient(),
    scrubber: new Scrubber(['password', 'authorization', 'api_key']),
    builder: new ExceptionPayloadBuilder(
        traceLimit: $config->traceLimit,
        release: $config->release,
        codeSnippets: $config->codeSnippets, // true by default; false sends file and line only
    ),
    logger: null, // optional Psr\Log\LoggerInterface
);

Sending data

php
try {
    // your code
} catch (\Throwable $e) {
    $reporter->reportException($e, ['user_id' => 42]);
}

The Reporter exposes three send methods, each returning bool and never throwing, monitoring must not break the host application:

MethodEndpointNotes
reportException(Throwable $e, array $context = [])POST /api/v1/ingestBuilds the payload, scrubs the context, sends it.
sendLogs(array $logs)POST /api/v1/logsWraps logs as {"logs": [...]} and scrubs them. Returns true immediately when $logs is empty.
sendDependencies(array $packages)POST /api/v1/dependenciesWraps as {"packages": [...]}. Returns false immediately when $packages is empty; dependencies are not scrubbed.

A send returns true only on an HTTP 2xx. If the client is not configured (Config::isConfigured() is false) the methods return false without touching the network. Any transport exception is caught, logged through the optional logger as a warning, and turned into false.

A refusal is logged too, and that is what the logger is for. A non-2xx answer is written as a warning naming the status and the address: a 401 on a mistyped key, a 402 while a payment is outstanding, a 403 on a plan gate. CurlHttpClient never throws, it returns 0, so the catch above could not fire on those and each of them arrived at the caller as one silent false. Pass a logger in a host that has no console of its own, or the client has no way at all to tell you it has never worked.

The exception payload

Payload\ExceptionPayloadBuilder::build() turns a Throwable into the wire shape:

php
[
    'exception' => [
        'class'   => $e::class,
        'message' => $e->getMessage(),
        'file'    => $e->getFile(),
        'line'    => $e->getLine(),
        'trace'   => [ // up to traceLimit frames, the throw site first
            ['class' => null, 'type' => null, 'function' => null, 'file' => $e->getFile(), 'line' => $e->getLine(),
             'code' => ['start' => ..., 'lines' => [...]]], // source snippet, application frames only
            ['class' => ..., 'type' => ..., 'function' => ..., 'file' => ..., 'line' => ...],
        ],
    ],
    'context' => [ /* release (if set) merged with your context */ ],
]

From version 0.3, the trace starts at the throw site: the exception's own file and line form frame zero, since getTrace() starts at the caller and never held the failing line. The trace is sliced to traceLimit frames, that frame included (1 = the site alone), each reduced to class, type, function, file, line. Application frames also carry a code object, produced by Payload\SourceSnippet: start, the number of the first line, and lines, the five lines on each side of the frame's line, read from the file that ran and cut at 500 characters, on at most ten frames per report (ExceptionPayloadBuilder::MAX_SNIPPET_FRAMES). A frame whose path contains /vendor/ never carries one, and any read that fails answers null rather than losing the report. codeSnippets: false on Config (and on the builder, which reads it as its third argument) sends file and line only. The Reporter passes the snippets through Scrubber::scrubSnippets(): a line that names a key of the scrub list, or a word of Scrubber::LINE_NEEDLES, and gives it a value is masked whole as [scrubbed], see Scrubbing. When release is non-null it is merged into context (your explicit context keys win on collision). The exact wire contract is described by the ingestion API (/docs/tool/1.0/ingestion-api).

Four fields are trimmed to the length the server accepts, by Support\Cut, with … [truncated] marking the cut: message at 8192 characters, class at 255, file at 1024 and release at 255. The endpoint validates them and answers 422 for the whole report, so a QueryException carrying its entire SQL statement, or an exception from an anonymous class whose name holds an absolute path, used to be exactly the report that vanished. The cut counts characters rather than bytes, so a multi-byte character is never split into a payload the server rejects as malformed UTF-8.

The transport abstraction

Http\HttpClient is a deliberately minimal contract:

php
interface HttpClient
{
    // returns the HTTP status code, or 0 on transport failure; must not throw
    public function postJson(string $url, string $token, array $payload, int $timeout): int;
}

The bundled Http\CurlHttpClient implements it with ext-curl and no other dependency: it JSON-encodes the body and sends Content-Type: application/json, Accept: application/json and Authorization: Bearer <token>. It returns 0 (rather than throwing) when JSON encoding or curl_init() fails.

Bringing your own client

Because HttpClient is a plain interface, not PSR-18 itself, you can wrap any client (a PSR-18 client, Guzzle, the framework HTTP client) behind it. Implementations must not throw and must return the status code (or 0):

php
use QuietGuard\Monitor\Http\HttpClient;
use Psr\Http\Client\ClientInterface;      // PSR-18
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;

final class Psr18HttpClient implements HttpClient
{
    public function __construct(
        private ClientInterface $client,
        private RequestFactoryInterface $requests,
        private StreamFactoryInterface $streams,
    ) {}

    public function postJson(string $url, string $token, array $payload, int $timeout): int
    {
        try {
            $request = $this->requests->createRequest('POST', $url)
                ->withHeader('Content-Type', 'application/json')
                ->withHeader('Accept', 'application/json')
                ->withHeader('Authorization', 'Bearer '.$token)
                ->withBody($this->streams->createStream((string) json_encode($payload)));

            return $this->client->sendRequest($request)->getStatusCode();
        } catch (\Throwable) {
            return 0;
        }
    }
}

Pass your implementation as the http argument to the Reporter. To capture errors automatically in a plain-PHP app, see the global error handler; to keep secrets out of payloads, see scrubbing.

You are reading the PHP Core v1.0 documentation.