Логирование в проде

echo "error" в /tmp/debug.log - это не логирование. Прод-логи должны быть структурированными, уровнированными, идти в правильное место (stdout в контейнере, syslog/файл на VM), и переживать рестарты. Стандарт интерфейса - PSR-3, де-факто библиотека - Monolog.

PSR-3: интерфейс LoggerInterface

Уже видели в уроке 17 про PSR. Уровни (от менее к более срочному):

УровеньКогда писать
debugДиагностика только в dev/staging: значения переменных, шаги пайплайна
infoБизнес-события: «юзер создан», «заказ оплачен»
noticeНормальное, но значимое: «применена миграция», «кеш прогрет»
warningЧто-то пошло не как ожидалось, но обработали: ретрай, fallback
errorОшибка в одной операции, остальные продолжают работать
criticalСломан компонент: «не дошёл до БД», «очередь не работает»
alertНужно немедленное вмешательство человека
emergencyСистема непригодна

Пиши $log->info('order paid', ['order_id' => 42]), а не $log->info('order #42 paid') - структура важнее текста.

Monolog: основы

composer require monolog/monolog
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Level;

$log = new Logger('app');
$log->pushHandler(new StreamHandler('php://stdout', Level::Info));

$log->info('Старт приложения', ['version' => '1.2.3']);
// {"message":"Старт приложения","context":{"version":"1.2.3"},"level":200,"level_name":"INFO","channel":"app","datetime":"2026-05-12T...","extra":[]}

Monolog состоит из трёх кусочков:

  • Logger - фасад с PSR-3 методами
  • Handlers - куда писать (файл, stdout, Sentry, Slack)
  • Processors - что добавлять в каждое сообщение (IP, request-id, memory)

Несколько handlers с разными уровнями

<?php
use Monolog\Handler\StreamHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Level;

$log = new Logger('app');

// stdout - для контейнера/systemd, JSON формат
$stdout = new StreamHandler('php://stdout', Level::Info);
$stdout->setFormatter(new JsonFormatter());
$log->pushHandler($stdout);

// файл с ротацией - debug-уровень для траблшутинга
$file = new RotatingFileHandler(__DIR__ . '/var/log/app.log', 7, Level::Debug);
$file->setFormatter(new JsonFormatter());
$log->pushHandler($file);

$log->debug('подробно для файла', ['sql' => 'SELECT ...']);
$log->info('наружу - в stdout и в файл', ['user_id' => 1]);
$log->error('всюду');

Каждый handler фильтрует по своему минимальному уровню. RotatingFileHandler хранит файлы за 7 дней (app-2026-05-12.log, app-2026-05-11.log, ...) - старые удаляются автоматом.

JSON-формат для структурных логов

В контейнерах прод-формат всегда JSON: docker/k8s/loki/datadog умеют парсить JSON и фильтровать по полям.

<?php
use Monolog\Formatter\JsonFormatter;

$h = new StreamHandler('php://stdout');
$h->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_NEWLINES, true));
//                                                                      ^ append \n после каждой строки

Что получается:

{"message":"order paid","context":{"order_id":42,"amount":100},"level":200,"level_name":"INFO","channel":"app","datetime":"2026-05-12T10:00:00+03:00"}

В Loki/Kibana ты потом ищешь order_id=42 и видишь всю цепочку этого заказа.

Processors: добавляем контекст автоматически

<?php
use Monolog\Processor\WebProcessor;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\MemoryUsageProcessor;
use Monolog\Processor\UidProcessor;

$log->pushProcessor(new WebProcessor());           // URL, IP, http_method
$log->pushProcessor(new MemoryUsageProcessor());   // peak memory
$log->pushProcessor(new UidProcessor());           // уникальный ID на процесс
$log->pushProcessor(new IntrospectionProcessor()); // файл, строка, класс, метод

Свой процессор - обычная функция:

<?php
$log->pushProcessor(function (array $record) {
    $record['extra']['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid();
    $record['extra']['user_id']    = $_SESSION['user_id'] ?? null;
    return $record;
});

Теперь каждый лог имеет request_id и user_id - для трассировки по запросу даже не задумываясь.

Куда писать в проде

Где работает приложениеКуда писатьЧем смотреть
Контейнер (Docker, k8s)php://stdoutdocker logs / Grafana Loki / Datadog
Bare-metal с systemdphp://stdoutjournalctl -u myapp
Старый серверФайл с ротациейtail/grep, log-shipper

Никогда не пиши прод-логи в /var/log/app/myfile.log внутри контейнера - после рестарта они исчезнут. Stdout/stderr - единственный надёжный канал в контейнерных средах.

Ошибки и исключения

Перехватчик исключений (был в уроке 20 про безопасность):

<?php
set_exception_handler(function (\Throwable $e) use ($log) {
    $log->error('uncaught exception', [
        'exception' => [
            'class' => $e::class,
            'message' => $e->getMessage(),
            'file'    => $e->getFile(),
            'line'    => $e->getLine(),
            'trace'   => $e->getTraceAsString(),
        ],
    ]);
    http_response_code(500);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'internal_error']);
});

set_error_handler(function ($severity, $message, $file, $line) {
    // превращаем PHP-warning'и в исключения, чтобы они тоже логировались
    throw new \ErrorException($message, 0, $severity, $file, $line);
});

Monolog умеет это «из коробки» через ErrorHandler:

<?php
use Monolog\ErrorHandler;
ErrorHandler::register($log);

Подцепит errors, exceptions, fatal errors - отлично сократит boilerplate.

Алертинг: Sentry / Slack

Локального лога мало - нужно знать о критичных событиях сразу.

Sentry (для исключений)

composer require sentry/sentry sentry/sentry-symfony  # symfony-bridge опционально
<?php
\Sentry\init(['dsn' => $_ENV['SENTRY_DSN']]);

