Профілювання PHP-коду
OxPHP постачається з вбудованим профілювальником, що працює на рівні окремого запиту.
На відміну від xdebug чи окремих розширень, він працює всередині самого сервера,
не потребує перезапуску PHP і не додає помітних накладних витрат, коли вимкнений
(гілка mode=Off завершується ще до того, як звертається до кешу фільтрів).
Це практичний посібник: від нульової конфігурації до пошуку повільних ендпоінтів у продакшені, читання флеймграфів і порівняння запусків до та після оптимізації.
Швидкий старт: 60 секунд до першого профілю
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
environment:
INTERNAL_ADDR: 0.0.0.0:9090
PROFILER_ENABLED: "true"
PROFILER_AUTH_TOKEN: "dev-secret"
PROFILER_OUTPUT_FORMATS: "xhprof,speedscope,collapsed"
volumes:
- ./www:/var/www/html
- profiles:/tmp/oxphp-profiles
ports:
- "80:80"
- "9090:9090"
volumes:
profiles:# 1. Request a page with the profiling trigger.
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/slow-endpoint
# 2. List captured runs.
curl -H "Authorization: Bearer dev-secret" http://localhost:9090/__profiler/runs \
| jq '.runs[0]'
# 3. Open the profile in speedscope (in-browser flamegraph).
open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"От і все. Решта посібника пояснює, що відбувається насправді і як перетворити це на корисні висновки для продакшену.
Що робить профілювальник
- Фіксує кожен виклик PHP-функції через Zend Observer API — без патчування байткоду й без змін у коді застосунку.
- Будує дерево спанів із wall-time, CPU-часом, пам’яттю на вході й виході, атрибутами та подіями.
- Експортує чотири формати одночасно:
xhprof.json,speedscope.json,pprof(protobuf + gzip),collapsed(дляflamegraph.pl). - Зберігає запуски: LRU-кеш у пам’яті + файли на диску + необов’язкове HTTP-надсилання (xhgui або будь-який власний колектор).
- Надає 8 внутрішніх HTTP-маршрутів на
INTERNAL_ADDRдля перегляду й завантаження профілів. - Віддає метрики Prometheus — запуски за джерелом, зібрані спани, записані байти, відкинуті запуски, збої надсилання.
- Не потребує перезапуску — вмикається для окремого запиту тригером.
Як це працює
graph TD R["Request"] R --> S1["Tokio thread: ProfilerRequestHandler inspects the trigger<br/>(header / cookie / query / sample_rate), constant-time compare"] S1 --> S2["Decision written into PluginRequestActions,<br/>forwarded to the worker via the SAPI channel"] S2 --> S3["Before RINIT the worker sets ProfilingMode = ProfileAll<br/>and registers Observer handlers on begin/end of every function"] S3 --> S4["Each PHP function call → C hook → bridge buffer → Rust SpanTree<br/>(span_id = monotonic BE counter; names interned in a<br/>thread-local interner, no extra allocations)"] S4 --> S5["After the response: ProfilerCompleteHandler receives Arc<SpanTree>,<br/>runs 4 exporters, puts it in the LRU cache, spawns disk-write<br/>and HTTP-push tasks (semaphores bound the fan-out)"]
Три режими на рівні запиту
| Режим | Коли | Що фіксується |
|---|---|---|
Off |
За замовчуванням. Жоден плагін не запитав профілювання. | Нічого. Нульові накладні витрати. |
ApmOnly |
plugin-apm увімкнено, але жоден тригер профілювальника не спрацював. |
Лише явні хуки APM: #[Trace], емітери PDO/cURL, oxphp_apm_*(). |
ProfileAll |
Спрацював тригер профілювальника (або було викликано OxPHP\Profile\start(), коли профілювальник увімкнено). |
Кожен виклик PHP-функції через Observer API плюс усе, що збирає APM. |
ProfileAll має пріоритет над ApmOnly: коли обидва плагіни увімкнено і тригер
спрацьовує, використовується одне спільне Arc<SpanTree> — без подвійного збирання.
Встановлення та збирання
Плагін plugin-profiler входить до стандартних cargo features. Звичайний
docker compose build уже його містить.
Щоб вимкнути його:
ARG OXPHP_WITH_PROFILER=0
# or
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profilerЩоб перевірити, що плагін вкомпільовано, перегляньте лог запуску. З
LOG_LEVEL=info (типове значення) кожен вкомпільований плагін пише рядок
Plugin initialized зі своєю назвою:
docker compose logs app | grep '"Plugin initialized"' | grep '"name":"profiler"'
# expect one JSON line with "message":"Plugin initialized" and "name":"profiler"Тригери активації
Тригери перевіряються в такому порядку пріоритету: 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 живе, профілюється кожен запит. Видаліть її, щоб зупинити. Корисно, щоб пройти користувацький сценарій (відкрити товар → додати в кошик → оформити замовлення) і зібрати серію профілів.
Cookie не вилучається із запиту: токен залишається видимим для коду, який
профілюється, і для всього, що логує або пересилає cookie. Він з’являється в
$_COOKIE і $_SERVER['HTTP_COOKIE'], як і будь-яка інша 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 відбирає випадкову частку всіх запитів — зокрема
службовий трафік фреймворку, який засмічує дані. Web debug toolbar у 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 — ні, бо виклик, який відкидає обмеження глибини, спершу враховується тут, тож обрізаний запуск тоді потрапляє на диск лише з часткою від цієї кількості спанів. |
PROFILER_MAX_DEPTH |
0 (без обмеження) |
Максимальна глибина вкладеності записуваних викликів; фактичний максимум — 32. Див. Обмеження глибини нижче. |
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-надсилання. |
PROFILER_EXPORT_AUTH_TOKEN |
(не задано) | Bearer-токен для цілі надсилання. |
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 |
(не задано) | app_name для Buggregator — проєкт, у якому групується профіль. |
PROFILER_EXPORT_TAGS |
(не задано) | tags для Buggregator, список key=value,key2=value2 для фільтрації. Некоректний токен (не key=value), порожній ключ або дубльований ключ — помилка під час запуску. |
Обмеження глибини
Виклик, вкладений глибше за PROFILER_MAX_DEPTH, не записується, і зазвичай не записується нічого з того, що він викликає: це піддерево відсутнє в профілі, а не сплющене в нього. Його час при цьому враховується: виклик на межі охоплює все, що він робив, і оскільки під ним нічого не записано, ця тривалість читається як його власний (exclusive) час. Тож фрейм на межі виглядає гарячим сам по собі, а не через те, що він викликав.
- Фактичний максимум —
32, глибина, до якої спостерігач рушія відстежує відкриті виклики. Більше значення приймається й обрізається, а зPROFILER_ENABLED=trueпро це обрізання повідомляється під час запуску. - Глибока саморекурсія — виняток. Після 32 фреймів функції, що рекурсивно викликає саму себе, спостерігач втрачає відомості про те, якому виклику належить повернення, і приписує його мілкішому фрейму тієї самої функції. Тоді виклик, глибший за межу, все ж може бути записаний, а тривалостям навколо нього довіряти не варто.
- Воно обмежує профіль, а не вартість його створення. Хук рушія все одно виконується на глибших фреймах, а відкинуті ним виклики все одно враховуються в
PROFILER_MAX_SPANS. - На спани, які застосунок передає через APM-хелпери, воно не поширюється.
Приклад продакшен-конфігурації
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. Коли профілювальник вимкнено (PROFILER_ENABLED),
start() теж нічого не робить.
Явне фіксування навколо ділянки коду
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. Безпечно викликати в гарячому коді.
Атрибути (PHP 8)
Сім атрибутів поділяються на дві категорії: фільтри спостерігача спрацьовують до створення спану; декоратори — після закриття спану.
| Атрибут | Категорія | Дія |
|---|---|---|
#[Profile] |
фільтр | Примусово включає функцію в дерево (навіть якщо загальні правила її виключили б). |
#[Exclude] |
фільтр | Пропускає функцію; її дочірні виклики переприв’язуються до найближчого включеного предка. |
#[Sample(rate: 0.1)] |
фільтр | Залишає лише частку викликів (rate ∈ [0.0; 1.0]). Імовірнісно, без блокувань. |
#[Tag(key, value)] |
фільтр | Додає мітку до спану. Повторюваний — кілька #[Tag] накопичуються. |
#[Mark(label?)] |
декоратор | Генерує подію Mark на вході у функцію. |
#[SlowThreshold(ms)] |
декоратор | Генерує подію Slow + встановлює статус, коли wall-time ≥ ms. |
#[MemoryThreshold(kb)] |
декоратор | Генерує 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 |
запасний варіант |
Формати експорту
Файли зберігаються в PROFILER_OUTPUT_DIR під іменами <run_id>.<ext>, де
run_id = <ts_ms>-<req_id_prefix>-<rand4> (наприклад,
1713600000000-a1b2c3d4-0f5e). Ім’я створюється під час збереження запуску, тож
кожен запуск отримує ім’я саме так, хоч би що ввімкнуло профілювання: тригер,
випадкова вибірка чи OxPHP\Profile\start() з PHP.
req_id_prefix — це перші вісім символів ідентифікатора запиту, і сам по собі він не є
ключем: в ідентифікаторі, який генерує сервер, ці символи — секунди епохи,
спільні для всіх запитів цієї секунди. Натомість використовуйте поле request_id
запису в index.json. Воно містить повний ідентифікатор, і саме його друкує
access-лог.
speedscope (типовий вибір для інтерактивного аналізу)
Розширення: .speedscope.json
- Флеймграф у браузері з масштабуванням, пошуком, перемиканням CPU / час / пам’ять.
- Жодного налаштування — відкривайте просто на 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, і історія накопичуватиметься в інтерфейсі. - Готовий 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, пам’ять. 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 як флеймграфи, згруповані за проєктами.
Надсилання 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:0.13.0
environment:
PROFILER_ENABLED: "true"
PROFILER_SAMPLE_RATE: "0.01"
PROFILER_EXPORT_URL: "http://buggregator:8000/api/profiler/store"
PROFILER_EXPORT_FORMAT: "xhprof"
PROFILER_EXPORT_APP_NAME: "checkout" # groups profiles by project
PROFILER_EXPORT_TAGS: "env=staging,region=eu" # filterable in the 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 | Sdk
"span_count": 4821,
"event_count": 7,
"error_count": 0,
"leaked_count": 0,
"truncated": false, // true — exceeded PROFILER_MAX_SPANS
"oxphp_version": "0.13.0",
"formats": ["xhprof.json", "speedscope.json"]
}source вказує, що допустило запуск. Header, Cookie і Query — це
три тригери активації, а SampleRate — випадкова
вибірка; Sdk охоплює кожен запуск, який не допустив жоден тригер: запит, що
сам увімкнув собі профілювання через OxPHP\Profile\start(), а також
плагін, що самостійно вибрав повне профілювання.
user_agent — це заголовок User-Agent запиту, обрізаний до 512 байтів (разом
із позначкою …(truncated)), якщо він довший; байти, що не є коректним UTF-8,
замінюються на U+FFFD. Значення дорівнює null, якщо запит його не надіслав.
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-надсилання та 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 не враховується — для таких URL задавайте примусово черезPROFILER_EXPORT_XHGUI=true|false. - План повторів: 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 smoke-тест: tests/php/profiler/test_xhgui_import.php.
Метрики Prometheus
Доступні на /metrics:
oxphp_profiler_runs_total{source="header"|"cookie"|"query"|"sample"|"sdk"}
oxphp_profiler_spans_collected_total
oxphp_profiler_bytes_written_total{format="xhprof"|"speedscope"|"pprof"|"collapsed"}
oxphp_profiler_disk_drops_total
oxphp_profiler_http_push_failures_total
oxphp_profiler_truncated_total
oxphp_profiler_in_memory_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) | sort | .[length/2|floor]' > /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. Щойно його досягнуто, нові спани не записуються, сам запит не переривається, а префікс, зібраний до цього моменту, записується зtruncated: true. Для дуже великих запитів краще точково обгорнути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');
// Turn profiling on from PHP — no trigger needed to test the SDK, but the
// profiler must be enabled (PROFILER_ENABLED=true), or start() does nothing.
$t->assertFalse('initially not active', is_active());
start();
$t->assertTrue('active after start', is_active());
mark('test.midpoint');
// some work
$sum = 0;
for ($i = 0; $i < 100_000; $i++) { $sum += $i; }
stop();
$t->assertFalse('inactive after stop', is_active());
$t->assertSame('computation OK', $sum, 4999950000);
$t->done();Пошук гарячої точки за серією запитів 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.
Промпти для вашого агента
Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.
Профілюй мій повільний ендпоінт за допомогою профілювальника OxPHP. Прочитай сторінку `features/profiling` через MCP-сервер документації OxPHP.
Увімкни профілювальник у конфігурації Compose або Kubernetes цього репозиторію, вибери тригер, який пасує до того, як я звертаюся до цього ендпоінта, і дай мені команди, що фіксують запуск і відкривають його.Прочитай мій зафіксований профіль OxPHP і скажи, що в ньому повільне. Прочитай сторінку `features/profiling` через MCP-сервер документації OxPHP.
Отримай запуск через внутрішні маршрути профілювальника, зістав його найширші фрейми з кодом у цьому репозиторії і скажи, що варто змінити насамперед.Посилання
- Специфікація в дереві коду:
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