Профілювання PHP-коду

OxPHP постачається з вбудованим профілювальником, що працює на рівні окремого запиту. На відміну від xdebug чи окремих розширень, він працює всередині самого сервера, не потребує перезапуску PHP і не додає помітних накладних витрат, коли вимкнений (гілка mode=Off завершується ще до того, як звертається до кешу фільтрів).

Це практичний посібник: від нульової конфігурації до пошуку повільних ендпоінтів у продакшені, читання флеймграфів і порівняння запусків до та після оптимізації.

Швидкий старт: 60 секунд до першого профілю

compose.yml
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:
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-часом, пам’яттю на вході й виході, атрибутами та подіями.
  • Експортує чотири формати одночасно: 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&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_apm_*().
ProfileAll Спрацював тригер профілювальника (або було викликано OxPHP\Profile\start(), коли профілювальник увімкнено). Кожен виклик PHP-функції через Observer API плюс усе, що збирає APM.

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

Встановлення та збирання

Плагін plugin-profiler входить до стандартних cargo features. Звичайний docker compose build уже його містить.

Щоб вимкнути його:

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

Щоб перевірити, що плагін вкомпільовано, перегляньте лог запуску. З LOG_LEVEL=info (типове значення) кожен вкомпільований плагін пише рядок Plugin initialized зі своєю назвою:

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

Для розробки та скриптів.

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

Ідеально для бенчмарків у CI, колекцій Postman, curl-скриптів.

Виключення шляхів із вибірки

PROFILER_SAMPLE_RATE відбирає випадкову частку всіх запитів — зокрема службовий трафік фреймворку, який засмічує дані. Web debug toolbar у 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 — ні, бо виклик, який відкидає обмеження глибини, спершу враховується тут, тож обрізаний запуск тоді потрапляє на диск лише з часткою від цієї кількості спанів.
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-хелпери, воно не поширюється.

Приклад продакшен-конфігурації

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. Коли профілювальник вимкнено (PROFILER_ENABLED), start() теж нічого не робить.

Явне фіксування навколо ділянки коду

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. Безпечно викликати в гарячому коді.

Атрибути (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.

Поєднання на рівні класу та методу

php
use OxPHP\Profile\{Tag, Profile, Exclude}; #[Tag(key: 'layer', value: 'domain')] #[Profile] // the whole class is always profiled class OrderService { #[Tag(key: 'op', value: 'create')] public function create(array $data): Order { /* ... */ } #[Exclude] // excluded, despite class-level #[Profile] public function debug_dump(): void { /* ... */ } public function find(int $id): ?Order { /* ... */ } // inherits #[Profile] and #[Tag(layer)] }
  • Атрибути рівня класу поширюються на кожен метод.
  • Атрибути рівня методу додаються до атрибутів рівня класу (теги накопичуються).
  • #[Exclude] на методі перекриває #[Profile] рівня класу.

Поріг повільної функції

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

Поріг пам’яті

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

Вибірка окремих функцій

php
use OxPHP\Profile\Sample; #[Sample(rate: 0.01)] function log_event(string $evt, array $ctx): void { // ≈ 1% of calls land in the tree; the rest are skipped entirely — // neither the span nor its children are created. Useful for functions // called millions of times per request. }
Фільтри чи декоратори?

Коли функція викликається дуже часто і ви хочете зменшити вартість фіксування, використовуйте #[Sample] або #[Exclude] (вони працюють до створення спану). Коли потрібно позначити подію понад поріг, використовуйте #[SlowThreshold] / #[MemoryThreshold] (вони аналізують уже зібраний спан).

Що містить зафіксований спан

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

Типи подій (SpanEvent::kind):

Тип Чим генерується
Mark mark(), metric(), #[Mark]
Slow #[SlowThreshold]
MemorySpike #[MemoryThreshold]
Sql хуки APM (PDO, mysqli)
Http хуки APM (cURL, HTTP-потоки)
Exception обробник винятків APM
Alloc (зарезервовано для семплювання купи)
Other запасний варіант

Формати експорту

Файли зберігаються в PROFILER_OUTPUT_DIR під іменами <run_id>.<ext>, де run_id = <ts_ms>-<req_id_prefix>-<rand4> (наприклад, 1713600000000-a1b2c3d4-0f5e). Ім’я створюється під час збереження запуску, тож кожен запуск отримує ім’я саме так, хоч би що ввімкнуло профілювання: тригер, випадкова вибірка чи OxPHP\Profile\start() з PHP.

Як пов’язати профіль із рядком access-логу

req_id_prefix — це перші вісім символів ідентифікатора запиту, і сам по собі він не є ключем: в ідентифікаторі, який генерує сервер, ці символи — секунди епохи, спільні для всіх запитів цієї секунди. Натомість використовуйте поле request_id запису в index.json. Воно містить повний ідентифікатор, і саме його друкує access-лог.

speedscope (типовий вибір для інтерактивного аналізу)

Розширення: .speedscope.json

  • Флеймграф у браузері з масштабуванням, пошуком, перемиканням CPU / час / пам’ять.
  • Жодного налаштування — відкривайте просто на 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, і історія накопичуватиметься в інтерфейсі.
  • Готовий 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, пам’ять. 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 як флеймграфи, згруповані за проєктами. Надсилання 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: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 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 | 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-знімок лічильників.

Приклади скриптів

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

HTTP-надсилання та xhgui

Надсилайте кожен запуск до віддаленого колектора:

yaml
environment: PROFILER_EXPORT_URL: "http://xhgui/run/import" PROFILER_EXPORT_FORMAT: "xhprof" PROFILER_EXPORT_AUTH_TOKEN: "shared-secret" # optional
  • Автовизначення обгортки xhgui: шлях URL закінчується на /run/import (канонічний ендпоінт xhgui). Нечіткий підрядок xhgui у хості чи query не враховується — для таких URL задавайте примусово через PROFILER_EXPORT_XHGUI=true|false.
  • План повторів: 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 smoke-тест: tests/php/profiler/test_xhgui_import.php.

Метрики Prometheus

Доступні на /metrics:

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

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) | sort | .[length/2|floor]' > /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. Щойно його досягнуто, нові спани не записуються, сам запит не переривається, а префікс, зібраний до цього моменту, записується з truncated: true. Для дуже великих запитів краще точково обгорнути 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'); // 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 іноді повільний, але не завжди».

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.

Промпти для вашого агента

Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.

prompt
Профілюй мій повільний ендпоінт за допомогою профілювальника OxPHP. Прочитай сторінку `features/profiling` через MCP-сервер документації OxPHP. Увімкни профілювальник у конфігурації Compose або Kubernetes цього репозиторію, вибери тригер, який пасує до того, як я звертаюся до цього ендпоінта, і дай мені команди, що фіксують запуск і відкривають його.
prompt
Прочитай мій зафіксований профіль OxPHP і скажи, що в ньому повільне. Прочитай сторінку `features/profiling` через MCP-сервер документації OxPHP. Отримай запуск через внутрішні маршрути профілювальника, зістав його найширші фрейми з кодом у цьому репозиторії і скажи, що варто змінити насамперед.

Посилання