set_exception_handler(function (\Throwable $e) use ($log) {
    \Sentry\captureException($e);
    $log->error('uncaught', ['exception' => $e->getMessage()]);
});

Sentry сгруппирует одинаковые ошибки, покажет частоту, релиз, юзера. Алерт в Slack, когда новая ошибка появилась - буквально в 2 клика.

Slack-handler для critical+

<?php
use Monolog\Handler\SlackWebhookHandler;
use Monolog\Level;

$slack = new SlackWebhookHandler(
    webhookUrl: $_ENV['SLACK_WEBHOOK'],
    channel: '#prod-alerts',
    username: 'php-app',
    level: Level::Critical,
);
$log->pushHandler($slack);

$log->critical('БД недоступна', ['attempt' => 3]);
// → улетает в Slack

level: Critical важно - не флуди в Slack обычными ошибками, только то, на что ты реально среагируешь ночью.

Чего НЕ логировать

  • Пароли, токены, ключи в любом виде. Даже частично: password целиком фильтруй ещё до логгера.
  • Содержимое Authorization / Cookie заголовков.
  • Полные тела запросов, если они содержат PII (имена, email, телефоны) - соберите подмножество.
  • Чужие персональные данные без анонимизации. GDPR штрафует за такое серьёзно.

Sanitizer-процессор для критичных полей:

<?php
$log->pushProcessor(function (array $record) {
    foreach (['password', 'token', 'authorization', 'cookie'] as $secret) {
        if (isset($record['context'][$secret])) {
            $record['context'][$secret] = '[REDACTED]';
        }
    }
    return $record;
});

Performance

Логирование стоит ресурсов: сериализация, диск, сеть. Правила:

  • Уровень debug в проде - ВЫКЛ. Сильнее всего бьёт по производительности и raздувает диск.
  • Не логируй в горячих циклах. foreach ($items as $i) { $log->info('item', $i); } на 10K элементах - внезапная катастрофа.
  • BufferHandler для всплесков: буферизует и одним батчем шлёт в БД/Sentry.
  • Async через Monolog WhatFailureGroupHandler: если основной handler упал (сеть к Sentry лежит), приложение продолжит работать.
<?php
use Monolog\Handler\WhatFailureGroupHandler;
use Monolog\Handler\BufferHandler;

$buffered = new BufferHandler($remote, bufferLimit: 100, flushOnOverflow: true);
$log->pushHandler(new WhatFailureGroupHandler([$buffered, $local]));

Как это в Symfony

Symfony из коробки приходит с MonologBundle. Конфиг в config/packages/monolog.yaml:

when@prod:
  monolog:
    handlers:
      main:
        type: stream
        path: php://stdout
        level: info
        formatter: monolog.formatter.json   # JSON в stdout - k8s-friendly
        channels: ['!event']

      sentry:
        type: sentry
        level: error
        dsn: '%env(SENTRY_DSN)%'
        channels: ['!event']

      console:
        type: console
        process_psr_3_messages: false
        channels: ['!event', '!doctrine']

when@dev:
  monolog:
    handlers:
      main:
        type: stream
        path: '%kernel.logs_dir%/%kernel.environment%.log'
        level: debug

В контроллере просто:

<?php
use Psr\Log\LoggerInterface;

public function __construct(
    private readonly LoggerInterface $logger,
) {}

Autowiring подсунет нужный канал. Channels (!event, !doctrine) позволяют отдельные категории логов отправлять в разные места - например, Doctrine SQL только в файл, бизнес-логику - в Sentry.

Типичные ошибки

  1. echo "log" или error_log() в боевом коде. Используй PSR-3 интерфейс - будет легко поменять реализацию.
  2. Логировать getMessage() без trace. Без стектрейса найти, откуда упало, в большой кодовой базе невозможно.
  3. Логировать одну и ту же ошибку 100 раз в секунду. Используй deduplication или sampling. Sentry сам дедуплит, но для file/stdout это твоя проблема.
  4. Чувствительные данные в логах. Фильтруй процессорами на этапе добавления, а не «потом почистим».
  5. Уровень error для бизнес-валидации. Пользователь ввёл невалидный email - это не error, это info. Error - это сломанный код или инфраструктура.
  6. Логи только в stderr. stderr - для самой системы (краши, fatal). Бизнес-события - в stdout.

Best practices

  • Пиши структурированный контекст info('event', ['order_id' => 42]) вместо строковых сообщений - поля ищутся в Loki/Kibana за секунды.
  • На проде используй JSON-формат (JsonFormatter) - docker/k8s/loki парсят поля автоматически без дополнительной настройки.
  • Никогда не логируй пароли, токены, заголовок Authorization, куки - только идентификаторы (user_id, order_id); фильтруй через Processor ещё до записи.
  • Уровень error - для сломанного кода или инфраструктуры, не для бизнес-валидации: пользователь ввёл невалидный email - это info, не error.
  • В контейнерных средах пиши только в php://stdout, а не в файлы - файлы исчезнут после рестарта контейнера.

Мини-задание

  • Установи Monolog и PSR/log. Создай логгер с двумя handler'ами: stdout (JSON, level=info) и файл с ротацией (level=debug)
  • Добавь WebProcessor, MemoryUsageProcessor, UidProcessor. Свой процессор - добавляет request_id из $_SERVER['HTTP_X_REQUEST_ID'] либо генерирует bin2hex(random_bytes(8))
  • Зарегистрируй Monolog\ErrorHandler::register($log). Брось throw new RuntimeException('test') - убедись, что в логе есть стектрейс
  • Sanitizer-процессор - заменяет значения полей password, token на [REDACTED]
  • Опционально: подключи Sentry, отправь тестовое исключение через Sentry\captureException

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.