PHP コードのプロファイリング
OxPHP は組み込みのリクエスト単位プロファイラーを同梱しています。xdebug やスタンドアロンの拡張機能とは異なり、これはサーバー自身の内部で動作し、PHP の再起動を必要とせず、無効化されているときには意味のあるオーバーヘッドを一切追加しません(mode=Off の分岐はフィルターキャッシュに触れる前に処理を抜けます)。
これは実践的なガイドです。ゼロ設定から始めて、遅い本番エンドポイントを追跡し、フレームグラフを読み、最適化前後の実行結果を比較するところまでを扱います。
クイックスタート: 60 秒で最初のプロファイルを取る
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"これだけです。このガイドの残りでは、実際に何が起きているのか、そしてそれを本番の知見にどう変えるのかを説明します。
プロファイラーがすること
- すべての PHP 関数呼び出しをキャプチャします — Zend Observer API を介して行うため、バイトコードのパッチもアプリケーションコードの変更も不要です。
- スパンツリーを構築します — 実時間、CPU 時間、開始/終了時のメモリ、属性、イベントを含みます。
- 4 つの形式を同時にエクスポートします:
xhprof.json、speedscope.json、pprof(protobuf + gzip)、collapsed(flamegraph.pl用)。 - 実行結果を永続化します: メモリ内 LRU キャッシュ + ディスクファイル + オプションの HTTP プッシュ(xhgui または任意のカスタムコレクター)。
- 8 つの内部 HTTP ルートを公開します —
INTERNAL_ADDR上で、プロファイルを閲覧・ダウンロードできます。 - Prometheus メトリクスを出力します — ソース別の実行数、収集されたスパン数、書き込まれたバイト数、ドロップ数、プッシュ失敗数。
- 再起動は不要です — トリガーによってリクエスト単位で有効化されます。
仕組み
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)"]
リクエスト単位の 3 つのモード
| モード | 適用されるとき | キャプチャされる内容 |
|---|---|---|
Off |
デフォルト。どのプラグインもプロファイリングを要求していない。 | 何もなし。オーバーヘッドはゼロ。 |
ApmOnly |
plugin-apm は有効だが、プロファイラーのトリガーは一致しなかった。 |
APM の明示的なフックのみ: #[Trace]、PDO/cURL のエミッター、oxphp_trace_*()。 |
ProfileAll |
プロファイラーのトリガーが一致した(または OxPHP\Profile\start() が呼ばれた)。 |
Observer API を介したすべての PHP 関数呼び出しに加え、APM が収集するすべて。 |
ProfileAll は ApmOnly に優先します。両方のプラグインが有効でトリガーが一致した場合、単一の共有された Arc<SpanTree> が使われます — 二重収集は起きません。
インストールとビルド
plugin-profiler プラグインはデフォルトの cargo feature の一部です。素の docker compose build にはすでに含まれています。
無効化するには:
ARG OXPHP_WITH_PROFILER=0
# or
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profilerプラグインがコンパイルに組み込まれているか確認するには:
docker compose exec app cat /proc/self/maps | grep -i profiler
# or: oxphp --list-plugins (if the command is available)有効化のトリガー
トリガーは次の優先順位でチェックされます: header → cookie → query → sample_rate。いずれかが一致すると ProfileAll が有効になります。トークンは定数時間で比較されます(subtle::ConstantTimeEq)。
開発とスクリプト向け。
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkoutCI ベンチマーク、Postman コレクション、curl スクリプトに最適です。
ブラウザでのデバッグ向け。DevTools や拡張機能を使ってブラウザで Cookie を設定します:
OXPROF=dev-secret; Domain=app.local; Path=/この Cookie が生きている限り、すべてのリクエストがプロファイリングされます。停止するには削除してください。ユーザーのシナリオ(商品を開く → カートに追加 → チェックアウト)をたどりながらプロファイルの一連の記録を収集するのに便利です。
リンク共有向け。
https://app.local/admin/report?__oxprof=dev-secret最も無骨な方法ですが、同僚に「これを開いて、バグが再現するから」といったリンクを送りたいときに重宝します。注意: このパラメーターはアクセスログや Referer に残るため、本番用トークンでは使わないでください。
本番向け。
PROFILER_SAMPLE_RATE=0.001 # ≈ 1 in 1000 requestsトークンなしで動作します。本番でこれを有効にすると、実際のトラフィックに対して統計を蓄積できます。開始点としては 0.0005..0.002 あたりが適切です。値を大きくすると、特に PROFILER_INTERNAL=true のときには顕著なオーバーヘッドが生じます。
サンプリングからパスを除外する
PROFILER_SAMPLE_RATE はすべてのリクエストのうちランダムな一部をサンプリングします — データを汚染するフレームワーク自身のトラフィックも含めてです。Symfony の web debug toolbar は /_wdt/{token} をポーリングし、/_profiler/{token} にリンクします。Laravel Debugbar や Telescope も同様の挙動をします。これらを PROFILER_EXCLUDE_PATHS でサンプリングの対象外にしましょう:
PROFILER_EXCLUDE_PATHS=/_profiler,/_profiler/**,/_wdt/**カンマ区切りの glob パターンで、PHP_DENY_PATHS と同じ構文です: * は / を跨がず、** は跨ぎ、先頭の / は任意です。1 つのパターンは、素のパスまたはそのサブツリーのいずれかに一致しますが、両方を列挙した場合のみ両方に一致します — /_profiler/** は /_profiler/x をカバーしますが、素の /_profiler はカバーしません。だからこそ上のレシピでは 2 つのパターンを使っています。パターンは受信したとおりのリクエストパスに一致します — パーセントデコードも .. の正規化も行われません — ので、お使いのフレームワークが利用するリテラルなパスを列挙してください。
明示的なトリガーを伴うリクエスト — x-oxphp-profile ヘッダー、OXPROF Cookie、__oxprof クエリパラメーター — は、除外パス上であっても常にプロファイリングされます。これにより、/_profiler 自体をバックグラウンドのサンプリングから外しておきつつ、意図的にプロファイリングすることができます。
設定リファレンス
| 変数 | デフォルト | 説明 |
|---|---|---|
PROFILER_ENABLED |
false |
マスタースイッチ。true → プラグインがロードされます。 |
PROFILER_AUTH_TOKEN |
(未設定) | トリガー用のシークレット、および /__profiler/* ルート用のベアラートークン。空文字列 = 「トークン不要」(空でないトリガー値なら何でも通過します)。トークンをリポジトリにコミットしないでください。 |
PROFILER_SAMPLE_RATE |
0.0 |
[0.0; 1.0]。ランダムサンプリングのレート。 |
PROFILER_EXCLUDE_PATHS |
(未設定) | PROFILER_SAMPLE_RATE から除外する CSV 形式の glob パターン(PHP_DENY_PATHS 構文)。明示的なトリガーはこれらのパスも引き続きプロファイリングします。例: /_profiler,/_profiler/**,/_wdt/**。 |
PROFILER_INTERNAL |
false |
内部の C 関数(strlen、json_encode、…)を観測します。完全なカバレッジが得られますが、2〜5 倍のオーバーヘッドが生じます。ピンポイントで使ってください。 |
PROFILER_MAX_SPANS |
50000 |
リクエストごとのツリーサイズの上限。超過すると、それ以降のスパンは truncated としてマークされ、書き込まれません。 |
PROFILER_MAX_DEPTH |
256 |
スタック深さの上限。 |
PROFILER_OUTPUT_DIR |
/tmp/oxphp-profiles |
絶対パス。www-data によって書き込み可能である必要があります。 |
PROFILER_OUTPUT_FORMATS |
xhprof,speedscope |
xhprof、speedscope、pprof、collapsed のうちの CSV 形式のサブセット。 |
PROFILER_RETENTION_COUNT |
100 |
保持する実行数(ディスク上と LRU の両方)。5 秒ごとにバックグラウンドで整理されます。 |
PROFILER_DISK_MAX_PER_SEC |
10 |
ディスクを保護するトークンバケット。あふれた分はドロップされ、oxphp_profiler_disk_drops_total が増加します。 |
PROFILER_EXPORT_URL |
(未設定) | キャプチャした各実行の POST 先 URL(xhgui、カスタムコレクター)。 |
PROFILER_EXPORT_FORMAT |
xhprof |
HTTP プッシュ用の 4 形式のいずれか。 |
PROFILER_EXPORT_AUTH_TOKEN |
(未設定) | プッシュ先のベアラートークン。 |
PROFILER_EXPORT_XHGUI |
auto |
xhgui エンベロープモードを強制します。Auto: URL パスが /run/import で終わる場合(xhgui の正規エンドポイント。ホストやクエリのヒントは照合されません — 非標準のパスにはこれを true に設定してください)。 |
PROFILER_EXPORT_BUGGREGATOR |
auto |
Buggregator エンベロープを強制します。Auto: URL パスが /api/profiler/store で終わる場合。このエンベロープは常に xhprof を出力するため、PROFILER_EXPORT_FORMAT は無視されます(xhprof 以外の値は警告されますが、致命的ではありません)。PROFILER_EXPORT_XHGUI とは排他です(両方を有効にすると起動エラーになります)。 |
PROFILER_EXPORT_APP_NAME |
(未設定) | Buggregator の app_name — プロファイルがグループ化されるプロジェクト。 |
PROFILER_EXPORT_TAGS |
(未設定) | Buggregator の tags。フィルタリング用の key=value,key2=value2 リスト。不正なトークン(key=value でないもの)、空のキー、重複したキーは起動エラーになります。 |
本番設定の例
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"PHP SDK
すべての関数は OxPHP\Profile 名前空間に属します。これらはいつでも安全に呼び出せます: 現在のリクエストでプロファイリングが有効でない場合、ミューテーターは安全な no-op となり、is_active() は false を返します。
領域を囲んだ明示的なキャプチャ
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() は冪等であり、stop() も同様です: 続けて 2 回呼び出しても安全です。
リクエストの途中で start() を呼ぶと、現在のツリーがリセットされます(php_sdk.rs の PROFILING_CONTEXT.reset() を参照)。これは仕様の不変条件と一致します: モードはリクエストごとに一度だけ設定されます — RINIT でのトリガーによって、または最初の start() 呼び出しによって、のいずれかです。
一時停止と再開
use function OxPHP\Profile\{pause, resume};
pause();
noisy_helper_we_dont_care_about(); // will not land in the tree
resume();stop() とは異なり、pause/resume は「一時的に」を表す説明的なシグナルです。内部的には同じフラグであり、この区別はコードを読む人の助けになるだけです。
ポイントマーカー: mark()
use function OxPHP\Profile\mark;
mark('cache_miss');
mark('got_auth_token', ['user_id' => (string) $user->id]);最も上位で開いているスパンに SpanEventKind::Mark イベントを付与します。開いているスパンがない場合は no-op です。長い関数の途中のタイムスタンプや、if/else の分岐のマーキングに便利です。
数値メトリクス: metric()
use function OxPHP\Profile\metric;
$rows = $pdo->query('SELECT ...')->fetchAll();
metric('db.rows', (float) count($rows));
metric('payload.kb', strlen($body) / 1024.0);現在のスパンの属性に metric.<name>=<value> を追加します。mark() とは異なり、これは単なるキーと値のペア(タイムスタンプなし)です。speedscope/xhgui ではスパンのプロパティとして表示されます。
状態チェック: 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));
}TLS の読み取り 2 回のみで、FFI はありません。ホットなコードで呼び出しても安全です。
属性 (PHP 8)
7 つの属性は 2 つのカテゴリーに分かれます: observer フィルターはスパン生成の前に、デコレーターはスパンがクローズした後に実行されます。
| 属性 | カテゴリー | 効果 |
|---|---|---|
#[Profile] |
filter | その関数をツリーに強制的に含めます(一般的なルールなら除外される場合でも)。 |
#[Exclude] |
filter | その関数をスキップします。その子は、最も近い含められた祖先に付け替えられます。 |
#[Sample(rate: 0.1)] |
filter | 呼び出しの一部のみを残します(rate ∈ [0.0; 1.0])。確率的で、ロックフリーです。 |
#[Tag(key, value)] |
filter | スパンにラベルを付与します。繰り返し可能で、複数の #[Tag] が累積します。 |
#[Mark(label?)] |
decorator | 関数のエントリーで Mark イベントを出力します。 |
#[SlowThreshold(ms)] |
decorator | 実時間が ms 以上のときに Slow イベントを出力し、ステータスを設定します。 |
#[MemoryThreshold(kb)] |
decorator | 正味のアロケーションが kb 以上のときに MemorySpike とステータスを出力します。 |
クラスとメソッドの合成
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)]
}- クラスレベルの属性はすべてのメソッドに伝播します。
- メソッドレベルの属性はクラスレベルのものに追加されます(タグは累積します)。
- メソッドの
#[Exclude]はクラスレベルの#[Profile]を上書きします。
遅い関数のしきい値
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.
}メモリのしきい値
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
}個々の関数のサンプリング
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.
}関数が非常に頻繁に呼ばれ、キャプチャのコストを減らしたいときは、#[Sample] または #[Exclude] を使ってください(これらはスパン生成の前に動作します)。しきい値を超えたイベントにフラグを立てたいときは、#[SlowThreshold] / #[MemoryThreshold] を使ってください(これらはすでに収集済みのスパンを見ます)。
キャプチャされたスパンに含まれるもの
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)
}イベントの種類 (SpanEvent::kind):
| 種類 | 出力元 |
|---|---|
Mark |
mark()、metric()、#[Mark] |
Slow |
#[SlowThreshold] |
MemorySpike |
#[MemoryThreshold] |
Sql |
APM フック(PDO、mysqli) |
Http |
APM フック(cURL、HTTP ストリーム) |
Exception |
APM の例外ハンドラー |
Alloc |
(ヒープサンプリング用に予約) |
Other |
フォールバック |
エクスポート形式
ファイルは PROFILER_OUTPUT_DIR 以下に <run_id>.<ext> という名前で保存されます。ここで run_id = <ts_ms>-<req_id_prefix>-<rand4> です(例: 1713600000000-a1b2c3d4-0f5e)。
speedscope(インタラクティブ分析のデフォルト)
拡張子: .speedscope.json
- ブラウザ内のフレームグラフで、ズーム、検索、CPU / 時間 / メモリの切り替えができます。
- セットアップ不要 — speedscope.app で直接開けます。
- OxPHP は
/__profiler/runs/{id}/speedscopeで 302 リダイレクトを返します →profileURL=…パラメーター付きの speedscope.app に飛び、プロファイルをあなたのサーバーから直接取得します。
# Ctrl-click in macOS Terminal / xdg-open on Linux
open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"xhprof(xhgui 向け: タイムラインと履歴の差分)
拡張子: .xhprof.json
- xhgui と互換です(URL 検索、トレンド、2 つの実行間の差分)。
- 本番での蓄積に最適です: アプリの隣で xhgui コンテナを動かし、
PROFILER_EXPORT_URL=http://xhgui/run/importを指定すれば、UI に履歴が積み上がっていきます。 - すぐ使える docker-compose:
tests/compose.xhgui.yml。
pprof(Google pprof ツール、Grafana pprof プラグイン、Pyroscope)
拡張子: .pprof(protobuf + gzip、レベル fast、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(Brendan Gregg の flamegraph.pl)
拡張子: .collapsed
- テキスト形式
func;child;grandchild <count>。 - SVG フレームグラフの事実上の入力形式です。
- 3 つのメトリクスの派生形: 実時間、CPU、メモリ。OxPHP は
.collapsed(実時間)を書き込みます。内部の経路では.collapsed.cpuと.collapsed.memも生成されます(tests/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(ローカルのデバッグサーバー)
Buggregator は単一バイナリのデバッグサーバーで、とりわけ xhprof プロファイルをプロジェクト別にグループ化したフレームグラフとしてレンダリングします。xhprof プッシュはその POST /api/profiler/store エンドポイントを直接ターゲットにします: OxPHP のネイティブなプロファイラーがデータを生成するため、xhprof PHP 拡張やクライアントライブラリは不要です。
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パスが /api/profiler/store で終わる URL は、自動的に Buggregator エンベロープを選択します(PROFILER_EXPORT_BUGGREGATOR: "true" はカスタム URL に対してそれを強制し、"false" はオプトアウトします)。このエンベロープは常に xhprof を出力するため、PROFILER_EXPORT_FORMAT は無視されます(xhprof 以外の値は起動時に警告されますが、致命的ではありません — プロファイラーがエクスポートの設定つまみのせいでサーバーをクラッシュさせることはありません)。app_name と tags は Buggregator のプロジェクトのグループ化とフィルタリングを駆動します。これらがなくてもプロファイルはレンダリングされますが、グループ化されないまま残ります。hostname は $HOSTNAME から取得され、その変数が未設定の場合は gethostname(2) システムコールにフォールバックします。
ストレージとクリーンアップ
/tmp/oxphp-profiles/
├── index.json # NDJSON — one record per line
├── 1713600000000-a1b2c3d4-0f5e.xhprof.json
├── 1713600000000-a1b2c3d4-0f5e.speedscope.json
└── 1713600001234-b2c3d4e5-4a2b.xhprof.jsonindex.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 は /__profiler/runs によってパースされ、新しい順にソートされて ?limit=N&offset=M でページ分割されます。
保持
- 5 秒ごとのバックグラウンドタスクが、
PROFILER_RETENTION_COUNTを超えたエントリーを削除します(アトミックなrename→index.json)。 index.jsonにエントリーがないファイル(クラッシュで孤立したもの)は一掃されます。PROFILER_DISK_MAX_PER_SECのトークンバケットがディスクを保護します: レートがそれより高い場合、実行は書き込まれず、oxphp_profiler_disk_drops_totalが増加します。
内部 HTTP ルート
INTERNAL_ADDR=0.0.0.0:9090 のとき、プラグインは /__profiler/ プレフィックスの下に 8 つのエンドポイントを登録します。トークンが設定されている場合、すべて Authorization: Bearer <PROFILER_AUTH_TOKEN> を必要とします。比較は定数時間です。
| ルート | メソッド | 目的 |
|---|---|---|
/__profiler/ |
GET | エンドポイント一覧を載せた HTML のランディングページ。 |
/__profiler/runs |
GET | 実行の JSON 配列。?limit=N&offset=M。 |
/__profiler/runs/{id} |
GET | 1 つの実行の JSON メタデータ。 |
/__profiler/runs/{id}.{format} |
GET | 生のプロファイルバイト列。format ∈ xhprof.json、speedscope.json、pprof、collapsed。 |
/__profiler/runs/{id}/speedscope |
GET | 302 → profileURL=… 付きの speedscope.app。 |
/__profiler/runs/{id} |
DELETE | すべての形式のファイル + index エントリーを削除します(204 を返します)。 |
/__profiler/config |
GET | 現在のプラグイン設定(トークンは伏字)。 |
/__profiler/stats |
GET | JSON のカウンタースナップショット。 |
スクリプト例
# 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/{}"HTTP プッシュと xhgui
各実行をリモートのコレクターに送ります:
environment:
PROFILER_EXPORT_URL: "http://xhgui/run/import"
PROFILER_EXPORT_FORMAT: "xhprof"
PROFILER_EXPORT_AUTH_TOKEN: "shared-secret" # optional- xhgui エンベロープの自動検出: URL のパスが
/run/importで終わる場合(xhgui の正規エンドポイント)。ホストやクエリの中の曖昧なxhguiという部分文字列は照合されません — そうした URL にはPROFILER_EXPORT_XHGUI=true|falseで強制してください。 - リトライ計画: 指数バックオフ
100/200/400 msで 3 回試行、合計の実時間予算は 5 秒。リクエストボディはbytes::Bytesとして試行間で共有されます(リトライ時のアロケーションはゼロ)。 - エラーは
oxphp_profiler_http_push_failures_totalを増加させます。
フルデモスタック
docker compose -f tests/compose.xhgui.yml up -d
# app: :80, xhgui: :8142 (UI), :27017 (mongo)E2E スモークテスト: tests/php/profiler/test_xhgui_import.php。
Prometheus メトリクス
/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_runsPrometheus アラートのたたき台:
- 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"ワークフロー
遅いエンドポイントを見つける
- 本番で
PROFILER_SAMPLE_RATE=0.001を有効にします。しばらく蓄積させます。 - 実行を
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})' - 一番上のものを speedscope で開きます:
.../__profiler/runs/<id>/speedscope。 - speedscope で Left Heavy モードを有効にします — 累積時間が最も大きい関数が見えます。
- 最も幅の広いバーをクリックすると、file:line と子のリストが得られます。
前後の仮説を検証する
- 変更を加える前にベンチマークを実行します:
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 つの xhprof プロファイルをダウンロードして xhgui にアップロードします — 組み込みの差分ビューがあります。
メモリリークを追跡する
- 「肥大化する」リクエストを送ります:
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv - speedscope で開き、メモリメトリクスに切り替えます(
.collapsed.mem経由、または speedscope のメモリビュー経由)。 - 疑わしい関数に
#[MemoryThreshold(kb: 1024)]を追加します — 次の実行で明示的なMemorySpikeイベントが得られます。 - ピンポイントな計測には
metric('mem.after', memory_get_usage())を使います。
クリティカルパスの継続的な監視
#[Profile]
#[SlowThreshold(ms: 500)]
public function chargeCard(PaymentRequest $r): PaymentResult
{
// always captured + an explicit Slow mark when it lags
}Grafana で oxphp_profiler_runs_total{source="sample"} のパネルを追加し、index.json の duration_ms の外れ値にアラートを設定します(ログベースのメトリクス、またはサイドカーのエクスポーター経由で)。
リンクでバグを再現する
同僚が「/admin/report が自分のところでは 500 を返す」と言います。あなたはこう返します:
https://app.local/admin/report?__oxprof=<one-time-token>彼らがアクセスしたあと — /__profiler/runs?limit=5 で、プロファイルを開き、例外が正確にどこで発生したかを確認します(status_code=2 + Exception イベント)。
APM との連携
- 両方のプラグインは単一の
Arc<SpanTree>を共有します。二重収集はありません。 - プロファイラーのトリガーなし + APM 有効 →
mode=ApmOnly。ツリーには明示的にタグ付けされたスパンのみが含まれます(#[Trace]、APM の SQL/HTTP フック)。 - プロファイラーのトリガーが一致 →
mode=ProfileAll。ツリーにはすべてに加えて APM のアノテーションが含まれます。 - APM は依然として明示的なスパンのみを OTLP に送ります(Jaeger/Tempo はトレースあたり約 1 万スパンで頭打ちになります)。全体像を見るには —
/__profiler/runs/<id>。
ベストプラクティス
PROFILER_AUTH_TOKENを絶対にコミットしないこと。Vault / Docker secrets / Kubernetes secrets から読み込みます。- 本番では
SAMPLE_RATEのみ。header/cookie/query は開発者向けのツールです。オンデマンドの本番プロファイリングが必要なら — 専用の、毎日ローテーションするトークンを使ってください。 PROFILER_INTERNAL=trueをグローバルに有効にしないこと。2〜5 倍のオーバーヘッドは本番を実験室に変えてしまいます。隔離してピンポイントで使ってください。PROFILER_RETENTION_COUNTを現実的に保つこと — 1 回の実行は数百 KB(小さなリクエスト)からメガバイト(大きなツリー)まで及びます。500 回 × 2 MB = 1 GB。ディスクをそれに合わせてサイジングしてください。- ノイズの多いヘルパーには
#[Exclude]を — ロギング、i18n、オートローダーなど。意味を失わずにツリーが読みやすくなります。 - プロファイルをトレースにリンクする:
trace_idは共有されます。Grafana / Kibana では、トレースビューから/__profiler/runs/<id>にリンクします。 - Git フレンドリーな識別子。このビルドでは、
span_idは決定論的なビッグエンディアンの単調増加カウンターです。保存された 2 つのプロファイルの差分がきれいになります。 - APM + プロファイラーは無料。両方を有効にしておいてください。ツリーは共有され、オーバーヘッドは APM の累積されたカバレッジからのみ生じます。
トラブルシューティング
プロファイルが 1 つも現れない
- プラグインはコンパイルに組み込まれていますか?
docker compose buildはデフォルトで含めます。--build-arg OXPHP_WITH_PROFILER=0や、plugin-profilerを含まないカスタムのCARGO_FEATURESを渡していないか確認してください。 PROFILER_ENABLED=trueになっていますか?- トリガーは実際に
PROFILER_AUTH_TOKENと一致していますか?- 環境変数に紛れ込んだ
\nを探してください。 - query の場合 — 正しく URL エンコードされていますか?
- 環境変数に紛れ込んだ
- そもそもサーバーがあなたのリクエストを受け取っていますか? アクセスログを確認してください。
/__profiler/runs から 401 が返る
ヘッダー内のベアラートークンが PROFILER_AUTH_TOKEN と一致していません。よくある罠: echo "secret" > secret.txt は \n を追加します。printf を使うか、環境変数経由で渡してください。
xhgui に新しい実行が表示されない
- 到達可能性を確認します:
docker compose exec app curl -v $PROFILER_EXPORT_URL oxphp_profiler_http_push_failures_totalを見ます。- ログを確認します: 各失敗ごとに
run_idと HTTP ステータスを含むtracing::warn!が出力されます。
ディスクにファイルがない
PROFILER_OUTPUT_DIRは絶対パスですか? 相対パスは無視されます。www-dataによって書き込み可能ですか?docker compose exec app ls -la /tmp/oxphp-profilesPROFILER_DISK_MAX_PER_SECが低すぎませんか?oxphp_profiler_disk_drops_totalを見てください。
本番でオーバーヘッドが大きすぎる
PROFILER_INTERNAL=false(これがデフォルトです)。PROFILER_SAMPLE_RATEを妥当な範囲に(0.0005..0.002)。- 妥当な
PROFILER_MAX_SPANS— 超過するとツリーは切り詰められますが、キャプチャ自体は続行します。非常に大きなリクエストには、対象のセクションを囲んだピンポイントなstart()/stop()を優先してください。
index.json で truncated=true
リクエストが PROFILER_MAX_SPANS(デフォルト 50,000)を超えました。選択肢:
- 上限を引き上げる(メモリと引き換えに詳細を得ます)。
- 数万回呼ばれる関数に
#[Exclude]/#[Sample(rate: 0.01)]を追加する。 - 疑わしい領域のみを
start()/stop()で囲む。
コマンドチートシート
# 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実践的な例
以下は、www/public/ に置いて curl で叩ける、すぐに実行できる 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');
}呼び出し:
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'属性付きのサービスクラス
<?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 { /* ... */ }
}バッチジョブ: 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 { /* ... */ }2 つの実装を比較する(プロファイル付きのマイクロベンチマーク)
<?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]);ワークフロー:
# 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'本番コードでの条件付きプロファイリング
<?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();
}
}自分自身をプロファイリングする統合テスト
<?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();一連の Postman リクエストからホットスポットを見つける
シナリオ: 「/api/search は常にではないが、ときどき遅い。」
// Postman Pre-request Script
pm.request.headers.add({
key: 'X-OxPHP-Profile',
value: pm.environment.get('PROFILE_TOKEN')
});100 回実行したあと、上位の異常値を出す jq ワンライナー:
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)")
| .[]'プロファイラーにデータを供給するカスタムデコレーター
あなた独自の #[ProfileDb] — 行数をログに記録し、自動的に 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 []; }
}デコレーターとプロファイラーの組み合わせは、そのまま動作します: metric() は、Observer API が現在監視している関数のスパンに自動的に付与されます。
リファレンス
- ツリー内の仕様:
src/profiling/mod.rs、src/plugins/ox_profiler/ - ブリッジ (C):
ext/bridge/oxphp_bridge.c、ext/oxphp_sapi.c - PHP テスト:
tests/php/profiler/ - 形式のフィクスチャ:
tests/fixtures/profiler_exports/ - 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