<? phpukraine СПІВБЕСІДИ
Пошук по платформі
DEVOPS · SENIOR

Як налаштувати спостережуваність PHP-застосунку: логи, метрики, трасування?

Три сигнали налаштовують як один набір даних, зшитий спільним trace_id: OpenTelemetry дає trace через PECL-розширення й auto-instrumentation, той самий trace_id підмішується процесором у кожен запис структурованого лога, а метрики через shared-nothing модель PHP збирає не сам процес, а локальний Collector; алерти вішають лише на SLI з бюджетом помилок, решта живе на дашборді.

Логи є, Grafana є, Sentry є, а на питання «де саме витрачені ті 3 секунди» ніхто відповісти не може. Чого бракує?
Що таке traceparent і що з ним стається, коли запит іде далі в чергу?
Чому не можна просто завести Prometheus counter у PHP-коді й скрейпити /metrics із застосунку?
Ви ввімкнули трасування на 100% і рахунок за APM став більшим за рахунок за сервери. Що робити?
observability OpenTelemetry трасування метрики SLO Monolog

Моніторинг і спостережуваність різняться тим, на які питання система дозволяє відповісти. Дашборд закриває передбачені: скільки 5xx, яка p95. Спостережуваність починається там, де питання ніхто не закладав заздалегідь: «чому саме в цих трьох клієнтів чекаут займає 8 секунд». Технічно це означає, що три сигнали мають бути одним набором даних, а не трьома вкладками в різних інтерфейсах. Зшиває їх trace_id: він народжується на вході, живе в кожному спані, підмішується в кожен запис лога і, за наявності exemplar, чіпляється до бакета гістограми, звідки Grafana дає перейти в конкретний трейс.

PHP ускладнює картину однією властивістю: shared-nothing. Процес обробляє запит і вмирає, фонового потоку для експорту немає, накопичувати стан ніде. Звідси три наслідки, які й відрізняють senior-відповідь. Метрики не можна тримати in-process, бо лічильник у пам'яті воркера FPM показує випадкове число, тому агрегує їх зовнішній процес: локальний OpenTelemetry Collector, що приймає delta-метрики через OTLP, або statsd-демон поруч. Спани вивантажуються синхронно на завершенні запиту, тому експортер зобов'язаний бути локальним і швидким, інакше телеметрія опиняється в критичному шляху. Auto-instrumentation через звичайний composer-пакет теж не вийде: перехопити виклик чужого методу без правки коду вміє тільки розширення.

Ідеться про PECL-розширення opentelemetry. Воно дає OpenTelemetry\Instrumentation\hook(), на якому побудовані пакети open-telemetry/opentelemetry-auto-laravel, -auto-pdo, -auto-psr18: після їх встановлення спани для HTTP-запиту, кожного SQL-виклику і кожного вихідного HTTP з'являються самі. Мінімальний набір composer: open-telemetry/sdk, open-telemetry/exporter-otlp і потрібні auto-пакети; конфігурація вся у змінних оточення (OTEL_PHP_AUTOLOAD_ENABLED, OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACES_SAMPLER), без жодного рядка в коді. Пропускати OTEL_SERVICE_NAME не можна: без нього все складається в unknown_service і ділити дані між сервісами неможливо. Назви атрибутів беруть із семантичних конвенцій, і там чекає пастка з версіями: у стабільній HTTP-семантиці http.method став http.request.method, а http.status_code перетворився на http.response.status_code, тому дашборди й правила алертів, написані під старі імена, після оновлення інструментації мовчки перестають знаходити дані.

Наскрізний контекст передається за W3C Trace Context: заголовок traceparent формату 00-<32 hex trace-id>-<16 hex span-id>-<flags>, де останній байт містить рішення семплера. Вхідний запит його приймає, вихідні HTTP-виклики отримують автоматично, а межу черги доводиться долати вручну: процес воркера не знає нічого про запит, який породив джобу. Пропагатор інжектить traceparent у payload, handle() витягує його назад. З логами історія симетрична: структурований JSON нікуди не дівається, але тепер кожен запис несе trace_id і span_id, які додає Monolog-процесор, що читає Span::getCurrent(). Далі діє те саме правило, що й для спанів: стабільна назва події в message, усе змінне в контекст, імена полів збігаються з іменами атрибутів спана. Якщо логи хочеться відправляти тим самим каналом, що й трейси, є open-telemetry/opentelemetry-logger-monolog; для єдиного бекенда це зручно, але не обов'язково, бо логи чудово живуть у Loki, поки в них є trace_id.

Тепер про те, що алертити. Алерт просить людину діяти зараз; усе, що цій умові не відповідає, має бути дашбордом. На пейджер іде швидкість спалювання бюджету помилок по SLI (частка успішних запитів, частка запитів у межах порога латентності) у двох вікнах: агресивне для аварії, повільне для тихої деградації. Туди ж heartbeat, бо застосунок, який перестав слати телеметрію, на графіку виглядає ідеально здоровим, і вік найстарішої джоби в черзі, оскільки це симптом, який відчує клієнт. Насиченість ресурсів, зайняті воркери FPM, hit ratio кешу, розкладка латентності по ендпойнтах, топ повільних SQL залишаються дашбордом для розслідування після спрацювання алерту. Робоча перевірка: якщо до правила не написаний runbook і посилання на відфільтрований дашборд, це не алерт.

Компроміси зводяться до грошей і кардинальності. Метрики дешеві, бо агреговані, тому їх збирають повністю, але одна мітка з user_id перетворює десяток серій на мільйони і кладе Prometheus. Логи дорожчі, тому рівень тримають на info і не тягнуть тіла запитів. Найдорожчі трейси, і тут працює двошаровий семплінг: head-семплінг parentbased_traceidratio на 5-10% дає узгоджене рішення між сервісами, а tail_sampling у Collector добирає всі трейси з помилками і хвіст латентності, бо на вході ще невідомо, який запит виявиться цікавим. Платити за це доводиться пам'яттю колектора і балансуванням спанів за trace ID, щоб один трейс не розповзся по різних інстансах. Остання пастка стосується довгоживучих процесів: в Octane і в чергових воркерах не завершений end() спан або не від'єднаний скоуп перетворюють усі наступні джоби на дітей першої й повільно з'їдають пам'ять, тому finally з detach() і end() там не стилістична дрібниця.

// Авто-інструментація вмикається змінними оточення, коду не потребує:
//   OTEL_PHP_AUTOLOAD_ENABLED=true      OTEL_SERVICE_NAME=checkout-api
//   OTEL_TRACES_EXPORTER=otlp           OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
//   OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318   // локальний Collector
//   OTEL_TRACES_SAMPLER=parentbased_traceidratio        OTEL_TRACES_SAMPLER_ARG=0.1

// 1. Кожен запис лога отримує trace_id - точка переходу «графік → трейс → логи».
final class TraceContextProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $context = Span::getCurrent()->getContext();

        if (! $context->isValid()) {   // поза запитом (artisan, тести) спану немає
            return $record;
        }

        return $record->with(extra: $record->extra + [
            'trace_id' => $context->getTraceId(),
            'span_id' => $context->getSpanId(),
        ]);
    }
}

// 2. Ручний span там, де операція має бізнес-сенс. Атрибути - за semconv.
$tracer = Globals::tracerProvider()->getTracer('checkout');
$span = $tracer->spanBuilder('payment.charge')->startSpan();
$scope = $span->activate();

try {
    $span->setAttribute('payment.provider', $gateway->name())
         ->setAttribute('order.id', $order->id);   // висока кардинальність:
                                                   // у спані доречно, у мітці метрики - ні
    return $gateway->charge($order);               // traceparent підставить auto-psr18
} catch (GatewayTimeout $e) {
    $span->recordException($e)->setStatus(StatusCode::STATUS_ERROR, 'gateway timeout');
    throw $e;
} finally {
    $scope->detach();   // без detach контекст протече в наступну джобу воркера
    $span->end();       // без end() спан не буде експортований взагалі
}
Що спостережуваність вимірюється не кількістю інструментів, а здатністю відповісти на питання, якого ви не передбачили заздалегідь: із графіка провалитись у конкретний трейс, із трейса - у логи саме цього запиту.
Розуміння, що shared-nothing модель PHP ламає звичний спосіб збору метрик: процес живе один запит, тому in-process лічильники нікуди не накопичуються і потрібен зовнішній агрегатор (Collector, statsd) або delta-темпоральність OTLP.
Конкретику по OpenTelemetry PHP: PECL-розширення `opentelemetry` як умова auto-instrumentation, пакети `open-telemetry/sdk` та `opentelemetry-auto-laravel`, налаштування через змінні `OTEL_*`, експорт у локальний Collector, а не напряму в хмару.
Наскрізний контекст за W3C Trace Context: заголовок `traceparent`, який приймається на вході, передається у вихідні HTTP-виклики і вручну кладеться в payload черги, бо джоба виконується в іншому процесі.
Розділення сигналів за кардинальністю: `order_id` і `user_id` доречні як атрибути span і поля лога, у мітках метрики вони кладуть Prometheus.
Що алерт - це запит на дію людини, тому на пейджер іде лише burn rate по SLO плюс heartbeat, а насиченість ресурсів, розподіл латентності по ендпойнтах і кеш-хіти залишаються дашбордом.
Ставити APM-агент і вважати роботу зробленою: без `service.name` усі дані злипаються в `unknown_service`, а без ручних span бізнес-операції не видно взагалі.
Логувати `trace_id` лише в одному сервісі: без наскрізної передачі `traceparent` кожен сервіс генерує власний trace і зшити їх уже нічим.
Тримати in-process лічильник у PHP-FPM і дивуватися, що значення стрибає: кожен воркер має власну копію, яка помирає разом із запитом.
Класти `user_id` у мітку Prometheus і покласти сховище мільйонами часових рядів.
Експортувати спани напряму в хмарний бекенд синхронно: недоступний колектор починає додавати секунди до кожної відповіді користувача.
У воркері Horizon чи в Octane створювати span і не викликати `end()` або не робити `detach()` скоупу: усі наступні джоби стають дітьми першої, а пам'ять воркера повзе вгору.
Алертити на p99 конкретного ендпойнта о третій ночі, коли SLO не порушено й діяти нікому не треба.
ПОРАДА

Сформулюйте так: «три сигнали - це один набір даних, зшитий trace_id; питання лише в тому, який із них можна дозволити собі зберігати повністю». Далі назвіть ціну кожного: метрики дешеві й агреговані, тому 100%; логи дорогі, тому `info` і структуровані; трейси найдорожчі, тому head-семплінг 10% плюс tail-семплінг у Collector, який добирає всі помилкові й повільні. Це відповідь людини, яка платила за це рахунок.

оновлено 7 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

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