PHP コードのプロファイリング

OxPHP は組み込みのリクエスト単位プロファイラーを同梱しています。xdebug やスタンドアロンの拡張機能とは異なり、これはサーバー自身の内部で動作し、PHP の再起動を必要とせず、無効化されているときには意味のあるオーバーヘッドを一切追加しません(mode=Off の分岐はフィルターキャッシュに触れる前に処理を抜けます)。

これは実践的なガイドです。ゼロ設定から始めて、遅い本番エンドポイントを追跡し、フレームグラフを読み、最適化前後の実行結果を比較するところまでを扱います。

クイックスタート: 60 秒で最初のプロファイルを取る

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

これだけです。このガイドの残りでは、実際に何が起きているのか、そしてそれを本番の知見にどう変えるのかを説明します。

プロファイラーがすること

  • すべての PHP 関数呼び出しをキャプチャします — Zend Observer API を介して行うため、バイトコードのパッチもアプリケーションコードの変更も不要です。
  • スパンツリーを構築します — 実時間、CPU 時間、開始/終了時のメモリ、属性、イベントを含みます。
  • 4 つの形式を同時にエクスポートします: xhprof.jsonspeedscope.jsonpprof(protobuf + gzip)、collapsedflamegraph.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&lt;SpanTree&gt;,<br/>runs 4 exporters, puts it in the LRU cache, spawns disk-write<br/>and HTTP-push tasks (semaphores bound the fan-out)"]

リクエスト単位の 3 つのモード

モード 適用されるとき キャプチャされる内容
Off デフォルト。どのプラグインもプロファイリングを要求していない。 何もなし。オーバーヘッドはゼロ。
ApmOnly plugin-apm は有効だが、プロファイラーのトリガーは一致しなかった。 APM の明示的なフックのみ: #[Trace]、PDO/cURL のエミッター、oxphp_trace_*()
ProfileAll プロファイラーのトリガーが一致した(または OxPHP\Profile\start() が呼ばれた)。 Observer API を介したすべての PHP 関数呼び出しに加え、APM が収集するすべて。

ProfileAllApmOnly に優先します。両方のプラグインが有効でトリガーが一致した場合、単一の共有された Arc<SpanTree> が使われます — 二重収集は起きません。

インストールとビルド

plugin-profiler プラグインはデフォルトの cargo feature の一部です。素の docker compose build にはすでに含まれています。

無効化するには:

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

プラグインがコンパイルに組み込まれているか確認するには:

bash
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)。

開発とスクリプト向け。

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

CI ベンチマーク、Postman コレクション、curl スクリプトに最適です。

サンプリングからパスを除外する

PROFILER_SAMPLE_RATEすべてのリクエストのうちランダムな一部をサンプリングします — データを汚染するフレームワーク自身のトラフィックも含めてです。Symfony の web debug toolbar は /_wdt/{token} をポーリングし、/_profiler/{token} にリンクします。Laravel Debugbar や Telescope も同様の挙動をします。これらを PROFILER_EXCLUDE_PATHS でサンプリングの対象外にしましょう:

bash
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 関数(strlenjson_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 xhprofspeedscopepprofcollapsed のうちの 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 でないもの)、空のキー、重複したキーは起動エラーになります。

本番設定の例

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

PHP SDK

すべての関数は OxPHP\Profile 名前空間に属します。これらはいつでも安全に呼び出せます: 現在のリクエストでプロファイリングが有効でない場合、ミューテーターは安全な no-op となり、is_active()false を返します。

領域を囲んだ明示的なキャプチャ

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

start() は冪等であり、stop() も同様です: 続けて 2 回呼び出しても安全です。

Warning

リクエストの途中で start() を呼ぶと、現在のツリーがリセットされます(php_sdk.rsPROFILING_CONTEXT.reset() を参照)。これは仕様の不変条件と一致します: モードはリクエストごとに一度だけ設定されます — RINIT でのトリガーによって、または最初の start() 呼び出しによって、のいずれかです。

