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.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:# 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_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 :
ARG OXPHP_WITH_PROFILER=0
# or
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profilerPour 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 :
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.
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.
Le cookie n'est pas retiré de la requête : le token reste visible pour le code
profilé et pour tout ce qui journalise ou relaie les cookies. Il apparaît dans
$_COOKIE et dans $_SERVER['HTTP_COOKIE'] comme n'importe quel autre cookie.
C'est une raison de plus de garder un token de production à l'écart de ce chemin
(voir Bonnes pratiques).
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 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 avecPROFILER_ENABLED=truecet 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
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
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. 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). 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.
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è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: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 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 | 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(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"|"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_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) | sort | .[length/2|floor]' > /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 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 avectruncated: true. 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');
// 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 ».
// 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.
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.
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.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
- 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