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.10.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_trace_*().
ProfileAll Un déclencheur de profileur a correspondu (ou OxPHP\Profile\start() a été appelé). 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é :

bash
docker compose exec app cat /proc/self/maps | grep -i profiler # or: oxphp --list-plugins (if the command is available)

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 la taille de l'arbre par requête. Une fois dépassé, les spans suivants sont marqués truncated et ne sont pas écrits.
PROFILER_MAX_DEPTH 256 Plafond strict sur la profondeur de la pile.
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.

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.

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, pas de FFI. 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).

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:latest 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 "span_count": 4821, "event_count": 7, "error_count": 0, "leaked_count": 0, "truncated": false, // true — exceeded PROFILER_MAX_SPANS "oxphp_version": "0.10.0", "formats": ["xhprof.json", "speedscope.json"] }

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. formatxhprof.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"} 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) | add / length' > /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 dépassé, l'arbre est tronqué mais la capture continue. 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'); // Enable the profiler manually (no trigger needed to test the SDK). $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.

Références