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 :
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 serviceWhen
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:
- La factory
AppBundle\Billing\Stripe\StripeClientFactory
est créée avec comme arguments :- Notre implémentation du
AppBundle\Billing\Stripe\Debug\Client
, qui a pour arguments- Le client original
Stripe\HttpClient\CurlClient
- Le DataCollector
- Le client original
- Notre implémentation du
AppBundle\Billing\Stripe\Debug\StreamingClient
- Le client original
Stripe\HttpClient\CurlClient
- Le DataCollector
- Le client original
- Notre implémentation du
- La factory configure Stripe avec les clients HTTP reçus en argument
- 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 !

Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Pour améliorer les performances et la pertinence des recherches sur le site e-commerce, JoliCode a réalisé un audit approfondi du moteur Elasticsearch existant. Nous avons optimisé les processus d’indexation, réduisant considérablement les temps nécessaires tout en minimisant les requêtes inutiles. Nous avons également ajusté les analyses pour mieux…
Dans le cadre du renouveau de sa stratégie digitale, Orpi France a fait appel à JoliCode afin de diriger la refonte du site Web orpi.com et l’intégration de nombreux nouveaux services. Pour effectuer cette migration, nous nous sommes appuyés sur une architecture en microservices à l’aide de PHP, Symfony, RabbitMQ, Elasticsearch et Docker.
Groupama confie à JoliCode la maintenance et les évolutions de son outil de souscription d’épargne salariale. Pierre angulaire de l’acquisition de nouveau client, l’outil permet aux apporteurs de saisir toutes les données de l’entreprise, faire signer numériquement le contrat au client et de conclure la souscription. Le tunnel est accompagné d’outil…