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
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:# 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(pourflamegraph.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_ADDRpour 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<SpanTree>,<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 :
ARG OXPHP_WITH_PROFILER=0
# or
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profilerPour vérifier que le plugin est bien compilé :
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.
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkoutIdéal pour les benchmarks CI, les collections Postman, les scripts curl.
Pour le débogage dans le navigateur. Définissez le cookie dans le navigateur via les DevTools ou une extension :
OXPROF=dev-secret; Domain=app.local; Path=/Tant que le cookie est vivant, chaque requête est profilée. Supprimez-le pour arrêter. Utile pour dérouler un scénario utilisateur (ouvrir un produit → ajouter au panier → passer à la caisse) et collecter une série de profils.
Pour partager des liens.
https://app.local/admin/report?__oxprof=dev-secretLa méthode la plus brutale, mais pratique quand vous voulez envoyer à un collègue
un lien du genre « ouvre ça, ça reproduit le bug ». Attention : le paramètre finit
dans les journaux d'accès et dans Referer, donc ne l'utilisez pas avec un token
de production.
Pour la production.
PROFILER_SAMPLE_RATE=0.001 # ≈ 1 in 1000 requestsS'exécute sans token. Activez-le en production pour accumuler des statistiques
sur le trafic réel. Une bonne plage de départ est 0.0005..0.002 ; des valeurs
plus élevées produisent un surcoût notable, surtout avec PROFILER_INTERNAL=true.
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 :
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.
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
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
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.
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
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()
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()
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()
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
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
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
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
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.
}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é
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ètreprofileURL=…qui récupère le profil directement depuis votre serveur.
# 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)
# 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.pprofcollapsed (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.cpuet.collapsed.mem(voirtests/fixtures/profiler_exports/).
curl -H "Authorization: Bearer dev-secret" \
http://localhost:9090/__profiler/runs/<run_id>.collapsed \
| flamegraph.pl --title "Checkout $run_id" > flame.svgBuggregator (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.
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 UIUne 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
/tmp/oxphp-profiles/
├── index.json # NDJSON — one record per line
├── 1713600000000-a1b2c3d4-0f5e.xhprof.json
├── 1713600000000-a1b2c3d4-0f5e.speedscope.json
└── 1713600001234-b2c3d4e5-4a2b.xhprof.jsonSchéma d'une entrée index.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(renameatomique →index.json). - Les fichiers sans entrée dans
index.json(orphelins d'un crash) sont balayés. - Le token bucket
PROFILER_DISK_MAX_PER_SECprotège le disque : si le débit dépasse ce seuil, les runs ne sont pas écrits etoxphp_profiler_disk_drops_totals'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
# 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 :
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 approximativexhguidans l'hôte ou la query n'est pas prise en compte — forcez viaPROFILER_EXPORT_XHGUI=true|falsepour 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 debytes::Bytes(zéro allocation lors des reprises). - Les erreurs incrémentent
oxphp_profiler_http_push_failures_total.
Stack de démonstration complète
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 :
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_runsAlertes Prometheus de départ :
- 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
- Activez
PROFILER_SAMPLE_RATE=0.001en production. Laissez-le s'accumuler. - Triez les runs par
duration_ms: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})' - Ouvrez le premier dans speedscope :
.../__profiler/runs/<id>/speedscope. - Activez le mode Left Heavy dans speedscope — vous verrez les fonctions au temps cumulé le plus élevé.
- Cliquez sur la barre la plus large — obtenez fichier:ligne et la liste des enfants.
Valider une hypothèse avant/après
- Lancez un benchmark AVANT vos modifications :
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 - Appliquez les modifications, reconstruisez, recommencez. Comparez les médianes.
- 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
- Envoyez la requête qui « grossit » :
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv - Ouvrez-le dans speedscope, basculez sur la métrique mémoire (via
.collapsed.memou la vue mémoire de speedscope). - Ajoutez
#[MemoryThreshold(kb: 1024)]sur les fonctions suspectes — vous obtiendrez des événementsMemorySpikeexplicites au run suivant. - Utilisez
metric('mem.after', memory_get_usage())pour une instrumentation chirurgicale.
Surveillance continue d'un chemin critique
#[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 :
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
- Ne commitez jamais
PROFILER_AUTH_TOKEN. Lisez-le depuis Vault / les secrets Docker / les secrets Kubernetes. - 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. - N'activez pas
PROFILER_INTERNAL=trueglobalement. Un surcoût de 2–5× transforme la production en laboratoire. Utilisez-le chirurgicalement, de façon isolée. - Gardez
PROFILER_RETENTION_COUNTré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. #[Exclude]les helpers bruyants (journalisation, i18n, l'autoloader) — l'arbre devient lisible sans perdre de sens.- Reliez les profils aux traces :
trace_idest partagé. Dans Grafana / Kibana, liez/__profiler/runs/<id>depuis la vue de trace. - Identifiants compatibles Git. Dans ce build,
span_idest un compteur monotone big-endian déterministe. Le diff de deux profils stockés est propre. - 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
- Le plugin est-il compilé ?
docker compose buildl'inclut par défaut. Vérifiez que vous n'avez pas passé--build-arg OXPHP_WITH_PROFILER=0ni unCARGO_FEATURESpersonnalisé sansplugin-profiler. PROFILER_ENABLED=true?- Le déclencheur correspond-il réellement à
PROFILER_AUTH_TOKEN?- Cherchez un
\nparasite dans la variable d'environnement. - Pour la query — est-elle correctement URL-encodée ?
- Cherchez un
- 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
- Vérifiez l'accessibilité :
docker compose exec app curl -v $PROFILER_EXPORT_URL - Regardez
oxphp_profiler_http_push_failures_total. - Consultez les logs : un
tracing::warn!avecrun_idet le statut HTTP est émis à chaque échec.
Aucun fichier sur le disque
PROFILER_OUTPUT_DIRest-il absolu ? Les chemins relatifs sont ignorés.- Accessible en écriture par
www-data?docker compose exec app ls -la /tmp/oxphp-profiles PROFILER_DISK_MAX_PER_SECest-il trop bas ? Regardezoxphp_profiler_disk_drops_total.
Trop de surcoût en production
PROFILER_INTERNAL=false(c'est la valeur par défaut).PROFILER_SAMPLE_RATEdans une plage raisonnable (0.0005..0.002).PROFILER_MAX_SPANSraisonnable — une fois dépassé, l'arbre est tronqué mais la capture continue. Pour les requêtes très volumineuses, préférez unstart()/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 :
- Relevez le plafond (au prix de mémoire, contre plus de détail).
- Ajoutez
#[Exclude]/#[Sample(rate: 0.01)]sur les fonctions appelées des dizaines de milliers de fois. - Enveloppez uniquement la région suspecte dans
start()/stop().
Aide-mémoire des commandes
# 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 | jqExemples 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
<?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 :
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'Classe de service avec attributs
<?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
<?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)
<?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 :
# 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
// 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
<?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 ».
// 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 :
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
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
- Spécification dans l'arborescence :
src/profiling/mod.rs,src/plugins/ox_profiler/ - Bridge (C) :
ext/bridge/oxphp_bridge.c,ext/oxphp_sapi.c - Tests PHP :
tests/php/profiler/ - Fixtures de format :
tests/fixtures/profiler_exports/ - Démo xhgui :
tests/compose.xhgui.yml - speedscope : https://www.speedscope.app/
- xhgui : https://github.com/perftools/xhgui
- Google pprof : https://github.com/google/pprof
- flamegraph.pl : https://github.com/brendangregg/FlameGraph