Profilage du code PHP

OxPHP embarque un profileur intégré, par requête. Contrairement à xdebug ou aux extensions autonomes, il s'exécute au sein du serveur lui-même, ne requiert aucun redémarrage de PHP et n'ajoute aucun surcoût notable lorsqu'il est désactivé (la branche mode=Off s'interrompt avant même de toucher au cache de filtres).

Ce guide est pratique : de zéro configuration à la traque des endpoints lents en production, en passant par la lecture des flamegraphs et la comparaison de runs d'optimisation avant/après.

Démarrage rapide : 60 secondes jusqu'au premier profil

compose.yml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 environment: INTERNAL_ADDR: 0.0.0.0:9090 PROFILER_ENABLED: "true" PROFILER_AUTH_TOKEN: "dev-secret" PROFILER_OUTPUT_FORMATS: "xhprof,speedscope,collapsed" volumes: - ./www:/var/www/html - profiles:/tmp/oxphp-profiles ports: - "80:80" - "9090:9090" volumes: profiles:
bash
# 1. Request a page with the profiling trigger. curl -H "X-OxPHP-Profile: dev-secret" http://localhost/slow-endpoint # 2. List captured runs. curl -H "Authorization: Bearer dev-secret" http://localhost:9090/__profiler/runs \ | jq '.runs[0]' # 3. Open the profile in speedscope (in-browser flamegraph). open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"

C'est tout. Le reste de ce guide explique ce qui se passe réellement, et comment en tirer des enseignements exploitables en production.

Ce que fait le profileur

  • Capture chaque appel de fonction PHP via l'API Zend Observer — aucun patch de bytecode, aucune modification du code applicatif.
  • Construit un arbre de spans avec temps mural, temps CPU, mémoire à l'entrée/sortie, attributs et événements.
  • Exporte quatre formats simultanément : xhprof.json, speedscope.json, pprof (protobuf + gzip), collapsed (pour flamegraph.pl).
  • Persiste les runs : cache LRU en mémoire + fichiers sur disque + push HTTP optionnel (xhgui ou tout collecteur personnalisé).
  • Expose 8 routes HTTP internes sur INTERNAL_ADDR pour parcourir et télécharger les profils.
  • Émet des métriques Prometheus — runs par source, spans collectés, octets écrits, drops, échecs de push.
  • Aucun redémarrage requis — activé par requête au moyen d'un déclencheur.

Fonctionnement

graph TD
  R["Request"]
  R --> S1["Tokio thread: ProfilerRequestHandler inspects the trigger<br/>(header / cookie / query / sample_rate), constant-time compare"]
  S1 --> S2["Decision written into PluginRequestActions,<br/>forwarded to the worker via the SAPI channel"]
  S2 --> S3["Before RINIT the worker sets ProfilingMode = ProfileAll<br/>and registers Observer handlers on begin/end of every function"]
  S3 --> S4["Each PHP function call → C hook → bridge buffer → Rust SpanTree<br/>(span_id = monotonic BE counter; names interned in a<br/>thread-local interner, no extra allocations)"]
  S4 --> S5["After the response: ProfilerCompleteHandler receives Arc&lt;SpanTree&gt;,<br/>runs 4 exporters, puts it in the LRU cache, spawns disk-write<br/>and HTTP-push tasks (semaphores bound the fan-out)"]

Trois modes par requête

Mode Quand Ce qui est capturé
Off Par défaut. Aucun plugin n'a demandé de profilage. Rien. Surcoût nul.
ApmOnly plugin-apm est activé mais aucun déclencheur de profileur n'a correspondu. Uniquement les hooks explicites de l'APM : #[Trace], émetteurs PDO/cURL, oxphp_apm_*().
ProfileAll Un déclencheur de profileur a correspondu (ou OxPHP\Profile\start() a été appelé alors que le profileur est activé). Chaque appel de fonction PHP via l'API Observer, plus tout ce que collecte l'APM.

ProfileAll prend le pas sur ApmOnly : lorsque les deux plugins sont activés et qu'un déclencheur correspond, un unique Arc<SpanTree> partagé est utilisé — pas de double collecte.

Installation et build

Le plugin plugin-profiler fait partie des cargo features par défaut. Un docker compose build standard l'inclut déjà.

Pour le désactiver :

Dockerfile
ARG OXPHP_WITH_PROFILER=0 # or ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profiler

Pour vérifier que le plugin est bien compilé, consultez le journal de démarrage. Avec LOG_LEVEL=info (la valeur par défaut), chaque plugin compilé écrit une ligne Plugin initialized qui porte son nom :

bash
docker compose logs app | grep '"Plugin initialized"' | grep '"name":"profiler"' # expect one JSON line with "message":"Plugin initialized" and "name":"profiler"

Déclencheurs d'activation

Les déclencheurs sont évalués dans cet ordre de priorité : header → cookie → query → sample_rate. Toute correspondance active ProfileAll. Les tokens sont comparés en temps constant (subtle::ConstantTimeEq).

Pour le développement et les scripts.

bash
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkout

Idéal pour les benchmarks CI, les collections Postman, les scripts curl.

Exclure des chemins de l'échantillonnage

PROFILER_SAMPLE_RATE échantillonne une fraction aléatoire de toutes les requêtes — y compris le trafic interne du framework qui pollue les données. La web debug toolbar de Symfony interroge /_wdt/{token} et pointe vers /_profiler/{token} ; Laravel Debugbar et Telescope se comportent de la même manière. Tenez-les à l'écart de l'échantillonnage avec PROFILER_EXCLUDE_PATHS :

