Логирование в проде
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://stdout | docker logs / Grafana Loki / Datadog |
| Bare-metal с systemd | php://stdout | journalctl -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.
Типичные ошибки
echo "log"илиerror_log()в боевом коде. Используй PSR-3 интерфейс - будет легко поменять реализацию.- Логировать
getMessage()безtrace. Без стектрейса найти, откуда упало, в большой кодовой базе невозможно. - Логировать одну и ту же ошибку 100 раз в секунду. Используй deduplication или sampling. Sentry сам дедуплит, но для file/stdout это твоя проблема.
- Чувствительные данные в логах. Фильтруй процессорами на этапе добавления, а не «потом почистим».
- Уровень
errorдля бизнес-валидации. Пользователь ввёл невалидный email - это не error, это info. Error - это сломанный код или инфраструктура. - Логи только в 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