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

Як організувати логи й моніторинг PHP-застосунку в продакшені?

Логи пишемо структурованим JSON у stdout одним рядком на подію й віддаємо збирачу, помилки дублюємо в Sentry, де вони групуються за fingerprint і мають реліз та контекст, а сигналом «щось не так» служать не логи, а метрики RED (rate, errors, duration) з алертами на симптоми, які бачить користувач.

Користувач каже «сайт лежав п'ять хвилин учора ввечері» — як ви це перевірите заднім числом?
Куди має писати логи PHP-застосунок у Docker і чому не у storage/logs?
Навіщо Sentry, якщо всі винятки й так є в лог-файлі?
Як ви дізнаєтесь про аварію раніше, ніж про неї напише клієнт?
логи моніторинг observability Monolog Sentry Prometheus

Почніть з того, що застосунок у контейнері не володіє своїми логами. Дванадцятифакторний підхід трактує лог як потік подій: процес пише в stdout/stderr і більше ні за що не відповідає — ані за ротацію, ані за доставку, ані за retention. У Docker цей потік підбирає логовий драйвер, у Kubernetes — збирач на ноді (Fluent Bit, Vector, Promtail), який складає записи в Loki, OpenSearch чи хмарне сховище. Файл storage/logs/laravel.log у поді помирає разом із подом, а канал daily у трьох репліках дає три розірвані стрічки, які ніхто не звірить. Технічно в Laravel це канал з driver => monolog, StreamHandler на php://stdout і JsonFormatter; найпростіший варіант того ж — вбудований канал stderr із LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter. Окремо не забудьте про сам PHP-FPM: без catch_workers_output = yesdecorate_workers_output = no, доступного з PHP 7.3) фатальні помилки воркера просто зникнуть, не дійшовши до stdout контейнера.

Структурованість важливіша за сам факт логування. Текстовий рядок «User 42 paid 1500 UAH» читається людиною і не читається машиною: за ним не побудувати ані фільтр, ані графік. PSR-3 навмисно розділяє message і масив context, і саме другий аргумент Log::info('checkout.paid', ['order_id' => …]) перетворюється на індексовані поля JSON. Практичне правило: повідомлення — це стабільний ідентифікатор події (checkout.paid, payment.gateway_timeout), усе змінне — у контекст. Тоді запит «усі таймаути платіжного шлюзу за останню годину, згруповані за провайдером» — це один рядок у мові запитів сховища, а не регулярний вираз по мегабайтах тексту.

Другий обов'язковий елемент — наскрізний ідентифікатор. Один HTTP-запит породжує записи у nginx, у застосунку, у кількох чергових джобах і, можливо, у сусідньому сервісі; без спільного ключа склеїти їх неможливо. Nginx уміє генерувати $request_id, застосунок приймає його заголовком X-Request-Id, кладе у Context (з Laravel 11 дані Context автоматично потрапляють у кожен запис лога і серіалізуються разом із джобою в чергу — на відміну від Log::withContext(), який межу черги не переживає), ставить тегом у Sentry і повертає у відповіді. Далі за одним рядком з тікета користувача піднімається вся історія запиту.

Sentry вирішує іншу задачу, ніж сховище логів, і тому не замінюється ним. Він рахує fingerprint події за типом винятку і стеком, тож мільйон однакових падінь — це одна проблема з лічильниками подій і унікальних користувачів. До кожної події прикріплені release і environment (звідси відповідь на головне питання чергового: «це з'явилося в останньому деплої?»), breadcrumbs із запитами і SQL перед падінням, значення змінних у кадрах стеку, призначення відповідальному і статус «regressed», якщо закрита помилка повернулася. У Laravel 11/12 інтеграція sentry/sentry-laravel підключається у bootstrap/app.php через Integration::handles($exceptions) у withExceptions(), а traces_sample_rate для трасування ставлять часткою на кшталт 0.1, бо повне трасування продакшену коштує грошей. Із тих самих міркувань send_default_pii лишають вимкненим, а в before_send вирізають ключі password, token, authorization.

І головне розмежування, яке очікує почути інтерв'юер: логи й Sentry — інструменти розслідування, а не сповіщення. Дізнаватися про аварію треба з метрик. Мінімум — модель RED на HTTP-шарі: частота запитів, частка 5xx і гістограма латентності (дивіться p95/p99, середнє ховає хвіст), плюс специфіка PHP — зайняті воркери FPM зі сторінки pm.status_path, яку знімає php-fpm_exporter, глибина черги і вік найстарішої джоби, кількість failed_jobs. Збирає це Prometheus, алертить Alertmanager. Алерти вішають на симптоми, які відчуває користувач, і бажано через бюджет помилок SLO з двома порогами burn rate: швидкий (умовно 14x за 5 хвилин) будить людину, повільний (3x за 6 годин) створює тікет. Алерт «на кожен ERROR у логах» гарантовано вимкнуть через тиждень; алерт «CPU > 80%» будить без причини, бо висока утилізація сама по собі не є проблемою.

Межі цієї схеми — вартість і сліпі зони. Логи на рівні debug у продакшені коштують дорожче за сервери й тягнуть персональні дані у сховище з довгим retention, тому рівень тримають на info, а деталізацію вмикають точково і тимчасово. Метрики ламаються на високій кардинальності: мітка user_id у Prometheus створює мільйони серій і кладе сховище — ідентифікаторам місце в логах і трейсах, не в мітках. Трейси (open-telemetry/opentelemetry-php разом із розширенням opentelemetry) закривають третій кут спостережуваності — де саме всередині запиту витрачений час, — але їх завжди семплюють. Нарешті, найпідступніший випадок — тиша: застосунок, який перестав слати метрики, на графіку виглядає так само, як ідеально здоровий, тому в схемі обов'язково має бути dead man's switch на потік метрик і на cron.

// config/logging.php — усе в stdout структурованим JSON, помилки додатково в Sentry
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['stdout', 'sentry'],
        'ignore_exceptions' => false,
    ],

    // Один рядок = один JSON-обʼєкт. Файлів немає: збирач читає stdout контейнера.
    'stdout' => [
        'driver' => 'monolog',
        'level' => env('LOG_LEVEL', 'info'),          // на проді info, не debug
        'handler' => Monolog\Handler\StreamHandler::class,
        'handler_with' => ['stream' => 'php://stdout'],
        'formatter' => Monolog\Formatter\JsonFormatter::class,
        'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
    ],

    'sentry' => [
        'driver' => 'sentry',
        'level' => 'error',                            // у Sentry — лише те, що вимагає дії
        'bubble' => true,
    ],
],