bash
PROFILER_EXCLUDE_PATHS=/_profiler,/_profiler/**,/_wdt/**

Des motifs glob séparés par des virgules, avec la même syntaxe que PHP_DENY_PATHS : * ne franchit pas /, ** le franchit, et un / initial est optionnel. Un motif correspond à un chemin nu ou à son sous-arbre uniquement si vous listez les deux — /_profiler/** couvre /_profiler/x mais pas le /_profiler nu, d'où la recette à deux motifs ci-dessus. Les motifs correspondent au chemin de la requête tel qu'il est reçu — sans décodage des pourcentages ni normalisation des .. — donc listez le chemin littéral qu'utilise votre framework.

L'exclusion n'affecte que l'échantillonnage automatique

Une requête portant un déclencheur explicite — le header x-oxphp-profile, le cookie OXPROF ou le paramètre de query __oxprof — est toujours profilée, même sur un chemin exclu. Cela vous permet de profiler délibérément /_profiler lui-même tout en le tenant à l'écart de l'échantillonnage d'arrière-plan.

Référence de configuration

Variable Valeur par défaut Description
PROFILER_ENABLED false Interrupteur principal. true → le plugin est chargé.
PROFILER_AUTH_TOKEN (non défini) Secret pour les déclencheurs et token bearer pour les routes /__profiler/*. Chaîne vide = « aucun token requis » (toute valeur de déclencheur non vide passe). Ne commitez jamais le token dans le dépôt.
PROFILER_SAMPLE_RATE 0.0 [0.0; 1.0]. Taux d'échantillonnage aléatoire.
PROFILER_EXCLUDE_PATHS (non défini) Motifs glob en CSV (syntaxe PHP_DENY_PATHS) exclus de PROFILER_SAMPLE_RATE. Les déclencheurs explicites les profilent tout de même. Exemple : /_profiler,/_profiler/**,/_wdt/**.
PROFILER_INTERNAL false Observe les fonctions C internes (strlen, json_encode, …). Couverture complète, mais surcoût de 2–5×. À utiliser chirurgicalement.
PROFILER_MAX_SPANS 50000 Plafond strict sur le nombre d'appels observés par requête. Une fois atteint, plus aucun span n'est enregistré et l'exécution est marquée truncated. Sans limite de profondeur, c'est aussi la taille de l'arbre ; avec PROFILER_MAX_DEPTH, ce n'est plus le cas, car un appel écarté par la limite de profondeur est d'abord compté ici : une exécution tronquée arrive alors sur disque avec seulement une fraction de ce nombre de spans.
PROFILER_MAX_DEPTH 0 (pas de plafond) Profondeur d'imbrication maximale des appels enregistrés ; le maximum effectif est 32. Voir Limite de profondeur ci-dessous.
PROFILER_OUTPUT_DIR /tmp/oxphp-profiles Chemin absolu. Doit être accessible en écriture par www-data.
PROFILER_OUTPUT_FORMATS xhprof,speedscope Sous-ensemble CSV parmi xhprof, speedscope, pprof, collapsed.
PROFILER_RETENTION_COUNT 100 Nombre de runs à conserver (sur disque et dans le LRU). Élagage en arrière-plan toutes les 5 secondes.
PROFILER_DISK_MAX_PER_SEC 10 Token bucket protégeant le disque. Le dépassement est rejeté et incrémente oxphp_profiler_disk_drops_total.
PROFILER_EXPORT_URL (non défini) URL POST pour chaque run capturé (xhgui, collecteur personnalisé).
PROFILER_EXPORT_FORMAT xhprof L'un des quatre formats pour le push HTTP.
PROFILER_EXPORT_AUTH_TOKEN (non défini) Token bearer pour la cible du push.
PROFILER_EXPORT_XHGUI auto Force le mode d'enveloppe xhgui. Auto : le chemin de l'URL se termine par /run/import (l'endpoint xhgui canonique ; les indices dans l'hôte/la query ne sont pas pris en compte — mettez true pour un chemin non standard).
PROFILER_EXPORT_BUGGREGATOR auto Force l'enveloppe Buggregator. Auto : le chemin de l'URL se termine par /api/profiler/store. L'enveloppe émet toujours du xhprof, donc PROFILER_EXPORT_FORMAT est ignoré pour elle (une valeur non-xhprof déclenche un avertissement, sans être fatale). Mutuellement exclusif avec PROFILER_EXPORT_XHGUI (activer les deux est une erreur au démarrage).
PROFILER_EXPORT_APP_NAME (non défini) app_name de Buggregator — le projet sous lequel un profil est regroupé.
PROFILER_EXPORT_TAGS (non défini) tags de Buggregator, une liste key=value,key2=value2 pour le filtrage. Un token mal formé (pas key=value), une clé vide ou une clé en double est une erreur au démarrage.

Limite de profondeur

Un appel imbriqué plus profondément que PROFILER_MAX_DEPTH n'est pas enregistré, et normalement tout ce qu'il appelle ne l'est pas non plus : ce sous-arbre est absent du profil au lieu d'y être aplati. Son temps reste compté : l'appel situé à la limite couvre tout ce qu'il a fait et, sans rien d'enregistré en dessous, cette durée apparaît comme son propre temps exclusif. Un frame à la frontière semble donc coûteux en lui-même plutôt que dans ce qu'il a appelé.

  • Le maximum effectif est 32, la profondeur jusqu'à laquelle l'observateur du moteur suit les appels ouverts. Une valeur plus grande est acceptée puis ramenée à ce plafond, et avec PROFILER_ENABLED=true cet ajustement est signalé au démarrage.
  • La récursion profonde d'une fonction sur elle-même fait exception. Au-delà de 32 frames d'une fonction qui s'appelle elle-même, l'observateur ne sait plus à quel appel appartient un retour et l'attribue à un frame moins profond de la même fonction. Un appel plus profond que la limite peut alors être enregistré malgré tout, et les durées autour de lui ne sont pas fiables.
  • Elle borne le profil, pas le coût de sa production. Le hook du moteur s'exécute toujours sur les frames plus profonds, et les appels qu'il écarte comptent toujours dans PROFILER_MAX_SPANS.
  • Les spans qu'une application pousse via les helpers APM n'y sont pas soumis.

Exemple de configuration de production

yaml
environment: PROFILER_ENABLED: "true" PROFILER_AUTH_TOKEN: "${PROFILER_TOKEN_FROM_VAULT}" PROFILER_SAMPLE_RATE: "0.001" # ~0.1% of traffic PROFILER_INTERNAL: "false" PROFILER_OUTPUT_DIR: /var/lib/oxphp/profiles PROFILER_OUTPUT_FORMATS: "xhprof,collapsed" PROFILER_RETENTION_COUNT: "500" PROFILER_DISK_MAX_PER_SEC: "20" PROFILER_EXPORT_URL: "http://xhgui.monitoring.svc.cluster.local/run/import" PROFILER_EXPORT_FORMAT: "xhprof"

SDK PHP

Toutes les fonctions résident dans l'espace de noms OxPHP\Profile. Elles sont toujours sûres à appeler : si le profilage n'est pas actif pour la requête courante, les mutateurs sont des no-ops sûrs et is_active() renvoie false. Lorsque le profileur est désactivé (PROFILER_ENABLED), start() ne fait rien non plus.

Capture explicite autour d'une région

php
use function OxPHP\Profile\{start, stop, is_active}; function heavy_report(): array { start(); // activate ProfileAll inside the request $result = build_report(); // this lands in the tree stop(); // stop capture return $result; }

start() est idempotent, tout comme stop() : l'appeler deux fois de suite est sans danger.

Warning

Appeler start() en milieu de requête réinitialise l'arbre courant (voir PROFILING_CONTEXT.reset() dans php_sdk.rs). Cela respecte l'invariant de la spécification : le mode est défini une seule fois par requête, soit par le déclencheur à RINIT, soit par le premier appel à start().

Pause et reprise

php
use function OxPHP\Profile\{pause, resume}; pause(); noisy_helper_we_dont_care_about(); // will not land in the tree resume();

Contrairement à stop(), pause/reprise est un signal documentaire pour « temporairement ». En interne, c'est le même drapeau ; la distinction aide simplement quiconque lit le code.

Marqueurs ponctuels : mark()

php
use function OxPHP\Profile\mark; mark('cache_miss'); mark('got_auth_token', ['user_id' => (string) $user->id]);

Attache un événement SpanEventKind::Mark au span ouvert le plus haut. No-op lorsqu'aucun span n'est ouvert. Utile pour des horodatages intermédiaires dans une fonction longue ou pour marquer des branches if/else.

Métriques numériques : metric()

php
use function OxPHP\Profile\metric; $rows = $pdo->query('SELECT ...')->fetchAll(); metric('db.rows', (float) count($rows)); metric('payload.kb', strlen($body) / 1024.0);

Ajoute metric.<name>=<value> aux attributs du span courant. Contrairement à mark(), il s'agit d'une simple paire clé-valeur (sans horodatage). Elle apparaît dans speedscope/xhgui comme une propriété du span.

Vérification d'état : is_active()

php
if (OxPHP\Profile\is_active()) { // can afford an expensive debug dump — // this request is being profiled anyway error_log(json_encode($debug_state)); }

Deux lectures TLS. Sûr à appeler dans du code chaud.

Attributs (PHP 8)

Les sept attributs se répartissent en deux catégories : les filtres d'observateur s'exécutent avant la création du span ; les décorateurs s'exécutent après la fermeture du span.

Attribut Catégorie Effet
#[Profile] filtre Force l'inclusion de la fonction dans l'arbre (même si les règles générales l'excluraient).
#[Exclude] filtre Ignore la fonction ; ses enfants sont rattachés à l'ancêtre inclus le plus proche.
#[Sample(rate: 0.1)] filtre Ne conserve qu'une fraction des appels (rate ∈ [0.0; 1.0]). Probabiliste — sans verrou.
#[Tag(key, value)] filtre Attache une étiquette au span. Répétable — plusieurs #[Tag] s'accumulent.
#[Mark(label?)] décorateur Émet un événement Mark à l'entrée de la fonction.
#[SlowThreshold(ms)] décorateur Émet un événement Slow + définit le statut lorsque le temps mural ≥ ms.
#[MemoryThreshold(kb)] décorateur Émet MemorySpike + statut lorsque l'allocation nette ≥ kb.

Composition classe / méthode

php
use OxPHP\Profile\{Tag, Profile, Exclude}; #[Tag(key: 'layer', value: 'domain')] #[Profile] // the whole class is always profiled class OrderService { #[Tag(key: 'op', value: 'create')] public function create(array $data): Order { /* ... */ } #[Exclude] // excluded, despite class-level #[Profile] public function debug_dump(): void { /* ... */ } public function find(int $id): ?Order { /* ... */ } // inherits #[Profile] and #[Tag(layer)] }
  • Les attributs au niveau de la classe se propagent à chaque méthode.
  • Les attributs au niveau de la méthode s'ajoutent à ceux de la classe (les tags s'accumulent).
  • #[Exclude] sur une méthode prime sur le #[Profile] au niveau de la classe.

