Aller au contenu

Remonter les exceptions

QuietGuard\Monitor\Reporter est le client neutre vis-à-vis du framework. Les adaptateurs le branchent sur les hooks d'exceptions et de logs d'un hôte ; vous pouvez aussi le piloter à la main.

Câbler un Reporter

Le constructeur du Reporter prend un Config, un Http\HttpClient, un Support\Scrubber, un Payload\ExceptionPayloadBuilder et un LoggerInterface PSR optionnel :

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: 'jeton-projet',
    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 par défaut ; false n'envoie que fichier et ligne
    ),
    logger: null, // Psr\Log\LoggerInterface optionnel
);

Envoyer des données

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

Le Reporter expose trois méthodes d'envoi, chacune renvoyant un bool et ne levant jamais d'exception, la surveillance ne doit pas casser l'application hôte :

MéthodeEndpointNotes
reportException(Throwable $e, array $context = [])POST /api/v1/ingestConstruit le payload, masque le context, l'envoie.
sendLogs(array $logs)POST /api/v1/logsEnveloppe les logs en {"logs": [...]} et les masque. Renvoie true immédiatement si $logs est vide.
sendDependencies(array $packages)POST /api/v1/dependenciesEnveloppe en {"packages": [...]}. Renvoie false immédiatement si $packages est vide ; les dépendances ne sont pas masquées.

Un envoi ne renvoie true que sur un HTTP 2xx. Si le client n'est pas configuré (Config::isConfigured() est faux), les méthodes renvoient false sans toucher au réseau. Toute exception de transport est capturée, journalisée via le logger optionnel en avertissement, et transformée en false.

Un refus est journalisé lui aussi, et c'est à cela que sert le logger. Une réponse non-2xx est écrite en avertissement, nommant le statut et l'adresse : un 401 sur une clé mal collée, un 402 pendant une relance de paiement, un 403 sur une limite de forfait. CurlHttpClient ne lève jamais, il renvoie 0 : le catch ci-dessus ne pouvait donc pas se déclencher sur ces cas, et chacun arrivait à l'appelant sous la forme d'un unique false muet. Passez un logger dans un hôte qui n'a pas de console à lui, sans quoi le client n'a aucun moyen de vous dire qu'il n'a jamais fonctionné.

Le payload d'exception

Payload\ExceptionPayloadBuilder::build() transforme un Throwable en la forme attendue sur le fil :

php
[
    'exception' => [
        'class'   => $e::class,
        'message' => $e->getMessage(),
        'file'    => $e->getFile(),
        'line'    => $e->getLine(),
        'trace'   => [ // jusqu'à traceLimit frames, le site de levée en premier
            ['class' => null, 'type' => null, 'function' => null, 'file' => $e->getFile(), 'line' => $e->getLine(),
             'code' => ['start' => ..., 'lines' => [...]]], // extrait de source, frames de l'application seulement
            ['class' => ..., 'type' => ..., 'function' => ..., 'file' => ..., 'line' => ...],
        ],
    ],
    'context' => [ /* release (si défini) fusionné avec votre contexte */ ],
]

Depuis la version 0.3, la trace commence au site de levée : le fichier et la ligne de l'exception elle-même forment la frame zéro, puisque getTrace() commence à l'appelant et n'a jamais contenu la ligne fautive. La trace est tronquée à traceLimit frames, cette frame comprise (1 = le site seul), chacune réduite à class, type, function, file, line. Les frames de l'application portent en plus un objet code, produit par Payload\SourceSnippet : start, le numéro de la première ligne, et lines, les cinq lignes de part et d'autre de la ligne de la frame, lues dans le fichier qui a tourné et coupées à 500 caractères, sur au plus dix frames par rapport (ExceptionPayloadBuilder::MAX_SNIPPET_FRAMES). Une frame dont le chemin contient /vendor/ n'en porte jamais, et toute lecture qui échoue répond null plutôt que de perdre le rapport. codeSnippets: false sur Config (et sur le builder, qui le lit en troisième argument) n'envoie que le fichier et la ligne. Le Reporter passe les extraits par Scrubber::scrubSnippets() : une ligne qui nomme une clé de la liste de masquage, ou un mot de Scrubber::LINE_NEEDLES, et lui donne une valeur est remplacée entière par [scrubbed], voir Masquage. Quand release est non nul, il est fusionné dans context (vos clés de contexte explicites l'emportent en cas de collision). Le contrat exact sur le fil est décrit par l'API d'ingestion (/docs/tool/1.0/ingestion-api).

Quatre champs sont coupés à la longueur que le serveur accepte, par Support\Cut, la coupe étant marquée par … [truncated] : message à 8192 caractères, class à 255, file à 1024 et release à 255. L'endpoint les valide et répond 422 pour le rapport entier : une QueryException portant toute sa requête SQL, ou une exception issue d'une classe anonyme dont le nom contient un chemin absolu, étaient donc précisément les rapports qui disparaissaient. La coupe compte des caractères et non des octets, si bien qu'un caractère multi-octets n'est jamais coupé en deux, ce qui produirait une charge refusée comme UTF-8 invalide.

L'abstraction de transport

Http\HttpClient est un contrat volontairement minimal :

php
interface HttpClient
{
    // renvoie le code de statut HTTP, ou 0 en cas d'échec de transport ; ne doit pas lever d'exception
    public function postJson(string $url, string $token, array $payload, int $timeout): int;
}

Le Http\CurlHttpClient fourni l'implémente avec ext-curl et sans aucune autre dépendance : il encode le corps en JSON et envoie Content-Type: application/json, Accept: application/json et Authorization: Bearer <token>. Il renvoie 0 (au lieu de lever une exception) quand l'encodage JSON ou curl_init() échoue.

Apporter votre propre client

Comme HttpClient est une simple interface, et non PSR-18 lui-même, vous pouvez encapsuler n'importe quel client (un client PSR-18, Guzzle, le client HTTP du framework) derrière elle. Les implémentations ne doivent pas lever d'exception et doivent renvoyer le code de statut (ou 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;
        }
    }
}

Passez votre implémentation comme argument http au Reporter. Pour capturer automatiquement les erreurs dans une appli PHP simple, voir le handler d'erreurs global ; pour garder les secrets hors des payloads, voir le masquage.

Vous lisez la documentation PHP Core v1.0.