9min.

Comment profiler un service avec Symfony

Si vous développez avec Symfony, vous connaissez probablement le « Symfony Profiler ». Cette barre d’outils s’affiche en bas de vos pages web et fournit des pages dédiées pour analyser les données collectées par Symfony.

Saviez-vous qu’il est possible d’ajouter davantage d’informations au profiler ? C’est ce que nous allons explorer dans cet article.

Section intitulée pourquoi-profiler-un-servicePourquoi profiler un service ?

Lorsque notre application interagit avec le monde extérieur (par exemple : HTTP, SQL, AMQP, API tierces, etc.), il est très utile de mettre en place un profilage pour observer facilement ce que notre application envoie et reçoit.

Cela évite d’avoir à appeler la fonction dump(). En un coup d’œil, nous disposons de toutes les informations nécessaires pour faciliter le débogage.

Voici ce que nous pouvons et allons faire :

Symfony web developer toolbar : Stripe widget

Symfony Profiler : Stripe panel

Comme vous l’avez deviné, nous allons créer un data collector pour le SDK Stripe 🎉

Notez qu’un collecteur de données n’est pas limité à la collecte d’informations échangées avec le monde extérieur. Par exemple, Symfony dispose d’un profiler pour les workflows ou la configuration de l’application.

Section intitulée decortiquons-le-sdk-de-stripeDécortiquons le SDK de Stripe

Avant de nous lancer tête baissée dans le code, il est essentiel de comprendre comment fonctionne la brique que nous souhaitons profiler et quels sont les points d’extension à notre disposition.

Stripe offre un point d’extension pour modifier l’ApiRequestor. Cette classe a pour but d’envoyer une requête HTTP et de retourner une réponse.

Bien que Stripe propose une API orientée objet, la manière de changer ce requestor est un vestige des temps anciens où l’API était uniquement statique :

$curl = new Stripe\HttpClient\CurlClient();

Stripe\ApiRequestor::setHttpClient($curl);

Avec Symfony, nous préférons des approches entièrement orientées objet, mais ici nous n’avons pas le choix. Nous devrons utiliser une fabrique pour créer et configurer le client :

namespace AppBundle\Billing\Stripe;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Stripe\HttpClient\ClientInterface;
use Stripe\HttpClient\StreamingClientInterface;
use Stripe\StripeClient;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class StripeClientFactory
{
    public function __construct(
        #[Autowire('%stripe_api_key%')]
        private string $apiKey,
        private ClientInterface $client,
        private StreamingClientInterface $streamingClient,
        private LoggerInterface $logger = new NullLogger(),
    ) {
    }

    public function create(): StripeClient
    {
        if (!$this->apiKey) {
            throw new \LogicException('Stripe API key is not set.');
        }

        \Stripe\Stripe::setLogger($this->logger);
        \Stripe\ApiRequestor::setHttpClient($this->client);
        \Stripe\ApiRequestor::setStreamingHttpClient($this->streamingClient);

        return new StripeClient([
            'api_key' => $this->apiKey,
            'stripe_version' => '2024-06-20',
        ]);
    }
}

Nous en profitons pour configurer un logger, ainsi qu’un client en streaming.

Il ne reste plus qu’à configurer nos services :

services:
    _defaults:
        autowire: true
        autoconfigure: true

    # Ce service implémente les deux interfaces
    Stripe\HttpClient\CurlClient: ~
    Stripe\HttpClient\ClientInterface: '@Stripe\HttpClient\CurlClient'
    Stripe\HttpClient\StreamingClientInterface: '@Stripe\HttpClient\CurlClient'

    Stripe\StripeClient:
        factory: ['@AppBundle\Billing\Stripe\StripeClientFactory', create]

Et voilà! Maintenant nous pouvons utiliser le service Stripe\StripeClient dans notre code :

class Foobar
{
    public function __construct(
        private readonly StripeClient $stripeClient,
    ) {
    }