// app/Http/Middleware/TrackRequestContext.php — наскрізний ідентифікатор
public function handle(Request $request, Closure $next): Response
{
    $requestId = $request->header('X-Request-Id') ?: (string) Str::uuid();

    // Context (Laravel 11+) сам додається в кожен запис лога і їде разом із джобою в чергу
    Context::add('request_id', $requestId);
    Context::add('user_id', $request->user()?->id);
    \Sentry\configureScope(fn ($scope) => $scope->setTag('request_id', $requestId));

    // Дані — окремим масивом-контекстом, а не всередині тексту повідомлення
    Log::info('checkout.paid', ['order_id' => $order->id, 'amount_uah' => $order->total]);

    return tap($next($request), fn ($response) => $response->headers->set('X-Request-Id', $requestId));
}
Що в контейнері застосунок не володіє файлами логів: пише в stdout/stderr одним рядком JSON, а ротацією, доставкою і зберіганням займається платформа (Docker driver, Fluent Bit, Vector, Promtail).
Різницю між текстовим і структурованим записом: у JSON-лога поля `level`, `message`, `context.request_id`, `user_id` індексуються і шукаються, у `sprintf`-рядку — ні.
Наскрізний `request_id`, який іде з заголовка `X-Request-Id` у кожен запис лога і в чергові джоби, — без нього логи розподіленої системи не склеюються.
Що Sentry — це не «ще один лог», а агрегатор: дедуплікація за fingerprint, кількість подій і користувачів, реліз, у якому проблема з'явилася, breadcrumbs і стек зі значеннями змінних.
Що алерти вішають на метрики й SLO (частка 5xx, p95 латентності, глибина черги, вік найстарішої джоби), а логи читають уже після спрацювання алерту, щоб зрозуміти причину.
Обізнаність про вартість і PII: рівні логування, семплінг трейсів, `send_default_pii=false` і скрабінг токенів перед відправкою.
Писати в `storage/logs/laravel.log` всередині контейнера: після рестарту подів логи зникають, а `daily` канал у кількох репліках дає розірвану картину.
Ставити `LOG_LEVEL=debug` на продакшені й отримати рахунок за зберігання більший, ніж за самі сервери, плюс SQL-запити з персональними даними в логах.
Форматувати дані в текст повідомлення (`Log::info("User {$id} paid {$sum}")`) замість другого аргументу-контексту — потім по такому логу неможливо агрегувати.
Вважати Sentry заміною моніторингу: він ловить винятки, але не побачить, що сервіс просто перестав відповідати, або що черга росте без жодної помилки.
Алертити на кожен `ERROR` у логах — за тиждень команда вимикає сповіщення й аварію знову помічає клієнт.
Заводити алерт на «CPU > 80%» замість симптому: висока утилізація сама по собі не є проблемою, а падіння p95 і зростання 5xx — є.
Забути про stderr самого PHP-FPM: без `catch_workers_output = yes` фатальні помилки воркера не потрапляють у stdout контейнера взагалі.
ПОРАДА

Скажіть так: «лог — це те, що читають, коли вже відомо, що зламалось; дізнаємось ми про поломку з метрик». Далі назвіть три рівні — структурований JSON у stdout для розслідування, Sentry для винятків із дедуплікацією і релізом, метрики RED з алертами на SLO для сповіщення. Це показує, що ви розрізняли ці інструменти на чергуванні, а не просто перелічили модні назви.

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

Контейнер ефемерний, тому логи віддають потоком у stdout і не володіють файлами; Sentry бачить лише винятки й не помітить «тиху» деградацію на кшталт зростання черги; алерт на кожен ERROR за тиждень призводить до того, що сповіщення ігнорують.