Профилирование 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 — без патчинга байткода и без изменений в коде приложения.
- Строит дерево спанов с 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<SpanTree>,<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 уже включает его.
Чтобы отключить его:
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/checkoutИдеально для CI-бенчмарков, коллекций Postman, curl-скриптов.
Для отладки в браузере. Установите cookie в браузере через DevTools или расширение:
OXPROF=dev-secret; Domain=app.local; Path=/Пока cookie живёт, профилируется каждый запрос. Удалите его, чтобы остановить. Удобно для прохода по пользовательскому сценарию (открыть товар → добавить в корзину → оформить заказ) и сбора серии профилей.
Для обмена ссылками.
https://app.local/admin/report?__oxprof=dev-secretСамый прямолинейный способ, но удобный, когда нужно отправить коллеге ссылку в духе
«открой вот это, здесь воспроизводится баг». Осторожно: параметр попадает в
access-логи и в Referer, так что не используйте его с продакшен-токеном.
Для продакшена.
PROFILER_SAMPLE_RATE=0.001 # ≈ 1 in 1000 requestsРаботает без токена. Включите в продакшене, чтобы накапливать статистику на
реальном трафике. Хороший стартовый диапазон — 0.0005..0.002; более высокие
значения дают заметные накладные расходы, особенно при PROFILER_INTERNAL=true.
Исключение путей из выборки
PROFILER_SAMPLE_RATE выбирает случайную долю всех запросов — включая
самотрафик фреймворка, который засоряет данные. Веб-панель отладки Symfony опрашивает
/_wdt/{token} и ссылается на /_profiler/{token}; Laravel Debugbar и Telescope
ведут себя похоже. Держите их вне выборки с помощью PROFILER_EXCLUDE_PATHS:
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), пустой ключ или дублирующийся ключ — ошибка при запуске. |
Пример продакшен-конфигурации
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() тоже: вызвать его дважды подряд безопасно.
Вызов start() в середине запроса сбрасывает текущее дерево (см.
PROFILING_CONTEXT.reset() в php_sdk.rs). Это соответствует инварианту из
спецификации: режим устанавливается один раз за запрос — либо триггером на 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, без 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. |
Композиция на уровне класса и метода
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 |
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=…, который забирает профиль прямо с вашего сервера.
# 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)
# 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 (flamegraph.pl Брендана Грегга)
Расширение: .collapsed
- Текстовый формат
func;child;grandchild <count>. - Де-факто вход для SVG-флеймграфов.
- Три варианта метрики: wall-time, CPU, memory. OxPHP пишет
.collapsed(wall); внутренние пути также производят.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 как флеймграфы, сгруппированные
по проектам. Push в формате xhprof бьёт напрямую в его эндпоинт
POST /api/profiler/store: не нужны ни PHP-расширение xhprof, ни клиентская
библиотека, поскольку данные производит нативный профилировщик OxPHP.
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 UIURL, путь которого заканчивается на /api/profiler/store, автоматически выбирает
конверт 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.jsonСхема записи в index.json
{
"run_id": "1713600000000-a1b2c3d4-0f5e",
"request_id": "a1b2c3d4e5f67890",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"timestamp_ms": 1713600000000,
"duration_ms": 123,
"method": "GET",
"url": "/checkout",
"status": 200,
"user_agent": "Mozilla/5.0 …",
"client_ip": "10.0.0.42",
"source": "Header", // Header | Cookie | Query | SampleRate
"span_count": 4821,
"event_count": 7,
"error_count": 0,
"leaked_count": 0,
"truncated": false, // true — exceeded PROFILER_MAX_SPANS
"oxphp_version": "0.10.0",
"formats": ["xhprof.json", "speedscope.json"]
}index.json разбирается маршрутом /__profiler/runs, сортируется от новых к старым
и разбивается на страницы через ?limit=N&offset=M.
Удержание
- Фоновая задача каждые 5 секунд удаляет записи сверх
PROFILER_RETENTION_COUNT(атомарныйrename→index.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 | Сырые байты профиля. format ∈ xhprof.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-снимок счётчиков. |
Примеры скриптов
# 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
Отправляйте каждый прогон в удалённый коллектор:
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.
Полный демо-стек
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_runsСтартовые алерты Prometheus:
- 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 - Внесите изменения, пересоберите, повторите. Сравните медианы.
- Для детального diff'а скачайте два профиля 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"} и алерт на
выбросы duration_ms из index.json (через метрику на основе логов или sidecar-
экспортёр).
Воспроизвести баг по ссылке
Коллега говорит: «/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], SQL/HTTP-хуки APM). - Сработал триггер профилировщика →
mode=ProfileAll. Дерево содержит всё плюс аннотации APM. - APM всё равно отправляет в OTLP только свои явные спаны (Jaeger/Tempo имеют
предел ~10k спанов на трассу). Полную картину —
/__profiler/runs/<id>.
Лучшие практики
- Никогда не коммитьте
PROFILER_AUTH_TOKEN. Читайте из Vault / Docker secrets / Kubernetes secrets. - В продакшене — только
SAMPLE_RATE. Header/cookie/query — инструменты разработчика. Если нужно профилирование в продакшене по требованию — используйте выделенный, ежедневно ротируемый токен. - Не включайте
PROFILER_INTERNAL=trueглобально. 2–5× накладных расходов превращают продакшен в лабораторию. Применяйте точечно и изолированно. - Держите
PROFILER_RETENTION_COUNTреалистичным — прогон может весить от сотен КБ (малый запрос) до мегабайтов (большое дерево). 500 прогонов × 2 МБ = 1 ГБ. Рассчитывайте размер диска соответственно. #[Exclude]для шумных хелперов (логирование, i18n, автозагрузчик) — дерево становится читаемым, не теряя смысла.- Связывайте профили с трассами:
trace_idобщий. В Grafana / Kibana дайте ссылку/__profiler/runs/<id>из вида трассы. - Git-дружественные идентификаторы. В этой сборке
span_id— детерминированный big-endian монотонный счётчик. Diff двух сохранённых профилей выходит чистым. - APM + профилировщик — бесплатно. Держите оба включёнными; дерево общее, накладные расходы приходят только от накопленного покрытия APM.
Устранение неполадок
Профили не появляются
- Скомпилирован ли плагин?
docker compose buildвключает его по умолчанию. Проверьте, что вы не передали--build-arg OXPHP_WITH_PROFILER=0или кастомныйCARGO_FEATURESбезplugin-profiler. PROFILER_ENABLED=true?- Действительно ли триггер совпадает с
PROFILER_AUTH_TOKEN?- Проверьте на случайный
\nв переменной окружения. - Для query — корректно ли выполнено URL-кодирование?
- Проверьте на случайный
- Видит ли сервер ваш запрос вообще? Проверьте access-лог.
401 от /__profiler/runs
Bearer-токен в заголовке не совпадает с PROFILER_AUTH_TOKEN. Частая ловушка:
echo "secret" > secret.txt дописывает \n. Используйте printf или передавайте
через env.
xhgui не показывает новые прогоны
- Проверьте доступность:
docker compose exec app curl -v $PROFILER_EXPORT_URL - Посмотрите на
oxphp_profiler_http_push_failures_total. - Проверьте логи: на каждую неудачу испускается
tracing::warn!сrun_idи HTTP-статусом.
Нет файлов на диске
- Является ли
PROFILER_OUTPUT_DIRабсолютным? Относительные пути игнорируются. - Доступен ли на запись пользователю
www-data?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). Варианты:
- Поднять предел (обменяв память на детализацию).
- Добавить
#[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Практические примеры
Ниже — готовые к запуску PHP-сценарии, которые можно положить в www/public/ и
дёрнуть через curl.
Простой контроллер с ручным управлением
<?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 { /* ... */ }Сравнить две реализации (микро-бенчмарк с профилями)
<?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