    /**
     * @return Collection<Customer>
     */
    public function getCustomers(): Collection
    {
        return $this->stripeClient->customers->all([]);
    }

Section intitulée un-data-collectorUn data collector

Symfony met à notre disposition un point d’extension pour ajouter plus d’informations au profiler. Ce point d’extension passe par l’implémentation de l’interface Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface :

interface DataCollectorInterface extends ResetInterface
{
    /**
     * Collects data for the given Request and Response.
     *
     * @return void
     */
    public function collect(Request $request, Response $response, ?\Throwable $exception = null);

    /**
     * Returns the name of the collector.
     *
     * @return string
     */
    public function getName();
}

Notre instance sera automatiquement sérialisée à la fin de la requête HTTP et sauvegardée dans le cache. Ensuite, lorsque nous inspecterons une requête, Symfony désérialisera cette instance.

Pour nous simplifier la vie, Symfony met à notre disposition la classe Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector pour nous éviter tout le code rébarbatif.

Ici, nous n’allons pas utiliser la méthode collect() qui est liée à un couple Request / Response. Nous allons ajouter une méthode call() qui prend en argument un callable, ainsi qu’une collection d’arguments. Le collecteur va ensuite exécuter cette callbable, et capturer toutes les informations nécessaires au débogage :

class StripeCollector extends AbstractDataCollector
{
    public function __construct(
        #[Autowire('%kernel.project_dir%')]
        private readonly string $projetDir,
        private readonly Stopwatch $stopwatch,
    ) {
    }

    public function call(callable $callable, array $args): mixed
    {
        $this->stopwatch->start('stripe', 'stripe');
        $startAt = microtime(true);

        try {
            return $return = $callable(...$args);
        } catch (\Throwable $exception) {
            throw $exception;
        } finally {
            $this->stopwatch->stop('stripe');

            foreach ($trace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS) as $k => $frame) {
                // This method
                if (0 === $k) {
                    continue;
                }
                if (!str_starts_with($frame['file'], $this->projetDir . '/src')) {
                    continue;
                }
                break;
            }
            $call = [
                'duration' => microtime(true) - $startAt,
                'args' => $this->cloneVar($args),
            ];
            if (isset($frame, $k)) {
                $call['method'] = $frame['class'] . '::' . $frame['function'];
                $call['trace'] = \array_slice($trace, $k);
            }
            if ($exception ?? null) {
                $call['exception'] = $this->cloneVar($exception);
            } elseif ($return ?? null) {
                $call['response'] = $return[0];
                $call['responseDecoded'] = $this->cloneVar(json_decode($return[0], true) ?? $return[0]);
                $call['status'] = $return[1];
                $call['headers'] = $this->cloneVar($return[2]);
            }

            $this->data['calls'][] = $call;
        }
    }

    public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
    {
        // Non utilisé!
    }

Le code est un peu technique 😅. Ce qu’il faut retenir, c’est que nous capturons un maximum d’informations pour les afficher par la suite. Pour capturer des éléments, nous utilisons la méthode $this->cloneVar(...) qui utilise le composant VarDumper et qui permet de sérialiser des variables de manière sécurisée.

Section intitulée surcharge-de-stripeSurcharge de Stripe

Nous y sommes enfin ! Nous allons surcharger les clients Stripe pour les intégrer avec le DataCollector :

namespace AppBundle\Billing\Stripe\Debug;

use Stripe\HttpClient\ClientInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\When;

#[When('dev')]
#[AsDecorator(ClientInterface::class)]
class Client implements ClientInterface
{
    public function __construct(
        private readonly ClientInterface $client,
        private readonly StripeCollector $collector,
    ) {
    }

    public function request($method, $absUrl, $headers, $params, $hasFile)
    {
        return $this->collector->call(
            $this->client->request(...),
            \func_get_args(),
        );
    }
}

et

namespace AppBundle\Billing\Stripe\Debug;

use Stripe\HttpClient\StreamingClientInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\When;

#[When('dev')]
#[AsDecorator(StreamingClientInterface::class)]
class StreamingClient implements StreamingClientInterface
{
    public function __construct(
        private readonly StreamingClientInterface $streamingClientInterface,
        private readonly StripeCollector $collector,
    ) {
    }