一時停止と再開

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

stop() とは異なり、pause/resume は「一時的に」を表す説明的なシグナルです。内部的には同じフラグであり、この区別はコードを読む人の助けになるだけです。

ポイントマーカー: mark()

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

最も上位で開いているスパンSpanEventKind::Mark イベントを付与します。開いているスパンがない場合は no-op です。長い関数の途中のタイムスタンプや、if/else の分岐のマーキングに便利です。

数値メトリクス: metric()

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

現在のスパンの属性metric.<name>=<value> を追加します。mark() とは異なり、これは単なるキーと値のペア(タイムスタンプなし)です。speedscope/xhgui ではスパンのプロパティとして表示されます。

状態チェック: is_active()

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

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 とステータスを出力します。

クラスとメソッドの合成

php
use OxPHP\Profile\{Tag, Profile, Exclude}; #[Tag(key: 'layer', value: 'domain')] #[Profile] // the whole class is always profiled class OrderService { #[Tag(key: 'op', value: 'create')] public function create(array $data): Order { /* ... */ } #[Exclude] // excluded, despite class-level #[Profile] public function debug_dump(): void { /* ... */ } public function find(int $id): ?Order { /* ... */ } // inherits #[Profile] and #[Tag(layer)] }
  • クラスレベルの属性はすべてのメソッドに伝播します
  • メソッドレベルの属性はクラスレベルのものに追加されます(タグは累積します)。
  • メソッドの #[Exclude] はクラスレベルの #[Profile]上書きします

遅い関数のしきい値

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

メモリのしきい値

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

個々の関数のサンプリング

php
use OxPHP\Profile\Sample; #[Sample(rate: 0.01)] function log_event(string $evt, array $ctx): void { // ≈ 1% of calls land in the tree; the rest are skipped entirely — // neither the span nor its children are created. Useful for functions // called millions of times per request. }
フィルターとデコレーター、どちらを使う?

関数が非常に頻繁に呼ばれ、キャプチャのコストを減らしたいときは、#[Sample] または #[Exclude] を使ってください(これらはスパン生成の前に動作します)。しきい値を超えたイベントにフラグを立てたいときは、#[SlowThreshold] / #[MemoryThreshold] を使ってください(これらはすでに収集済みのスパンを見ます)。

キャプチャされたスパンに含まれるもの

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

