Профилирование 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 — без патчинга байткода и без изменений в коде приложения.
  • Строит дерево спанов с wall-time, CPU-time, памятью на входе/выходе, атрибутами и событиями.
  • Экспортирует четыре формата одновременно: xhprof.json, speedscope.json, pprof (protobuf + gzip), collapsed (для flamegraph.pl).
  • Сохраняет прогоны: LRU-кэш в памяти + файлы на диске + опциональный push по HTTP (xhgui или любой другой коллектор).
  • Открывает 8 внутренних HTTP-маршрутов на INTERNAL_ADDR для просмотра и скачивания профилей.
  • Отдаёт метрики Prometheus — прогоны по источнику, собранные спаны, записанные байты, потери, неудачные push-запросы.
  • Не требует перезапуска — активируется для отдельного запроса по триггеру.

Как это работает

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)"]

Три режима на уровне запроса

Режим Когда Что захватывается
Off По умолчанию. Ни один плагин не запросил профилирование. Ничего. Нулевые накладные расходы.
ApmOnly plugin-apm включён, но ни один триггер профилировщика не сработал. Только явные хуки APM: #[Trace], эмиттеры PDO/cURL, oxphp_trace_*().
ProfileAll Сработал триггер профилировщика (или был вызван OxPHP\Profile\start()). Каждый вызов PHP-функции через Observer API плюс всё, что собирает APM.

ProfileAll перекрывает ApmOnly: когда оба плагина включены и триггер срабатывает, используется единое разделяемое Arc<SpanTree> — двойного сбора нет.

Установка и сборка

Плагин plugin-profiler входит в набор фич cargo по умолчанию. Обычная сборка 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 опрашивает /_wdt/{token} и ссылается на /_profiler/{token}; Laravel Debugbar и Telescope ведут себя похоже. Держите их вне выборки с помощью PROFILER_EXCLUDE_PATHS:

bash
PROFILER_EXCLUDE_PATHS=/_profiler,/_profiler/**,/_wdt/**

Glob-паттерны через запятую, тот же синтаксис, что и у PHP_DENY_PATHS: * не пересекает /, ** пересекает, а ведущий / необязателен. Паттерн совпадает с голым путём или его поддеревом только если вы перечислите оба — /_profiler/** покрывает /_profiler/x, но не голый /_profiler, отсюда и рецепт из двух паттернов выше. Паттерны сопоставляются с путём запроса как есть — без percent-декодирования и без нормализации .. — так что перечисляйте буквальный путь, который использует ваш фреймворк.

Исключение влияет только на автоматическую выборку

Запрос с явным триггером — заголовком x-oxphp-profile, cookie OXPROF или query-параметром __oxprofвсегда профилируется, даже на исключённом пути. Это позволяет намеренно профилировать сам /_profiler, держа его при этом вне фоновой выборки.

Справочник по конфигурации

Переменная По умолчанию Описание
PROFILER_ENABLED false Главный выключатель. true → плагин загружен.
PROFILER_AUTH_TOKEN (не задан) Секрет для триггеров и bearer-токен для маршрутов /__profiler/*. Пустая строка = «токен не требуется» (проходит любое непустое значение триггера). Никогда не коммитьте токен в репозиторий.
PROFILER_SAMPLE_RATE 0.0 [0.0; 1.0]. Частота случайной выборки.
PROFILER_EXCLUDE_PATHS (не задан) Glob-паттерны через запятую (синтаксис PHP_DENY_PATHS), исключённые из PROFILER_SAMPLE_RATE. Явные триггеры всё равно их профилируют. Пример: /_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.
PROFILER_RETENTION_COUNT 100 Сколько прогонов хранить (и на диске, и в LRU). Фоновая обрезка каждые 5 секунд.
PROFILER_DISK_MAX_PER_SEC 10 Token bucket, защищающий диск. Переполнение отбрасывается и инкрементирует oxphp_profiler_disk_drops_total.
PROFILER_EXPORT_URL (не задан) URL для POST-запроса по каждому захваченному прогону (xhgui, кастомный коллектор).
PROFILER_EXPORT_FORMAT xhprof Один из четырёх форматов для HTTP-push.
PROFILER_EXPORT_AUTH_TOKEN (не задан) Bearer-токен для цели push-запроса.
PROFILER_EXPORT_XHGUI auto Принудительно включить режим конверта xhgui. Auto: путь URL заканчивается на /run/import (канонический эндпоинт xhgui; подсказки в хосте/query не учитываются — установите 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() тоже: вызвать его дважды подряд безопасно.

Warning

Вызов start() в середине запроса сбрасывает текущее дерево (см. PROFILING_CONTEXT.reset() в php_sdk.rs). Это соответствует инварианту из спецификации: режим устанавливается один раз за запрос — либо триггером на 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, без FFI. Безопасно вызывать в горячем коде.

Атрибуты (PHP 8)

Семь атрибутов делятся на две категории: фильтры-наблюдатели работают до создания спана; декораторы работают после закрытия спана.

Атрибут Категория Эффект
#[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 Испустить событие Slow + установить статус, когда wall-time ≥ ms.
#[MemoryThreshold(kb)] decorator Испустить MemorySpike + статус, когда чистая аллокация ≥ kb.

Композиция на уровне класса и метода

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 fallback

Форматы экспорта

Файлы лежат в PROFILER_OUTPUT_DIR с именами вида <run_id>.<ext>, где run_id = <ts_ms>-<req_id_prefix>-<rand4> (например, 1713600000000-a1b2c3d4-0f5e).

speedscope (по умолчанию для интерактивного анализа)

Расширение: .speedscope.json

  • Флеймграф в браузере с зумом, поиском, переключением CPU / time / memory.
  • Ноль настройки — открывается прямо на speedscope.app.
  • OxPHP возвращает 302-редирект на /__profiler/runs/{id}/speedscope → speedscope.app с параметром profileURL=…, который забирает профиль прямо с вашего сервера.
bash
# Ctrl-click in macOS Terminal / xdg-open on Linux open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"

xhprof (для xhgui: таймлайн и исторический diff)

Расширение: .xhprof.json

  • Совместим с xhgui (поиск по URL, тренды, diff между двумя прогонами).
  • Идеален для накопления в продакшене: запустите контейнер 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 (flamegraph.pl Брендана Грегга)

Расширение: .collapsed

  • Текстовый формат func;child;grandchild <count>.
  • Де-факто вход для SVG-флеймграфов.
  • Три варианта метрики: wall-time, CPU, memory. OxPHP пишет .collapsed (wall); внутренние пути также производят .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 как флеймграфы, сгруппированные по проектам. Push в формате xhprof бьёт напрямую в его эндпоинт POST /api/profiler/store: не нужны ни PHP-расширение xhprof, ни клиентская библиотека, поскольку данные производит нативный профилировщик OxPHP.

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

URL, путь которого заканчивается на /api/profiler/store, автоматически выбирает конверт Buggregator (PROFILER_EXPORT_BUGGREGATOR: "true" форсирует его для кастомного URL; "false" отключает). Этот конверт всегда отдаёт xhprof, поэтому PROFILER_EXPORT_FORMAT для него игнорируется (значение, отличное от xhprof, вызывает предупреждение при запуске, а не фатальную ошибку — профилировщик никогда не роняет сервер из-за настройки экспорта). app_name и tags управляют группировкой и фильтрацией по проектам в 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 (осиротевшие из-за краха) подметаются.
  • Token bucket PROFILER_DISK_MAX_PER_SEC защищает диск: если частота выше, прогоны не записываются и oxphp_profiler_disk_drops_total инкрементируется.

Внутренние HTTP-маршруты

При INTERNAL_ADDR=0.0.0.0:9090 плагин регистрирует 8 эндпоинтов под префиксом /__profiler/. Все требуют Authorization: Bearer <PROFILER_AUTH_TOKEN>, когда токен сконфигурирован. Сравнение выполняется за константное время.

Маршрут Метод Назначение
/__profiler/ GET HTML-страница с индексом эндпоинтов.
/__profiler/runs GET JSON-массив прогонов. ?limit=N&offset=M.
/__profiler/runs/{id} GET JSON-метаданные одного прогона.
/__profiler/runs/{id}.{format} GET Сырые байты профиля. formatxhprof.json, speedscope.json, pprof, collapsed.
/__profiler/runs/{id}/speedscope GET 302 → speedscope.app с profileURL=….
/__profiler/runs/{id} DELETE Удалить файлы всех форматов + запись в индексе (возвращает 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-push и 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 в хосте или query не учитывается — форсируйте через PROFILER_EXPORT_XHGUI=true|false для таких URL.
  • План повторов: 3 попытки с экспоненциальным откатом 100/200/400 ms, общий бюджет 5 с по wall-clock. Тело запроса разделяется между попытками как 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. Для детального diff'а скачайте два профиля 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"} и алерт на выбросы duration_ms из index.json (через метрику на основе логов или sidecar- экспортёр).

Воспроизвести баг по ссылке

Коллега говорит: «/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], SQL/HTTP-хуки APM).
  • Сработал триггер профилировщика → mode=ProfileAll. Дерево содержит всё плюс аннотации APM.
  • APM всё равно отправляет в OTLP только свои явные спаны (Jaeger/Tempo имеют предел ~10k спанов на трассу). Полную картину — /__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 реалистичным — прогон может весить от сотен КБ (малый запрос) до мегабайтов (большое дерево). 500 прогонов × 2 МБ = 1 ГБ. Рассчитывайте размер диска соответственно.
  5. #[Exclude] для шумных хелперов (логирование, i18n, автозагрузчик) — дерево становится читаемым, не теряя смысла.
  6. Связывайте профили с трассами: trace_id общий. В Grafana / Kibana дайте ссылку /__profiler/runs/<id> из вида трассы.
  7. Git-дружественные идентификаторы. В этой сборке span_id — детерминированный big-endian монотонный счётчик. Diff двух сохранённых профилей выходит чистым.
  8. APM + профилировщик — бесплатно. Держите оба включёнными; дерево общее, накладные расходы приходят только от накопленного покрытия APM.

Устранение неполадок

Профили не появляются
  1. Скомпилирован ли плагин? docker compose build включает его по умолчанию. Проверьте, что вы не передали --build-arg OXPHP_WITH_PROFILER=0 или кастомный CARGO_FEATURES без plugin-profiler.
  2. PROFILER_ENABLED=true?
  3. Действительно ли триггер совпадает с PROFILER_AUTH_TOKEN?
    • Проверьте на случайный \n в переменной окружения.
    • Для query — корректно ли выполнено URL-кодирование?
  4. Видит ли сервер ваш запрос вообще? Проверьте access-лог.
401 от /__profiler/runs

Bearer-токен в заголовке не совпадает с PROFILER_AUTH_TOKEN. Частая ловушка: echo "secret" > secret.txt дописывает \n. Используйте printf или передавайте через env.

xhgui не показывает новые прогоны
  1. Проверьте доступность:
    bash
    docker compose exec app curl -v $PROFILER_EXPORT_URL
  2. Посмотрите на oxphp_profiler_http_push_failures_total.
  3. Проверьте логи: на каждую неудачу испускается tracing::warn! с run_id и HTTP-статусом.
Нет файлов на диске
  • Является ли 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() вокруг интересующего участка.
truncated=true в index.json

Запрос превысил 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

Практические примеры

Ниже — готовые к запуску PHP-сценарии, которые можно положить в www/public/ и дёрнуть через curl.

Простой контроллер с ручным управлением

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 { /* ... */ }

Сравнить две реализации (микро-бенчмарк с профилями)

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.

Ссылки