    public function requestStream($method, $absUrl, $headers, $params, $hasFile, $readBodyChunkCallable)
    {
        return $this->collector->call(
            $this->streamingClientInterface->requestStream(...),
            \func_get_args(),
        );
    }
}

Section intitulée un-mot-sur-les-attributsUn mot sur les attributs

Nous utilisons ici deux attributs :

  • AsDecorator nous permet de facilement surcharger (décorer) un service
  • When pour nous assurer que notre surcharge n’est active qu’en environnement de développement

Finalement, dans chaque implémentation, nous appelons StripeCollector::call() avec comme callable l’implémentation réelle ainsi que tous les arguments qui vont bien.

Prenons quelques minutes pour expliquer ce qu’il se passe en environnement de dev :

Quand le service Stripe\StripeClient est requis:

  1. La factory AppBundle\Billing\Stripe\StripeClientFactory est créée avec comme arguments :
    1. Notre implémentation du AppBundle\Billing\Stripe\Debug\Client, qui a pour arguments
      1. Le client original Stripe\HttpClient\CurlClient
      2. Le DataCollector
    2. Notre implémentation du AppBundle\Billing\Stripe\Debug\StreamingClient
      1. Le client original Stripe\HttpClient\CurlClient
      2. Le DataCollector
  2. La factory configure Stripe avec les clients HTTP reçus en argument
  3. Instancie et retourne la classe Stripe\StripeClient

Dans les autres environnements, il n’y a pas le décorateur et donc pas de DataCollector.

Section intitulée configuration-du-datacollector-et-templateConfiguration du DataCollector et Template

Il ne reste plus qu’à configurer le chemin de la template à utiliser :

namespace AppBundle\Billing\Stripe\Debug;

use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
use Symfony\Component\DependencyInjection\Attribute\When;

#[When('dev')]
class StripeCollector extends AbstractDataCollector
{
    public static function getTemplate(): ?string
    {
        return 'collector/stripe/stripe.html.twig';
    }