Seuil de fonction lente

php
use OxPHP\Profile\SlowThreshold; #[SlowThreshold(ms: 250)] function render_dashboard(User $u): string { // if it runs ≥ 250 ms — a Slow event is appended to the span and // status_code=2 (error). Immediately visible in xhgui / speedscope. }

Seuil de mémoire

php
use OxPHP\Profile\MemoryThreshold; #[MemoryThreshold(kb: 512)] function import_csv(string $path): int { // if the function net-allocates ≥ 512 KB during execution — // MemorySpike event + status=error }

Échantillonner des fonctions individuelles

php
use OxPHP\Profile\Sample; #[Sample(rate: 0.01)] function log_event(string $evt, array $ctx): void { // ≈ 1% of calls land in the tree; the rest are skipped entirely — // neither the span nor its children are created. Useful for functions // called millions of times per request. }
Filtres ou décorateurs ?

Lorsqu'une fonction est appelée très souvent et que vous voulez réduire le coût de capture, utilisez #[Sample] ou #[Exclude] (ils agissent avant la création du span). Lorsque vous voulez signaler un événement au-dessus d'un seuil, utilisez #[SlowThreshold] / #[MemoryThreshold] (ils examinent le span déjà collecté).

Ce que contient un span capturé

ruby
FinishedSpan { span_id # Arc<str>, W3C-compatible parent_span_id # Arc<str> trace_id # Arc<str>, shared with APM name # Fully-qualified PHP function/method name start_ns # wall-clock, ns since the profiler epoch end_ns cpu_ns # CLOCK_THREAD_CPUTIME_ID (0 when the platform doesn't provide it) memory_start # zend_memory_usage(0) on entry memory_end # zend_memory_usage(0) on exit attributes # Vec<(Arc<str>, Arc<str>)> — from #[Tag], metric(), APM SQL/HTTP events # Vec<SpanEvent { ts, kind, label, attrs }> status_code # 0 = unset, 1 = ok, 2 = error status_message leaked # true if the span was force-closed by finalize (PHP threw past the observer) }

Types d'événements (SpanEvent::kind) :

Type Émis par
Mark mark(), metric(), #[Mark]
Slow #[SlowThreshold]
MemorySpike #[MemoryThreshold]
Sql hooks APM (PDO, mysqli)
Http hooks APM (cURL, flux HTTP)
Exception gestionnaire d'exceptions APM
Alloc (réservé à l'échantillonnage du tas)
Other solution de repli

Formats d'export

Les fichiers résident sous PROFILER_OUTPUT_DIR, nommés <run_id>.<ext>, où run_id = <ts_ms>-<req_id_prefix>-<rand4> (par exemple 1713600000000-a1b2c3d4-0f5e). Le nom est attribué au moment où l'exécution est stockée, si bien que chaque exécution est nommée ainsi quel que soit ce qui a activé le profilage : un déclencheur, le tirage d'échantillonnage ou OxPHP\Profile\start() depuis PHP.

Relier un profil à sa ligne du journal d'accès

req_id_prefix correspond aux huit premiers caractères de l'identifiant de requête et ne constitue pas une clé à lui seul : dans l'identifiant généré par le serveur, ces caractères sont les secondes epoch, partagées par toutes les requêtes de cette seconde. Utilisez plutôt le champ request_id de l'entrée index.json. Il contient l'identifiant complet et c'est lui que le journal d'accès affiche.

speedscope (par défaut pour l'analyse interactive)

Extension : .speedscope.json

  • Flamegraph dans le navigateur avec zoom, recherche et bascule CPU / temps / mémoire.
  • Aucune configuration — ouvrez-le directement sur speedscope.app.
  • OxPHP renvoie une redirection 302 sur /__profiler/runs/{id}/speedscope → speedscope.app avec un paramètre profileURL=… qui récupère le profil directement depuis votre serveur.
bash
# Ctrl-click in macOS Terminal / xdg-open on Linux open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"

xhprof (pour xhgui : timeline et diff historique)

Extension : .xhprof.json

  • Compatible avec xhgui (recherche par URL, tendances, diff entre deux runs).
  • Parfait pour l'accumulation en production : exécutez un conteneur xhgui à côté de votre application, pointez PROFILER_EXPORT_URL=http://xhgui/run/import, et l'historique s'accumule dans l'interface.
  • docker-compose prêt à l'emploi : tests/compose.xhgui.yml.

pprof (outillage Google pprof, plugin pprof pour Grafana, Pyroscope)

Extension : .pprof (protobuf + gzip, niveau fast, backend zlib)

bash
# save and open curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.pprof > profile.pprof go tool pprof -http=:8080 profile.pprof # or pyroscope-cli adhoc --input profile.pprof

collapsed (le flamegraph.pl de Brendan Gregg)

Extension : .collapsed

  • Format texte func;child;grandchild <count>.
  • Format d'entrée de facto pour les flamegraphs SVG.
  • Trois variantes de métrique : temps mural, CPU, mémoire. OxPHP écrit .collapsed (mural) ; des chemins internes produisent aussi .collapsed.cpu et .collapsed.mem (voir tests/fixtures/profiler_exports/).
bash
curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.collapsed \ | flamegraph.pl --title "Checkout $run_id" > flame.svg

Buggregator (serveur de débogage local)

Buggregator est un serveur de débogage mono-binaire qui, entre autres, restitue les profils xhprof sous forme de flame graphs groupés par projet. Le push xhprof cible directement son endpoint POST /api/profiler/store : aucune extension PHP xhprof ni bibliothèque cliente n'est nécessaire, puisque le profileur natif d'OxPHP produit les données.

yaml
services: buggregator: image: ghcr.io/buggregator/server:latest ports: ["8000:8000"] app: image: ghcr.io/oxphp/oxphp:0.13.0 environment: PROFILER_ENABLED: "true" PROFILER_SAMPLE_RATE: "0.01" PROFILER_EXPORT_URL: "http://buggregator:8000/api/profiler/store" PROFILER_EXPORT_FORMAT: "xhprof" PROFILER_EXPORT_APP_NAME: "checkout" # groups profiles by project PROFILER_EXPORT_TAGS: "env=staging,region=eu" # filterable in the UI

Une URL dont le chemin se termine par /api/profiler/store sélectionne automatiquement l'enveloppe Buggregator (PROFILER_EXPORT_BUGGREGATOR: "true" la force pour une URL personnalisée ; "false" la désactive). Cette enveloppe émet toujours du xhprof, donc PROFILER_EXPORT_FORMAT est ignoré pour elle (une valeur non-xhprof déclenche un avertissement au démarrage, sans être fatale — le profileur ne fait jamais planter le serveur à cause d'un réglage d'export). app_name et tags pilotent le regroupement et le filtrage par projet dans Buggregator ; sans eux, le profil s'affiche tout de même mais reste non groupé. hostname provient de $HOSTNAME, avec repli sur l'appel système gethostname(2) lorsque cette variable n'est pas définie.

Stockage et nettoyage

text
/tmp/oxphp-profiles/ ├── index.json # NDJSON — one record per line ├── 1713600000000-a1b2c3d4-0f5e.xhprof.json ├── 1713600000000-a1b2c3d4-0f5e.speedscope.json └── 1713600001234-b2c3d4e5-4a2b.xhprof.json

Schéma d'une entrée index.json

json
{ "run_id": "1713600000000-a1b2c3d4-0f5e", "request_id": "a1b2c3d4e5f67890", "trace_id": "0af7651916cd43dd8448eb211c80319c", "timestamp_ms": 1713600000000, "duration_ms": 123, "method": "GET", "url": "/checkout", "status": 200, "user_agent": "Mozilla/5.0 …", "client_ip": "10.0.0.42", "source": "Header", // Header | Cookie | Query | SampleRate | Sdk "span_count": 4821, "event_count": 7, "error_count": 0, "leaked_count": 0, "truncated": false, // true — exceeded PROFILER_MAX_SPANS "oxphp_version": "0.13.0", "formats": ["xhprof.json", "speedscope.json"] }

source indique ce qui a admis l'exécution. Header, Cookie et Query sont les trois déclencheurs d'activation et SampleRate est le tirage d'échantillonnage ; Sdk couvre toute exécution qu'aucun déclencheur n'a admise : une requête qui a activé le profilage pour elle-même avec OxPHP\Profile\start(), ainsi qu'un plugin qui a sélectionné le profilage complet de lui-même.

user_agent est l'en-tête User-Agent de la requête, tronqué à 512 octets (marqueur …(truncated) compris) lorsqu'il est plus long, les octets qui ne sont pas de l'UTF-8 valide étant remplacés par U+FFFD. Il vaut null lorsque la requête n'en a envoyé aucun.

index.json est analysé par /__profiler/runs, trié du plus récent au plus ancien et paginé via ?limit=N&offset=M.

Rétention

  • Une tâche d'arrière-plan supprime toutes les 5 secondes les entrées au-delà de PROFILER_RETENTION_COUNT (rename atomique → index.json).
  • Les fichiers sans entrée dans index.json (orphelins d'un crash) sont balayés.
  • Le token bucket PROFILER_DISK_MAX_PER_SEC protège le disque : si le débit dépasse ce seuil, les runs ne sont pas écrits et oxphp_profiler_disk_drops_total s'incrémente.

Routes HTTP internes

Avec INTERNAL_ADDR=0.0.0.0:9090, le plugin enregistre 8 endpoints sous le préfixe /__profiler/. Tous exigent Authorization: Bearer <PROFILER_AUTH_TOKEN> lorsqu'un token est configuré. La comparaison se fait en temps constant.

Route Méthode Rôle
/__profiler/ GET Page d'accueil HTML avec l'index des endpoints.
/__profiler/runs GET Tableau JSON des runs. ?limit=N&offset=M.
/__profiler/runs/{id} GET Métadonnées JSON d'un run.
/__profiler/runs/{id}.{format} GET Octets bruts du profil. format ∈ xhprof.json, speedscope.json, pprof, collapsed.
/__profiler/runs/{id}/speedscope GET 302 → speedscope.app avec profileURL=….
/__profiler/runs/{id} DELETE Supprime tous les fichiers de format + l'entrée d'index (renvoie 204).
/__profiler/config GET Configuration actuelle du plugin (tokens masqués).
/__profiler/stats GET Instantané JSON des compteurs.

Exemples de scripts

bash
# Top-5 slowest of the last 20 runs curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:5]' # All profiles for a given URL curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=500" \ | jq '.runs[] | select(.url == "/checkout")' # Delete all runs older than 1 hour (independent of the plugin's retention) NOW=$(date +%s%3N) CUTOFF=$((NOW - 3600000)) curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=1000" \ | jq -r --arg c "$CUTOFF" '.runs[] | select(.timestamp_ms < ($c|tonumber)) | .run_id' \ | xargs -I{} curl -X DELETE -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs/{}"

Push HTTP et xhgui

Envoyez chaque run vers un collecteur distant :

yaml
environment: PROFILER_EXPORT_URL: "http://xhgui/run/import" PROFILER_EXPORT_FORMAT: "xhprof" PROFILER_EXPORT_AUTH_TOKEN: "shared-secret" # optional
  • Détection automatique de l'enveloppe xhgui : le chemin de l'URL se termine par /run/import (l'endpoint xhgui canonique). Une sous-chaîne approximative xhgui dans l'hôte ou la query n'est pas prise en compte — forcez via PROFILER_EXPORT_XHGUI=true|false pour de telles URL.
  • Plan de nouvelles tentatives : 3 tentatives avec backoff exponentiel 100/200/400 ms, budget total de 5 s en temps mural. Le corps de la requête est partagé entre les tentatives sous forme de bytes::Bytes (zéro allocation lors des reprises).
  • Les erreurs incrémentent oxphp_profiler_http_push_failures_total.

Stack de démonstration complète

bash
docker compose -f tests/compose.xhgui.yml up -d # app: :80, xhgui: :8142 (UI), :27017 (mongo)

Test de fumée E2E : tests/php/profiler/test_xhgui_import.php.

Métriques Prometheus

Exposées sur /metrics :

text
oxphp_profiler_runs_total{source="header"|"cookie"|"query"|"sample"|"sdk"} oxphp_profiler_spans_collected_total oxphp_profiler_bytes_written_total{format="xhprof"|"speedscope"|"pprof"|"collapsed"} oxphp_profiler_disk_drops_total oxphp_profiler_http_push_failures_total oxphp_profiler_truncated_total oxphp_profiler_in_memory_runs

Alertes Prometheus de départ :

yaml
- alert: ProfilerDiskDrops expr: rate(oxphp_profiler_disk_drops_total[5m]) > 0 annotations: summary: "Profiler is dropping runs on disk — check PROFILER_DISK_MAX_PER_SEC" - alert: ProfilerPushFailing expr: rate(oxphp_profiler_http_push_failures_total[5m]) > 0 annotations: summary: "xhgui / collector is unreachable" - alert: ProfilerTruncatingTrees expr: rate(oxphp_profiler_truncated_total[5m]) > 0 annotations: summary: "Requests exceed PROFILER_MAX_SPANS — raise the cap or investigate"

Workflows

Trouver un endpoint lent

  1. Activez PROFILER_SAMPLE_RATE=0.001 en production. Laissez-le s'accumuler.
  2. Triez les runs par duration_ms :
    bash
    curl -s -H "Authorization: Bearer $TOK" \ "http://INT_ADDR/__profiler/runs?limit=500" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10] | map({run_id, url, duration_ms, span_count})'
  3. Ouvrez le premier dans speedscope : .../__profiler/runs/<id>/speedscope.
  4. Activez le mode Left Heavy dans speedscope — vous verrez les fonctions au temps cumulé le plus élevé.
  5. Cliquez sur la barre la plus large — obtenez fichier:ligne et la liste des enfants.

Valider une hypothèse avant/après

  1. Lancez un benchmark AVANT vos modifications :
    bash
    for i in $(seq 1 20); do curl -s -H "X-OxPHP-Profile: dev-secret" http://localhost/api/report > /dev/null done curl -s -H "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | map(.duration_ms) | sort | .[length/2|floor]' > /tmp/p50_before.txt
  2. Appliquez les modifications, reconstruisez, recommencez. Comparez les médianes.
  3. Pour un diff détaillé, téléchargez deux profils xhprof et importez-les dans xhgui — il dispose d'une vue de diff intégrée.

Traquer une fuite mémoire

  1. Envoyez la requête qui « grossit » :
    bash
    curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv
  2. Ouvrez-le dans speedscope, basculez sur la métrique mémoire (via .collapsed.mem ou la vue mémoire de speedscope).
  3. Ajoutez #[MemoryThreshold(kb: 1024)] sur les fonctions suspectes — vous obtiendrez des événements MemorySpike explicites au run suivant.
  4. Utilisez metric('mem.after', memory_get_usage()) pour une instrumentation chirurgicale.

Surveillance continue d'un chemin critique

php
#[Profile] #[SlowThreshold(ms: 500)] public function chargeCard(PaymentRequest $r): PaymentResult { // always captured + an explicit Slow mark when it lags }

Dans Grafana, ajoutez un panneau pour oxphp_profiler_runs_total{source="sample"} et une alerte sur les valeurs aberrantes de duration_ms issues de index.json (via une métrique basée sur les logs ou un exporteur en sidecar).

Reproduire un bug via un lien

Un collègue dit « /admin/report me renvoie 500 ». Vous répondez :

text
https://app.local/admin/report?__oxprof=<one-time-token>

Une fois qu'il l'a visitée — /__profiler/runs?limit=5, ouvrez le profil, et voyez exactement où l'exception a atterri (status_code=2 + événement Exception).

Interaction avec l'APM

  • Les deux plugins partagent un unique Arc<SpanTree>. Pas de double collecte.
  • Aucun déclencheur de profileur + APM activé → mode=ApmOnly. L'arbre ne contient que les spans explicitement tagués (#[Trace], hooks SQL/HTTP de l'APM).
  • Déclencheur de profileur touché → mode=ProfileAll. L'arbre contient tout, plus les annotations de l'APM.
  • L'APM n'envoie toujours que ses spans explicites à OTLP (Jaeger/Tempo plafonnent à ~10k spans par trace). Pour la vue d'ensemble — /__profiler/runs/<id>.

Bonnes pratiques

  1. Ne commitez jamais PROFILER_AUTH_TOKEN. Lisez-le depuis Vault / les secrets Docker / les secrets Kubernetes.
  2. En production — uniquement SAMPLE_RATE. Header/cookie/query sont des outils de développeur. Si vous avez besoin de profilage à la demande en prod — utilisez un token dédié, tourné quotidiennement.
  3. N'activez pas PROFILER_INTERNAL=true globalement. Un surcoût de 2–5× transforme la production en laboratoire. Utilisez-le chirurgicalement, de façon isolée.
  4. Gardez PROFILER_RETENTION_COUNT réaliste — un run peut peser de quelques centaines de Ko (petite requête) à plusieurs mégaoctets (arbre volumineux). 500 runs × 2 Mo = 1 Go. Dimensionnez le disque en conséquence.
  5. #[Exclude] les helpers bruyants (journalisation, i18n, l'autoloader) — l'arbre devient lisible sans perdre de sens.
  6. Reliez les profils aux traces : trace_id est partagé. Dans Grafana / Kibana, liez /__profiler/runs/<id> depuis la vue de trace.
  7. Identifiants compatibles Git. Dans ce build, span_id est un compteur monotone big-endian déterministe. Le diff de deux profils stockés est propre.
  8. APM + profileur, c'est gratuit. Gardez les deux activés ; l'arbre est partagé, le surcoût ne provient que de la couverture accumulée par l'APM.

Dépannage

Aucun profil n'apparaît
  1. Le plugin est-il compilé ? docker compose build l'inclut par défaut. Vérifiez que vous n'avez pas passé --build-arg OXPHP_WITH_PROFILER=0 ni un CARGO_FEATURES personnalisé sans plugin-profiler.
  2. PROFILER_ENABLED=true ?
  3. Le déclencheur correspond-il réellement à PROFILER_AUTH_TOKEN ?
    • Cherchez un \n parasite dans la variable d'environnement.
    • Pour la query — est-elle correctement URL-encodée ?
  4. Le serveur voit-il seulement votre requête ? Consultez le journal des accès.
401 depuis /__profiler/runs

Le token bearer dans le header ne correspond pas à PROFILER_AUTH_TOKEN. Piège classique : echo "secret" > secret.txt ajoute un \n. Utilisez printf ou passez-le via l'environnement.

xhgui n'affiche pas les nouveaux runs
  1. Vérifiez l'accessibilité :
    bash
    docker compose exec app curl -v $PROFILER_EXPORT_URL
  2. Regardez oxphp_profiler_http_push_failures_total.
  3. Consultez les logs : un tracing::warn! avec run_id et le statut HTTP est émis à chaque échec.
Aucun fichier sur le disque
  • PROFILER_OUTPUT_DIR est-il absolu ? Les chemins relatifs sont ignorés.
  • Accessible en écriture par www-data ?
    bash
    docker compose exec app ls -la /tmp/oxphp-profiles
  • PROFILER_DISK_MAX_PER_SEC est-il trop bas ? Regardez oxphp_profiler_disk_drops_total.
Trop de surcoût en production
  • PROFILER_INTERNAL=false (c'est la valeur par défaut).
  • PROFILER_SAMPLE_RATE dans une plage raisonnable (0.0005..0.002).
  • PROFILER_MAX_SPANS raisonnable. Une fois atteint, plus aucun span n'est enregistré, la requête elle-même n'est pas interrompue, et le préfixe collecté jusque-là est écrit avec truncated: true. Pour les requêtes très volumineuses, préférez un start()/stop() chirurgical autour de la section d'intérêt.
truncated=true dans index.json

Une requête a dépassé PROFILER_MAX_SPANS (50 000 par défaut). Options :

  1. Relevez le plafond (au prix de mémoire, contre plus de détail).
  2. Ajoutez #[Exclude] / #[Sample(rate: 0.01)] sur les fonctions appelées des dizaines de milliers de fois.
  3. Enveloppez uniquement la région suspecte dans start()/stop().

Aide-mémoire des commandes

bash
# Activate for one request curl -H "X-OxPHP-Profile: $TOK" http://localhost/endpoint # List runs, top-10 by duration curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/runs \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10]' # Open in speedscope open "http://localhost:9090/__profiler/runs/$RUN_ID/speedscope" # Download as xhprof for xhgui import curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.xhprof.json > run.xhprof.json # Download as pprof and open curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.pprof > run.pprof go tool pprof -http=:8080 run.pprof # flamegraph.pl curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.collapsed \ | flamegraph.pl > flame.svg # Delete a run curl -X DELETE -H "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID # Metrics curl -s http://localhost:9090/metrics | grep oxphp_profiler_ # Current plugin config (safe — tokens are redacted) curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/config | jq

Exemples pratiques

Vous trouverez ci-dessous des scénarios PHP prêts à l'emploi que vous pouvez déposer dans www/public/ et interroger avec curl.

Contrôleur simple avec contrôle manuel

www/public/report.php
<?php declare(strict_types=1); use function OxPHP\Profile\{start, stop, mark, metric, is_active}; function fetch_rows(PDO $db, int $user_id): array { $stmt = $db->prepare('SELECT * FROM orders WHERE user_id = ? LIMIT 1000'); $stmt->execute([$user_id]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } function render_report(array $rows): string { $sum = array_sum(array_column($rows, 'amount')); return json_encode(['count' => count($rows), 'total' => $sum]); } $db = new PDO('mysql:host=db;dbname=app', 'app', 'secret'); $user_id = (int) ($_GET['user_id'] ?? 1); // Explicitly profile only the heavy block — even if the trigger wasn't set. start(); mark('report.begin', ['user_id' => (string) $user_id]); $rows = fetch_rows($db, $user_id); metric('db.rows', (float) count($rows)); $body = render_report($rows); metric('response.bytes', (float) strlen($body)); mark('report.done'); stop(); header('Content-Type: application/json'); echo $body; // Optional — tell the frontend that this request was profiled: if (is_active()) { header('X-Profiled: 1'); }

Invocation :

bash
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'

Classe de service avec attributs

www/lib/OrderService.php
<?php declare(strict_types=1); use OxPHP\Profile\{Profile, Tag, Exclude, Sample, SlowThreshold, MemoryThreshold}; #[Profile] #[Tag(key: 'layer', value: 'domain')] #[Tag(key: 'svc', value: 'orders')] final class OrderService { public function __construct( private readonly PDO $db, private readonly Mailer $mailer, ) {} #[SlowThreshold(ms: 250)] #[Tag(key: 'op', value: 'create')] public function create(array $payload): int { $this->db->beginTransaction(); try { $id = $this->insertOrder($payload); $this->insertLines($id, $payload['items']); $this->db->commit(); $this->mailer->sendReceipt($id); return $id; } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } } #[MemoryThreshold(kb: 2048)] #[Tag(key: 'op', value: 'export')] public function exportCsv(int $user_id): string { $stmt = $this->db->prepare('SELECT * FROM orders WHERE user_id = ?'); $stmt->execute([$user_id]); $buf = fopen('php://temp', 'r+'); fputcsv($buf, ['id', 'created_at', 'total']); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { fputcsv($buf, [$row['id'], $row['created_at'], $row['total']]); } rewind($buf); return stream_get_contents($buf); } // Trivial getter — don't clutter the tree. #[Exclude] public function find(int $id): ?array { $stmt = $this->db->prepare('SELECT * FROM orders WHERE id = ?'); $stmt->execute([$id]); return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; } // Very frequent audit — sample to avoid inflating the tree. #[Sample(rate: 0.05)] private function audit(string $event, array $ctx): void { $this->db->prepare('INSERT INTO audit (event, ctx) VALUES (?, ?)') ->execute([$event, json_encode($ctx)]); } private function insertOrder(array $p): int { /* ... */ return 0; } private function insertLines(int $id, array $items): void { /* ... */ } }

Job par lot : profiler uniquement la première itération sur N

www/bin/import.php
<?php declare(strict_types=1); use function OxPHP\Profile\{start, stop, pause, resume, mark}; $files = glob('/data/incoming/*.csv'); $i = 0; foreach ($files as $path) { if ($i === 0) { start(); // profile only the first file in full mark('batch.begin', ['path' => $path]); } else { pause(); // the rest — no-op for capture } import_one($path); if ($i === 0) { mark('batch.first_done'); stop(); } $i++; } function import_one(string $path): void { /* ... */ }

Comparer deux implémentations (micro-benchmark avec profils)

www/public/bench.php
<?php // naive vs streaming comparison declare(strict_types=1); use function OxPHP\Profile\{start, stop, mark, metric}; function naive_sum(string $path): int { $rows = array_map('str_getcsv', file($path)); // whole file into memory return array_sum(array_column($rows, 1)); } function streaming_sum(string $path): int { $h = fopen($path, 'r'); $total = 0; while (($row = fgetcsv($h)) !== false) { $total += (int) ($row[1] ?? 0); } fclose($h); return $total; } $path = '/data/big.csv'; $which = $_GET['impl'] ?? 'naive'; start(); mark('bench.begin', ['impl' => $which]); $t0 = hrtime(true); $result = $which === 'naive' ? naive_sum($path) : streaming_sum($path); $elapsed_ms = (hrtime(true) - $t0) / 1e6; metric('bench.elapsed_ms', $elapsed_ms); metric('bench.result', (float) $result); mark('bench.done'); stop(); echo json_encode(['impl' => $which, 'elapsed_ms' => $elapsed_ms, 'result' => $result]);

Workflow :

bash
# Naive curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=naive" # Streaming curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=streaming" # Diff in xhgui (two latest xhprof runs) curl -sH "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=2" | jq '.runs[] | .run_id'

Profilage conditionnel dans du code de production

php
<?php // Classic case: a suspected function is slow for certain users only. declare(strict_types=1); use function OxPHP\Profile\{start, stop, is_active}; function charge(User $user, Money $amount): PaymentResult { // Audit: if this request is being profiled, // enable extra logging inside the third-party call. $verbose = is_active(); $gateway = new StripeClient(verbose: $verbose); return $gateway->charge($user->id, $amount); } function oncall_path(Order $order): void { // Profile only VIP users — no external trigger needed. if ($order->user->tier === 'vip') { start(); } process($order); if ($order->user->tier === 'vip') { stop(); } }

Test d'intégration qui se profile lui-même

tests/php/profile_smoke.php
<?php declare(strict_types=1); require __DIR__ . '/test_helper.php'; use function OxPHP\Profile\{start, stop, mark, is_active}; $t = new TestCase('profile_smoke', 'my-app'); // Turn profiling on from PHP — no trigger needed to test the SDK, but the // profiler must be enabled (PROFILER_ENABLED=true), or start() does nothing. $t->assertFalse('initially not active', is_active()); start(); $t->assertTrue('active after start', is_active()); mark('test.midpoint'); // some work $sum = 0; for ($i = 0; $i < 100_000; $i++) { $sum += $i; } stop(); $t->assertFalse('inactive after stop', is_active()); $t->assertSame('computation OK', $sum, 4999950000); $t->done();

Trouver un point chaud à partir d'une série de requêtes Postman

Scénario : « /api/search est parfois lent, pas toujours ».

javascript
// Postman Pre-request Script pm.request.headers.add({ key: 'X-OxPHP-Profile', value: pm.environment.get('PROFILE_TOKEN') });

Après 100 runs, un one-liner jq pour les principales anomalies :

bash
curl -sH "Authorization: Bearer $TOK" \ "http://int.app.local:9090/__profiler/runs?limit=200" \ | jq -r '.runs | map(select(.url | startswith("/api/search"))) | sort_by(-.duration_ms) | .[:5] | map("\(.duration_ms)ms \(.run_id) \(.url)") | .[]'

Un décorateur personnalisé qui alimente le profileur

Votre propre #[ProfileDb] — journalise le nombre de lignes et appelle automatiquement metric('db.rows', …) :

php
<?php use OxPHP\Decorator\{AttributeInterface, Context}; use function OxPHP\Profile\metric; #[Attribute(Attribute::TARGET_METHOD)] class ProfileDb implements AttributeInterface { public function before(Context $ctx): void {} public function after(Context $ctx): void { $result = $ctx->returnValue; if (is_array($result)) { metric('db.rows', (float) count($result)); } elseif ($result instanceof PDOStatement) { metric('db.rows', (float) $result->rowCount()); } } } oxphp_register_decorator(ProfileDb::class); class UserRepository { #[ProfileDb] public function findAll(): array { /* ... */ return []; } }

L'association décorateur + profileur fonctionne d'emblée : metric() s'attache automatiquement au span de la fonction que l'API Observer surveille à cet instant.

Prompts pour votre agent

Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.

prompt
Profile un de mes endpoints lents avec le profileur d'OxPHP. Lis la page `features/profiling` via le serveur MCP de la documentation OxPHP. Active le profileur dans la configuration Compose ou Kubernetes de ce dépôt, choisis le déclencheur qui convient à la façon dont j'atteins cet endpoint, et donne-moi les commandes qui capturent un run et l'ouvrent.
prompt
Lis le profil OxPHP que j'ai capturé et dis-moi ce qui est lent. Lis la page `features/profiling` via le serveur MCP de la documentation OxPHP. Récupère le run via les routes internes du profileur, puis remets ses frames les plus larges en correspondance avec le code de ce dépôt et dis-moi ce qui mérite d'être changé en premier.

Références

Une erreur ? Signalez-la →