イベントの種類 (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 に飛び、プロファイルをあなたのサーバーから直接取得します。
bash
# 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 バックエンド)

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

collapsed(Brendan Gregg の flamegraph.pl)

拡張子: .collapsed

  • テキスト形式 func;child;grandchild <count>
  • SVG フレームグラフの事実上の入力形式です。
  • 3 つのメトリクスの派生形: 実時間、CPU、メモリ。OxPHP は .collapsed(実時間)を書き込みます。内部の経路では .collapsed.cpu.collapsed.mem も生成されます(tests/fixtures/profiler_exports/ を参照)。
bash
curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.collapsed \ | flamegraph.pl --title "Checkout $run_id" > flame.svg

Buggregator(ローカルのデバッグサーバー)

Buggregator は単一バイナリのデバッグサーバーで、とりわけ xhprof プロファイルをプロジェクト別にグループ化したフレームグラフとしてレンダリングします。xhprof プッシュはその POST /api/profiler/store エンドポイントを直接ターゲットにします: OxPHP のネイティブなプロファイラーがデータを生成するため、xhprof PHP 拡張やクライアントライブラリは不要です。

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

パスが /api/profiler/store で終わる URL は、自動的に Buggregator エンベロープを選択します(PROFILER_EXPORT_BUGGREGATOR: "true" はカスタム URL に対してそれを強制し、"false" はオプトアウトします)。このエンベロープは常に xhprof を出力するため、PROFILER_EXPORT_FORMAT は無視されます(xhprof 以外の値は起動時に警告されますが、致命的ではありません — プロファイラーがエクスポートの設定つまみのせいでサーバーをクラッシュさせることはありません)。app_nametags は Buggregator のプロジェクトのグループ化とフィルタリングを駆動します。これらがなくてもプロファイルはレンダリングされますが、グループ化されないまま残ります。hostname$HOSTNAME から取得され、その変数が未設定の場合は gethostname(2) システムコールにフォールバックします。

ストレージとクリーンアップ

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

index.json エントリーのスキーマ

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

index.json/__profiler/runs によってパースされ、新しい順にソートされて ?limit=N&offset=M でページ分割されます。

保持

  • 5 秒ごとのバックグラウンドタスクが、PROFILER_RETENTION_COUNT を超えたエントリーを削除します(アトミックな renameindex.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 生のプロファイルバイト列。formatxhprof.jsonspeedscope.jsonpprofcollapsed
/__profiler/runs/{id}/speedscope GET 302 → profileURL=… 付きの speedscope.app。
/__profiler/runs/{id} DELETE すべての形式のファイル + index エントリーを削除します(204 を返します)。
/__profiler/config GET 現在のプラグイン設定(トークンは伏字)。
/__profiler/stats GET JSON のカウンタースナップショット。

スクリプト例

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

HTTP プッシュと xhgui

各実行をリモートのコレクターに送ります:

yaml
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 を増加させます。

フルデモスタック

bash
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 で公開されます:

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

Prometheus アラートのたたき台:

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

ワークフロー

遅いエンドポイントを見つける

  1. 本番で PROFILER_SAMPLE_RATE=0.001 を有効にします。しばらく蓄積させます。
  2. 実行を duration_ms でソートします:
    bash
    curl -s -H "Authorization: Bearer $TOK" \ "http://INT_ADDR/__profiler/runs?limit=500" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10] | map({run_id, url, duration_ms, span_count})'
  3. 一番上のものを speedscope で開きます: .../__profiler/runs/<id>/speedscope
  4. speedscope で Left Heavy モードを有効にします — 累積時間が最も大きい関数が見えます。
  5. 最も幅の広いバーをクリックすると、file:line と子のリストが得られます。

前後の仮説を検証する

  1. 変更を加える前にベンチマークを実行します:
    bash
    for i in $(seq 1 20); do curl -s -H "X-OxPHP-Profile: dev-secret" http://localhost/api/report > /dev/null done curl -s -H "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | map(.duration_ms) | add / length' > /tmp/p50_before.txt
  2. 変更を加え、リビルドし、繰り返します。中央値を比較します。
  3. 詳細な差分を見るには、2 つの xhprof プロファイルをダウンロードして xhgui にアップロードします — 組み込みの差分ビューがあります。

メモリリークを追跡する

  1. 「肥大化する」リクエストを送ります:
    bash
    curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv
  2. speedscope で開き、メモリメトリクスに切り替えます(.collapsed.mem 経由、または speedscope のメモリビュー経由)。
  3. 疑わしい関数に #[MemoryThreshold(kb: 1024)] を追加します — 次の実行で明示的な MemorySpike イベントが得られます。
  4. ピンポイントな計測には metric('mem.after', memory_get_usage()) を使います。

クリティカルパスの継続的な監視

php
#[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.jsonduration_ms の外れ値にアラートを設定します(ログベースのメトリクス、またはサイドカーのエクスポーター経由で)。

リンクでバグを再現する

同僚が「/admin/report が自分のところでは 500 を返す」と言います。あなたはこう返します:

text
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>

ベストプラクティス

  1. PROFILER_AUTH_TOKEN を絶対にコミットしないこと。Vault / Docker secrets / Kubernetes secrets から読み込みます。
  2. 本番では SAMPLE_RATE のみ。header/cookie/query は開発者向けのツールです。オンデマンドの本番プロファイリングが必要なら — 専用の、毎日ローテーションするトークンを使ってください。
  3. PROFILER_INTERNAL=true をグローバルに有効にしないこと。2〜5 倍のオーバーヘッドは本番を実験室に変えてしまいます。隔離してピンポイントで使ってください。
  4. PROFILER_RETENTION_COUNT を現実的に保つこと — 1 回の実行は数百 KB(小さなリクエスト)からメガバイト(大きなツリー)まで及びます。500 回 × 2 MB = 1 GB。ディスクをそれに合わせてサイジングしてください。
  5. ノイズの多いヘルパーには #[Exclude] — ロギング、i18n、オートローダーなど。意味を失わずにツリーが読みやすくなります。
  6. プロファイルをトレースにリンクする: trace_id は共有されます。Grafana / Kibana では、トレースビューから /__profiler/runs/<id> にリンクします。
  7. Git フレンドリーな識別子。このビルドでは、span_id は決定論的なビッグエンディアンの単調増加カウンターです。保存された 2 つのプロファイルの差分がきれいになります。
  8. APM + プロファイラーは無料。両方を有効にしておいてください。ツリーは共有され、オーバーヘッドは APM の累積されたカバレッジからのみ生じます。

トラブルシューティング

プロファイルが 1 つも現れない
  1. プラグインはコンパイルに組み込まれていますか? docker compose build はデフォルトで含めます。--build-arg OXPHP_WITH_PROFILER=0 や、plugin-profiler を含まないカスタムの CARGO_FEATURES を渡していないか確認してください。
  2. PROFILER_ENABLED=true になっていますか?
  3. トリガーは実際に PROFILER_AUTH_TOKEN と一致していますか?
    • 環境変数に紛れ込んだ \n を探してください。
    • query の場合 — 正しく URL エンコードされていますか?
  4. そもそもサーバーがあなたのリクエストを受け取っていますか? アクセスログを確認してください。
/__profiler/runs から 401 が返る

ヘッダー内のベアラートークンが PROFILER_AUTH_TOKEN と一致していません。よくある罠: echo "secret" > secret.txt\n を追加します。printf を使うか、環境変数経由で渡してください。

xhgui に新しい実行が表示されない
  1. 到達可能性を確認します:
    bash
    docker compose exec app curl -v $PROFILER_EXPORT_URL
  2. oxphp_profiler_http_push_failures_total を見ます。
  3. ログを確認します: 各失敗ごとに run_id と HTTP ステータスを含む tracing::warn! が出力されます。
ディスクにファイルがない
  • PROFILER_OUTPUT_DIR絶対パスですか? 相対パスは無視されます。
  • www-data によって書き込み可能ですか?
    bash
    docker compose exec app ls -la /tmp/oxphp-profiles
  • PROFILER_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)を超えました。選択肢:

  1. 上限を引き上げる(メモリと引き換えに詳細を得ます)。
  2. 数万回呼ばれる関数に #[Exclude] / #[Sample(rate: 0.01)] を追加する。
  3. 疑わしい領域のみを start()/stop() で囲む。

コマンドチートシート

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

実践的な例

以下は、www/public/ に置いて curl で叩ける、すぐに実行できる PHP のシナリオです。

手動制御を伴うシンプルなコントローラー

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

呼び出し:

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

属性付きのサービスクラス

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

バッチジョブ: N 回のうち最初のイテレーションだけをプロファイリングする

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

2 つの実装を比較する(プロファイル付きのマイクロベンチマーク)

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

ワークフロー:

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

本番コードでの条件付きプロファイリング

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

自分自身をプロファイリングする統合テスト

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

一連の Postman リクエストからホットスポットを見つける

シナリオ: 「/api/search は常にではないが、ときどき遅い。」

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

100 回実行したあと、上位の異常値を出す jq ワンライナー:

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

プロファイラーにデータを供給するカスタムデコレーター

あなた独自の #[ProfileDb] — 行数をログに記録し、自動的に metric('db.rows', …) を呼び出します:

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

デコレーターとプロファイラーの組み合わせは、そのまま動作します: metric() は、Observer API が現在監視している関数のスパンに自動的に付与されます。

リファレンス