    public function getCalls(): array
    {
        return $this->data['calls'] ?? [];
    }
}

et pour finir, la template :

Voir le code de la template
{% extends request.isXmlHttpRequest ? '@WebProfiler/Profiler/ajax_layout.html.twig' : '@WebProfiler/Profiler/layout.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <style>
        h4 {
            margin-top: 0.2em;
        }
    </style>
{% endblock %}

{% block svg %}
    <svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><defs><linearGradient x1="100%" y1="58.356%" x2="0%" y2="0%" id="a"><stop stop-color="#2697D4" offset="0%"/><stop stop-color="#207BCB" offset="50%"/><stop stop-color="#2285CE" offset="100%"/></linearGradient><filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="b"><feOffset dx="2" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur stdDeviation="3.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.185125113 0" in="shadowBlurOuter1" result="shadowMatrixOuter1"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><path fill="url(#a)" d="M0 0h256v256H0z"/><path d="M139.688 111c-12.927-4.78-20.011-8.5-20.011-14.343 0-4.96 4.073-7.792 11.334-7.792 13.281 0 26.916 5.136 36.302 9.739l5.312-32.76c-7.438-3.542-22.667-9.385-43.74-9.385-14.874 0-27.27 3.895-36.124 11.156-9.21 7.614-13.99 18.594-13.99 31.875 0 24.083 14.698 34.354 38.604 43.031 15.406 5.49 20.542 9.386 20.542 15.406 0 5.844-4.96 9.208-13.99 9.208-11.157 0-29.572-5.489-41.615-12.572L77 187.677c10.27 5.844 29.395 11.864 49.23 11.864 15.76 0 28.864-3.718 37.718-10.801 9.916-7.792 15.052-19.302 15.052-34.177 0-24.615-15.052-34.886-39.313-43.562z" fill="#FFF" filter="url(#b)"/></svg>
{% endblock svg %}

{% block menu %}
    <span class="label {{ collector.calls|length == 0 ? 'disabled' }}">
        <span class="icon">
            {{ block('svg') }}
        </span>
        <strong>Stripe</strong>
        {% if collector.calls|length %}
            <span class="count">
                {{ collector.calls|length }}
            </span>
        {% endif %}
    </span>
{% endblock %}

{% block toolbar %}
    {% if collector.calls|length %}
        {% set icon %}
            {{ block('svg') }}
            <span class="sf-toolbar-value">{{ collector.calls|length }}</span>
        {% endset %}
        {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
    {% endif %}
{% endblock %}

{% block panel %}
    <h2>Stripe</h2>

    <h3>Calls ({{ collector.calls|length }})</h3>

    {% for call in collector.calls %}
        <table>
            <thead>
                <tr>
                    <th>
                        {{ (call.duration * 1000)|round(2) }}ms
                    </th>
                    <th class="full-width">
                        {% if call.trace is defined %}
                            <code>
                                <a href="{{ call.trace[0].file|file_link(call.trace[0].line ?? 1) }}">
                                    {{ call.method }}()
                                </a>
                            </code>
                        {% endif %}
                    </th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    {% if call.exception is defined %}
                        <th>
                            <h4>Exception</h4>
                            <span class="label status-error">
                                Exception
                            </span>
                        </th>
                        <td>
                            {{ profiler_dump(call.exception) }}
                        </td>
                    {% elseif call.response is defined %}
                        <th>
                            <h4>Response</h4>
                            <span class="label status-success">
                                {{ call.status }}
                            </span>
                        </th>
                        <td>
                            {{ profiler_dump(call.responseDecoded) }}
                            <details>
                                <summary>Raw ⬇</summary>
                                <pre>{{ call.response }}</pre>
                            </details>
                            <details>
                                <summary>Headers ⬇</summary>
                                {{ profiler_dump(call.headers) }}
                            </details>
                        </td>
                    {% endif %}
                </tr>
                <tr>
                    <td colspan="">
                        <h4>Args</h4>
                    </td>
                    <td colspan="2">
                        {{ profiler_dump(call.args) }}
                    </td>
                </tr>
                {% if call.trace is defined %}
                    <tr>
                        <td colspan="">
                            <h4>Trace</h4>
                        </td>
                        <td colspan="2">
                            <div>
                                <table>
                                    <thead>
                                    <tr>
                                        <th scope="col">#</th>
                                        <th scope="col">File/Call</th>
                                    </tr>
                                    </thead>
                                    <tbody>
                                    {% for trace in call.trace %}
                                        <tr>
                                            <td>{{ loop.index }}</td>
                                            <td>
                                                <span class="text-small">
                                                    {% set line_number = trace.line|default(1) %}
                                                    {% if trace.file is defined %}
                                                        <a href="{{ trace.file|file_link(line_number) }}">
                                                    {% endif %}
                                                            {{- trace.class|default ~ (trace.class is defined ? trace.type|default('::')) -}}
                                                        <span class="status-warning">{{ trace.function }}</span>
                                                    {% if trace.file is defined %}
                                                        </a>
                                                    {% endif %}
                                                    (line {{ line_number }})
                                                </span>
                                            </td>
                                        </tr>
                                    {% endfor %}
                                    </tbody>
                                </table>
                            </div>
                        </td>
                    </tr>
                {% endif %}
            </tbody>
        </table>
    {% endfor %}
{% endblock %}

Et voilà ! Nous avons créé notre propre collecteur de données pour afficher les appels réalisés par le SDK Stripe.

Il est temps de déboguer notre code et d’explorer ce qu’il se passe dans notre application 🐛

Section intitulée conclusionConclusion

Créer un DataCollector personnalisé pour ses besoins est un vrai plus. Le projet gagne en observabilité, et la Developper Experience (DX) est très plaisante.

Si la brique à observer possède des points d’extension satisfaisants, la mise en place d’un DataCollector est très rapide et facile.

D’ailleurs, j’aimerais bien rendre ce mécanisme encore plus simple dans Symfony, où l’on pourrait taguer un service, et Symfony générerait automatiquement les décorateurs, data collector, et template associés. Qu’en pensez-vous ?

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise