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